diff --git a/crates/freshell-claude-sidecar/boot.mjs b/crates/freshell-claude-sidecar/boot.mjs new file mode 100644 index 000000000..a3700272f --- /dev/null +++ b/crates/freshell-claude-sidecar/boot.mjs @@ -0,0 +1,113 @@ +// boot.mjs — how a `create` request becomes an SDK launch, and how a launch +// the CLI refused is reported (docs/plans/2026-10-22-freshclaude-core-runtime.md +// §4.2 and §5.1, protocol v2). Pure: index.mjs keeps the side effects. +// --------------------------------------------------------------------------- +// The sidecar answers `created` only once the CLI has booted +// (`initializationResult()` resolved, T2). A boot the CLI refused is answered +// with `create.failed { code, message }` instead, and the sidecar exits with +// BOOT_FAILED_EXIT_CODE. The codes come from the pinned CLI's exact refusal +// strings (claude-cli-contract.test.ts pins them, T4 and T5): +// +// SESSION_ID_IN_USE `Session ID X is already in use.` on the CLI's +// stderr, then exit code 1 (T4) +// CONVERSATION_NOT_FOUND `No conversation found with session ID: X` (T5) +// RESUME_POINT_NOT_FOUND `No message found with message.uuid of: U` (T5) +// FORK_GUARD_REFUSED `Resume rejected by --resume-drops-turn: …` (T5) +// BOOT_FAILED anything else (for one, a CLAUDE_CMD that is not +// the CLI) +// SIDECAR_PROTOCOL_MISMATCH the request does not speak SIDECAR_PROTOCOL + +/** The stdio protocol version this sidecar speaks (`create.protocol`, `created.protocol`). */ +export const SIDECAR_PROTOCOL = 2 + +/** The sidecar's exit code after a `create.failed`: it hosts no session. */ +export const BOOT_FAILED_EXIT_CODE = 2 + +/** The most characters of refusal text a `create.failed` carries. */ +export const BOOT_FAILURE_MESSAGE_MAX_CHARS = 1000 + +/** The refusals the CLI reports by name, each matched on its own line of evidence. */ +const REFUSALS = [ + { code: 'SESSION_ID_IN_USE', pattern: /Session ID \S+ is already in use\./ }, + { code: 'CONVERSATION_NOT_FOUND', pattern: /No conversation found with session ID:.*/ }, + { code: 'RESUME_POINT_NOT_FOUND', pattern: /No message found with message\.uuid of:.*/ }, + { code: 'FORK_GUARD_REFUSED', pattern: /Resume rejected by --resume-drops-turn:.*/ }, +] + +const DIAGNOSTIC_PREFIX = '[ede_diagnostic]' + +const BOOT_FAILED_COPY = 'The Claude CLI did not start.' + +/** A `create` the sidecar cannot launch: its own code, message and no SDK call. */ +export class CreateRequestError extends Error { + constructor(code, message) { + super(message) + this.name = 'CreateRequestError' + this.code = code + } +} + +/** + * The SDK `query()` options that pick which conversation the CLI boots + * (protocol v2 `create.boot`). `sessionId` is the id Rust minted for this + * session, when it minted one: a fresh boot runs under it and a fork writes + * the child under it. A resume never sets `sessionId` (the SDK refuses + * sessionId with resume unless forking). + */ +export function bootOptions(boot, sessionId) { + switch (boot?.kind) { + case 'fresh': + return sessionId ? { sessionId } : {} + case 'resume': + if (!nonEmpty(boot.resume)) throw invalidBoot('a resume boot names no conversation') + return { resume: boot.resume } + case 'fork': + if (!nonEmpty(boot.resume)) throw invalidBoot('a fork boot names no parent conversation') + return { + resume: boot.resume, + forkSession: true, + ...(nonEmpty(boot.resumeSessionAt) ? { resumeSessionAt: boot.resumeSessionAt } : {}), + ...(sessionId ? { sessionId } : {}), + ...(nonEmpty(boot.dropsTurn) ? { resumeDropsTurn: boot.dropsTurn } : {}), + } + default: + throw invalidBoot(`unknown boot kind ${JSON.stringify(boot?.kind ?? null)}`) + } +} + +/** + * Why the CLI refused to boot, as `{ code, message }`. The evidence is what + * the boot produced: `error` (the `initializationResult()` rejection), + * `resultErrors` (the `errors` of the `error_during_execution` result the CLI + * sends before it rejects, T5) and `stderr` (the CLI's own output, where the + * "already in use" refusal appears, T4). A named refusal's message is the + * CLI's own sentence; anything else is BOOT_FAILED with the error's text. + */ +export function classifyBootError({ error, resultErrors = [], stderr = '' } = {}) { + const results = resultErrors + .filter((text) => typeof text === 'string') + .map((text) => text.trim()) + .filter((text) => text && !text.startsWith(DIAGNOSTIC_PREFIX)) + const errorMessage = error === undefined ? '' : String(error?.message || error) + const evidence = [...results, errorMessage, ...String(stderr).split('\n')] + .flatMap((text) => text.split('\n')) + for (const { code, pattern } of REFUSALS) { + for (const line of evidence) { + const match = pattern.exec(line) + if (match) return { code, message: cut(match[0].trim()) } + } + } + return { code: 'BOOT_FAILED', message: cut(results.join('\n') || errorMessage || BOOT_FAILED_COPY) } +} + +function invalidBoot(message) { + return new CreateRequestError('BOOT_FAILED', `invalid create request: ${message}`) +} + +function nonEmpty(value) { + return typeof value === 'string' && value.length > 0 +} + +function cut(text) { + return text.slice(0, BOOT_FAILURE_MESSAGE_MAX_CHARS) +} diff --git a/crates/freshell-claude-sidecar/index.mjs b/crates/freshell-claude-sidecar/index.mjs index ad6deddea..30c22cca2 100644 --- a/crates/freshell-claude-sidecar/index.mjs +++ b/crates/freshell-claude-sidecar/index.mjs @@ -6,16 +6,18 @@ // harness (crates/freshell-freshagent, claude WS slice) speaks newline-delimited // JSON to this process: // -// Rust → sidecar (stdin, one JSON per line): -// { type:'create', requestId, cwd?, model?, permissionMode?, effort?, resumeSessionId?, -// resumeSessionAt?, forkSession?, resumeDropsTurn? } -// — kata 1wxv Task 4 fork-at-point keys ride the SDK query() options -// verbatim (resumeSessionAt keeps through-AND-including the named -// uuid over the raw parentUuid chain; forkSession mints a NEW -// durable session id and never rewrites the original's JSONL; -// resumeDropsTurn arms the fork-time discard-guard). Covered by -// crates/freshell-ws/tests/freshagent_claude_rollback.rs + the -// rust-chromium e2e rollback spec. +// Rust → sidecar (stdin, one JSON per line), protocol v2 (boot.mjs): +// { type:'create', protocol:2, requestId, sessionType?, sessionId?, +// boot:{kind:'fresh'} | {kind:'resume', resume} +// | {kind:'fork', resume, resumeSessionAt?, dropsTurn?}, +// cwd?, model?, permissionMode?, effort?, plugins?, hostedMcp? } +// — `boot` picks the conversation the CLI boots (bootOptions): a +// fork keeps the parent through-AND-including resumeSessionAt +// over the raw parentUuid chain, writes a NEW conversation and +// never rewrites the parent's JSONL; dropsTurn arms the SDK's +// fork-time discard guard. `sessionId` is the id Rust minted for +// a fresh or forked boot, when it minted one. One sidecar hosts +// one session: a second create is refused. // { type:'configure', sessionId, requestId, settings } // { type:'send', sessionId, text, images? } // { type:'interrupt', sessionId } @@ -24,8 +26,15 @@ // { type:'shutdown' } // // sidecar → Rust (stdout, one JSON per line): -// { type:'created', requestId, sessionId } // the SDK bridge's BARE nanoid placeholder -// { type:'create.failed', requestId, message } +// { type:'created', requestId, sessionId, protocol:2, cliPid } +// — sent only once the CLI booted (initializationResult() resolved, +// T2); sessionId is the SDK bridge's BARE nanoid placeholder and +// cliPid the CLI's pid. The command catalog follows it. +// { type:'create.failed', requestId, code, message } +// — the CLI refused the boot (boot.mjs names the codes) or the +// request is not protocol v2. Nothing from the refused boot is +// forwarded (its error result is the refusal's evidence, not a +// turn), and the sidecar then exits with code 2. // { type:'sdk.configured', sessionId, requestId, ok, settings?, message? } // { type:'sdk.session.init', sessionId, cliSessionId, model, cwd, tools } // durable Claude UUID // { type:'sdk.assistant', sessionId, content, model } @@ -34,14 +43,22 @@ // { 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.session.changed', sessionId, reason:'session-commands', commands } +// — the command catalog without the terminal-only commands (the pinned +// CLI's until a turn's init names its own): from the boot, then +// whenever a turn's init or commands_changed changes it +// { 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.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 +77,16 @@ // 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. A boot the CLI refused ends +// the sidecar with `create.failed` and exit code 2. When the 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 +111,9 @@ 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 { BOOT_FAILED_EXIT_CODE, bootOptions, classifyBootError, SIDECAR_PROTOCOL } from './boot.mjs' +import { usableCommands } from './terminal-commands.mjs' import { createClaudeSdkCleanEnv } from './sdk-env.mjs' import { canUseTool as routeCanUseTool, @@ -108,6 +133,20 @@ function emit(msg) { process.stdout.write(`${JSON.stringify(msg)}\n`) } +/** Set once the sidecar is exiting on its own (a refused boot, or an unrequested stream end). */ +let exiting = false +/** How long an exiting sidecar waits for stdout and stderr to flush. */ +const EXIT_FLUSH_BUDGET_MS = 2000 +/** Set by the first `create`: one sidecar hosts one session. */ +let createReceived = false +/** The most CLI stderr kept while the CLI boots (a refusal may be named there, T4). */ +const BOOT_STDERR_MAX_CHARS = 8192 +/** + * After initializationResult() rejected: how long the refusal waits for the + * SDK stream to end, so the CLI's last stderr line is part of its evidence. + */ +const BOOT_FAILURE_SETTLE_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 @@ -206,10 +245,20 @@ function normalizeCommands(rows, strict = false) { return result } -function publishCommands(sessionId, state) { - if (!state.commandsInitSeen || state.commandCatalog === undefined) return - const terminal = new Set(state.terminalCommandNames ?? []) - const commands = state.commandCatalog.filter((row) => !terminal.has(row.name)) +/** + * Publish the session's command catalog when it differs from the last one + * published. The catalog comes from the boot (initializationResult().commands) + * and from commands_changed, and leaves out the CLI's terminal-only commands: + * the ones each turn's init names, or the pinned CLI's before the first init + * (terminal-commands.mjs). Every init repeats the same lists, so an unchanged + * catalog is never sent again. + */ +function publishCommandsIfChanged(sessionId, state) { + if (!state.booted || state.commandCatalog === undefined) return + const commands = usableCommands(state.commandCatalog, state.terminalCommandNames) + const published = JSON.stringify(commands) + if (published === state.publishedCommands) return + state.publishedCommands = published emit({ type: 'sdk.session.changed', sessionId, reason: 'session-commands', commands }) } @@ -217,6 +266,12 @@ function publishCommands(sessionId, state) { function handleSdkMessage(sessionId, msg) { const st = sessions.get(sessionId) if (!st) return + // Until `created` is sent nothing reaches Rust: a refused boot's + // error_during_execution result (T5) is the refusal's evidence, not a turn. + if (!st.booted) { + if (msg.type === 'result' && Array.isArray(msg.errors)) st.bootEvidence.resultErrors.push(...msg.errors) + return + } // ep4-r6 quiesce bookkeeping (fail-closed approximations; the discharge rule // is tight per ep4-r5 F1): @@ -252,7 +307,6 @@ function handleSdkMessage(sessionId, msg) { if (msg.subtype === 'init') { st.cliSessionId = msg.session_id st.settings.cwd ??= msg.cwd - st.commandsInitSeen = true st.terminalCommandNames = Array.isArray(msg.terminal_slash_commands) ? msg.terminal_slash_commands : [] emit({ type: 'sdk.session.init', @@ -262,13 +316,12 @@ function handleSdkMessage(sessionId, msg) { cwd: msg.cwd, tools: Array.isArray(msg.tools) ? msg.tools.map((t) => ({ name: t })) : undefined, }) - publishCommands(sessionId, st) + publishCommandsIfChanged(sessionId, st) } else if (msg.subtype === 'commands_changed') { const commands = normalizeCommands(msg.commands, true) if (commands !== undefined) { st.commandCatalog = commands - st.commandsChangedSeen = true - publishCommands(sessionId, st) + publishCommandsIfChanged(sessionId, st) } } else if (msg.subtype === 'status' && msg.status === 'compacting') { emit({ type: 'sdk.status', sessionId, status: 'compacting' }) @@ -331,93 +384,156 @@ 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 }) + // A stream that ends before the CLI booted is a refused boot: bootSession + // reports it (create.failed) with this end as part of its evidence. + if (st && !st.booted) { + st.bootEvidence.streamEnded = failure ?? {} + return } - // 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 }) + } + // 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 }) + exitWithoutSession(st?.query, STREAM_ENDED_EXIT_CODE) + } } } +/** + * The sidecar has no session left (a refused boot, or an unrequested stream + * end): read no more requests (the frame just written stays the last line), + * make sure the CLI is gone, and exit with `code` once stdout and stderr have + * flushed. + */ +function exitWithoutSession(sdkQuery, code) { + if (exiting) return + exiting = true + try { sdkQuery?.close?.() } catch (error) { log('warn', 'stream.close_failed', { error: errorText(error) }) } + let unflushed = 2 + const flushed = () => { if (--unflushed === 0) process.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(code), EXIT_FLUSH_BUDGET_MS) +} + // ── request handlers ───────────────────────────────────────────────────────── + +/** + * A boot that did not happen: answer `create.failed` (the last line this + * sidecar writes) and exit with BOOT_FAILED_EXIT_CODE. + */ +function failBoot(requestId, { code, message }, fields = {}, sdkQuery = undefined) { + log('warn', 'boot.failed', { requestId, code, error: message, ...fields }) + emit({ type: 'create.failed', requestId, code, message }) + exitWithoutSession(sdkQuery, BOOT_FAILED_EXIT_CODE) +} + function handleCreate(req) { const requestId = req.requestId - let sessionId - try { - sessionId = nanoid() - const abort = new AbortController() - // ep4-r3/ep4-r4 quiesce state (declared first so the stream can arm it): - // turnOpen — an SDK turn is mid-flight (cleared at its result); - // handedCompactLikely — a compact crossed the un-cancellable SDK handoff - // (same-tick push OR queued pull — either path); cleared at the next - // result or observed status frame (its evidence has by then reached Rust). - const state = { - abort, - permissionMode: req.permissionMode, - settings: { model: req.model, effort: req.effort, permissionMode: req.permissionMode, cwd: req.cwd }, - turnOpen: false, - handedCompactLikely: false, - // Unified attention bookkeeping: the gate suppresses exactly the - // interrupted turn's own result (ordering-scoped, NO reset-at-send — - // see turn-complete-gate.mjs); pendingResults counts ACCEPTED sends - // still awaiting their terminal result frame, so consumeStream's - // finally can mint the edge for a result-less turn end. - turnCompleteGate: createTurnCompleteGate(), - pendingResults: 0, - pendingCheckpointAttempts: new Map(), - } - const { iterable, handle } = createInputStream((isCompact) => { - if (isCompact) state.handedCompactLikely = true + if (createReceived) { + log('warn', 'create.refused', { requestId, reason: 'second_create' }) + emit({ type: 'create.failed', requestId, code: 'BOOT_FAILED', message: 'This Claude sidecar already hosts a session.' }) + return + } + createReceived = true + // A server and sidecar from different builds would misread each other: + // fail before anything is launched. + if (req.protocol !== SIDECAR_PROTOCOL) { + failBoot(requestId, { + code: 'SIDECAR_PROTOCOL_MISMATCH', + message: `The server sent sidecar protocol ${req.protocol ?? 1}; this Claude sidecar speaks protocol ${SIDECAR_PROTOCOL}.`, }) - state.inputStream = handle - // Liveness IS session-map membership: consumeStream's finally removes the - // session synchronously after cancelPending (LB-04 — a parked canUseTool - // promise must never be resolved after transport close), and stdin line - // events (macrotasks) cannot interleave inside that finally. - sessions.set(sessionId, state) + return + } + let launch + try { + launch = bootOptions(req.boot, req.sessionId) + } catch (error) { + failBoot(requestId, { code: error?.code ?? 'BOOT_FAILED', message: errorText(error) }) + return + } + + const sessionId = nanoid() + const abort = new AbortController() + // ep4-r3/ep4-r4 quiesce state (declared first so the stream can arm it): + // turnOpen — an SDK turn is mid-flight (cleared at its result); + // handedCompactLikely — a compact crossed the un-cancellable SDK handoff + // (same-tick push OR queued pull — either path); cleared at the next + // result or observed status frame (its evidence has by then reached Rust). + const state = { + abort, + permissionMode: req.permissionMode, + settings: { model: req.model, effort: req.effort, permissionMode: req.permissionMode, cwd: req.cwd }, + // False until `created` is sent; until then nothing reaches Rust and + // bootEvidence collects what a refusal is named from (boot.mjs). + booted: false, + bootEvidence: { resultErrors: [], stderr: '', streamEnded: undefined }, + turnOpen: false, + handedCompactLikely: false, + // Unified attention bookkeeping: the gate suppresses exactly the + // interrupted turn's own result (ordering-scoped, NO reset-at-send — + // see turn-complete-gate.mjs); pendingResults counts ACCEPTED sends + // still awaiting their terminal result frame, so consumeStream's + // finally can mint the edge for a result-less turn end. + turnCompleteGate: createTurnCompleteGate(), + pendingResults: 0, + pendingCheckpointAttempts: new Map(), + } + const { iterable, handle } = createInputStream((isCompact) => { + if (isCompact) state.handedCompactLikely = true + }) + state.inputStream = handle + // Liveness IS session-map membership: consumeStream's finally removes the + // session synchronously after cancelPending (LB-04 — a parked canUseTool + // promise must never be resolved after transport close), and stdin line + // events (macrotasks) cannot interleave inside that finally. + sessions.set(sessionId, state) - const sdkStderr = cliStderrLogger(log, { hosted: isHostedSidecar(), fields: { sessionId } }) - const sdkQuery = query({ + const logCliStderr = cliStderrLogger(log, { hosted: isHostedSidecar(), fields: { sessionId } }) + const sdkStderr = (data) => { + if (!state.booted) state.bootEvidence.stderr = (state.bootEvidence.stderr + String(data)).slice(-BOOT_STDERR_MAX_CHARS) + logCliStderr(data) + } + let sdkQuery + try { + sdkQuery = query({ prompt: iterable, options: { cwd: req.cwd || undefined, - resume: req.resumeSessionId, - // kata 1wxv Task 4 (fork-at-point emulation): the ONLY sanctioned lane is - // the query() options triple — NEVER the standalone forkSession() fn (it - // remaps every uuid). - resumeSessionAt: req.resumeSessionAt || undefined, - forkSession: req.forkSession === true || undefined, - resumeDropsTurn: req.resumeDropsTurn || undefined, + // Which conversation the CLI boots: fresh, resumed or forked (boot.mjs). + // A fork rides the query() options — NEVER the standalone forkSession() + // fn (it remaps every uuid). + ...launch, model: req.model, permissionMode: req.permissionMode, // Enables the user's in-session permission selector; the selected mode @@ -449,25 +565,65 @@ function handleCreate(req) { }, }, }) - state.query = sdkQuery - state.commandsChangedSeen = false - if (typeof sdkQuery.supportedCommands === 'function') { - Promise.resolve().then(() => sdkQuery.supportedCommands()).then((rows) => { - if (state.query !== sdkQuery || state.commandsChangedSeen) return - const commands = normalizeCommands(rows) - if (commands === undefined) return - state.commandCatalog = commands - publishCommands(sessionId, state) - }).catch((error) => log('warn', 'commands.unavailable', { sessionId, error: errorText(error) })) - } + } catch (error) { + sessions.delete(sessionId) + failBoot(requestId, classifyBootError({ error }), { sessionId }) + return + } + state.query = sdkQuery + const streamDone = consumeStream(sessionId, sdkQuery) + streamDone.catch((err) => log('error', 'stream.consume_failed', { sessionId, error: errorText(err) })) + bootSession({ req, sessionId, state, sdkQuery, streamDone }) + .catch((err) => log('error', 'boot.handler_failed', { requestId, sessionId, error: errorText(err) })) +} - // Return the placeholder before the native system/init identity arrives. - emit({ type: 'created', requestId, sessionId }) - consumeStream(sessionId, sdkQuery).catch((err) => log('error', 'stream.consume_failed', { sessionId, error: errorText(err) })) - } catch (err) { - if (sessionId) sessions.delete(sessionId) - log('warn', 'create.failed', { requestId, error: errorText(err) }) - emit({ type: 'create.failed', requestId, message: err?.message || String(err) }) +/** + * Wait for the CLI to boot (initializationResult(), T2: no prompt needed), + * then answer `created` and publish the command catalog from the boot. A + * refused boot (T4, T5) is answered `create.failed` with its typed code. + */ +async function bootSession({ req, sessionId, state, sdkQuery, streamDone }) { + const requestId = req.requestId + const startedAt = Date.now() + const fields = () => ({ sessionId, sessionType: req.sessionType, boot: req.boot?.kind, ms: Date.now() - startedAt }) + // A stream that ends first ends the boot too, whatever initializationResult() does. + const streamEndedFirst = streamDone.then(() => { + throw state.bootEvidence?.streamEnded?.error ?? new Error('The Claude CLI ended the session while it started.') + }) + // Once the boot is settled a later stream end is the stream's own business (consumeStream). + streamEndedFirst.catch(() => {}) + let init + let error + try { + if (typeof sdkQuery.initializationResult !== 'function') throw new Error('The Claude SDK cannot report its boot (no initializationResult).') + init = await Promise.race([sdkQuery.initializationResult(), streamEndedFirst]) + } catch (rejection) { + error = rejection + } + // A CLI that died right after it booted did not boot. + if (error === undefined && state.bootEvidence.streamEnded) { + error = state.bootEvidence.streamEnded.error ?? new Error('The Claude CLI ended the session while it started.') + } + if (error !== undefined) { + // The CLI writes its refusal before it exits, and the stream ends after + // the exit: wait for that end (bounded) so all the evidence is in. + await Promise.race([streamDone, new Promise((resolve) => setTimeout(resolve, BOOT_FAILURE_SETTLE_MS))]) + const { resultErrors, stderr } = state.bootEvidence + sessions.delete(sessionId) + failBoot(requestId, classifyBootError({ error, resultErrors, stderr }), fields(), sdkQuery) + return + } + // A shutdown or stdin close while the CLI booted: the sidecar is going away. + if (!sessions.has(sessionId)) return + state.booted = true + state.bootEvidence = undefined + const cliPid = Number.isInteger(init?.pid) ? init.pid : undefined + emit({ type: 'created', requestId, sessionId, protocol: SIDECAR_PROTOCOL, ...(cliPid !== undefined ? { cliPid } : {}) }) + log('info', 'boot.ok', { requestId, cliPid, ...fields() }) + const commands = normalizeCommands(init?.commands) + if (commands !== undefined) { + state.commandCatalog = commands + publishCommandsIfChanged(sessionId, state) } } @@ -604,6 +760,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 @@ -653,14 +811,18 @@ 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) } }) // stdout lost (Rust dropped our reader, e.g. a kill aborted its consumer before // it wrote the shutdown line): nobody hears us any more, the same request as // stdin EOF. Unhandled, the next emit's EPIPE kills node with exit 1 before // the SDK query is aborted, and nothing stops the claude CLI: it runs on until -// it next writes into its own closed pipe or Rust kills the process tree. +// it next writes into its own closed pipe or Rust kills the process tree. A +// sidecar already exiting on its own (a refused boot, an unrequested stream +// end) has closed its query and keeps its own exit code: its flush wait +// (exitWithoutSession) still ends the process. process.stdout.on('error', (err) => { log('warn', 'stdout.closed', { code: err?.code ?? null, error: errorText(err) }) + if (exiting) return 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-claude-sidecar/terminal-commands.mjs b/crates/freshell-claude-sidecar/terminal-commands.mjs new file mode 100644 index 000000000..754d73536 --- /dev/null +++ b/crates/freshell-claude-sidecar/terminal-commands.mjs @@ -0,0 +1,23 @@ +// terminal-commands.mjs — the slash commands an SDK session cannot run. +// +// The CLI lists every command in its boot catalog (initializationResult().commands, +// T2), terminal-only ones included, and names the terminal-only ones only in the +// `terminal_slash_commands` of each turn's system/init (T1), which needs a prompt. +// The catalog the sidecar publishes at boot must already leave them out, so until +// the first init reports the CLI's own list the sidecar uses the pinned CLI's. + +/** + * The pinned CLI's terminal-only commands, exactly as its system/init lists them. + * The real-CLI contract suite (test/integration/providers/claude-cli-contract.test.ts) + * fails when the pinned CLI's list differs. + */ +export const PINNED_TERMINAL_COMMANDS = Object.freeze(['doctor', 'color']) + +/** + * The catalog rows an SDK session can use: `catalog` without the terminal-only + * commands (`terminalNames`, from the latest init; the pinned list before one). + */ +export function usableCommands(catalog, terminalNames = PINNED_TERMINAL_COMMANDS) { + const terminal = new Set(terminalNames) + return catalog.filter((row) => !terminal.has(row.name)) +} diff --git a/crates/freshell-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index 83af2a42c..05120bdce 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -75,10 +75,12 @@ use freshell_protocol::{ FreshAgentSessionMaterialized, ServerMessage, SessionRuntimeOwner, SessionType, }; +use crate::claude_handshake::{read_created, BootContext}; +use crate::claude_launch::{create_line, ClaudeBoot}; use crate::{FreshAgentCreateDedup, FreshAgentCreateOutcome, SharedPaneIdentitySink}; /// The runtime provider (`AGENT_SESSION_TYPES.claude.provider`). -const PROVIDER: &str = "claude"; +pub(crate) const PROVIDER: &str = "claude"; /// Build the sidecar-only correlation record for one checkpointed Claude /// send. The UUID is supplied on SDKUserMessage and later accepted only when @@ -293,6 +295,15 @@ 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: crate::claude_dead_sessions::SharedDeadSessions, + /// Test holds on the session lifecycle ([`crate::claude_test_holds`]); none + /// is set in production. + test_holds: Arc, } /// Focused-ep5-r1 Finding 1: retention for demoted alias records. The @@ -474,13 +485,21 @@ impl AliasTombstones { } /// The cached result of a completed claude/kilroy `freshAgent.create`, keyed by -/// `requestId` in [`FreshClaudeState::create_dedup`]. Claude's `create()` returns only a -/// bare nanoid placeholder (no `sessionRef` -- `adapter.ts` returns `{ sessionId }` only), -/// so only `session_id` need be cached; the replay branch mirrors the live path's own -/// broadcast (NO `sessionRef`). +/// `requestId` in [`FreshClaudeState::create_dedup`]: what the original +/// `freshAgent.created` said, so the replay branch re-answers identically (a resume +/// create's `sessionRef` included). #[derive(Clone)] struct ClaudeCreateRecord { session_id: String, + session_ref: Option, +} + +/// The `sessionRef` of the claude conversation `session_id`. +fn claude_session_ref(session_id: &str) -> freshell_protocol::SessionLocator { + freshell_protocol::SessionLocator { + provider: PROVIDER.to_string(), + session_id: session_id.to_string(), + } } /// The per-session pending approval/question set folded from the sidecar's stdout @@ -651,23 +670,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 @@ -676,9 +678,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 → @@ -812,6 +816,8 @@ 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: Default::default(), + test_holds: Arc::default(), handoff_kill_pause: None, rollback_adopt_parked: None, rollback_adopt_release: None, @@ -847,6 +853,23 @@ impl FreshClaudeState { self } + /// Test seam (see [`crate::claude_test_holds`]): every later session + /// registration waits `hold` between starting the session's consumer and + /// inserting its record, the way a production resume create awaits its + /// identity-sink write there. + #[cfg(any(test, feature = "test-support"))] + pub fn hold_session_registration(&self, hold: Duration) { + self.test_holds.hold_registration(hold); + } + + /// Test seam (see [`crate::claude_test_holds`]): park every later + /// end-of-output path after it recorded the dead session, before it reaps the + /// sidecar and reports the exit, until the returned hold is released. + #[cfg(any(test, feature = "test-support"))] + pub fn hold_exit_reports(&self) -> Arc { + self.test_holds.hold_exit_report() + } + /// Spawn one claude sidecar process (create, attach-resume and rollback all /// come through here) for a session of `session_type`. async fn spawn_sidecar_process(&self, session_type: &str) -> Result { @@ -1086,33 +1109,15 @@ impl FreshClaudeState { /// holds `Stopping`) additionally defers its commit on the escalation. async fn teardown_removed_session_logged(&self, session: ClaudeSession) -> LoggedTeardown { let session_label = session.sidecar_session_id.clone(); - match teardown_removed_session( - session, - self.handoff_confirm_rounds - .unwrap_or(TREE_DEATH_CONFIRM_ROUNDS), - ) - .await - { - TeardownConfirmation::Confirmed => LoggedTeardown::Confirmed, - TeardownConfirmation::PlatformLimited => LoggedTeardown::PlatformLimited, - TeardownConfirmation::NotConfirmed { tree, ownership_id } => { - tracing::error!(target: "freshell_freshagent::claude", - session_id = %session_label, - lingering = tree.len(), - "freshagent.claude.teardown_unconfirmed: the bounded confirmation \ - window expired with tagged descendants still alive — the teardown \ - did NOT confirm the runtime tree's death; the escalation continues \ - detached and the caller's coordinator fence (if any) stays held" - ); - LoggedTeardown::NotConfirmed { - escalation: spawn_claude_tree_death_escalation( - tree, - ownership_id, - session_label, - ), - } - } - } + let confirmation = teardown_removed_session(session, self.tree_confirm_rounds()).await; + escalate_unconfirmed_teardown(confirmation, session_label) + } + + /// The tree-death confirmation budget every sidecar stop uses (a test can + /// shorten it). + fn tree_confirm_rounds(&self) -> u8 { + self.handoff_confirm_rounds + .unwrap_or(TREE_DEATH_CONFIRM_ROUNDS) } /// Wire the cross-kind terminal-liveness probe (Task 13b; called by `main.rs` @@ -1922,9 +1927,7 @@ impl FreshClaudeState { let mut guard = self.sessions.lock().await; guard.drain().map(|(_, s)| s).collect() }; - let confirm_rounds = self - .handoff_confirm_rounds - .unwrap_or(TREE_DEATH_CONFIRM_ROUNDS); + let confirm_rounds = self.tree_confirm_rounds(); // One task per session: each teardown's synchronous `/proc` scans run // on the runtime's workers in parallel, so no session's shutdown // line waits behind another session's scan. @@ -2009,8 +2012,10 @@ impl FreshClaudeState { // ── freshAgent.create (WS) ─────────────────────────────────────────────────────── /// Handle a `freshAgent.create` for claude/kilroy: spawn the Node sidecar, drive the - /// SDK `create` to get the BARE nanoid placeholder, register the session + its stdout - /// consumer, and broadcast `freshAgent.created` (or `freshAgent.create.failed`). + /// SDK `create` to get the BARE nanoid placeholder (the sidecar answers `created` only + /// once the CLI booted the conversation; a refused boot is a typed `create.failed`), + /// register the session + its stdout consumer, and broadcast `freshAgent.created` + /// (with `sessionRef` for a resume) or `freshAgent.create.failed`. /// Long-running (cold sidecar spawn), so the WS loop dispatches this as a detached task. /// `provenance` (D8): the WS connection's stamped identity for this create /// (`None` on conn-less lanes); threaded to the `sdk.session.init` binding write. @@ -2040,7 +2045,7 @@ impl FreshClaudeState { runtime_provider: PROVIDER.to_string(), session_id: cached.session_id, session_type: session_type.to_string(), - session_ref: None, + session_ref: cached.session_ref, name_ref, session_name, })); @@ -2478,18 +2483,21 @@ impl FreshClaudeState { }; // Send the create request (faithful to createClaudeSdkOptions inputs). - let create_req = json!({ - "type": "create", - "requestId": request_id, - "cwd": msg.cwd, - "model": msg.model, - "permissionMode": msg.permission_mode, - "effort": msg.effort, - "plugins": msg.plugins, - "hostedMcp": self.hosted_mcp, - "modelSelection": msg.model_selection, - "resumeSessionId": resume_sid, - }); + let boot = ClaudeBoot::resume_or_fresh(resume_sid.as_deref()); + let create_req = create_line( + &request_id, + session_type, + &boot, + json!({ + "cwd": msg.cwd, + "model": msg.model, + "permissionMode": msg.permission_mode, + "effort": msg.effort, + "plugins": msg.plugins, + "hostedMcp": self.hosted_mcp, + "modelSelection": msg.model_selection, + }), + ); if let Err(err) = write_line(&mut stdin, &create_req).await { let _ = child.start_kill(); reap_owned_claude_sidecars(&ownership_id); @@ -2501,19 +2509,27 @@ impl FreshClaudeState { return; } - // Read stdout until `created` / `create.failed` (bounded). Keep the reader to hand - // to the consumer so no post-created event line is lost. + // Read stdout until `created` / `create.failed` (bounded): the sidecar answers + // `created` only once the CLI has booted the conversation, and a refused boot + // (a resume of a conversation the CLI does not have, a CLAUDE_CMD that is not + // the CLI) is a typed `create.failed`, never a session that is already dead. + // Keep the reader to hand to the consumer so no post-created event line is lost. let mut reader = BufReader::new(stdout).lines(); - let created = match read_created(&mut reader, SIDECAR_CREATE_BUDGET).await { - Ok(session_id) => session_id, - Err(err) => { + let boot_context = BootContext { + request_id: &request_id, + session_type, + boot: &boot, + }; + let created = match read_created(&mut reader, SIDECAR_CREATE_BUDGET, &boot_context).await { + Ok(created) => created.session_id, + Err(failure) => { let _ = child.start_kill(); reap_owned_claude_sidecars(&ownership_id); // Own tree torn down above -- release so the loser can acquire. if let Some(mut g) = lease_guard.take() { g.fail(); } - self.fail_create(&request_id, "CLAUDE_CREATE_FAILED", &err); + self.fail_create(&request_id, failure.code(), failure.message()); return; } }; @@ -2620,49 +2636,92 @@ impl FreshClaudeState { } } + // V5 interleaving 2 (Task 12): on the create-resume path, insert + // `cli_index[durable] = map_key` SYNCHRONOUSLY at session registration + // (mirroring the attach path) -- NOT lazily at `sdk.session.init`, which lands + // hundreds of ms later and leaves `has_live_session` blind exactly when the 1s + // loser retry arrives. The `sdk.session.init` write stays as a corrector. + if let Some(sid) = resume_sid.as_deref() { + self.cli_index + .lock() + .await + .insert(sid.to_string(), created.clone()); + } + // Start the stdout consumer (the completion edge normalization + the Task 2 - // pending-set fold live here). `Some(settings)` => the consumer records a - // binding row at `sdk.session.init`. + // pending-set fold live here) and register the session, under the sessions + // map lock (review PRB-C10): the consumer reaches its session only through + // that lock, so no line it reads -- the boot's command catalog, a CLI death + // right after the boot -- can find the session missing. `Some(settings)` => + // the consumer records a binding row at `sdk.session.init`. let broadcast_id = Arc::new(std::sync::Mutex::new(created.clone())); log_labels.bind_session(&broadcast_id); 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(())); // Fresh session: nothing announced yet; the consumer's status fold owns it. let last_status = Arc::new(std::sync::Mutex::new("idle".to_string())); - let consumer = self.spawn_consumer( - reader, - created.clone(), - session_type.to_string(), - created.clone(), - Some(settings.clone()), - Arc::clone(&broadcast_id), - Arc::clone(&pending), - 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, - ); - - // V5 interleaving 2 (Task 12): on the create-resume path, insert - // `cli_index[durable] = map_key` SYNCHRONOUSLY at session registration - // (mirroring the attach path) -- NOT lazily at `sdk.session.init`, which lands - // hundreds of ms later and leaves `has_live_session` blind exactly when the 1s - // loser retry arrives. The `sdk.session.init` write stays as a corrector. + { + let mut sessions = self.sessions.lock().await; + let consumer = self.spawn_consumer( + reader, + created.clone(), + session_type.to_string(), + created.clone(), + Some(settings.clone()), + Arc::clone(&broadcast_id), + Arc::clone(&pending), + Arc::clone(&last_status), + Arc::clone(&in_turn), + Arc::clone(&turn_tracker), + Arc::clone(&last_synthesized_complete_at), + None, + provenance, + ); + self.test_holds.registration().await; + sessions.insert( + created.clone(), + ClaudeSession { + configuration: ClaudeConfiguration { + settings, + ..ClaudeConfiguration::default() + }, + stdin, + child, + tree_guard, + ownership_id: ownership_id.clone(), + consumer, + sidecar_session_id: created.clone(), + cli_session_id: resume_sid.clone(), + // b8ke focused episode-2 round-2 F2: the owning lifecycle op + // — the create-resume claim's op id, so the session-init + // adoption's Blocked arm can correlate its OWN in-flight + // claim (a foreign blocker tears down instead). + owning_operation: own_ticket.as_ref().map(|t| t.operation_id().to_string()), + owning_generation: None, + broadcast_id, + pending, + in_turn, + turn_tracker, + last_synthesized_complete_at, + turn_lock, + rollback_probe_slot: Arc::new(std::sync::Mutex::new(None)), + last_status, + }, + ); + // C09: a create that resumes an ended session makes it live again. + // Forgotten before the lock is released, so a death of this new + // incarnation is recorded after it, never forgotten by it. + if let Some(sid) = resume_sid.as_deref() { + self.dead_sessions.forget(&[sid], "resumed"); + } + } + // Round 6 (Finding 2): the alias mapping is durable at registration (the + // adopt path's corrector re-stamps it later). if let Some(sid) = resume_sid.as_deref() { - self.cli_index - .lock() - .await - .insert(sid.to_string(), created.clone()); - // Round 6 (Finding 2): the alias mapping is durable at - // registration (the adopt path's corrector re-stamps it later). if let Some(sink) = self.identity_sink() { if let Err(e) = sink .record_alias_tombstone( @@ -2678,37 +2737,6 @@ impl FreshClaudeState { } } } - self.sessions.lock().await.insert( - created.clone(), - ClaudeSession { - configuration: ClaudeConfiguration { - settings, - ..ClaudeConfiguration::default() - }, - stdin, - child, - tree_guard, - ownership_id: ownership_id.clone(), - consumer, - sidecar_session_id: created.clone(), - cli_session_id: resume_sid.clone(), - // b8ke focused episode-2 round-2 F2: the owning lifecycle op - // — the create-resume claim's op id, so the session-init - // adoption's Blocked arm can correlate its OWN in-flight - // claim (a foreign blocker tears down instead). - owning_operation: own_ticket.as_ref().map(|t| t.operation_id().to_string()), - owning_generation: None, - broadcast_id, - 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)), - last_status, - }, - ); // 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 @@ -2818,18 +2846,23 @@ impl FreshClaudeState { // codex/opencode: a duplicate `create` arriving right after this point must see // the cache populated, never race past this guard's release and spawn a second // sidecar). + // A resume create names the conversation the CLI booted: `created` comes + // only after that boot, so the caller learns the native identity here + // instead of waiting for a turn's init (design §6, §7.3). A fresh create + // has no durable id until its first turn, so it carries none. + let session_ref = resume_sid.as_deref().map(claude_session_ref); self.create_dedup .record_success( &request_id, ClaudeCreateRecord { session_id: created.clone(), + session_ref: session_ref.clone(), }, ) .await; - // Broadcast freshAgent.created (ws-handler.ts:3378). NO sessionRef for claude - // (adapter.ts returns { sessionId } only); placeholder == the bare nanoid. - // Unified agent names: the created frame carries the pane's naming + // Broadcast freshAgent.created (ws-handler.ts:3378); placeholder == the bare + // nanoid. Unified agent names: the created frame carries the pane's naming // projection (the pending handle's record) when one was admitted. let name_ref = naming_projection .as_ref() @@ -2841,7 +2874,7 @@ impl FreshClaudeState { runtime_provider: PROVIDER.to_string(), session_id: created, session_type: session_type.to_string(), - session_ref: None, + session_ref, name_ref, session_name, })); @@ -2919,6 +2952,7 @@ impl FreshClaudeState { request_id, ClaudeCreateRecord { session_id: durable.to_string(), + session_ref: Some(claude_session_ref(durable)), }, ) .await; @@ -2942,10 +2976,7 @@ impl FreshClaudeState { runtime_provider: PROVIDER.to_string(), session_id: durable.to_string(), session_type: session_type.to_string(), - session_ref: Some(freshell_protocol::SessionLocator { - provider: PROVIDER.to_string(), - session_id: durable.to_string(), - }), + session_ref: Some(claude_session_ref(durable)), name_ref, session_name, })); @@ -3669,6 +3700,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.dead_sessions.forget(&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). @@ -4513,7 +4546,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; @@ -4649,7 +4682,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; } } @@ -4684,7 +4717,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 @@ -5301,24 +5334,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_sessions.find(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), @@ -5332,7 +5391,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); } } @@ -5373,6 +5432,34 @@ impl FreshClaudeState { self.sessions.lock().await.contains_key(&key) } + /// 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_sessions.find(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, @@ -5581,7 +5668,6 @@ impl FreshClaudeState { durable_id, in_turn, turn_tracker, - result_idle_pair_pending, turn_lock, session_type, last_synthesized_complete_at, @@ -5592,7 +5678,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 @@ -6486,11 +6571,6 @@ impl FreshClaudeState { let mut guard_retried = guard_uuid.is_none(); // no guard => nothing to retry let spawned = loop { let mut create_req = Map::new(); - create_req.insert("type".to_string(), json!("create")); - create_req.insert( - "requestId".to_string(), - json!(format!("rollback-{}", uuid::Uuid::new_v4())), - ); if let Some(cwd) = &spawn_cwd { create_req.insert("cwd".to_string(), json!(cwd)); } @@ -6505,20 +6585,23 @@ impl FreshClaudeState { create_req.insert("effort".to_string(), json!(effort)); } } - if let Some(resume_at) = &resume_at_uuid { - create_req.insert("resumeSessionId".to_string(), json!(resume_value)); - create_req.insert("resumeSessionAt".to_string(), json!(resume_at)); - create_req.insert("forkSession".to_string(), json!(true)); - if let Some(guard) = &guard_uuid { + let boot = match &resume_at_uuid { + Some(resume_at) => ClaudeBoot::Fork { + resume: resume_value.clone(), + at: Some(resume_at.clone()), // attempt 1 arms the guard; the retried recovery omits it. - if !guard_retried { - create_req.insert("resumeDropsTurn".to_string(), json!(guard)); - } - } - } - let create_req = Value::Object(create_req); + drops_turn: guard_uuid.clone().filter(|_| !guard_retried), + }, + None => ClaudeBoot::Fresh, + }; + let create_req = create_line( + &format!("rollback-{}", uuid::Uuid::new_v4()), + session_type, + &boot, + Value::Object(create_req), + ); match self - .rollback_spawn_create(&create_req, session_type, lease_guard.as_mut()) + .rollback_spawn_create(&create_req, &boot, session_type, lease_guard.as_mut()) .await { Ok(ok) => break Ok(ok), @@ -6586,59 +6669,63 @@ impl FreshClaudeState { // clock (extracted with the other carried handles above) — the fork // continues the same logical session lifetime, so its Rust-minted // `at`s stay ordered against anything the session minted before. - let consumer = self.spawn_consumer( - reader, - map_key.clone(), - session_type.to_string(), - sidecar_session_id.clone(), - recovered.clone(), - Arc::clone(&broadcast_id), - Arc::clone(&pending), - 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(), - preseeded_init, - adopted_tx, - }), - // Conn-less lane (D8): the rollback respawn has no client - // connection — the ledger merge inherits the superseded parent's - // stamps. - None, - ); - self.sessions.lock().await.insert( - map_key.clone(), - ClaudeSession { - configuration: ClaudeConfiguration { - settings: recovered.clone().unwrap_or_default(), - ..ClaudeConfiguration::default() + // Spawned and registered under the sessions map lock (review PRB-C10): no + // line the consumer reads can find the replacement session missing. + { + let mut sessions = self.sessions.lock().await; + let consumer = self.spawn_consumer( + reader, + map_key.clone(), + session_type.to_string(), + sidecar_session_id.clone(), + recovered.clone(), + Arc::clone(&broadcast_id), + Arc::clone(&pending), + Arc::clone(&last_status), + in_turn.clone(), + turn_tracker.clone(), + Arc::clone(&last_synthesized_complete_at), + Some(RollbackAdoption { + supersedes: durable_id.clone(), + preseeded_init, + adopted_tx, + }), + // Conn-less lane (D8): the rollback respawn has no client + // connection — the ledger merge inherits the superseded parent's + // stamps. + None, + ); + self.test_holds.registration().await; + sessions.insert( + map_key.clone(), + ClaudeSession { + configuration: ClaudeConfiguration { + settings: recovered.clone().unwrap_or_default(), + ..ClaudeConfiguration::default() + }, + stdin, + child, + tree_guard, + ownership_id, + consumer, + sidecar_session_id, + cli_session_id: Some(cli_id.clone()), + // b8ke focused episode-2 round-2 F2: the ROLLBACK's claim + // owns this respawn's coordinator transition — the + // adoption's Blocked arm correlates through the op id. + owning_operation: own_ticket.as_ref().map(|t| t.operation_id().to_string()), + owning_generation: None, + broadcast_id: Arc::clone(&broadcast_id), + pending, + in_turn: in_turn.clone(), + turn_tracker: turn_tracker.clone(), + last_synthesized_complete_at, + turn_lock: turn_lock.clone(), + rollback_probe_slot: Arc::new(std::sync::Mutex::new(None)), + last_status, }, - stdin, - child, - tree_guard, - ownership_id, - consumer, - sidecar_session_id, - cli_session_id: Some(cli_id.clone()), - // b8ke focused episode-2 round-2 F2: the ROLLBACK's claim - // owns this respawn's coordinator transition — the - // adoption's Blocked arm correlates through the op id. - owning_operation: own_ticket.as_ref().map(|t| t.operation_id().to_string()), - owning_generation: None, - broadcast_id: Arc::clone(&broadcast_id), - 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)), - last_status, - }, - ); + ); + } // Adoption rides the EXISTING sdk.session.init consumer (preseeded): // cli_index insert + AWAITED binding WITH supersedes + rollback-row @@ -7205,7 +7292,7 @@ impl FreshClaudeState { } /// The not-tracked resume (codex `ensure_session_resumable` analog, file-store - /// flavored): transcript-present gate -> spawn sidecar with `resumeSessionId` -> + /// flavored): transcript-present gate -> spawn sidecar with a resume boot -> /// register under the CLIENT's id -> idle snapshot. `lease_guard` (Task 13): the /// attach-path D8 lease -- the kill handle is armed after the spawn and the lease /// completed (bound to the map key) at registration; error paths tear down their @@ -7297,7 +7384,7 @@ impl FreshClaudeState { // keeping the attach cwd for the process itself. The path form is // reserved for a transcript WITH records: a zero-turn transcript // (touched at create, no records) has no cwd to resolve and NOTHING - // to path-resume — and the sidecar protocol's `resumeSessionId` is a + // to path-resume — and the create line's resume boot names a // SESSION ID (the real sidecar forwards it verbatim to the SDK's // `resume` option), so a path there would make the recovered session // write under a path-flattened phantom file. An empty transcript @@ -7308,18 +7395,21 @@ impl FreshClaudeState { .map(|meta| meta.len() > 0) .unwrap_or(false); let (resume_value, resume_cwd) = match original_cwd { - Some(cwd) => (json!(durable), json!(cwd)), + Some(cwd) => (durable.to_string(), json!(cwd)), None if transcript_has_records => ( - json!(transcript.to_string_lossy()), + transcript.to_string_lossy().to_string(), json!(msg.cwd.clone().or_else(|| rec.cwd.clone())), ), // Attach cwd stays primary; the ledger record's cwd is a FINAL // fallback only when both existing sources are absent (Task 10). None => ( - json!(durable), + durable.to_string(), json!(msg.cwd.clone().or_else(|| rec.cwd.clone())), ), }; + let boot = ClaudeBoot::Resume { + resume: resume_value, + }; // Round 4 (focused-ep5-r3 Finding 1): the claim's dead-state // SNAPSHOT — taken at claim start, before the sidecar spawn and the @@ -7359,31 +7449,47 @@ impl FreshClaudeState { sidecar_pid, ); let request_id = format!("attach-resume-{}", uuid::Uuid::new_v4()); - let create_req = json!({ - "type": "create", - "requestId": request_id, - "cwd": resume_cwd, - // Recovered from the ledger record (Task 10); `json!` serializes `None` - // as `null`, preserving today's fallback wire shape exactly on a miss. - "model": rec.model, - "permissionMode": rec.permission_mode, - "effort": rec.effort, - "resumeSessionId": resume_value, - }); + let session_type = session_type_str(msg.session_type); + let create_req = create_line( + &request_id, + session_type, + &boot, + json!({ + "cwd": resume_cwd, + // Recovered from the ledger record (Task 10); `json!` serializes + // `None` as `null`, preserving today's fallback wire shape exactly + // on a miss. + "model": rec.model, + "permissionMode": rec.permission_mode, + "effort": rec.effort, + }), + ); if let Err(err) = write_line(&mut stdin, &create_req).await { let _ = child.start_kill(); reap_owned_claude_sidecars(&ownership_id); return Err(ResumeClaudeError::Transient(err)); } let mut reader = BufReader::new(stdout).lines(); - let sidecar_session_id = match read_created(&mut reader, SIDECAR_CREATE_BUDGET).await { - Ok(id) => id, - Err(err) => { - let _ = child.start_kill(); - reap_owned_claude_sidecars(&ownership_id); - return Err(ResumeClaudeError::Transient(err)); - } + let boot_context = BootContext { + request_id: &request_id, + session_type, + boot: &boot, }; + // A refusal stays transient here: the transcript was found just above, so a + // CLI that cannot resume it is not proof the conversation is gone. + let sidecar_session_id = + match read_created(&mut reader, SIDECAR_CREATE_BUDGET, &boot_context).await { + Ok(created) => created.session_id, + Err(failure) => { + let _ = child.start_kill(); + reap_owned_claude_sidecars(&ownership_id); + return Err(ResumeClaudeError::Transient(format!( + "{}: {}", + failure.code(), + failure.message() + ))); + } + }; // Retire-on-kill round 2/3 (focused-ep5-r1 Finding 2, -r2 Findings // 4+5): this attach is a NEW pane GENUINELY CLAIMING `durable` — the @@ -7442,58 +7548,66 @@ 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(())); // Freshly resumed session: the tracked status starts "idle" (truthful — no // turn can be in flight before the client sends). let last_status = Arc::new(std::sync::Mutex::new("idle".to_string())); - let consumer = self.spawn_consumer( - reader, - msg.session_id.clone(), - session_type.clone(), - sidecar_session_id.clone(), - recovered.clone(), - Arc::clone(&broadcast_id), - Arc::clone(&pending), - 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 - // merge keeps the row's existing stamps. - None, - ); - self.sessions.lock().await.insert( - msg.session_id.clone(), - ClaudeSession { - configuration: ClaudeConfiguration { - settings: recovered.unwrap_or_default(), - ..ClaudeConfiguration::default() + // Spawned and registered under the sessions map lock (review PRB-C10): no + // line the consumer reads can find the resumed session missing. + { + let mut sessions = self.sessions.lock().await; + let consumer = self.spawn_consumer( + reader, + msg.session_id.clone(), + session_type.clone(), + sidecar_session_id.clone(), + recovered.clone(), + Arc::clone(&broadcast_id), + Arc::clone(&pending), + Arc::clone(&last_status), + Arc::clone(&in_turn), + Arc::clone(&turn_tracker), + Arc::clone(&last_synthesized_complete_at), + None, + // Conn-less lane (D8): attach-resume is not a create; the ledger + // merge keeps the row's existing stamps. + None, + ); + self.test_holds.registration().await; + sessions.insert( + msg.session_id.clone(), + ClaudeSession { + configuration: ClaudeConfiguration { + settings: recovered.unwrap_or_default(), + ..ClaudeConfiguration::default() + }, + stdin, + child, + tree_guard, + ownership_id, + consumer, + sidecar_session_id, + cli_session_id: Some(durable.to_string()), + owning_operation, + owning_generation: handoff_generation, + broadcast_id, + pending, + in_turn, + turn_tracker, + last_synthesized_complete_at, + turn_lock, + rollback_probe_slot: Arc::new(std::sync::Mutex::new(None)), + last_status, }, - stdin, - child, - tree_guard, - ownership_id, - consumer, - sidecar_session_id, - cli_session_id: Some(durable.to_string()), - owning_operation, - owning_generation: handoff_generation, - broadcast_id, - 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)), - last_status, - }, - ); + ); + // C09: the session is live again, so it is no longer an ended one. + // Forgotten before the lock is released, so a death of this new + // incarnation is recorded after it, never forgotten by it. + self.dead_sessions + .forget(&[msg.session_id.as_str(), durable], "resumed"); + } self.cli_index .lock() .await @@ -7617,13 +7731,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, @@ -8514,11 +8637,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 @@ -8528,14 +8651,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>>, @@ -8598,6 +8716,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() { @@ -8606,6 +8727,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 @@ -8623,10 +8748,12 @@ 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 no `sdk.status:idle` at all since C09 (its stream's end + // is `sdk.stream.ended` and its exit, G23); only the legacy + // protocol fakes still send one, until C17/C18 delete this arm. + // An idle is never a result's trailing punctuation, so it + // 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 @@ -8683,23 +8810,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") => @@ -8813,21 +8928,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 @@ -8837,7 +8949,6 @@ impl FreshClaudeState { in_turn.store(false, std::sync::atomic::Ordering::SeqCst); } } - result_idle_pair_pending.store(false, std::sync::atomic::Ordering::SeqCst); } _ => {} } @@ -8852,7 +8963,7 @@ impl FreshClaudeState { // (:445) — so a mid-turn "compacting" can never wedge the tracker // for the rest of the session's life — and the raw sdk.status wire // value folds on top (:351-352 compacting announces truthfully - // mid-turn; the stream-end idle is a no-op after the last result). + // mid-turn; only the legacy protocol fakes still send an idle). match value.get("type").and_then(Value::as_str) { Some("sdk.assistant") => { *last_status.lock().expect("last status lock") = "running".to_string(); @@ -8957,23 +9068,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(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 @@ -9000,46 +9111,139 @@ 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. It is recorded + // before the stop on purpose: a resume (an attach, which is a + // recovery verb, or a create that resumes) can land while the + // stop runs and forgets the record, which is how the report + // below knows to stay away from a session that is live again. + // So for as long as the stop runs, a snapshot already reads + // `exited` and a send already gets the lost-session frame. + // That is short: node has usually exited by now (it ended its + // stream, or it died), so the stop is the tree guard's kill and + // the confirmation; only a node that closed its output and kept + // running is given the whole grace period before it is killed. + let reason = stream_ended + .as_ref() + .map_or(crate::claude_sidecar_exit::PROCESS_ENDED_REASON, |ended| { + ended.reason.as_str() + }); + state + .dead_sessions + .record(crate::claude_dead_sessions::Death { + map_key: &session_id, + stamp: &stamp, + session_type: &session_type, + durables: &removed_durables, + cli_session_id: evicted.cli_session_id.as_deref(), + settings: &evicted.configuration.settings, + commands: evicted.configuration.commands.as_ref(), + reason, + }); + state.test_holds.exit_report().await; + // Stop the ended sidecar through the one sidecar stop every + // teardown uses (G31): node is reaped for its exit status (the + // cause of a death that reported no stream end), whatever is + // left of its tree (the CLI) is killed and, within the stop's + // bounded confirmation window, confirmed gone before the exit + // report offers Resume, and no zombie is left behind. It is not + // this consumer's to abort: the consumer is this task. + let processes = SidecarProcesses { + child: evicted.child, + stdin: evicted.stdin, + tree_guard: evicted.tree_guard, + ownership_id: evicted.ownership_id, + }; + let tree = processes.capture_tree(); + let (exit, confirmation) = processes + .stop( + tree, + &stamp, + state.tree_confirm_rounds(), + crate::claude_spawn::SIDECAR_GRACEFUL_EXIT, + ) + .await; + let confirmed = confirmation.label(); + // A tree the window could not confirm gone keeps being killed in + // the background (logged there), and the report does not wait + // for it. On Linux such a member has outlived SIGKILL: it runs + // no more code and is only slow to finish dying (or stuck in + // the kernel), so a resume cannot race it, while holding the + // report back would leave the pane busy for as long as the + // kernel keeps it. Off Linux an unconfirmed tree is the guard's + // typed platform limitation, logged by the stop. + let _ = escalate_unconfirmed_teardown(confirmation, stamp.clone()); + // A node that outlived the grace period was killed by the stop: + // the signal it died of is ours, not the cause. + let status = exit + .status + .filter(|_| exit.stop != crate::claude_spawn::SidecarStop::Forced); + 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. - 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 { + // 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 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(exit.status), + stop = exit.stop.label(), + confirmation = confirmed, + 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 — and why + // (SIDECAR_EXITED, which also drops the pane to idle). The report + // belongs to the dead incarnation: a resume that registered the + // session again during the stop (an attach under the same pane + // id) or a close forgot the dead record, and then neither frame + // may reach the id, which names a live session or nothing now. + if state.dead_sessions.find(&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(), + session_id: stamp.clone(), + session_type: session_type.clone(), + })); + } else { + tracing::info!(target: "freshell_freshagent::claude", + provider = PROVIDER, + session_id = %stamp, + session_type = %session_type, + reason = cause.reason(), + "freshagent.claude.exit_report_skipped: the session was resumed or \ + closed while its dead sidecar was reaped"); + } + // Task 5 (unified needs-attention signal): an UNREQUESTED end of a + // running turn is a turn end the user did not witness, even when a + // resume brought the session back during the stop. 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() @@ -9079,9 +9283,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 real sidecar sends no idle +/// (G23; only the legacy protocol fakes do, until C17/C18) — 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 @@ -9281,8 +9485,9 @@ 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 only the legacy protocol fakes still send: the real sidecar sends none +/// since C09). Attribution /// retires the OLDEST outstanding op: /// /// (running) the edge belongs to the op in the `running` slot — the active @@ -9740,6 +9945,10 @@ fn lost_session_frame(session_id: &str, session_type: SessionType) -> ServerMess /// twice). const RESUME_DROPS_TURN_REFUSAL_PREFIX: &str = "Resume rejected by --resume-drops-turn:"; +/// The `create.failed` code of the same refusal when the CLI reports it at boot +/// (`crates/freshell-claude-sidecar/boot.mjs`). +const FORK_GUARD_REFUSED_CODE: &str = "FORK_GUARD_REFUSED"; + /// Why one rollback-respawn attempt failed. enum RollbackSpawnError { /// The create's early output carried the `--resume-drops-turn` refusal @@ -9793,6 +10002,7 @@ impl FreshClaudeState { async fn rollback_spawn_create( &self, create_req: &Value, + boot: &ClaudeBoot, session_type: &str, lease_guard: Option<&mut crate::FreshSessionLeaseGuard>, ) -> Result { @@ -9818,14 +10028,30 @@ impl FreshClaudeState { return Err(RollbackSpawnError::Other(err)); } let mut reader = BufReader::new(stdout).lines(); - let sidecar_session_id = match read_created(&mut reader, SIDECAR_CREATE_BUDGET).await { - Ok(id) => id, - Err(err) => { - let _ = child.start_kill(); - reap_owned_claude_sidecars(&ownership_id); - return Err(RollbackSpawnError::Other(err)); - } + let request_id = create_req + .get("requestId") + .and_then(Value::as_str) + .unwrap_or_default(); + let boot_context = BootContext { + request_id, + session_type, + boot, }; + let sidecar_session_id = + match read_created(&mut reader, SIDECAR_CREATE_BUDGET, &boot_context).await { + Ok(created) => created.session_id, + Err(failure) => { + let _ = child.start_kill(); + reap_owned_claude_sidecars(&ownership_id); + // The CLI refuses a fork whose drops-turn guard does not hold at + // boot (T5): the same refusal the init pre-read below watches for. + return Err(if failure.is_refusal(FORK_GUARD_REFUSED_CODE) { + RollbackSpawnError::GuardRefusal + } else { + RollbackSpawnError::Other(failure.message().to_string()) + }); + } + }; match read_session_init(&mut reader, SIDECAR_CREATE_BUDGET).await { Ok(init) => { let cli_id = init @@ -10027,67 +10253,15 @@ mod provider_exec_tests { } for case in ["large", "small"] { let output=std::process::Command::new(std::env::current_exe().unwrap()).env_clear() - .env("PATH","/usr/local/bin:/usr/bin:/bin").env(CASE,case) - .args(["--exact","claude::provider_exec_tests::provider_exec_profile_checks_actual_managed_claude_sidecar_spawn","--nocapture"]) - .output().unwrap(); - assert!( - output.status.success(), - "actual sidecar fixture case {case} failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - } - } -} - -/// Read the sidecar's stdout until the `created` (→ the nanoid placeholder) or -/// `create.failed` control line, bounded by `budget`. EOF before either is a failure. -async fn read_created( - reader: &mut tokio::io::Lines>, - budget: Duration, -) -> Result { - let read = async { - loop { - match reader.next_line().await { - Ok(Some(line)) => { - let trimmed = line.trim(); - if trimmed.is_empty() { - continue; - } - let Ok(value) = serde_json::from_str::(trimmed) else { - continue; - }; - match value.get("type").and_then(Value::as_str) { - Some("created") => { - let session_id = value - .get("sessionId") - .and_then(Value::as_str) - .ok_or("created carried no sessionId")?; - return Ok(session_id.to_string()); - } - Some("create.failed") => { - let message = value - .get("message") - .and_then(Value::as_str) - .unwrap_or("unknown sidecar create failure"); - return Err(message.to_string()); - } - // sdk.* events before `created` are impossible (the sidecar emits - // `created` first), but tolerate + skip any stray line. - _ => continue, - } - } - // EOF before `created` → the sidecar died at startup (e.g. bad node/SDK). - Ok(None) => return Err("sidecar stdout closed before `created`".to_string()), - Err(e) => return Err(format!("sidecar stdout read error: {e}")), - } + .env("PATH","/usr/local/bin:/usr/bin:/bin").env(CASE,case) + .args(["--exact","claude::provider_exec_tests::provider_exec_profile_checks_actual_managed_claude_sidecar_spawn","--nocapture"]) + .output().unwrap(); + assert!( + output.status.success(), + "actual sidecar fixture case {case} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); } - }; - match tokio::time::timeout(budget, read).await { - Ok(result) => result, - Err(_) => Err(format!( - "sidecar did not answer `create` within {}s", - budget.as_secs() - )), } } @@ -10253,32 +10427,120 @@ enum LoggedTeardown { }, } +/// A sidecar stop's typed outcome (b8ke focused round-2 review R2-2): an +/// unconfirmed tree is never a silent success — the structured error line +/// lands AND the detached escalation keeps killing the tree, its handle in +/// the answer. +fn escalate_unconfirmed_teardown( + confirmation: TeardownConfirmation, + session_label: String, +) -> LoggedTeardown { + match confirmation { + TeardownConfirmation::Confirmed => LoggedTeardown::Confirmed, + TeardownConfirmation::PlatformLimited => LoggedTeardown::PlatformLimited, + TeardownConfirmation::NotConfirmed { tree, ownership_id } => { + tracing::error!(target: "freshell_freshagent::claude", + session_id = %session_label, + lingering = tree.len(), + "freshagent.claude.teardown_unconfirmed: the bounded confirmation \ + window expired with tagged descendants still alive — the teardown \ + did NOT confirm the runtime tree's death; the escalation continues \ + detached and the caller's coordinator fence (if any) stays held" + ); + LoggedTeardown::NotConfirmed { + escalation: spawn_claude_tree_death_escalation(tree, ownership_id, session_label), + } + } + } +} + /// The detached unconfirmed-tree escalation (b8ke focused round-2 review -/// R2-2): SIGTERM→SIGKILL rounds until the captured tree is confirmed -/// dead-by-starttime. Resolves `true` only on confirmed death — the -/// deferred stop commit releases on exactly that. +/// R2-2): SIGTERM→SIGKILL windows until the captured tree is confirmed +/// dead-by-starttime ([`escalate_until_dead`]). Resolves `true` only on +/// confirmed death — the deferred stop commit releases on exactly that. fn spawn_claude_tree_death_escalation( tree: Vec<(i32, u64)>, ownership_id: String, session_label: String, ) -> tokio::task::JoinHandle { tokio::spawn(async move { - let mut tree = tree; - while !confirm_captured_claude_tree_dead( - &mut tree, - &ownership_id, + let mut captured = CapturedClaudeTree { tree, ownership_id }; + escalate_until_dead(&mut captured, &session_label).await + }) +} + +/// One bounded kill-and-confirm window of the detached escalation. +trait TreeDeathWindow { + /// Signal the tree and poll it for one window; `true` once every member + /// is confirmed dead. + async fn confirm_dead(&mut self) -> bool; + /// How many members were still alive after the last window. + fn lingering(&self) -> usize; +} + +/// The captured ownership-tagged tree a stop could not confirm gone. +struct CapturedClaudeTree { + tree: Vec<(i32, u64)>, + ownership_id: String, +} + +impl TreeDeathWindow for CapturedClaudeTree { + async fn confirm_dead(&mut self) -> bool { + confirm_captured_claude_tree_dead( + &mut self.tree, + &self.ownership_id, TREE_DEATH_CONFIRM_ROUNDS, ) .await - { - tracing::warn!(target: "freshell_freshagent::claude", - session_id = %session_label, lingering = tree.len(), - "freshagent.claude.teardown_escalation_continues: the detached tree-death \ - escalation re-armed its bounded window" - ); - } - true - }) + } + + fn lingering(&self) -> usize { + self.tree.len() + } +} + +/// The detached escalation's pause before the next window, after +/// `failed_windows` windows that ended with the tree alive: half a second, +/// doubling, at most a minute. A tree that outlives a whole window +/// (SIGKILL included) is stuck in the kernel; retrying it at once would only +/// rescan `/proc` every 25 ms and warn every 0.6 s for as long as it stays +/// stuck. +fn escalation_pause(failed_windows: u32) -> Duration { + const FIRST: Duration = Duration::from_millis(500); + const LONGEST: Duration = Duration::from_secs(60); + let doublings = failed_windows.saturating_sub(1).min(16); + FIRST.saturating_mul(1 << doublings).min(LONGEST) +} + +/// Run `window` until it confirms the tree dead, pausing between windows +/// ([`escalation_pause`]). It never gives up: an explicit kill's deferred +/// stop commit waits on the confirmation. Each unconfirmed window logs +/// `freshagent.claude.teardown_escalation_continues` (warn), the +/// confirmation `freshagent.claude.teardown_escalation_confirmed` (info). +async fn escalate_until_dead(window: &mut impl TreeDeathWindow, session_label: &str) -> bool { + let started = std::time::Instant::now(); + let mut failed_windows: u32 = 0; + while !window.confirm_dead().await { + failed_windows = failed_windows.saturating_add(1); + let pause = escalation_pause(failed_windows); + tracing::warn!(target: "freshell_freshagent::claude", + session_id = %session_label, + lingering = window.lingering(), + windows = failed_windows, + next_window_in_ms = u64::try_from(pause.as_millis()).unwrap_or(u64::MAX), + "freshagent.claude.teardown_escalation_continues: the detached tree-death \ + escalation's window ended with the tree still alive; it tries again after a pause" + ); + tokio::time::sleep(pause).await; + } + tracing::info!(target: "freshell_freshagent::claude", + session_id = %session_label, + windows = failed_windows.saturating_add(1), + elapsed_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX), + "freshagent.claude.teardown_escalation_confirmed: the detached escalation confirmed \ + the runtime tree's death" + ); + true } /// The bounded SIGTERM→SIGKILL confirmation round budget (24 × 25ms with @@ -10387,59 +10649,100 @@ async fn teardown_removed_session_within( grace: Duration, ) -> TeardownConfirmation { let started = std::time::Instant::now(); - let mut tree = capture_owned_claude_tree(&session.ownership_id, session.child.id()); + let processes = SidecarProcesses { + child: session.child, + stdin: session.stdin, + tree_guard: session.tree_guard, + ownership_id: session.ownership_id, + }; + let tree = processes.capture_tree(); session.consumer.abort(); let _ = session.consumer.await; - // G31: ask first — the shutdown request lets the sidecar abort its SDK - // query (which stops the claude CLI mid-turn) and exit; SIGTERM/SIGKILL - // only after the grace period. Node's reaped exit is the portable - // confirmation floor, awaited on EVERY platform. - let exit = crate::claude_spawn::graceful_then_kill( - session.child, - session.stdin, - session.tree_guard, - grace, - ) - .await; - reap_owned_claude_sidecars(&session.ownership_id); - // The captured ownership-tagged tree's confirmed death (bounded poll, - // SIGKILL escalation — a TERM-immune grandchild cannot outlive this). - #[cfg(target_os = "linux")] - let confirmation = { - if confirm_captured_claude_tree_dead(&mut tree, &session.ownership_id, confirm_rounds).await - { - TeardownConfirmation::Confirmed - } else { - TeardownConfirmation::NotConfirmed { - tree, - ownership_id: session.ownership_id, - } - } - }; - // Off Linux the process-tree guard is the confirmation (macOS: the - // sidecar's process group is empty). Without a guard the typed - // platform limitation stands: node's awaited exit is the only floor. - #[cfg(not(target_os = "linux"))] - let confirmation = { - let _ = (&mut tree, confirm_rounds); - if exit.tree == crate::claude_spawn::TreeConfirmation::Confirmed { - TeardownConfirmation::Confirmed - } else { - tracing::warn!(target: "freshell_freshagent::claude", - session_id = %session.sidecar_session_id, - platform_limitation = "descendant_tree_unverifiable", - tree = ?exit.tree, - "freshagent.claude.teardown_platform_limited: the process-tree guard could \ - not confirm the sidecar's descendants gone; node's awaited exit is the only \ - confirmation" - ); - TeardownConfirmation::PlatformLimited - } - }; + let (exit, confirmation) = processes + .stop(tree, &session.sidecar_session_id, confirm_rounds, grace) + .await; log_claude_teardown(&session.sidecar_session_id, &exit, started, &confirmation); confirmation } +/// A claude sidecar's processes, taken out of its session record to be +/// stopped: node with its stdin, the guard that owns node's whole process +/// tree (G31), and the ownership tag the `/proc` reaper finds that tree by. +struct SidecarProcesses { + child: Child, + stdin: ChildStdin, + tree_guard: crate::claude_spawn::SidecarTreeGuard, + ownership_id: String, +} + +impl SidecarProcesses { + /// The ownership-tagged tree as `(pid, starttime)` pairs. Capture it + /// before anything is stopped: YAMA hides a reparented grandchild's + /// environ once node is gone ([`capture_owned_claude_tree`]). + fn capture_tree(&self) -> Vec<(i32, u64)> { + capture_owned_claude_tree(&self.ownership_id, self.child.id()) + } + + /// The ONE way a claude sidecar's process tree is stopped and reaped, for + /// a requested teardown and for a sidecar that ended on its own (C08) + /// alike. G31: ask first — the shutdown request lets the sidecar abort + /// its SDK query (which stops the claude CLI mid-turn) and exit; + /// SIGTERM/SIGKILL only after `grace`. A sidecar that already ended no + /// longer reads the request, so its stop is just the wait for node's exit + /// status and the guard settling the rest of the tree. Node's reaped exit + /// is the portable confirmation floor, awaited on EVERY platform; then + /// the `/proc` ownership reap and the captured `tree`'s confirmed death + /// (bounded poll, SIGKILL escalation — a TERM-immune grandchild cannot + /// outlive this). + async fn stop( + self, + mut tree: Vec<(i32, u64)>, + session_label: &str, + confirm_rounds: u8, + grace: Duration, + ) -> (crate::claude_spawn::SidecarExit, TeardownConfirmation) { + let exit = + crate::claude_spawn::graceful_then_kill(self.child, self.stdin, self.tree_guard, grace) + .await; + reap_owned_claude_sidecars(&self.ownership_id); + #[cfg(target_os = "linux")] + let confirmation = { + let _ = session_label; + if confirm_captured_claude_tree_dead(&mut tree, &self.ownership_id, confirm_rounds) + .await + { + TeardownConfirmation::Confirmed + } else { + TeardownConfirmation::NotConfirmed { + tree, + ownership_id: self.ownership_id, + } + } + }; + // Off Linux the process-tree guard is the confirmation (macOS: the + // sidecar's process group is empty). Without a guard the typed + // platform limitation stands: node's awaited exit is the only floor. + #[cfg(not(target_os = "linux"))] + let confirmation = { + let _ = (&mut tree, confirm_rounds); + if exit.tree == crate::claude_spawn::TreeConfirmation::Confirmed { + TeardownConfirmation::Confirmed + } else { + tracing::warn!(target: "freshell_freshagent::claude", + session_id = %session_label, + platform_limitation = "descendant_tree_unverifiable", + tree = ?exit.tree, + "freshagent.claude.teardown_platform_limited: the process-tree guard could \ + not confirm the sidecar's descendants gone; node's awaited exit is the only \ + confirmation" + ); + TeardownConfirmation::PlatformLimited + } + }; + (exit, confirmation) + } +} + /// The one structured record of every sidecar stop (G31): `info` when the /// sidecar honored the shutdown request, `warn` when it crashed on its way /// down or had to be forced. @@ -10478,6 +10781,9 @@ fn log_claude_teardown( #[cfg(test)] mod teardown_tests; +#[cfg(test)] +mod end_of_output_tests; + /// ISO-8601 / RFC-3339 millis-Z timestamp (`new Date().toISOString()`) for error frames. fn now_iso() -> String { let now = SystemTime::now() @@ -10583,7 +10889,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)), @@ -10959,11 +11264,11 @@ pub(crate) mod tests { st.cli_index.lock().await.get(durable), Some(&"client-nanoid-1".to_string()) ); - // The fake received resumeSessionId (spawn log now records the create request). + // The fake received the resume boot (spawn log now records the create request). let log = std::fs::read_to_string(env.spawn_log_path()).unwrap(); assert!( log.contains(durable), - "sidecar create must carry resumeSessionId" + "sidecar create must carry the resume boot" ); // Idle snapshot frame, tagged with the client's id + the durable timeline id. let frame = await_frame_of_inner_type(&mut rx, "freshAgent.session.snapshot").await; @@ -11921,7 +12226,7 @@ rl.on('line', (line) => { } if (msg.type === 'create') { // Log the WHOLE create request (one line per create) so tests can both count - // spawns AND assert what the sidecar received (e.g. resumeSessionId). + // spawns AND assert what the sidecar received (e.g. the resume boot). if (spawnLog) { fs.appendFileSync(spawnLog, `${JSON.stringify(msg)}\n`) } @@ -11931,14 +12236,14 @@ rl.on('line', (line) => { const sessionId = `fake-claude-session-${process.pid}-${counter}` liveSessions.add(sessionId) configuredSettings.set(sessionId, { model: msg.model, effort: msg.effort, permissionMode: msg.permissionMode, cwd: msg.cwd }) - process.stdout.write(JSON.stringify({ type: 'created', sessionId }) + '\n') - // Mirror the real sidecar's post-create init: echo resumeSessionId as the durable - // id when present (resume continuity), else a fixed fake uuid. - // Task 4 (kata 1wxv): a forkSession:true create mints a FRESH durable id, + process.stdout.write(JSON.stringify({ type: 'created', sessionId, protocol: 2 }) + '\n') + // Mirror the real sidecar's post-create init: echo the resume boot's id as the + // durable id when present (resume continuity), else a fixed fake uuid. + // Task 4 (kata 1wxv): a fork boot mints a FRESH durable id, // never an echo — fork-at-point adoption depends on the id changing. - const cliSessionId = msg.forkSession === true + const cliSessionId = msg.boot?.kind === 'fork' ? `fork-${process.pid}-${counter}-0000-4000-8000-000000000000` - : (msg.resumeSessionId || 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa') + : (msg.boot?.resume || 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa') console.log(JSON.stringify({ type: 'sdk.session.init', sessionId, cliSessionId, model: 'fake-model', cwd: '/tmp', tools: [] })) console.log(JSON.stringify({ type: 'sdk.status', sessionId, status: 'idle' })) } @@ -12029,22 +12334,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 none). 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' @@ -12056,7 +12362,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) @@ -12153,7 +12458,7 @@ rl.on('line', (line) => { } /// Path of the spawn log (one full JSON create-request line per spawn) so tests - /// can assert what the sidecar received (e.g. `resumeSessionId`). + /// can assert what the sidecar received (e.g. the resume boot). fn spawn_log_path(&self) -> &std::path::Path { &self.spawn_log } @@ -13305,8 +13610,8 @@ rl.on('line', (line) => { /// ONLY identity is a provider-matched `sessionRef` must resume exactly /// like the legacy `resumeSessionId` carrier — the canonical field cannot /// be a second-class citizen while the client migrates off the legacy - /// duplicate. The fake sidecar echoes the create request's - /// `resumeSessionId` back as the durable id, so the created frame's + /// duplicate. The fake sidecar echoes the create request's resume + /// boot back as the durable id, so the created frame's /// sessionId proves the promotion reached the sidecar. #[tokio::test] async fn handle_create_with_session_ref_only_resumes_like_legacy() { @@ -13330,7 +13635,7 @@ rl.on('line', (line) => { let log = std::fs::read_to_string(env.spawn_log_path()).unwrap(); assert!( log.contains(durable), - "sidecar create must carry the sessionRef-derived resumeSessionId: {log}" + "sidecar create must carry the sessionRef-derived resume boot: {log}" ); drop(env); } @@ -15231,7 +15536,7 @@ rl.on('line', (line) => { // ── b8ke focused episode-2 round-1: the adoption's atomicity tests ───── - /// The fake sidecar's durable id for a create with NO resumeSessionId. + /// The fake sidecar's durable id for a create with NO resume boot. const FRESH_CREATE_DURABLE_ID: &str = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; /// A minimal thread-local tracing capture (the session_handoff @@ -17347,7 +17652,7 @@ rl.on('line', (line) => { let created = await_claude_created(&mut rx, "req-alias-exit").await; let placeholder = created["sessionId"].as_str().unwrap().to_string(); // The fake sidecar's default durable cliSessionId (its script emits it - // on sdk.session.init when no resumeSessionId was supplied). + // on sdk.session.init when no resume boot was supplied). let durable = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; // The REAL consumer fold adopts the alias (poll, bounded) — the kill // must race NOTHING here: the alias exists before the exit. @@ -17494,7 +17799,7 @@ rl.on('line', (line) => { /// Focused-ep5-r1 Finding 2, the tombstone lifecycle's exit: an EXPLICIT /// late resume of a killed session still works — the claim clears the /// tombstone, and the resumed session's own adoption write (the fake - /// sidecar echoes resumeSessionId as the durable cliSessionId) lands + /// sidecar echoes the resume boot's id as the durable cliSessionId) lands /// instead of being suppressed as a stale orphan. #[tokio::test] async fn resume_create_after_a_kill_clears_the_tombstone_and_rebinds() { @@ -17705,7 +18010,7 @@ rl.on('line', (line) => { ); // Epoch 2: the genuine claim — resume the SAME durable id (the fake - // sidecar echoes resumeSessionId as the cliSessionId). + // sidecar echoes the resume boot's id as the cliSessionId). let mut resume = dedup_create_msg("req-reopen-2"); resume.resume_session_id = Some(durable.to_string()); resume.cwd = Some("/tmp".to_string()); @@ -19645,7 +19950,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)), @@ -20089,7 +20393,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) @@ -20321,7 +20624,7 @@ rl.on('line', (line) => { json!(["/workspace/.claude/plugins/local"]) ); assert_eq!(creates[1]["plugins"], Value::Null); - assert_eq!(creates[1]["resumeSessionId"], Value::Null); + assert_eq!(creates[1]["boot"], json!({ "kind": "fresh" })); } #[tokio::test(flavor = "multi_thread")] @@ -20735,7 +21038,10 @@ rl.on('line', (line) => { let log = std::fs::read_to_string(env.spawn_log_path()).unwrap(); let create_req: serde_json::Value = serde_json::from_str(log.lines().next().unwrap()).unwrap(); - assert_eq!(create_req["resumeSessionId"], DURABLE); + assert_eq!( + create_req["boot"], + json!({ "kind": "resume", "resume": DURABLE }) + ); assert_eq!(create_req["model"], "opus-x"); assert_eq!(create_req["permissionMode"], "plan"); let frame = await_frame_of_inner_type(&mut rx, "freshAgent.session.snapshot").await; @@ -20751,7 +21057,7 @@ rl.on('line', (line) => { /// create TOUCHES its transcript (the file exists with zero records). /// After a restart, attach's resume lane finds that empty transcript — /// its cwd lookup answers nothing — and the path-based resume fallback - /// must NOT fire: the sidecar protocol's `resumeSessionId` is a SESSION + /// must NOT fire: the create line's resume boot names a SESSION /// ID (the real sidecar forwards it verbatim to the SDK's `resume` /// option), and a transcript PATH there makes the recovered session /// write its conversation under a path-flattened phantom file (observed @@ -20788,9 +21094,9 @@ rl.on('line', (line) => { serde_json::from_str(log.lines().next().expect("one spawn-logged create request")) .unwrap(); assert_eq!( - create_req["resumeSessionId"] + create_req["boot"]["resume"] .as_str() - .expect("resumeSessionId on the wire"), + .expect("the resume on the wire"), DURABLE, "an empty zero-turn transcript must resume by session id, never by transcript path" ); @@ -21106,6 +21412,186 @@ 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" + ); + } + + /// Review PRB-C09: an attach that resumes the session under its own pane id + /// while the dead sidecar is still being reaped (the reload and reconnect + /// path) makes that id live again, so the dead incarnation's exit report — + /// the `exited` status and the SIDECAR_EXITED banner, which also resets the + /// pane to idle — must not reach it. The turn the death cut short still + /// rings, once: it ended, and nobody saw it end. + #[tokio::test(flavor = "multi_thread")] + async fn a_resume_during_the_reap_gets_no_exit_report_but_the_lost_turn_rings() { + 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(); + st.handle_create(dedup_create_msg("req-reap-resume"), None) + .await; + let created = await_claude_created_and_session_init(&mut rx, "req-reap-resume").await; + let pane_id = created["sessionId"].as_str().unwrap().to_string(); + // The fake never answers a plain send: the turn stays in flight. + st.handle_send(send_msg(&pane_id, "a turn the sidecar dies in")) + .await; + assert!(in_turn_of(&st, &pane_id).await); + + let hold = st.hold_exit_reports(); + st.sessions + .lock() + .await + .get_mut(&pane_id) + .expect("live session in map") + .child + .start_kill() + .expect("kill the sidecar"); + tokio::time::timeout(Duration::from_secs(15), hold.parked()) + .await + .expect("the end-of-output path reached the exit report"); + + 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_id, DURABLE)) + .await; + std::env::remove_var("CLAUDE_CONFIG_DIR"); + assert!(st.has_live_session(DURABLE).await, "the attach resumed it"); + while rx.try_recv().is_ok() {} + + hold.release(); + let mut frames = Vec::new(); + let deadline = tokio::time::Instant::now() + Duration::from_secs(2); + while let Ok(Ok(raw)) = tokio::time::timeout_at(deadline, rx.recv()).await { + frames.push(serde_json::from_str::(&raw).unwrap()); + } + let about_the_pane: Vec<&Value> = frames + .iter() + .filter(|frame| frame["sessionId"] == pane_id.as_str()) + .collect(); + assert!( + about_the_pane + .iter() + .all(|frame| frame["event"]["status"] != "exited" + && frame["event"]["code"] != "SIDECAR_EXITED"), + "no exit report reaches the resumed session: {about_the_pane:?}" + ); + assert_eq!( + about_the_pane + .iter() + .filter(|frame| frame["event"]["type"] == "freshAgent.turn.complete") + .count(), + 1, + "the lost turn rings once: {about_the_pane:?}" + ); + assert_ne!(st.live_snapshot_overlay(&pane_id).await["status"], "exited"); + } + + /// 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 _; @@ -21260,7 +21746,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())); @@ -21276,7 +21761,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) @@ -21298,7 +21782,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)), @@ -21350,7 +21833,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)), @@ -21410,7 +21892,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())); @@ -21426,7 +21907,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) @@ -21448,7 +21928,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)), @@ -21566,6 +22045,37 @@ rl.on('line', (line) => { } } + /// The consumer's `sdk.status:idle` arm, which only the legacy protocol fakes + /// still reach (the real sidecar sends no idle since C09; C17/C18 delete the + /// arm). An idle after an earlier turn's result is an edge of its own and + /// releases the busy truth: the deleted result/idle pair latch skipped + /// exactly this idle and left the session busy (`apply_snapshot_metadata` + /// then reported `running`). The real sidecar's G23 behavior is pinned on + /// RealSidecarSim by `a_natural_end_of_stream_mid_turn_leaves_the_session_not_busy` + /// (crates/freshell-ws/tests/freshagent_claude_lifecycle.rs). + #[tokio::test] + async fn a_legacy_fake_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"); + // A legacy fake's idle while the second turn runs. + 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; @@ -21608,15 +22118,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() { @@ -21950,14 +22460,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() { @@ -21974,9 +22484,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"); @@ -21998,11 +22507,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( @@ -22027,9 +22535,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() @@ -22047,9 +22555,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). @@ -22076,18 +22583,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( @@ -22121,10 +22626,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 @@ -22366,7 +22870,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; @@ -22426,7 +22930,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, @@ -22565,108 +23068,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 @@ -22694,10 +23095,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 @@ -22708,10 +23107,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, @@ -22726,14 +23124,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( @@ -22863,9 +23260,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] @@ -22883,9 +23280,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). @@ -22923,7 +23319,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; @@ -22932,7 +23328,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, @@ -22957,7 +23352,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: @@ -22965,9 +23360,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] @@ -22986,21 +23381,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 @@ -23050,17 +23442,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, @@ -23089,7 +23479,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( @@ -23116,7 +23505,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] @@ -23134,10 +23523,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, @@ -23172,11 +23560,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); } @@ -23191,7 +23578,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; @@ -23205,10 +23592,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, @@ -23271,10 +23657,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(); @@ -23351,12 +23736,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, @@ -23376,9 +23760,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( @@ -23398,7 +23781,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 @@ -23436,8 +23819,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( @@ -23491,10 +23874,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( @@ -23523,7 +23905,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 @@ -23542,13 +23924,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!( @@ -23604,7 +23985,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; @@ -23626,7 +24007,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( @@ -23705,11 +24085,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; @@ -23761,12 +24140,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(); @@ -24451,11 +24829,11 @@ rl.on('line', (line) => { 1, "exactly the rollback's fork create: {creates:?}" ); - assert_eq!(creates[0]["resumeSessionId"], json!("s-prime")); - assert_eq!(creates[0]["resumeSessionAt"], json!("a1")); - assert_eq!(creates[0]["forkSession"], json!(true)); + assert_eq!(creates[0]["boot"]["resume"], json!("s-prime")); + assert_eq!(creates[0]["boot"]["resumeSessionAt"], json!("a1")); + assert_eq!(creates[0]["boot"]["kind"], json!("fork")); assert_eq!( - creates[0]["resumeDropsTurn"], + creates[0]["boot"]["dropsTurn"], json!("uq"), "the guard is the first-to-discard turn's prompt uuid (brief/SDK semantics)" ); @@ -24595,14 +24973,14 @@ rl.on('line', (line) => { .collect(); assert_eq!(creates.len(), 2, "{creates:?}"); assert_eq!( - creates[1]["resumeSessionId"], + creates[1]["boot"]["resume"], json!("s-prime"), "redo re-forks the re-rooted chain root" ); - assert_eq!(creates[1]["resumeSessionAt"], json!("aq")); - assert_eq!(creates[1]["forkSession"], json!(true)); + assert_eq!(creates[1]["boot"]["resumeSessionAt"], json!("aq")); + assert_eq!(creates[1]["boot"]["kind"], json!("fork")); assert!( - creates[1].get("resumeDropsTurn").is_none(), + creates[1]["boot"].get("dropsTurn").is_none(), "the guard is omitted when the discard range is empty (redo to the tip) — never fabricated" ); let record2 = sink_impl diff --git a/crates/freshell-freshagent/src/claude/end_of_output_tests.rs b/crates/freshell-freshagent/src/claude/end_of_output_tests.rs new file mode 100644 index 000000000..5f7a4f662 --- /dev/null +++ b/crates/freshell-freshagent/src/claude/end_of_output_tests.rs @@ -0,0 +1,604 @@ +//! One stop for every claude sidecar (C08, C09, G31): a sidecar that ends on +//! its own and a sidecar the server stops both go through +//! `SidecarProcesses::stop` (graceful wait, process-tree guard, `/proc` +//! ownership reap, tree-death confirmation). What differs is the report: +//! +//! * An unrequested end (the consumer's end-of-output path) records the dead +//! session, stops the tree, and only then reports `exited` and +//! `SIDECAR_EXITED` (Resume): the old tree is confirmed gone when the pane +//! learns it may resume. The cause is the stream end the sidecar reported, or +//! node's own exit status; a signal the stop itself sent is not the cause. +//! * A requested stop (here `handle_kill`) reports nothing about the end. +//! * A tree that outlives the stop's bounded confirmation window does not hold +//! the report back: the report goes out, and the kill keeps going in the +//! background until the tree is dead. +//! +//! The sidecar is a small Node fake started through the production create +//! (`handle_create`), so its consumer, eviction and stop are the real ones. It +//! starts two TERM-immune stand-ins for the claude CLI that carry its +//! ownership tag: one inside its process group (the process-tree guard +//! reaches it) and one that left the group with `setsid` (only the `/proc` +//! ownership reap and the confirmation's SIGKILL reach it). + +use super::*; + +use super::teardown_tests::capture; + +/// The durable id the fake's session reports. +const DURABLE: &str = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; + +/// The fake sidecar; `MODE`, `DIR` and `DURABLE` are prepended. +/// * `stream-ends`: a send makes it report `sdk.stream.ended` (`cli_exited`) +/// and exit 3, as index.mjs does when its SDK stream ends. +/// * `silent`: a send is never answered, so the turn stays in flight. +/// * `output-closes`: a send makes it close its stdout and keep running, +/// ignoring the shutdown line, stdin EOF and SIGTERM. +/// +/// Every mode but `output-closes` exits 0 on the shutdown line. +const ENDING_SIDECAR: &str = r#" +const fs = require('node:fs') +const path = require('node:path') +const { spawn } = require('node:child_process') +const STAND_IN = "process.on('SIGTERM', () => {}); require('node:fs').writeFileSync(process.argv[1], String(process.pid)); setInterval(() => {}, 1000)" +spawn(process.execPath, ['-e', STAND_IN, path.join(DIR, 'in-group.pid')], { stdio: 'ignore' }) +spawn(process.execPath, ['-e', STAND_IN, path.join(DIR, 'left-group.pid')], { stdio: 'ignore', detached: true }) +process.on('SIGTERM', () => { if (MODE !== 'output-closes') process.exit(143) }) +const out = (frame) => process.stdout.write(JSON.stringify(frame) + '\n') +require('node:readline').createInterface({ input: process.stdin }).on('line', (line) => { + let msg + try { msg = JSON.parse(line) } catch { return } + if (msg.type === 'create') { + const sessionId = 'ending-sidecar-session' + out({ type: 'created', requestId: msg.requestId, sessionId, protocol: 2 }) + out({ type: 'sdk.session.init', sessionId, cliSessionId: DURABLE, model: 'fake-model', cwd: '/tmp', tools: [] }) + out({ type: 'sdk.status', sessionId, status: 'idle' }) + } else if (msg.type === 'send') { + if (MODE === 'stream-ends') { + const ended = { type: 'sdk.stream.ended', sessionId: msg.sessionId, reason: 'cli_exited', message: 'Claude Code process exited with code 1' } + process.stdout.write(JSON.stringify(ended) + '\n', () => process.exit(3)) + } else if (MODE === 'output-closes') { + fs.closeSync(1) + } + } else if (msg.type === 'shutdown' && MODE !== 'output-closes') { + process.exit(0) + } +}) +setInterval(() => {}, 1000) +"#; + +/// A process captured by pid and start time: dead once that pid no longer +/// carries the start time (gone, reused, or a zombie, which runs nothing). +struct Captured { + pid: i32, + start: u64, +} + +impl Captured { + fn of(pid: i32) -> Self { + let start = crate::session_lease::proc_starttime(pid) + .unwrap_or_else(|| panic!("process {pid} is running")); + Self { pid, start } + } + + fn alive(&self) -> bool { + crate::session_lease::proc_starttime(self.pid) == Some(self.start) + } + + async fn dies_within(&self, budget: Duration) -> bool { + let deadline = tokio::time::Instant::now() + budget; + while self.alive() { + if tokio::time::Instant::now() >= deadline { + return false; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + true + } +} + +/// A session of the fake sidecar, created through the production create. +struct EndingSession { + st: FreshClaudeState, + rx: tokio::sync::broadcast::Receiver, + /// The pane's id for the session (its map key and envelope id). + pane: String, + node: Captured, + in_group: Captured, + left_group: Captured, + /// Every frame read off the bus so far, in order. + frames: Vec, + _dir: tempfile::TempDir, + _env: tokio::sync::MutexGuard<'static, ()>, +} + +impl EndingSession { + /// Start the fake in `mode`; `confirm_rounds` overrides the stop's + /// tree-death confirmation budget. + async fn start(mode: &str, confirm_rounds: Option) -> Self { + let env = super::tests::CLAUDE_ENV_LOCK.lock().await; + let dir = tempfile::tempdir().expect("fake sidecar directory"); + let script = dir.path().join("ending-sidecar.cjs"); + let quoted = |text: &str| serde_json::to_string(text).expect("a JS string"); + std::fs::write( + &script, + format!( + "const MODE = {}\nconst DIR = {}\nconst DURABLE = {}\n{ENDING_SIDECAR}", + quoted(mode), + quoted(&dir.path().to_string_lossy()), + quoted(DURABLE), + ), + ) + .expect("write the fake sidecar"); + std::env::set_var("FRESHELL_CLAUDE_SIDECAR", &script); + std::env::set_var("CLAUDE_CONFIG_DIR", dir.path().join("claude")); + let (tx, mut rx) = tokio::sync::broadcast::channel::(256); + let mut st = FreshClaudeState::new(Arc::new(tx)); + st.set_handoff_confirm_rounds_for_test(confirm_rounds); + + st.handle_create(create_msg("end-of-output"), None).await; + let created = tokio::time::timeout(Duration::from_secs(20), async { + loop { + let frame = 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).expect("a JSON frame"), + }; + if frame["requestId"] == "end-of-output" { + return frame; + } + } + }) + .await + .expect("the create answers"); + assert_eq!(created["type"], "freshAgent.created", "{created}"); + let pane = created["sessionId"] + .as_str() + .expect("the created session id") + .to_string(); + let node_pid = st + .sessions + .lock() + .await + .get(&pane) + .and_then(|session| session.child.id()) + .expect("a live sidecar") as i32; + let stand_in = |name: &str| { + let file = dir.path().join(name); + async move { + let deadline = tokio::time::Instant::now() + Duration::from_secs(20); + loop { + if let Some(pid) = std::fs::read_to_string(&file) + .ok() + .and_then(|pid| pid.trim().parse::().ok()) + { + return Captured::of(pid); + } + assert!( + tokio::time::Instant::now() < deadline, + "the sidecar started its {} stand-in", + file.display() + ); + tokio::time::sleep(Duration::from_millis(20)).await; + } + } + }; + let in_group = stand_in("in-group.pid").await; + let left_group = stand_in("left-group.pid").await; + Self { + st, + rx, + pane, + node: Captured::of(node_pid), + in_group, + left_group, + frames: Vec::new(), + _dir: dir, + _env: env, + } + } + + /// Read frames until one matches `want` (returned) or 20 s pass. + async fn await_frame(&mut self, what: &str, want: impl Fn(&Value) -> bool) -> Value { + let found = tokio::time::timeout(Duration::from_secs(20), async { + loop { + let frame = match self.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).expect("a JSON frame"), + }; + self.frames.push(frame.clone()); + if want(&frame) { + return frame; + } + } + }) + .await; + found.unwrap_or_else(|_| panic!("no {what}; frames: {:?}", self.frames)) + } + + /// Read every frame that arrives within `quiet`. + async fn drain(&mut self, quiet: Duration) { + while let Ok(Ok(raw)) = tokio::time::timeout(quiet, self.rx.recv()).await { + self.frames + .push(serde_json::from_str(&raw).expect("a JSON frame")); + } + } + + /// How many frames about this pane's session match `want`. + fn count(&self, want: impl Fn(&Value) -> bool) -> usize { + self.frames + .iter() + .filter(|frame| frame["sessionId"] == self.pane.as_str() && want(frame)) + .count() + } +} + +impl Drop for EndingSession { + fn drop(&mut self) { + std::env::remove_var("FRESHELL_CLAUDE_SIDECAR"); + std::env::remove_var("CLAUDE_CONFIG_DIR"); + // A failed assertion must not leave the TERM-immune stand-ins behind. + for process in [&self.node, &self.in_group, &self.left_group] { + if process.alive() { + // SAFETY: the pid still carries the start time captured from + // this test's own process; kill only signals it. + unsafe { + libc::kill(process.pid, libc::SIGKILL); + } + } + } + } +} + +fn create_msg(request_id: &str) -> FreshAgentCreate { + FreshAgentCreate { + naming_handle: None, + observed_epoch: None, + observed_generation: None, + request_id: request_id.to_string(), + session_type: SessionType::Freshclaude, + provider: Some(freshell_protocol::AgentProvider::Claude), + cwd: None, + legacy_restore_context: None, + resume_session_id: None, + session_ref: None, + model: None, + model_selection: None, + permission_mode: None, + sandbox: None, + effort: None, + plugins: None, + tab_id: None, + } +} + +fn send_msg(session_id: &str, text: &str) -> FreshAgentSend { + FreshAgentSend { + provider: freshell_protocol::AgentProvider::Claude, + session_id: session_id.to_string(), + session_type: SessionType::Freshclaude, + text: text.to_string(), + cwd: None, + images: None, + request_id: None, + settings: None, + observed_epoch: None, + observed_generation: None, + checkpoint_intent: None, + } +} + +fn kill_msg(session_id: &str) -> FreshAgentKill { + FreshAgentKill { + observed_epoch: None, + observed_generation: None, + provider: freshell_protocol::AgentProvider::Claude, + session_id: session_id.to_string(), + session_type: SessionType::Freshclaude, + cwd: None, + } +} + +fn is_exited_status(frame: &Value) -> bool { + frame["type"] == "freshAgent.event" + && frame["event"]["type"] == "freshAgent.status" + && frame["event"]["status"] == "exited" +} + +fn is_sidecar_exited(frame: &Value) -> bool { + frame["type"] == "freshAgent.event" + && frame["event"]["type"] == "freshAgent.error" + && frame["event"]["code"] == "SIDECAR_EXITED" +} + +/// Either half of the exit report (`exited` comes first). +fn is_exit_report(frame: &Value) -> bool { + is_exited_status(frame) || is_sidecar_exited(frame) +} + +fn is_turn_complete(frame: &Value) -> bool { + frame["event"]["type"] == "freshAgent.turn.complete" +} + +/// The captured records whose message starts with `message`, in order. +fn records( + events: &Arc>>, + message: &str, +) -> Vec { + events + .lock() + .expect("capture lock") + .iter() + .filter(|(_, fields)| { + fields + .get("message") + .is_some_and(|logged| logged.starts_with(message)) + }) + .map(|(_, fields)| fields.clone()) + .collect() +} + +/// Where the first record whose message starts with `message` was logged. +fn logged_at( + events: &Arc>>, + message: &str, +) -> Option { + events + .lock() + .expect("capture lock") + .iter() + .position(|(_, fields)| { + fields + .get("message") + .is_some_and(|logged| logged.starts_with(message)) + }) +} + +fn field<'a>(fields: &'a capture::Fields, name: &str) -> Option<&'a str> { + fields.get(name).map(String::as_str) +} + +/// C08 + G31: a sidecar whose SDK stream ended reports it and exits 3. Its +/// whole tree (the CLI stand-in in its group AND the one that left the group) +/// is dead by the time the pane hears `exited`, with no waiting on the test's +/// side; the pane hears it once, with the reported cause, and nothing rings +/// (the sidecar rings a cut-off turn itself before it reports the end). +#[cfg(target_os = "linux")] +#[tokio::test] +async fn a_stream_end_is_reported_once_its_whole_tree_is_confirmed_gone() { + let (events, _log) = capture::install(); + let mut session = EndingSession::start("stream-ends", None).await; + assert!(session.in_group.alive() && session.left_group.alive()); + + session + .st + .handle_send(send_msg(&session.pane, "a turn whose stream ends")) + .await; + let first = session.await_frame("the exit report", is_exit_report).await; + + assert!( + is_exited_status(&first), + "`exited` leads the report: {first}" + ); + assert!(!session.node.alive(), "node is reaped before the report"); + assert!( + !session.in_group.alive(), + "the CLI stand-in in node's group is dead before the report" + ); + assert!( + !session.left_group.alive(), + "the CLI stand-in that left node's group is dead before the report" + ); + let report = session + .await_frame("SIDECAR_EXITED", is_sidecar_exited) + .await; + assert_eq!( + report["event"]["cause"], + json!({ "reason": "cli_exited", "message": "Claude Code process exited with code 1" }), + "the reported stream end is the cause: {report}" + ); + session.drain(Duration::from_millis(300)).await; + assert_eq!(session.count(is_exited_status), 1, "{:?}", session.frames); + assert_eq!(session.count(is_sidecar_exited), 1, "{:?}", session.frames); + assert_eq!( + session.count(is_turn_complete), + 0, + "a reported stream end rings nothing from Rust: {:?}", + session.frames + ); + assert!(session.st.dead_sessions.find(&session.pane).is_some()); + + let logged = records(&events, "freshagent.claude.sidecar_exit"); + assert_eq!(logged.len(), 1, "{logged:?}"); + assert_eq!(field(&logged[0], "reason"), Some("cli_exited")); + assert_eq!(field(&logged[0], "stop"), Some("abnormal_exit")); + assert_eq!(field(&logged[0], "confirmation"), Some("confirmed")); + assert_eq!(field(&logged[0], "exit_status"), Some("code 3")); + assert_eq!(field(&logged[0], "rings"), Some("false")); +} + +/// C09 + G31: node killed mid-turn reports no stream end. Its exit status is +/// the cause (SIGKILL), its tree is dead before the report, and the cut-off +/// turn rings exactly once, from Rust. +#[cfg(target_os = "linux")] +#[tokio::test] +async fn a_node_killed_mid_turn_reports_its_signal_after_the_tree_is_gone_and_rings_once() { + let (events, _log) = capture::install(); + let mut session = EndingSession::start("silent", None).await; + session + .st + .handle_send(send_msg(&session.pane, "a turn the sidecar dies in")) + .await; + let in_turn = session + .st + .sessions + .lock() + .await + .get(&session.pane) + .map(|live| live.in_turn.load(std::sync::atomic::Ordering::SeqCst)); + assert_eq!(in_turn, Some(true), "the turn is in flight"); + + // SAFETY: the sidecar is this test's own child. + unsafe { + libc::kill(session.node.pid, libc::SIGKILL); + } + let first = session.await_frame("the exit report", is_exit_report).await; + + assert!( + is_exited_status(&first), + "`exited` leads the report: {first}" + ); + assert!(!session.in_group.alive(), "the in-group stand-in is dead"); + assert!( + !session.left_group.alive(), + "the stand-in that left is dead" + ); + let report = session + .await_frame("SIDECAR_EXITED", is_sidecar_exited) + .await; + let cause = &report["event"]["cause"]; + assert_eq!(cause["reason"], "sidecar_exited", "{report}"); + assert_eq!(cause["signal"], "SIGKILL", "{report}"); + assert!(cause.get("exitCode").is_none(), "{report}"); + session.drain(Duration::from_millis(300)).await; + assert_eq!(session.count(is_exited_status), 1, "{:?}", session.frames); + assert_eq!(session.count(is_sidecar_exited), 1, "{:?}", session.frames); + assert_eq!( + session.count(is_turn_complete), + 1, + "the cut-off turn rings once: {:?}", + session.frames + ); + + let logged = records(&events, "freshagent.claude.sidecar_exit"); + assert_eq!(logged.len(), 1, "{logged:?}"); + assert_eq!(field(&logged[0], "stop"), Some("abnormal_exit")); + assert_eq!(field(&logged[0], "exit_status"), Some("SIGKILL")); + assert_eq!(field(&logged[0], "confirmation"), Some("confirmed")); + assert_eq!(field(&logged[0], "rings"), Some("true")); +} + +/// G31: a sidecar that closed its output but kept running, deaf to the +/// shutdown line and SIGTERM, is killed by the stop after the grace period. +/// The SIGKILL is the stop's own doing, so the cause names no exit code or +/// signal; the log still records the status. +#[cfg(target_os = "linux")] +#[tokio::test] +async fn a_sidecar_the_stop_had_to_kill_reports_no_signal_as_its_cause() { + let (events, _log) = capture::install(); + let mut session = EndingSession::start("output-closes", None).await; + session + .st + .handle_send(send_msg(&session.pane, "a turn whose output goes away")) + .await; + + let first = session.await_frame("the exit report", is_exit_report).await; + + assert!( + is_exited_status(&first), + "`exited` leads the report: {first}" + ); + assert!( + !session.node.alive(), + "the stop killed node before the report" + ); + assert!(!session.in_group.alive(), "the in-group stand-in is dead"); + assert!( + !session.left_group.alive(), + "the stand-in that left is dead" + ); + let report = session + .await_frame("SIDECAR_EXITED", is_sidecar_exited) + .await; + assert_eq!( + report["event"]["cause"], + json!({ "reason": "sidecar_exited", "message": "the agent process ended" }), + "the stop's own kill is not the cause: {report}" + ); + + let logged = records(&events, "freshagent.claude.sidecar_exit"); + assert_eq!(logged.len(), 1, "{logged:?}"); + assert_eq!(field(&logged[0], "stop"), Some("forced")); + assert_eq!(field(&logged[0], "exit_status"), Some("SIGKILL")); + assert_eq!(field(&logged[0], "confirmation"), Some("confirmed")); +} + +/// G31: the same sidecar stopped on request (a pane kill) goes through the +/// same stop, so its whole tree dies, but nothing about the end is reported +/// and no dead session is recorded. +#[cfg(target_os = "linux")] +#[tokio::test] +async fn a_requested_stop_kills_the_same_tree_and_reports_nothing() { + let (events, _log) = capture::install(); + let mut session = EndingSession::start("stream-ends", None).await; + + session.st.handle_kill(kill_msg(&session.pane)).await; + + assert!(!session.node.alive(), "node stopped"); + assert!(!session.in_group.alive(), "the in-group stand-in is dead"); + assert!( + !session.left_group.alive(), + "the stand-in that left is dead" + ); + session.drain(Duration::from_millis(500)).await; + assert_eq!(session.count(is_exit_report), 0, "{:?}", session.frames); + assert_eq!(session.count(is_turn_complete), 0, "{:?}", session.frames); + assert!(session.st.dead_sessions.find(&session.pane).is_none()); + assert!(session.st.dead_sessions.find(DURABLE).is_none()); + assert!(records(&events, "freshagent.claude.sidecar_exit").is_empty()); + let teardowns = capture::teardown_events(&events); + assert_eq!(teardowns.len(), 1, "{teardowns:?}"); + assert_eq!(field(&teardowns[0].1, "confirmation"), Some("confirmed")); +} + +/// G31: a tree the stop cannot confirm gone within its bounded window (here a +/// window of no rounds, which the stand-in that left node's group outlives) +/// does not hold the report back. The pane hears `exited` and SIDECAR_EXITED +/// once each, and the detached escalation keeps killing the tree until it is +/// dead. The report is logged before the escalation confirms that death, so +/// the report did not wait for it. +#[cfg(target_os = "linux")] +#[tokio::test] +async fn a_tree_the_stop_cannot_confirm_gone_is_reported_and_killed_in_the_background() { + let (events, _log) = capture::install(); + let mut session = EndingSession::start("stream-ends", Some(0)).await; + + session + .st + .handle_send(send_msg(&session.pane, "a turn whose stream ends")) + .await; + session + .await_frame("SIDECAR_EXITED", is_sidecar_exited) + .await; + + assert!( + !session.in_group.alive(), + "the process-tree guard killed node's group before the report" + ); + assert!( + session + .left_group + .dies_within(Duration::from_secs(10)) + .await, + "the background escalation kills the stand-in that left the group" + ); + session.drain(Duration::from_millis(300)).await; + assert_eq!(session.count(is_exited_status), 1, "{:?}", session.frames); + assert_eq!(session.count(is_sidecar_exited), 1, "{:?}", session.frames); + + let logged = records(&events, "freshagent.claude.sidecar_exit"); + assert_eq!(logged.len(), 1, "{logged:?}"); + assert_eq!(field(&logged[0], "confirmation"), Some("not_confirmed")); + assert_eq!( + records(&events, "freshagent.claude.teardown_unconfirmed").len(), + 1, + "the unconfirmed stop is an error record" + ); + let reported = logged_at(&events, "freshagent.claude.sidecar_exit").expect("reported"); + let confirmed = logged_at(&events, "freshagent.claude.teardown_escalation_confirmed") + .expect("the escalation confirms the tree's death"); + assert!( + reported < confirmed, + "the report went out before the background kill finished" + ); +} diff --git a/crates/freshell-freshagent/src/claude/teardown_tests.rs b/crates/freshell-freshagent/src/claude/teardown_tests.rs index 19ab7cdd2..a8ddc3613 100644 --- a/crates/freshell-freshagent/src/claude/teardown_tests.rs +++ b/crates/freshell-freshagent/src/claude/teardown_tests.rs @@ -113,7 +113,6 @@ async fn fake_session( 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)), @@ -370,3 +369,99 @@ async fn spawn_sidecar_runs_the_sidecar_inside_its_own_process_group() { crate::claude_spawn::TreeConfirmation::Confirmed ); } + +/// The captured records whose message starts with `prefix`. +fn records_starting( + events: &Arc>>, + prefix: &str, +) -> Vec<(tracing::Level, capture::Fields)> { + events + .lock() + .expect("capture lock") + .iter() + .filter(|(_, fields)| { + fields + .get("message") + .is_some_and(|message| message.starts_with(prefix)) + }) + .cloned() + .collect() +} + +/// A kill-and-confirm window that confirms the tree dead on its +/// `dies_on`-th run, recording when each run started. +struct ScriptedWindow { + runs: Vec, + dies_on: usize, +} + +impl TreeDeathWindow for ScriptedWindow { + async fn confirm_dead(&mut self) -> bool { + self.runs.push(std::time::Instant::now()); + self.runs.len() >= self.dies_on + } + + fn lingering(&self) -> usize { + 1 + } +} + +/// A tree that outlives a whole window survived SIGKILL: it is stuck in the +/// kernel. The detached escalation keeps going until it is dead (the kill's +/// deferred stop commit waits on that), but it backs off between windows +/// instead of rescanning `/proc` every 25 ms and warning every 0.6 s for +/// the rest of the server's life, and it says when the tree finally died. +#[tokio::test] +async fn the_detached_escalation_backs_off_between_windows_until_the_tree_dies() { + let (events, _guard) = capture::install(); + let mut window = ScriptedWindow { + runs: Vec::new(), + dies_on: 4, + }; + + assert!(escalate_until_dead(&mut window, "backoff").await); + + assert_eq!(window.runs.len(), 4, "it ran until the tree died"); + let gaps: Vec = window.runs.windows(2).map(|w| w[1] - w[0]).collect(); + for (gap, floor) in gaps.iter().zip([500, 1_000, 2_000]) { + assert!( + *gap >= Duration::from_millis(floor), + "the pause doubles from half a second: {gaps:?}" + ); + } + let continues = records_starting(&events, "freshagent.claude.teardown_escalation_continues"); + assert_eq!( + continues + .iter() + .map(|(level, fields)| ( + *level, + fields.get("windows").cloned(), + fields.get("next_window_in_ms").cloned() + )) + .collect::>(), + [("1", "500"), ("2", "1000"), ("3", "2000")] + .map(|(windows, pause)| ( + tracing::Level::WARN, + Some(windows.to_string()), + Some(pause.to_string()) + )) + .to_vec(), + "one warning per unconfirmed window, naming the next pause" + ); + let confirmed = records_starting(&events, "freshagent.claude.teardown_escalation_confirmed"); + assert_eq!(confirmed.len(), 1, "{confirmed:?}"); + assert_eq!(confirmed[0].0, tracing::Level::INFO); + assert_eq!(confirmed[0].1.get("windows").map(String::as_str), Some("4")); +} + +/// Once backed off, an unkillable tree costs one window (24 rounds of 25 ms) +/// and one warning a minute. +#[test] +fn the_pause_between_escalation_windows_doubles_up_to_a_minute() { + assert_eq!(escalation_pause(1), Duration::from_millis(500)); + assert_eq!(escalation_pause(2), Duration::from_secs(1)); + assert_eq!(escalation_pause(3), Duration::from_secs(2)); + assert_eq!(escalation_pause(7), Duration::from_secs(32)); + assert_eq!(escalation_pause(8), Duration::from_secs(60)); + assert_eq!(escalation_pause(u32::MAX), Duration::from_secs(60)); +} 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..9404e3ee0 --- /dev/null +++ b/crates/freshell-freshagent/src/claude_dead_sessions.rs @@ -0,0 +1,255 @@ +//! 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 std::sync::{Arc, Mutex}; + +use serde_json::{json, Value}; + +use crate::claude::PROVIDER; +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() + } +} + +/// One death as the end-of-output path knows it, for [`SharedDeadSessions::record`]. +pub(crate) struct Death<'a> { + /// The session's key in the sessions map. + pub(crate) map_key: &'a str, + /// The id its events were stamped with. + pub(crate) stamp: &'a str, + pub(crate) session_type: &'a str, + /// The durable ids the session's aliases named. + pub(crate) durables: &'a [String], + /// The durable id the session itself held. + pub(crate) cli_session_id: Option<&'a str>, + pub(crate) settings: &'a FreshAgentSettings, + pub(crate) commands: Option<&'a Vec>, + /// The `sdk.stream.ended` reason, or `sidecar_exited`. + pub(crate) reason: &'a str, +} + +/// The claude lane's dead-session records, shared by every clone of its state +/// (the end-of-output path records from its consumer task). +#[derive(Clone, Default)] +pub(crate) struct SharedDeadSessions(Arc>); + +impl SharedDeadSessions { + /// The record of the session answering to `id` whose sidecar ended + /// unrequested and that has not been resumed or closed since. + pub(crate) fn find(&self, id: &str) -> Option { + self.0.lock().expect("dead sessions lock").find(id).cloned() + } + + /// Record a session the end-of-output path just evicted: it answers to its + /// map key, its envelope stamp and every durable id it held. + pub(crate) fn record(&self, death: Death<'_>) { + let mut ids = vec![death.map_key.to_string()]; + for id in std::iter::once(death.stamp) + .chain(death.durables.iter().map(String::as_str)) + .chain(death.cli_session_id) + { + if !ids.iter().any(|known| known == id) { + ids.push(id.to_string()); + } + } + let dead = DeadClaudeSession { + ids: ids.clone(), + settings: death.settings.clone(), + commands: death.commands.cloned(), + reason: death.reason.to_string(), + at_ms: crate::rollback_record::now_ms(), + }; + let (dropped, remembered) = { + let mut store = self.0.lock().expect("dead sessions lock"); + let dropped = store.record(dead); + (dropped, store.len()) + }; + tracing::info!(target: "freshell_freshagent::claude", + provider = PROVIDER, session_id = %death.stamp, session_type = %death.session_type, + ids = ?ids, reason = death.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 = DEAD_SESSIONS_CAP, + "freshagent.claude.dead_session_dropped"); + } + } + + /// Forget the records answering to any of `ids`: the session is live again + /// (`resumed`) or the pane closed it (`killed`). + pub(crate) fn forget>(&self, ids: &[S], reason: &'static str) { + let forgotten = self.0.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"); + } + } +} + +/// 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/claude_handshake.rs b/crates/freshell-freshagent/src/claude_handshake.rs new file mode 100644 index 000000000..aa76e3082 --- /dev/null +++ b/crates/freshell-freshagent/src/claude_handshake.rs @@ -0,0 +1,357 @@ +//! The claude sidecar's answer to `create` (core-runtime design §4.2 and §5.1, +//! protocol v2). +//! +//! After Rust writes the `create` line, the sidecar answers exactly once: +//! - `created {requestId, sessionId, protocol, cliPid}` once the CLI has booted +//! the conversation (`initializationResult()` resolved, no prompt needed), or +//! - `create.failed {requestId, code, message}` when the CLI refused the boot (the +//! codes are the sidecar's, `crates/freshell-claude-sidecar/boot.mjs`); the +//! sidecar then exits. +//! +//! [`read_created`] reads that answer. It forwards nothing that comes before it: +//! any earlier line is logged at debug and dropped, so a refused boot can never +//! reach the turn machinery. A `created` that speaks another protocol is refused +//! as [`CreateFailure::ProtocolMismatch`]: a packaged app pairing a stale sidecar +//! with this server must fail loudly, not misread every later frame. + +use std::time::{Duration, Instant}; + +use serde_json::Value; +use tokio::io::{AsyncBufRead, Lines}; + +use crate::claude_launch::{ClaudeBoot, SIDECAR_PROTOCOL}; + +/// The client-visible code of a create the sidecar never answered (it died, its +/// stdout failed, or the budget ran out) or answered without a code. +pub(crate) const CREATE_FAILED_CODE: &str = "CLAUDE_CREATE_FAILED"; + +/// The code of a `created` that speaks another protocol. +pub(crate) const PROTOCOL_MISMATCH_CODE: &str = "SIDECAR_PROTOCOL_MISMATCH"; + +/// The sidecar's `created`: the CLI booted. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct SidecarCreated { + /// The sidecar's id for the session (`created.sessionId`). + pub(crate) session_id: String, + /// The booted CLI's pid (`initializationResult().pid`), when reported. + pub(crate) cli_pid: Option, +} + +/// Why a create got no `created`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum CreateFailure { + /// The sidecar answered `create.failed`: the CLI refused the boot, or the + /// request was invalid. `code` is the sidecar's (`None` when it sent none). + Refused { + code: Option, + message: String, + }, + /// The sidecar answered `created` in another protocol. + ProtocolMismatch { message: String }, + /// No answer: the sidecar's stdout ended or failed, or the budget ran out. + NoAnswer(String), +} + +impl CreateFailure { + /// The `freshAgent.create.failed` code a client gets for this failure. + pub(crate) fn code(&self) -> &str { + match self { + Self::Refused { + code: Some(code), .. + } => code, + Self::Refused { code: None, .. } | Self::NoAnswer(_) => CREATE_FAILED_CODE, + Self::ProtocolMismatch { .. } => PROTOCOL_MISMATCH_CODE, + } + } + + /// What went wrong, in words. + pub(crate) fn message(&self) -> &str { + match self { + Self::Refused { message, .. } + | Self::ProtocolMismatch { message } + | Self::NoAnswer(message) => message, + } + } + + /// Whether the sidecar named this refusal `code`. + pub(crate) fn is_refusal(&self, code: &str) -> bool { + matches!(self, Self::Refused { code: Some(refused), .. } if refused == code) + } +} + +/// What a boot is, for its log record. +pub(crate) struct BootContext<'a> { + pub(crate) request_id: &'a str, + pub(crate) session_type: &'a str, + pub(crate) boot: &'a ClaudeBoot, +} + +/// Read the sidecar's stdout until its answer to `create`, bounded by `budget`, and +/// log the boot (`freshagent.claude.boot`, with how long it took). Lines before the +/// answer are dropped; the reader is left on the line after it. +pub(crate) async fn read_created( + reader: &mut Lines, + budget: Duration, + context: &BootContext<'_>, +) -> Result +where + R: AsyncBufRead + Unpin, +{ + let started = Instant::now(); + let answer = match tokio::time::timeout(budget, read_answer(reader, context)).await { + Ok(answer) => answer, + Err(_) => Err(CreateFailure::NoAnswer(format!( + "sidecar did not answer `create` within {}s", + budget.as_secs() + ))), + }; + log_boot(context, &answer, started.elapsed()); + answer +} + +async fn read_answer( + reader: &mut Lines, + context: &BootContext<'_>, +) -> Result +where + R: AsyncBufRead + Unpin, +{ + loop { + let line = match reader.next_line().await { + Ok(Some(line)) => line, + // EOF before an answer: the sidecar died at startup (bad node, missing SDK). + Ok(None) => { + return Err(CreateFailure::NoAnswer( + "sidecar stdout closed before `created`".to_string(), + )) + } + Err(error) => { + return Err(CreateFailure::NoAnswer(format!( + "sidecar stdout read error: {error}" + ))) + } + }; + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + let value = serde_json::from_str::(trimmed).ok(); + let line_type = value + .as_ref() + .and_then(|value| value.get("type")) + .and_then(Value::as_str); + match (line_type, value.as_ref()) { + (Some("created"), Some(value)) => return created(value), + (Some("create.failed"), Some(value)) => return Err(refused(value)), + _ => { + // Nothing before the answer is forwarded (design §4.2). + tracing::debug!( + target: "freshell_freshagent::claude", + event = "freshagent.claude.handshake_line_dropped", + request_id = context.request_id, + line_type = line_type.unwrap_or(""), + bytes = trimmed.len(), + "a sidecar line before its answer to create was dropped" + ); + } + } + } +} + +fn created(value: &Value) -> Result { + let protocol = value.get("protocol").and_then(Value::as_u64); + if protocol != Some(SIDECAR_PROTOCOL) { + let spoken = protocol.map_or_else(|| "1".to_string(), |p| p.to_string()); + return Err(CreateFailure::ProtocolMismatch { + message: format!( + "The Claude sidecar speaks protocol {spoken}; this server speaks protocol \ + {SIDECAR_PROTOCOL}. The sidecar and the server come from different builds." + ), + }); + } + let session_id = value + .get("sessionId") + .and_then(Value::as_str) + .filter(|id| !id.is_empty()) + .ok_or_else(|| CreateFailure::NoAnswer("created carried no sessionId".to_string()))?; + let cli_pid = value + .get("cliPid") + .and_then(Value::as_u64) + .and_then(|pid| u32::try_from(pid).ok()); + Ok(SidecarCreated { + session_id: session_id.to_string(), + cli_pid, + }) +} + +fn refused(value: &Value) -> CreateFailure { + CreateFailure::Refused { + code: value + .get("code") + .and_then(Value::as_str) + .filter(|code| !code.is_empty()) + .map(str::to_string), + message: value + .get("message") + .and_then(Value::as_str) + .unwrap_or("unknown sidecar create failure") + .to_string(), + } +} + +fn log_boot( + context: &BootContext<'_>, + answer: &Result, + elapsed: Duration, +) { + let elapsed_ms = u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX); + let boot = context.boot.kind(); + match answer { + Ok(created) => tracing::info!( + target: "freshell_freshagent::claude", + event = "freshagent.claude.boot", + request_id = context.request_id, + session_type = context.session_type, + boot, + session_id = %created.session_id, + cli_pid = created.cli_pid, + elapsed_ms, + "the claude CLI booted" + ), + Err(failure @ CreateFailure::ProtocolMismatch { .. }) => tracing::error!( + target: "freshell_freshagent::claude", + event = "freshagent.claude.boot", + request_id = context.request_id, + session_type = context.session_type, + boot, + code = failure.code(), + error = failure.message(), + elapsed_ms, + "the claude sidecar speaks another protocol" + ), + Err(failure) => tracing::warn!( + target: "freshell_freshagent::claude", + event = "freshagent.claude.boot", + request_id = context.request_id, + session_type = context.session_type, + boot, + code = failure.code(), + error = failure.message(), + elapsed_ms, + "the claude CLI did not boot" + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::io::{AsyncBufReadExt, BufReader}; + + const BUDGET: Duration = Duration::from_secs(5); + + fn context() -> BootContext<'static> { + BootContext { + request_id: "req-1", + session_type: "freshclaude", + boot: &ClaudeBoot::Fresh, + } + } + + fn lines(text: &'static str) -> Lines> { + BufReader::new(text.as_bytes()).lines() + } + + #[tokio::test] + async fn a_protocol_v2_created_is_the_boot() { + let mut reader = + lines("{\"type\":\"created\",\"requestId\":\"req-1\",\"sessionId\":\"s1\",\"protocol\":2,\"cliPid\":4242}\n"); + assert_eq!( + read_created(&mut reader, BUDGET, &context()).await, + Ok(SidecarCreated { + session_id: "s1".into(), + cli_pid: Some(4242), + }) + ); + } + + #[tokio::test] + async fn a_created_in_another_protocol_is_refused_as_a_mismatch() { + for line in [ + "{\"type\":\"created\",\"requestId\":\"req-1\",\"sessionId\":\"s1\"}\n", + "{\"type\":\"created\",\"requestId\":\"req-1\",\"sessionId\":\"s1\",\"protocol\":3}\n", + ] { + let mut reader = BufReader::new(line.as_bytes()).lines(); + let failure = read_created(&mut reader, BUDGET, &context()) + .await + .expect_err(line); + assert!( + matches!(failure, CreateFailure::ProtocolMismatch { .. }), + "{failure:?}" + ); + assert_eq!(failure.code(), "SIDECAR_PROTOCOL_MISMATCH"); + } + } + + #[tokio::test] + async fn a_refused_boot_keeps_the_sidecar_code_and_message() { + let mut reader = lines( + "{\"type\":\"create.failed\",\"requestId\":\"req-1\",\"code\":\"CONVERSATION_NOT_FOUND\",\"message\":\"No conversation found with session ID: X\"}\n", + ); + let failure = read_created(&mut reader, BUDGET, &context()) + .await + .expect_err("refused"); + assert_eq!(failure.code(), "CONVERSATION_NOT_FOUND"); + assert_eq!( + failure.message(), + "No conversation found with session ID: X" + ); + assert!(failure.is_refusal("CONVERSATION_NOT_FOUND")); + // An answer with no code still fails the create, under the generic code. + let mut reader = lines("{\"type\":\"create.failed\",\"message\":\"boom\"}\n"); + let failure = read_created(&mut reader, BUDGET, &context()) + .await + .expect_err("refused"); + assert_eq!(failure.code(), "CLAUDE_CREATE_FAILED"); + assert_eq!(failure.message(), "boom"); + } + + #[tokio::test] + async fn nothing_before_the_answer_is_forwarded_and_the_reader_stops_after_it() { + let mut reader = lines(concat!( + "{\"type\":\"sdk.result\",\"sessionId\":\"s1\",\"result\":\"error_during_execution\"}\n", + "not json\n", + "\n", + "{\"type\":\"sdk.error\",\"sessionId\":\"s1\",\"message\":\"boom\"}\n", + "{\"type\":\"created\",\"requestId\":\"req-1\",\"sessionId\":\"s1\",\"protocol\":2}\n", + "{\"type\":\"sdk.session.changed\",\"sessionId\":\"s1\"}\n", + )); + let created = read_created(&mut reader, BUDGET, &context()) + .await + .expect("created"); + assert_eq!(created.session_id, "s1"); + // The consumer that takes the reader over starts at the line after created. + let next = reader.next_line().await.unwrap().unwrap(); + assert!(next.contains("sdk.session.changed"), "{next}"); + } + + #[tokio::test] + async fn a_sidecar_that_ends_without_answering_fails_the_create() { + let mut reader = lines("{\"type\":\"sdk.status\",\"sessionId\":\"s1\"}\n"); + let failure = read_created(&mut reader, BUDGET, &context()) + .await + .expect_err("no answer"); + assert!(matches!(failure, CreateFailure::NoAnswer(_)), "{failure:?}"); + assert_eq!(failure.code(), "CLAUDE_CREATE_FAILED"); + } + + #[tokio::test] + async fn a_sidecar_that_never_answers_fails_the_create_at_the_budget() { + let (_writer, read_half) = tokio::io::duplex(64); + let mut reader = BufReader::new(read_half).lines(); + let failure = read_created(&mut reader, Duration::from_millis(50), &context()) + .await + .expect_err("timed out"); + assert!(matches!(failure, CreateFailure::NoAnswer(_)), "{failure:?}"); + } +} diff --git a/crates/freshell-freshagent/src/claude_launch.rs b/crates/freshell-freshagent/src/claude_launch.rs new file mode 100644 index 000000000..ea2a85e4e --- /dev/null +++ b/crates/freshell-freshagent/src/claude_launch.rs @@ -0,0 +1,168 @@ +//! Which conversation a claude sidecar boots (core-runtime design §4.2 and §5.1, +//! protocol v2). +//! +//! Every `create` line Rust writes to a claude sidecar names its stdio protocol +//! ([`SIDECAR_PROTOCOL`]) and a [`ClaudeBoot`]: a fresh conversation, a resume, or a +//! fork of a parent conversation at a point. The sidecar maps the boot onto the SDK's +//! `query()` options (`crates/freshell-claude-sidecar/boot.mjs`) and answers +//! `created` only once the CLI has booted it (`claude_handshake`). + +use serde_json::{json, Map, Value}; + +/// The claude sidecar stdio protocol this server speaks: `create.protocol` and +/// `created.protocol` (`crates/freshell-claude-sidecar/boot.mjs`). +pub(crate) const SIDECAR_PROTOCOL: u64 = 2; + +/// The conversation a sidecar boots. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum ClaudeBoot { + /// A new conversation. + Fresh, + /// An existing conversation: its id, or its transcript path when the + /// conversation's original directory is gone. + Resume { resume: String }, + /// A new conversation holding `resume`'s transcript through `at` (all of it + /// when `None`). `drops_turn` arms the SDK's guard that the discarded part + /// starts with that turn's prompt. + Fork { + resume: String, + at: Option, + drops_turn: Option, + }, +} + +impl ClaudeBoot { + /// `Resume` of `resume` when there is one, else `Fresh`. + pub(crate) fn resume_or_fresh(resume: Option<&str>) -> Self { + match resume { + Some(resume) => Self::Resume { + resume: resume.to_string(), + }, + None => Self::Fresh, + } + } + + /// The boot's kind as the protocol and the logs name it. + pub(crate) fn kind(&self) -> &'static str { + match self { + Self::Fresh => "fresh", + Self::Resume { .. } => "resume", + Self::Fork { .. } => "fork", + } + } + + /// The `create.boot` value. + pub(crate) fn wire(&self) -> Value { + match self { + Self::Fresh => json!({ "kind": "fresh" }), + Self::Resume { resume } => json!({ "kind": "resume", "resume": resume }), + Self::Fork { + resume, + at, + drops_turn, + } => { + let mut boot = Map::new(); + boot.insert("kind".into(), json!("fork")); + boot.insert("resume".into(), json!(resume)); + if let Some(at) = at { + boot.insert("resumeSessionAt".into(), json!(at)); + } + if let Some(drops_turn) = drops_turn { + boot.insert("dropsTurn".into(), json!(drops_turn)); + } + Value::Object(boot) + } + } + } +} + +/// A sidecar `create` line: `type`, the stdio `protocol`, `requestId`, +/// `sessionType` and the `boot` (the fields every launch's line carries), plus +/// that launch's own `fields` (an object: cwd, model, permission mode and so on). +/// A launch field never replaces one of the line's own. The one builder of the +/// common fields until `ClaudeLaunchSpec::create_request()` builds the whole line +/// (design §3.2, C11). +pub(crate) fn create_line( + request_id: &str, + session_type: &str, + boot: &ClaudeBoot, + fields: Value, +) -> Value { + let mut line = match fields { + Value::Object(fields) => fields, + _ => Map::new(), + }; + line.insert("type".into(), json!("create")); + line.insert("protocol".into(), json!(SIDECAR_PROTOCOL)); + line.insert("requestId".into(), json!(request_id)); + line.insert("sessionType".into(), json!(session_type)); + line.insert("boot".into(), boot.wire()); + Value::Object(line) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn each_boot_has_its_protocol_v2_wire_shape() { + assert_eq!(ClaudeBoot::Fresh.wire(), json!({ "kind": "fresh" })); + assert_eq!( + ClaudeBoot::Resume { resume: "P".into() }.wire(), + json!({ "kind": "resume", "resume": "P" }) + ); + assert_eq!( + ClaudeBoot::Fork { + resume: "P".into(), + at: Some("a1".into()), + drops_turn: Some("u2".into()), + } + .wire(), + json!({ "kind": "fork", "resume": "P", "resumeSessionAt": "a1", "dropsTurn": "u2" }) + ); + // A fork of the whole parent with no guard names neither. + assert_eq!( + ClaudeBoot::Fork { + resume: "P".into(), + at: None, + drops_turn: None, + } + .wire(), + json!({ "kind": "fork", "resume": "P" }) + ); + } + + #[test] + fn a_create_line_carries_the_protocol_session_type_and_boot_with_the_launch_fields() { + let boot = ClaudeBoot::Resume { resume: "P".into() }; + let line = create_line( + "req-1", + "kilroy", + &boot, + json!({ "cwd": "/w", "model": null, "type": "not-create", "protocol": 1 }), + ); + assert_eq!( + line, + json!({ + "type": "create", + "protocol": SIDECAR_PROTOCOL, + "requestId": "req-1", + "sessionType": "kilroy", + "boot": { "kind": "resume", "resume": "P" }, + "cwd": "/w", + "model": null, + }), + "a launch field never overrides the line's own fields" + ); + } + + #[test] + fn a_resume_id_makes_a_resume_boot_and_none_a_fresh_one() { + assert_eq!(ClaudeBoot::resume_or_fresh(None), ClaudeBoot::Fresh); + assert_eq!( + ClaudeBoot::resume_or_fresh(Some("P")), + ClaudeBoot::Resume { resume: "P".into() } + ); + assert_eq!(ClaudeBoot::resume_or_fresh(Some("P")).kind(), "resume"); + } +} 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..904572582 --- /dev/null +++ b/crates/freshell-freshagent/src/claude_sidecar_exit.rs @@ -0,0 +1,315 @@ +//! 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 stops the ended +//! sidecar through the same process-tree stop every teardown uses (`claude.rs` +//! `SidecarProcesses::stop`, which reaps node's exit status), 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 serde_json::{json, Map, Value}; + +/// 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, or when node outlived the + /// stop's grace period and the stop killed it (that signal is not the cause). + 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: {}. Resume the session 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) + } +} + +/// 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/claude_test_holds.rs b/crates/freshell-freshagent/src/claude_test_holds.rs new file mode 100644 index 000000000..17d9fed82 --- /dev/null +++ b/crates/freshell-freshagent/src/claude_test_holds.rs @@ -0,0 +1,85 @@ +//! Test holds on a claude session's lifecycle. Each one parks a single step so a +//! test can land a race inside that window every time instead of by luck. No hold +//! is ever set in production, where each is a no-op. +//! +//! - Registration ([`ClaudeTestHolds::hold_registration`]): registering a session +//! waits this long between starting its consumer and inserting its record. A +//! production resume create awaits an identity-sink write while it registers; +//! rigs wire no sink, so this hold stands in for that await. +//! - Exit report ([`ClaudeTestHolds::hold_exit_report`]): the end-of-output path +//! parks after it recorded the dead session, before it reaps the sidecar and +//! reports the exit, so a resume can land while the dead sidecar is reaped. + +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use tokio::sync::Notify; + +/// The holds a test set on one claude lane (shared by every clone of the state). +#[derive(Default)] +pub(crate) struct ClaudeTestHolds { + registration: Mutex>, + exit_report: Mutex>>, +} + +/// A parked exit report: [`Self::parked`] returns once an end-of-output path +/// reached the hold, and the report goes on after [`Self::release`]. +#[derive(Default)] +pub struct ExitReportHold { + parked: Notify, + release: Notify, +} + +#[cfg(any(test, feature = "test-support"))] +impl ExitReportHold { + /// Wait until an end-of-output path is parked at the hold. + pub async fn parked(&self) { + self.parked.notified().await; + } + + /// Let the parked exit report go on. + pub fn release(&self) { + // `notify_one` keeps a permit for a waiter that has not arrived yet. + self.release.notify_one(); + } +} + +impl ClaudeTestHolds { + /// Make every later session registration wait `hold` between starting the + /// session's consumer and inserting its record. + #[cfg(any(test, feature = "test-support"))] + pub(crate) fn hold_registration(&self, hold: Duration) { + *self.registration.lock().expect("registration hold lock") = Some(hold); + } + + /// Park every later end-of-output path before it reaps the sidecar and + /// reports the exit, until the returned hold is released. + #[cfg(any(test, feature = "test-support"))] + pub(crate) fn hold_exit_report(&self) -> Arc { + let hold = Arc::new(ExitReportHold::default()); + *self.exit_report.lock().expect("exit report hold lock") = Some(Arc::clone(&hold)); + hold + } + + /// The registration hold, if a test set one. + pub(crate) async fn registration(&self) { + let hold = *self.registration.lock().expect("registration hold lock"); + if let Some(hold) = hold { + tokio::time::sleep(hold).await; + } + } + + /// The exit-report hold, if a test set one. + pub(crate) async fn exit_report(&self) { + let hold = self + .exit_report + .lock() + .expect("exit report hold lock") + .clone(); + if let Some(hold) = hold { + // `notify_one` keeps a permit for a test that is not waiting yet. + hold.parked.notify_one(); + hold.release.notified().await; + } + } +} diff --git a/crates/freshell-freshagent/src/lib.rs b/crates/freshell-freshagent/src/lib.rs index f92c8df0e..8496c98ed 100644 --- a/crates/freshell-freshagent/src/lib.rs +++ b/crates/freshell-freshagent/src/lib.rs @@ -37,10 +37,15 @@ //! backstop, by the harness sentinel sweep — no orphans. pub mod claude; +pub(crate) mod claude_dead_sessions; +pub(crate) mod claude_handshake; +pub(crate) mod claude_launch; pub(crate) mod claude_projection; +pub(crate) mod claude_sidecar_exit; pub(crate) mod claude_sidecar_io; pub(crate) mod claude_snapshot; pub mod claude_spawn; +pub(crate) mod claude_test_holds; pub mod codex; pub(crate) mod codex_sidecar_tracking; pub(crate) mod delegation; diff --git a/crates/freshell-freshagent/src/session_handoff/tests.rs b/crates/freshell-freshagent/src/session_handoff/tests.rs index 28850b3b1..d558f0ef8 100644 --- a/crates/freshell-freshagent/src/session_handoff/tests.rs +++ b/crates/freshell-freshagent/src/session_handoff/tests.rs @@ -62,8 +62,8 @@ rl.on('line', (line) => { if (msg.type === 'create') { counter += 1 const sessionId = `fake-claude-session-${process.pid}-${counter}` - process.stdout.write(JSON.stringify({ type: 'created', requestId: msg.requestId, sessionId }) + '\n') - const cliSessionId = msg.resumeSessionId || 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + process.stdout.write(JSON.stringify({ type: 'created', requestId: msg.requestId, sessionId, protocol: 2 }) + '\n') + const cliSessionId = msg.boot?.resume || 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' process.stdout.write(JSON.stringify({ type: 'sdk.session.init', sessionId, cliSessionId, model: 'fake-model', cwd: '/tmp', tools: [] }) + '\n') process.stdout.write(JSON.stringify({ type: 'sdk.status', sessionId, status: 'idle' }) + '\n') } else if (msg.type === 'shutdown') { @@ -105,7 +105,7 @@ impl FakeSidecarEnv { fn sidecar_pid_for(&self, sid: &str) -> Option { self.create_rows() .into_iter() - .find(|r| r["msg"]["resumeSessionId"] == sid) + .find(|r| r["msg"]["boot"]["resume"] == sid) .and_then(|r| r["pid"].as_u64()) .map(|p| p as u32) } @@ -5705,7 +5705,7 @@ async fn a_handoff_target_exiting_before_the_commit_never_publishes_live() { let target_pid = rows .iter() .rev() - .find(|r| r["msg"]["resumeSessionId"] == sid) + .find(|r| r["msg"]["boot"]["resume"] == sid) .and_then(|r| r["pid"].as_u64()) .expect("the target sidecar's create row") as u32; let _ = std::process::Command::new("kill") @@ -6183,7 +6183,7 @@ async fn handoff_abort_during_fresh_target_resume_reaps_the_registered_session_a let target_pid = env .create_rows() .into_iter() - .filter(|r| r["msg"]["resumeSessionId"] == sid) + .filter(|r| r["msg"]["boot"]["resume"] == sid) .filter_map(|r| r["pid"].as_u64()) .find(|pid| *pid != prior_pid as u64) .expect("the target sidecar's create row") as u32; @@ -6515,7 +6515,7 @@ async fn handoff_abort_holds_the_fence_until_the_uncommitted_target_continuation let target_pid = env .create_rows() .into_iter() - .filter(|r| r["msg"]["resumeSessionId"] == sid) + .filter(|r| r["msg"]["boot"]["resume"] == sid) .filter_map(|r| r["pid"].as_u64()) .find(|pid| *pid != prior_pid as u64) .expect("the target sidecar's create row") as u32; @@ -6811,7 +6811,7 @@ async fn an_aborted_from_vacant_unconfirmed_target_fences_with_a_replacement_wat let target_pid = env .create_rows() .into_iter() - .filter(|r| r["msg"]["resumeSessionId"] == sid) + .filter(|r| r["msg"]["boot"]["resume"] == sid) .filter_map(|r| r["pid"].as_u64()) .find(|pid| *pid != 0) .expect("the target sidecar's create row") as u32; @@ -6945,7 +6945,7 @@ async fn an_abort_cleanup_broadcasts_the_restored_prior_owner() { ); } -/// The fake sidecar's durable id for a create with NO resumeSessionId +/// The fake sidecar's durable id for a create with NO resume boot /// (the fresh-create fallback in FAKE_CLAUDE_SIDECAR_SOURCE). const FRESH_CREATE_DURABLE_ID: &str = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; diff --git a/crates/freshell-freshagent/tests/claude_sidecar_teardown.rs b/crates/freshell-freshagent/tests/claude_sidecar_teardown.rs index 7b0a0bd4e..cb7cf7236 100644 --- a/crates/freshell-freshagent/tests/claude_sidecar_teardown.rs +++ b/crates/freshell-freshagent/tests/claude_sidecar_teardown.rs @@ -600,7 +600,7 @@ require('node:readline').createInterface({ input: process.stdin }).on('line', (l let msg try { msg = JSON.parse(line) } catch { return } if (msg.type === 'create') { - process.stdout.write(JSON.stringify({ type: 'created', requestId: msg.requestId, sessionId: 'teardownConfirmSession01' }) + '\n') + process.stdout.write(JSON.stringify({ type: 'created', requestId: msg.requestId, sessionId: 'teardownConfirmSession01', protocol: 2 }) + '\n') } else if (msg.type === 'shutdown') { process.exit(0) } diff --git a/crates/freshell-server/tests/safe11_term22_shutdown_reaping.rs b/crates/freshell-server/tests/safe11_term22_shutdown_reaping.rs index 79ff509f0..c6ee9bb80 100644 --- a/crates/freshell-server/tests/safe11_term22_shutdown_reaping.rs +++ b/crates/freshell-server/tests/safe11_term22_shutdown_reaping.rs @@ -798,7 +798,7 @@ require('node:readline').createInterface({ input: process.stdin }).on('line', (l if (msg.type === 'create') { created += 1 const sessionId = `wedged-claude-${process.pid}-${created}` - process.stdout.write(JSON.stringify({ type: 'created', requestId: msg.requestId, sessionId }) + '\n') + process.stdout.write(JSON.stringify({ type: 'created', requestId: msg.requestId, sessionId, protocol: 2 }) + '\n') } }) setInterval(() => {}, 1000) diff --git a/crates/freshell-session-host/Cargo.toml b/crates/freshell-session-host/Cargo.toml index d4c01b550..d37bfc377 100644 --- a/crates/freshell-session-host/Cargo.toml +++ b/crates/freshell-session-host/Cargo.toml @@ -32,3 +32,6 @@ tempfile = "3" [dev-dependencies] rusqlite = { version = "0.31", features = ["bundled"] } +# The claude tests run the real sidecar over the CLI simulator through the +# sidecar-environment seam (FreshClaudeState::with_sidecar_environment). +freshell-freshagent = { path = "../freshell-freshagent", features = ["test-support"] } diff --git a/crates/freshell-session-host/src/providers/fresh_agent.rs b/crates/freshell-session-host/src/providers/fresh_agent.rs index 2352ded4f..53e208cd0 100644 --- a/crates/freshell-session-host/src/providers/fresh_agent.rs +++ b/crates/freshell-session-host/src/providers/fresh_agent.rs @@ -151,6 +151,21 @@ impl HostedTransport { Self::new_with_context(provider, None).await } + /// A claude/kilroy transport whose sidecars get exactly `environment` (the + /// freshell-freshagent `test-support` seam): the real sidecar over a simulator. + #[cfg(test)] + async fn with_claude_sidecar_environment( + provider: FreshProvider, + environment: Vec<(String, String)>, + ) -> Arc { + let (broadcast_tx, broadcast_rx) = broadcast::channel(1024); + let broadcast_tx = Arc::new(broadcast_tx); + let state = ProviderState::Claude( + FreshClaudeState::new(Arc::clone(&broadcast_tx)).with_sidecar_environment(environment), + ); + Self::from_parts(provider, state, broadcast_tx, broadcast_rx) + } + async fn new_with_context( provider: FreshProvider, context: Option<&ProviderLaunchContext>, @@ -202,6 +217,17 @@ impl HostedTransport { } } }; + Self::from_parts(provider, state, broadcast_tx, broadcast_rx) + } + + /// The transport around a provider `state` that publishes its frames on + /// `broadcast_tx`; they arrive on `broadcast_rx`. + fn from_parts( + provider: FreshProvider, + state: ProviderState, + broadcast_tx: Arc>, + broadcast_rx: broadcast::Receiver, + ) -> Arc { let (created_tx, created_rx) = watch::channel(None); let (native_tx, native_rx) = watch::channel(None); let (event_tx, event_rx) = mpsc::channel(EVENT_CHANNEL_CAPACITY); @@ -1122,6 +1148,13 @@ fn spawn_broadcast_bridge( kill_outcomes, suppressed_kills, } = outputs; + // claude/kilroy: the native identity is announced once per distinct id + // (design §7.3). `freshAgent.created` names a resumed conversation (the CLI + // booted it before the sidecar answered); a fresh one is named by its first + // turn's init or by `materialized`. Every later turn's init repeats the same + // id and announces nothing. + let claude = matches!(provider, FreshProvider::Claude | FreshProvider::Kilroy); + let mut announced: Option = None; tokio::spawn(async move { let mut dropped = 0u64; loop { @@ -1159,6 +1192,13 @@ fn spawn_broadcast_bridge( } if value.get("type").and_then(Value::as_str) == Some("freshAgent.created") { if let Some(session_id) = value.get("sessionId").and_then(Value::as_str) { + if claude { + if let Some(native_id) = claude_session_ref_id(&value) { + if !announce_native(&mut announced, native_id, &native, &events).await { + break; + } + } + } let _ = created.send(Some(Ok(session_id.to_string()))); if matches!(provider, FreshProvider::Codex) || (matches!(provider, FreshProvider::Opencode) @@ -1171,15 +1211,21 @@ fn spawn_broadcast_bridge( if value.get("type").and_then(Value::as_str) == Some("freshAgent.session.materialized") { if let Some(session_id) = value.get("sessionId").and_then(Value::as_str) { - let _ = native.send(Some(session_id.to_string())); - if events - .send(AgentEvent::Started { - native_session_id: session_id.to_string(), - }) - .await - .is_err() - { - break; + if claude { + if !announce_native(&mut announced, session_id, &native, &events).await { + break; + } + } else { + let _ = native.send(Some(session_id.to_string())); + if events + .send(AgentEvent::Started { + native_session_id: session_id.to_string(), + }) + .await + .is_err() + { + break; + } } } } @@ -1195,15 +1241,22 @@ fn spawn_broadcast_bridge( .or_else(|| event.get("sessionId")) .and_then(Value::as_str) { - let _ = native.send(Some(session_id.to_string())); - if events - .send(AgentEvent::Started { - native_session_id: session_id.to_string(), - }) - .await - .is_err() - { - break; + if claude { + if !announce_native(&mut announced, session_id, &native, &events).await + { + break; + } + } else { + let _ = native.send(Some(session_id.to_string())); + if events + .send(AgentEvent::Started { + native_session_id: session_id.to_string(), + }) + .await + .is_err() + { + break; + } } } } @@ -1251,6 +1304,37 @@ fn spawn_broadcast_bridge( }); } +/// The claude conversation a `freshAgent.created` names in its `sessionRef`. +fn claude_session_ref_id(created: &Value) -> Option<&str> { + let reference = created.get("sessionRef")?; + (reference.get("provider").and_then(Value::as_str) == Some("claude")) + .then(|| reference.get("sessionId").and_then(Value::as_str)) + .flatten() + .filter(|id| !id.is_empty()) +} + +/// Publish `native_id` as the session's native identity and send one +/// `AgentEvent::Started` for it, unless it is the id last announced. False once +/// the event receiver is gone. +async fn announce_native( + announced: &mut Option, + native_id: &str, + native: &watch::Sender>, + events: &mpsc::Sender, +) -> bool { + if announced.as_deref() == Some(native_id) { + return true; + } + *announced = Some(native_id.to_string()); + let _ = native.send(Some(native_id.to_string())); + events + .send(AgentEvent::Started { + native_session_id: native_id.to_string(), + }) + .await + .is_ok() +} + fn parse_sandbox(value: &str) -> Option { match value { "read-only" | "readOnly" | "readonly" => Some(freshell_protocol::Sandbox::ReadOnly), @@ -1495,8 +1579,8 @@ lines.on('line', line=>{ const req=JSON.parse(line); if(req.type==='create'){ writeFileSync(process.env.FRESHELL_TEST_MCP_RECEIPT,JSON.stringify(req)); - process.stdout.write(JSON.stringify({type:'created',requestId:req.requestId,sessionId:'owned-mcp-fixture'})+'\n'); - if(req.model!=='deferred-identity')process.stdout.write(JSON.stringify({type:'sdk.session.init',sessionId:'owned-mcp-fixture',cliSessionId:'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',model:'fixture',cwd:req.cwd,tools:[],slashCommands:[]})+'\n'); + process.stdout.write(JSON.stringify({type:'created',requestId:req.requestId,sessionId:'owned-mcp-fixture',protocol:2})+'\n'); + if(req.model!=='deferred-identity')process.stdout.write(JSON.stringify({type:'sdk.session.init',sessionId:'owned-mcp-fixture',cliSessionId:req.boot?.resume??'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',model:'fixture',cwd:req.cwd,tools:[],slashCommands:[]})+'\n'); } if(req.type==='shutdown')process.exit(0); }); @@ -1554,15 +1638,19 @@ lines.on('line', line=>{ let observed: Value = serde_json::from_slice(&std::fs::read(&receipt).unwrap()).unwrap(); transport.stop().await.unwrap(); - let started = started.expect("Claude create cannot wait for the first model turn"); - if expected_identity.is_some() { - assert!( - started.is_err(), - "an exact resume must reject a different actual native identity" + let started = started + .expect("Claude create cannot wait for the first model turn") + .unwrap(); + if let Some(expected) = expected_identity { + // A resume is named by `created` (the sidecar answers it once the + // CLI booted that conversation), not by a later turn's init. + assert_eq!(started.native_session_id.as_deref(), Some(expected)); + assert_eq!( + observed["boot"], + json!({ "kind": "resume", "resume": expected }), + "the create boots the resumed conversation" ); - continue; } - let started = started.unwrap(); if deferred_identity { assert!( started.native_session_id.is_none(), @@ -1580,6 +1668,163 @@ lines.on('line', line=>{ } } + /// A hosted claude transport running the REAL sidecar over the CLI simulator + /// (`test/fixtures/claude-cli-sim/query.mjs`), with an isolated HOME whose + /// CLAUDE_CONFIG_DIR holds a transcript for each id in `conversations`. + struct SimulatedClaude { + root: tempfile::TempDir, + ledger: std::path::PathBuf, + transport: Arc, + } + + impl SimulatedClaude { + async fn start(conversations: &[&str]) -> Self { + let root = tempfile::tempdir().unwrap(); + let repo = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let home = root.path().join("home"); + let workspace = root.path().join("workspace"); + let config = home.join(".claude"); + let project = config.join("projects").join("-workspace"); + std::fs::create_dir_all(&workspace).unwrap(); + std::fs::create_dir_all(&project).unwrap(); + for id in conversations { + let line = json!({ "type": "user", "uuid": format!("u-{id}"), "parentUuid": null, + "sessionId": id, "cwd": workspace, "message": { "role": "user", "content": "hello" } }); + std::fs::write(project.join(format!("{id}.jsonl")), format!("{line}\n")).unwrap(); + } + let ledger = root.path().join("sim-ledger.jsonl"); + let text = |path: &std::path::Path| path.to_string_lossy().into_owned(); + let environment = vec![ + ("HOME".to_string(), text(&home)), + ( + "PATH".to_string(), + std::env::var("PATH").unwrap_or_default(), + ), + ("LANG".to_string(), "C.UTF-8".to_string()), + ("CLAUDE_CONFIG_DIR".to_string(), text(&config)), + ( + "FRESHELL_CLAUDE_SDK_QUERY_MODULE".to_string(), + text(&repo.join("test/fixtures/claude-cli-sim/query.mjs")), + ), + ("FRESHELL_CLAUDE_SIM_LEDGER".to_string(), text(&ledger)), + ]; + let transport = HostedTransport::with_claude_sidecar_environment( + FreshProvider::Claude, + environment, + ) + .await; + Self { + root, + ledger, + transport, + } + } + + fn profile(&self, resume: Option<&str>) -> FreshAgentProfile { + FreshAgentProfile { + provider: FreshProvider::Claude, + runtime_variant: "freshclaude".into(), + cwd: self.root.path().join("workspace").to_string_lossy().into(), + model: None, + effort: None, + permission_mode: None, + sandbox: None, + provider_store_id: "owned-store".into(), + native_session_id: resume.map(str::to_owned), + plugins: None, + model_selection: None, + session_ref: None, + provider_launch_context: None, + provider_secret_references: vec![], + } + } + + /// The simulator's ledger rows of one kind (`query`, `boot`, `prompt`, ...). + fn ledger_rows(&self, kind: &str) -> Vec { + std::fs::read_to_string(&self.ledger) + .unwrap_or_default() + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .filter(|row| row["kind"] == kind) + .collect() + } + } + + /// The hosted claude resume `start` resolves from `freshAgent.created`'s + /// `sessionRef` (design §7.3): the sidecar answers `created` once the CLI booted + /// the conversation, so no prompt is needed. The bridge announces the native + /// identity once (a turn's init does not announce it again). + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_claude_resume_start_resolves_from_created_with_no_prompt() { + // The sidecar entry is resolved from this process's environment, which + // other tests here set: hold their lock. + let _lock = OPENCODE_SNAPSHOT_ENV_LOCK.lock().await; + let conversation = "4b0e8f6a-1d2c-4e3f-9a5b-6c7d8e9f0a1b"; + let claude = SimulatedClaude::start(&[conversation]).await; + let mut events = claude.transport.take_event_stream().expect("event stream"); + let profile = claude.profile(Some(conversation)); + + let started = + tokio::time::timeout(Duration::from_secs(20), claude.transport.start(&profile)) + .await + .expect("a claude resume start must not wait for a prompt") + .expect("the resume start succeeds"); + assert_eq!(started.native_session_id.as_deref(), Some(conversation)); + assert_eq!( + claude.ledger_rows("boot").len(), + 1, + "the CLI booted the conversation" + ); + assert!( + claude.ledger_rows("prompt").is_empty(), + "no prompt was needed" + ); + + // A turn's init names the same conversation: it is not announced again. + claude + .transport + .dispatch( + &RequestId::parse("resume-turn").unwrap(), + "hello again", + &profile, + ) + .await + .expect("the send is accepted"); + let mut announced = Vec::new(); + tokio::time::timeout(Duration::from_secs(20), async { + while let Some(event) = events.recv().await { + match event { + AgentEvent::Started { native_session_id } => announced.push(native_session_id), + AgentEvent::Provider { payload } + if payload["event"]["type"] == "freshAgent.turn.complete" => + { + break + } + _ => {} + } + } + }) + .await + .expect("the turn completes"); + assert_eq!(announced, vec![conversation.to_string()]); + Arc::clone(&claude.transport).stop().await.unwrap(); + } + + /// A hosted claude resume of a conversation the CLI does not have fails at start: + /// the boot is refused (`create.failed`), so no session is reported started. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_claude_resume_start_of_a_missing_conversation_fails() { + let _lock = OPENCODE_SNAPSHOT_ENV_LOCK.lock().await; + let claude = SimulatedClaude::start(&[]).await; + let profile = claude.profile(Some("9c8b7a6f-5e4d-4c3b-8a2f-1e0d9c8b7a6f")); + let started = + tokio::time::timeout(Duration::from_secs(20), claude.transport.start(&profile)) + .await + .expect("the refused boot answers promptly"); + assert!(started.is_err(), "a refused resume boot fails the start"); + Arc::clone(&claude.transport).stop().await.unwrap(); + } + /// What one hosted Claude configure produced: the actor's answer, the /// durable profile afterwards, every configure line the scripted sidecar /// received, the provider event named `settled_on` once it reached the @@ -1617,7 +1862,7 @@ const lines=createInterface({{input:process.stdin}}); const emit=frame=>process.stdout.write(JSON.stringify(frame)+'\n'); lines.on('line', line=>{{ const req=JSON.parse(line); - if(req.type==='create')emit({{type:'created',requestId:req.requestId,sessionId:'owned-configure'}}); + if(req.type==='create')emit({{type:'created',requestId:req.requestId,sessionId:'owned-configure',protocol:2}}); if(req.type==='configure'){{ appendFileSync(process.env.FRESHELL_TEST_CONFIGURE_RECEIPT,JSON.stringify(req)+'\n'); emit({receipt}); diff --git a/crates/freshell-ws/tests/claude_session_rebind.rs b/crates/freshell-ws/tests/claude_session_rebind.rs index faa8b2437..60430885f 100644 --- a/crates/freshell-ws/tests/claude_session_rebind.rs +++ b/crates/freshell-ws/tests/claude_session_rebind.rs @@ -319,7 +319,7 @@ async fn send_create(ws: &mut common::TestWs, body: serde_json::Value) -> serde_ } /// Minimal fake claude sidecar speaking the newline-JSON protocol: answers -/// `create` with `created` + `sdk.session.init` (echoing resumeSessionId as +/// `create` with `created` + `sdk.session.init` (echoing the resume boot's id as /// the durable cliSessionId), exits on `shutdown`.#[cfg(unix)] const FAKE_CLAUDE_SIDECAR_SOURCE: &str = r#"import readline from 'node:readline' @@ -337,8 +337,8 @@ rl.on('line', (line) => { if (msg.type === 'create') { counter += 1 const sessionId = `fake-claude-session-${process.pid}-${counter}` - process.stdout.write(JSON.stringify({ type: 'created', requestId: msg.requestId, sessionId }) + '\n') - const cliSessionId = msg.resumeSessionId || 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + process.stdout.write(JSON.stringify({ type: 'created', requestId: msg.requestId, sessionId, protocol: 2 }) + '\n') + const cliSessionId = msg.boot?.resume || 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' process.stdout.write(JSON.stringify({ type: 'sdk.session.init', sessionId, cliSessionId, model: 'fake-model', cwd: '/tmp', tools: [] }) + '\n') process.stdout.write(JSON.stringify({ type: 'sdk.status', sessionId, status: 'idle' }) + '\n') } else if (msg.type === 'shutdown') { diff --git a/crates/freshell-ws/tests/common/claude_rig.rs b/crates/freshell-ws/tests/common/claude_rig.rs index c41c88f61..1102a1a39 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, } @@ -300,27 +304,36 @@ impl RigConnection { } } - /// `freshAgent.create` for a freshclaude session in the rig's workspace; returns - /// the session id from `freshAgent.created`. - pub async fn create(&mut self, request_id: &str) -> String { + /// `freshAgent.create` for a freshclaude session in the rig's workspace, with + /// `extra` fields merged in (a resume, say); returns the answer: + /// `freshAgent.created`, `freshAgent.create.failed` or a request `error`. + pub async fn request_create(&mut self, request_id: &str, extra: Value) -> Value { let cwd = path_string(&self.workspace); - self.send(json!({ + let mut frame = json!({ "type": "freshAgent.create", "requestId": request_id, "provider": "claude", "sessionType": "freshclaude", "cwd": cwd, - })) - .await; - let answer = self - .wait_for(&format!("the answer to create {request_id}"), |v| { - v["requestId"] == request_id - && matches!( - v["type"].as_str(), - Some("freshAgent.created" | "freshAgent.create.failed" | "error") - ) - }) - .await; + }); + if let (Some(frame), Value::Object(extra)) = (frame.as_object_mut(), extra) { + frame.extend(extra); + } + self.send(frame).await; + self.wait_for(&format!("the answer to create {request_id}"), |v| { + v["requestId"] == request_id + && matches!( + v["type"].as_str(), + Some("freshAgent.created" | "freshAgent.create.failed" | "error") + ) + }) + .await + } + + /// `freshAgent.create` for a freshclaude session in the rig's workspace; returns + /// the session id from `freshAgent.created`. + pub async fn create(&mut self, request_id: &str) -> String { + let answer = self.request_create(request_id, json!({})).await; assert_eq!( answer["type"], "freshAgent.created", "create failed: {answer}" @@ -392,6 +405,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 +538,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 +557,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 +621,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/cross_kind_liveness.rs b/crates/freshell-ws/tests/cross_kind_liveness.rs index 08f39e99b..734cece52 100644 --- a/crates/freshell-ws/tests/cross_kind_liveness.rs +++ b/crates/freshell-ws/tests/cross_kind_liveness.rs @@ -70,8 +70,8 @@ rl.on('line', (line) => { if (msg.type === 'create') { counter += 1 const sessionId = `fake-claude-session-${process.pid}-${counter}` - process.stdout.write(JSON.stringify({ type: 'created', requestId: msg.requestId, sessionId }) + '\n') - const cliSessionId = msg.resumeSessionId || 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + process.stdout.write(JSON.stringify({ type: 'created', requestId: msg.requestId, sessionId, protocol: 2 }) + '\n') + const cliSessionId = msg.boot?.resume || 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' process.stdout.write(JSON.stringify({ type: 'sdk.session.init', sessionId, cliSessionId, model: 'fake-model', cwd: '/tmp', tools: [] }) + '\n') process.stdout.write(JSON.stringify({ type: 'sdk.status', sessionId, status: 'idle' }) + '\n') } else if (msg.type === 'shutdown') { @@ -2123,7 +2123,7 @@ async fn concurrent_terminal_and_fresh_agent_start_same_session_ref_yield_one_wr let creates = sidecar .create_rows() .iter() - .filter(|r| r["msg"]["resumeSessionId"].as_str() == Some(sid.as_str())) + .filter(|r| r["msg"]["boot"]["resume"].as_str() == Some(sid.as_str())) .count(); let ptys = live_pty_count_for_session(®istry, "claude", &sid); creates + ptys @@ -2205,7 +2205,7 @@ async fn concurrent_terminal_and_fresh_agent_start_same_session_ref_yield_one_wr let sidecar_creates = sidecar .create_rows() .iter() - .filter(|r| r["msg"]["resumeSessionId"].as_str() == Some(sid.as_str())) + .filter(|r| r["msg"]["boot"]["resume"].as_str() == Some(sid.as_str())) .count(); assert!( pty_spawns.load(std::sync::atomic::Ordering::SeqCst) + sidecar_creates <= 1, diff --git a/crates/freshell-ws/tests/freshagent_claude_attach.rs b/crates/freshell-ws/tests/freshagent_claude_attach.rs index d02a93459..565bab14d 100644 --- a/crates/freshell-ws/tests/freshagent_claude_attach.rs +++ b/crates/freshell-ws/tests/freshagent_claude_attach.rs @@ -27,7 +27,7 @@ static CLAUDE_ENV_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(( /// A minimal scripted fake claude sidecar (no real SDK, no network, no cost) speaking /// the SAME newline-JSON protocol `spawn_sidecar()` drives the vendored package with. -/// On `create` it replies `created`, then `sdk.session.init` echoing `resumeSessionId` +/// On `create` it replies `created`, then `sdk.session.init` echoing the resume boot's id /// as the durable `cliSessionId` (resume continuity -- exactly what the real sidecar's /// SDK init does), then `sdk.status idle`; on `send` it emits `sdk.status busy` (the /// Task 10b event-after-rebind knob); on `shutdown` it exits. Every inbound request is @@ -59,8 +59,8 @@ rl.on('line', (line) => { if (msg.type === 'create') { counter += 1 const sessionId = `fake-claude-session-${process.pid}-${counter}` - process.stdout.write(JSON.stringify({ type: 'created', requestId: msg.requestId, sessionId }) + '\n') - const cliSessionId = msg.resumeSessionId || 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + process.stdout.write(JSON.stringify({ type: 'created', requestId: msg.requestId, sessionId, protocol: 2 }) + '\n') + const cliSessionId = msg.boot?.resume || 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' process.stdout.write(JSON.stringify({ type: 'sdk.session.init', sessionId, cliSessionId, model: 'fake-model', cwd: '/tmp', tools: [] }) + '\n') process.stdout.write(JSON.stringify({ type: 'sdk.status', sessionId, status: 'idle' }) + '\n') } else if (msg.type === 'send') { @@ -343,7 +343,7 @@ async fn claude_attach_for_untracked_session_emits_lost_session_frame_over_ws() /// Restart parity (Task 6): an attach for an untracked session that DOES carry a /// durable claude UUID with a resumable transcript must be resumed in place -- the -/// server spawns a sidecar with `resumeSessionId` and emits the idle +/// server spawns a sidecar with a resume boot and emits the idle /// `freshAgent.session.snapshot` whose `timelineSessionId` is the durable UUID (the /// frozen client persists it unvalidated -- NEVER a nanoid), all under the CLIENT's /// original session id. Before the fix this attach produced the lost frame instead diff --git a/crates/freshell-ws/tests/freshagent_claude_checkpointed_send.rs b/crates/freshell-ws/tests/freshagent_claude_checkpointed_send.rs index eb3ccc662..71a50f4f7 100644 --- a/crates/freshell-ws/tests/freshagent_claude_checkpointed_send.rs +++ b/crates/freshell-ws/tests/freshagent_claude_checkpointed_send.rs @@ -75,7 +75,7 @@ rl.on('line', (line) => { const sessionId = `fake-claude-${process.pid}-${counter}` const st = { cliSessionId: randomUUID() } sessions.set(sessionId, st) - emit({ type: 'created', requestId: msg.requestId, sessionId }) + emit({ type: 'created', requestId: msg.requestId, sessionId, protocol: 2 }) if (initAt === 'create') reportSession(sessionId, st) emit({ type: 'sdk.status', sessionId, status: 'idle' }) } else if (msg.type === 'send') { diff --git a/crates/freshell-ws/tests/freshagent_claude_kill_after_restart.rs b/crates/freshell-ws/tests/freshagent_claude_kill_after_restart.rs index 8016f57ee..f16d50abc 100644 --- a/crates/freshell-ws/tests/freshagent_claude_kill_after_restart.rs +++ b/crates/freshell-ws/tests/freshagent_claude_kill_after_restart.rs @@ -50,7 +50,7 @@ static CLAUDE_ENV_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(( /// A minimal scripted fake claude sidecar (production wire protocol only): /// `create` answers `created` with a fresh placeholder and then emits -/// `sdk.session.init` echoing `resumeSessionId` as the durable cli id (the +/// `sdk.session.init` echoing the resume boot's id as the durable cli id (the /// resume-continuity shape) so the ADOPTION path runs its real fold — /// cli_index insert, binding write, and (round 6) the alias-tombstone mint /// write. `shutdown` exits (the kill's graceful teardown drives it). @@ -72,8 +72,8 @@ rl.on('line', (line) => { if (msg.type === 'create') { counter += 1 const sessionId = `fake-claude-kar-${process.pid}-${counter}` - process.stdout.write(JSON.stringify({ type: 'created', sessionId }) + '\n') - const cliSessionId = msg.resumeSessionId || '00000000-0000-4000-8000-0000000000ff' + process.stdout.write(JSON.stringify({ type: 'created', sessionId, protocol: 2 }) + '\n') + const cliSessionId = msg.boot?.resume || '00000000-0000-4000-8000-0000000000ff' console.log(JSON.stringify({ type: 'sdk.session.init', sessionId, cliSessionId, model: 'fake-model', cwd: '/tmp', tools: [] })) console.log(JSON.stringify({ type: 'sdk.status', sessionId, status: 'idle' })) } else if (msg.type === 'shutdown') { diff --git a/crates/freshell-ws/tests/freshagent_claude_kill_interrupt.rs b/crates/freshell-ws/tests/freshagent_claude_kill_interrupt.rs index 2894a497a..8ba66f76c 100644 --- a/crates/freshell-ws/tests/freshagent_claude_kill_interrupt.rs +++ b/crates/freshell-ws/tests/freshagent_claude_kill_interrupt.rs @@ -81,7 +81,7 @@ rl.on('line', (line) => { if (msg.type === 'create') { counter += 1 const sessionId = `fake-claude-session-${process.pid}-${counter}` - process.stdout.write(JSON.stringify({ type: 'created', requestId: msg.requestId, sessionId }) + '\n') + process.stdout.write(JSON.stringify({ type: 'created', requestId: msg.requestId, sessionId, protocol: 2 }) + '\n') } else if (msg.type === 'interrupt') { const log = process.env.FRESHELL_TEST_CLAUDE_INTERRUPT_LOG if (log) fs.appendFileSync(log, `${msg.sessionId}\n`) 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..16e0a472e --- /dev/null +++ b/crates/freshell-ws/tests/freshagent_claude_lifecycle.rs @@ -0,0 +1,606 @@ +//! 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. +//! +//! 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. +//! +//! A session starts only once its CLI booted (C10): the sidecar answers `created` +//! after the boot, so a boot the CLI refuses is a typed `freshAgent.create.failed`, +//! never a `freshAgent.created` for a session that is already dead, and a resume's +//! `created` names the resumed conversation in `sessionRef`. +//! +//! 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 `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( + 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("exited"), + "the session's last status is exited; 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; + sigkill(pid); + + 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, 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 = + 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 + ); + 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 + ); +} + +// ── Boot (C10): `created` only after the CLI booted; typed boot failures ────────── + +/// The pid of the sidecar process of the `n`th query() the simulator ran in this rig +/// (1-based; every query() writes a `query` row, booted or not). +async fn query_pid(rig: &ClaudeRig, n: usize) -> i32 { + let deadline = tokio::time::Instant::now() + RIG_FRAME_BUDGET; + loop { + if let Some(row) = rig.ledger_rows("query").get(n - 1) { + return row["pid"].as_i64().expect("the query row carries a pid") as i32; + } + assert!( + tokio::time::Instant::now() < deadline, + "the sidecar never made query() #{n}" + ); + tokio::time::sleep(Duration::from_millis(20)).await; + } +} + +/// The create frame's field that resumes `durable`, as the client sends it. +fn resume_fields(durable: &str) -> Value { + json!({ "sessionRef": { "provider": "claude", "sessionId": durable } }) +} + +/// A resume create of a conversation the CLI does not have is refused at create +/// (T5): one typed `freshAgent.create.failed` with the CLI's own sentence, never a +/// `freshAgent.created` for a session that is already dead, and no sidecar is left +/// behind. On both rigs: the simulator, and the pinned CLI itself. +async fn a_resume_create_of_a_missing_conversation_fails_typed(kind: RigKind) { + let rig = ClaudeRig::start(kind).await; + let mut conn = rig.connect().await; + let missing = uuid::Uuid::new_v4().to_string(); + + let answer = conn + .request_create("create-missing", resume_fields(&missing)) + .await; + assert_eq!(answer["type"], "freshAgent.create.failed", "{answer}"); + assert_eq!(answer["code"], "CONVERSATION_NOT_FOUND", "{answer}"); + assert_eq!( + answer["message"], + format!("No conversation found with session ID: {missing}"), + "{answer}" + ); + if kind == RigKind::Sim { + wait_until_gone(query_pid(&rig, 1).await).await; + } + + conn.read_for(QUIET).await; + assert!( + !conn + .frames + .iter() + .any(|v| v["type"] == "freshAgent.created" || v["type"] == "freshAgent.event"), + "nothing of the refused boot reaches the client; frames: {:?}", + conn.frames + ); + assert!(!rig.fresh_claude.has_live_session(&missing).await); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_resume_create_of_a_missing_conversation_fails_typed_and_leaves_no_sidecar() { + a_resume_create_of_a_missing_conversation_fails_typed(RigKind::Sim).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_resume_create_of_a_missing_conversation_fails_typed_on_the_pinned_cli() { + a_resume_create_of_a_missing_conversation_fails_typed(RigKind::Cli).await; +} + +/// A CLAUDE_CMD that is not the CLI fails the create (the pinned SDK cannot spawn +/// it, so no request is ever made): a typed `freshAgent.create.failed` naming the +/// path, not a `freshAgent.created` followed by the session's death. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_claude_cmd_that_is_not_the_cli_fails_the_create_typed() { + let rig = + ClaudeRig::start_with_env(RigKind::Cli, &[("CLAUDE_CMD", "/nonexistent/claude")]).await; + let mut conn = rig.connect().await; + + let answer = conn.request_create("create-bad-cmd", json!({})).await; + assert_eq!(answer["type"], "freshAgent.create.failed", "{answer}"); + assert_eq!(answer["code"], "BOOT_FAILED", "{answer}"); + assert!( + answer["message"] + .as_str() + .is_some_and(|message| message.contains("/nonexistent/claude")), + "the failure names the command: {answer}" + ); + conn.read_for(QUIET).await; + assert!( + !conn + .frames + .iter() + .any(|v| v["type"] == "freshAgent.created" || v["type"] == "freshAgent.event"), + "frames: {:?}", + conn.frames + ); +} + +/// A resume create is answered only once the CLI booted the conversation (no prompt +/// needed, T2), and `freshAgent.created` names the resumed conversation in +/// `sessionRef`, so a caller learns the native identity without waiting for a turn. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_resume_create_answers_created_with_its_session_ref_once_the_cli_booted() { + 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", "a turn to resume") + .await; + conn.wait_for_event("freshAgent.turn.complete").await; + let durable = durable_id(&conn, &session_id); + rig.wait_for_transcripts().await; + conn.kill(&session_id).await; + + let answer = conn + .request_create("create-resume", resume_fields(&durable)) + .await; + assert_eq!(answer["type"], "freshAgent.created", "{answer}"); + // The simulated CLI records its boot before initializationResult() resolves. + let boots = rig.ledger_rows("boot"); + assert_eq!( + boots.len(), + 2, + "the resumed CLI booted before created: {boots:?}" + ); + assert_eq!(boots[1]["sessionId"], durable.as_str(), "{boots:?}"); + assert_eq!( + answer["sessionRef"], + json!({ "provider": "claude", "sessionId": durable }), + "{answer}" + ); + assert!(rig.ledger_rows("prompt").len() == 1, "no prompt was needed"); +} + +// ── Registration: a session is in the map before its consumer reads a line ──────── + +/// How long the server is made to take between starting a session's consumer and +/// registering the session (the production resume create awaits an identity-sink +/// write there; the rig has no sink). Longer than the sidecar takes to write the +/// boot catalog, or a CLI death 300 ms after its boot, so both land inside it. +const REGISTRATION_HOLD: Duration = Duration::from_millis(1500); + +/// The session's snapshot overlay once `ready` holds for it (bounded wait). +async fn overlay_once(rig: &ClaudeRig, id: &str, ready: impl Fn(&Value) -> bool) -> Value { + let deadline = tokio::time::Instant::now() + Duration::from_secs(10); + loop { + let overlay = rig.fresh_claude.live_snapshot_overlay(id).await; + if ready(&overlay) { + return overlay; + } + assert!( + tokio::time::Instant::now() < deadline, + "the snapshot overlay of {id} never got there: {overlay}" + ); + tokio::time::sleep(Duration::from_millis(50)).await; + } +} + +/// The command catalog the sidecar publishes right after `created` reaches the +/// session's live state (its snapshot's `commands`) with no turn sent, for a fresh +/// and for a resume create, however long the server takes to register the session. +/// A consumer that read the catalog line before its session was registered dropped +/// it, and the slash menu then had only the built-in entries (review PRB-C10). +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn the_boot_catalog_reaches_the_live_state_however_long_the_registration_takes() { + 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", "a turn to resume") + .await; + conn.wait_for_event("freshAgent.turn.complete").await; + let durable = durable_id(&conn, &session_id); + rig.wait_for_transcripts().await; + conn.kill(&session_id).await; + + rig.fresh_claude + .hold_session_registration(REGISTRATION_HOLD); + for (request_id, extra) in [ + ("create-fresh", json!({})), + ("create-resume", resume_fields(&durable)), + ] { + let answer = conn.request_create(request_id, extra).await; + assert_eq!(answer["type"], "freshAgent.created", "{answer}"); + let id = answer["sessionId"].as_str().expect("created carries an id"); + let overlay = overlay_once(&rig, id, |overlay| overlay["commands"].is_array()).await; + assert!( + overlay["commands"] + .as_array() + .is_some_and(|commands| commands.iter().any(|c| c["name"] == "compact")), + "{request_id}: {overlay}" + ); + } + assert_eq!( + rig.ledger_rows("prompt").len(), + 1, + "no turn was sent after the first" + ); +} + +/// A CLI that dies right after it booted, while the server is still registering +/// the session, still ends the session: the exit report arrives and the session +/// reads as exited, never as a live session nobody will ever end (review PRB-C10). +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_cli_death_while_the_session_registers_still_ends_the_session() { + let rig = + rig_with_program(json!([{ "on": "start", "emit": [{ "kind": "crash", "delayMs": 300 }] }])) + .await; + rig.fresh_claude + .hold_session_registration(REGISTRATION_HOLD); + 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}"); + let overlay = overlay_once(&rig, &session_id, |overlay| overlay["status"].is_string()).await; + assert_eq!(overlay["status"], "exited", "{overlay}"); +} diff --git a/crates/freshell-ws/tests/freshagent_claude_rollback.rs b/crates/freshell-ws/tests/freshagent_claude_rollback.rs index 74810d590..ee7be32e7 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' @@ -104,11 +105,11 @@ rl.on('line', (line) => { placeholderCounter += 1 const sessionId = `fake-sidecar-${process.pid}-${placeholderCounter}` fs.mkdirSync(projDir, { recursive: true }) - if (msg.forkSession === true) { + if (msg.boot?.kind === 'fork') { // Fork-at-point: NEW durable id, child file = the parent transcript's prefix. - const src = msg.resumeSessionId + const src = msg.boot?.resume let lines = readLines(src) - if (msg.resumeSessionAt) lines = prefixThrough(lines, msg.resumeSessionAt) + if (msg.boot?.resumeSessionAt) lines = prefixThrough(lines, msg.boot?.resumeSessionAt) cliId = mintCliId() turnCount = 0 fs.writeFileSync(transcriptPath(cliId), lines.length ? `${lines.join('\n')}\n` : '') @@ -117,7 +118,7 @@ rl.on('line', (line) => { turnCount = 0 fs.writeFileSync(transcriptPath(cliId), '') } - process.stdout.write(JSON.stringify({ type: 'created', requestId: msg.requestId, sessionId }) + '\n') + process.stdout.write(JSON.stringify({ type: 'created', requestId: msg.requestId, sessionId, protocol: 2 }) + '\n') process.stdout.write(JSON.stringify({ type: 'sdk.session.init', sessionId, cliSessionId: cliId, model: 'fake-model', cwd: '/tmp', tools: [] }) + '\n') process.stdout.write(JSON.stringify({ type: 'sdk.status', sessionId, status: 'idle' }) + '\n') } else if (msg.type === 'send') { @@ -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; } @@ -920,16 +921,16 @@ async fn setup_and_undo_two_turn_conversation() -> ( let creates = rig.create_lines(); let fork_create = creates .iter() - .find(|l| l["forkSession"] == json!(true)) + .find(|l| l["boot"]["kind"] == json!("fork")) .expect("fork create observed"); - assert_eq!(fork_create["resumeSessionId"], json!(original)); + assert_eq!(fork_create["boot"]["resume"], json!(original)); assert_eq!( - fork_create["resumeSessionAt"], + fork_create["boot"]["resumeSessionAt"], json!("a1"), "keep prefix through a1" ); assert_eq!( - fork_create["resumeDropsTurn"], + fork_create["boot"]["dropsTurn"], json!("u2"), "the guard is the first-to-discard turn's prompt uuid" ); @@ -1029,15 +1030,15 @@ async fn claude_redo_reforks_from_the_original_at_a_later_point() { let creates = rig.create_lines(); let fork_creates: Vec<&Value> = creates .iter() - .filter(|l| l["forkSession"] == json!(true)) + .filter(|l| l["boot"]["kind"] == json!("fork")) .collect(); assert_eq!(fork_creates.len(), 2, "{creates:?}"); let redo_create = fork_creates[1]; - assert_eq!(redo_create["resumeSessionId"], json!(original)); - assert_eq!(redo_create["resumeSessionAt"], json!("a2")); - assert_eq!(redo_create["forkSession"], json!(true)); + assert_eq!(redo_create["boot"]["resume"], json!(original)); + assert_eq!(redo_create["boot"]["resumeSessionAt"], json!("a2")); + assert_eq!(redo_create["boot"]["kind"], json!("fork")); assert!( - redo_create.get("resumeDropsTurn").is_none(), + redo_create["boot"].get("dropsTurn").is_none(), "the guard is omitted when the discard range is empty (redo to the tip)" ); @@ -1107,17 +1108,11 @@ async fn claude_undo_of_the_only_turn_empties_the_conversation_via_a_fresh_creat "the initial create + the rollback's FRESH create: {creates:?}" ); let fresh_create = &creates[1]; - for key in [ - "resumeSessionId", - "resumeSessionAt", - "forkSession", - "resumeDropsTurn", - ] { - assert!( - fresh_create.get(key).is_none(), - "the fresh-conversation leg carries NO resume/fork keys ({key} absent): {fresh_create}" - ); - } + assert_eq!( + fresh_create["boot"], + json!({ "kind": "fresh" }), + "the fresh-conversation leg boots a new conversation, with NO resume/fork keys: {fresh_create}" + ); // The record retained the discarded session as the redo source. let rows = rollback_rows(&rig.ledger_dir()); @@ -1159,10 +1154,10 @@ async fn claude_undo_of_the_only_turn_empties_the_conversation_via_a_fresh_creat let creates = rig.create_lines(); assert_eq!(creates.len(), 3, "{creates:?}"); let redo_create = &creates[2]; - assert_eq!(redo_create["forkSession"], json!(true)); - assert_eq!(redo_create["resumeSessionId"], json!(original)); + assert_eq!(redo_create["boot"]["kind"], json!("fork")); + assert_eq!(redo_create["boot"]["resume"], json!(original)); assert_eq!( - redo_create["resumeSessionAt"], + redo_create["boot"]["resumeSessionAt"], json!("a1"), "the r3 boundary rule — resume at a1, NOT u1" ); @@ -1259,11 +1254,17 @@ async fn claude_undo_with_an_unreadable_store_is_internal_error_not_nothing_to_u json!("INTERNAL_ERROR"), "an unreadable store is a retryable failure, not an empty conversation: {refusal}" ); + // The create log holds protocol-2 creates, whose boot names the kind: a + // fork is `boot.kind == "fork"` (a v1 `forkSession` key is never written, + // so reading it would pass whatever ran). + let creates = rig.create_lines(); assert!( - rig.create_lines() - .iter() - .all(|l| l["forkSession"] != json!(true)), - "no fork may run against an unchecked store" + !creates.is_empty() && creates.iter().all(|l| l["boot"]["kind"].is_string()), + "every logged create names its boot kind: {creates:?}" + ); + assert!( + creates.iter().all(|l| l["boot"]["kind"] != json!("fork")), + "no fork may run against an unchecked store: {creates:?}" ); assert!( rollback_rows(&rig.ledger_dir()).is_empty(), @@ -1376,7 +1377,7 @@ async fn claude_undo_forks_from_the_transcript_it_located_even_if_the_store_stop wait_until(15, "the fork create", || { rig.create_lines() .iter() - .any(|l| l["forkSession"] == json!(true)) + .any(|l| l["boot"]["kind"] == json!("fork")) }) .await; drop(locked); @@ -1384,10 +1385,10 @@ async fn claude_undo_forks_from_the_transcript_it_located_even_if_the_store_stop let creates = rig.create_lines(); let fork_create = creates .iter() - .find(|l| l["forkSession"] == json!(true)) + .find(|l| l["boot"]["kind"] == json!("fork")) .expect("fork create observed"); - assert_eq!(fork_create["resumeSessionId"], json!(original)); - assert_eq!(fork_create["resumeSessionAt"], json!("a1")); + assert_eq!(fork_create["boot"]["resume"], json!(original)); + assert_eq!(fork_create["boot"]["resumeSessionAt"], json!("a1")); assert_eq!( fork_create["cwd"], json!("/tmp"), diff --git a/crates/freshell-ws/tests/freshagent_session_lease.rs b/crates/freshell-ws/tests/freshagent_session_lease.rs index 34a636bc6..b7cfc4b72 100644 --- a/crates/freshell-ws/tests/freshagent_session_lease.rs +++ b/crates/freshell-ws/tests/freshagent_session_lease.rs @@ -151,8 +151,8 @@ rl.on('line', (line) => { counter += 1 const sessionId = `fake-claude-session-${process.pid}-${counter}` const reply = () => { - process.stdout.write(JSON.stringify({ type: 'created', requestId: msg.requestId, sessionId }) + '\n') - const cliSessionId = msg.resumeSessionId || 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + process.stdout.write(JSON.stringify({ type: 'created', requestId: msg.requestId, sessionId, protocol: 2 }) + '\n') + const cliSessionId = msg.boot?.resume || 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' process.stdout.write(JSON.stringify({ type: 'sdk.session.init', sessionId, cliSessionId, model: 'fake-model', cwd: '/tmp', tools: [] }) + '\n') process.stdout.write(JSON.stringify({ type: 'sdk.status', sessionId, status: 'idle' }) + '\n') } diff --git a/crates/freshell-ws/tests/pane_reconcile_freshagent.rs b/crates/freshell-ws/tests/pane_reconcile_freshagent.rs index e09625aba..30c157610 100644 --- a/crates/freshell-ws/tests/pane_reconcile_freshagent.rs +++ b/crates/freshell-ws/tests/pane_reconcile_freshagent.rs @@ -63,7 +63,7 @@ impl freshell_ws::existence::SessionExistenceProbe for StubProbe { /// A minimal scripted fake claude sidecar (no real SDK, no network, no cost) /// speaking the SAME newline-JSON protocol `spawn_sidecar()` drives the /// vendored package with. On `create` it replies `created`, then -/// `sdk.session.init` echoing `resumeSessionId` (or a fixed durable UUID the +/// `sdk.session.init` echoing the resume boot's id (or a fixed durable UUID the /// tests control) as the durable `cliSessionId`, then `sdk.status idle`; on /// `shutdown` it exits. const FAKE_CLAUDE_SIDECAR_SOURCE: &str = r#" @@ -83,8 +83,8 @@ rl.on('line', (line) => { if (msg.type === 'create') { counter += 1 const sessionId = `fake-claude-session-${process.pid}-${counter}` - process.stdout.write(JSON.stringify({ type: 'created', requestId: msg.requestId, sessionId }) + '\n') - const cliSessionId = msg.resumeSessionId || 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + process.stdout.write(JSON.stringify({ type: 'created', requestId: msg.requestId, sessionId, protocol: 2 }) + '\n') + const cliSessionId = msg.boot?.resume || 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' process.stdout.write(JSON.stringify({ type: 'sdk.session.init', sessionId, cliSessionId, model: 'fake-model', cwd: '/tmp', tools: [] }) + '\n') process.stdout.write(JSON.stringify({ type: 'sdk.status', sessionId, status: 'idle' }) + '\n') } else if (msg.type === 'shutdown') { diff --git a/crates/freshell-ws/tests/rest_claude_identity.rs b/crates/freshell-ws/tests/rest_claude_identity.rs index b5ab0bd57..cfef38980 100644 --- a/crates/freshell-ws/tests/rest_claude_identity.rs +++ b/crates/freshell-ws/tests/rest_claude_identity.rs @@ -592,8 +592,8 @@ rl.on('line', (line) => { if (msg.type === 'create') { counter += 1 const sessionId = `fake-claude-session-${process.pid}-${counter}` - process.stdout.write(JSON.stringify({ type: 'created', requestId: msg.requestId, sessionId }) + '\n') - const cliSessionId = msg.resumeSessionId || 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + process.stdout.write(JSON.stringify({ type: 'created', requestId: msg.requestId, sessionId, protocol: 2 }) + '\n') + const cliSessionId = msg.boot?.resume || 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' process.stdout.write(JSON.stringify({ type: 'sdk.session.init', sessionId, cliSessionId, model: 'fake-model', cwd: '/tmp', tools: [] }) + '\n') process.stdout.write(JSON.stringify({ type: 'sdk.status', sessionId, status: 'idle' }) + '\n') } else if (msg.type === 'shutdown') { @@ -800,7 +800,7 @@ async fn rest_ws_and_browser_paths_share_the_one_coordinator() { let sidecar_creates = sidecar .create_rows() .iter() - .filter(|r| r["msg"]["resumeSessionId"].as_str() == Some(sid.as_str())) + .filter(|r| r["msg"]["boot"]["resume"].as_str() == Some(sid.as_str())) .count(); assert_eq!(sidecar_creates, 1, "exactly the one freshclaude owner"); 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 5ae9bf5a9..643fdddfc 100644 --- a/src/components/fresh-agent/FreshAgentView.tsx +++ b/src/components/fresh-agent/FreshAgentView.tsx @@ -490,6 +490,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) } @@ -3069,6 +3078,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 @@ -3080,6 +3090,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 ( @@ -3919,6 +3942,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 @@ -4585,9 +4616,7 @@ export function FreshAgentView({
This session has ended{sessionErrorMessage ? '' : ' (the agent process exited)'}.
- {paneContent.provider === 'codex' - && effectiveStatus === 'exited' - && getCanonicalCodexResumeSessionId(paneContent) ? ( + {effectiveStatus === 'exited' && endedSessionResumeId ? (