Repository navigation
feat(freshclaude): lifecycle truth — the sidecar exits when its stream ends, exited status with Resume, and created only after the CLI boots (core PR-B) - #865
Merged
Conversation
…exits 0
The simulator turned every CLI exit into a thrown "Claude Code process
exited with code N" error, including code 0. The pinned SDK does not: its
process transport reports no error for exit code 0 (getProcessExitError in
sdk.mjs), so the message iterator simply ends. That natural end of stream is
the case the sidecar must handle as a session end (G23, design C08), and the
simulator could not produce it.
Now a `crash { code: 0 }` step ends the stream with no error and no result,
like the real SDK. Other codes and signals still throw the SDK's messages.
Evidence, from the pinned CLI 2.1.237 against the local endpoint (probes run
through claude-cli-scenarios.mjs, not committed): SIGINT mid-turn makes the
CLI report an aborted result, exit 0, and the iterator end with no error;
SIGTERM mid-turn makes it exit 143 and the iterator throw "Claude Code
process exited with code 143".
Red: claude-cli-sim.test.ts "a CLI that exits with code 0 mid-turn ends the
stream cleanly" failed with { error: 'Claude Code process exited with code 0' }.
Green: claude-cli-sim.test.ts and test/unit/claude-sidecar (117 tests); the
real-CLI contract and conformance suite still passes (39 tests).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Design C08 (docs/plans/2026-10-22-freshclaude-core-runtime.md §5.1, §5.7,
§10 PR-B). Each sidecar hosts one session, but when that session's SDK
stream ended (the CLI died, or it exited 0) the Node sidecar stayed alive
with no session: the server never evicted it, later sends got "session not
found", and nothing recovered (G02). And the server assumed every turn ends
with a result plus a trailing idle, so a stream that ended mid-turn after an
earlier result left the session busy forever (G23).
Sidecar (crates/freshell-claude-sidecar):
- consumeStream's finally: on an end nobody asked for, cancel the parked
cards, ring a result-less turn end (interim, until C18), send the
stream-end idle (interim, until the exited status of C09 replaces it),
then `sdk.stream.ended {sessionId, reason, message}` as the LAST line, and
exit with code 3 once stdout and stderr have flushed. Requests that arrive
after that are ignored. A requested teardown (shutdown, SIGTERM, stdin
closing) exits 0 and reports nothing.
- New pure stream-end.mjs: reason `cli_exited` (the SDK's "Claude Code
process terminated by signal …" / "… exited with code …"), `stream_error`
(anything else thrown), `stream_eof` (no error); the message is cut to
1000 characters. The `stream.ended` log record uses the same reasons
(warn with the error, info for an end of stream).
- The catch's `sdk.error "SDK error: …"` is gone: the cause rides
sdk.stream.ended and the server's exit report, so the user sees one banner.
Server (crates/freshell-freshagent):
- Deleted `result_idle_pair_pending`, its doc comment, its plumbing through
create, attach-resume and rollback, and its tests. An idle now always
folds as a terminal edge; the real sidecar only sends one at its stream
end.
- The consumer keeps the sidecar's `sdk.stream.ended` (never broadcast) and
the end-of-output arm now reaps the evicted sidecar for its exit status
(no zombie left) and reports `freshAgent.error{SIDECAR_EXITED}` with a
`cause` ({reason, message} from the stream end, or for a Node death
{reason: sidecar_exited, message, exitCode | signal}); the message names
the cause. Logged as `freshagent.claude.sidecar_exit` (warn: reason,
cause, exit status, turn in flight, rings).
- Interim ring rule (§5.7): the server rings only when no stream end was
reported (a Node death mid-turn); the sidecar already rang a reported one.
Exactly one edge either way.
- New small module claude_sidecar_exit.rs (StreamEnded, SidecarExitCause,
death_rings, reap) with unit tests.
Fakes stop emitting a per-turn idle (they now end a turn the way the real
sidecar does, with its result): the in-crate fake's interrupt settle, the
e2e fake claude-SDK sidecar (completion and interrupt), the rollback test
fake (sent idle only; now a result, and the test waits for it) and the e2e
fake-claude-sidecar.mjs (sent turn.complete + idle; now result + edge). The
in-crate tests that injected a result plus a trailing idle inject the
result alone. The e2e fake's stream-error lane now models the real end of
stream and exits 3; its unit tests are rewritten to that behavior.
Tests (red first):
- test/unit/claude-sidecar/stream-end.test.ts (new): a simulator CLI crash,
a natural end mid-turn (CLI exit 0) and a non-death stream error each end
with sdk.stream.ended last and exit code 3 (red: "the sidecar did not exit
within 10000 ms", 4 cases); an idle CLI death exits with no edge; shutdown
and stdin closing mid-turn exit 0 with nothing alert-related; streamEnd on
the exact SDK strings.
- crates/freshell-ws/tests/freshagent_claude_lifecycle.rs (new, RealSidecarSim):
a CLI crash mid-turn gives exactly one turn.complete and SIDECAR_EXITED
with cause cli_exited, and the sidecar process is gone; a natural end of
stream mid-turn ends with status idle and no live session (not busy); a
Node SIGKILL mid-turn rings exactly once with cause sidecar_exited/SIGKILL;
an idle CLI death reports the exit with no edge. Red on the parent commit:
the three stream-end cases time out waiting for SIDECAR_EXITED (the
sidecar never exits), the Node death has no cause.
- claude.rs a_stream_end_idle_after_an_earlier_result_releases_the_busy_truth
(new, G23): red on the parent commit ("in_turn never became false").
- Rewritten to the new protocol: sidecar-log.test.ts (reason cli_exited for
a SIGKILL; new: an end of stream logs at info with no error),
interrupt-idle-gate.test.ts (waits for sdk.stream.ended, not sdk.error),
fake-claude-sdk-sidecar-control.test.ts (no per-turn idle; the
stream-error lane exits 3), and the e2e STREAM_ERROR lane in
fresh-agent-error-turn-attention.spec.ts now expects one edge, the
SIDECAR_EXITED error on the pane, sdk.stream.ended last and the sidecar
process gone.
- The rig keeps its server's claude lane (`ClaudeRig::fresh_claude`) and
gains `RigConnection::read_for` for bounded "nothing more arrives" checks.
Green: cargo test -p freshell-ws (1205 passed); cargo test -p
freshell-freshagent --lib (1335 passed; 1 failure,
session_handoff::tests::an_opencode_placeholder_handoff_resolves_the_durable_owner_and_completes,
passes alone: a pre-existing HOME race with claude_snapshot.rs tests that
remove HOME); vitest test/unit/claude-sidecar + test/unit/sidecar (all
pass); e2e helpers fake-claude-sdk-sidecar-control (11 passed); clippy -D
warnings and fmt clean.
Deviations from the design text:
- The stream-end `sdk.status idle` is still sent before sdk.stream.ended
(staging rule §10.2): today it is what takes a compacting pane out of its
busy state; C09's exited status replaces it.
- `sdk.exit` stays on the requested (abort) path until C18 removes it.
- The server reaps the sidecar to read its exit status, which the design's
"or the exit status" cause needs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Follow-up to C08 (the fakes stop emitting a per-turn idle). The claude
lane's "approval: Deny …" test in fresh-agent-control-rust.spec.ts waited
for the fake sidecar's trailing `sdk.status idle` after the denied turn's
errored result and used it as the fence for the exactly-one-edge check. The
real sidecar ends a turn with its result alone (it sends idle only when its
stream ends), and so does the fake now, so the wait timed out on the cloud
backend ("the errored sdk.result + unified edge + trailing idle marker for
the denied turn", 3 of 3 attempts).
The lane now waits for the errored result and the edge after it, still
asserts exactly one edge for the denied turn (fenced before the follow-up
send), and asserts that no per-turn idle follows the result.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A prerequisite for C09's mid-compaction death test (design §10 PR-B): the
simulator's /compact finished after 25 ms, so a test could not kill the
sidecar while it was compacting without racing that window.
Now `/compact HANG` keeps the compaction in progress (status `compacting`,
no result) until the CLI dies, the way the real CLI behaves when the local
provider's HANG mode holds its summary request. The instructions reach the
summary request on the real CLI: `/compact APIERR:400` already fails the
compaction there (T15, scenario `compact`). What the real CLI does when a
hung compaction is interrupted is not recorded, so an interrupt fails the
simulator loudly ("not modeled") instead of guessing.
Red: claude-cli-sim.test.ts "a /compact whose summary request hangs ...
stays compacting with no result" failed (the last status was null: the
compaction completed).
Green: claude-cli-sim.test.ts 17/17; claude-cli-contract.test.ts (real CLI
plus conformance) 39/39.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Design C09 (docs/plans/2026-10-22-freshclaude-core-runtime.md §3.3, §5.7,
§6, §8, §10 PR-B). When a claude sidecar ended unrequested, the server sent
only the SIDECAR_EXITED banner: a pane that was compacting stayed busy
forever, the composer stayed enabled, the next send vanished, there was no
Resume, and the session's snapshot fell back to a disk `idle` the client
could not trust (G05).
Server (crates/freshell-freshagent):
- New small module claude_dead_sessions.rs: DeadClaudeSession (every id the
session answered to — map key, envelope stamp, durable ids — plus its
settings, commands, the end reason and time) and DeadClaudeSessions, a
store bounded at 256 deaths (oldest forgotten first; a second death
replaces the first record).
- The end-of-output path records the evicted session (before the reap's
wait, so a resume that lands meanwhile forgets it), then broadcasts
`freshAgent.status{exited}` (unless that resume already happened), then
SIDECAR_EXITED, then the death edge when owed (unchanged interim rule).
- The snapshot overlay (apply_snapshot_metadata) answers a dead id with
`exited`, statusFromLiveState and the session's last settings/commands.
- handle_send on a dead id (map key, stamp or durable id): the nested
`INVALID_SESSION_ID` lost frame plus a request error (code
INVALID_SESSION_ID, "The Claude agent process for this session exited.
Resume the session to continue."); never accepted. Other unknown ids keep
SESSION_NOT_FOUND (C20/C21 change those).
- A create that resumes the durable id, an attach-resume, or a kill forgets
the record. has_live_session stays live-only (false for a dead id).
- Logs: freshagent.claude.dead_session_recorded / _dropped / _forgotten
(info), freshagent.claude.send_to_dead_session (warn).
Sidecar: the interim stream-end `sdk.status idle` is gone (the staging rule:
its consumer, the exited status, switches in this commit). The e2e fake's
stream-error lane drops it too.
Client:
- The session-ended card offers "Resume session" for claude/kilroy (any
provider with a canonical durable id; triggerRecovery resolves the same
id), not only codex.
- A refused claude send (any request error for an owned send) puts its text
back into the composer, in front of anything typed since, with "Message
not sent: <reason>" (a server `CODE: ` prefix is stripped). New composer
handle method restoreText.
Tests (red first):
- crates/freshell-ws/tests/freshagent_claude_lifecycle.rs (RealSidecarSim):
new a_sidecar_death_mid_compact_ends_exited_with_one_ring_and_an_exited_snapshot
(red: last status stayed `compacting`) and
a_send_to_a_dead_session_gets_the_lost_frame_and_a_request_error (red: no
lost frame); an_idle_cli_death... now also expects `exited` and
a_natural_end_of_stream... expects `exited` instead of the removed idle
(red: `idle`).
- claude.rs: a_resumed_dead_session_no_longer_reads_as_ended and
closing_a_dead_session_forgets_it (red with forgetting disabled: the old
ids still read `exited`); claude_dead_sessions unit tests (5).
- stream-end.test.ts: no stream-end idle after a CLI crash, and a CLI crash
mid-compaction ends with only `compacting` (red: an idle followed).
- fake-claude-sdk-sidecar-control.test.ts: no status after the stream-error
edge (red: the idle).
- FreshAgentView.test.tsx: Resume for an ended freshclaude/kilroy session,
even mid-compaction, resumes the same durable id (red: no Resume button);
a refused freshclaude/kilroy send is restored with the reason (red: the
composer stayed empty).
- freshAgentSlice.test.ts: exited replaces compacting and the exit banner
keeps it exited and not busy (a pin: the slice already did this; the
server never sent `exited`).
- e2e: fresh-agent-error-turn-attention.spec.ts gains a production-sidecar
crash lane for freshclaude and kilroy (background crash: seq +1, tab
highlight, ended card with Resume, Resume restores the conversation with
no reload and takes the next message); its stream-error step now expects
`exited`; the Stop lane's boot moved into a shared helper. The kilroy
crash lane in fresh-agent-control-rust.spec.ts crashes in a background
tab and recovers through Resume with no reload (seq +1, highlight), then
through the restart as before.
Deviations from the design text:
- The dead record keeps no attention clock or turn lock yet: nothing uses
them until the transparent resume (C20) moves them across.
- Recovery after a send to a dead id is the client's lost-frame recovery;
the server-side transparent resume is C20.
- The composer restore covers every refused claude send, not only the three
named codes: a request error always means the message was not sent.
- Rig snapshots read the overlay through a test-support seam
(live_snapshot_overlay): the rig server cannot read the rig's transcripts
(the store root comes from this process's environment).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`sendFreshAgentTurn` clicked Send and then waited for the pane to read idle, but the pane still reads idle in the instant after the click, so the helper could return before the send reached the sidecar. The "real-user reload ... NO manual persist flush" lane then read the fixture's transcript too early and missed its second turn. The race was already there before C09: earlier cloud runs on other branches failed the same assertion with [] and with only one of the two turns (scratchpad logs locator-e2e.log, final-e2e.log, cp-cloud-full.log). On this branch it failed in all three cloud attempts and in a local run, while the page itself showed the second turn: it had landed a moment after the transcript was read. Now the helper waits for the turn's completion edge (`turnCompletion.seq` goes up; the fixture writes both transcript lines before it), then for idle. Red: the lane failed locally at aceaa17 ("Received: ['no-flush first turn']"). Green: the whole spec passes locally (4 passed). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sidecar protocol v2 (design §4.2, §5.1). The sidecar answers `created`
only once the CLI has booted the conversation (`initializationResult()`
resolved, no prompt needed, T2), and a boot the CLI refuses is a typed
`create.failed` instead of a `created` followed by a session that is
already dead.
Sidecar:
- New pure module boot.mjs: bootOptions(boot, sessionId) maps the v2
`create.boot` ({kind: fresh | resume | fork}) onto the SDK options
(resume never sets sessionId); classifyBootError names the CLI's exact
T4/T5 refusals: SESSION_ID_IN_USE (from the CLI's stderr),
CONVERSATION_NOT_FOUND, RESUME_POINT_NOT_FOUND, FORK_GUARD_REFUSED, else
BOOT_FAILED.
- `created {requestId, sessionId, protocol: 2, cliPid}` after the boot. A
`booted` flag gates every SDK message until then: the refused boot's
error_during_execution result is only the refusal's evidence, never an
sdk.result. A refused boot writes only `create.failed {code, message}`
and exits with code 2. A create that is not protocol 2 fails fast
(SIDECAR_PROTOCOL_MISMATCH, no SDK call). A second create is refused.
- The command catalog comes from initializationResult().commands and is
published right after `created`, then again only when it changes (a
turn's init removes the terminal-only commands; later inits repeat it).
- Logs boot.ok (info) and boot.failed (warn) with the boot kind and time.
Rust:
- New claude_launch.rs (ClaudeBoot, SIDECAR_PROTOCOL) and
claude_handshake.rs (read_created): typed CreateFailure, a `created` in
another protocol is SIDECAR_PROTOCOL_MISMATCH (logged at error), lines
before the answer are logged at debug and dropped, and every boot is
logged as freshagent.claude.boot with its elapsed time.
- All three create sites (create, attach-resume, rollback) write the v2
line. handle_create forwards the sidecar's code in
freshAgent.create.failed and sets `created.sessionRef` for a resume
create (also on a dedup replay).
- Session host: the bridge announces a claude/kilroy native identity once
per distinct id, from `created.sessionRef` for a resume, so a hosted
resume `start` resolves with no prompt; a turn's init no longer
re-announces it.
Boot cost (decision 4), median of 7 on the pinned CLI 2.1.237 against
the local endpoint: create to `created` went from 269/256 ms
(fresh/resume) to 936/940 ms; create to the first turn's result, with
the send written as soon as `created` arrives, went from 1104/1056 ms to
1026/1042 ms. `created` is ~0.7 s later; the first turn is not slower.
Deviations:
- The v2 `boot` object replaces the flat resume/fork keys now, so every
fake sidecar (Rust tests, e2e fixtures) reads `boot` and answers
`protocol: 2`; tests that asserted the flat keys assert `boot`.
- Attach-resume keeps every boot refusal transient: it found the
transcript just before, so the Lost rule of §4.2 waits for
decide_boot/establish_runtime (C11/C12). The SESSION_ID_IN_USE retry
waits for fresh boots with a minted id (C23).
- Rollback maps FORK_GUARD_REFUSED onto its existing guard retry until
C13 replaces that flow.
- interrupt-idle-gate: a Stop can no longer reach the CLI before it
boots, so the cold-Stop cases now overtake the send after the boot
(the receipt-shape knob only applies before boot); the "Stop during a
refused resume boot" case is now boot-handshake.test.ts's refusal.
- The hosted-MCP session-host test's exact-resume case now expects the
identity from `created`; the old "different init id" check is replaced
by real-sidecar tests.
- The electron checkout-free fake SDK reports its boot and the test
expects the catalog after `created` (it also expected the stream-end
idle that C09 removed).
- Observed while verifying on the real CLI: the simulator refuses a
drops-turn guard even when nothing is discarded; the real CLI boots.
The guard test discards a turn, so it does not depend on that.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…me apart C10's update of the checkout-free acceptance bound the command-catalog frame to `catalog`, a name the test already uses for the model catalog, so the file no longer compiled. The electron-runtime lane (run locally against a freshly staged runtime) caught it; it now passes 3 of 3. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
C10 moved the resume and fork keys of the sidecar `create` line into the protocol-v2 `boot` object. Seven e2e specs inspect the fake sidecar's request log for the resume or fork create and still read `resumeSessionId` / `forkSession` / `resumeSessionAt`, so the first cloud run on C10 failed them (rollback undo/redo for claude and kilroy, settings resume, restart parity, zero-turn restart, hidden-pane rebind, reconcile completion, wave-A identity stores). They now read `boot.kind`, `boot.resume` and `boot.resumeSessionAt`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… commands Review finding F1 on PR-B (C10). C10 publishes the command catalog at boot, from initializationResult(), but only a turn's system/init names the CLI's terminal-only commands (terminal_slash_commands). So /doctor and /color reached the slash menu of every new or resumed pane until its first turn, and the test that guarded this had been relaxed to read the last catalog. Sidecar: - New small module terminal-commands.mjs: PINNED_TERMINAL_COMMANDS (the pinned CLI 2.1.237's list, doctor and color) and usableCommands(). The catalog leaves out the pinned list until the first init reports the CLI's own, which then replaces it. With the pinned CLI the lists agree, so the boot catalog is the only one published. Tests (red first): - session-settings.test.ts: the FIRST published catalog again must not list doctor or color (red: it listed both). - boot-handshake.test.ts: the boot catalog leaves out doctor and color, and later inits publish nothing (red); new case: an init naming a different list (simulator knob FRESHELL_CLAUDE_SIM_TERMINAL_COMMANDS) replaces the pinned list and the catalog is republished (red: the boot catalog listed color). - claude-cli-contract.test.ts (real CLI): the boot result names no terminal-only list, and every init lists exactly PINNED_TERMINAL_COMMANDS. Mutation check: pinning only 'doctor' fails it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… line Review finding PRB-C10-boot-catalog-dropped-on-resume. Since C10 the sidecar publishes the command catalog right after `created`. handle_create started the session's consumer, then awaited the identity sink's alias tombstone write (a resume create in production), and only then inserted the session into the sessions map. A consumer that read the catalog line in that window found no session and dropped it: a resumed pane's slash menu had only the built-in entries until a turn's init republished it. The same window let a CLI that died right after its boot leave a live zombie: the end-of-output path found nothing to evict, ended silently, and the session was inserted afterwards. Server (crates/freshell-freshagent): - The consumer is spawned and the session inserted under the sessions map lock, at all three registration sites (create, rollback respawn, attach-resume). The consumer reaches its session only through that lock, so it cannot handle a line before the session is registered. - handle_create: cli_index is written before the locked registration and the alias tombstone after it (the attach-resume order), so no await sits between starting the consumer and registering the session. - create and attach-resume forget the resumed session's dead record under the same lock, so a death of the new incarnation is recorded after it. - New small module claude_test_holds.rs (test seams; none set in production) with a registration hold: a wait between starting the consumer and inserting the record, standing in for the production sink await the rigs do not have. test-support: FreshClaudeState::hold_session_registration. Tests (RealSidecarSim, red first): - the_boot_catalog_reaches_the_live_state_however_long_the_registration_takes: fresh and resume creates under a 1.5 s registration hold have the boot catalog in their snapshot overlay with no turn (red: the overlay had no `commands`). - a_cli_death_while_the_session_registers_still_ends_the_session: a CLI crash 300 ms after boot, inside the hold, still gives SIDECAR_EXITED and an `exited` overlay (red: no exit report ever arrived). Deviation: the suggested oneshot gate was not needed; holding the map lock across spawn and insert gives the same ordering with no new channel or teardown paths to drop it on. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review finding PRB-C09-reap-window-exited-gate. The end-of-output path records the dead session, waits for the sidecar's exit status (reap), then reports the exit. C09 skipped only the `exited` status when a resume had brought the session back during that wait; SIDECAR_EXITED still went out under the same id. An attach that resumed the session under its own pane id (the reload and reconnect path) then got an error banner and was reset to idle while it was live. The rule had no test. Server: the `exited` status and SIDECAR_EXITED are now one report, sent only while the dead record still stands; when a resume or a close forgot it during the reap, both are skipped and logged (freshagent.claude.exit_report_skipped, info). The death ring is kept: the turn the death cut short did end, unwitnessed (the unified attention rule). Test seam: claude_test_holds.rs gains an exit-report hold (parks the end-of-output path after it recorded the dead session, before the reap); test-support: FreshClaudeState::hold_exit_reports. Test (red first): claude.rs a_resume_during_the_reap_gets_no_exit_report_but_the_lost_turn_rings (red: SIDECAR_EXITED reached the resumed pane). Mutation check: replacing the dead-record check with `true` fails it. Deviations: - The ring is not part of the skipped report (the finding suggested it could be): a crashed turn ends unwitnessed whether or not the session was resumed meanwhile. - The test is in-crate on the fake sidecar, not on RealSidecarSim: the attach-resume finds the transcript through this process's environment, which a rig cannot point at its own store (as for the C09 sibling test a_resumed_dead_session_no_longer_reads_as_ended). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review finding F2. C09 removed the sidecar's end-of-stream `sdk.status idle`, so the real sidecar sends no idle at all, but comments still said it sends one when its stream ends: the consumer's terminal-edge comment, the status fold, the TurnTracker invariants, the fold_terminal_edge doc, the in-crate fake's interrupt comment, and the e2e fakes and tests. The in-crate G23 test drove that idle through the hand-written fake and said the real sidecar sends it. It is renamed a_legacy_fake_idle_after_an_earlier_result_releases_the_busy_truth and its doc now says what it pins: the consumer's idle arm, which only the legacy protocol fakes reach until C17/C18 delete it. The real-sidecar G23 behavior stays pinned on RealSidecarSim by a_natural_end_of_stream_mid_turn_leaves_the_session_not_busy. No behavior change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review finding F3. C08 stopped the fakes from sending an idle after each turn, but fake-kilroy-runtime.mjs still sent `sdk.status idle` after every turn's result and edge, and after the synthesized Stop result. Run as a real server's sidecar (FRESHELL_CLAUDE_SIDECAR), that counted every turn as ending twice now that result_idle_pair_pending is gone, and it kept teaching the result-plus-idle pair the real sidecar never sends (G23). - fake-kilroy-runtime.mjs: no idle after a turn's result (the scripted turn and the held turn's synthesized Stop result). The idles after create and after an interrupt with nothing in flight stay, as in the SDK fake. - kilroy-runtime.test.ts (red first): a full turn and a held-turn Stop assert that no idle follows the result (red: the idle). Per-turn idle waits are gone; a Stop with nothing in flight serves as the read barrier. - harness-06-misc-fixtures.spec.ts: the smoke turn no longer waits for the per-turn idle. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review finding F4. C09 replaced the kilroy crash lane's reload recovery (crash, page.reload, the same durable session re-attaches) with the Resume button instead of keeping both, so no browser test covered recovering a crashed fresh-agent pane by reloading, the path whose attach must now clear the server's dead-session record. fresh-agent-control-rust.spec.ts, kilroy crash lane: after the Resume recovery and its follow-up turn, a second CRASH_NOW ends the session again; once the pane reads as ended the page reloads, and the lane asserts the reloaded pane resumed the SAME durable id (one more fixture resume), the ended card is gone, and a follow-up turn lands. The restart step then runs on the reloaded page, and the transcript check includes the new turn. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… claude.rs Review finding F5. PR-B added logic to claude.rs that has a home in the new small modules (design §3.2): the dead-session helpers on FreshClaudeState, and three copies of the v2 create-line fields (protocol, sessionType, boot) in handle_create, the rollback respawn and attach-resume. - claude_dead_sessions.rs: SharedDeadSessions, the lane's shared record store, with find, record (taking a Death: the ids, settings, commands and reason the end-of-output path has) and forget, logging as before (freshagent.claude.dead_session_recorded / _dropped / _forgotten). The state's field is this handle; dead_session, record_dead_session and forget_dead_sessions are gone from claude.rs. - claude_launch.rs: create_line(request_id, session_type, boot, fields), the one builder of type, protocol, requestId, sessionType and boot; each launch passes only its own fields, which can never replace those. C11's ClaudeLaunchSpec::create_request() replaces it. - claude.rs PROVIDER is pub(crate) for the moved logs. Deviation: refuse_send_to_missing_session stays in claude.rs. It is a handler's answer built from claude.rs-private helpers (send_error, lost_session_frame, the broadcast); it now reads the shared store. Test (red first): claude_launch a_create_line_carries_the_protocol_session_type_and_boot_with_the_launch_fields (red: no builder). The moved code is covered by the existing dead-session tests (in-crate `dead` 30/30, freshagent_claude_lifecycle 12/12) and the create/rollback/attach lanes (in-crate claude 317/317, freshagent_claude_rollback 7/7, freshagent_claude_attach 4/4). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review finding F6. The SIDECAR_EXITED banner still told the user to "Reopen the pane or create a new agent to continue", directly above the ended card's "Resume session" button (C09), while a refused send says "Resume the session to continue." The banner now ends the same way: "The Claude agent process exited unexpectedly: <cause>. Resume the session to continue." Text only, no test: the user's rules forbid tests that check prose. The e2e lanes that read the banner match "exited unexpectedly", which is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ycle Brings in main's merged work (#852-#862) as PR-A reconciled it: the send fence fix, hosted configure, G28 TranscriptLocation, graceful teardown with SidecarTreeGuard and ClaudeNodeCommand (claude_spawn.rs), sdk-env.mjs, the sidecar's stdout-error shutdown, the durable transcript projection, client alerts and the managed session-host startup. Conflicts and how they were resolved: - crates/freshell-claude-sidecar/index.mjs: both import sets kept (stream-end, boot, terminal-commands, sdk-env). The stdin-close handler keeps PR-B's `exiting` guard. The new stdout-error handler (#859) does the same: a sidecar already exiting on its own (refused boot, unrequested stream end) has closed its query and keeps its own exit code (3 or 2), so the handler only logs; otherwise it shuts down and exits 0 as before. - crates/freshell-freshagent/src/claude.rs: the three session inserts (create, rollback respawn, attach resume) take PR-B's record (no result_idle_pair_pending, the dead-session forget under the lock) plus PR-A's tree_guard. All three start sites already share spawn_sidecar_process (ClaudeNodeCommand + spawn_guarded) and write PR-B's create_line() v2 request. - One reap/teardown path: PR-B's end-of-output path reaped an ended sidecar with its own claude_sidecar_exit::reap (a bounded child.wait), while #859's requested teardown stops through graceful_then_kill and the tree guard. The process stop is now one function, SidecarProcesses::stop in claude.rs (graceful_then_kill, the /proc ownership reap, the tree-death confirmation), used by teardown_removed_session_within (requested: consumer aborted first, map entry removed first, so it stays silent and logs freshagent.claude.teardown) and by the consumer's unrequested-end path (which still records the dead session before the stop, and now reports the exit only once the old tree is confirmed gone; an unconfirmed tree escalates detached through the shared escalate_unconfirmed_teardown). claude_sidecar_exit::reap and its budget are gone; the exit status comes from the stop, and a node the stop had to force reports no status as the cause. freshagent.claude.sidecar_exit now also logs stop and confirmation. - crates/freshell-freshagent/src/lib.rs: both module lists kept. - crates/freshell-session-host/src/providers/fresh_agent.rs: both test sets kept (PR-B's simulated-CLI resume starts, PR-A's hosted configure). PR-B's HostedTransport::from_parts now also takes the broadcast sender PR-A's configure subscribes to (provider_frames). - test/e2e-browser/fixtures/fake-claude-sidecar.mjs: protocol-2 `created` and the boot.resume field (PR-B) with PR-A's FAKE_CLAUDE_SIDECAR_INIT_AT knob. Protocol v2 follow-through for tests PR-A brought in: fakes answer `created` with protocol 2 (checkpointed_send, claude_sidecar_teardown, safe11 shutdown reaping, the hosted configure fake); the rollback G28 test reads the fork from create.boot; the stdout-loss unit test and the pinned CLI shutdown contract send a v2 create (protocol 2, boot fresh), and the streaming query fixture reports initializationResult(). The teardown unit fixture drops result_idle_pair_pending, which PR-B removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…core-lifecycle PR-A moved two test commits past e128d37 before it landed as #863: the stdout-loss unit test now pins the `stdout.closed` warn record, and the pinned-CLI shutdown contract uses the shared resolvePinnedClaude / createClaudeFixture helpers and FAKE_MODEL. Conflict: test/integration/providers/claude-sidecar-shutdown.mjs, the create line. Resolved to PR-B's protocol-v2 create (protocol 2, a fresh boot) with PR-A's FAKE_MODEL. The sidecar's merged stdout-error handler logs `stdout.closed` once at warn before anything else, so the new record assertion holds unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e sidecar create log PR-A's G11 default-model spec (freshagent-default-model-rust) came in with the sync merges and still looked for the resumed sidecar create by its v1 top-level `resumeSessionId`. Protocol v2 (C10) moved that id into `boot.resume`, so the resume step could never match (a 30 s timeout for both freshclaude and kilroy), and the fresh-create check `!frame.resumeSessionId` held for every create. The fresh create is now `boot.kind === 'fresh'` and the resumed one is a resume boot of the durable id, like the seven specs e639d5e converted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…the v2 boot The G28 test claude_undo_with_an_unreadable_store_is_internal_error_not_nothing_to_undo asserted "no fork may run against an unchecked store" by reading the v1 top-level `forkSession` key. No protocol-2 create carries it, so the check passed whatever the undo spawned. It now reads `boot.kind`, as the merge already did for the sibling fork test, and first asserts that every logged create names its boot kind, so the check cannot go vacuous again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e when it loses stdout The sync merge added `if (exiting) return` to the sidecar's stdout-error handler, and nothing covered it. Two tests now drive index.mjs past it: an SDK stream that ends (the CLI killed under an idle session) and a refused boot (a protocol-1 create), each after the test destroyed the sidecar's stdout reader. They expect exit 3 and exit 2, one `stdout.closed` warn record, and the stream-end or boot-failure record. Without help the guard is rarely reached: the exit flush usually finishes before the pipe's error event, so the process exits with its own code either way. The new fixture entry (slow-stderr-flush-entry.mjs) delays the stderr flush callback by 500 ms, as a slowly read stderr would, so the error event lands inside the exiting window. With the guard removed both tests fail with exit code 0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sted teardowns The sync merge made SidecarProcesses::stop the one way a claude sidecar's process tree is stopped, for a requested teardown and for a sidecar that ended on its own, and nothing tested the unrequested side. The new claude::end_of_output_tests drive a Node fake through the production create, so the consumer, eviction and stop are the real ones. The fake starts two TERM-immune stand-ins for the claude CLI: one in its process group and one that left it with setsid. 1. A stream end (sdk.stream.ended, exit 3): both stand-ins and node are dead when the pane hears `exited`, read with no waiting. The report comes once, with the reported cause, and Rust rings nothing. sidecar_exit logs stop=abnormal_exit, confirmation=confirmed and exit_status="code 3". 2. Node SIGKILLed mid-turn: the tree is dead at the report, the cause's signal is SIGKILL, and the cut-off turn rings once. 3. A sidecar that closed its stdout but kept running and ignored the shutdown line and SIGTERM: stop=forced, and the cause has no exit code or signal, though the log records SIGKILL. 4. The same fake stopped by handle_kill: the same tree dies, with no exit report, no ring and no dead record. Checked by mutation: broadcasting `exited` before the stop fails 1-3, and dropping the forced-stop filter on the cause fails 3. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…dows A stop that cannot confirm the sidecar's tree gone hands it to a detached escalation. That escalation ran its 24-round kill-and-confirm window again at once, forever: a tree stuck in the kernel (it survived SIGKILL) cost a full /proc environ scan every 25 ms and a warning every 0.6 s for the rest of the server's life. Since the merge every unrequested death whose stop ends unconfirmed starts one, so they could pile up. The escalation still never gives up, because an explicit kill's deferred stop commit waits for it to confirm death. It now pauses between windows: half a second, doubling, at most a minute. Each unconfirmed window logs one `teardown_escalation_continues` warning that names the next pause, and the escalation logs `teardown_escalation_confirmed` (info, with the window count and elapsed time) when the tree dies. The loop runs over a small TreeDeathWindow trait, so a test drives it with a scripted window. That test fails if the pause is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The end-of-output path said the old tree is "killed and confirmed gone BEFORE the exit report offers Resume", and the merge commit claimed the same. Both hold only within the stop's bounded confirmation window. When the window ends with a member still alive, the report goes out at once while the detached escalation keeps killing the tree. That is the intended behavior: 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. Holding the report back would leave the pane busy for as long as the kernel keeps that member. The comments now say so. The comment on the dead record now also says why the record comes before the stop. A resume that lands during the stop (an attach is a recovery verb) forgets the record, and that is how the report knows to stay away. The comment also notes the cost: while the stop runs, a snapshot already reads `exited`. The stop is short unless node closed its output and kept running, which gets the 2 s grace period. New test a_tree_the_stop_cannot_confirm_gone_is_reported_and_killed_in_the_background uses a zero-round confirmation window, so the setsid'd TERM-immune stand-in outlives the stop. It asserts one `exited` and one SIDECAR_EXITED, confirmation=not_confirmed, one teardown_unconfirmed error, the stand-in killed by the escalation, and the report logged before teardown_escalation_confirmed. Making the report await the escalation fails it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Core PR-B of the freshclaude core-runtime redesign (design, §10 PR-B: C08, C09, C10). Core PR-A merged as #863; this PR's diff is PR-B only.
What users get
creatednow arrives about 0.7 s later (median 936/940 ms fresh/resume on the pinned CLI, against 269/256 ms before), inside the approved 0.6–1.5 s range. The first reply is not slower: create to the first turn's result went from 1104/1056 ms to 1026/1042 ms.What changed
sdk.stream.ended:cli_exited,stream_errororstream_eof) as its last line and exits with code 3. A requested stop still exits 0 and reports nothing. The server no longer expects a result followed by an idle at the end of each turn (result_idle_pair_pendingis gone). The fakes now end a turn the way the real sidecar does.claude_dead_sessions.rs, at most 256). Its snapshot readsexited, a send to it gets the lost-session frame plus a request error, and resuming or closing it clears the record. The pane getsfreshAgent.status{exited}and thenSIDECAR_EXITED, with the cause. The client's ended card offers Resume for claude/kilroy.createcarriesprotocol: 2and abootobject (fresh,resumeorfork;boot.mjs,claude_launch.rs create_line()). The sidecar answerscreatedonceinitializationResult()resolves. A refused boot is a typedcreate.failed(CONVERSATION_NOT_FOUND,RESUME_POINT_NOT_FOUND,FORK_GUARD_REFUSED,SESSION_ID_IN_USE,BOOT_FAILED,SIDECAR_PROTOCOL_MISMATCH) followed by exit code 2. On the Rust side the handshake lives inclaude_handshake.rs. The command catalog is published from the boot.claude.rs.SidecarProcesses::stopinclaude.rs. It runs a graceful stop, the/procownership reap, and the whole-tree death confirmation. A requested stop is silent. For an unrequested end:/procevery 25 ms forever. It logsteardown_escalation_confirmedwhen the tree dies.claude_sidecar_exit::reapis deleted.bootfields. Before, the spec could never match the resumed create, and the rollback test's no-fork check passed whatever ran.Tests
All run on the final head:
cargo test -p freshell-freshagent: 1440 unit tests passed, plus the integration targets (8 sidecar-teardown, 1 interrupt-dispatch, 2 transcript-export). This includes the newclaude::end_of_output_tests(5 tests):handle_killof the same sidecar kills the same tree and reports nothing.It also includes the escalation backoff tests. Checked by mutation: reporting before the stop fails tests 1–3, dropping the forced-stop filter fails test 3, a report that waits for the background kill fails test 5, and removing the backoff pause fails the backoff test.
freshell-ws:
freshagent_claude_lifecycle12,freshagent_claude_real_cli9,freshagent_claude_checkpointed_send4,freshagent_claude_rollback7,freshagent_claude_attach8,freshagent_session_lease9. All passed.freshell-server
safe11_term22_shutdown_reaping: 3 passed.Sidecar Vitest (
test/unit/claude-sidecar/plus the provider claude-sidecar-shutdown, claude-cli-sim, claude-cli-contract and checkpoint-uuid-replay suites): 21 files, 199 tests passed. Two new tests cover a sidecar whose stream ended or whose boot was refused after its stdout reader went away. It keeps exit code 3 or 2 (0 without the guard).Cloud e2e (chromium), 43 passed, 0 failed:
Known interim limits
exitedand may resume. This window is normally milliseconds. A sidecar that closed its output but kept running gets the 2 s grace period before it is killed.🤖 Generated with Claude Code