Astrolabe / ajxpublic /korvus
Repository

Session Manager and Lifecycle

reference scope internal/session Verification failed
  • internal/session/manager.go
  • internal/session/registry.go
  • Manager
  • Store
  • Runner
  • Config
  • CreateRequest
  • ContextRef
  • IncidentRegistry
Relevant source files · 3

A session is one self-contained multi-turn investigation inside an incident, and every session ends by landing a report. The internal/session package owns that lifecycle: it creates the row, runs the agent in the background, cancels it, and writes the terminal state. The manager exists because a session run can take minutes — far longer than a request — so the work is deliberately moved off the request goroutine and onto a run of its own.

The shape of the package is stated at the top of the file: Create returns as soon as the sessions row exists, giving the caller an id it can immediately watch over the streaming RPC, while the agent run happens on a background goroutine and reports progress through the turn stream instead of through Create's return value package documentation. Everything else in the package follows from that one decision: the run outlives the request, so the run needs its own cancellation, its own context, its own panic barrier, and a way to record how it ended even when everything else is broken.

How it works

Constructing a manager

NewManager takes a Config and returns a (*Manager, error) NewManager. The three required dependencies are the store, the runner and the registry; a missing one is rejected at construction rather than allowed to panic on the first run, because that first run happens on a background goroutine where a panic would take the process down with no request to attribute it to NewManager. Everything else in Config is optional and each optional dependency has a defined degradation: no blob backend means the persist phase stores no bytes but every report row still lands, and an empty scratch root disables the per-session workspace entirely Config. The run timeout defaults to the agent's own burst ceiling when the config leaves it at zero NewManager.

The Manager struct holds the store, the runner and the registry, plus the optional blob, scratch, docs, skill and stream collaborators Manager. Its concurrency machinery is worth naming explicitly, because each piece solves a different problem: running is a sync.WaitGroup that joins launched goroutines, kept separate from the registry because registry entries leave before the goroutine finishes its terminal write, so joining on the registry would return while runs were still writing Manager. stopMu/stopping order Create against Stop, so every create is either registered before the shutdown cancellation or refused outright, and running.Add can never race running.Wait Manager. The settled map holds one channel per run this process owns, closed after that run's terminal write — it is what lets a watcher end its stream on the terminal state rather than on the agent's stream close, which happens earlier Manager.

Creating a session

Create is the entry point Create. It refuses a blank prompt with ErrPromptRequired, refuses a closed incident with ErrIncidentClosed, and refuses to run at all once Stop has begun, with ErrShuttingDown — a session created now would either miss the shutdown cancellation or be cancelled the moment it started Create. The kind of the session (initial or follow_up) is inferred from the incident's existing sessions when the caller leaves it blank inferKind.

CreateRequest deliberately has no ID, sequence number or status field: the sequence is allocated race-free inside the store's transaction, and a request type that cannot spell it is what keeps that guarantee structural CreateRequest. The manager mints the id itself as a UUID v7 and writes the row with status running rather than pending, so the status the caller is handed is the status the run will report against, and the terminal write is the only status change that follows Create Create.

Context refs are resolved before the row is written, so a bad ref is refused without leaving behind a session that never runs Create. resolveContextRefs rejects an empty artifact id, a duplicated id, an unknown artifact and an artifact belonging to a different incident, and it derives a role from the artifact kind when the caller left the role blank resolveContextRefs ContextRef roleForKind. The incident check duplicates what the store's composite foreign keys enforce on insert; the duplication is deliberate, because the database's refusal arrives only after the session row exists and only as a constraint name resolveContextRefs.

Once the row exists, the refs are inserted into session_context_refs with one-microsecond-spaced timestamps so the stored order matches the request order Create. If that insert fails — for any reason, including the composite foreign keys refusing a cross-incident pair — the row is settled failed rather than left reading running forever, because no run will start for it Create.

The run goroutine

launch is the single place where background work begins launch. It registers the run's cancel function with the registry, tracks the settle signal, increments the run group, and starts the goroutine. The run's context is deliberately not derived from the caller's request context, because that context is cancelled the moment Create returns and inheriting it would cancel every run instantly; the run's cancellation channel is the registry, not the request launch.

The goroutine's defers are ordered, and the order is load-bearing rather than cosmetic. The panic barrier is declared last so it runs first, ahead of the others: without it, a panic anywhere under the runner's call — provider decoding, an MCP tool response, the report extractor — would reach the runtime's default handler and kill the process, stranding every other session's row at running launch. A recovered panic is logged and settled as failed with the manager's own exitPanicked reason, which is one of the reasons that describe conditions the agent cannot report about itself exit reasons launch. The remaining defers deregister the session, close the settle signal and release the run group, in that order, so a watcher released by the barrier reads the terminal row even for a panicked run, and Wait cannot return while a terminal write is still in flight launch.

run itself is the first-run body: it prepares the session's scratch directory, calls the runner with the session, the incident, the inherited artifacts, the workspace reference, the session tools, the template path and the skill catalog, and hands the result to land run.

Landing the report and the terminal row

land is shared by the first run and by a reopened iteration, and it is where the durable outcome of one chat iteration is recorded land. The order of operations is the substance of this function. The persist phase runs first, because the report row carries the markdown key that phase derives; landing the row first would need a second update to attach it, and a failure in between would leave a stored document no report points at land. The report is then persisted before the status is written, because a watcher that sees a terminal status immediately asks for the report — landing the report second would open a window where the session is complete and the report lookup returns not-found land.

Two failure shapes get their own exit reasons rather than a generic error. If the author phase did not finish cleanly, the persist phase's pointer at a restored earlier report.md is refused, so an earlier revision's root cause is never served as this revision's, and the exit reason becomes report_author_failed adoptsStaleReport land. If the document was verified on disk but could not be stored, the reason becomes report_upload_failed, which distinguishes a storage outage from a model that never wrote the document exit reasons. A report the operator cannot read at all outranks the missing-markdown case, so a failed report write sets report_persist_failed — but only when the run itself succeeded, because a failed run has its own more actionable primary cause land.

statusFor is the mapping from an agent exit onto a session status statusFor. A non-nil run error becomes failed; a cancelled exit becomes cancelled; an exit that is not terminal — the agent's awaiting_user, the one reason that means the burst finished but the session has not — becomes running again, because settling an idle session as finished would be a lie a watcher acts on ExitReason.IsTerminal statusFor. Every other terminal exit becomes completed statusFor. The turn, tool-call and token counters from the run are copied onto the row before the write, so the session row carries the same audit numbers the report does land.

settle performs the terminal write, and it is the last statement on every path that ends a run settle. It stamps ended_at only for a status that actually ends the session, so an idle session that goes back to running keeps a null end time — a row carrying both a running status and an end time is a contradiction settle. The write itself runs on a context detached from the run's and bounded by terminalWriteTimeout, because a cancelled or timed-out run must still record that it was cancelled, and a session stuck at running forever is exactly the failure this package exists to prevent persistReport terminalWriteTimeout. The store call is guarded by its own recover, because settle is itself reachable from the panic barrier and a defer cannot recover its own panic; the panic is logged as a terminal-write failure rather than misattributed to the agent run settle.

Cancellation, waiting and shutdown

Cancel reads the sessions row and refuses a session that already reached a terminal status with ErrAlreadyEnded Cancel. Its legitimacy check never consults the registry: the registry is a per-process cache, and a session running in another replica is simply absent from it. When the row says the session is active but this process is not running it, the manager logs a warning and returns the row unchanged rather than inventing a terminal write that would race the replica owning the run Cancel.

Wait joins the run group and cancels nothing on its own; a caller that wants a bounded shutdown cancels first Wait. Stop is the shutdown path: it cancels every run this process owns through the registry and then waits for each to settle. The order against the store matters, because runs are detached from the request that started them and nothing else cancels them when the server stops — closing the connection pool first would fail every terminal write and strand those rows at running Stop. Stop is safe to call more than once and from several goroutines; only the first call cancels, every call waits Stop.

AwaitSettled blocks until a run has written its terminal row, or until the caller's context is done. It exists because the agent closes a session's live stream when the run returns, which is before the manager persists the report and writes the terminal status — waiting here is what lets a stream end on the terminal state rather than on a still-running row AwaitSettled.

The in-process registry

IncidentRegistry tracks the sessions running in this process so a cancel request can reach the goroutine driving one IncidentRegistry. It is explicitly a cache for cancellation and stream fan-out, never a source of truth: PostgreSQL owns session state, so a session missing from the registry is not evidence that it is not running, and a session present in it is not evidence that it still is, because the run goroutine deregisters after its terminal write IncidentRegistry.

Register refuses a duplicate id with ErrAlreadyLive and leaves the existing entry untouched; overwriting it would drop the first run's cancel function and leak a goroutine nothing can stop Register. Unregister is idempotent, because the run goroutine's deferred unregister must be safe after a cancel that already removed the entry Unregister. Cancel does not remove the entry — the run goroutine owns that, and removing it here would let a second cancel report false while the first run is still unwinding — and it calls the cancel function outside the mutex, since CancelFunc runs cancellation callbacks synchronously and holding the registry lock across arbitrary code invites a lock-order inversion Cancel. Live and Count answer per-incident fan-out queries without a database round trip, and CancelAll cancels every entry the same way, outside the lock Live Count CancelAll.

Error handling

The package's domain errors are the contract the transport layer maps onto codes; callers never inspect message text domain errors. ErrPromptRequired reports a create with no opening instruction, ErrIncidentClosed reports a create against a closed incident, ErrAlreadyEnded reports a cancel on a session that already reached a terminal status, and ErrInvalidContextRef reports a ref the manager refuses before any session exists. ErrNotFound and ErrNotFinished wrap their store counterparts so callers may match either domain errors.

Two of the exit reasons on the row are the manager's own rather than the agent's, because they describe conditions the agent cannot report: one happens instead of the agent returning (panicked), the other after it already has (report_persist_failed, report_upload_failed, context_refs_failed). The column is free text in SQL and passes straight through transport conversion, so nothing needed a schema change to accommodate them exit reasons.

Relationships

graph TD
    Caller["transport and CLI callers"] -->|Create, Cancel, Reopen| Manager
    Manager --> Store
    Manager --> Runner
    Manager --> IncidentRegistry
    Manager -->|Config wiring| Config
    Manager -->|request payload| CreateRequest
    Manager -->|ref resolution| ContextRef
    Manager -->|terminal row write| Store
    IncidentRegistry -->|cancel handle| Manager
    Manager -->|run, resume, settle| Runner
    manager.go --> Manager
    manager.go --> Store
    manager.go --> Runner
    manager.go --> Config
    manager.go --> CreateRequest
    manager.go --> ContextRef
    registry.go --> IncidentRegistry

The manager's persistence surface is the narrow Store interface it declares for itself, covering the session, incident, report and artifact calls the lifecycle needs Store; the broader persistence contract lives with the storage layer in Storage Domain Model and Store Interfaces. The Runner interface is the manager's two-method view of the agent — one bounded investigation, and one more chat iteration of a finished session Runner — whose inner loop is described in Agent Run Loop, Tool Execution and Provider Calls. The scratch directory, the blob persist phase and the reopen path are covered in Session Persistence, Reopen and Scratch Seeding, while the in-memory conversation state and the turn stream the manager waits on live in Agent Session State, Resume and Event Broadcasting. The process-level wiring of all of this — including the required dependencies and the shutdown ordering — is in korvus CLI and Process Wiring, and the RPC surface that calls into the manager is in Connect API Server and Service Handlers.

Reference

Symbol Kind What it does Location
Manager struct Owns the session lifecycle: create, run, cancel, settle, stop src/internal/session/manager.go#L148-L201
Config struct Wires a Manager; store, runner and registry are required, the rest degrade src/internal/session/manager.go#L204-L252
Store interface The manager's view of persistence: sessions, incidents, reports, artifacts, context refs src/internal/session/manager.go#L32-L49
Runner interface The manager's view of the agent: Run for a first investigation, Resume for another iteration src/internal/session/manager.go#L53-L56
CreateRequest struct Everything an engineer supplies to start a session; deliberately cannot spell id, seq or status src/internal/session/manager.go#L296-L322
ContextRef struct One artifact a new session should inherit, by id and optional role src/internal/session/manager.go#L325-L332
IncidentRegistry struct In-process cache of live sessions and their cancel handles src/internal/session/registry.go#L41-L44
NewManager function Builds a Manager, rejecting a missing required dependency src/internal/session/manager.go#L258-L288
Manager.Create method Writes the row, records context refs, launches the run, returns immediately src/internal/session/manager.go#L342-L430
Manager.launch method Starts the background goroutine: registry registration, panic barrier, settle signal src/internal/session/manager.go#L436-L489
Manager.resolveContextRefs method Validates refs and derives their roles before any row exists src/internal/session/manager.go#L510-L553
Manager.run method First-run body: scratch, agent run, then landing src/internal/session/manager.go#L583-L600
Manager.land method Records one iteration's artifacts, report revision and terminal row src/internal/session/manager.go#L604-L685
statusFor function Maps an agent exit and run error onto a session status src/internal/session/manager.go#L718-L729
Manager.persistReport method Writes the report row on a detached, bounded context src/internal/session/manager.go#L733-L748
Manager.settle method Writes the terminal row and stamps ended_at for ending statuses src/internal/session/manager.go#L771-L806
Manager.Cancel method Cancels a running session after reading the row for legitimacy src/internal/session/manager.go#L839-L860
Manager.Wait method Joins every run this manager launched src/internal/session/manager.go#L864-L868
Manager.Stop method Shutdown path: cancels every run, then waits for each to settle src/internal/session/manager.go#L880-L892
Manager.AwaitSettled method Blocks until a run has written its terminal row src/internal/session/manager.go#L905-L916
NewIncidentRegistry function Returns an empty, concurrency-safe registry src/internal/session/registry.go#L47-L49
IncidentRegistry.Register method Records a cancel handle, refusing a duplicate id src/internal/session/registry.go#L56-L65
IncidentRegistry.Cancel method Cancels a live session without removing its entry src/internal/session/registry.go#L76-L90
IncidentRegistry.CancelAll method Cancels every live session in this process src/internal/session/registry.go#L111-L125

Generated by astrolabe from commit 0c2dca73 on branch main.