449 lines
28 KiB
Markdown
449 lines
28 KiB
Markdown
# STRUCTURED-VIEW.md — the structured session contract
|
|
|
|
The contract for a structured session (`view: Structured`, #133): an agent run
|
|
as a daemon child over the Agent Client Protocol (ACP) instead of in a tmux
|
|
pane. Alongside `docs/WORKTREES.md`, `docs/SIDEBAR.md` and `docs/RESTART.md`,
|
|
read this before changing anything that renders a structured session — both
|
|
the web and TUI renderers are written against it.
|
|
|
|
It covers the eight transcript row kinds, what the daemon decides versus what
|
|
a client decides, the two projections and their delta/cursor semantics, the
|
|
ACP handshake and the capabilities it declines, the adapter's install
|
|
command, and the approval nonce rules. **Non-goal: the detached runner.** It
|
|
does not exist yet, and nothing here describes it.
|
|
|
|
## What a structured session is
|
|
|
|
A terminal session's agent runs inside a tmux pane; a structured session's
|
|
agent runs as a subprocess of the daemon itself, speaking ACP over stdio. The
|
|
daemon is the ACP **client**; the provider adapter process is the ACP
|
|
**agent**. It translates the provider's events into ACP `session/update`
|
|
notifications instead of drawing them to a pane. `crates/maestro-acp` owns the
|
|
daemon's side of that connection;
|
|
`crates/maestro-daemon/src/projection.rs` folds what it receives into the two
|
|
projections below.
|
|
|
|
The child is a real child of the daemon process: same process group, and on
|
|
Linux marked with `PR_SET_PDEATHSIG` so it dies with the daemon even if the
|
|
daemon exits without cleaning up; other platforms have no such signal and
|
|
depend on an orderly shutdown/drop to terminate it. The ordinary path on
|
|
every platform is `AcpSession::terminate` (`session/cancel`, a short grace,
|
|
`SIGTERM`, then `SIGKILL`), called from both `DELETE /api/sessions/:id` and
|
|
daemon shutdown.
|
|
`docs/WORKTREES.md`'s kill-guard rule — an unforced kill refused while the
|
|
session's worktree holds unsaved work — applies to a structured session
|
|
exactly as it does to a terminal one; only what gets terminated differs (an
|
|
ACP handle instead of a tmux pane).
|
|
|
|
## Choosing the view
|
|
|
|
`view` is fixed at spawn and never changes. Every client picks it the same
|
|
way, from the resolved command alone
|
|
(`maestro_protocol::display::supports_structured_view`): a command that
|
|
resolves to Claude Code or Codex spawns **Structured** by default, anything
|
|
else spawns **Terminal**. The operator can override the default — the web and
|
|
TUI spawn forms show the checkbox ticked and let it be unticked, and
|
|
`maestro spawn` takes `--terminal` (or `--structured`, which sends the request
|
|
even for a command the CLI does not recognise and lets the daemon refuse it).
|
|
Once the operator works the toggle, a later command change never re-ticks it
|
|
under them; it can still be forced off when the new command has no adapter.
|
|
|
|
The daemon is the last word: a `Structured` request for a command with no
|
|
adapter is refused there, whichever client sent it.
|
|
|
|
## The adapter
|
|
|
|
The resolved structured command selects a provider adapter. Its binary is
|
|
resolved by walking the session's `PATH` when the spawn request sets one, or
|
|
the daemon's `PATH` otherwise, and taking the first executable with that name.
|
|
|
|
| Command | Adapter binary | Install command | Provider detail |
|
|
| --- | --- | --- | --- |
|
|
| `claude` | `claude-code-acp` | `npm install -g --prefix ~/.local @zed-industries/claude-code-acp` | Needs a logged-in Claude Code. |
|
|
| `codex` | `codex-acp` | `npm install -g --prefix ~/.local @agentclientprotocol/codex-acp` | Uses Codex CLI or adapter-configured authentication; it advertises model, reasoning-effort, agent-mode, and optional fast-mode configuration. |
|
|
|
|
The selected install command is baked into `AcpError::AdapterMissing`. When an
|
|
adapter cannot be found, spawn still creates a **Dead** session with that
|
|
command as `activity_detail` (see `docs/INSTALL.md` for the systemd `PATH`
|
|
caveat). Provider authentication failures surface through the adapter's stderr
|
|
tail in the same field.
|
|
|
|
The handshake is bounded by `HANDSHAKE_TIMEOUT` (60s) — long enough for a
|
|
slow machine to boot an adapter and its provider process, never long enough
|
|
to hang a spawn indefinitely. Any handshake failure — missing adapter, early
|
|
exit, a protocol error, or the timeout — leaves no orphaned process: `spawn`
|
|
closes and reaps the child itself before returning the error.
|
|
|
|
## The ACP handshake
|
|
|
|
`initialize` is where the daemon states what kind of client it is. It
|
|
declares, deliberately, almost nothing:
|
|
|
|
| Capability | Declared | Why |
|
|
| --- | --- | --- |
|
|
| `fs.readTextFile` | `false` | Providers read the session workspace through their own tool surface. |
|
|
| `fs.writeTextFile` | `false` | Providers write the session workspace through their own tool surface. |
|
|
| `terminal` | `false` | Providers run commands through their own tool surface. |
|
|
|
|
Declining all three keeps the daemon out of the file/terminal surface
|
|
entirely — the adapter already has direct access to the session's `cwd`, and
|
|
routing every read, write, and shell command through the daemon would only
|
|
add a hop with no new capability. Every other capability namespace ACP
|
|
defines (session extensions, auth, elicitation) is simply left at its
|
|
default; the daemon does not advertise support because neither supported
|
|
adapter needs it, not because it was weighed and refused.
|
|
|
|
The one client method that *is* implemented for real is
|
|
`session/request_permission` (#139) — see [Approvals](#approvals) below.
|
|
Before #139 it was a stub that declined every request; that stub is gone now
|
|
that a live implementation exists.
|
|
|
|
`session/new` opens a fresh ACP session on every ordinary spawn. A respawn
|
|
after a daemon restart instead sets `SpawnConfig::load_session(id)` and runs
|
|
`session/load` with the ACP session id the original handshake returned
|
|
(`Session::acp_session_id` on the wire) — the adapter must advertise
|
|
`agent_capabilities.load_session`, or the handshake fails outright naming
|
|
that. Whatever history the adapter streams back while `session/load` is in
|
|
flight is dropped on arrival, deliberately: the daemon's own event log
|
|
already holds that history, and forwarding it too would duplicate every row
|
|
in the projections below.
|
|
|
|
Codex and Claude differ in what they emit, not in this lifecycle. Codex maps
|
|
shell commands, file changes, MCP calls, web search, image generation/view,
|
|
reasoning, plans, and token usage into standard ACP chunks and tool calls;
|
|
those fold into the same eight rows. Codex's `available_commands_update` and
|
|
`session_info_update`, like equivalent Claude updates, remain opaque
|
|
`Notice` rows. Its `session/load` capability is required for daemon recovery
|
|
just as it is for Claude.
|
|
|
|
## The eight transcript row kinds
|
|
|
|
`TranscriptRowKind` (`maestro-protocol`) is the alphabet a structured
|
|
transcript is written in. Every row has a stable `id` (so later deltas can
|
|
address it), a `kind`, and a `data: Value` that preserves the underlying ACP
|
|
payload — clients depend on ACP's *shape* without depending on ACP as a
|
|
crate.
|
|
|
|
| Kind | Carries | Row identity |
|
|
| --- | --- | --- |
|
|
| `User` | The prompt accepted by Maestro's structured prompt endpoint, persisted before ACP dispatch. An adapter `user_message_chunk` is also supported for imported ACP history. | `user:<uuid>` for a daemon-recorded prompt; `user:<message_id>` or `user:<seq>` for an ACP chunk. |
|
|
| `AgentText` | An `agent_message_chunk` — the agent's visible reply, streamed. | `agent:<message_id>`, or `agent:<seq>` from the first chunk when no id is supplied. Later chunks for the same id—or consecutive anonymous text chunks—are appended into `data.content.text` in place. An empty anonymous chunk starts a fresh row, matching the turn marker emitted by adapters that omit message ids. |
|
|
| `AgentThought` | An `agent_thought_chunk` — the model's reasoning stream. | `thought:<message_id>` / first anonymous `thought:<seq>`, coalesced the same way as `AgentText`. |
|
|
| `ToolCall` | A `tool_call` / `tool_call_update` pair whose content carries no diff. | `tool:<tool_call_id>` — one row for the call's whole life; updates patch it in place, they never create a second row. |
|
|
| `ToolDiff` | The same tool-call machinery, when the tool's content includes a `Diff` block. | Same `tool:<tool_call_id>` identity as `ToolCall`; the *kind* flips between the two as the tool's own content changes, not the row. |
|
|
| `Approval` | A daemon-originated permission request (`RequestPermissionRequest`), serialized without its nonce. | `approval:<uuid>` — minted fresh per request, unrelated to the ACP request's own id. |
|
|
| `Plan` | A `plan` update — the agent's current step list. | The fixed id `"plan"`. Each new plan **replaces** the row wholesale; entries are not diffed against the previous plan. |
|
|
| `Notice` | The raw JSON of any `session/update` variant the ACP dependency already decodes but the projection does not special-case. Maestro also uses a tagged `maestroTurnMeta` notice as the durable footer for a completed, cancelled, or failed turn. | `notice:<seq>` for ACP updates; `turn_meta:<uuid>` for a turn footer. |
|
|
|
|
`Notice` covers known-but-unspecialized `SessionUpdate` variants: ones the
|
|
`agent-client-protocol` dependency's schema already deserializes (it
|
|
currently folds `available_commands_update` and `session_info_update`
|
|
there) but the projection's `match` doesn't fold into one of the other
|
|
seven kinds. It is not a wire-level forward-compatibility seam:
|
|
`SessionUpdate` is an internally-tagged enum with no unknown-variant
|
|
fallback, so a `session/update` whose `sessionUpdate` tag this build's
|
|
dependency has never heard of — including a variant gated behind an
|
|
unstable ACP feature this build doesn't enable, which is compiled out of
|
|
the enum entirely — fails to deserialize before the projection ever runs,
|
|
rather than becoming a `Notice`. A genuinely new ACP variant needs the
|
|
`agent-client-protocol` dependency upgraded (and, typically, the projection's
|
|
`match` taught about it) before it can appear on this transcript at all — see
|
|
`projection.rs`'s test asserting that an unrecognised `sessionUpdate` tag is a
|
|
deserialization error, not a `Notice`. A client that does not know what to do
|
|
with a `Notice` can still render it as an opaque, collapsed line — that
|
|
remains a reasonable floor, not a client bug.
|
|
|
|
Rendering is entirely a client concern. The projection's job stops at
|
|
folding ACP into these eight shapes; markdown formatting, diff rendering,
|
|
tool-card treatment, and how a `Notice` is presented are all renderer
|
|
choices, not daemon ones.
|
|
|
|
## Daemon decisions vs. client decisions
|
|
|
|
**The daemon decides:**
|
|
|
|
- Which ACP `session/update` becomes which row kind, and under what id — the
|
|
fold in `projection.rs` is the one place this logic lives, so the web and
|
|
TUI renderers can never disagree about it.
|
|
- Turn lifecycle: when a turn begins, and whether an ACP response, a cancel,
|
|
or a failure ends it (`TurnState.responding` / `.thinking` /
|
|
`.active_tool_calls` / `.started_at` are derived, not renderer-computed).
|
|
Ending a turn appends a durable `maestroTurnMeta` footer with its elapsed
|
|
time, outcome, model, and context-window usage/delta. ACP does not expose
|
|
provider billing-token counts, so these are explicitly context tokens.
|
|
- Approval nonce minting, secrecy, and the single-active-approval rule (see
|
|
below) — a client never sees an ACP permission request before the daemon
|
|
has already turned it into an `Approval` row and a nonce.
|
|
- What happens to an ACP update variant the dependency decodes but nobody has
|
|
taught the projection to special-case (falls back to `Notice` rather than
|
|
erroring). A variant the dependency itself can't decode is a deserialization
|
|
error, not a `Notice` — see [The eight transcript row
|
|
kinds](#the-eight-transcript-row-kinds).
|
|
- Persistence and replay: what is written to the event log, in what order,
|
|
and how a restart resumes it.
|
|
|
|
**A client decides:**
|
|
|
|
- How each row kind is drawn — markdown for `AgentText`/`AgentThought`, a
|
|
diff view for `ToolDiff`, a tool card for `ToolCall`, and a floor
|
|
presentation for `Notice`. `Plan` is the exception: both clients omit it
|
|
from the transcript; the TUI may show the current plan in its status line,
|
|
while the web client deliberately has no stats or plan rail.
|
|
- The web client groups two or more tool rows after the same `User` row into
|
|
one collapsed turn activity block. Opening it reveals independently
|
|
expandable tool calls, including any raw command, input, output, and diff
|
|
content the adapter supplied.
|
|
- The web client's user, agent, thought, tool-detail, and notice text blocks
|
|
copy their complete source text when clicked or keyboard-activated.
|
|
- The web client consumes `available_commands_update` notices as the live
|
|
source for composer `/` autocomplete at any whitespace-delimited token under
|
|
the caret. Completing a command replaces only that token. The client omits
|
|
those state-only notices, along with `session_info_update`, from the visible
|
|
transcript.
|
|
- How live `responding`, `thinking`, tool activity, elapsed time, and context
|
|
usage are drawn. The web client keeps these in a compact strip above the
|
|
composer and renders each durable turn footer inline with the transcript.
|
|
- How the operator is asked to resolve an `Approval` row — the daemon only
|
|
exposes the three-way decision (`AllowOnce` / `AllowForSession` / `Deny`),
|
|
never the UI that solicits it.
|
|
- Scrolling, collapsing, and every other presentation-only behaviour.
|
|
|
|
## The two projections
|
|
|
|
`Projection` (daemon-only) is folded once from the durable ACP stream and
|
|
exposes two independent views, both defined in `maestro-protocol`:
|
|
|
|
- **`TranscriptSnapshot { cursor, rows }`** — the complete ordered list of
|
|
transcript rows built so far, plus the log position (`cursor`) it reflects.
|
|
It is intentionally always the *whole* transcript, not a windowed one: a
|
|
tool row patched much later must still find its original entry.
|
|
- **`ReducedState`** — one whole-session snapshot folded over every event
|
|
seen: `turn` (the per-turn indicators above), `pending_approval`, `plan`,
|
|
`usage`/`context`, `model`, and `mode`. Like `TranscriptSnapshot`, it is
|
|
always full-history, never a delta — a client that only wants "is a turn
|
|
active right now" never has to replay anything to answer that.
|
|
|
|
Changes to the transcript travel as `TranscriptDelta`, tagged `type`:
|
|
`Append { row }` (a new row), `Patch { row }` (an existing row, addressed by
|
|
`id`, replaced wholesale), and `Remove { id }`. `Remove` is defined on the
|
|
wire for forward compatibility; nothing in the current projection emits it —
|
|
every row, once appended, is only ever patched or left alone.
|
|
|
|
### Cursor and replay semantics
|
|
|
|
The event log's own line position is the cursor: 1-based, shared by ACP
|
|
notifications and daemon-generated rows alike (`sidecar.rs`'s
|
|
`PersistedEvent` envelope is what lets `User` and `Approval` rows share a cursor
|
|
sequence with raw ACP JSON without pretending to be ACP traffic; the envelope
|
|
also carries durable `Turn` footers). Two ways to
|
|
read it:
|
|
|
|
- **`WS /api/structured/:id`** — on connect, sends a `TranscriptSnapshot` and
|
|
a `ReducedState` covering the whole log as it stands, then streams
|
|
`ProjectionUpdate { transcript, state }` for everything that happens next.
|
|
There is no client-supplied resume cursor on this socket: every connection
|
|
gets the full snapshot, not a gap-filled continuation, so a client never
|
|
needs the replay endpoint just to obtain the current transcript. Use
|
|
replay instead for cursor-bounded pagination through the durable log —
|
|
history from before this connection, fetched a page at a time.
|
|
- **`GET /api/sessions/:id/structured/replay`** — cursor-bounded pagination
|
|
over the durable log, returning one `StructuredReplayEvent` (`cursor`,
|
|
`transcript`, `state`) per matching line. `?since=<n>` returns the first
|
|
page after `n` (`since=0` selects from the beginning, i.e. the first
|
|
page); a client pages forward from there using the cursor of the last
|
|
event returned. `?before=<n>` returns the page immediately before `n`,
|
|
still in ascending order — passing both is a `400`. `?limit` defaults to
|
|
100 and is clamped to 500. Each call re-folds
|
|
the entire log from scratch to compute the state at every returned cursor,
|
|
so a page reflects exactly the *durable* state at its cursor, not an
|
|
approximation of it — at the cost of redoing the whole fold per request.
|
|
That durable state is not always identical to what a live client saw at
|
|
that moment, though: turn lifecycle transitions are broadcast live without
|
|
a durable event backing them (see [Prompt and cancel](#prompt-and-cancel-bounded-liveness)),
|
|
and a restored `Approval` row carries no pending nonce, because the nonce
|
|
never reached disk (see [Approvals](#approvals)) — replay is exact and
|
|
reconstructible, not a perfect record of everything the live view showed.
|
|
A session that is not a live structured session (terminal, or a dead
|
|
adapter) answers `409 not_structured` from either route rather than a
|
|
coherent-looking but stale reply.
|
|
|
|
## Approvals
|
|
|
|
An ACP `session/request_permission` callback becomes an `Approval` row plus a
|
|
pending decision, entirely on the daemon side:
|
|
|
|
- **Generation.** The daemon mints the nonce itself — a fresh
|
|
`Uuid::new_v4()` string per request — rather than using anything ACP's own
|
|
request carries. ACP permission callbacks have no request id of their own
|
|
that would double as one.
|
|
- **Live-only placement.** The nonce lives in exactly one place: the
|
|
in-memory `pending_approvals` map keyed by nonce, and its mirror in
|
|
`ReducedState.pending_approval.nonce` on the live wire. It is never part of
|
|
the `Approval` row's `data` — the row is `RequestPermissionRequest`
|
|
serialized *without* the nonce.
|
|
- **Secrecy from the agent and from durable history.** The adapter itself
|
|
never learns the nonce — it is a daemon-to-client secret, never sent back
|
|
over the ACP connection. It is likewise never written to the event log:
|
|
restoring an `Approval` row from disk (`Projection::restore_row`)
|
|
produces a historical row with no `pending_approval` in state, because the
|
|
nonce that would make it "pending" never made it to disk in the first
|
|
place. A restart therefore cannot resume a pending approval — see
|
|
[Persistence](#persistence-recovery-and-dead-sessions).
|
|
- **One live approval at a time.** A new permission request mints its own
|
|
nonce, then cancels whatever was already pending (`cancel_pending_approvals`)
|
|
before installing itself as the new pending approval — ACP serializes
|
|
requests through one connection, so this only ever fires if an adapter
|
|
nonetheless overlaps two. Either way, the prior pending request is
|
|
cancelled/cleared before the new one is installed, so the newest request
|
|
wins.
|
|
- **Single-use and duplicate resolution.**
|
|
`POST /api/sessions/:id/structured/approvals/:nonce` **always answers
|
|
`204`** for a known, live structured session — whether the nonce is the
|
|
one currently pending, one already consumed by an earlier answer, or one
|
|
that never existed. Only an unknown or non-structured *session* answers
|
|
otherwise (`404` / `409`). This is deliberate: two clients racing to answer the same
|
|
approval must not turn a harmless duplicate click into a client-visible
|
|
error. Whichever `POST` reaches the daemon first removes the nonce from
|
|
`pending_approvals` and is the one whose decision actually reaches the
|
|
adapter; every later `POST` for that nonce is a no-op `204`.
|
|
- **Cancellation vs. denial.** A turn cancel or a session shutdown resolves
|
|
any pending approval with ACP's `Cancelled` outcome, not a `Deny` decision
|
|
— the adapter should read those as "the operation was withdrawn," not "the
|
|
operator refused it."
|
|
- **Decision mapping.** `AllowOnce` and `AllowForSession` select the ACP
|
|
option of kind `AllowOnce` / `AllowAlways` from the request's own option
|
|
list. `Deny` prefers a one-shot refusal (`RejectOnce`) and falls back to a
|
|
permanent one (`RejectAlways`) only when that is the sole rejection option
|
|
offered; if neither is present, it answers `Cancelled`.
|
|
|
|
## Prompt and cancel: bounded liveness
|
|
|
|
The web composer accepts up to ten files through its attachment picker, file
|
|
drop, or clipboard. Pasted text of at least 4,000 characters or 40 lines becomes
|
|
a UTF-8 `.txt` attachment. Shorter pastes stay in the composer. Attachments can
|
|
be removed before sending; failed sends retain the draft and attachments.
|
|
Pastes also stay inline when all ten attachment slots are occupied or the text
|
|
exceeds the 20 MiB file limit.
|
|
|
|
On send, `POST /api/sessions/:id/structured/attachments?name=<filename>` uploads
|
|
each file as raw bytes (20 MiB maximum) and returns its absolute `path`.
|
|
Only live structured sessions accept uploads. Filenames are limited to 180
|
|
bytes and cannot contain directory separators or control characters. Files get unique names and private
|
|
file permissions in `<id>.attachments` beside the session sidecar. They persist
|
|
across daemon restarts; removing a composer chip does not delete uploaded data.
|
|
There is currently no automatic attachment cleanup.
|
|
|
|
The prompt includes each filename and host path so the adapter can read it
|
|
through its own file tools, including images when the provider supports them.
|
|
These references are persisted in the ordinary user transcript row. Attachment
|
|
bytes are not embedded in ACP messages or transcript history.
|
|
|
|
`POST /api/sessions/:id/structured/prompt` and `.../cancel` are serialized
|
|
against each other through one per-session `operation` lock
|
|
(`AcpEventStream::operation`), because ACP permission callbacks carry no
|
|
per-request id — the daemon's own notion of "the active turn" is the only
|
|
thing a callback can be attributed to, so at most one turn may ever be
|
|
in flight, and a cancelled turn must fully settle before the next prompt is
|
|
allowed to claim the daemon's idea of "the active turn."
|
|
|
|
- A prompt is appended as a durable `User` row before it claims the turn and
|
|
dispatches to ACP, so the transcript never depends on an adapter echoing the
|
|
operator's input. If that append fails, the prompt is not dispatched. While a
|
|
turn is active, a second prompt gets `409 turn_in_flight` rather than
|
|
queuing — one active turn per session, never a hidden queue of prompts.
|
|
- Cancel sends `session/cancel`, clears any pending approval as `Cancelled`,
|
|
and ends the turn's client-visible lifecycle immediately. If the cancelled
|
|
turn was still *dispatched* to ACP (waiting on its response), the daemon
|
|
cannot simply forget it: ACP has not confirmed the cancel yet, and a new
|
|
prompt claiming "the active turn" while the old one is still outstanding
|
|
would make the next permission callback ambiguous between the two.
|
|
- So a cancelled-but-still-dispatched turn must **settle within
|
|
`CANCEL_GRACE` (500ms)** — either ACP acknowledges the prompt (however it
|
|
resolves) or that grace period runs out. If it settles in time, the next
|
|
prompt proceeds normally. If it does not, the daemon does not guess: it
|
|
marks the whole structured session dead (`fail_cancelled_acp_turn`) rather
|
|
than risk a callback resolving against the wrong turn. This is the bounded
|
|
half of "bounded liveness" — a structured session's prompt/cancel dance
|
|
either resolves promptly or the session is declared unusable, never left
|
|
in an indefinite ambiguous state.
|
|
|
|
## Persistence, recovery, and dead sessions
|
|
|
|
A structured session's durable state is three files in the sidecar
|
|
directory: the sidecar itself (`<id>.json`), plus two next to it:
|
|
|
|
- **`<id>.events.jsonl`** — the unified event log. Each line is either a raw
|
|
ACP `SessionNotification` (kept in its original JSON so old logs stay
|
|
readable) or a `{"maestroEvent": "user"|"approval", "row": …}` envelope
|
|
for a daemon-generated row. Both share the same 1-based line-position cursor. A
|
|
line a hard kill left half-written is terminated before the next append, so
|
|
it keeps its position and is skipped as malformed rather than corrupting
|
|
what follows. An append that fails *while the daemon is running* — a
|
|
`write_all` that errors after only a prefix reached disk, or a `flush` that
|
|
errors after a complete write — is rolled back to the exact byte length the
|
|
file was at before the call, so `last_seq` and the file's line count never
|
|
disagree and the same log handle can be appended to again immediately (#184).
|
|
If that rollback itself cannot restore the prior length, the append is
|
|
fatal: the log's consistency can no longer be promised, so the caller kills
|
|
the session instead of appending to it again.
|
|
- **`<id>.recover`** — an empty marker file. Its only meaning is "the daemon
|
|
that wrote this shut down gracefully while this session was running, and
|
|
the next daemon may attempt to resume it." It is consumed (deleted) the
|
|
moment a recovery attempt reads it — one recovery attempt per graceful
|
|
shutdown, never a standing flag.
|
|
|
|
Three ways a structured session ends up `Dead`, each with different
|
|
persistence consequences:
|
|
|
|
1. **The adapter exits, or a notification append to the event log fails,
|
|
while the daemon keeps running.** (An approval-row append failure is
|
|
narrower: as long as the log rolled back cleanly, it only cancels the
|
|
pending approval and rolls the unpersisted `Approval` row back out of the
|
|
live transcript, without marking the session dead — a snapshot taken after
|
|
the failure must never show a row durable replay could not reproduce
|
|
(#184). If the rollback itself did not restore the log's prior length,
|
|
this narrower path does not apply either: the session is marked dead the
|
|
same as any other append failure, since the log can no longer promise
|
|
`last_seq` matches its file.)
|
|
`mark_session_dead` applies the same natural-exit worktree rule
|
|
as a terminal session's own pane exiting (`docs/WORKTREES.md`): a pristine
|
|
worktree is removed, one holding unsaved work is left on disk. No recovery
|
|
marker is written. This death is permanent — a later daemon restart's
|
|
`recover()` finds the sidecar, sees no marker, and leaves the session
|
|
`Dead` without attempting to resume it.
|
|
2. **A cancelled turn fails to settle within `CANCEL_GRACE`.** Same
|
|
consequence as (1): the session is marked dead through the same path, no
|
|
marker, no future resumption.
|
|
3. **Graceful daemon shutdown**, while the session was still `Running`.
|
|
`mark_session_dead_after_shutdown` marks the session dead **without**
|
|
applying the worktree exit rule — the tree is kept exactly as it is,
|
|
pristine or not, because there is nothing "natural" about this exit — and
|
|
writes the `.recover` marker. The sidecar on disk therefore says `Dead`,
|
|
but is eligible for resumption.
|
|
|
|
Recovery, on the next daemon's `recover()`: for every `Structured` sidecar
|
|
not already adopted, the marker is consumed. When one was present and the
|
|
sidecar carries a stored `acp_session_id`, the daemon spawns a fresh adapter
|
|
with `SpawnConfig::load_session(acp_session_id)`, replays the
|
|
entire durable event log into a new `Projection` (rebuilding the transcript
|
|
and reduced state exactly as they were, with any restored `Approval` row
|
|
landing as history — never as a re-armed pending approval, since its nonce
|
|
never reached disk), and only then reopens the log for further appends. What
|
|
the adapter streams back while its own `session/load` is in flight is
|
|
discarded rather than appended, so the replayed history is not duplicated
|
|
(see [The ACP handshake](#the-acp-handshake)). Success flips the session back
|
|
to `Running`/`Idle`; failure (no stored `acp_session_id`, or the handshake
|
|
itself failing) leaves it `Dead` with the failure reason as
|
|
`activity_detail` — the same "tell the operator what to fix" pattern as a
|
|
missing adapter binary.
|
|
|
|
Pending approvals and their nonces never survive any of this. They live only
|
|
in the in-memory `pending_approvals` map and the live `ReducedState`, both
|
|
gone the instant the daemon that held them exits. Whatever ACP permission
|
|
request was mid-flight when the old daemon died was already answered
|
|
`Cancelled` on that daemon's own shutdown path, so a resumed session always
|
|
starts with no pending approval.
|
|
|
|
See `docs/RESTART.md` for what a *client* observes across a daemon restart,
|
|
now that structured sessions exist alongside terminal ones.
|