docs/design/2026-08-19-prompt-terminal-ledger-design.md
The daemon's turn terminal events (turn_complete / turn_error) are synthesized by the ACP bridge from agent signals and published over SSE. They are never persisted. After a daemon restart, POST /session/:id/load performs a cold restore whose replay is produced by the agent subprocess re-reading the session JSONL transcript (collectHistoryReplayUpdates → HistoryReplayer), which emits only session_update chunk-class events — never terminal events.
External orchestrators that mediate prompts by id therefore cannot resolve a prompt that was in flight when the daemon died: the replay contract "a terminal event for exactly this promptId" can never be satisfied, and the only safe answer is unknown.
Two building blocks already exist and this design builds on them instead of adding new state machines:
bridge.shutdown() already flushes a formal error terminal (flushPromptTerminals(entry, 'daemon_shutdown', ...)) for every unfinished prompt through publishPromptTerminal — but only to memory and SSE, which die with the process.detectTurnInterruption (packages/core/src/core/turn-interruption.ts) is a pure read-only classifier over an api-history tail that distinguishes a clean tail from interrupted_prompt / interrupted_turn.unknown).acp-bridge for the ledger; ledger writes are pure node:fs and best-effort.Each session owns a sidecar ledger next to its transcript: <sessionRuntimeBaseDir>/projects/<hash(workspaceCwd)>/chats/<sessionId>.ledger.jsonl, resolved by SessionService.getPromptLedgerPath(sessionId). The naming follows the existing <sessionId>.worktree.json sidecar convention and does not match SESSION_FILE_PATTERN, so directory scans ignore it.
Records are single-line JSON objects, append-only:
{"v":1,"promptId":"...","state":"in_flight","at":1692000000000}
{"v":1,"promptId":"...","state":"in_flight","tailUuid":"rec-uuid","at":1692000000000}
{"v":1,"promptId":"...","terminal":"completed","stopReason":"stop","at":1692000000123}
{"v":1,"promptId":"...","terminal":"error","code":"daemon_shutdown","at":1692000000456}
{"v":1,"promptId":"...","terminal":"interrupted","code":"daemon_lost","at":1692000000789}
terminal is one of completed | cancelled | error | interrupted. code carries the flush origin (daemon_shutdown, session_killed, channel_closed, session_closed) or the normalized turn error code; stopReason carries the agent stop reason when present. tailUuid (in_flight only) is the dispatch marker: the uuid of the transcript's last record at admission, best-effort (absent when the transcript is missing/unreadable or for records written before the marker existed).
The reader (readPromptLedgerRecords) tolerates torn tails: lines that fail structural validation are dropped, a missing file reads as empty. The writer (appendPromptLedgerRecord) seals a torn tail before appending: if the file is non-empty and its last byte is not \n (a crash mid-append), a newline is appended first so the next record cannot fuse with the torn fragment — without the seal, one torn tail plus one fresh append loses both records.
danglingInFlightPromptIds reduces records per promptId (last write wins) and returns ids whose latest record is in_flight, in first-appearance order (admission order).
All writes go through the module-level appendPromptLedgerBestEffort helper: any failure is logged via writeStderrLine and swallowed. A ledger problem must never block prompt execution or terminal flush.
sendPrompt pushes onto pendingPromptList, an in_flight record is appended synchronously (write-ahead: the in_flight fact must be on disk before the prompt can produce a terminal). Before the append, the bridge asks the sink for the transcript's last record uuid (transcriptTailUuid, best-effort) and stamps it as tailUuid — the dispatch marker that binds cold-load evidence to this admission (see step 5 below).terminalPublished latch is set inside publishPromptTerminal, the terminal record is appended. Because all four flushPromptTerminals scenarios (channel_closed, closeSession/session_closed, killSession/session_killed, bridge.shutdown/daemon_shutdown) funnel unfinished prompts through publishPromptTerminal, one write point covers graceful shutdown too. daemon_shutdown persistence therefore precedes process exit without any extra sync path beyond the append being synchronous (appendFileSync).acp-bridge must gain no new core coupling for the ledger (the ledger module stays dependency-free beyond node:fs), and the bridge cannot know the serve-layer storage layout. BridgeOptions therefore gains an optional injected sink:
promptLedger?: PromptLedgerSink; // { appendSync, transcriptTailUuid? }
run-qwen-serve.ts assembles it (createPromptLedgerSink(workspaceCwd, sessionRuntimeBaseDir), backed by SessionService.getPromptLedgerPath) and injects it at the three bridge construction sites (primary, secondary, websocket-workspace — the latter skips live-conversation entries, which have no transcript to reconcile against). Reading, reconciliation, and HTTP exposure live in packages/cli/src/serve/prompt-terminal-ledger.ts, which may import core.
Hook: restoreSessionHandler (POST /session/:id/load), after bridge.loadSession resolves and before the response, only when action === 'load' && !restored.attached && !restored.hasActivePrompt && provenance !== 'live-conversation'. Concurrent loads of the same session already coalesce through the existing inFlightRestores map, so reconciliation runs at most once per cold restore.
Algorithm (every step that cannot attribute the tail with confidence returns without appending — fail closed):
unknown (omitted from promptTerminals).target be the oldest dangling id. Attribution guard: walking the ledger forward, skip the in_flight records of prompts that have settled (a terminal record exists for them); the last remaining in_flight record must be target's own admission. Skipping settled prompts matters for [A if, B if, B cancelled] (B queued, then cancelled while A still ran): the tail belongs to A even though B's in_flight line is the later record — a naive "last in_flight must match target" guard would wrongly veto A with B's settled admission. In [if p1, if p2, term p1] (valid interleave: p1 settled while p2 runs, daemon dies) the guard passes and p2 is attributed the tail.loadSession); failure or undefined → return.in_flight record carries tailUuid, the projection must contain that record AND at least one visible write (non-system, with a message) after it. The transcript is append-only, so anything after the marker postdates admission — an identity/ordering check immune to clock skew. A marker absent from the projection, or present with no visible write beyond it, fails closed. Records without a marker (legacy, or capture failure) fall through to the temporal chain below.system records with a message, mirroring SessionApiHistoryAccumulator) must be strictly after target's in_flight at. Measuring the raw stream instead would let evidence the verdict never sees — a post-admission ui_telemetry/custom_title/… record — pass the check for a prompt that never reached the model. An empty message list (or system-only tail) fails the same check. Equality is vetoed too: both clocks are 1 ms-granularity Date.now() reads, so a write landing in the admission millisecond cannot be attributed.chat_compression record carrying a compressedHistory written at or after target's admission → return. The accumulator swaps the whole history for the compressed snapshot, so the verdict's projection no longer carries target's turn and nothing may be attributed. Marker-bearing admissions detect "after admission" by position (any compression record past the marker) so a backward clock step cannot hide the reset; marker-less admissions compare wall clocks.at (same-millisecond equality is vetoed, for the same clock-collision reason as above). Under FIFO admission target's turn can only start after every predecessor settled, so an older tail belongs to that predecessor's turn. This closes the queued-never-dispatched class ([A if, B if(queued), A term] — A's tail predates A's own terminal, so B gets nothing) and the stale-dangling class left by restore paths that skip reconciliation (a later prompt's completed tail predates its own terminal, so the stale prompt gets nothing).code: 'prompt_deadline_exceeded' → return. The deadline path releases the FIFO while the wedged agent is explicitly allowed to keep streaming (DAEMON-003), so that terminal's timestamp does not fence its turn's writes: stale writes can postdate both the terminal and target's admission and the temporal checks above cannot veto them. The veto is deliberately unconditional: the append-only ledger never expires records, so one deadline-exceeded prompt keeps the session fail-closed for every later dangling prompt (a missing terminal, never a wrong one). A recency bound cannot distinguish the stale case from the adjacent-overlap case without daemon-generation tracking, which the ledger does not carry.buildApiHistoryFromConversation) and classify the last TURN_INTERRUPTION_HISTORY_TAIL_COUNT entries with detectTurnInterruption, then apply the id-less tool-call guard: when the verdict is none but the api-history tail's last entry is a model turn holding any functionCall part (with or without an id), upgrade to interrupted — detectTurnInterruption ignores id-less functionCalls because they cannot be paired on the wire, but reconciliation needs no wire pairing; a model tail holding a tool call means the daemon died mid tool-run.
none (clean tail) → append {"terminal":"completed","stopReason":"reconstructed_from_transcript"}.interrupted_prompt / interrupted_turn / upgraded tool-call guard → append {"terminal":"interrupted","code":"daemon_lost"}.loadSession ran appends its in_flight after the snapshot, and the visible tail the verdict was computed from may now belong to it — the verdict must not be stamped onto the old dangling id.unknown.Residual attribution risk. The dispatch marker binds evidence to the target's admission by ORDERING, not by OWNERSHIP: transcript records carry no prompt or writer identity, so any visible write landing after the marker passes the guard regardless of which writer produced it. Ordering-breakage classes are closed wherever the marker is present (a recordless predecessor's tail predates the successor's marker, a backward clock step cannot fake record order, a system-reminder-only tail contains no visible write beyond the marker), but two ownership classes survive even with a marker:
<runtimeBase>/projects/<hash>/chats tree, and /resume lists serve-created sessions without a provenance filter; an interactive turn writes transcript records but no ledger records, invisible to every guard.Closing ownership requires binding transcript records to a writer identity (a transcript-record schema change, tracked in https://github.com/QwenLM/qwen-code/issues/9483); until then these entrances stay fail-open by design limitation, not by evidence. Marker-less admissions — legacy in_flight records written before the marker existed, marker capture failure (unreadable transcript, or a final record larger than the 64 KiB tail window) — additionally fall back to the temporal evidence chain and its documented classes (recordless-predecessor inheritance, backward clock steps, non-model-record tails). Ledger records written before this change never gain a marker retroactively; they stay on the temporal chain permanently.
The serve-layer response type extends BridgeRestoredSession with an optional promptTerminals array (the trailing 64 terminal records, including reconciliation output). The bridge-level BridgeRestoredSession is untouched: the field is serve-layer evidence, so its type lives in the serve layer. When the ledger has no terminal records the field is omitted entirely.
inFlightRestores).terminalPublished latch makes bridge duplicates impossible.archiveSessions / unarchiveSessions move the sidecar alongside the transcript via moveLedgerSidecar (warn-only on failure, both directions log the full source and destination paths). When the destination already exists (a partially completed earlier archive cycle), the source is not clobbered and the move does not wedge: the source contents are appended to the destination (append-only JSONL, write order preserved) and the source is unlinked — merge semantics instead of a permanent split.removeSessionFiles deletes the ledger in both states (active and archived) alongside the worktree sidecars, so removing a session leaves no orphan evidence.DataProcessor.scanChatFiles and usageHistoryService.rebuildFromSessionJsonl select .jsonl files but reject .ledger.jsonl — the ledger is not a transcript and must never be parsed as chat records or usage evidence.The sidecar follows the transcript through every state transition: created on first admission (best-effort), moved alongside on archive/unarchive (merge semantics on collision), and deleted in both states on session removal. All paths are derived from one helper (getPromptLedgerPathForState) so no call site hand-assembles the file name.
Records contain only v, promptId, state/terminal, code, stopReason, at. No prompt text, user content, tool input/output, or file paths are ever written. The ledger inherits the transcript directory's permissions.
| Daemon | Client | Behavior |
|---|---|---|
| new | new | Cold load returns promptTerminals; orchestrators resolve dangling prompts. |
| new | old | Client ignores the unknown field; behavior identical to today. |
| old | new | No ledger file → field omitted; client falls back to unknown as today. |
| old | old | Unchanged. |
Date.now() reads), otherwise the tail belongs to an earlier turn and nothing is appended.prompt_deadline_exceeded terminal of another prompt voids the evidence chain: the deadline path releases the FIFO while the wedged agent may keep writing, so that terminal does not fence its turn's writes. The veto is unconditional and permanent (missing terminals over wrong ones).0o600), matching the transcript's protection; it is never created with umask-default permissions.daemon_shutdown, in_flight recorded for queued admissions and both prompts flushed on shutdown, best-effort failure containment.SessionService fixture: clean tail → completed, interrupted_prompt/interrupted_turn → interrupted, id-less functionCall tail → interrupted, missing transcript → fail closed, no dangling → no-op, multiple dangling → fail closed (nothing appended), settled-then-queued interleave ([A if, B if, B cancelled]) → A attributed, valid interleave ([if p1, if p2, term p1] on a real time axis) → p2 attributed, stale tail (last transcript write predates the admission) → fail closed, queued-never-dispatched ([A if, B if, A term] with A's terminal postdating its turn) → fail closed, stale dangling behind a later settled prompt → fail closed, post-admission compression checkpoint → fail closed, post-admission system-record-only tail → fail closed, idempotence.sessionService: archive/unarchive move the ledger, move failure is warn-only, destination-exists merges instead of wedging, session removal deletes both states, insight/usage scans skip .ledger.jsonl.POST /session/:id/load: field presence, omission without ledger, attached loads skip reconciliation, active prompts skip reconciliation, resume responses stay free of promptTerminals.npm run build and npm run typecheck.