Astrolabe / ajxpublic /korvus
Repository

Session Persistence, Reopen and Scratch Seeding

reference scope internal/session Verification failed
  • internal/session/persist.go
  • internal/session/reopen.go
  • internal/session/scratch.go
  • BlobStore
  • StreamReopener
  • Seeder
  • DocsSearch
  • harvestCandidates
  • capCandidates
  • contentTypeFor
Relevant source files · 7

A Korvus session does two things that are easy to mistake for one thing. It runs an agent in a throwaway workspace directory, and it has to keep what that run produced — the authored markdown report, plus whatever other files the session left behind. Between the run and the storage of its output sits a small, deliberately dull machinery: a per-session scratch directory, a harvest walk over it, an upload-then-row ordering, and a reopen path that rebuilds the next iteration's workspace from what the store already knows.

This page covers internal/session's three persistence-facing files. For the lifecycle that drives them — Create, SendMessage, Cancel, Stop, and the goroutine bookkeeping — see Session Manager and Lifecycle. For the agent side of a run and the author phase that writes report.md, see Agent Run Loop, Tool Execution and Provider Calls and Agent Prompts, Report Authoring and Finalization.

How it works

The persist phase

The persist phase runs after the agent run returns and before the report row is written. Its contract is stated at the top of the file: blob storage owns the bytes, PostgreSQL owns the keys, so every file is uploaded first and its artifacts row written second the ordering rationale. The inverse order can leave a row pointing at bytes that do not exist, which every reader of that row then fails on; the chosen order can at worst leave bytes nothing references, which no reader ever sees.

The phase is invoked from Manager.land, which is shared by the first run and by a reopen, and which calls it before persisting the report because the report row carries the markdown key the phase derives the ordering comment. If the phase produced a markdown key, land attaches it and the byte count to the report row; otherwise the row lands with an empty markdown_key and the failure is recorded in exit_reason rather than by dropping the report.

persistArtifacts is the entry point persistArtifacts. It refuses early and without error in two cases: no blob store configured, or no workspace on the session. Both produce a harvestResult with Attempted false and a human-readable Reason — the distinction matters because a phase that was skipped has not failed at anything, and marking it as a failure would make every deployment without a blob backend look broken the Attempted rationale. Once it proceeds, it runs on a detached, two-minute-bounded context, because the files a cancelled session produced are exactly the ones worth keeping the timeout.

Containment is enforced by the kernel, not by path checks: the phase opens the scratch directory with os.OpenRoot and walks root.FS(), so a symlink the session planted cannot make the phase read — and then publish — a file from outside the sandbox the os.Root rationale.

For each candidate file the phase reads the bytes, skips empty files (a row and a key on bytes no reader can use), compares the digest against the restored artifact set, and uploads. A file still byte-identical to what an earlier revision stored is not stored again; its earlier row and blob already hold it, so the count lands in Unchanged and the authored report keeps pointing at the earlier revision's key the unchanged-file rule.

storeFile does the upload and the row write, and it is one body rather than two because the authored report and every other file differ only in key layout and artifact kind storeFile. The report gets blob.ReportKey and ArtifactKindReport; everything else gets a UUIDv7-named blob.ArtifactKey and ArtifactKindAgentOutput. If the row write fails after a successful upload, the bytes are unreferenced but harmless — and because the report's key is derived rather than random, a retry overwrites rather than accumulating.

The harvest walk

harvestCandidates walks the scratch filesystem and returns the files worth storing, in the walk's lexical order harvestCandidates. The walk is defensive in four distinct ways: an unreadable subtree is skipped rather than abandoning the harvest (the report sits at the root, not in the subtree); dot-prefixed names are treated as tooling state and never descended into; a directory under a skip prefix is pruned; and anything that is not a regular file — a symlink, in practice — is counted as skipped rather than read through. Skipped files are counted because "a session produced something this phase declined to store" is a fact an operator should see in the log line rather than infer from a missing row.

The skip set itself comes from the workspace layer's deny list rather than a second copy of it: skipPrefixes asks workspace.ReadOnlyPrefixes for the read-only trees and adds the checkout directory when it sits inside the scratch root skipPrefixes. The reasoning is that a session cannot have produced those trees at all, so harvesting them would file the pinned repository as session output, once per session. The workspace package documents the same coupling from its side ReadOnlyPrefixes; the checkout and materialization side is covered in Workspace Sync, Checkout and Materialization.

When the walk finds more than 64 files, capCandidates trims the set capCandidates. The authored report is kept regardless of its lexical position — it is the one file the phase exists for, and losing it to a session that also wrote sixty-five scratch notes would be the worst available application of a cap whose only purpose is to bound storage.

The MIME type recorded on each row comes from contentTypeFor, an explicit table rather than mime.TypeByExtension contentTypeFor. The reason is host independence: that function consults /etc/mime.types and the Windows registry, so the same report.md would be recorded as text/markdown on one machine and application/octet-stream on another. A column whose value depends on which machine ran the session is not usable by a UI deciding whether to render or to download.

The scratch directory

Each session run by this process gets <scratch root>/<session id>, seeded with the workspace repository's read-only trees at the pinned commit and with the files it inherited under context/. The workspace tools are rooted there, the persist phase harvests it, and it is removed once the run settles the package comment.

prepareScratch creates and fills that directory, records it on the request's Workspace, and builds the run's tools over it prepareScratch. The steps are: create the directory, seed the pinned commit, materialise the inherited context, restore the session's own earlier output when this is a reopen. A caller-supplied scratch root is used as given and never removed — the manager only cleans up what it created.

Directory creation uses os.Mkdir rather than MkdirAll for the last element createScratch: an existing directory under a fresh session id is not this session's, and reusing it would hand the run someone else's files.

Seeding itself is a one-method interface, Seeder, satisfied by *workspace.Syncer Seeder. seedScratch skips when no repository is configured or the session is not pinned to a commit, and logs the reason either way seedScratch. A pinned session that was not seeded gets no skills at all rather than the deployment-wide catalog, because a fallback would build a prompt that sessions.workspace_ref does not describe skillsFor; the catalog is read from the seeded skills/ tree with skill.Load skill.Load, and the catalog's own rules are described in Skill Catalog and Frontmatter Parsing.

Inherited files are materialised read-only under context/<n>-<name>, at exactly the paths the first user message's manifest names, using agent.ContextPath materializeContext and ContextPath. An artifact belonging to a different incident is refused, and the check is repeated here rather than left to the agent's own validation, because that validation would come too late — bytes would already be in the session's directory.

sessionTools builds the tools that exist for this run only: the workspace tools rooted in the scratch directory, and docs_search over the docs at the session's pinned commit sessionTools. The docs client comes from the DocsSearch interface, satisfied by *docs.Indexer DocsSearch, which returns a client plus a release function that unpins the commit; the release runs when the scratch is released the release path. The indexing side is described in Documentation Chunking, Indexing and Search.

Once the run settles, releaseScratch releases the docs pin, optionally archives the directory as a single tar.gz, and removes it releaseScratch. The archive is uploaded with the same bytes-first, row-second ordering as any other artifact, and its row is ArtifactKindWorkspaceArchive with the path workspace.tar.gz — a name that cannot collide with a harvested file archiveScratch. Symlinks are left out of the archive for the same reason the harvest leaves them out: they point wherever the session pointed them writeArchive. Crash residue is swept by ReapScratch, which removes only UUID-named directories under the scratch root ReapScratch.

Reopen

Manager.Reopen continues a finished session — completed, failed or cancelled — with a new user message, running the next chat iteration in the background and landing the next report revision Reopen.

The guards come first. A shutting-down manager refuses; an empty message is refused; a session whose status is not terminal is refused with ErrNotFinished. The session's row decides whether it is finished, not the in-process registry — but a finished session whose run in this process is still unwinding is waited for first, because the next run reuses both its scratch directory and its registry slot.

Everything the next iteration needs is read from the store before the transition, in planReopen planReopen: the incident, the stored turns, the next revision number, the inherited context, and the session's own artifacts. The inherited context is rebuilt by storedContext, which replays the stored context refs in their recorded order so the files land where the manifest says storedContext.

The transition itself is a single conditional statement in the store, ReopenSession, which is what serialises two racing reopens: the loser gets ErrNotFinished and nothing is saved the store interface. Before the run starts and before Reopen returns, the live turn stream's finished-session latch is lifted through the StreamReopener interface, satisfied by *agent.TurnBroadcaster StreamReopener — a client that watches as soon as the call answers must find the stream open.

resume is then the reopen counterpart of the first run: a fresh scratch directory at the session's pinned commit, with inherited context and its own earlier output restored, one more chat iteration, then the same landing resume. The stream and resume mechanics on the agent side are covered in Agent Session State, Resume and Event Broadcasting.

Restoring the session's own earlier output is restoreOwn's job restoreOwn. For each workspace path, the newest revision wins; the workspace archive is not a workspace file and is skipped latestOwnArtifacts. A file that cannot be restored is logged and left out, so it is harvested again as new if the model recreates it. A restored file under a read-only tree is refused outright — a row claiming otherwise is not this session's output and must not land where the workspace layer forbids writes.

Degradation

Every step in this area degrades rather than fails the session, for the same reason the report row is guaranteed: an enhancement must not be able to suppress the guarantee it enhances. Missing blob storage, an unreadable workspace, an oversized file, a failed upload, a failed row write — all of them reduce to a skipped file, an empty markdown_key and a logged cause-first reason causeThen. The reason is joined cause-first because a later failure is usually a consequence of an earlier one, and a record that keeps only the last names a symptom while the cause is what an operator needs.

Relationships

flowchart TD
    subgraph session["internal/session"]
        Land["Manager.land"]
        Persist["Manager.persistArtifacts"]
        Harvest["harvestCandidates"]
        Cap["capCandidates"]
        CType["contentTypeFor"]
        StoreFile["Manager.storeFile"]
        Prepare["Manager.prepareScratch"]
        Seed["Manager.seedScratch"]
        Skills["Manager.skillsFor"]
        Material["Manager.materializeContext"]
        Restore["Manager.restoreOwn"]
        Release["Manager.releaseScratch"]
        Archive["Manager.archiveScratch"]
        Reopen["Manager.Reopen"]
        Plan["Manager.planReopen"]
        Resume["Manager.resume"]
    end

    Land --> Persist
    Persist --> Harvest
    Harvest --> Cap
    Persist --> StoreFile
    StoreFile --> CType
    Prepare --> Seed
    Prepare --> Material
    Prepare --> Restore
    Prepare --> Release
    Release --> Archive
    Resume --> Prepare
    Resume --> Land
    Reopen --> Plan
    Plan --> Resume
    Persist --> BlobStore["BlobStore"]
    Seed --> Seeder["Seeder"]
    Reopen --> StreamReopener["StreamReopener"]
    Prepare --> DocsSearch["DocsSearch"]
    Skills --> SkillLoad["skill.Load"]
    StoreFile --> BlobKeys["blob.ArtifactKey / blob.ReportKey"]
    Persist --> Store["store.CreateArtifact"]
    Material --> Store
    Archive --> Store

Reference

Symbol Kind What it does Location
BlobStore interface Put/Get over blob storage; the manager's view of the backend src/internal/session/persist.go#L75-L78
harvestResult struct What the persist phase did: attempted, markdown key/bytes, artifact, unchanged and skipped counts, reason src/internal/session/persist.go#L94-L101
Manager.persistArtifacts method Walks the scratch tree, uploads files, writes artifact rows; never returns an error src/internal/session/persist.go#L111-L181
Manager.storeFile method Uploads one file then records its artifact row; report vs. output differ in key and kind src/internal/session/persist.go#L183-L227
harvestCandidates function Walks the workspace FS and returns the files worth storing, with a skip summary src/internal/session/persist.go#L247-L303
capCandidates function Trims the candidate set to the file ceiling, always keeping the authored report src/internal/session/persist.go#L311-L332
skipPrefixes function The read-only trees and in-root checkout the harvest must not descend into src/internal/session/persist.go#L341-L354
contentTypeFor function Maps a harvested path onto a host-independent MIME type src/internal/session/persist.go#L381-L398
causeThen function Joins a degradation reason cause-first src/internal/session/persist.go#L400-L414
StreamReopener interface Lifts the finished-session latch of the live turn stream src/internal/session/reopen.go#L17-L19
Manager.Reopen method Continues a terminal session with a new user message and lands the next revision src/internal/session/reopen.go#L37-L96
reopenPlan struct Incident, turns, revision, inherited context and own artifacts read before the transition src/internal/session/reopen.go#L103-L109
Manager.planReopen method Reads everything the next iteration needs from the store src/internal/session/reopen.go#L111-L137
Manager.storedContext method Rebuilds the inherited context in its stored order src/internal/session/reopen.go#L142-L156
Manager.resume method Runs one more chat iteration for a reopened session and lands it src/internal/session/reopen.go#L161-L179
Seeder interface Materialises the repository's read-only trees at a pinned commit src/internal/session/scratch.go#L43-L45
DocsSearch interface Builds a commit-pinned docs_search client plus its release function src/internal/session/scratch.go#L50-L52
scratch struct One run's prepared workspace: dir, tools, docs release, seeded flag, restored artifacts src/internal/session/scratch.go#L59-L75
Manager.prepareScratch method Creates, seeds and fills the run's scratch directory and builds its tools src/internal/session/scratch.go#L83-L101
Manager.sessionTools method Builds the workspace tools and the pinned docs_search for this run src/internal/session/scratch.go#L110-L139
createScratch function Makes <root>/<sessionID> with Mkdir, refusing to reuse an existing directory src/internal/session/scratch.go#L144-L153
Manager.seedScratch method Materialises the pinned commit; skips with a reason when unseedable src/internal/session/scratch.go#L155-L175
Manager.skillsFor method Picks the skill catalog: the seeded tree for a pinned session, none if unseeded src/internal/session/scratch.go#L191-L207
Manager.materializeContext method Writes inherited artifacts read-only under context/ at the manifest's paths src/internal/session/scratch.go#L215-L248
Manager.restoreOwn method Writes a reopened session's own earlier output back into its scratch directory src/internal/session/scratch.go#L287-L324
latestOwnArtifacts function Picks the newest revision per workspace path among the session's own artifacts src/internal/session/scratch.go#L328-L340
Manager.releaseScratch method Releases the docs pin, archives the directory and removes it src/internal/session/scratch.go#L349-L364
Manager.archiveScratch method Uploads the directory as one tar.gz and records the archive row src/internal/session/scratch.go#L369-L412
writeArchive function Streams the directory as a gzipped tar, leaving symlinks out src/internal/session/scratch.go#L421-L464
ReapScratch function Removes UUID-named scratch directories left by a dead process src/internal/session/scratch.go#L488-L513

Related pages: Session Manager and Lifecycle, Storage Domain Model and Store Interfaces, Blob Storage Backend and Key Rules, Workspace Sandbox Client and Built-in Tools, Korvus Overview.


Generated by astrolabe from commit 0c2dca73 on branch main.