From 3dafc15159185bf8322c77e723dd25b007920bd6 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:47:42 -0700 Subject: [PATCH 01/23] test(claude): the CLI simulator ends its stream cleanly when the CLI exits 0 The simulator turned every CLI exit into a thrown "Claude Code process exited with code N" error, including code 0. The pinned SDK does not: its process transport reports no error for exit code 0 (getProcessExitError in sdk.mjs), so the message iterator simply ends. That natural end of stream is the case the sidecar must handle as a session end (G23, design C08), and the simulator could not produce it. Now a `crash { code: 0 }` step ends the stream with no error and no result, like the real SDK. Other codes and signals still throw the SDK's messages. Evidence, from the pinned CLI 2.1.237 against the local endpoint (probes run through claude-cli-scenarios.mjs, not committed): SIGINT mid-turn makes the CLI report an aborted result, exit 0, and the iterator end with no error; SIGTERM mid-turn makes it exit 143 and the iterator throw "Claude Code process exited with code 143". Red: claude-cli-sim.test.ts "a CLI that exits with code 0 mid-turn ends the stream cleanly" failed with { error: 'Claude Code process exited with code 0' }. Green: claude-cli-sim.test.ts and test/unit/claude-sidecar (117 tests); the real-CLI contract and conformance suite still passes (39 tests). Co-Authored-By: Claude Opus 5.5 --- test/fixtures/claude-cli-sim/program.mjs | 3 ++- test/fixtures/claude-cli-sim/query.mjs | 7 +++++++ test/integration/providers/claude-cli-sim.test.ts | 12 ++++++++++++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/test/fixtures/claude-cli-sim/program.mjs b/test/fixtures/claude-cli-sim/program.mjs index 174d17110..3e45e9f83 100644 --- a/test/fixtures/claude-cli-sim/program.mjs +++ b/test/fixtures/claude-cli-sim/program.mjs @@ -26,7 +26,8 @@ // completion { subtype?, text?, errors? } end the turn with this result subtype // api_error { status?, message? } the model request fails // hang {} the model request never completes -// crash { signal? | code? } the CLI process dies (default SIGKILL) +// crash { signal? | code? } the CLI process dies (default SIGKILL); +// with code 0 the stream ends with no error // stall_stdin {} the host process stops reading stdin // cli_turn { afterMs? } a background task finishes later and // starts a CLI-initiated turn diff --git a/test/fixtures/claude-cli-sim/query.mjs b/test/fixtures/claude-cli-sim/query.mjs index 03da98f9c..97ce3974b 100644 --- a/test/fixtures/claude-cli-sim/query.mjs +++ b/test/fixtures/claude-cli-sim/query.mjs @@ -324,6 +324,13 @@ class SimulatedCli { onCliExit(code, signal) { this.cliExited = true if (this.closing || this.dead) return + // Like the SDK's process transport (getProcessExitError): a CLI that exits + // with code 0 is no error, so the message stream just ends. A real CLI + // does that, for one, after SIGINT (an aborted result, then exit 0). + if (code === 0 && !signal && !this.bootError) { + this.terminate(undefined) + return + } const message = this.bootError ?? (signal ? `Claude Code process terminated by signal ${signal}` : `Claude Code process exited with code ${code}`) this.terminate(new Error(message)) diff --git a/test/integration/providers/claude-cli-sim.test.ts b/test/integration/providers/claude-cli-sim.test.ts index 71996c285..eb47bfa31 100644 --- a/test/integration/providers/claude-cli-sim.test.ts +++ b/test/integration/providers/claude-cli-sim.test.ts @@ -190,6 +190,18 @@ describe('Claude CLI simulator programs', () => { expect(exited.ended).toEqual({ error: 'Claude Code process exited with code 3' }) }) + it('a CLI that exits with code 0 mid-turn ends the stream cleanly, with no error and no result', async () => { + // The SDK reports no error for exit code 0 (sdk.mjs getProcessExitError), + // so its message iterator simply ends: the natural end of stream that the + // sidecar must treat as a session end (G23). + const s = await open({}, program([{ on: 'msg:prompt', emit: [{ kind: 'text', data: { text: 'partial' } }, { kind: 'crash', data: { code: 0 } }] }])) + s.prompt.push('go') + await s.until(() => s.ended !== null) + expect(s.ended).toEqual({}) + expect(s.messages.some((m) => m.type === 'assistant')).toBe(true) + expect(results(s.messages)).toEqual([]) + }) + it('a hang rule keeps the turn open until an interrupt aborts it', async () => { const s = await open({}, program([{ on: 'msg:prompt', emit: [{ kind: 'hang' }] }])) s.prompt.push('wait forever', '22222222-2222-4222-8222-222222222222') From 7e2b75edc0048c2b72ad22664331ecd3e00af160 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Thu, 22 Oct 2026 13:30:24 -0700 Subject: [PATCH 02/23] fix(freshclaude): the sidecar exits when its SDK stream ends (G02, G23) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design C08 (docs/plans/2026-10-22-freshclaude-core-runtime.md §5.1, §5.7, §10 PR-B). Each sidecar hosts one session, but when that session's SDK stream ended (the CLI died, or it exited 0) the Node sidecar stayed alive with no session: the server never evicted it, later sends got "session not found", and nothing recovered (G02). And the server assumed every turn ends with a result plus a trailing idle, so a stream that ended mid-turn after an earlier result left the session busy forever (G23). Sidecar (crates/freshell-claude-sidecar): - consumeStream's finally: on an end nobody asked for, cancel the parked cards, ring a result-less turn end (interim, until C18), send the stream-end idle (interim, until the exited status of C09 replaces it), then `sdk.stream.ended {sessionId, reason, message}` as the LAST line, and exit with code 3 once stdout and stderr have flushed. Requests that arrive after that are ignored. A requested teardown (shutdown, SIGTERM, stdin closing) exits 0 and reports nothing. - New pure stream-end.mjs: reason `cli_exited` (the SDK's "Claude Code process terminated by signal …" / "… exited with code …"), `stream_error` (anything else thrown), `stream_eof` (no error); the message is cut to 1000 characters. The `stream.ended` log record uses the same reasons (warn with the error, info for an end of stream). - The catch's `sdk.error "SDK error: …"` is gone: the cause rides sdk.stream.ended and the server's exit report, so the user sees one banner. Server (crates/freshell-freshagent): - Deleted `result_idle_pair_pending`, its doc comment, its plumbing through create, attach-resume and rollback, and its tests. An idle now always folds as a terminal edge; the real sidecar only sends one at its stream end. - The consumer keeps the sidecar's `sdk.stream.ended` (never broadcast) and the end-of-output arm now reaps the evicted sidecar for its exit status (no zombie left) and reports `freshAgent.error{SIDECAR_EXITED}` with a `cause` ({reason, message} from the stream end, or for a Node death {reason: sidecar_exited, message, exitCode | signal}); the message names the cause. Logged as `freshagent.claude.sidecar_exit` (warn: reason, cause, exit status, turn in flight, rings). - Interim ring rule (§5.7): the server rings only when no stream end was reported (a Node death mid-turn); the sidecar already rang a reported one. Exactly one edge either way. - New small module claude_sidecar_exit.rs (StreamEnded, SidecarExitCause, death_rings, reap) with unit tests. Fakes stop emitting a per-turn idle (they now end a turn the way the real sidecar does, with its result): the in-crate fake's interrupt settle, the e2e fake claude-SDK sidecar (completion and interrupt), the rollback test fake (sent idle only; now a result, and the test waits for it) and the e2e fake-claude-sidecar.mjs (sent turn.complete + idle; now result + edge). The in-crate tests that injected a result plus a trailing idle inject the result alone. The e2e fake's stream-error lane now models the real end of stream and exits 3; its unit tests are rewritten to that behavior. Tests (red first): - test/unit/claude-sidecar/stream-end.test.ts (new): a simulator CLI crash, a natural end mid-turn (CLI exit 0) and a non-death stream error each end with sdk.stream.ended last and exit code 3 (red: "the sidecar did not exit within 10000 ms", 4 cases); an idle CLI death exits with no edge; shutdown and stdin closing mid-turn exit 0 with nothing alert-related; streamEnd on the exact SDK strings. - crates/freshell-ws/tests/freshagent_claude_lifecycle.rs (new, RealSidecarSim): a CLI crash mid-turn gives exactly one turn.complete and SIDECAR_EXITED with cause cli_exited, and the sidecar process is gone; a natural end of stream mid-turn ends with status idle and no live session (not busy); a Node SIGKILL mid-turn rings exactly once with cause sidecar_exited/SIGKILL; an idle CLI death reports the exit with no edge. Red on the parent commit: the three stream-end cases time out waiting for SIDECAR_EXITED (the sidecar never exits), the Node death has no cause. - claude.rs a_stream_end_idle_after_an_earlier_result_releases_the_busy_truth (new, G23): red on the parent commit ("in_turn never became false"). - Rewritten to the new protocol: sidecar-log.test.ts (reason cli_exited for a SIGKILL; new: an end of stream logs at info with no error), interrupt-idle-gate.test.ts (waits for sdk.stream.ended, not sdk.error), fake-claude-sdk-sidecar-control.test.ts (no per-turn idle; the stream-error lane exits 3), and the e2e STREAM_ERROR lane in fresh-agent-error-turn-attention.spec.ts now expects one edge, the SIDECAR_EXITED error on the pane, sdk.stream.ended last and the sidecar process gone. - The rig keeps its server's claude lane (`ClaudeRig::fresh_claude`) and gains `RigConnection::read_for` for bounded "nothing more arrives" checks. Green: cargo test -p freshell-ws (1205 passed); cargo test -p freshell-freshagent --lib (1335 passed; 1 failure, session_handoff::tests::an_opencode_placeholder_handoff_resolves_the_durable_owner_and_completes, passes alone: a pre-existing HOME race with claude_snapshot.rs tests that remove HOME); vitest test/unit/claude-sidecar + test/unit/sidecar (all pass); e2e helpers fake-claude-sdk-sidecar-control (11 passed); clippy -D warnings and fmt clean. Deviations from the design text: - The stream-end `sdk.status idle` is still sent before sdk.stream.ended (staging rule §10.2): today it is what takes a compacting pane out of its busy state; C09's exited status replaces it. - `sdk.exit` stays on the requested (abort) path until C18 removes it. - The server reaps the sidecar to read its exit status, which the design's "or the exit status" cause needs. Co-Authored-By: Claude Opus 5.5 --- crates/freshell-claude-sidecar/index.mjs | 117 +++-- crates/freshell-claude-sidecar/stream-end.mjs | 42 ++ crates/freshell-freshagent/src/claude.rs | 477 ++++++------------ .../src/claude_sidecar_exit.rs | 336 ++++++++++++ crates/freshell-freshagent/src/lib.rs | 1 + crates/freshell-ws/tests/common/claude_rig.rs | 30 +- .../tests/freshagent_claude_lifecycle.rs | 249 +++++++++ .../tests/freshagent_claude_rollback.rs | 21 +- .../fixtures/fake-claude-sidecar.mjs | 7 +- .../providers/fake-claude-sdk-sidecar.mjs | 58 ++- .../fake-claude-sdk-sidecar-control.test.ts | 51 +- .../fresh-agent-error-turn-attention.spec.ts | 73 ++- .../interrupt-idle-gate.test.ts | 2 +- test/unit/claude-sidecar/sidecar-log.test.ts | 10 +- test/unit/claude-sidecar/stream-end.test.ts | 141 ++++++ 15 files changed, 1167 insertions(+), 448 deletions(-) create mode 100644 crates/freshell-claude-sidecar/stream-end.mjs create mode 100644 crates/freshell-freshagent/src/claude_sidecar_exit.rs create mode 100644 crates/freshell-ws/tests/freshagent_claude_lifecycle.rs create mode 100644 test/unit/claude-sidecar/stream-end.test.ts diff --git a/crates/freshell-claude-sidecar/index.mjs b/crates/freshell-claude-sidecar/index.mjs index 062f6a798..a748f078f 100644 --- a/crates/freshell-claude-sidecar/index.mjs +++ b/crates/freshell-claude-sidecar/index.mjs @@ -34,14 +34,17 @@ // { type:'sdk.turn.complete', sessionId, at } // EVERY result (any subtype) except // the stopped turn's own aborted result (an accepted user interrupt's mark // silences only that — turn-complete-gate.mjs); also minted from consumeStream's finally -// when a turn ends with NO result frame (stream exception or natural EOF +// when a turn ends with NO result frame (the CLI died or the stream ended // with a send still pending) — the abort/sdk.exit requested-teardown path // stays silent // { type:'sdk.turn.waiting', sessionId, at } // 0->>=1 pending edge (written by this sidecar; codex controls emits its own waiting edge outside the sidecar lane) // { type:'sdk.status', sessionId, status } // compacting / idle (stream end) // { type:'sdk.error', sessionId, message, turnFailure? } // turnFailure: a failed turn's result // (sanitized text, no [ede_diagnostic] lines); never for the user's own Stop -// { type:'sdk.exit', sessionId } +// { type:'sdk.stream.ended', sessionId, reason, message } // the session's SDK stream ended +// unrequested (reason cli_exited | stream_error | stream_eof, stream-end.mjs): +// always the LAST line; the sidecar then exits with code 3 (G02) +// { type:'sdk.exit', sessionId } // a requested teardown (abort) // { type:'sdk.permission.request', sessionId, requestId, subtype:'can_use_tool', tool:{name,input}, toolUseID, suggestions, blockedPath, decisionReason } // { type:'sdk.question.request', sessionId, requestId, questions } // { type:'sdk.permission.cancelled', sessionId, requestId } @@ -60,11 +63,15 @@ // from this process's own stdout: `sdk.turn.complete` rides every SDK // `result` (the unified needs-attention edge — only the stopped turn's own // aborted result is silenced, by the accepted-interrupt mark) plus the -// consumeStream finally mint for a result-less turn end (stream exception or -// natural EOF with a send pending; never the aborted/sdk.exit teardown). If -// the PROCESS dies mid-turn, stdout simply ends and no completion is ever -// written — that unrequested death is what the Rust-side death synthesis -// (ADR Decision 2.1) rings for. +// consumeStream finally mint for a result-less turn end (the CLI died or the +// stream ended with a send pending; never the aborted/sdk.exit teardown). +// +// Process lifetime: one sidecar hosts one session. When that session's SDK +// stream ends without a request, the sidecar writes `sdk.stream.ended` last +// and exits with code 3, so the Rust end-of-output path ends the session. A +// requested teardown (shutdown, SIGTERM, stdin closing) exits 0 and reports +// nothing. If the PROCESS itself dies mid-turn, stdout simply ends with no +// `sdk.stream.ended` — the one end the Rust side rings for itself. // // Scope discipline (ADR "keep it minimal — only what the claude T2 invariant set // needs"): the freshell MCP-server injection (createClaudeSdkMcpServers) is @@ -89,6 +96,7 @@ import { managedSdkSpawn } from './exec-profile.mjs' import { hostedMcpServers } from './hosted-mcp.mjs' import { cliStderrLogger, errorText, log } from './log.mjs' import { isHostedSidecar } from './hosted.mjs' +import { STREAM_ENDED_EXIT_CODE, streamEnd } from './stream-end.mjs' import { canUseTool as routeCanUseTool, cancelPending, @@ -107,6 +115,11 @@ function emit(msg) { process.stdout.write(`${JSON.stringify(msg)}\n`) } +/** Set once the session's stream has ended unrequested: the sidecar is exiting. */ +let exiting = false +/** How long an exiting sidecar waits for stdout and stderr to flush. */ +const EXIT_FLUSH_BUDGET_MS = 2000 + // ── nanoid()-compatible bare id: 21 url-safe chars ([A-Za-z0-9_-]) ─────────── // Faithful shape of server/sdk-bridge.ts's `const sessionId = nanoid()` (the // placeholder the claude adapter surfaces verbatim). The T2 invariant checks the @@ -337,44 +350,64 @@ async function consumeStream(sessionId, sdkQuery) { let failure try { for await (const msg of sdkQuery) handleSdkMessage(sessionId, msg) - } catch (err) { - failure = err - emit({ type: 'sdk.error', sessionId, message: `SDK error: ${err?.message || 'Unknown error'}` }) + } catch (error) { + failure = { error } } finally { - // A requested teardown (abort) is routine; any other error end is the CLI - // dying or failing under the session. - if (st?.abort.signal.aborted) log('info', 'stream.ended', { sessionId, reason: 'aborted' }) - else if (failure) log('warn', 'stream.ended', { sessionId, reason: 'stream_error', error: errorText(failure) }) - else log('info', 'stream.ended', { sessionId, reason: 'stream_eof' }) - // Stream ended (natural end, error, or abort). The transport is closing: - // clear any parked cards WITHOUT resolving their promises (LB-04: a late - // resolve lands inside the SDK's floating promise chain → unhandled - // rejection → crash under Node 22 throw-mode). - if (st) { - cancelPending(st, emit, sessionId, { resolveDeny: false }) - } - // A turn that ends with NO result frame — stream exception OR natural - // EOF with a result still pending — IS a turn end the user didn't - // witness: ring the unified edge (the abort/sdk.exit path is a - // REQUESTED teardown and deliberately stays silent). pendingResults - // covers the accept→first-message window turnOpen cannot see, and - // counts queued sends a boolean would lose. A pending Stop mark does not - // silence it: only an aborted result is the stopped turn's own - // (turn-complete-gate.mjs), and this end has no result. - if (st && st.pendingResults > 0 && !st.abort.signal.aborted) { - const at = nextMonotonic(st.lastTurnCompleteAt, Date.now()) - st.lastTurnCompleteAt = at - emit({ type: 'sdk.turn.complete', sessionId, at }) - } - // Mirror SdkBridge: an aborted session surfaces sdk.exit; a natural end - // surfaces an idle status. NEITHER is a completion chime, so a mid-turn - // death cannot fake a turn.complete. - if (st?.abort.signal.aborted) emit({ type: 'sdk.exit', sessionId }) - else emit({ type: 'sdk.status', sessionId, status: 'idle' }) + // An abort is a requested teardown (shutdown); any other end is the CLI + // dying under the session or ending it. + const requested = st?.abort.signal.aborted === true + const ended = requested ? undefined : streamEnd(failure) + if (requested) log('info', 'stream.ended', { sessionId, reason: 'aborted' }) + else log(failure ? 'warn' : 'info', 'stream.ended', { sessionId, reason: ended.reason, ...(failure ? { error: ended.message } : {}) }) + // The transport is closing: clear any parked cards WITHOUT resolving their + // promises (LB-04: a late resolve lands inside the SDK's floating promise + // chain → unhandled rejection → crash under Node 22 throw-mode). + if (st) cancelPending(st, emit, sessionId, { resolveDeny: false }) sessions.delete(sessionId) + if (requested) { + emit({ type: 'sdk.exit', sessionId }) + } else { + // Interim, until Rust mints every edge (C18): a turn that ends with NO + // result frame — the CLI died, or the stream ended, with a result still + // pending — is a turn end the user didn't witness, so it rings here. + // pendingResults covers the accept→first-message window and counts + // queued sends. A pending Stop mark does not silence it: only an aborted + // result is the stopped turn's own (turn-complete-gate.mjs). Rust rings + // only for a death that reports no stream end, so this edge is the one. + if (st && st.pendingResults > 0) { + const at = nextMonotonic(st.lastTurnCompleteAt, Date.now()) + st.lastTurnCompleteAt = at + emit({ type: 'sdk.turn.complete', sessionId, at }) + } + // Interim, until the exited status replaces it (C09): the idle status + // takes a compacting pane out of its busy state. + emit({ type: 'sdk.status', sessionId, status: 'idle' }) + // The end of the stream is the end of this sidecar (G02): report how + // the stream ended, last, and exit, so the server's end-of-output path + // ends the session instead of keeping a sidecar with no session. + emit({ type: 'sdk.stream.ended', sessionId, ...ended }) + exitAfterStreamEnd(st) + } } } +/** + * After an unrequested stream end: read no more requests (sdk.stream.ended + * stays the last line), make sure the CLI is gone, and exit with + * STREAM_ENDED_EXIT_CODE once stdout and stderr have flushed. + */ +function exitAfterStreamEnd(st) { + if (exiting) return + exiting = true + try { st?.query?.close?.() } catch (error) { log('warn', 'stream.close_failed', { error: errorText(error) }) } + let unflushed = 2 + const flushed = () => { if (--unflushed === 0) process.exit(STREAM_ENDED_EXIT_CODE) } + process.stdout.write('', flushed) + process.stderr.write('', flushed) + // A pipe that never drains must not keep a sidecar with no session alive. + setTimeout(() => process.exit(STREAM_ENDED_EXIT_CODE), EXIT_FLUSH_BUDGET_MS) +} + // ── request handlers ───────────────────────────────────────────────────────── function handleCreate(req) { const requestId = req.requestId @@ -610,6 +643,8 @@ function shutdown() { // ── stdin dispatch loop (newline-JSON) ─────────────────────────────────────── const rl = createInterface({ input: process.stdin }) rl.on('line', (line) => { + // An exiting sidecar has no session left: sdk.stream.ended stays its last line. + if (exiting) return const trimmed = line.trim() if (!trimmed) return let req @@ -659,7 +694,7 @@ rl.on('line', (line) => { }) // stdin closed (Rust reaped us): abort every query and exit so no claude CLI // grandchild is left mid-stream. -rl.on('close', () => { shutdown(); process.exit(0) }) +rl.on('close', () => { if (!exiting) { shutdown(); process.exit(0) } }) process.on('SIGTERM', () => { shutdown(); process.exit(0) }) process.on('SIGINT', () => { shutdown(); process.exit(0) }) diff --git a/crates/freshell-claude-sidecar/stream-end.mjs b/crates/freshell-claude-sidecar/stream-end.mjs new file mode 100644 index 000000000..330624c52 --- /dev/null +++ b/crates/freshell-claude-sidecar/stream-end.mjs @@ -0,0 +1,42 @@ +// stream-end.mjs — how a session's SDK message stream ended, as the sidecar +// reports it in its last stdout line (docs/plans/ +// 2026-10-22-freshclaude-core-runtime.md §5.1 and §5.7). +// --------------------------------------------------------------------------- +// Each sidecar hosts one session, so an end of its SDK stream that nobody asked +// for is the end of the sidecar: index.mjs emits +// `sdk.stream.ended { sessionId, reason, message }` and exits with +// STREAM_ENDED_EXIT_CODE. The Rust server's end-of-output path then ends the +// session (and reports the cause to the user). A requested teardown (shutdown, +// stdin closing) is not a stream end and is never reported here. +// +// Reasons: +// cli_exited the CLI process died: the SDK's iterator threw its +// "Claude Code process terminated by signal …" or +// "… exited with code …" error (T14). +// stream_error the iterator threw anything else. +// stream_eof the iterator ended with no error: the CLI exited with code 0 +// (the SDK reports no error for that exit code). + +/** The sidecar's exit code after an SDK stream end it did not ask for. */ +export const STREAM_ENDED_EXIT_CODE = 3 + +/** The most characters of an error message the frame carries (the SDK appends the CLI's stderr tail). */ +export const STREAM_END_MESSAGE_MAX_CHARS = 1000 + +const CLI_EXIT = /^Claude Code process (?:terminated by signal|exited with code) / + +const EOF_MESSAGE = 'The Claude CLI ended the session without reporting an error.' + +/** + * `{ reason, message }` for a stream end. `failure` is `{ error }` when the + * SDK's iterator threw `error`, or undefined when it ended normally. + */ +export function streamEnd(failure) { + if (!failure) return { reason: 'stream_eof', message: EOF_MESSAGE } + const error = failure.error + const text = String(error?.message || error) + return { + reason: CLI_EXIT.test(text) ? 'cli_exited' : 'stream_error', + message: text.slice(0, STREAM_END_MESSAGE_MAX_CHARS), + } +} diff --git a/crates/freshell-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index 39108c52e..541cfe8f1 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -645,23 +645,6 @@ struct ClaudeSession { /// is gone). /// Carried across the rollback's kill+respawn exactly like `in_turn`. turn_tracker: Arc>, - /// Focused-review ep2-r1 F1: the paired-terminal-frames mark. The - /// supported protocol closes EVERY turn with `sdk.result` AND a trailing - /// `sdk.status:idle` (both provider fixtures emit exactly that pair; the - /// real sidecar's consumeStream finally emits the trailing idle). The - /// idle is the SAME turn's closing punctuation, never a new op's edge — - /// set by the consumer after folding ANY `sdk.result`, consumed by the - /// NEXT `sdk.status:idle` which skips the fold (a second edge's - /// attribution would double-count the turn — the reviewer's exact repro: - /// A's trailing idle misattributed to a queued garlanded send fired the - /// FIFO-drop branch and released the busy gate over still-queued work). - /// Arms/sends NEVER reset it (a fresh op submitted between a turn's - /// result and its trailing idle must not let that idle fold); an - /// in-stream `sdk.error` DOES reset it (the fail-closed `in_turn` needs a - /// LIVE terminal edge after the error — the trailing idle must fold - /// there), and sidecar EOF zeroes it with the tracker set. Carried across - /// the rollback's kill+respawn exactly like `in_turn`. - result_idle_pair_pending: Arc, /// Task 5 (unified needs-attention signal): the session's Rust-side /// completion clock — an UNREQUESTED sidecar death with a turn in flight /// synthesizes the `freshAgent.turn.complete` edge the dead sidecar could @@ -670,9 +653,11 @@ struct ClaudeSession { /// its process (and resets across a rollback fork, same as the sidecar's /// own per-session state); wall-clock advancement orders the Rust-minted /// `at`s against the sidecar-minted ones — the accepted regime, same as - /// Task 3. Minted ONLY by the consumer's unrequested-death arm: requested - /// deaths (kill/shutdown/teardown/rollback fork) never reach it, and an - /// idle death (no armed turn) mints nothing. + /// Task 3. Minted ONLY by the consumer's unrequested-death arm, and only + /// for a death that reported no stream end (`sdk.stream.ended`: the + /// sidecar already rang that one itself — the interim rule until C18): + /// requested deaths (kill/shutdown/teardown/rollback fork) never reach it, + /// and an idle death (no armed turn) mints nothing. last_synthesized_complete_at: Arc>>, /// kata 1wxv Task 4 (r2 serialization discipline): ONE per-session async turn /// lock. `handle_rollback` holds it across the WHOLE handler (busy-check → @@ -2605,7 +2590,6 @@ impl FreshClaudeState { let pending = Arc::new(std::sync::Mutex::new(ClaudePending::default())); let in_turn = Arc::new(std::sync::atomic::AtomicBool::new(false)); let turn_tracker = Arc::new(std::sync::Mutex::new(TurnTracker::default())); - let result_idle_pair_pending = Arc::new(std::sync::atomic::AtomicBool::new(false)); // Task 5: the fresh session's death-mint clock (see the field doc). let last_synthesized_complete_at = Arc::new(std::sync::Mutex::new(None)); let turn_lock = Arc::new(TokioMutex::new(())); @@ -2622,7 +2606,6 @@ impl FreshClaudeState { Arc::clone(&last_status), Arc::clone(&in_turn), Arc::clone(&turn_tracker), - Arc::clone(&result_idle_pair_pending), Arc::clone(&last_synthesized_complete_at), None, provenance, @@ -2678,7 +2661,6 @@ impl FreshClaudeState { pending, in_turn, turn_tracker, - result_idle_pair_pending, last_synthesized_complete_at, turn_lock, rollback_probe_slot: Arc::new(std::sync::Mutex::new(None)), @@ -5551,7 +5533,6 @@ impl FreshClaudeState { durable_id, in_turn, turn_tracker, - result_idle_pair_pending, turn_lock, session_type, last_synthesized_complete_at, @@ -5562,7 +5543,6 @@ impl FreshClaudeState { s.cli_session_id.clone().unwrap_or_else(|| map_key.clone()), s.in_turn.clone(), s.turn_tracker.clone(), - s.result_idle_pair_pending.clone(), s.turn_lock.clone(), session_type_str(op.session_type), // Task 5: the fork continues the SAME per-session @@ -6557,7 +6537,6 @@ impl FreshClaudeState { Arc::clone(&last_status), in_turn.clone(), turn_tracker.clone(), - result_idle_pair_pending.clone(), Arc::clone(&last_synthesized_complete_at), Some(RollbackAdoption { supersedes: durable_id.clone(), @@ -6591,7 +6570,6 @@ impl FreshClaudeState { pending, in_turn: in_turn.clone(), turn_tracker: turn_tracker.clone(), - result_idle_pair_pending: result_idle_pair_pending.clone(), last_synthesized_complete_at, turn_lock: turn_lock.clone(), rollback_probe_slot: Arc::new(std::sync::Mutex::new(None)), @@ -7398,7 +7376,6 @@ impl FreshClaudeState { let pending = Arc::new(std::sync::Mutex::new(ClaudePending::default())); let in_turn = Arc::new(std::sync::atomic::AtomicBool::new(false)); let turn_tracker = Arc::new(std::sync::Mutex::new(TurnTracker::default())); - let result_idle_pair_pending = Arc::new(std::sync::atomic::AtomicBool::new(false)); // Task 5: the resumed session's death-mint clock (see the field doc). let last_synthesized_complete_at = Arc::new(std::sync::Mutex::new(None)); let turn_lock = Arc::new(TokioMutex::new(())); @@ -7416,7 +7393,6 @@ impl FreshClaudeState { Arc::clone(&last_status), Arc::clone(&in_turn), Arc::clone(&turn_tracker), - Arc::clone(&result_idle_pair_pending), Arc::clone(&last_synthesized_complete_at), None, // Conn-less lane (D8): attach-resume is not a create; the ledger @@ -7442,7 +7418,6 @@ impl FreshClaudeState { pending, in_turn, turn_tracker, - result_idle_pair_pending, last_synthesized_complete_at, turn_lock, rollback_probe_slot: Arc::new(std::sync::Mutex::new(None)), @@ -8469,11 +8444,11 @@ impl FreshClaudeState { // turn lock, the promote/retire/disarm folds live here). `in_turn` is // its DERIVED busy cache — this task's folds recompute it from // [`TurnTracker::busy`] at every terminal edge: - // (a) result/unpaired-idle while a compact was PROMOTED (manual-confirmed + // (a) result/idle while a compact was PROMOTED (manual-confirmed // by its completion boundary, ep3-r1 F1) — the // compact's own terminal edge: retire `running`, hold busy while // anything remains outstanding (ep1-r3 F1); - // (b) the FIFO-drop peel — result/unpaired-idle with NO running op and + // (b) the FIFO-drop peel — result/idle with NO running op and // an UNPROMOTED compact at the queue head: the compact provably // dropped, evidenced by the oldest queued Turn behind it (peel // leading compacts up to that Turn; compacts queued BEHIND the send @@ -8483,14 +8458,9 @@ impl FreshClaudeState { // own terminal edge or EOF retires it), or EOF (the queue died // with the sidecar) zeroes everything. turn_tracker: Arc>, - // ep2-r1 F1: the paired-terminal-frames mark — see - // [`ClaudeSession::result_idle_pair_pending`]. This consumer sets it - // after folding ANY `sdk.result`, and the NEXT `sdk.status:idle` - // consumes it to skip the fold (pair punctuation, never an edge). - result_idle_pair_pending: Arc, // Task 5 (unified needs-attention signal): the session's Rust-side // completion clock — see [`ClaudeSession::last_synthesized_complete_at`]. - // Held by the consumer (like the latch above) so the unrequested-death + // Held by the consumer (like the tracker above) so the unrequested-death // arm below can mint AFTER the eviction has already torn the session // record out of the map. last_synthesized_complete_at: Arc>>, @@ -8553,6 +8523,9 @@ impl FreshClaudeState { let _ = adoption.adopted_tx.send(None); } } + // The sidecar's report of its SDK stream's end (its last line before it + // exits), read by the end-of-output path below. + let mut stream_ended: Option = None; while let Ok(Some(line)) = reader.next_line().await { let trimmed = line.trim(); if trimmed.is_empty() { @@ -8561,6 +8534,10 @@ impl FreshClaudeState { let Ok(value) = serde_json::from_str::(trimmed) else { continue; }; + if let Some(ended) = crate::claude_sidecar_exit::StreamEnded::from_line(&value) { + stream_ended = Some(ended); + continue; + } // kata 1wxv Task 4 busy-truth clear edges — IN-STREAM arms (a) and // (b): `sdk.result` with ANY subtype ends the turn (r2: the earlier // success-only wording is void), and `sdk.status:idle`. NO other @@ -8578,10 +8555,10 @@ impl FreshClaudeState { // outstanding op's own edge. Any `sdk.error` clears the QUEUE // outright (the queued ops provably never arrive) while leaving // the running op / `in_turn` fail-closed untouched. - // ep2-r1 F1 (the paired terminal frames): EVERY turn closes with - // `sdk.result` AND a trailing `sdk.status:idle` — the idle is - // the SAME turn's punctuation, never a second edge; the fold - // below skips the fold for exactly that paired idle. + // A turn ends with its `sdk.result` alone: the real sidecar + // sends `sdk.status:idle` only when its stream ends, right + // before `sdk.stream.ended` and its exit (G23), so an idle is + // never a result's trailing punctuation and always folds. // ep4-r2 F2: the fold NEVER serializes behind rollback's // turn_lock (blocking it let rollback absorb compact debt and // then miss a compaction that started in the sidecar behind @@ -8638,23 +8615,11 @@ impl FreshClaudeState { } Some("sdk.result") => { fold_terminal_edge(&in_turn, &turn_tracker); - // A result's trailing idle is its pair punctuation the - // idle arm below skips (ep2-r1 F1). - result_idle_pair_pending.store(true, std::sync::atomic::Ordering::SeqCst); } Some("sdk.status") if value.get("status").and_then(Value::as_str) == Some("idle") => { - // ep2-r1 F1: the idle trailing a result is the SAME - // turn's closing punctuation — NEVER a new op's edge - // (folding it would double-attribute the turn: the - // reviewer's exact repro fired the drop branch on - // A's trailing idle while C/S sat queued). - if !result_idle_pair_pending - .swap(false, std::sync::atomic::Ordering::SeqCst) - { - fold_terminal_edge(&in_turn, &turn_tracker); - } + fold_terminal_edge(&in_turn, &turn_tracker); } Some("sdk.status") if value.get("status").and_then(Value::as_str) == Some("compacting") => @@ -8768,21 +8733,18 @@ impl FreshClaudeState { // The queued ops provably never arrive as // their own turns — clear the QUEUE outright (the running // op and `in_turn` stay fail-closed for its own terminal - // edge or EOF to retire). ep2-r1 F1: the pair mark - // resets TOO — the fail-closed `in_turn` needs a LIVE - // terminal edge after the error; a result's trailing - // idle past an error must fold, never be skipped. + // edge or EOF to retire). let session_dead = value.get("sessionNotFound").and_then(Value::as_bool) == Some(true); { let mut tracker = turn_tracker.lock().expect("turn tracker lock"); tracker.queued.clear(); if session_dead { - // ep3-r2 F2: the provider-side session is DEAD — - // the sidecar (handleSend/handleInterrupt) - // answers `sdk.error` and keeps the long-lived - // stdout OPEN, so NO terminal edge or EOF will - // ever retire what remains. Every tracked op — + // ep3-r2 F2: the sidecar does not host the + // named session (handleSend/handleInterrupt + // answer `sdk.error` with stdout still OPEN), + // so NO terminal edge or EOF will ever retire + // what remains. Every tracked op — // including `running` — can never complete: // retire the whole tracker and open the gate // (else the pane wedges BUSY forever after an @@ -8792,7 +8754,6 @@ impl FreshClaudeState { in_turn.store(false, std::sync::atomic::Ordering::SeqCst); } } - result_idle_pair_pending.store(false, std::sync::atomic::Ordering::SeqCst); } _ => {} } @@ -8912,23 +8873,23 @@ impl FreshClaudeState { tracker.queued.clear(); tracker.compact_candidate = false; } - result_idle_pair_pending.store(false, std::sync::atomic::Ordering::SeqCst); - // Consumer exit == this sidecar's stdout closed == sidecar death - // (ledger A9). Evict the dead session and its cli_index entries, - // identity-guarded: a newer session re-registered under the same - // map key (attach-resume, Task 6) has a DIFFERENT - // sidecar_session_id and must not be evicted by this stale consumer. + // Consumer exit == this sidecar's stdout closed == the sidecar ended + // (ledger A9): it exits after reporting its SDK stream's end + // (`sdk.stream.ended`, G02), or the Node process died. Evict the dead + // session and its cli_index entries, identity-guarded: a newer session + // re-registered under the same map key (attach-resume, Task 6) has a + // DIFFERENT sidecar_session_id and must not be evicted by this stale + // consumer. let evicted = { let mut map = sessions.lock().await; match map.get(&session_id) { Some(s) if s.sidecar_session_id == sidecar_session_id => { - map.remove(&session_id); - true + map.remove(&session_id) } - _ => false, + _ => None, } }; - if evicted { + if let Some(mut evicted) = evicted { // Focused-ep5-r1 Finding 1: the aliases are DEMOTED to the // retained alias tombstones, never dropped — the client closes // a crashed pane by its ORIGINAL bare placeholder, and a kill @@ -8955,46 +8916,49 @@ impl FreshClaudeState { "freshclaude/consumer-exit", ); } + // Reap the sidecar for its exit status: the cause of a death that + // reported no stream end, and no zombie left behind. + let status = crate::claude_sidecar_exit::reap(&mut evicted.child).await; + let cause = + crate::claude_sidecar_exit::SidecarExitCause::new(stream_ended.take(), status); // Adapter-asymmetry fix (bug-hunt pbh-20260807): an UNREQUESTED sidecar - // death must never be TOTAL SILENCE. The codex sibling broadcasts its - // crash self-heal `exited` status (`codex.rs spawn_exit_watcher`) and - // opencode's turn task emits an unconditional `idle` - // (`opencode_ws.rs run_turn`); the reference bridge broadcasts an - // explicit idle for exactly this edge so the pane doesn't stay stuck - // blue (`server/sdk-bridge.ts:344-353`). Claude alone dropped the pane - // into a forever-"working" wedge. Broadcast the `freshAgent.error` + // end must never be TOTAL SILENCE. Broadcast the `freshAgent.error` // shape the client folds into a visible banner AND a - // running/streaming->idle drop (`fresh-agent-ws.ts:333-342`, - // `freshAgentSlice.sessionError`). This branch is UNREQUESTED-death - // only: `handle_kill`/`shutdown`/attach-teardown all remove the map - // entry (and abort this consumer) first, so `evicted` is false there - // and stays silent. ADR Decision 2.1's no-FALSE-completion property - // now holds through the GATE below: an in-flight death rings the - // unified attention edge (a turn end the user did not witness), - // an IDLE death still fabricates no chime. + // running/streaming->idle drop (`fresh-agent-ws.ts`, + // `freshAgentSlice.sessionError`), with the cause. This branch is + // UNREQUESTED-end only: `handle_kill`/`shutdown`/attach-teardown all + // remove the map entry (and abort this consumer) first, so nothing is + // evicted here and it stays silent. let stamp = broadcast_id.lock().expect("broadcast id lock").clone(); - tracing::warn!(session_id = %stamp, "freshagent.claude.sidecar_death_detected"); - state.emit_fresh_agent_error( - &stamp, - &session_type, - "SIDECAR_EXITED", - "Claude agent process exited unexpectedly - the in-flight turn was lost. Reopen the pane or create a new agent to continue.", - ); - // Task 5 (unified needs-attention signal): an UNREQUESTED death - // ends any in-flight turn — the user must come look. The sidecar - // (Task 4) rings its own edge on every turn end its stream can - // report; the ONE end it cannot report is a process death - // mid-turn (its finally never runs), so synthesize the SAME - // `freshAgent.turn.complete` edge here. The armed state was - // captured at EOF entry above — the clearing + eviction in - // between have already torn down the latch and the session - // record, so the mint reads the CAPTURED value and the - // consumer-held clock Arc. The sidecar's own monotonic clock - // died with its process; wall-clock advancement orders the - // Rust-minted `at`s against the sidecar-minted ones — the - // accepted regime, same as Task 3. Requested deaths never - // reach this arm; an idle death (no armed turn) stays silent. - if turn_was_in_flight { + let rings = crate::claude_sidecar_exit::death_rings(turn_was_in_flight, &cause); + tracing::warn!(target: "freshell_freshagent::claude", + provider = PROVIDER, + session_id = %stamp, + session_type = %session_type, + reason = cause.reason(), + cause = %cause.detail(), + exit_status = %crate::claude_sidecar_exit::describe_status(status), + turn_in_flight = turn_was_in_flight, + rings, + "freshagent.claude.sidecar_exit"); + state.broadcast(&ServerMessage::FreshAgentEvent(FreshAgentEvent { + event: crate::claude_sidecar_exit::sidecar_exited_event(&stamp, &cause), + provider: PROVIDER.to_string(), + session_id: stamp.clone(), + session_type: session_type.clone(), + })); + // Task 5 (unified needs-attention signal): an UNREQUESTED end of a + // running turn is a turn end the user did not witness. Interim + // rule (design §5.7, until C18): a sidecar that reported its + // stream's end already rang a result-less turn end itself, so the + // server rings only for a death that reported nothing (a Node + // death mid-turn) — exactly one edge either way. The armed state + // was captured at EOF entry above; the clearing + eviction in + // between have torn down the tracker and the session record, so + // the mint reads the CAPTURED value and the consumer-held clock + // Arc. Wall-clock advancement orders the Rust-minted `at`s against + // the sidecar-minted ones — the accepted regime, same as Task 3. + if rings { let at = { let mut guard = last_synthesized_complete_at .lock() @@ -9034,9 +8998,9 @@ impl FreshClaudeState { /// Stream invariants the attribution leans on (provider FIFO discipline): the /// sidecar processes its input queue strictly in acceptance order; at most one /// op runs at a time; EVERY op ends with exactly one logical terminal edge — -/// a turn emits `sdk.result` (any subtype) followed by its trailing -/// `sdk.status:idle` PUNCTUATION (paired-skipped by the consumer before this -/// fold ever sees it, ep2-r1 F1), or a completed interrupt stands in for the +/// a turn emits `sdk.result` (any subtype) — the sidecar's only idle comes +/// when its stream ends, right before it exits (G23) — or a completed +/// interrupt stands in for the /// running op's edge at the write site (no result frame exists, Task 4 edge /// (d)); and a compact's terminal edge is ALWAYS preceded by its /// manual-confirmed completion boundary in-stream (an unpromoted compact can @@ -9236,8 +9200,8 @@ fn absorb_unstarted_compact_debt( } } -/// The in-stream TERMINAL-edge fold (`sdk.result` any subtype; `sdk.status:idle` -/// only when the consumer's pair-skip let it through, ep2-r1 F1). Attribution +/// The in-stream TERMINAL-edge fold (`sdk.result` any subtype; `sdk.status:idle`, +/// which the sidecar sends only when its stream ends). Attribution /// retires the OLDEST outstanding op: /// /// (running) the edge belongs to the op in the `running` slot — the active @@ -10498,7 +10462,6 @@ pub(crate) mod tests { pending: Arc::new(std::sync::Mutex::new(ClaudePending::default())), in_turn: Arc::new(std::sync::atomic::AtomicBool::new(false)), turn_tracker: Arc::new(std::sync::Mutex::new(TurnTracker::default())), - result_idle_pair_pending: Arc::new(std::sync::atomic::AtomicBool::new(false)), last_synthesized_complete_at: Arc::new(std::sync::Mutex::new(None)), turn_lock: Arc::new(TokioMutex::new(())), rollback_probe_slot: Arc::new(std::sync::Mutex::new(None)), @@ -11940,22 +11903,23 @@ rl.on('line', (line) => { // asynchronously, and per the SDK contract (`sdk.d.ts:3760`) the control // receipt is written BEFORE the interrupted turn's terminal `sdk.result` // — so the fake mirrors: settle frame first, then (accepted case) the - // interrupted turn's own result + paired idle, IN THAT ORDER. The + // interrupted turn's own result, IN THAT ORDER (no trailing idle: the + // real sidecar sends idle only when its stream ends). The // rollback gate opens at the RESULTS, never at the receipt. A knocked-out // settle (probe on an idle sidecar) still stands alone: with no in-flight // op the result folds harmlessly on an empty tracker. // // Knobs: FRESHELL_TEST_CLAUDE_INTERRUPT_REJECT=1 — the provider REJECTED - // the interrupt (ok:false, NO trailing result/idle: the turn still runs + // the interrupt (ok:false, NO trailing result: the turn still runs // and its own later edge owns it — the gate stays closed); // FRESHELL_TEST_CLAUDE_INTERRUPT_SETTLE_MS — defer the whole settle→result - // →idle chain (the request↔settle window is the provider-work window the + // chain (the request↔settle window is the provider-work window the // gate must hold); // FRESHELL_TEST_CLAUDE_PROBE_COMPACT_BEFORE_SETTLE=1 — BEFORE the settle, // emit `sdk.status:compacting` (the absorbed compact debt started in the // sidecar before consuming the probe request: rollback's recheck must see // the revived candidate via the probe's in-order stream position and - // ABORT the teardown) — and NO trailing result/idle for the compact: its + // ABORT the teardown) — and NO trailing result for the compact: its // own terminal edge has not yet landed (the hazard window). const settleMs = parseInt(process.env.FRESHELL_TEST_CLAUDE_INTERRUPT_SETTLE_MS || '0', 10) const reject = process.env.FRESHELL_TEST_CLAUDE_INTERRUPT_REJECT === '1' @@ -11967,7 +11931,6 @@ rl.on('line', (line) => { console.log(JSON.stringify({ type: 'sdk.interrupt_settled', sessionId: msg.sessionId, ok: !reject })) if (!reject && !compactBeforeSettle) { console.log(JSON.stringify({ type: 'sdk.result', sessionId: msg.sessionId, subtype: 'error', errors: ['interrupted'] })) - console.log(JSON.stringify({ type: 'sdk.status', sessionId: msg.sessionId, status: 'idle' })) } } if (settleMs > 0) setTimeout(settle, settleMs) @@ -19478,7 +19441,6 @@ rl.on('line', (line) => { pending: Arc::new(std::sync::Mutex::new(pending)), in_turn: Arc::new(std::sync::atomic::AtomicBool::new(false)), turn_tracker: Arc::new(std::sync::Mutex::new(TurnTracker::default())), - result_idle_pair_pending: Arc::new(std::sync::atomic::AtomicBool::new(false)), last_synthesized_complete_at: Arc::new(std::sync::Mutex::new(None)), turn_lock: Arc::new(TokioMutex::new(())), rollback_probe_slot: Arc::new(std::sync::Mutex::new(None)), @@ -19922,7 +19884,6 @@ rl.on('line', (line) => { Arc::new(std::sync::Mutex::new("idle".to_string())), Arc::new(std::sync::atomic::AtomicBool::new(false)), Arc::new(std::sync::Mutex::new(TurnTracker::default())), - Arc::new(std::sync::atomic::AtomicBool::new(false)), Arc::new(std::sync::Mutex::new(None)), None, None, // provenance: test lane is conn-less (D8) @@ -21093,7 +21054,6 @@ rl.on('line', (line) => { let in_turn = Arc::new(std::sync::atomic::AtomicBool::new(false)); let turn_tracker = Arc::new(std::sync::Mutex::new(TurnTracker::default())); let pending = Arc::new(std::sync::Mutex::new(ClaudePending::default())); - let result_idle_pair_pending = Arc::new(std::sync::atomic::AtomicBool::new(false)); let last_synthesized_complete_at = Arc::new(std::sync::Mutex::new(None)); let turn_lock = Arc::new(TokioMutex::new(())); let last_status = Arc::new(std::sync::Mutex::new("idle".to_string())); @@ -21109,7 +21069,6 @@ rl.on('line', (line) => { Arc::clone(&last_status), Arc::clone(&in_turn), Arc::clone(&turn_tracker), - Arc::clone(&result_idle_pair_pending), Arc::clone(&last_synthesized_complete_at), None, None, // provenance: test lane is conn-less (D8) @@ -21130,7 +21089,6 @@ rl.on('line', (line) => { pending, in_turn, turn_tracker, - result_idle_pair_pending, last_synthesized_complete_at, turn_lock, rollback_probe_slot: Arc::new(std::sync::Mutex::new(None)), @@ -21181,7 +21139,6 @@ rl.on('line', (line) => { pending: Arc::new(std::sync::Mutex::new(ClaudePending::default())), in_turn: Arc::new(std::sync::atomic::AtomicBool::new(false)), turn_tracker: Arc::new(std::sync::Mutex::new(TurnTracker::default())), - result_idle_pair_pending: Arc::new(std::sync::atomic::AtomicBool::new(false)), last_synthesized_complete_at: Arc::new(std::sync::Mutex::new(None)), turn_lock: Arc::new(TokioMutex::new(())), rollback_probe_slot: Arc::new(std::sync::Mutex::new(None)), @@ -21241,7 +21198,6 @@ rl.on('line', (line) => { let in_turn = Arc::new(std::sync::atomic::AtomicBool::new(false)); let turn_tracker = Arc::new(std::sync::Mutex::new(TurnTracker::default())); let pending = Arc::new(std::sync::Mutex::new(ClaudePending::default())); - let result_idle_pair_pending = Arc::new(std::sync::atomic::AtomicBool::new(false)); let last_synthesized_complete_at = Arc::new(std::sync::Mutex::new(None)); let turn_lock = Arc::new(TokioMutex::new(())); let last_status = Arc::new(std::sync::Mutex::new("idle".to_string())); @@ -21257,7 +21213,6 @@ rl.on('line', (line) => { Arc::clone(&last_status), Arc::clone(&in_turn), Arc::clone(&turn_tracker), - Arc::clone(&result_idle_pair_pending), Arc::clone(&last_synthesized_complete_at), None, None, // provenance: test lane is conn-less (D8) @@ -21278,7 +21233,6 @@ rl.on('line', (line) => { pending, in_turn, turn_tracker, - result_idle_pair_pending, last_synthesized_complete_at, turn_lock, rollback_probe_slot: Arc::new(std::sync::Mutex::new(None)), @@ -21396,6 +21350,35 @@ rl.on('line', (line) => { } } + /// G23: the real sidecar sends `sdk.status:idle` only when its stream ends + /// (right before `sdk.stream.ended` and its exit), never as a result's + /// trailing punctuation. A stream that ends while a second turn runs, after + /// the first turn's result, must release the busy truth: the deleted + /// result/idle pair latch skipped exactly this idle and left the session + /// busy (`apply_snapshot_metadata` then reported `running`). + #[tokio::test] + async fn a_stream_end_idle_after_an_earlier_result_releases_the_busy_truth() { + let _guard = CLAUDE_ENV_LOCK.lock().await; + let env = FakeClaudeSidecarEnv::install(); + let (st, mut rx) = state_with_bus(); + st.handle_create(dedup_create_msg("req-stream-end-idle"), None) + .await; + let created = await_claude_created(&mut rx, "req-stream-end-idle").await; + let sid = created["sessionId"].as_str().unwrap().to_string(); + await_status_frame(&mut rx, &sid, "idle").await; + + st.handle_send(send_msg(&sid, "turn one")).await; + inject_raw_send(&st, &sid, "__emit_result__").await; + await_in_turn(&st, &sid, false).await; + + st.handle_send(send_msg(&sid, "turn two")).await; + assert!(in_turn_of(&st, &sid).await, "the second turn is running"); + // The stream ends mid-turn: the sidecar's stream-end idle. + inject_raw_send(&st, &sid, "__emit_idle__").await; + await_in_turn(&st, &sid, false).await; + drop(env); + } + #[tokio::test] async fn in_turn_clears_on_exactly_the_four_contract_edges_fail_closed_otherwise() { let _guard = CLAUDE_ENV_LOCK.lock().await; @@ -21438,15 +21421,15 @@ rl.on('line', (line) => { await_in_turn(&st, &sid, false).await; // (d) a completed handle_interrupt clears — the interrupted turn's OWN - // terminal frames (result + idle, per the SDK receipt-before-result - // contract) do it; an error-subtype result never chimes: + // terminal result (per the SDK receipt-before-result contract) does + // it; an error-subtype result never chimes: st.handle_send(send_msg(&sid, "turn three")).await; assert!(in_turn_of(&st, &sid).await); // Baseline: drop every frame the create/emit dance left on the bus. while rx.try_recv().is_ok() {} st.handle_interrupt(interrupt_msg(&sid)).await; // ep4-r2 F1: the gate releases at the interrupted op's OWN result fold - // (the fake emits settle → result → idle in SDK order); poll it. + // (the fake emits settle → result in SDK order); poll it. await_in_turn(&st, &sid, false).await; tokio::time::sleep(Duration::from_millis(300)).await; while let Ok(raw) = rx.try_recv() { @@ -21780,14 +21763,14 @@ rl.on('line', (line) => { /// ahead, B queued as the de-facto active turn], B automatically /// compacts mid-turn: status-time promotion attributed the AUTO compaction /// to C1 ("promotes the phantom"); B's own result frame then retired the - /// PHANTOM compact and stranded B queued — its PAIRED idle deliberately - /// skipped (ep2-r1 F1) — `in_turn` held FOREVER (BUSY_TURN for every later + /// PHANTOM compact and stranded B queued with no edge left to retire it — + /// `in_turn` held FOREVER (BUSY_TURN for every later /// undo). Only the SDK's compact COMPLETION boundary /// (`sdk.compact_boundary {trigger}`) discriminates the trigger; promotion /// now requires the `manual` boundary. /// - /// A → C1 → B → A's pair (A retires) → B generating → AUTO compact - /// (status + boundary auto — NO promotion) → B's own result + idle pair + /// A → C1 → B → A's result (A retires) → B generating → AUTO compact + /// (status + boundary auto — NO promotion) → B's own result /// (B retires) → the gate OPENS. #[tokio::test] async fn an_automatic_compaction_mid_turn_is_never_attributed_to_a_queued_compact() { @@ -21804,9 +21787,8 @@ rl.on('line', (line) => { st.handle_compact(compact_msg(&session_id, None)).await; // C1 st.handle_send(send_msg(&session_id, "turn two")).await; // B - // A's pair retires A; [C1 dropped, B (de-facto active)] remains. + // A's result retires A; [C1 dropped, B (de-facto active)] remains. inject_raw_send(&st, &session_id, "__emit_result_error__").await; - inject_raw_send(&st, &session_id, "__emit_idle__").await; tokio::time::sleep(Duration::from_millis(300)).await; assert!(in_turn_of(&st, &session_id).await, "C1(dropped) + B owed"); @@ -21828,11 +21810,10 @@ rl.on('line', (line) => { "ep3-r1 F1: the queued C1 is NOT promoted by an automatic compaction — B still owes its terminal edge" ); - // B's own result + its PAIRED idle retires B — and the gate OPENS: + // B's own result retires B — and the gate OPENS: // nothing is owed (C1's silent drop is proven by B's activity per the // existing drop-absorber), the pane is never permanently busy. inject_raw_send(&st, &session_id, "__emit_result_error__").await; - inject_raw_send(&st, &session_id, "__emit_idle__").await; tokio::time::sleep(Duration::from_millis(300)).await; let (sink, captured) = capturing_sink(); st.handle_rollback( @@ -21857,9 +21838,9 @@ rl.on('line', (line) => { /// the sole mid-turn gate admitted a rollback wipe mid-generation. With a /// LIVE candidate the interrupt retires EXACTLY the front compact. /// - /// A → C1 → C2 → S → A's pair (A retires) → C1 compacting (candidate) → + /// A → C1 → C2 → S → A's result (A retires) → C1 compacting (candidate) → /// INTERRUPT (cancels C1 only — the gate STAYS CLOSED for C2 + S) → C2's - /// manual boundary + pair (C2 retires; S still owed) → S's pair (gate + /// manual boundary + result (C2 retires; S still owed) → S's result (gate /// OPENS exactly here). #[tokio::test] async fn an_interrupt_inside_the_compact_candidate_window_retires_only_the_interrupted_compact() @@ -21877,9 +21858,8 @@ rl.on('line', (line) => { st.handle_compact(compact_msg(&session_id, None)).await; // C2 st.handle_send(send_msg(&session_id, "turn two")).await; // S - // A's pair retires A: [C1, C2, S] all queued, running empty. + // A's result retires A: [C1, C2, S] all queued, running empty. inject_raw_send(&st, &session_id, "__emit_result_error__").await; - inject_raw_send(&st, &session_id, "__emit_idle__").await; tokio::time::sleep(Duration::from_millis(300)).await; // C1 starts compacting (candidate pending — the boundary lands later). @@ -21906,18 +21886,16 @@ rl.on('line', (line) => { ); // C2 then runs for real: status marks its candidate, the manual - // boundary promotes it, and its own pair retires it; S is still owed + // boundary promotes it, and its own result retires it; S is still owed // behind it — the gate holds. inject_raw_send(&st, &session_id, "__emit_compacting__").await; inject_raw_send(&st, &session_id, "__emit_compact_boundary_manual__").await; inject_raw_send(&st, &session_id, "__emit_result_error__").await; - inject_raw_send(&st, &session_id, "__emit_idle__").await; tokio::time::sleep(Duration::from_millis(300)).await; assert!(in_turn_of(&st, &session_id).await, "S still owed behind C2"); // S's own pair: the gate OPENS at the last owed edge, exactly. inject_raw_send(&st, &session_id, "__emit_result_error__").await; - inject_raw_send(&st, &session_id, "__emit_idle__").await; tokio::time::sleep(Duration::from_millis(300)).await; let (sink, captured) = capturing_sink(); st.handle_rollback( @@ -21951,10 +21929,9 @@ rl.on('line', (line) => { let session_id = created["sessionId"].as_str().unwrap().to_string(); await_status_frame(&mut rx, &session_id, "idle").await; - // One completed turn (armed then retired by its pair) — the gate is open. + // One completed turn (armed then retired by its result) — the gate is open. st.handle_send(send_msg(&session_id, "turn one")).await; inject_raw_send(&st, &session_id, "__emit_result_error__").await; - inject_raw_send(&st, &session_id, "__emit_idle__").await; await_in_turn(&st, &session_id, false).await; // The provider-side session DIES (stream end deletes it) — stdout stays @@ -22196,7 +22173,7 @@ rl.on('line', (line) => { /// compact ahead of a running one — in-band indistinguishable, so the debt /// must settle at the gate, not the attribution). A turnaround edge replay /// after absorption folds as benign noise (boundary-promotion on an empty - /// queue promotes nothing; the pair-skip consumes the trailing idle). + /// queue promotes nothing). #[tokio::test] async fn two_queued_compacts_are_provably_quiescent_and_absorb_at_the_rollback_gate() { let _guard = CLAUDE_ENV_LOCK.lock().await; @@ -22256,7 +22233,6 @@ rl.on('line', (line) => { "a revived absorbed compact RE-ARMS the gate at its proven start" ); inject_raw_send(&st, &session_id, "__emit_result_error__").await; - inject_raw_send(&st, &session_id, "__emit_idle__").await; tokio::time::sleep(Duration::from_millis(300)).await; assert!( in_turn_of(&st, &session_id).await, @@ -22395,108 +22371,6 @@ rl.on('line', (line) => { drop(env); } - /// Focused-review ep2-r1 F1 (the PAIRED terminal frames): the supported - /// Claude/Kilroy protocol emits `sdk.result` AND a trailing - /// `sdk.status:idle` for ONE turn (both provider fixtures emit exactly that - /// pair; the real sidecar's consumeStream finally emits the trailing idle). - /// The trailing idle is the SAME turn's closing punctuation, never a new - /// op's edge — folding it through attribution double-counts the turn. The - /// repair-delta fold missed this: with turn A running and C+S queued - /// behind it, A's result consumed the prior debt and then A's TRAILING IDLE - /// was attributed to the garlanded send (the FIFO-drop branch), clearing - /// the compact/send trackers and `in_turn` while C and S were still - /// queued — rollback passed the sole busy gate mid-queue. - /// - /// Queue: A running → C queued → S queued. EVERY turn ends with its pair - /// (result then idle). - /// 1. A's pair: the result folds as the owed prior edge; the trailing - /// idle is pair-skipped (NEVER the garlanded drop proof) — the gate - /// HOLDS; - /// 2. C's observed compacting + result + trailing idle retires C — the - /// gate HOLDS (S still owed); - /// 3. S's result releases the gate (its trailing idle is skipped too). - #[tokio::test] - async fn a_turns_trailing_idle_is_pair_skipped_never_attributed_to_queued_ops() { - let _guard = CLAUDE_ENV_LOCK.lock().await; - let env = FakeClaudeSidecarEnv::install(); - let (st, mut rx) = state_with_bus(); - st.handle_create(dedup_create_msg("req-qpair"), None).await; - let created = await_claude_created(&mut rx, "req-qpair").await; - let session_id = created["sessionId"].as_str().unwrap().to_string(); - await_status_frame(&mut rx, &session_id, "idle").await; - - st.handle_send(send_msg(&session_id, "turn one")).await; // A - st.handle_compact(compact_msg(&session_id, None)).await; // C - st.handle_send(send_msg(&session_id, "turn two")).await; // S - - // (1) A's pair — the result consumes the ONE owed prior edge; the - // trailing idle is C's/S's edges' queue-mate punctuation, NEVER theirs. - inject_raw_send(&st, &session_id, "__emit_result_error__").await; - inject_raw_send(&st, &session_id, "__emit_idle__").await; - tokio::time::sleep(Duration::from_millis(300)).await; - assert!( - in_turn_of(&st, &session_id).await, - "A's trailing idle is pair-punctuation, not the garlanded drop proof — the compact and send are STILL queued" - ); - let (sink, captured) = capturing_sink(); - st.handle_rollback( - rollback_op(&session_id, "rb-qpair-1", RollbackDirection::Undo), - sink, - ) - .await; - let frames = captured_json(&captured); - assert_eq!( - frames[0]["event"]["code"], "BUSY_TURN", - "ep2-r1 F1: A's trailing idle must not release the gate over queued C/S: {frames:?}" - ); - assert_eq!( - env.spawn_count(), - 1, - "zero teardown from the refused attempt" - ); - - // (2) C observably runs and its pair lands: retires C — the gate HOLDS - // (S is generating behind it). - inject_raw_send(&st, &session_id, "__emit_compacting__").await; - inject_raw_send(&st, &session_id, "__emit_compact_boundary_manual__").await; - inject_raw_send(&st, &session_id, "__emit_result_error__").await; - inject_raw_send(&st, &session_id, "__emit_idle__").await; - tokio::time::sleep(Duration::from_millis(300)).await; - assert!( - in_turn_of(&st, &session_id).await, - "C's pair retires C only — S's own terminal edge is still owed" - ); - let (sink, captured) = capturing_sink(); - st.handle_rollback( - rollback_op(&session_id, "rb-qpair-2", RollbackDirection::Undo), - sink, - ) - .await; - assert_eq!( - captured_json(&captured)[0]["event"]["code"], - "BUSY_TURN", - "the garlanded send is still owed after C's pair — the gate holds" - ); - - // (3) S's pair: the result is the garlanded tail's last owed edge — the - // gate releases here; the trailing idle folds nothing further. - inject_raw_send(&st, &session_id, "__emit_result_error__").await; - inject_raw_send(&st, &session_id, "__emit_idle__").await; - await_in_turn(&st, &session_id, false).await; - let (sink, captured) = capturing_sink(); - st.handle_rollback( - rollback_op(&session_id, "rb-qpair-3", RollbackDirection::Undo), - sink, - ) - .await; - let frames = captured_json(&captured); - assert_eq!( - frames[0]["event"]["code"], "NOTHING_TO_UNDO", - "the gate released exactly at S's own terminal edge: {frames:?}" - ); - drop(env); - } - /// Focused-review ep2-r1 F2 (the stale prior-edge debt): a SECOND compact /// armed while the FIRST compact is mid-compaction owes C₁'s terminal edge /// (the one op active at its arm). When C₁'s terminal lands it is C₁'s OWN @@ -22524,10 +22398,8 @@ rl.on('line', (line) => { st.handle_send(send_msg(&session_id, "turn one")).await; // A st.handle_compact(compact_msg(&session_id, None)).await; // C₁ queued - // A's pair lands first: the result settles the arm's prior debt; A's - // trailing idle is pair-skipped. + // A's result lands first: it settles the arm's prior debt. inject_raw_send(&st, &session_id, "__emit_result_error__").await; - inject_raw_send(&st, &session_id, "__emit_idle__").await; tokio::time::sleep(Duration::from_millis(300)).await; // C₁ observably starts; C₂ arms while C₁ is MID-COMPACTION — it owes @@ -22538,10 +22410,9 @@ rl.on('line', (line) => { st.handle_compact(compact_msg(&session_id, None)).await; // C₂ armed st.handle_send(send_msg(&session_id, "turn two")).await; // S garlanded - // C₁'s pair: retires C₁ AND settles C₂'s owed edge — C₂ remains the + // C₁'s result: retires C₁ AND settles C₂'s owed edge — C₂ remains the // sole queued compact; S is still owed. The gate HOLDS throughout. inject_raw_send(&st, &session_id, "__emit_result_error__").await; - inject_raw_send(&st, &session_id, "__emit_idle__").await; tokio::time::sleep(Duration::from_millis(300)).await; assert!( in_turn_of(&st, &session_id).await, @@ -22556,14 +22427,13 @@ rl.on('line', (line) => { assert_eq!( captured_json(&captured)[0]["event"]["code"], "BUSY_TURN", - "C₂ queued + S owed — the gate holds past C₁'s pair" + "C₂ queued + S owed — the gate holds past C₁'s result" ); - // C₂ is provider-DROPPED (never observed compacting); S's pair lands: + // C₂ is provider-DROPPED (never observed compacting); S's result lands: // the result is the FIFO-drop proof (C₂ provably never ran), releasing - // at the LAST garlanded edge; the trailing idle is pair-skipped. + // at the LAST garlanded edge. inject_raw_send(&st, &session_id, "__emit_result_error__").await; - inject_raw_send(&st, &session_id, "__emit_idle__").await; await_in_turn(&st, &session_id, false).await; let (sink, captured) = capturing_sink(); st.handle_rollback( @@ -22693,9 +22563,9 @@ rl.on('line', (line) => { /// AND closes the gate (in_turn=true); consumption at the boundary either /// promotes (gate stays held) or recomputes the owed-debt busy truth. /// - /// A → C1 → C2 → A's pair → absorb probe (admitted; C1+C2 survive; gate + /// A → C1 → C2 → A's result → absorb probe (admitted; C1+C2 survive; gate /// open) → C1's STATUS lands (candidate: the gate RE-CLOSES immediately) - /// → manual boundary (C1 promoted; held) → C1's pair (C1 retires; C2 + /// → manual boundary (C1 promoted; held) → C1's result (C1 retires; C2 /// owed) → absorb probe (admitted again) — the gate never rides the /// proven-running interval. #[tokio::test] @@ -22713,9 +22583,8 @@ rl.on('line', (line) => { st.handle_compact(compact_msg(&session_id, None)).await; // C1 st.handle_compact(compact_msg(&session_id, None)).await; // C2 - // A's pair retires A: [C1, C2] queued, nothing running. + // A's result retires A: [C1, C2] queued, nothing running. inject_raw_send(&st, &session_id, "__emit_result_error__").await; - inject_raw_send(&st, &session_id, "__emit_idle__").await; tokio::time::sleep(Duration::from_millis(300)).await; // Provably-quiescent debt: the gate ADMITS (entries survive). @@ -22753,7 +22622,7 @@ rl.on('line', (line) => { ); assert!(in_turn_of(&st, &session_id).await); - // C1's boundary promotes it (still held); its own pair retires it; C2 + // C1's boundary promotes it (still held); its own result retires it; C2 // is owed; the gate admits at the next absorb probe, quiescent again. inject_raw_send(&st, &session_id, "__emit_compact_boundary_manual__").await; tokio::time::sleep(Duration::from_millis(300)).await; @@ -22762,7 +22631,6 @@ rl.on('line', (line) => { "promotion holds the closed gate through the boundary" ); inject_raw_send(&st, &session_id, "__emit_result_error__").await; - inject_raw_send(&st, &session_id, "__emit_idle__").await; tokio::time::sleep(Duration::from_millis(300)).await; assert!( in_turn_of(&st, &session_id).await, @@ -22787,7 +22655,7 @@ rl.on('line', (line) => { /// promotes the FRONT queued compact, so [C1 silently dropped, C2 runs] is /// in-band indistinguishable from [C1 runs, C2 queued]: C2's whole evidence /// set is consumed by the phantom C1 entry, and C2's own entry remains as - /// debt with NO further evidence — the trailing idle is pair-skipped, so + /// debt with NO further evidence, so /// `in_turn` holds forever and every later undo/redo is refused BUSY_TURN /// ("unavailable until another submission, which would itself destroy /// redo"). The gate now admits when the debt is provably quiescent: @@ -22795,9 +22663,9 @@ rl.on('line', (line) => { /// compacts have not started (their runs raise the candidate FIRST), and /// the rollback teardown discards their provider-side inputs. /// - /// A → C1 → C2 → A's pair (A retires) → C1 dropped (never evidences) → + /// A → C1 → C2 → A's result (A retires) → C1 dropped (never evidences) → /// C2 runs (status + manual boundary promotes the phantom C1) → C2's - /// result + paired idle (the phantom retires; C2's entry remains) → + /// result (the phantom retires; C2's entry remains) → /// the rollback gate OPENS on admission: the provably-quiescent debt /// absorbs (both in a single atomic decision). #[tokio::test] @@ -22816,21 +22684,18 @@ rl.on('line', (line) => { st.handle_compact(compact_msg(&session_id, None)).await; // C1 st.handle_compact(compact_msg(&session_id, None)).await; // C2 - // A's pair retires A: [C1, C2] queued, running empty. + // A's result retires A: [C1, C2] queued, running empty. inject_raw_send(&st, &session_id, "__emit_result_error__").await; - inject_raw_send(&st, &session_id, "__emit_idle__").await; tokio::time::sleep(Duration::from_millis(300)).await; assert!(in_turn_of(&st, &session_id).await, "both compacts owed"); // C1 is provider-DROPPED (never evidences). C2 RUNS: its status marks // the candidate and its manual boundary promotes the FRONT — the - // phantom C1. C2's pair lands: the result retires running (phantom C1); - // the trailing idle is deliberately pair-skipped (ep2-r1 F1). C2's own - // entry is the surviving dead debt. + // phantom C1. C2's result lands: it retires running (phantom C1). + // C2's own entry is the surviving dead debt. inject_raw_send(&st, &session_id, "__emit_compacting__").await; inject_raw_send(&st, &session_id, "__emit_compact_boundary_manual__").await; inject_raw_send(&st, &session_id, "__emit_result_error__").await; - inject_raw_send(&st, &session_id, "__emit_idle__").await; tokio::time::sleep(Duration::from_millis(300)).await; // The debt is provably quiescent (nothing running, no candidate, no @@ -22880,17 +22745,15 @@ rl.on('line', (line) => { st.handle_send(send_msg(&session_id, "turn two")).await; // S1 st.handle_compact(compact_msg(&session_id, None)).await; // C2 (behind S1) - // A's pair: the result retires A; the trailing idle is pair-skipped. + // A's result retires A. inject_raw_send(&st, &session_id, "__emit_result_error__").await; - inject_raw_send(&st, &session_id, "__emit_idle__").await; tokio::time::sleep(Duration::from_millis(300)).await; assert!(in_turn_of(&st, &session_id).await, "three ops still queued"); - // C1 is provider-DROPPED (never observed); S1's pair lands: the result + // C1 is provider-DROPPED (never observed); S1's result lands: the result // evidences C1's drop and retires S1 — but C2 is queued BEHIND S1 and // may still run, so the busy gate MUST stay closed past S1's edge. inject_raw_send(&st, &session_id, "__emit_result_error__").await; - inject_raw_send(&st, &session_id, "__emit_idle__").await; tokio::time::sleep(Duration::from_millis(300)).await; assert!( in_turn_of(&st, &session_id).await, @@ -22919,7 +22782,6 @@ rl.on('line', (line) => { inject_raw_send(&st, &session_id, "__emit_compacting__").await; inject_raw_send(&st, &session_id, "__emit_compact_boundary_manual__").await; inject_raw_send(&st, &session_id, "__emit_result_error__").await; - inject_raw_send(&st, &session_id, "__emit_idle__").await; await_in_turn(&st, &session_id, false).await; let (sink, captured) = capturing_sink(); st.handle_rollback( @@ -22946,7 +22808,7 @@ rl.on('line', (line) => { /// of it already retired-or-dropped: those ahead compacts' silence at the /// live turn's generation IS the drop evidence, absorbed here. /// - /// A running → C1 queued → B queued → A's pair → B generating NOW → + /// A running → C1 queued → B queued → A's result → B generating NOW → /// interrupt. Aftermath: NOTHING stands — the gate opens and STAYS sized /// (a follow-up turn cycles fully clean). #[tokio::test] @@ -22964,10 +22826,9 @@ rl.on('line', (line) => { st.handle_compact(compact_msg(&session_id, None)).await; // C1 st.handle_send(send_msg(&session_id, "turn two")).await; // B - // A's pair retires the running op; C1's compacting frame NEVER came — + // A's result retires the running op; C1's compacting frame NEVER came — // B generates (the drop-evidence window). The gate is closed. inject_raw_send(&st, &session_id, "__emit_result_error__").await; - inject_raw_send(&st, &session_id, "__emit_idle__").await; tokio::time::sleep(Duration::from_millis(300)).await; assert!( in_turn_of(&st, &session_id).await, @@ -23002,11 +22863,10 @@ rl.on('line', (line) => { "ep2-r4 F1: the gate opened exactly at the interrupt and stays truthful: {frames:?}" ); - // No-residue proof: a fresh turn's pair cycles the tracker fully clean. + // No-residue proof: a fresh turn's result cycles the tracker fully clean. st.handle_send(send_msg(&session_id, "turn three")).await; assert!(in_turn_of(&st, &session_id).await); inject_raw_send(&st, &session_id, "__emit_result_error__").await; - inject_raw_send(&st, &session_id, "__emit_idle__").await; await_in_turn(&st, &session_id, false).await; drop(env); } @@ -23021,7 +22881,7 @@ rl.on('line', (line) => { /// interrupt retires the running slot OR the queue FRONT (the op actually in /// flight) — never the still-queued ops behind it. /// - /// A running → B queued → A's pair retires A → B starts → interrupt B. + /// A running → B queued → A's result retires A → B starts → interrupt B. #[tokio::test] async fn interrupting_a_queue_advanced_turn_releases_the_gate() { let _guard = CLAUDE_ENV_LOCK.lock().await; @@ -23035,10 +22895,9 @@ rl.on('line', (line) => { st.handle_send(send_msg(&session_id, "turn one")).await; // A st.handle_send(send_msg(&session_id, "turn two")).await; // B queued - // A's pair retires the running op; B (the queue front) is now the + // A's result retires the running op; B (the queue front) is now the // de-facto active turn — the gate stays closed (B owes its own edge). inject_raw_send(&st, &session_id, "__emit_result_error__").await; - inject_raw_send(&st, &session_id, "__emit_idle__").await; tokio::time::sleep(Duration::from_millis(300)).await; assert!( in_turn_of(&st, &session_id).await, @@ -23101,10 +22960,9 @@ rl.on('line', (line) => { st.handle_compact(compact_msg(&session_id, None)).await; // C1 st.handle_send(send_msg(&session_id, "turn two")).await; // B - // A's pair retires A. [C1 (never promoted — dropped), B (now the + // A's result retires A. [C1 (never promoted — dropped), B (now the // de-facto active turn)] remains; the gate is closed. inject_raw_send(&st, &session_id, "__emit_result_error__").await; - inject_raw_send(&st, &session_id, "__emit_idle__").await; tokio::time::sleep(Duration::from_millis(300)).await; assert!(in_turn_of(&st, &session_id).await, "C1(dropped) + B owed"); let (sink, captured) = capturing_sink(); @@ -23181,12 +23039,11 @@ rl.on('line', (line) => { ); // C runs for real: observed compacting + confirmed-manual boundary + - // its pair — retires C, and S is STILL owed: the gate MUST stay closed + // its result — retires C, and S is STILL owed: the gate MUST stay closed // here. inject_raw_send(&st, &session_id, "__emit_compacting__").await; inject_raw_send(&st, &session_id, "__emit_compact_boundary_manual__").await; inject_raw_send(&st, &session_id, "__emit_result_error__").await; - inject_raw_send(&st, &session_id, "__emit_idle__").await; tokio::time::sleep(Duration::from_millis(300)).await; assert!( in_turn_of(&st, &session_id).await, @@ -23206,9 +23063,8 @@ rl.on('line', (line) => { ); assert_eq!(env.spawn_count(), 1, "zero teardown from refused attempts"); - // S's pair: the gate releases exactly at S's own terminal edge. + // S's result: the gate releases exactly at S's own terminal edge. inject_raw_send(&st, &session_id, "__emit_result_error__").await; - inject_raw_send(&st, &session_id, "__emit_idle__").await; await_in_turn(&st, &session_id, false).await; let (sink, captured) = capturing_sink(); st.handle_rollback( @@ -23228,7 +23084,7 @@ rl.on('line', (line) => { /// settle-ack ≠ turn-complete per the SDK receipt-before-result contract /// at ep4-r2): with the settle deferred, the gate must stay closed for the /// whole provider-work window — and opens only at the interrupted turn's - /// OWN terminal frames (result + paired idle, emitted after the receipt + /// OWN terminal frame (its result, emitted after the receipt /// in that order). /// /// A running → interrupt (settle chain deferred by knob) → mid-window @@ -23266,8 +23122,8 @@ rl.on('line', (line) => { "the busy truth holds past the request write" ); - // The deferred chain arrives (receipt, then the turn's own result + - // paired idle): the gate opens exactly at the op's own terminal edge. + // The deferred chain arrives (receipt, then the turn's own result): + // the gate opens exactly at the op's own terminal edge. await_in_turn(&st, &session_id, false).await; let (sink, captured) = capturing_sink(); st.handle_rollback( @@ -23321,10 +23177,9 @@ rl.on('line', (line) => { "the rejected interrupt left the running turn owed — gate closed" ); - // The turn's OWN terminal edge (its real result + paired idle) retires + // The turn's OWN terminal edge (its real result) retires // it — the gate opens exactly there. inject_raw_send(&st, &session_id, "__emit_result_error__").await; - inject_raw_send(&st, &session_id, "__emit_idle__").await; await_in_turn(&st, &session_id, false).await; let (sink, captured) = capturing_sink(); st.handle_rollback( @@ -23353,7 +23208,7 @@ rl.on('line', (line) => { /// (BUSY_TURN + compensating ledger rewrite), the sidecar survives. /// /// Transcript-eligible durable fixture on a LIVE fake sidecar; [A → C1 → - /// A's pair → C2] owed compacts; the gate absorb admits; the pre-teardown + /// A's result → C2] owed compacts; the gate absorb admits; the pre-teardown /// quiesce probe (an interrupt write) elicits the fake's /// FRESHELL_TEST_CLAUDE_PROBE_COMPACT_BEFORE_SETTLE compacting STATUS /// before its settle → the recheck provably sees the revived candidate @@ -23372,13 +23227,12 @@ rl.on('line', (line) => { st.set_identity_sink(sink_impl.clone()); insert_rollback_fixture_session_with_live_sidecar(&st, "rb-probe", "dur-probe").await; - // [A → C1][A's pair][C2] — compact-only owed queue (C1 promoted + // [A → C1][A's result][C2] — compact-only owed queue (C1 promoted // briefly under A, so retiring A leaves BOTH compacts queued and // never-promoted: the absorb family's admittance shape). st.handle_send(send_msg("rb-probe", "turn one")).await; // A st.handle_compact(compact_msg("rb-probe", None)).await; // C1 queued behind A inject_raw_send(&st, "rb-probe", "__emit_result_error__").await; - inject_raw_send(&st, "rb-probe", "__emit_idle__").await; st.handle_compact(compact_msg("rb-probe", None)).await; // C2 queued tokio::time::sleep(Duration::from_millis(300)).await; assert!( @@ -23434,7 +23288,7 @@ rl.on('line', (line) => { st.handle_send(send_msg(&session_id, "turn one")).await; // A running st.handle_send(send_msg(&session_id, "turn two")).await; // B queued - // Interrupt A: receipt + A's own trailing result/idle (in that order). + // Interrupt A: receipt + A's own result (in that order). // NOTHING beyond A may retire. st.handle_interrupt(interrupt_msg(&session_id)).await; tokio::time::sleep(Duration::from_millis(300)).await; @@ -23456,7 +23310,6 @@ rl.on('line', (line) => { // B's own terminal frames retire it — the gate opens exactly there. inject_raw_send(&st, &session_id, "__emit_result_error__").await; - inject_raw_send(&st, &session_id, "__emit_idle__").await; await_in_turn(&st, &session_id, false).await; let (sink, captured) = capturing_sink(); st.handle_rollback( @@ -23535,11 +23388,10 @@ rl.on('line', (line) => { let _ = insert_rollback_fixture_session_with_live_sidecar(&st, "rb-qadmit", "dur-qadmit").await; - // Compact-only owed queue (C1 rides under A; A's pair retires it; C2). + // Compact-only owed queue (C1 rides under A; A's result retires it; C2). st.handle_send(send_msg("rb-qadmit", "turn one")).await; st.handle_compact(compact_msg("rb-qadmit", None)).await; inject_raw_send(&st, "rb-qadmit", "__emit_result_error__").await; - inject_raw_send(&st, "rb-qadmit", "__emit_idle__").await; st.handle_compact(compact_msg("rb-qadmit", None)).await; tokio::time::sleep(Duration::from_millis(300)).await; @@ -23591,12 +23443,11 @@ rl.on('line', (line) => { st.set_identity_sink(sink_impl.clone()); insert_rollback_fixture_session_with_live_sidecar(&st, "rb-vhanded", "dur-vhanded").await; - // A send+compact arm the debt realistically (A's pair folds them to + // A send+compact arm the debt realistically (A's result folds them to // absorb shape: [C] owed, running empty). st.handle_send(send_msg("rb-vhanded", "turn one")).await; st.handle_compact(compact_msg("rb-vhanded", None)).await; inject_raw_send(&st, "rb-vhanded", "__emit_result_error__").await; - inject_raw_send(&st, "rb-vhanded", "__emit_idle__").await; tokio::time::sleep(Duration::from_millis(300)).await; let (sink, captured) = capturing_sink(); diff --git a/crates/freshell-freshagent/src/claude_sidecar_exit.rs b/crates/freshell-freshagent/src/claude_sidecar_exit.rs new file mode 100644 index 000000000..b7be07c29 --- /dev/null +++ b/crates/freshell-freshagent/src/claude_sidecar_exit.rs @@ -0,0 +1,336 @@ +//! Why a claude sidecar ended when nobody asked it to (core-runtime design §5.7, +//! C08; G02, G23). +//! +//! Each sidecar hosts one session. When the session's SDK stream ends, the sidecar +//! reports how in `sdk.stream.ended {reason, message}` (its last stdout line) and +//! exits with code 3 (`crates/freshell-claude-sidecar/stream-end.mjs`). If the Node +//! process itself dies, stdout just ends and only its exit status tells why. Either +//! way the consumer's end-of-output path ends the session: it reports the +//! [`SidecarExitCause`] to the user in `freshAgent.error{SIDECAR_EXITED}`, logs it +//! as `freshagent.claude.sidecar_exit`, and decides whether the lost turn rings +//! ([`death_rings`]). + +use std::process::ExitStatus; +use std::time::Duration; + +use serde_json::{json, Map, Value}; + +/// How long the end-of-output path waits for an ended sidecar's exit status. Its +/// stdout has closed, so the process is exiting or gone. +const EXIT_STATUS_BUDGET: Duration = Duration::from_secs(2); + +/// The sidecar's last line when its SDK stream ended unrequested. +pub(crate) const STREAM_ENDED_TYPE: &str = "sdk.stream.ended"; + +/// The reason a [`SidecarExitCause::ProcessEnded`] reports. +pub(crate) const PROCESS_ENDED_REASON: &str = "sidecar_exited"; + +/// `sdk.stream.ended`: how the session's SDK stream ended (`reason` is +/// `cli_exited`, `stream_error` or `stream_eof`; `message` explains it). +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct StreamEnded { + pub(crate) reason: String, + pub(crate) message: String, +} + +impl StreamEnded { + /// The stream end a sidecar stdout line reports, if it is `sdk.stream.ended`. + pub(crate) fn from_line(line: &Value) -> Option { + if line.get("type").and_then(Value::as_str) != Some(STREAM_ENDED_TYPE) { + return None; + } + let text = |key: &str| line.get(key).and_then(Value::as_str).unwrap_or("").trim(); + let reason = match text("reason") { + "" => "stream_error", + reason => reason, + }; + Some(Self { + reason: reason.to_string(), + message: text("message").to_string(), + }) + } +} + +/// Why an unrequested sidecar end happened. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum SidecarExitCause { + /// The sidecar reported its SDK stream's end, then exited. + StreamEnded(StreamEnded), + /// The sidecar process ended without reporting a stream end (Node itself died). + /// `None` when its exit status could not be read. + ProcessEnded(Option), +} + +impl SidecarExitCause { + /// The cause: the reported stream end when there is one, else the exit status. + pub(crate) fn new(stream_ended: Option, status: Option) -> Self { + match stream_ended { + Some(ended) => Self::StreamEnded(ended), + None => Self::ProcessEnded(status), + } + } + + pub(crate) fn reason(&self) -> &str { + match self { + Self::StreamEnded(ended) => &ended.reason, + Self::ProcessEnded(_) => PROCESS_ENDED_REASON, + } + } + + /// What happened, in a phrase for the banner and the log. + pub(crate) fn detail(&self) -> String { + match self { + Self::StreamEnded(ended) if !ended.message.is_empty() => { + ended.message.trim_end_matches('.').to_string() + } + Self::StreamEnded(ended) => format!("the session's stream ended ({})", ended.reason), + Self::ProcessEnded(status) => match (exit_code(*status), exit_signal(*status)) { + (Some(code), _) => format!("the agent process exited with code {code}"), + (None, Some(signal)) => format!("the agent process was stopped by {signal}"), + (None, None) => "the agent process ended".to_string(), + }, + } + } + + /// The user-facing `freshAgent.error{SIDECAR_EXITED}` message. + pub(crate) fn user_message(&self) -> String { + format!( + "The Claude agent process exited unexpectedly: {}. Reopen the pane or create a new agent to continue.", + self.detail() + ) + } + + /// The `cause` object of the error frame: `{reason, message}`, plus + /// `exitCode`/`signal` for a process end. + pub(crate) fn to_json(&self) -> Value { + let mut cause = Map::new(); + cause.insert("reason".into(), json!(self.reason())); + cause.insert("message".into(), json!(self.detail())); + if let Self::ProcessEnded(status) = self { + if let Some(code) = exit_code(*status) { + cause.insert("exitCode".into(), json!(code)); + } + if let Some(signal) = exit_signal(*status) { + cause.insert("signal".into(), json!(signal)); + } + } + Value::Object(cause) + } +} + +/// Reap an ended sidecar: its exit status, or `None` if it cannot be read within +/// [`EXIT_STATUS_BUDGET`]. Waiting also leaves no zombie behind. +pub(crate) async fn reap(child: &mut tokio::process::Child) -> Option { + match tokio::time::timeout(EXIT_STATUS_BUDGET, child.wait()).await { + Ok(Ok(status)) => Some(status), + Ok(Err(error)) => { + tracing::warn!(target: "freshell_freshagent::claude", provider = "claude", + error = %error, "freshagent.claude.sidecar_exit_status_unreadable"); + None + } + Err(_) => { + tracing::warn!(target: "freshell_freshagent::claude", provider = "claude", + budget_ms = EXIT_STATUS_BUDGET.as_millis() as u64, + "freshagent.claude.sidecar_exit_status_timeout"); + None + } + } +} + +/// An exit status for the log: `code 3`, `SIGKILL`, or `unknown`. +pub(crate) fn describe_status(status: Option) -> String { + match (exit_code(status), exit_signal(status)) { + (Some(code), _) => format!("code {code}"), + (None, Some(signal)) => signal, + (None, None) => "unknown".to_string(), + } +} + +/// Whether the end-of-output path rings for a lost turn. Interim (C08 until Rust +/// mints every edge, C18): the sidecar rings a turn that ends with no result before +/// it reports the stream end, so the server rings only when there was no report — +/// a Node death — and a turn was running. +pub(crate) fn death_rings(turn_was_in_flight: bool, cause: &SidecarExitCause) -> bool { + turn_was_in_flight && matches!(cause, SidecarExitCause::ProcessEnded(_)) +} + +/// The `freshAgent.error{SIDECAR_EXITED}` event for `session_id`. +pub(crate) fn sidecar_exited_event(session_id: &str, cause: &SidecarExitCause) -> Value { + json!({ + "type": "freshAgent.error", + "sessionId": session_id, + "code": "SIDECAR_EXITED", + "message": cause.user_message(), + "cause": cause.to_json(), + }) +} + +fn exit_code(status: Option) -> Option { + status.and_then(|status| status.code()) +} + +#[cfg(unix)] +fn exit_signal(status: Option) -> Option { + use std::os::unix::process::ExitStatusExt; + status.and_then(|status| status.signal()).map(signal_name) +} + +#[cfg(not(unix))] +fn exit_signal(_status: Option) -> Option { + None +} + +#[cfg(unix)] +fn signal_name(signal: i32) -> String { + match signal { + libc::SIGHUP => "SIGHUP".into(), + libc::SIGINT => "SIGINT".into(), + libc::SIGQUIT => "SIGQUIT".into(), + libc::SIGABRT => "SIGABRT".into(), + libc::SIGKILL => "SIGKILL".into(), + libc::SIGSEGV => "SIGSEGV".into(), + libc::SIGPIPE => "SIGPIPE".into(), + libc::SIGTERM => "SIGTERM".into(), + other => format!("signal {other}"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ended(reason: &str, message: &str) -> SidecarExitCause { + SidecarExitCause::StreamEnded(StreamEnded { + reason: reason.into(), + message: message.into(), + }) + } + + #[test] + fn a_stream_ended_line_is_read_and_other_lines_are_not() { + assert_eq!( + StreamEnded::from_line(&json!({ + "type": "sdk.stream.ended", "sessionId": "s", + "reason": "cli_exited", "message": "Claude Code process terminated by signal SIGKILL", + })), + Some(StreamEnded { + reason: "cli_exited".into(), + message: "Claude Code process terminated by signal SIGKILL".into(), + }) + ); + assert_eq!( + StreamEnded::from_line(&json!({ "type": "sdk.stream.ended", "sessionId": "s" })), + Some(StreamEnded { + reason: "stream_error".into(), + message: String::new() + }), + "a malformed report still counts as a reported end" + ); + assert_eq!( + StreamEnded::from_line(&json!({ "type": "sdk.status", "status": "idle" })), + None + ); + } + + #[test] + fn a_reported_stream_end_is_the_cause_whatever_the_exit_status() { + let report = StreamEnded { + reason: "stream_eof".into(), + message: "The Claude CLI ended the session without reporting an error.".into(), + }; + #[cfg(unix)] + let status = { + use std::os::unix::process::ExitStatusExt; + Some(ExitStatus::from_raw(3 << 8)) + }; + #[cfg(not(unix))] + let status = None; + let cause = SidecarExitCause::new(Some(report.clone()), status); + assert_eq!(cause, SidecarExitCause::StreamEnded(report)); + assert_eq!(cause.reason(), "stream_eof"); + assert_eq!(cause.to_json().get("exitCode"), None); + assert_eq!( + cause.to_json(), + json!({ "reason": "stream_eof", "message": "The Claude CLI ended the session without reporting an error" }), + "the sidecar's own message is the cause (its closing period dropped)" + ); + assert!(cause.user_message().contains(&cause.detail())); + } + + #[test] + fn an_unreported_end_is_named_by_its_exit_status() { + let unknown = SidecarExitCause::new(None, None); + assert_eq!(unknown.reason(), PROCESS_ENDED_REASON); + let cause = unknown.to_json(); + assert_eq!(cause["reason"], "sidecar_exited"); + assert!(cause["message"].as_str().is_some_and(|m| !m.is_empty())); + assert_eq!(cause.get("exitCode"), None); + assert_eq!(cause.get("signal"), None); + } + + #[cfg(unix)] + #[test] + fn an_exit_code_and_a_signal_are_both_reported() { + use std::os::unix::process::ExitStatusExt; + let exited = SidecarExitCause::new(None, Some(ExitStatus::from_raw(1 << 8))); + let cause = exited.to_json(); + assert_eq!(cause["reason"], "sidecar_exited"); + assert_eq!(cause["exitCode"], 1); + assert_eq!(cause.get("signal"), None); + assert_eq!( + describe_status(Some(ExitStatus::from_raw(1 << 8))), + "code 1" + ); + assert_eq!( + describe_status(Some(ExitStatus::from_raw(libc::SIGKILL))), + "SIGKILL" + ); + assert_eq!(describe_status(None), "unknown"); + assert!(exited.user_message().contains(&exited.detail())); + + let killed = SidecarExitCause::new(None, Some(ExitStatus::from_raw(libc::SIGKILL))); + let cause = killed.to_json(); + assert_eq!(cause["signal"], "SIGKILL"); + assert_eq!(cause.get("exitCode"), None); + assert!(killed.detail().contains("SIGKILL")); + + let other = SidecarExitCause::new(None, Some(ExitStatus::from_raw(libc::SIGUSR1))); + assert_eq!( + other.to_json()["signal"], + format!("signal {}", libc::SIGUSR1) + ); + } + + #[test] + fn only_an_unreported_death_mid_turn_rings() { + let node_death = SidecarExitCause::new(None, None); + assert!(death_rings(true, &node_death)); + assert!(!death_rings(false, &node_death), "an idle death is silent"); + // The sidecar already rang the turn before it reported the end (interim). + assert!(!death_rings(true, &ended("cli_exited", "x"))); + assert!(!death_rings(true, &ended("stream_eof", ""))); + assert!(!death_rings(false, &ended("stream_error", "x"))); + } + + #[test] + fn the_exited_event_carries_the_code_the_message_and_the_cause() { + let cause = ended( + "cli_exited", + "Claude Code process terminated by signal SIGKILL", + ); + let event = sidecar_exited_event("sess-1", &cause); + assert_eq!(event["type"], "freshAgent.error"); + assert_eq!(event["sessionId"], "sess-1"); + assert_eq!(event["code"], "SIDECAR_EXITED"); + assert_eq!( + event["cause"], + json!({ "reason": "cli_exited", "message": "Claude Code process terminated by signal SIGKILL" }) + ); + assert!( + event["message"] + .as_str() + .is_some_and(|m| m.contains("Claude Code process terminated by signal SIGKILL")), + "the banner names the cause: {event}" + ); + } +} diff --git a/crates/freshell-freshagent/src/lib.rs b/crates/freshell-freshagent/src/lib.rs index ed186122f..704aea05f 100644 --- a/crates/freshell-freshagent/src/lib.rs +++ b/crates/freshell-freshagent/src/lib.rs @@ -37,6 +37,7 @@ //! backstop, by the harness sentinel sweep — no orphans. pub mod claude; +pub(crate) mod claude_sidecar_exit; pub(crate) mod claude_sidecar_io; pub(crate) mod claude_snapshot; pub mod codex; diff --git a/crates/freshell-ws/tests/common/claude_rig.rs b/crates/freshell-ws/tests/common/claude_rig.rs index c41c88f61..d1505b1ad 100644 --- a/crates/freshell-ws/tests/common/claude_rig.rs +++ b/crates/freshell-ws/tests/common/claude_rig.rs @@ -67,6 +67,9 @@ pub struct ClaudeRig { pub claude_config_dir: PathBuf, /// The simulator's ledger (JSONL; written by [`RigKind::Sim`] only). pub ledger: PathBuf, + /// The rig server's claude lane, for the server-side truths a test checks + /// (for example `has_live_session`). + pub fresh_claude: freshell_freshagent::FreshClaudeState, /// The local fake Anthropic endpoint ([`RigKind::Cli`] only), killed on drop. _provider: Option, _root: tempfile::TempDir, @@ -159,7 +162,7 @@ impl ClaudeRig { environment.push(((*key).into(), (*value).into())); } - let url = spawn_rig_server(environment).await; + let (url, fresh_claude) = spawn_rig_server(environment).await; eprintln!( "{}", json!({ @@ -173,6 +176,7 @@ impl ClaudeRig { workspace, claude_config_dir, ledger, + fresh_claude, _provider: provider, _root: root, } @@ -392,6 +396,23 @@ impl RigConnection { .collect() } + /// Keep reading frames for `quiet` and keep them in [`Self::frames`]: a bounded + /// window for asserting that nothing more arrives (a second edge, say). + pub async fn read_for(&mut self, quiet: Duration) { + let deadline = tokio::time::Instant::now() + quiet; + loop { + match tokio::time::timeout_at(deadline, self.ws.next()).await { + Ok(Some(Ok(WsMessage::Text(text)))) => { + self.frames + .push(serde_json::from_str(&text).expect("json frame")); + } + Ok(Some(Ok(_))) => {} + Ok(Some(Err(error))) => panic!("ws error while reading: {error}"), + Ok(None) | Err(_) => return, + } + } + } + /// The first `freshAgent.event` whose inner event has this type. pub async fn wait_for_event(&mut self, event_type: &str) -> Value { self.wait_for(&format!("a {event_type} event"), |v| { @@ -508,7 +529,9 @@ async fn start_local_provider() -> (tokio::process::Child, String) { /// A freshell-ws server on an ephemeral loopback port (never 3001/3002) with fresh /// agents enabled and a claude state whose sidecars get exactly `environment`. -async fn spawn_rig_server(environment: Vec<(String, String)>) -> String { +async fn spawn_rig_server( + environment: Vec<(String, String)>, +) -> (String, freshell_freshagent::FreshClaudeState) { let mut settings_value = test_settings_value(); settings_value["freshAgent"]["enabled"] = json!(true); let auth_token = Arc::new(AUTH_TOKEN.to_string()); @@ -525,6 +548,7 @@ async fn spawn_rig_server(environment: Vec<(String, String)>) -> String { let mut fresh_claude = freshell_freshagent::FreshClaudeState::new(Arc::clone(&broadcast_tx)) .with_sidecar_environment(environment); fresh_claude.set_ownership(Arc::clone(&ownership)); + let lane = fresh_claude.clone(); let state = WsState { layout: Default::default(), @@ -588,5 +612,5 @@ async fn spawn_rig_server(environment: Vec<(String, String)>) -> String { tokio::spawn(async move { let _ = axum::serve(listener, router).await; }); - format!("ws://{addr}/ws") + (format!("ws://{addr}/ws"), lane) } diff --git a/crates/freshell-ws/tests/freshagent_claude_lifecycle.rs b/crates/freshell-ws/tests/freshagent_claude_lifecycle.rs new file mode 100644 index 000000000..259ab99d9 --- /dev/null +++ b/crates/freshell-ws/tests/freshagent_claude_lifecycle.rs @@ -0,0 +1,249 @@ +//! How a freshclaude session ends when nobody asked it to (design §5.7 and §11.3 +//! of docs/plans/2026-10-22-freshclaude-core-runtime.md; G02, G23). Every case runs +//! the REAL claude sidecar over the CLI simulator (RealSidecarSim, +//! `common::claude_rig`). +//! +//! The sidecar hosts one session, so when that session's SDK stream ends it reports +//! the end (`sdk.stream.ended`) and exits; the server's end-of-output path then ends +//! the session: one `freshAgent.error{SIDECAR_EXITED}` carrying the cause, exactly +//! one attention edge when a turn was running, and no live session left behind. +//! A Node death reports nothing, so the server rings for it itself. +//! +//! Unix only: the tests read the sidecar's pid and signal it (rust-gate runs on Linux). +#![cfg(unix)] + +mod common; + +use std::time::Duration; + +use common::claude_rig::{ClaudeRig, RigConnection, RigKind, RIG_FRAME_BUDGET}; +use serde_json::{json, Value}; + +/// How long a test keeps reading after the exit report before it counts edges: the +/// server sends any edge of its own right after the report, so a second edge would +/// arrive well inside this window. +const QUIET: Duration = Duration::from_millis(500); + +async fn rig_with_program(rules: Value) -> ClaudeRig { + let program = json!({ "rules": rules }).to_string(); + ClaudeRig::start_with_env(RigKind::Sim, &[("FRESHELL_CLAUDE_SIM_PROGRAM", &program)]).await +} + +/// The pid of the sidecar process: the simulator runs inside it and stamps every +/// ledger row with its host's pid. +async fn sidecar_pid(rig: &ClaudeRig) -> i32 { + let deadline = tokio::time::Instant::now() + RIG_FRAME_BUDGET; + loop { + if let Some(row) = rig.ledger_rows("boot").first() { + return row["pid"].as_i64().expect("the boot row carries a pid") as i32; + } + assert!( + tokio::time::Instant::now() < deadline, + "the simulated CLI never booted" + ); + tokio::time::sleep(Duration::from_millis(20)).await; + } +} + +/// Whether a process with this pid still exists (a zombie counts: it is not gone). +fn process_exists(pid: i32) -> bool { + // SAFETY: signal 0 only checks for the process; nothing is sent. + unsafe { libc::kill(pid, 0) == 0 } +} + +async fn wait_until_gone(pid: i32) { + let deadline = tokio::time::Instant::now() + RIG_FRAME_BUDGET; + while process_exists(pid) { + assert!( + tokio::time::Instant::now() < deadline, + "the sidecar process {pid} is still there" + ); + tokio::time::sleep(Duration::from_millis(20)).await; + } +} + +async fn wait_for_sidecar_exited(conn: &mut RigConnection, session_id: &str) -> Value { + let frame = conn + .wait_for("freshAgent.error{SIDECAR_EXITED}", |v| { + v["type"] == "freshAgent.event" + && v["event"]["type"] == "freshAgent.error" + && v["event"]["code"] == "SIDECAR_EXITED" + }) + .await; + assert_eq!(frame["sessionId"], session_id, "{frame}"); + assert_eq!(frame["event"]["sessionId"], session_id, "{frame}"); + frame["event"].clone() +} + +/// The durable id the session's first turn reported (`freshAgent.session.init`). +fn durable_id(conn: &RigConnection, session_id: &str) -> String { + conn.frames + .iter() + .find_map(|v| { + (v["type"] == "freshAgent.event" + && v["event"]["type"] == "freshAgent.session.init" + && v["event"]["sessionId"] == session_id) + .then(|| v["event"]["cliSessionId"].as_str().map(str::to_string)) + .flatten() + }) + .expect("the session reported its durable id") +} + +/// The statuses broadcast for the session, in order. +fn statuses(conn: &RigConnection, session_id: &str) -> Vec { + conn.frames + .iter() + .filter(|v| { + v["type"] == "freshAgent.event" + && v["event"]["type"] == "freshAgent.status" + && v["sessionId"] == session_id + }) + .filter_map(|v| v["event"]["status"].as_str().map(str::to_string)) + .collect() +} + +/// The CLI dies mid-turn (SIGKILL, T14): one attention edge for the lost turn, one +/// SIDECAR_EXITED naming the CLI's death, and the sidecar process is gone. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_cli_crash_mid_turn_rings_once_and_reports_the_cause() { + let rig = rig_with_program(json!([{ "on": "msg:prompt", "emit": [ + { "kind": "text", "data": { "text": "partial" } }, + { "kind": "crash" }, + ] }])) + .await; + let mut conn = rig.connect().await; + let session_id = conn.create("create-1").await; + conn.send_text(&session_id, "send-1", "a turn the CLI dies in") + .await; + let pid = sidecar_pid(&rig).await; + + let exited = wait_for_sidecar_exited(&mut conn, &session_id).await; + assert_eq!( + exited["cause"], + json!({ "reason": "cli_exited", "message": "Claude Code process terminated by signal SIGKILL" }), + "{exited}" + ); + assert!( + exited["message"].as_str().is_some_and( + |message| message.contains("Claude Code process terminated by signal SIGKILL") + ), + "the banner says what happened: {exited}" + ); + wait_until_gone(pid).await; + + conn.read_for(QUIET).await; + assert_eq!( + conn.events_seen("freshAgent.turn.complete"), + 1, + "exactly one edge for the lost turn; frames: {:?}", + conn.frames + ); + assert_eq!( + conn.events_seen("freshAgent.result"), + 0, + "{:?}", + conn.frames + ); + assert_eq!( + conn.frames + .iter() + .filter(|v| v["event"]["code"] == "SIDECAR_EXITED") + .count(), + 1, + "one exit report; frames: {:?}", + conn.frames + ); +} + +/// The CLI ends the stream cleanly in the middle of the session's second turn (it +/// exits 0, so the SDK's iterator just ends). The earlier turn's result must not +/// leave the session busy (G23): the session ends, its last status is idle, and +/// the server no longer holds it as live. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_natural_end_of_stream_mid_turn_leaves_the_session_not_busy() { + let rig = rig_with_program( + json!([{ "on": "msg:prompt", "match": { "text": "second" }, "emit": [ + { "kind": "text", "data": { "text": "partial" } }, + { "kind": "crash", "data": { "code": 0 } }, + ] }]), + ) + .await; + let mut conn = rig.connect().await; + let session_id = conn.create("create-1").await; + conn.send_text(&session_id, "send-1", "first").await; + conn.wait_for_event("freshAgent.turn.complete").await; + let durable = durable_id(&conn, &session_id); + assert!(rig.fresh_claude.has_live_session(&durable).await); + let pid = sidecar_pid(&rig).await; + + conn.send_text(&session_id, "send-2", "second").await; + let exited = wait_for_sidecar_exited(&mut conn, &session_id).await; + assert_eq!(exited["cause"]["reason"], "stream_eof", "{exited}"); + wait_until_gone(pid).await; + + conn.read_for(QUIET).await; + assert_eq!( + statuses(&conn, &session_id).last().map(String::as_str), + Some("idle"), + "the session's last status is idle; frames: {:?}", + conn.frames + ); + assert!( + !rig.fresh_claude.has_live_session(&durable).await, + "the ended session is not held as live (and so cannot stay busy)" + ); + assert_eq!( + conn.events_seen("freshAgent.turn.complete"), + 2, + "one edge per turn; frames: {:?}", + conn.frames + ); +} + +/// The Node sidecar itself is killed mid-turn: it reports nothing, so the server +/// rings for the lost turn itself, once, and names the signal. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_node_death_mid_turn_rings_once() { + let rig = rig_with_program(json!([{ "on": "msg:prompt", "emit": [{ "kind": "hang" }] }])).await; + let mut conn = rig.connect().await; + let session_id = conn.create("create-1").await; + conn.send_text(&session_id, "send-1", "a turn that never ends") + .await; + conn.wait_for_event("freshAgent.session.init").await; + let pid = sidecar_pid(&rig).await; + // SAFETY: the pid is this rig's own sidecar, read from its simulator's ledger. + assert_eq!(unsafe { libc::kill(pid, libc::SIGKILL) }, 0); + + let exited = wait_for_sidecar_exited(&mut conn, &session_id).await; + assert_eq!(exited["cause"]["reason"], "sidecar_exited", "{exited}"); + assert_eq!(exited["cause"]["signal"], "SIGKILL", "{exited}"); + wait_until_gone(pid).await; + + conn.read_for(QUIET).await; + assert_eq!( + conn.events_seen("freshAgent.turn.complete"), + 1, + "exactly one edge for the lost turn; frames: {:?}", + conn.frames + ); +} + +/// An idle session whose CLI dies ends with the exit report and no edge. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn an_idle_cli_death_reports_the_exit_without_an_edge() { + let rig = + rig_with_program(json!([{ "on": "start", "emit": [{ "kind": "crash", "delayMs": 300 }] }])) + .await; + let mut conn = rig.connect().await; + let session_id = conn.create("create-1").await; + + let exited = wait_for_sidecar_exited(&mut conn, &session_id).await; + assert_eq!(exited["cause"]["reason"], "cli_exited", "{exited}"); + conn.read_for(QUIET).await; + assert_eq!( + conn.events_seen("freshAgent.turn.complete"), + 0, + "{:?}", + conn.frames + ); +} diff --git a/crates/freshell-ws/tests/freshagent_claude_rollback.rs b/crates/freshell-ws/tests/freshagent_claude_rollback.rs index f29563c5d..2fddc72d4 100644 --- a/crates/freshell-ws/tests/freshagent_claude_rollback.rs +++ b/crates/freshell-ws/tests/freshagent_claude_rollback.rs @@ -45,9 +45,10 @@ static CLAUDE_ENV_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(( /// the raw parentUuid chain) as the child's file — the source file is NEVER /// touched (Stage-2-proven SDK invariant: the original's JSONL is hash-identical /// after a fork). A ``-th `send` appends a uuid-chained u/a pair (the -/// user line carries the verbatim prompt text) and answers `sdk.status idle` -/// (the in_turn clear edge) — NEVER a result frame, so no completion chime can -/// leak into the rollback capture. +/// user line carries the verbatim prompt text) and answers with the turn's +/// `sdk.result` (the in_turn clear edge, as the real sidecar ends a turn) — and +/// NEVER a `sdk.turn.complete`, so no completion chime can leak into the rollback +/// capture. const FAKE_ROLLBACK_SIDECAR_SOURCE: &str = r#" import fs from 'node:fs' import path from 'node:path' @@ -131,9 +132,10 @@ rl.on('line', (line) => { const user = { type: 'user', uuid: u, parentUuid: parent, timestamp: `t${turnCount}a`, cwd: '/tmp', message: { role: 'user', content: [{ type: 'text', text: msg.text }] } } const assistant = { type: 'assistant', uuid: a, parentUuid: u, timestamp: `t${turnCount}b`, message: { role: 'assistant', content: [{ type: 'text', text: `answer ${turnCount}` }] } } fs.appendFileSync(transcriptPath(cliId), `${JSON.stringify(user)}\n${JSON.stringify(assistant)}\n`) - // The rollback capture surface must NEVER see a completion chime; idle is the - // busy-clear edge. - process.stdout.write(JSON.stringify({ type: 'sdk.status', sessionId: msg.sessionId, status: 'idle' }) + '\n') + // The rollback capture surface must NEVER see a completion chime; the + // turn's result is the busy-clear edge (no per-turn idle, like the real + // sidecar). + process.stdout.write(JSON.stringify({ type: 'sdk.result', sessionId: msg.sessionId, result: 'success', durationMs: 1, costUsd: 0, usage: {} }) + '\n') } else if (msg.type === 'rollback.quiesce') { // ep4-r3 wire parity: rollback's pre-teardown probe — this fake never has // an inflight turn at that point (its sends settle their statuses @@ -812,12 +814,11 @@ async fn drive_conversation( && v["requestId"] == json!(format!("send-{i}")) }) .await; - // The fake appends the u/a pair BEFORE answering idle, so this waits - // out both the transcript append AND the busy-clear edge. + // The fake appends the u/a pair BEFORE answering with the result, + // so this waits out both the transcript append AND the busy-clear edge. await_frame_into(&mut ws, &mut frames, 15, |v| { v["type"] == json!("freshAgent.event") - && v["event"]["type"] == json!("freshAgent.status") - && v["event"]["status"] == json!("idle") + && v["event"]["type"] == json!("freshAgent.result") }) .await; } diff --git a/test/e2e-browser/fixtures/fake-claude-sidecar.mjs b/test/e2e-browser/fixtures/fake-claude-sidecar.mjs index 22f0afc0c..160762c56 100644 --- a/test/e2e-browser/fixtures/fake-claude-sidecar.mjs +++ b/test/e2e-browser/fixtures/fake-claude-sidecar.mjs @@ -146,8 +146,11 @@ rl.on('line', (line) => { content: [{ type: 'text', text }], model, }) - emit({ type: 'sdk.turn.complete', sessionId: msg.sessionId, subtype: 'success', at: Date.now() }) - emit({ type: 'sdk.status', sessionId: msg.sessionId, status: 'idle' }) + // A turn ends the way the real sidecar ends it: its result, then the + // attention edge. No per-turn idle (the real sidecar sends idle only when + // its stream ends). + emit({ type: 'sdk.result', sessionId: msg.sessionId, result: 'success', durationMs: 1, costUsd: 0, usage: { input_tokens: 1, output_tokens: 1 } }) + emit({ type: 'sdk.turn.complete', sessionId: msg.sessionId, at: Date.now() }) } else if (msg.type === 'shutdown') { process.exit(0) } diff --git a/test/e2e-browser/fixtures/providers/fake-claude-sdk-sidecar.mjs b/test/e2e-browser/fixtures/providers/fake-claude-sdk-sidecar.mjs index b7b45651e..8046f9e10 100755 --- a/test/e2e-browser/fixtures/providers/fake-claude-sdk-sidecar.mjs +++ b/test/e2e-browser/fixtures/providers/fake-claude-sdk-sidecar.mjs @@ -36,8 +36,10 @@ // Turn semantics: `send` ALWAYS opens with sdk.status running (bookkeeping the // real bridge performs unconditionally); a matching program rule then owns // the turn; when no rule emitted completion/crash — AND no approval/question -// parked the turn — the canned assistant+turn.complete+idle success turn -// closes it. A turn that raised an approval/question PARKS: it stays open +// parked the turn — the canned assistant+result+turn.complete success turn +// closes it. Like the real sidecar, a turn ends with its result alone: there +// is no per-turn idle (the real sidecar sends idle only when its stream ends). +// A turn that raised an approval/question PARKS: it stays open // until a matching permission.respond/question.respond (or interrupt) arrives, // exactly like the real sidecar's parked canUseTool promise. // @@ -62,9 +64,8 @@ // (sdk.d.ts:2384-2394; the real sidecar's ok:false 'no in-flight SDK query' // shape fires only when the SDK surface lacks the interrupt method entirely) // — and NOTHING arms, so no stray mark survives to eat the NEXT unrelated -// turn's ring. Either way the turn ends with sdk.status idle — NEVER an -// sdk.exit (the real sidecar keeps the session alive across interrupt; -// AGENT-03's interrupt/kill separation). +// turn's ring. Either way it is NEVER an sdk.exit (the real sidecar keeps the +// session alive across interrupt; AGENT-03's interrupt/kill separation). // // Raw-stdin audit (AGENT-05/06 e2e): when FRESHELL_FAKE_STDIN= is set, // EVERY raw stdin line is appended there as a JSONL row {t, pid, line} — @@ -78,10 +79,14 @@ // subtype — success, error, max-turns, denied — EXCEPT the stopped turn's own // aborted result under a pending accepted user-interrupt mark (the user's own // interrupt never rings). -// The `stream-error` lane models the real consumeStream catch+finally for a -// mid-turn stream exception: sdk.error + the finally-minted edge + session -// teardown WITHOUT exiting the process; the `crash` lane stays as-is (a real -// crash screams no protocol frame and exits — the Rust unrequested-death +// The `stream-error` lane models the real sidecar's end of stream (its +// consumeStream finally): cancel the parked cards, the finally-minted edge for +// a turn that ends with no result, the stream-end idle, then +// `sdk.stream.ended {reason, message}` as the LAST line and exit code 3 (each +// sidecar hosts one session, so its stream's end is the sidecar's end). The +// Rust server reports the exit (SIDECAR_EXITED) and, because the end was +// reported, does not ring again. The `crash` lane stays as-is (a real Node +// death screams no protocol frame and exits — the Rust unrequested-death // synthesis rings for it, and an edge from the crash lane would double-ring). // // Wire audit (D1-F2): every OUTBOUND frame is ALSO recorded into the @@ -107,7 +112,7 @@ // Rust server uses; the sidecar inherits the harness's isolated HOME. // // The process stays alive until `shutdown` (exit 0), a scripted `crash` -// (exit code), or kill — an early exit would stop the server-side consumer. +// (exit code), a `stream-error` (exit 3), or kill. import fs from 'node:fs' import os from 'node:os' import path from 'node:path' @@ -118,6 +123,10 @@ import { createTurnCompleteGate } from '../../../../crates/freshell-claude-sidec import { nextMonotonic } from '../../../../crates/freshell-claude-sidecar/monotonic-clock.mjs' const provider = process.env.FRESHELL_FAKE_PROVIDER ?? 'kilroy' +/** The real sidecar's exit code after its SDK stream ends (stream-end.mjs). */ +const STREAM_ENDED_EXIT_CODE = 3 +/** Set by the stream-error lane: the fake is exiting and reads no more requests. */ +let exiting = false /** The SDK result the pinned CLI reports for a turn a Stop landed on (T11). */ const STOPPED_RESULT = Object.freeze({ type: 'result', subtype: 'error_during_execution', terminal_reason: 'aborted_streaming' }) const env = process.env @@ -298,19 +307,15 @@ async function render(event) { emit({ type: 'sdk.turn.complete', sessionId, at }) } st.turnOpen = false - emit({ type: 'sdk.status', sessionId, status: 'idle' }) break } case 'stream-error': { - // The real consumeStream catch+finally for a mid-turn stream exception: - // the catch arm's sdk.error, then the finally's teardown — cancel every - // parked entry, mint the unified attention edge (a turn ended with NO - // result frame: pendingResults > 0, not aborted; a pending Stop mark - // never silences a result-less end), idle - // status — and the session is deleted WITHOUT exiting the process (a - // later create on the same live process works). The separate `crash` - // lane deliberately stays different: no protocol frame + process exit. - emit({ type: 'sdk.error', sessionId, message: data.message ?? 'SDK error: fixture stream error' }) + // The real sidecar's end of stream (index.mjs consumeStream finally): + // cancel every parked entry, mint the unified attention edge (a turn + // ended with NO result frame: pendingResults > 0; a pending Stop mark + // never silences a result-less end), the stream-end idle, then + // sdk.stream.ended as the LAST line and exit code 3. The separate + // `crash` lane deliberately stays different: no protocol frame + exit. const st = sessions.get(sessionId) if (st) { for (const entry of st.pendingEntries.splice(0)) { @@ -329,6 +334,14 @@ async function render(event) { } emit({ type: 'sdk.status', sessionId, status: 'idle' }) if (st) sessions.delete(sessionId) + emit({ + type: 'sdk.stream.ended', + sessionId, + reason: data.reason ?? 'stream_error', + message: data.message ?? 'Fixture stream error', + }) + exiting = true + process.stdout.write('', () => process.exit(STREAM_ENDED_EXIT_CODE)) break } case 'marker': @@ -355,6 +368,8 @@ const engine = new FixtureEngine({ const rl = readline.createInterface({ input: process.stdin }) rl.on('line', (line) => { appendJsonl(STDIN_LOG, { t: Date.now(), pid: process.pid, line }) + // Like the real sidecar: once its stream ended, sdk.stream.ended stays the last line. + if (exiting) return void handleInput(line).catch((err) => { emit({ type: 'sdk.error', sessionId: activeSessionId, message: String(err?.message ?? err) }) }) @@ -562,7 +577,7 @@ async function handleInput(line) { if (st.sendInFlight) return // Parked/plain in-flight turn: nothing else will end it, so synthesize // the interrupted turn's own non-success terminal result — the armed - // gate consumes its ring — then idle. Still NEVER an sdk.exit. The + // gate consumes its ring. Still NEVER an sdk.exit. The // counter goes to ZERO, not minus one: the fake models ONE in-flight // turn, so the interrupt ends everything pending — a stale-positive // counter here would let a later stream-error lane mint a phantom ring @@ -572,7 +587,6 @@ async function handleInput(line) { st.turnCompleteGate.resultEmitsAttention(STOPPED_RESULT) st.turnOpen = false st.interrupted = false - emit({ type: 'sdk.status', sessionId: msg.sessionId, status: 'idle' }) return } // No turn awaits a terminal frame — real-sidecar parity: the SDK RESOLVES diff --git a/test/e2e-browser/helpers/fake-claude-sdk-sidecar-control.test.ts b/test/e2e-browser/helpers/fake-claude-sdk-sidecar-control.test.ts index 6d78970d5..c991b2d59 100644 --- a/test/e2e-browser/helpers/fake-claude-sdk-sidecar-control.test.ts +++ b/test/e2e-browser/helpers/fake-claude-sdk-sidecar-control.test.ts @@ -3,6 +3,7 @@ // permission.respond/question.respond pending-resolution (waiting edge // re-fires), interrupt-time pending cancellation (cancel frames, never an // sdk.exit), the parked-turn rule (no canned completion while parked), the +// stream-error lane's exit (the real sidecar's end of stream), the // FRESHELL_FAKE_STDIN raw-stdin audit, and the durable-transcript writes the // reload-while-pending snapshot route depends on. Process/wire-level contract // for all seven fixtures stays in specs/harness-03-provider-fixtures.spec.ts; @@ -269,7 +270,8 @@ describe('fake-claude-sdk-sidecar respond/interrupt arms (AGENT-05/06 fixture)', expect(cancelledPermission).toMatchObject({ sessionId, requestId: 'req-perm-1' }) const cancelledQuestion = await fx.waitLine((o) => o.type === 'sdk.question.cancelled', 'sdk.question.cancelled') expect(cancelledQuestion).toMatchObject({ sessionId, requestId: 'req-q-1' }) - await fx.waitLine((o) => o.type === 'sdk.status' && o.status === 'idle', 'idle after interrupt') + // The interrupted turn ends with its own (aborted) result, like the real CLI's (T11). + await fx.waitLine((o) => o.type === 'sdk.result' && o.result === 'error_during_execution', 'the interrupted turn result') // AGENT-03 separation: interrupt ends the turn, never the session. expect(fx.stdoutLines().some((o) => o.type === 'sdk.exit')).toBe(false) @@ -289,7 +291,7 @@ describe('fake-claude-sdk-sidecar respond/interrupt arms (AGENT-05/06 fixture)', } }) - it('a deny/errored completion emits sdk.result + idle AND the unified attention edge (needs-attention)', async () => { + it('a deny/errored completion emits sdk.result AND the unified attention edge, and no per-turn idle (needs-attention)', async () => { const eventsLog = path.join(tmp, 'events.jsonl') const fx = launch({ rules: [ @@ -319,13 +321,13 @@ describe('fake-claude-sdk-sidecar respond/interrupt arms (AGENT-05/06 fixture)', fx.send({ type: 'permission.respond', sessionId, requestId: 'req-perm-1', decision: { behavior: 'deny' } }) // Race-free: the errored sdk.result is the terminal marker for the deny - // continuation (the following sdk.status idle renders synchronously). + // continuation (its edge renders synchronously after it). await fx.waitLine((o) => o.type === 'sdk.result' && o.result === 'error', 'errored sdk.result') // Unified needs-attention contract: a deny is an ERROR TURN END, so the // fake (mirroring the real sidecar's gate) rings the SAME attention - // edge a success would — exactly one, minted after the errored result - // and before the idle close, with a finite numeric monotonic `at`. + // edge a success would — exactly one, minted after the errored result, + // with a finite numeric monotonic `at`. const denyEdge = await fx.waitLine( (o) => o.type === 'sdk.turn.complete' && o.sessionId === sessionId, 'the deny turn.complete attention edge', @@ -343,10 +345,12 @@ describe('fake-claude-sdk-sidecar respond/interrupt arms (AGENT-05/06 fixture)', const denyEdgeIdx = out.findIndex((o) => o.type === 'sdk.turn.complete' && o.sessionId === sessionId) expect(resultIdx, 'the denied turn produced an errored sdk.result').toBeGreaterThanOrEqual(0) expect(denyEdgeIdx, 'the edge follows the errored result in stream order').toBeGreaterThan(resultIdx) - const idleIdx = out.findIndex( - (o, i) => i > denyEdgeIdx && o.type === 'sdk.status' && o.sessionId === sessionId && o.status === 'idle', - ) - expect(idleIdx, 'the idle close follows the edge in stream order').toBeGreaterThan(denyEdgeIdx) + // Like the real sidecar, the turn ends with its result alone: no idle + // follows it (the real sidecar sends idle only when its stream ends). + expect( + out.filter((o, i) => i > resultIdx && o.type === 'sdk.status' && o.sessionId === sessionId && o.status === 'idle'), + 'no per-turn idle after the result', + ).toEqual([]) expect( out.some((o) => o.type === 'sdk.assistant' && o.sessionId === sessionId && o.content?.[0]?.text?.includes('denied')), @@ -426,38 +430,27 @@ describe('fake-claude-sdk-sidecar respond/interrupt arms (AGENT-05/06 fixture)', } }) - it('a completion against a session that no longer exists is dropped entirely (real-sidecar parity, task-004 F-M2)', async () => { + it('the stream-error lane ends the sidecar like the real one: one edge, sdk.stream.ended last, exit code 3', async () => { const fx = launch({ rules: [ { on: 'msg:send', match: { text: 'DIE' }, emit: [{ kind: 'stream-error' }] }, - { on: 'msg:send', match: { text: 'WITNESS' }, emit: [{ kind: 'activity', data: { status: 'compacting' } }] }, ], }) try { fx.send({ type: 'create', requestId: 'req-1', cwd: tmp }) const created = await fx.waitLine((o) => o.type === 'created', 'created') const sessionId = created.sessionId as string + const exited = new Promise((resolve) => fx.proc.once('exit', (code) => resolve(code))) - // The stream-error lane tears the session down mid-turn; its - // finally-mint edge fires (the send was accepted, pendingResults > 0). + // The stream ends mid-turn: the finally-mint edge fires (the send was + // accepted, pendingResults > 0), then the end report, then the exit. fx.send({ type: 'send', sessionId, text: 'DIE' }) - await fx.waitLine((o) => o.type === 'sdk.error', 'stream-error sdk.error') - await fx.waitLine((o) => o.type === 'sdk.turn.complete', 'the stream-error finally-mint edge') - - // Deterministic settle: a follow-up send against the dead session - // renders the WITNESS activity, proving the engine kept processing — - // while the default completion renders behind BOTH sends stay DROPPED. - // The real sidecar drops the whole message for a missing session - // (index.mjs:215-216): no assistant, no result, no second edge. - fx.send({ type: 'send', sessionId, text: 'WITNESS' }) - await fx.waitLine((o) => o.type === 'sdk.status' && o.status === 'compacting', 'witness activity') + expect(await exited).toBe(3) const out = fx.stdoutLines() - expect(out.filter((o) => o.type === 'sdk.assistant'), 'no assistant frame for a dead session').toEqual([]) - expect(out.filter((o) => o.type === 'sdk.result'), 'no result frame for a dead session').toEqual([]) - expect( - out.filter((o) => o.type === 'sdk.turn.complete'), - 'exactly the stream-error edge — no completion-render edge for the corpse', - ).toHaveLength(1) + expect(out.at(-1)).toEqual({ type: 'sdk.stream.ended', sessionId, reason: 'stream_error', message: 'Fixture stream error' }) + expect(out.filter((o) => o.type === 'sdk.turn.complete'), 'exactly the result-less turn end rings').toHaveLength(1) + expect(out.filter((o) => o.type === 'sdk.result'), 'the turn ended with no result').toEqual([]) + expect(out.filter((o) => o.type === 'sdk.error'), 'the end report carries the cause; no error frame repeats it').toEqual([]) } finally { await fx.stop() } diff --git a/test/e2e-browser/specs/fresh-agent-error-turn-attention.spec.ts b/test/e2e-browser/specs/fresh-agent-error-turn-attention.spec.ts index e85d91969..ac661e0f3 100644 --- a/test/e2e-browser/specs/fresh-agent-error-turn-attention.spec.ts +++ b/test/e2e-browser/specs/fresh-agent-error-turn-attention.spec.ts @@ -23,9 +23,11 @@ // still folds the edge and sets the attention flag — the bell rings for // any tab when the window is unfocused (the sound suppression split is // unit-pinned; e2e asserts the fold partition); -// 5. a mid-turn stream exception (sdk.error + the finally-minted edge, NO -// process exit) rings exactly one edge, resolves the pane to idle, and -// leaves the sidecar process alive (no unrequested-death double edge). +// 5. a mid-turn end of the SDK stream (the finally-minted edge, then +// sdk.stream.ended and the sidecar's exit) rings exactly one edge, +// resolves the pane to idle, reports the exit (SIDECAR_EXITED), and the +// sidecar process is gone: a stream end is a session end (G02), and the +// server does not ring a second time for the reported end. // // The user-Stop lane runs on the PRODUCTION sidecar // (crates/freshell-claude-sidecar/index.mjs) over the CLI simulator @@ -87,10 +89,9 @@ const FAKE_PROGRAM = { ], }, { - // Mid-turn stream-exception lane (NO process exit): sdk.error + the - // finally-minted unified edge + session teardown. The sidecar stays - // alive — the separate `crash` lane (silent process death) belongs to - // the Rust-synthesis lanes. + // Mid-turn end-of-stream lane: the finally-minted unified edge, then + // sdk.stream.ended and exit code 3, like the real sidecar. The separate + // `crash` lane (silent process death) belongs to the Rust-synthesis lanes. on: 'msg:send', match: { text: 'STREAM_ERROR' }, emit: [{ kind: 'stream-error', delayMs: 3_000 }], @@ -355,47 +356,67 @@ test.describe('fresh-agent error-turn attention (unified needs-attention signal) .toBeNull() await paneStatus().toBe('idle') - // ── Step 5: mid-turn stream exception — NO process exit ──────────── - // The fake's stream-error lane models the real consumeStream - // catch+finally: sdk.error + the finally-minted unified edge + session - // teardown, with the sidecar process still alive. + // ── Step 5: the SDK stream ends mid-turn — the sidecar exits ─────── + // The fake's stream-error lane models the real sidecar's end of stream: + // the finally-minted unified edge, then sdk.stream.ended as its last + // line and exit code 3. The server reports the exit and, because the end + // was reported, does not ring again. await composer.fill('STREAM_ERROR') await sendButton.click() await page.locator(`[data-context="tab"][data-tab-id="${tabB}"]`).click() await expect .poll(async () => (await turnCompletionState()).seq ?? 0, - { timeout: 30_000, message: 'the stream-exception turn rings exactly one unified edge (seq 4)' }) + { timeout: 30_000, message: 'the turn the stream ended in rings exactly one unified edge (seq 4)' }) .toBe(4) await expect .poll(async () => (await turnCompletionState()).attentionByTab?.[tabA] ?? null, - { timeout: 15_000, message: 'the unwitnessed stream exception sets attention' }) + { timeout: 15_000, message: 'the unwitnessed end sets attention' }) .toBe(true) await expect .poll(async () => { const layout = await harness.getPaneLayout(tabA) return findFreshAgentLeaf(layout)?.content?.status ?? null - }, { timeout: 30_000, message: 'the pane resolves to idle after the stream exception' }) + }, { timeout: 30_000, message: 'the pane resolves to idle after the stream ends' }) .toBe('idle') - - // The sidecar process is STILL ALIVE: the wire rows carry its pid and - // signal-0 proves the process exists — a process death would have let - // the Rust unrequested-death synthesis mint a SECOND edge, which the - // exact-one seq pin above already excludes; this is the direct proof. + // The server reported the sidecar's exit to the pane. + await expect + .poll(async () => { + const state = await harness.getState() + const sessions = Object.values(state?.freshAgent?.sessions ?? {}) as any[] + return sessions.find((session) => session.sessionId === bridgeSessionId)?.lastErrorCode ?? null + }, { timeout: 30_000, message: 'the pane learns the sidecar exited (SIDECAR_EXITED)' }) + .toBe('SIDECAR_EXITED') + + // The sidecar process is GONE: the wire rows carry its pid, the last + // one it wrote is the end report, and signal 0 finds no process. const streamWires = readJsonl(path.join(sharedRoot, 'sidecar-events.jsonl')) .filter((r) => r.kind === 'wire' && r.frame?.sessionId === bridgeSessionId) expect(streamWires.length, 'the session produced wire rows to read the sidecar pid from').toBeGreaterThan(0) const sidecarPid = streamWires[0].pid as number - expect(() => process.kill(sidecarPid, 0), 'the sidecar process survived the stream error (no exit)').not.toThrow() - const streamErrIdx = streamWires.findIndex((r) => r.frame?.type === 'sdk.error') - expect(streamErrIdx, 'the stream-exception lane surfaced its sdk.error frame').toBeGreaterThanOrEqual(0) + expect(streamWires.at(-1)?.frame, 'the sidecar wrote sdk.stream.ended last').toMatchObject({ type: 'sdk.stream.ended', reason: 'stream_error' }) + await expect + .poll(() => { + try { + process.kill(sidecarPid, 0) + return 'alive' + } catch { + return 'gone' + } + }, { timeout: 30_000, message: 'the sidecar process exited after its stream ended' }) + .toBe('gone') + // Each send opens with a running status: count the edges after the last one. + const lastSendOpen = streamWires + .map((r) => r.frame?.type === 'sdk.status' && r.frame?.status === 'running') + .lastIndexOf(true) + expect(lastSendOpen, 'the ended turn opened with a running status').toBeGreaterThanOrEqual(0) expect( - streamWires.slice(streamErrIdx).filter((r) => r.frame?.type === 'sdk.turn.complete'), - 'exactly one unified edge for the stream-exception turn (fenced after its sdk.error; the session\'s earlier turns each rang their own edge)', + streamWires.slice(lastSendOpen).filter((r) => r.frame?.type === 'sdk.turn.complete'), + 'exactly one unified edge for the ended turn (the session\'s earlier turns each rang their own edge)', ).toHaveLength(1) - // One edge total for the lane — stable across a settle window (no - // death-synthesis double ring). + // One edge total for the lane — stable across a settle window (the + // reported end gets no second, server-minted edge). await page.waitForTimeout(1_500) expect((await turnCompletionState()).seq).toBe(4) } finally { diff --git a/test/unit/claude-sidecar/interrupt-idle-gate.test.ts b/test/unit/claude-sidecar/interrupt-idle-gate.test.ts index cb7d4abd2..8e2c8b324 100644 --- a/test/unit/claude-sidecar/interrupt-idle-gate.test.ts +++ b/test/unit/claude-sidecar/interrupt-idle-gate.test.ts @@ -161,7 +161,7 @@ describe('Claude sidecar interrupt gate arming (unified attention edge)', () => bridge.sendLines([{ type: 'send', sessionId, text: 'CRASH_SOON' }, { type: 'interrupt', sessionId }]) expect(await bridge.waitFor('sdk.interrupt_settled')).toMatchObject({ sessionId, ok: true }) await bridge.waitForPrompt(1) - expect(await bridge.waitFor('sdk.error')).toMatchObject({ sessionId }) + expect(await bridge.waitFor('sdk.stream.ended')).toMatchObject({ sessionId, reason: 'cli_exited' }) expect(await bridge.waitFor('sdk.turn.complete')).toMatchObject({ sessionId }) expect(bridge.frames.filter((frame) => frame.type === 'sdk.result')).toEqual([]) }) diff --git a/test/unit/claude-sidecar/sidecar-log.test.ts b/test/unit/claude-sidecar/sidecar-log.test.ts index 4dd9c3b83..c79ce4436 100644 --- a/test/unit/claude-sidecar/sidecar-log.test.ts +++ b/test/unit/claude-sidecar/sidecar-log.test.ts @@ -154,10 +154,18 @@ describe('the sidecar log', () => { const sessionId = await sidecar.create() sidecar.send({ type: 'send', sessionId, text: 'hello' }) const ended = await waitForRecord(sidecar, 'stream.ended', (record) => record.event === 'stream.ended') - expect(ended).toMatchObject({ severity: 'warn', component: 'claude-sidecar', sessionId, reason: 'stream_error' }) + expect(ended).toMatchObject({ severity: 'warn', component: 'claude-sidecar', sessionId, reason: 'cli_exited' }) expect(ended.error).toContain('terminated by signal SIGKILL') }) + it('logs an SDK stream that ends without an error at info, with no error', async () => { + const sidecar = await startRealSidecar({ program: { rules: [{ on: 'msg:prompt', emit: [{ kind: 'crash', data: { code: 0 } }] }] } }) + const sessionId = await sidecar.create() + sidecar.send({ type: 'send', sessionId, text: 'hello' }) + const ended = await waitForRecord(sidecar, 'stream.ended', (record) => record.event === 'stream.ended') + expect(ended).toEqual({ severity: 'info', component: 'claude-sidecar', event: 'stream.ended', sessionId, reason: 'stream_eof' }) + }) + it('writes only JSON records with a real severity and an event name', async () => { const sidecar = await startRealSidecar() const sessionId = await sidecar.create() diff --git a/test/unit/claude-sidecar/stream-end.test.ts b/test/unit/claude-sidecar/stream-end.test.ts new file mode 100644 index 000000000..b27722054 --- /dev/null +++ b/test/unit/claude-sidecar/stream-end.test.ts @@ -0,0 +1,141 @@ +// @vitest-environment node + +// The sidecar's end of stream (G02, G23; design §5.1 and §5.7 of +// docs/plans/2026-10-22-freshclaude-core-runtime.md). Each sidecar hosts one +// session, so when its SDK message stream ends without being asked to, the +// sidecar is done: it reports how the stream ended in `sdk.stream.ended`, its +// last stdout line, and exits with code 3. The Rust server's end-of-output path +// then ends the session. A requested teardown (shutdown, stdin closing) exits 0 +// and reports nothing about the stream. +// +// Driven through the production index.mjs over the CLI simulator (harness.ts), +// plus the pure `streamEnd` classification on the SDK's exact error strings +// (claude-cli-contract.test.ts pins the CLI-death message, T14). + +import { describe, expect, it } from 'vitest' +import { STREAM_ENDED_EXIT_CODE, streamEnd } from '../../../crates/freshell-claude-sidecar/stream-end.mjs' +import { startRealSidecar, type RealSidecar, type SidecarFrame } from './harness' + +const ALERT_FRAMES = new Set(['sdk.stream.ended', 'sdk.turn.complete', 'sdk.error', 'sdk.status']) + +/** A session whose simulated CLI has booted, with nothing in flight. */ +async function bootedSession(sidecar: RealSidecar) { + const sessionId = await sidecar.create() + await sidecar.waitForBoot() + return sessionId +} + +function framesOf(sidecar: RealSidecar, type: string): SidecarFrame[] { + return sidecar.frames.filter((frame) => frame.type === type) +} + +describe('streamEnd', () => { + it('names a CLI death by the SDK\'s own message (a signal or an exit code)', () => { + expect(streamEnd({ error: new Error('Claude Code process terminated by signal SIGKILL') })) + .toEqual({ reason: 'cli_exited', message: 'Claude Code process terminated by signal SIGKILL' }) + expect(streamEnd({ error: new Error('Claude Code process exited with code 143') })) + .toEqual({ reason: 'cli_exited', message: 'Claude Code process exited with code 143' }) + }) + + it('calls any other error a stream error, keeping its message', () => { + expect(streamEnd({ error: new Error('Unexpected end of JSON input') })) + .toEqual({ reason: 'stream_error', message: 'Unexpected end of JSON input' }) + expect(streamEnd({ error: 'a thrown string' })).toEqual({ reason: 'stream_error', message: 'a thrown string' }) + }) + + it('calls a stream that ended without an error an end of stream', () => { + expect(streamEnd(undefined)).toMatchObject({ reason: 'stream_eof' }) + expect(streamEnd(undefined).message).toEqual(expect.any(String)) + }) + + it('cuts a long message (the SDK appends the CLI\'s stderr tail)', () => { + const long = `Claude Code process exited with code 1\n${'x'.repeat(5000)}` + const ended = streamEnd({ error: new Error(long) }) + expect(ended.reason).toBe('cli_exited') + expect(ended.message.length).toBeLessThanOrEqual(1000) + expect(long.startsWith(ended.message)).toBe(true) + }) +}) + +describe('the sidecar exits when its SDK stream ends', () => { + it('a CLI crash mid-turn: sdk.stream.ended (cli_exited) is the last line and the exit code is 3', async () => { + const sidecar = await startRealSidecar({ program: { rules: [{ on: 'msg:prompt', emit: [{ kind: 'text', data: { text: 'partial' } }, { kind: 'crash' }] }] } }) + const sessionId = await bootedSession(sidecar) + sidecar.send({ type: 'send', sessionId, text: 'a turn the CLI dies in' }) + + expect(await sidecar.waitExit()).toEqual({ code: STREAM_ENDED_EXIT_CODE, signal: null }) + expect(sidecar.frames.at(-1)).toEqual({ + type: 'sdk.stream.ended', + sessionId, + reason: 'cli_exited', + message: 'Claude Code process terminated by signal SIGKILL', + }) + // The cause travels in sdk.stream.ended; no second error frame repeats it. + expect(framesOf(sidecar, 'sdk.error')).toEqual([]) + expect(framesOf(sidecar, 'sdk.result')).toEqual([]) + // Interim (until Rust mints every edge, C18): the sidecar still rings the + // result-less turn end, once. + expect(framesOf(sidecar, 'sdk.turn.complete')).toHaveLength(1) + }) + + it('a natural end of stream mid-turn (the CLI exits 0): sdk.stream.ended (stream_eof) and exit code 3', async () => { + const sidecar = await startRealSidecar({ program: { rules: [ + { on: 'msg:prompt', match: { text: 'second' }, emit: [{ kind: 'text', data: { text: 'partial' } }, { kind: 'crash', data: { code: 0 } }] }, + ] } }) + const sessionId = await bootedSession(sidecar) + sidecar.send({ type: 'send', sessionId, text: 'first' }) + await sidecar.waitFor('sdk.turn.complete') + sidecar.send({ type: 'send', sessionId, text: 'second' }) + + expect(await sidecar.waitExit()).toEqual({ code: STREAM_ENDED_EXIT_CODE, signal: null }) + expect(sidecar.frames.at(-1)).toMatchObject({ type: 'sdk.stream.ended', sessionId, reason: 'stream_eof' }) + expect(framesOf(sidecar, 'sdk.result')).toHaveLength(1) + // One edge for the first turn's result, one (interim) for the second turn's end. + expect(framesOf(sidecar, 'sdk.turn.complete')).toHaveLength(2) + }) + + it('a stream that fails for another reason: sdk.stream.ended (stream_error) and exit code 3', async () => { + // An approval step under bypassPermissions is a simulator failure: the + // stream throws an error that is not a CLI death. + const sidecar = await startRealSidecar({ program: { rules: [{ on: 'msg:prompt', emit: [{ kind: 'approval', data: { tool: 'Bash', input: { command: 'ls' } } }] }] } }) + const sessionId = await sidecar.create({ permissionMode: 'bypassPermissions' }) + await sidecar.waitForBoot() + sidecar.send({ type: 'send', sessionId, text: 'go' }) + + expect(await sidecar.waitExit()).toEqual({ code: STREAM_ENDED_EXIT_CODE, signal: null }) + const ended = sidecar.frames.at(-1)! + expect(ended).toMatchObject({ type: 'sdk.stream.ended', sessionId, reason: 'stream_error' }) + expect(ended.message).toMatch(/^Claude CLI simulator failure: /) + }) + + it('an idle session whose CLI dies still ends the sidecar, with no edge', async () => { + const sidecar = await startRealSidecar({ program: { rules: [{ on: 'start', emit: [{ kind: 'crash', delayMs: 200 }] }] } }) + const sessionId = await sidecar.create() + + expect(await sidecar.waitExit()).toEqual({ code: STREAM_ENDED_EXIT_CODE, signal: null }) + expect(sidecar.frames.at(-1)).toMatchObject({ type: 'sdk.stream.ended', sessionId, reason: 'cli_exited' }) + expect(framesOf(sidecar, 'sdk.turn.complete')).toEqual([]) + }) + + it('a shutdown request mid-turn exits 0 and reports nothing alert-related', async () => { + const sidecar = await startRealSidecar() + const sessionId = await bootedSession(sidecar) + sidecar.send({ type: 'send', sessionId, text: 'SLOW a turn still streaming' }) + await sidecar.waitFor('sdk.session.init') + sidecar.send({ type: 'shutdown' }) + + expect(await sidecar.waitExit()).toEqual({ code: 0, signal: null }) + expect(sidecar.frames.filter((frame) => ALERT_FRAMES.has(frame.type))).toEqual([]) + }) + + it('stdin closing (the server let go of the sidecar) exits 0 and reports nothing alert-related', async () => { + const sidecar = await startRealSidecar() + const sessionId = await bootedSession(sidecar) + sidecar.send({ type: 'send', sessionId, text: 'SLOW a turn still streaming' }) + await sidecar.waitFor('sdk.session.init') + sidecar.child.stdin!.end() + + expect(await sidecar.waitExit()).toEqual({ code: 0, signal: null }) + expect(sidecar.frames.filter((frame) => ALERT_FRAMES.has(frame.type))).toEqual([]) + }) +}) From 94783e0c80fbf21517eeb731aa691ec184e47388 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Thu, 22 Oct 2026 13:47:21 -0700 Subject: [PATCH 03/23] test(freshclaude): the e2e Deny lane expects no per-turn idle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to C08 (the fakes stop emitting a per-turn idle). The claude lane's "approval: Deny …" test in fresh-agent-control-rust.spec.ts waited for the fake sidecar's trailing `sdk.status idle` after the denied turn's errored result and used it as the fence for the exactly-one-edge check. The real sidecar ends a turn with its result alone (it sends idle only when its stream ends), and so does the fake now, so the wait timed out on the cloud backend ("the errored sdk.result + unified edge + trailing idle marker for the denied turn", 3 of 3 attempts). The lane now waits for the errored result and the edge after it, still asserts exactly one edge for the denied turn (fenced before the follow-up send), and asserts that no per-turn idle follows the result. Co-Authored-By: Claude Opus 5.5 --- .../specs/fresh-agent-control-rust.spec.ts | 50 +++++++++---------- 1 file changed, 23 insertions(+), 27 deletions(-) diff --git a/test/e2e-browser/specs/fresh-agent-control-rust.spec.ts b/test/e2e-browser/specs/fresh-agent-control-rust.spec.ts index 38e921880..643daa9e5 100644 --- a/test/e2e-browser/specs/fresh-agent-control-rust.spec.ts +++ b/test/e2e-browser/specs/fresh-agent-control-rust.spec.ts @@ -703,57 +703,53 @@ test.describe('fresh-agent control surfaces — claude lane (rust)', () => { // sidecar's gate — the denied turn's ERRORED result rings the SAME // sdk.turn.complete edge a success would. A deny is an error turn // end; only a user-initiated interrupt stays silent. Race-free: poll - // the outbound ledger until the errored result AND the edge AND the - // following idle marker exist, THEN assert the edge's exact-once shape - // — fenced BEFORE the follow-up send below (a LATER, legitimately- - // completing turn on the same session). + // the outbound ledger until the errored result AND the edge that + // follows it exist, THEN assert the edge's exact-once shape — fenced + // BEFORE the follow-up send below (a LATER, legitimately-completing + // turn on the same session). Like the real sidecar, the turn ends with + // its result alone: no per-turn idle follows it. const deniedBridgeId = respond.sessionId as string + const denyWires = () => readJsonl(lane.eventsLog).filter((r) => r.kind === 'wire') + const denyResultIndex = (wires: any[]) => wires.findIndex( + (r) => r.frame?.type === 'sdk.result' && r.frame?.sessionId === deniedBridgeId + && r.frame?.result === 'error', + ) await expect .poll( () => { - const wires = readJsonl(lane.eventsLog).filter((r) => r.kind === 'wire') - const resultIdx = wires.findIndex( - (r) => r.frame?.type === 'sdk.result' && r.frame?.sessionId === deniedBridgeId - && r.frame?.result === 'error', - ) + const wires = denyWires() + const resultIdx = denyResultIndex(wires) if (resultIdx === -1) return false - const idleIdx = wires.findIndex( - (r, i) => i > resultIdx && r.frame?.type === 'sdk.status' && r.frame?.sessionId === deniedBridgeId - && r.frame?.status === 'idle', - ) - if (idleIdx === -1) return false return wires - .slice(resultIdx, idleIdx + 1) + .slice(resultIdx) .some((r) => r.frame?.type === 'sdk.turn.complete' && r.frame?.sessionId === deniedBridgeId) }, - { timeout: 15_000, message: 'the errored sdk.result + unified edge + trailing idle marker for the denied turn' }, + { timeout: 15_000, message: 'the errored sdk.result + the unified edge after it for the denied turn' }, ) .toBe(true) - const wires = readJsonl(lane.eventsLog).filter((r) => r.kind === 'wire') - const denyResultIdx = wires.findIndex( - (r) => r.frame?.type === 'sdk.result' && r.frame?.sessionId === deniedBridgeId - && r.frame?.result === 'error', - ) + const wires = denyWires() + const denyResultIdx = denyResultIndex(wires) expect(denyResultIdx, 'the denied turn produced an errored sdk.result').toBeGreaterThanOrEqual(0) const denyEdgeIdx = wires.findIndex( (r, i) => i > denyResultIdx && r.frame?.type === 'sdk.turn.complete' && r.frame?.sessionId === deniedBridgeId, ) expect(denyEdgeIdx, 'the unified attention edge follows the errored result in stream order').toBeGreaterThan(denyResultIdx) - const denyIdleIdx = wires.findIndex( - (r, i) => i > denyEdgeIdx && r.frame?.type === 'sdk.status' && r.frame?.sessionId === deniedBridgeId - && r.frame?.status === 'idle', - ) - expect(denyIdleIdx, 'the idle close follows the edge in stream order').toBeGreaterThan(denyEdgeIdx) expect( Number.isFinite(wires[denyEdgeIdx]?.frame?.at), 'the edge carries a finite numeric monotonic at', ).toBe(true) expect( wires - .slice(denyResultIdx, denyIdleIdx + 1) + .slice(denyResultIdx) .filter((r) => r.frame?.type === 'sdk.turn.complete' && r.frame?.sessionId === deniedBridgeId), 'exactly one unified edge for the denied turn', ).toHaveLength(1) + expect( + wires + .slice(denyResultIdx) + .filter((r) => r.frame?.type === 'sdk.status' && r.frame?.sessionId === deniedBridgeId && r.frame?.status === 'idle'), + 'no per-turn idle follows the result (the real sidecar sends idle only when its stream ends)', + ).toEqual([]) expect( wires.some((r) => r.frame?.type === 'sdk.assistant' && r.frame?.sessionId === deniedBridgeId && r.frame?.content?.[0]?.text?.includes('denied')), From 772fcceb7f52874957f79f800ec419b62d7a0168 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:07:25 -0700 Subject: [PATCH 04/23] test(claude): the CLI simulator can hang a /compact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A prerequisite for C09's mid-compaction death test (design §10 PR-B): the simulator's /compact finished after 25 ms, so a test could not kill the sidecar while it was compacting without racing that window. Now `/compact HANG` keeps the compaction in progress (status `compacting`, no result) until the CLI dies, the way the real CLI behaves when the local provider's HANG mode holds its summary request. The instructions reach the summary request on the real CLI: `/compact APIERR:400` already fails the compaction there (T15, scenario `compact`). What the real CLI does when a hung compaction is interrupted is not recorded, so an interrupt fails the simulator loudly ("not modeled") instead of guessing. Red: claude-cli-sim.test.ts "a /compact whose summary request hangs ... stays compacting with no result" failed (the last status was null: the compaction completed). Green: claude-cli-sim.test.ts 17/17; claude-cli-contract.test.ts (real CLI plus conformance) 39/39. Co-Authored-By: Claude Opus 5.5 --- test/fixtures/claude-cli-sim/local-commands.mjs | 11 ++++++++++- .../integration/providers/claude-cli-sim.test.ts | 16 ++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/test/fixtures/claude-cli-sim/local-commands.mjs b/test/fixtures/claude-cli-sim/local-commands.mjs index d4d95c42d..6a1dbb16c 100644 --- a/test/fixtures/claude-cli-sim/local-commands.mjs +++ b/test/fixtures/claude-cli-sim/local-commands.mjs @@ -24,8 +24,17 @@ export async function runLocalCommand(cli, turn, { name, args }) { if (name === 'compact') { cli.emit({ type: 'system', subtype: 'status', status: 'compacting' }) - await cli.sleep(T.compaction) const marker = parsePromptMarkers(args) + if (marker.kind === 'hang') { + // The instructions reach the summary request (APIERR does the same on + // the real CLI, T15), so HANG leaves the compaction in progress until + // the CLI dies. What the CLI does when interrupted here is not recorded, + // so an interrupt fails the simulator loudly instead of guessing. + await cli.wait(Number.POSITIVE_INFINITY, turn) + if (cli.dead) return undefined + throw new Error('an interrupt during a hung /compact is not modeled') + } + await cli.sleep(T.compaction) const error = marker.kind === 'api_error' ? `Error during compaction: API Error: ${marker.status} local provider error` : cli.modelTurnsSinceCompact === 0 ? 'Not enough messages to compact.' : null diff --git a/test/integration/providers/claude-cli-sim.test.ts b/test/integration/providers/claude-cli-sim.test.ts index eb47bfa31..ccfc547a7 100644 --- a/test/integration/providers/claude-cli-sim.test.ts +++ b/test/integration/providers/claude-cli-sim.test.ts @@ -214,6 +214,22 @@ describe('Claude CLI simulator programs', () => { await s.until(() => s.messages.some((m) => m.type === 'command_lifecycle' && m.state === 'cancelled')) }) + it('a /compact whose summary request hangs (HANG in its instructions) stays compacting with no result', async () => { + // The marker reaches the compaction's summary request the way APIERR does + // on the real CLI (T15 scenario `compact`), so the local provider's HANG + // mode would leave the real CLI compacting too. Only a CLI death ends it. + const s = await open({}, { FRESHELL_CLAUDE_SIM_TIME_SCALE: '0.1' }) + s.prompt.push('something to compact') + await s.until(() => results(s.messages).length === 1) + s.prompt.push('/compact HANG', '33333333-3333-4333-8333-333333333333') + await s.until(() => s.messages.some((m) => m.type === 'system' && m.subtype === 'status' && m.status === 'compacting')) + await new Promise((resolve) => setTimeout(resolve, 300)) + const statuses = s.messages.filter((m) => m.type === 'system' && m.subtype === 'status').map((m) => m.status) + expect(statuses.at(-1), 'the compaction is still in progress').toBe('compacting') + expect(results(s.messages), 'the hung compaction has no result').toHaveLength(1) + expect(s.ended).toBeNull() + }) + it('a start rule can schedule a CLI-initiated turn: init and a result with no lifecycle, bracketed by running and idle', async () => { const s = await open({}, program([{ on: 'start', emit: [{ kind: 'cli_turn', data: { afterMs: 50 } }] }])) await s.until(() => results(s.messages).length === 1) From aceaa1707d2fd80b75ed4b0c37798b90e02bf2f2 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:35:23 -0700 Subject: [PATCH 05/23] feat(freshclaude): exited status, dead-session record and Resume (G05) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design C09 (docs/plans/2026-10-22-freshclaude-core-runtime.md §3.3, §5.7, §6, §8, §10 PR-B). When a claude sidecar ended unrequested, the server sent only the SIDECAR_EXITED banner: a pane that was compacting stayed busy forever, the composer stayed enabled, the next send vanished, there was no Resume, and the session's snapshot fell back to a disk `idle` the client could not trust (G05). Server (crates/freshell-freshagent): - New small module claude_dead_sessions.rs: DeadClaudeSession (every id the session answered to — map key, envelope stamp, durable ids — plus its settings, commands, the end reason and time) and DeadClaudeSessions, a store bounded at 256 deaths (oldest forgotten first; a second death replaces the first record). - The end-of-output path records the evicted session (before the reap's wait, so a resume that lands meanwhile forgets it), then broadcasts `freshAgent.status{exited}` (unless that resume already happened), then SIDECAR_EXITED, then the death edge when owed (unchanged interim rule). - The snapshot overlay (apply_snapshot_metadata) answers a dead id with `exited`, statusFromLiveState and the session's last settings/commands. - handle_send on a dead id (map key, stamp or durable id): the nested `INVALID_SESSION_ID` lost frame plus a request error (code INVALID_SESSION_ID, "The Claude agent process for this session exited. Resume the session to continue."); never accepted. Other unknown ids keep SESSION_NOT_FOUND (C20/C21 change those). - A create that resumes the durable id, an attach-resume, or a kill forgets the record. has_live_session stays live-only (false for a dead id). - Logs: freshagent.claude.dead_session_recorded / _dropped / _forgotten (info), freshagent.claude.send_to_dead_session (warn). Sidecar: the interim stream-end `sdk.status idle` is gone (the staging rule: its consumer, the exited status, switches in this commit). The e2e fake's stream-error lane drops it too. Client: - The session-ended card offers "Resume session" for claude/kilroy (any provider with a canonical durable id; triggerRecovery resolves the same id), not only codex. - A refused claude send (any request error for an owned send) puts its text back into the composer, in front of anything typed since, with "Message not sent: " (a server `CODE: ` prefix is stripped). New composer handle method restoreText. Tests (red first): - crates/freshell-ws/tests/freshagent_claude_lifecycle.rs (RealSidecarSim): new a_sidecar_death_mid_compact_ends_exited_with_one_ring_and_an_exited_snapshot (red: last status stayed `compacting`) and a_send_to_a_dead_session_gets_the_lost_frame_and_a_request_error (red: no lost frame); an_idle_cli_death... now also expects `exited` and a_natural_end_of_stream... expects `exited` instead of the removed idle (red: `idle`). - claude.rs: a_resumed_dead_session_no_longer_reads_as_ended and closing_a_dead_session_forgets_it (red with forgetting disabled: the old ids still read `exited`); claude_dead_sessions unit tests (5). - stream-end.test.ts: no stream-end idle after a CLI crash, and a CLI crash mid-compaction ends with only `compacting` (red: an idle followed). - fake-claude-sdk-sidecar-control.test.ts: no status after the stream-error edge (red: the idle). - FreshAgentView.test.tsx: Resume for an ended freshclaude/kilroy session, even mid-compaction, resumes the same durable id (red: no Resume button); a refused freshclaude/kilroy send is restored with the reason (red: the composer stayed empty). - freshAgentSlice.test.ts: exited replaces compacting and the exit banner keeps it exited and not busy (a pin: the slice already did this; the server never sent `exited`). - e2e: fresh-agent-error-turn-attention.spec.ts gains a production-sidecar crash lane for freshclaude and kilroy (background crash: seq +1, tab highlight, ended card with Resume, Resume restores the conversation with no reload and takes the next message); its stream-error step now expects `exited`; the Stop lane's boot moved into a shared helper. The kilroy crash lane in fresh-agent-control-rust.spec.ts crashes in a background tab and recovers through Resume with no reload (seq +1, highlight), then through the restart as before. Deviations from the design text: - The dead record keeps no attention clock or turn lock yet: nothing uses them until the transparent resume (C20) moves them across. - Recovery after a send to a dead id is the client's lost-frame recovery; the server-side transparent resume is C20. - The composer restore covers every refused claude send, not only the three named codes: a request error always means the message was not sent. - Rig snapshots read the overlay through a test-support seam (live_snapshot_overlay): the rig server cannot read the rig's transcripts (the store root comes from this process's environment). Co-Authored-By: Claude Opus 5.5 --- crates/freshell-claude-sidecar/index.mjs | 6 +- crates/freshell-freshagent/src/claude.rs | 325 ++++++++++++++++-- .../src/claude_dead_sessions.rs | 175 ++++++++++ crates/freshell-freshagent/src/lib.rs | 1 + .../tests/freshagent_claude_lifecycle.rs | 150 +++++++- .../fresh-agent/FreshAgentComposer.tsx | 27 +- src/components/fresh-agent/FreshAgentView.tsx | 36 +- .../providers/fake-claude-sdk-sidecar.mjs | 8 +- .../fake-claude-sdk-sidecar-control.test.ts | 5 + .../specs/fresh-agent-control-rust.spec.ts | 78 +++-- .../fresh-agent-error-turn-attention.spec.ts | 267 ++++++++++---- test/unit/claude-sidecar/stream-end.test.ts | 18 + .../fresh-agent/FreshAgentView.test.tsx | 105 ++++++ .../unit/client/store/freshAgentSlice.test.ts | 14 + 14 files changed, 1079 insertions(+), 136 deletions(-) create mode 100644 crates/freshell-freshagent/src/claude_dead_sessions.rs diff --git a/crates/freshell-claude-sidecar/index.mjs b/crates/freshell-claude-sidecar/index.mjs index a748f078f..aa76501ac 100644 --- a/crates/freshell-claude-sidecar/index.mjs +++ b/crates/freshell-claude-sidecar/index.mjs @@ -38,7 +38,8 @@ // with a send still pending) — the abort/sdk.exit requested-teardown path // stays silent // { type:'sdk.turn.waiting', sessionId, at } // 0->>=1 pending edge (written by this sidecar; codex controls emits its own waiting edge outside the sidecar lane) -// { type:'sdk.status', sessionId, status } // compacting / idle (stream end) +// { type:'sdk.status', sessionId, status } // compacting (an ended stream sends +// no status: the server reports the session as exited) // { type:'sdk.error', sessionId, message, turnFailure? } // turnFailure: a failed turn's result // (sanitized text, no [ede_diagnostic] lines); never for the user's own Stop // { type:'sdk.stream.ended', sessionId, reason, message } // the session's SDK stream ended @@ -379,9 +380,6 @@ async function consumeStream(sessionId, sdkQuery) { st.lastTurnCompleteAt = at emit({ type: 'sdk.turn.complete', sessionId, at }) } - // Interim, until the exited status replaces it (C09): the idle status - // takes a compacting pane out of its busy state. - emit({ type: 'sdk.status', sessionId, status: 'idle' }) // The end of the stream is the end of this sidecar (G02): report how // the stream ended, last, and exit, so the server's end-of-output path // ends the session instead of keeping a sidecar with no session. diff --git a/crates/freshell-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index 541cfe8f1..3c97fddb6 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -291,6 +291,12 @@ pub struct FreshClaudeState { /// (idempotent in the store) even when blank settings bypass /// `record_binding`. naming_handles: Arc>>, + /// Sessions whose sidecar ended unrequested (C09, G05): the end-of-output + /// path records each one here as it evicts it from [`Self::sessions`], so the + /// pane's ids still resolve to an ended session — its snapshot overlay says + /// `exited` and a send to it is refused visibly. Resuming or closing the + /// session forgets the record. See [`crate::claude_dead_sessions`]. + dead_sessions: Arc>, } /// Focused-ep5-r1 Finding 1: retention for demoted alias records. The @@ -791,6 +797,7 @@ impl FreshClaudeState { close_pending: Arc::new(std::sync::Mutex::new(HashMap::new())), naming: crate::naming::NamingSink::default(), naming_handles: Arc::new(TokioMutex::new(HashMap::new())), + dead_sessions: Arc::default(), handoff_kill_pause: None, rollback_adopt_parked: None, rollback_adopt_release: None, @@ -2667,6 +2674,10 @@ impl FreshClaudeState { last_status, }, ); + // C09: a create that resumes an ended session makes it live again. + if let Some(sid) = resume_sid.as_deref() { + self.forget_dead_sessions(&[sid], "resumed"); + } // Round 5 (focused-ep5-r4 Finding 1): the claim's POST-REGISTRATION // dead-state re-check. The commit above and a kill's retire serialize @@ -3627,6 +3638,8 @@ impl FreshClaudeState { // Live-state destruction begins (every durable id the one envelope // covers is durably closed by this point). The consumer ABORT stays // synchronous (the minter channel closes). + // C09: a closed session is not an ended one waiting to be resumed. + self.forget_dead_sessions(&retire_ids, "killed"); let removed = self.sessions.lock().await.remove(&map_key); // The gate entry dies with the session (the map rule was the gate's // whole meaning; an aborted session adopts nothing more). @@ -4471,7 +4484,7 @@ impl FreshClaudeState { break handles; } if !self.rollback_in_flight.contains(&session_id) { - self.send_error(&request_id, "SESSION_NOT_FOUND", "claude session not found"); + self.refuse_send_to_missing_session(&request_id, &session_id, msg.session_type); return; } tokio::time::sleep(MID_ROLLBACK_PARK_TICK).await; @@ -4601,7 +4614,7 @@ impl FreshClaudeState { }), None => { drop(guard); - self.send_error(&request_id, "SESSION_NOT_FOUND", "claude session not found"); + self.refuse_send_to_missing_session(&request_id, &session_id, msg.session_type); return; } } @@ -4636,7 +4649,7 @@ impl FreshClaudeState { let Some(session) = guard.get_mut(&map_key) else { drop(guard); undo_turn_op_arm(&in_turn, &turn_tracker, send_was_busy); - self.send_error(&request_id, "SESSION_NOT_FOUND", "claude session not found"); + self.refuse_send_to_missing_session(&request_id, &session_id, msg.session_type); return; }; // Address the sidecar by ITS id for this session (== the map key for created @@ -5253,24 +5266,50 @@ impl FreshClaudeState { Ok(snapshot) } + /// Test seam (`test-support`, C09 rigs): the live-state overlay the snapshot + /// route applies ([`Self::apply_snapshot_metadata`]), over an empty snapshot. A + /// rig cannot read a transcript through its server (the store root comes from + /// this process's environment), so it reads the overlay directly. + #[cfg(any(test, feature = "test-support"))] + pub async fn live_snapshot_overlay(&self, any_id: &str) -> Value { + let mut snapshot = json!({}); + self.apply_snapshot_metadata(any_id, &mut snapshot).await; + snapshot + } + + /// The snapshot overlay from the server's own state: the live session's status, + /// settings and commands, or, for a session whose sidecar ended unrequested, + /// `exited` with the settings and commands it last had (C09, G05). Both are + /// live-state truth (`statusFromLiveState`), so the client adopts the status. + /// An id the server holds nothing for leaves the snapshot untouched. pub(crate) async fn apply_snapshot_metadata(&self, any_id: &str, snapshot: &mut Value) { - let Some(key) = self.resolve_session_key(any_id).await else { - return; + let live = match self.resolve_session_key(any_id).await { + Some(key) => self.sessions.lock().await.get(&key).map(|session| { + let status = session.current_status(); + let status = if status == "idle" + && session.in_turn.load(std::sync::atomic::Ordering::SeqCst) + { + "running".to_string() + } else { + status + }; + ( + status, + session.configuration.settings.clone(), + session.configuration.commands.clone(), + ) + }), + None => None, }; - let sessions = self.sessions.lock().await; - let Some(session) = sessions.get(&key) else { - return; + let (status, settings, commands) = match live { + Some(live) => live, + None => match self.dead_session(any_id) { + Some(dead) => ("exited".to_string(), dead.settings, dead.commands), + None => return, + }, }; - let status = session.current_status(); - snapshot["status"] = json!(if status == "idle" - && session.in_turn.load(std::sync::atomic::Ordering::SeqCst) - { - "running".to_string() - } else { - status - }); + snapshot["status"] = json!(status); snapshot["extensions"]["claude"]["statusFromLiveState"] = json!(true); - let settings = &session.configuration.settings; let mut fields = Map::new(); for (key, value) in [ ("model", &settings.model), @@ -5284,7 +5323,7 @@ impl FreshClaudeState { if !fields.is_empty() { snapshot["settings"] = Value::Object(fields); } - if let Some(commands) = &session.configuration.commands { + if let Some(commands) = &commands { snapshot["commands"] = json!(commands); } } @@ -5325,6 +5364,104 @@ impl FreshClaudeState { self.sessions.lock().await.contains_key(&key) } + /// The record of the session answering to `id` whose sidecar ended unrequested + /// and that has not been resumed or closed since (C09, G05). + fn dead_session(&self, id: &str) -> Option { + self.dead_sessions + .lock() + .expect("dead sessions lock") + .find(id) + .cloned() + } + + /// Record a session the end-of-output path just evicted (C09): it answers to + /// its map key, its envelope stamp and every durable id it held. + fn record_dead_session( + &self, + map_key: &str, + stamp: &str, + session_type: &str, + durables: &[String], + evicted: &ClaudeSession, + reason: &str, + ) { + let mut ids = vec![map_key.to_string()]; + for id in std::iter::once(stamp) + .chain(durables.iter().map(String::as_str)) + .chain(evicted.cli_session_id.as_deref()) + { + if !ids.iter().any(|known| known == id) { + ids.push(id.to_string()); + } + } + let dead = crate::claude_dead_sessions::DeadClaudeSession { + ids: ids.clone(), + settings: evicted.configuration.settings.clone(), + commands: evicted.configuration.commands.clone(), + reason: reason.to_string(), + at_ms: crate::rollback_record::now_ms(), + }; + let (dropped, remembered) = { + let mut store = self.dead_sessions.lock().expect("dead sessions lock"); + let dropped = store.record(dead); + (dropped, store.len()) + }; + tracing::info!(target: "freshell_freshagent::claude", + provider = PROVIDER, session_id = %stamp, session_type = %session_type, + ids = ?ids, reason, remembered, + "freshagent.claude.dead_session_recorded"); + if let Some(dropped) = dropped { + tracing::info!(target: "freshell_freshagent::claude", + provider = PROVIDER, ids = ?dropped.ids, died_at_ms = dropped.at_ms, + cap = crate::claude_dead_sessions::DEAD_SESSIONS_CAP, + "freshagent.claude.dead_session_dropped"); + } + } + + /// Forget the dead-session records answering to any of `ids`: the session is + /// live again (`resumed`) or the pane closed it (`killed`). + fn forget_dead_sessions>(&self, ids: &[S], reason: &'static str) { + let forgotten = self + .dead_sessions + .lock() + .expect("dead sessions lock") + .forget(ids); + if forgotten > 0 { + let ids: Vec<&str> = ids.iter().map(AsRef::as_ref).collect(); + tracing::info!(target: "freshell_freshagent::claude", + provider = PROVIDER, ids = ?ids, reason, forgotten, + "freshagent.claude.dead_session_forgotten"); + } + } + + /// A send whose session is not live (C09, G05). A send to a dead session is + /// refused visibly: the lost-session frame for the id the pane sent (the + /// client starts its recovery from it) and a request error that hands the + /// message back to the composer. Until the transparent resume (C20) it is + /// never accepted. Any other id keeps the `SESSION_NOT_FOUND` request error. + fn refuse_send_to_missing_session( + &self, + request_id: &Option, + session_id: &str, + session_type: SessionType, + ) { + let Some(dead) = self.dead_session(session_id) else { + self.send_error(request_id, "SESSION_NOT_FOUND", "claude session not found"); + return; + }; + tracing::warn!(target: "freshell_freshagent::claude", + provider = PROVIDER, session_id = %session_id, + session_type = session_type_str(session_type), request_id = ?request_id, + reason = %dead.reason, died_at_ms = dead.at_ms, + "freshagent.claude.send_to_dead_session"); + self.broadcast(&lost_session_frame(session_id, session_type)); + self.send_request_error( + request_id, + ErrorCode::InvalidSessionId, + crate::claude_dead_sessions::SEND_TO_DEAD_SESSION_MESSAGE, + ); + } + pub(crate) async fn local_snapshot_owner( &self, native_id: &str, @@ -7428,6 +7565,8 @@ impl FreshClaudeState { .lock() .await .insert(durable.to_string(), msg.session_id.clone()); + // C09: the session is live again, so it is no longer an ended one. + self.forget_dead_sessions(&[msg.session_id.as_str(), durable], "resumed"); // Test seam (Task 6 round-3 review I-1): park with the target // session REGISTERED (map + cli_index) but the resume unreturned — // the fresh-arm target-spawn-window abort test's deterministic @@ -7547,13 +7686,22 @@ impl FreshClaudeState { } fn send_error(&self, request_id: &Option, code: &str, message: &str) { + self.send_request_error( + request_id, + ErrorCode::InternalError, + &format!("{code}: {message}"), + ); + } + + /// A top-level `error` frame correlated to `request_id`. + fn send_request_error(&self, request_id: &Option, code: ErrorCode, message: &str) { self.broadcast(&ServerMessage::Error(ErrorMsg { provider_preparation_failure: None, owner_kind: None, owner_generation: None, owner_epoch: None, - code: ErrorCode::InternalError, - message: format!("{code}: {message}"), + code, + message: message.to_string(), timestamp: now_iso(), actual_session_ref: None, expected_session_ref: None, @@ -8916,6 +9064,23 @@ impl FreshClaudeState { "freshclaude/consumer-exit", ); } + let stamp = broadcast_id.lock().expect("broadcast id lock").clone(); + // G05 (C09): the ended session stays known as ended — its ids + // resolve to a dead record, not to nothing. Recorded before the + // reap's wait, so a resume that lands meanwhile forgets it. + let reason = stream_ended + .as_ref() + .map_or(crate::claude_sidecar_exit::PROCESS_ENDED_REASON, |ended| { + ended.reason.as_str() + }); + state.record_dead_session( + &session_id, + &stamp, + &session_type, + &removed_durables, + &evicted, + reason, + ); // Reap the sidecar for its exit status: the cause of a death that // reported no stream end, and no zombie left behind. let status = crate::claude_sidecar_exit::reap(&mut evicted.child).await; @@ -8929,7 +9094,6 @@ impl FreshClaudeState { // UNREQUESTED-end only: `handle_kill`/`shutdown`/attach-teardown all // remove the map entry (and abort this consumer) first, so nothing is // evicted here and it stays silent. - let stamp = broadcast_id.lock().expect("broadcast id lock").clone(); let rings = crate::claude_sidecar_exit::death_rings(turn_was_in_flight, &cause); tracing::warn!(target: "freshell_freshagent::claude", provider = PROVIDER, @@ -8941,6 +9105,17 @@ impl FreshClaudeState { turn_in_flight = turn_was_in_flight, rings, "freshagent.claude.sidecar_exit"); + // G05: every pane learns the session exited — a compacting or + // running pane stops being busy and offers Resume — unless a + // resume already brought it back during the reap. + if state.dead_session(&stamp).is_some() { + state.broadcast(&ServerMessage::FreshAgentEvent(FreshAgentEvent { + event: crate::claude_dead_sessions::exited_status_event(&stamp), + provider: PROVIDER.to_string(), + session_id: stamp.clone(), + session_type: session_type.clone(), + })); + } state.broadcast(&ServerMessage::FreshAgentEvent(FreshAgentEvent { event: crate::claude_sidecar_exit::sidecar_exited_event(&stamp, &cause), provider: PROVIDER.to_string(), @@ -20900,6 +21075,114 @@ rl.on('line', (line) => { ); } + /// C09: crash the session's sidecar (an unrequested death, not `handle_kill`) + /// and wait for its exit report. Returns the session's map key. + async fn crash_a_new_session( + st: &FreshClaudeState, + rx: &mut tokio::sync::broadcast::Receiver, + request_id: &str, + ) -> String { + st.handle_create(dedup_create_msg(request_id), None).await; + let created = await_claude_created_and_session_init(rx, request_id).await; + let session_id = created["sessionId"].as_str().unwrap().to_string(); + st.sessions + .lock() + .await + .get_mut(&session_id) + .expect("live session in map") + .child + .start_kill() + .expect("kill the sidecar"); + tokio::time::timeout(Duration::from_secs(15), async { + loop { + let frame: Value = match rx.recv().await { + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, + Err(err) => panic!("broadcast recv failed: {err}"), + Ok(raw) => serde_json::from_str(&raw).unwrap(), + }; + if frame["event"]["code"] == "SIDECAR_EXITED" { + return; + } + } + }) + .await + .expect("the death is reported"); + session_id + } + + /// C09: once an ended session is resumed (here by an attach, the reload and + /// reconnect path) it is live again, so none of its ids may still read as + /// ended — not even the old pane id the resumed session no longer uses. + #[tokio::test(flavor = "multi_thread")] + async fn a_resumed_dead_session_no_longer_reads_as_ended() { + let _guard = CLAUDE_ENV_LOCK.lock().await; + let _env = FakeClaudeSidecarEnv::install(); + // The fake sidecar's session reports this durable id. + const DURABLE: &str = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + let (st, mut rx) = state_with_bus(); + let old_pane_id = crash_a_new_session(&st, &mut rx, "req-dead-resume").await; + for id in [old_pane_id.as_str(), DURABLE] { + assert_eq!( + st.live_snapshot_overlay(id).await["status"], + "exited", + "{id} reads as ended after the crash" + ); + } + + let home = tempfile::tempdir().unwrap(); + write_fake_transcript(home.path(), DURABLE); + std::env::set_var("CLAUDE_CONFIG_DIR", home.path()); + st.handle_attach(attach_msg_with_resume("pane-after-reload", DURABLE)) + .await; + std::env::remove_var("CLAUDE_CONFIG_DIR"); + assert!(st.has_live_session(DURABLE).await, "the attach resumed it"); + + assert_ne!(st.live_snapshot_overlay(DURABLE).await["status"], "exited"); + assert_eq!( + st.live_snapshot_overlay(&old_pane_id).await, + json!({}), + "the old pane id no longer names an ended session" + ); + } + + /// C09: closing an ended session forgets it: its snapshot no longer says + /// `exited`, and a later send is an ordinary unknown-session refusal, not the + /// ended session's lost frame. + #[tokio::test(flavor = "multi_thread")] + async fn closing_a_dead_session_forgets_it() { + let _guard = CLAUDE_ENV_LOCK.lock().await; + let _env = FakeClaudeSidecarEnv::install(); + let (st, mut rx) = state_with_bus(); + let pane_id = crash_a_new_session(&st, &mut rx, "req-dead-close").await; + assert_eq!(st.live_snapshot_overlay(&pane_id).await["status"], "exited"); + + st.handle_kill(kill_msg(&pane_id)).await; + assert_eq!(st.live_snapshot_overlay(&pane_id).await, json!({})); + + while rx.try_recv().is_ok() {} + let mut send = send_msg(&pane_id, "after the close"); + send.request_id = Some("send-after-close".into()); + st.handle_send(send).await; + let mut frames = Vec::new(); + while let Ok(raw) = rx.try_recv() { + frames.push(serde_json::from_str::(&raw).unwrap()); + } + assert!( + frames + .iter() + .all(|frame| frame["event"]["code"] != "INVALID_SESSION_ID"), + "no lost frame for a closed session: {frames:?}" + ); + assert!( + frames.iter().any(|frame| frame["type"] == "error" + && frame["requestId"] == "send-after-close" + && frame["message"] + .as_str() + .is_some_and(|m| m.starts_with("SESSION_NOT_FOUND"))), + "the send is refused as unknown: {frames:?}" + ); + } + // ── kata 1wxv Task 4: claude/kilroy undo/redo (fork-at-point emulation) ── use crate::identity_sink::PaneIdentitySink as _; diff --git a/crates/freshell-freshagent/src/claude_dead_sessions.rs b/crates/freshell-freshagent/src/claude_dead_sessions.rs new file mode 100644 index 000000000..8b528b569 --- /dev/null +++ b/crates/freshell-freshagent/src/claude_dead_sessions.rs @@ -0,0 +1,175 @@ +//! What a claude session leaves behind when its sidecar ends unrequested +//! (core-runtime design §3.3, §5.7, §6; C09, G05). +//! +//! The end-of-output path evicts the dead session from the live map, but the +//! pane still holds it. So the server keeps a [`DeadClaudeSession`]: the session +//! reports `freshAgent.status{exited}` ([`exited_status_event`]), its snapshot +//! overlay says `exited` from live state (not a stale `idle` or `compacting`), and +//! a send to it is refused visibly instead of vanishing. Resuming the session +//! (attach or a create that resumes it) or closing it forgets the record. +//! +//! Until the durable id is minted at create (C23) a session answers to several +//! ids — its sessions-map key (the pane's id from create or attach), its envelope +//! stamp and its durable claude UUIDs — so a record answers to all of them. + +use std::collections::VecDeque; + +use serde_json::{json, Value}; + +use crate::identity_sink::FreshAgentSettings; + +/// How many dead sessions are remembered (codex bounds its dead threads the same +/// way). Past it the oldest death is forgotten. +pub(crate) const DEAD_SESSIONS_CAP: usize = 256; + +/// One session whose sidecar ended unrequested. +#[derive(Debug, Clone)] +pub(crate) struct DeadClaudeSession { + /// Every id the session answered to (never empty). + pub(crate) ids: Vec, + /// The settings the session last ran under. + pub(crate) settings: FreshAgentSettings, + /// The command catalog the session last published. + pub(crate) commands: Option>, + /// Why it ended: the `sdk.stream.ended` reason, or `sidecar_exited` for a + /// sidecar that died without reporting one. + pub(crate) reason: String, + /// When it ended (epoch ms). + pub(crate) at_ms: i64, +} + +impl DeadClaudeSession { + fn answers_to(&self, id: &str) -> bool { + self.ids.iter().any(|known| known == id) + } +} + +/// The remembered dead sessions, oldest death first. +#[derive(Debug, Default)] +pub(crate) struct DeadClaudeSessions { + entries: VecDeque, +} + +impl DeadClaudeSessions { + /// Remember a death. An earlier record sharing any id is replaced (the session + /// died again). Returns the record forgotten to stay within + /// [`DEAD_SESSIONS_CAP`], if any. + pub(crate) fn record(&mut self, dead: DeadClaudeSession) -> Option { + self.entries + .retain(|entry| !dead.ids.iter().any(|id| entry.answers_to(id))); + self.entries.push_back(dead); + if self.entries.len() > DEAD_SESSIONS_CAP { + self.entries.pop_front() + } else { + None + } + } + + /// The record of the dead session answering to `id`. + pub(crate) fn find(&self, id: &str) -> Option<&DeadClaudeSession> { + self.entries.iter().rev().find(|entry| entry.answers_to(id)) + } + + /// Forget every record answering to any of `ids` (the session was resumed or + /// closed). Returns how many were forgotten. + pub(crate) fn forget>(&mut self, ids: &[S]) -> usize { + let before = self.entries.len(); + self.entries + .retain(|entry| !ids.iter().any(|id| entry.answers_to(id.as_ref()))); + before - self.entries.len() + } + + pub(crate) fn len(&self) -> usize { + self.entries.len() + } +} + +/// The `freshAgent.status{exited}` event for the session's envelope id: the +/// session's process is gone, so the pane is not busy and offers Resume. +pub(crate) fn exited_status_event(session_id: &str) -> Value { + json!({ + "type": "freshAgent.status", + "sessionId": session_id, + "status": "exited", + }) +} + +/// The request error a send to a dead session gets (the client shows it as the +/// reason the message was not sent). +pub(crate) const SEND_TO_DEAD_SESSION_MESSAGE: &str = + "The Claude agent process for this session exited. Resume the session to continue."; + +#[cfg(test)] +mod tests { + use super::*; + + fn dead(ids: &[&str]) -> DeadClaudeSession { + DeadClaudeSession { + ids: ids.iter().map(|id| id.to_string()).collect(), + settings: FreshAgentSettings::default(), + commands: None, + reason: "cli_exited".into(), + at_ms: 1, + } + } + + #[test] + fn a_record_answers_to_every_id_the_session_had() { + let mut store = DeadClaudeSessions::default(); + store.record(dead(&["placeholder", "durable"])); + assert_eq!( + store.find("placeholder").map(|d| d.ids.clone()), + Some(vec!["placeholder".to_string(), "durable".to_string()]) + ); + assert!(store.find("durable").is_some()); + assert!(store.find("someone-else").is_none()); + } + + #[test] + fn a_second_death_replaces_the_first_record() { + let mut store = DeadClaudeSessions::default(); + store.record(dead(&["placeholder", "durable"])); + let mut again = dead(&["placeholder-2", "durable"]); + again.at_ms = 2; + store.record(again); + assert_eq!(store.len(), 1, "one record per session"); + assert_eq!(store.find("durable").map(|d| d.at_ms), Some(2)); + assert!( + store.find("placeholder").is_none(), + "the replaced record's other ids go with it" + ); + } + + #[test] + fn forgetting_any_id_forgets_the_whole_record() { + let mut store = DeadClaudeSessions::default(); + store.record(dead(&["placeholder", "durable"])); + store.record(dead(&["other"])); + assert_eq!(store.forget(&["durable"]), 1); + assert!(store.find("placeholder").is_none()); + assert!(store.find("other").is_some()); + assert_eq!(store.forget(&["nobody"]), 0); + } + + #[test] + fn past_the_cap_the_oldest_death_is_forgotten() { + let mut store = DeadClaudeSessions::default(); + for n in 0..DEAD_SESSIONS_CAP { + assert!(store.record(dead(&[&format!("s{n}")])).is_none()); + } + let dropped = store.record(dead(&["newest"])); + assert_eq!(dropped.map(|d| d.ids), Some(vec!["s0".to_string()])); + assert_eq!(store.len(), DEAD_SESSIONS_CAP); + assert!(store.find("s0").is_none()); + assert!(store.find("s1").is_some()); + assert!(store.find("newest").is_some()); + } + + #[test] + fn the_exited_status_event_names_the_session() { + assert_eq!( + exited_status_event("pane-id"), + json!({ "type": "freshAgent.status", "sessionId": "pane-id", "status": "exited" }) + ); + } +} diff --git a/crates/freshell-freshagent/src/lib.rs b/crates/freshell-freshagent/src/lib.rs index 704aea05f..99c69ed2c 100644 --- a/crates/freshell-freshagent/src/lib.rs +++ b/crates/freshell-freshagent/src/lib.rs @@ -37,6 +37,7 @@ //! backstop, by the harness sentinel sweep — no orphans. pub mod claude; +pub(crate) mod claude_dead_sessions; pub(crate) mod claude_sidecar_exit; pub(crate) mod claude_sidecar_io; pub(crate) mod claude_snapshot; diff --git a/crates/freshell-ws/tests/freshagent_claude_lifecycle.rs b/crates/freshell-ws/tests/freshagent_claude_lifecycle.rs index 259ab99d9..b40a71df1 100644 --- a/crates/freshell-ws/tests/freshagent_claude_lifecycle.rs +++ b/crates/freshell-ws/tests/freshagent_claude_lifecycle.rs @@ -9,6 +9,10 @@ //! one attention edge when a turn was running, and no live session left behind. //! A Node death reports nothing, so the server rings for it itself. //! +//! An ended session is not forgotten (C09, G05): it reports `status: exited`, the +//! server keeps a dead-session record so its snapshot says `exited` (from live +//! state), and a send to it is refused visibly instead of vanishing. +//! //! Unix only: the tests read the sidecar's pid and signal it (rust-gate runs on Linux). #![cfg(unix)] @@ -157,8 +161,8 @@ async fn a_cli_crash_mid_turn_rings_once_and_reports_the_cause() { /// The CLI ends the stream cleanly in the middle of the session's second turn (it /// exits 0, so the SDK's iterator just ends). The earlier turn's result must not -/// leave the session busy (G23): the session ends, its last status is idle, and -/// the server no longer holds it as live. +/// leave the session busy (G23): the session ends, its last status is `exited` +/// (not busy), and the server no longer holds it as live. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn a_natural_end_of_stream_mid_turn_leaves_the_session_not_busy() { let rig = rig_with_program( @@ -184,8 +188,8 @@ async fn a_natural_end_of_stream_mid_turn_leaves_the_session_not_busy() { conn.read_for(QUIET).await; assert_eq!( statuses(&conn, &session_id).last().map(String::as_str), - Some("idle"), - "the session's last status is idle; frames: {:?}", + Some("exited"), + "the session's last status is exited; frames: {:?}", conn.frames ); assert!( @@ -211,8 +215,7 @@ async fn a_node_death_mid_turn_rings_once() { .await; conn.wait_for_event("freshAgent.session.init").await; let pid = sidecar_pid(&rig).await; - // SAFETY: the pid is this rig's own sidecar, read from its simulator's ledger. - assert_eq!(unsafe { libc::kill(pid, libc::SIGKILL) }, 0); + sigkill(pid); let exited = wait_for_sidecar_exited(&mut conn, &session_id).await; assert_eq!(exited["cause"]["reason"], "sidecar_exited", "{exited}"); @@ -228,7 +231,8 @@ async fn a_node_death_mid_turn_rings_once() { ); } -/// An idle session whose CLI dies ends with the exit report and no edge. +/// An idle session whose CLI dies ends with the exit report, status `exited`, and +/// no edge. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn an_idle_cli_death_reports_the_exit_without_an_edge() { let rig = @@ -246,4 +250,136 @@ async fn an_idle_cli_death_reports_the_exit_without_an_edge() { "{:?}", conn.frames ); + assert_eq!( + statuses(&conn, &session_id), + vec!["exited".to_string()], + "an idle session that ends reports exited; frames: {:?}", + conn.frames + ); +} + +/// Kill the rig's sidecar process (the simulator runs inside it). +fn sigkill(pid: i32) { + // SAFETY: the pid is this rig's own sidecar, read from its simulator's ledger. + assert_eq!(unsafe { libc::kill(pid, libc::SIGKILL) }, 0); +} + +/// The Node sidecar is killed while a `/compact` runs (G05: an out-of-memory kill +/// mid-compaction). The pane must not stay `compacting` (busy) forever: the session +/// reports `exited`, the lost compaction rings exactly once, and the snapshot +/// overlay, read from the server's live state, says `exited` too. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_sidecar_death_mid_compact_ends_exited_with_one_ring_and_an_exited_snapshot() { + let rig = ClaudeRig::start(RigKind::Sim).await; + let mut conn = rig.connect().await; + let session_id = conn.create("create-1").await; + conn.send_text(&session_id, "send-1", "something to compact") + .await; + conn.wait_for_event("freshAgent.turn.complete").await; + let durable = durable_id(&conn, &session_id); + let pid = sidecar_pid(&rig).await; + + // `/compact HANG`: the simulated compaction never finishes on its own. + conn.send(json!({ + "type": "freshAgent.compact", + "provider": "claude", + "sessionType": "freshclaude", + "sessionId": session_id, + "requestId": "compact-1", + "instructions": "HANG", + })) + .await; + conn.wait_for("freshAgent.status{compacting}", |v| { + v["type"] == "freshAgent.event" + && v["event"]["type"] == "freshAgent.status" + && v["event"]["status"] == "compacting" + }) + .await; + let edges_before = conn.events_seen("freshAgent.turn.complete"); + sigkill(pid); + + let exited = wait_for_sidecar_exited(&mut conn, &session_id).await; + assert_eq!(exited["cause"]["signal"], "SIGKILL", "{exited}"); + wait_until_gone(pid).await; + conn.read_for(QUIET).await; + + assert_eq!( + statuses(&conn, &session_id).last().map(String::as_str), + Some("exited"), + "exited replaces compacting; frames: {:?}", + conn.frames + ); + assert_eq!( + conn.events_seen("freshAgent.turn.complete") - edges_before, + 1, + "the lost compaction rings exactly once; frames: {:?}", + conn.frames + ); + assert!(!rig.fresh_claude.has_live_session(&durable).await); + for id in [&durable, &session_id] { + let overlay = rig.fresh_claude.live_snapshot_overlay(id).await; + assert_eq!(overlay["status"], "exited", "snapshot for {id}: {overlay}"); + assert_eq!( + overlay["extensions"]["claude"]["statusFromLiveState"], true, + "the exited status is live state, not a guess: {overlay}" + ); + } +} + +/// A send that reaches a session whose sidecar has ended is refused visibly: the +/// pane's lost-session frame (nested `INVALID_SESSION_ID`, which starts the client's +/// recovery) and a request error that hands the message back to the composer. It is +/// never accepted and never silently dropped. Either id the pane may hold works. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_send_to_a_dead_session_gets_the_lost_frame_and_a_request_error() { + let rig = ClaudeRig::start(RigKind::Sim).await; + let mut conn = rig.connect().await; + let session_id = conn.create("create-1").await; + conn.send_text(&session_id, "send-1", "first").await; + conn.wait_for_event("freshAgent.turn.complete").await; + let durable = durable_id(&conn, &session_id); + let pid = sidecar_pid(&rig).await; + sigkill(pid); + wait_for_sidecar_exited(&mut conn, &session_id).await; + wait_until_gone(pid).await; + assert!( + !rig.fresh_claude.has_live_session(&durable).await, + "a dead session is not live" + ); + + for (id, request_id) in [(session_id.clone(), "send-2"), (durable.clone(), "send-3")] { + conn.send_text(&id, request_id, "after the death").await; + let lost = conn + .wait_for(&format!("the lost frame for {id}"), |v| { + v["type"] == "freshAgent.event" + && v["event"]["type"] == "freshAgent.error" + && v["event"]["code"] == "INVALID_SESSION_ID" + && v["sessionId"] == id.as_str() + }) + .await; + assert_eq!(lost["event"]["sessionId"], id.as_str(), "{lost}"); + assert_eq!(lost["sessionType"], "freshclaude", "{lost}"); + let error = conn + .wait_for(&format!("the request error for {request_id}"), |v| { + v["type"] == "error" && v["requestId"] == request_id + }) + .await; + assert_eq!(error["code"], "INVALID_SESSION_ID", "{error}"); + assert!( + error["message"] + .as_str() + .is_some_and(|message| message.contains("exited")), + "the error says why: {error}" + ); + } + conn.read_for(QUIET).await; + assert!( + !conn + .frames + .iter() + .any(|v| v["type"] == "freshAgent.send.accepted" + && matches!(v["requestId"].as_str(), Some("send-2" | "send-3"))), + "no send after the death was accepted; frames: {:?}", + conn.frames + ); } diff --git a/src/components/fresh-agent/FreshAgentComposer.tsx b/src/components/fresh-agent/FreshAgentComposer.tsx index 315182d05..4540ef25b 100644 --- a/src/components/fresh-agent/FreshAgentComposer.tsx +++ b/src/components/fresh-agent/FreshAgentComposer.tsx @@ -65,6 +65,11 @@ export type FreshAgentComposerHandle = { appendText: (text: string) => void /** kata 1wxv decision 4: refill = OVERWRITE the current contents, focus, caret to end. */ replaceText: (text: string) => void + /** + * A message that was not sent comes back: it goes in front of anything + * typed since (never overwriting it), focus, caret to end. + */ + restoreText: (text: string) => void } /** @@ -236,6 +241,15 @@ export const FreshAgentComposer = forwardRef { + requestAnimationFrame(() => { + const el = textareaRef.current + if (!el) return + el.focus() + el.setSelectionRange(el.value.length, el.value.length) + }) + }, []) + useImperativeHandle(ref, () => ({ focus: () => textareaRef.current?.focus(), insertText: (value: string) => { @@ -252,14 +266,13 @@ export const FreshAgentComposer = forwardRef { setText(value) - requestAnimationFrame(() => { - const el = textareaRef.current - if (!el) return - el.focus() - el.setSelectionRange(el.value.length, el.value.length) - }) + focusAtEnd() + }, + restoreText: (value: string) => { + setText((current) => (current.trim() ? `${value}\n${current}` : value)) + focusAtEnd() }, - }), [disabled]) + }), [disabled, focusAtEnd]) const chatPrefix = getCommandPrefix(text) const mention = useMemo(() => getMentionToken(text), [text]) diff --git a/src/components/fresh-agent/FreshAgentView.tsx b/src/components/fresh-agent/FreshAgentView.tsx index 487cd15c4..a117b03bc 100644 --- a/src/components/fresh-agent/FreshAgentView.tsx +++ b/src/components/fresh-agent/FreshAgentView.tsx @@ -520,6 +520,15 @@ function getEffectiveFreshAgentPermissionMode(content: FreshAgentPaneContent): s return content.provider === 'opencode' ? undefined : content.permissionMode } +/** + * Why a send was refused, from its request error's message: the reason alone, + * without the `CODE: ` prefix some server errors carry. + */ +function sendFailureReason(message: unknown): string { + const text = typeof message === 'string' ? message.trim() : '' + return text.replace(/^[A-Z][A-Z0-9_]+:\s*/, '') || 'the server refused it' +} + function isStatusRegression(current: string, next: string): boolean { return !EARLY_STATES.has(current) && EARLY_STATES.has(next) } @@ -3107,6 +3116,7 @@ export function FreshAgentView({ // the three leaks a failed send otherwise leaves behind -- the // pending-metadata entry, the stale local echo (dual-write), and the // optimistic `running` status. + const failedText = pendingSendMetadataRef.current.get(failedRequestId)?.text pendingSendMetadataRef.current.delete(failedRequestId) if (outgoingTurnRef.current?.requestId === failedRequestId) { outgoingTurnRef.current = null @@ -3118,6 +3128,19 @@ export function FreshAgentView({ if (current.provider === 'opencode' && current.status === 'running') { dispatch(mergePaneContent({ tabId, paneId, updates: { status: 'idle' } })) } + // G05 (C09): a claude send the server refused was never sent — for + // example its session ended — so the text goes back into the + // composer with the reason instead of vanishing with the echo. + if (current.provider === 'claude' && failedText) { + composerRef.current?.restoreText(failedText) + setNotice(`Message not sent: ${sendFailureReason(message.message)}`) + log.warn('fresh_agent.send_refused', { + provider: current.provider, + sessionType: current.sessionType, + sessionId: current.sessionId, + code: typeof message.code === 'string' ? message.code : undefined, + }) + } return } if ( @@ -3955,6 +3978,14 @@ export function FreshAgentView({ : (agentSession?.status ?? paneContent.status) const isBusy = BUSY_STATES.has(effectiveStatus) const sessionEnded = effectiveStatus === 'exited' || effectiveStatus === 'create-failed' + // The durable conversation an ended session's Resume reopens (triggerRecovery + // resolves the same id): codex threads, and claude/kilroy sessions whose + // sidecar died (G05). + const endedSessionResumeId = paneContent.provider === 'codex' + ? getCanonicalCodexResumeSessionId(paneContent) + : paneContent.provider === 'claude' + ? getCanonicalDurableSessionId(claudeSession) ?? getCanonicalPaneResumeSessionId(paneContent) + : undefined isBusyRef.current = isBusy // kata 1wxv: snapshot-stamped rollback capabilities drive every client affordance // (per-turn icon, slash/menus, the pre-flight gate). Legacy servers emit neither @@ -4621,9 +4652,7 @@ export function FreshAgentView({
This session has ended{sessionErrorMessage ? '' : ' (the agent process exited)'}.
- {paneContent.provider === 'codex' - && effectiveStatus === 'exited' - && getCanonicalCodexResumeSessionId(paneContent) ? ( + {effectiveStatus === 'exited' && endedSessionResumeId ? (