Repository navigation
feat(freshclaude): one launch spec, conversation genesis, undo/redo that works with the real CLI, and fork (core PR-C) - #870
Merged
Conversation
Every claude sidecar launch now hands the CLI the pane's plugins and, in a hosted lane, the hosted Freshell MCP. Before this, only a create sent them: an attach that resumed an untracked session (a reload after a crash, or a restart), a handoff resume and an undo/redo respawn dropped both. - claude_launch.rs: `ClaudeLaunchSpec` and `create_request()`, the only builder of a sidecar `create` line (it replaces `create_line`). Every line carries the same keys (`plugins` and `hostedMcp` included; unset values are null); `modelSelection` rides only a create that has one. - `FreshAgentSettings.plugins` and `BindingRow.plugins` (serde-optional under LEDGER_VERSION 1): the create records its plugins with its binding row at `sdk.session.init`, and attach-resume, handoff resume and the rollback respawn read them back. An empty plugin list is recorded as none, so a pane without plugins keeps the lineage-only row. `fresh_agent_settings_recorded` counts plugins, matching `load_settings`. - claude/establish.rs: `boot_sidecar` (spawn, lease kill handle, start cancellation, the create line, `created`) for every launch, and `establish_runtime` (§4.9): naming admission, kill-fence claim commit, the fresh create's provisional-key rekey, registration, alias record, post-registration dead-state recheck, lease completion and coordinator commit, moved unchanged out of handle_create and resume_for_attach (the handoff resume is one). It logs `freshagent.claude.runtime_established` (info) and `freshagent.claude.runtime_not_established` (warn). - `decide_boot` (§4.2, I8) matches on `TranscriptLocation`: found resumes (by id from the recorded directory, else by path), absent is lost, unreadable is transient. The attach-resume uses it; `resume_target` is shared with the rollback's fork source. - The lane locates transcripts in the store its sidecars write: this process's environment, or the sidecar environment a rig configures (`with_sidecar_environment`), so RealSidecarSim tests can resume. Tests: - New `freshagent_claude_launch_parity.rs` (RealSidecarSim, hosted lane, real pane ledger, handoff route): the create, attach-resume, handoff resume and rollback respawn `query()` options (simulator ledger) carry the plugins and the `freshell` MCP server. Red: with the old resume and rollback lines, the three resume tests fail (`plugins` null, no `mcpServers`); create was already green. - Ledger, sink and create round trips of the plugins (pane_ledger_tests, freshell-server identity_sink, an in-crate create test), each red with its write or read reverted. - claude_launch unit tests: the spec's line on every boot, and each `decide_boot` row. - The existing kill/claim/commit race tests run unchanged against `establish_runtime` (freshagent lib 1440/1440; ws claude binaries green). - The rig gains `RigOptions` (durable ledger, hosted claude lane, handoff route) and `post_json`; `common/ledger_sink.rs` mirrors the production LedgerIdentitySink. Deviations from the design: - The rollback respawn boots through `boot_sidecar` and the launch spec, but its registration still waits for the fork's first `sdk.session.init` (G01). C13 rewrites the rollback onto `establish_runtime`. - `decide_boot` has no genesis input yet: the binding row's genesis lands in C12. A create that resumes still lets the CLI answer for the transcript (typed CONVERSATION_NOT_FOUND), as before. - `ClaudeCreateOutcome` (I1) follows in its own commit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`FreshClaudeState::handle_create` now returns what it answered, for the
API plan's in-process callers (design §9.3, I1):
enum ClaudeCreateOutcome {
Created { session_id, session_ref: Option<SessionLocator> },
Failed { code, message, retryable: Option<bool>,
owner: Option<TerminalOwnerFields> },
}
The WS frames stay byte-identical: a failed create's
`freshAgent.create.failed` frame is now built from its outcome
(`claude/create_outcome.rs`), and the dispatchers ignore the value.
Typing every answer exposed a fall-through: when the coordinator refused
a fresh create's pane-scoped pre-spawn claim (another operation holds
`pending-create-<requestId>`), or answered Adopt for it, the handler
answered and then went on to spawn a sidecar and answer `created` too.
Both arms now return their answer.
Tests:
- `a_refused_fresh_create_claim_answers_only_the_refusal_and_spawns_nothing`
(red before: `create.failed` then `freshAgent.created`, and a spawn),
which also checks the returned outcome equals the frame.
- `a_create_returns_the_session_its_created_frame_names` (fresh and
resume creates).
- `a_failed_outcome_is_its_create_failed_frame` (wire shape with and
without the retry hint and owner fields).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e create log C10 moved a create's resume into the protocol-v2 `boot` object, and its follow-up (e639d5e) updated seven e2e specs that read the fake sidecar's create log, but not `freshagent-default-model-rust.spec.ts`. That spec still waited for a create with `resumeSessionId`, which no create line carries any more, so its resume leg timed out for both freshclaude and kilroy on the cloud backend ("timed out waiting for the resumed ... pane's sidecar create"). It now finds the new pane's create by `boot.kind === 'fresh'` and the resumed pane's by `boot.resume`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The create-resume claim's commit moved into `establish_runtime` (C11); the comments above the claim's dead-state snapshot in `handle_create` still said the commit happens "further down" and "below". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… and reconcile
A claude session that has no transcript yet (a zero-turn session, a pending
fork after an undo) records how it came to be in its binding row, so the
snapshot shows what it holds and a restart brings it back instead of
declaring it dead (core-runtime design §4.1, §4.3, §4.4). Inert until C13
writes a genesis: no lane sets one yet.
- `ClaudeGenesis` (claude_launch.rs): `Fresh` or
`Fork { parent, at, dropsTurn }`, stored as `{"kind":"fresh"}` /
`{"kind":"fork",...}`; a kind this server does not know reads as none.
- `BindingRow.genesis` (pane ledger, opaque like the rollback payload,
serde-optional under LEDGER_VERSION 1: old rows parse to none). A binding
write carries `GenesisWrite::{Keep, Set, Clear}`; every write that is not
about the genesis keeps it (a settings change before the first send, T7),
and a fork-chain child never inherits its parent's.
`PaneLedger::binding_genesis` (memory read) and `clear_binding_genesis`
(the lazy clear: drops only the genesis, durably; no-op without one).
- Identity sink: `FreshAgentBindingUpsert.genesis` (`GenesisUpdate`),
`load_genesis` (with the row's last write time) and `clear_genesis`, in
the production `LedgerIdentitySink`, the fake and the ws test sinks.
- Snapshot (§4.5): the effective transcript. A found transcript wins; a clean
miss with a `Fresh` genesis is an empty snapshot; with a `Fork` genesis it
is the parent's lines on the raw parentUuid chain through `at` (the whole
parent when none), projected with the parent's subagents, so the turn ids
are the ones the fork keeps (T6). A parent that is gone or no longer holds
the fork point reads as lost; an unreadable store is Io, genesis or not.
A pending snapshot's revision is the row's last write, floored by the
rollback record.
- REST and the session host read claude snapshots through the lane
(`FreshClaudeState::disk_snapshot`, new `claude/genesis.rs`), from the
store its sidecars write: a bound pending id answers 200, not the lost
404.
- Reconcile (§4.4): a claude disk miss for an id whose binding row has a
genesis and that was never seen on disk reads as present (`respawn`,
under the respawn cap); seen-then-gone stays `dead_session`. The cold
index already answered `respawn` for ledger-bound ids, so warm and cold
now agree. Logged as `freshagent.claude.reconcile_pending_genesis`
(debug); an unservable pending fork as
`freshagent.claude.pending_fork_unservable` (warn); an unknown genesis
shape as `freshagent.claude.genesis_unreadable` (warn).
Tests (red first, then green):
- pane_ledger_tests: the genesis round-trips across a restart; a row written
before the field parses with none; only an explicit clear drops it
(settings writes keep it, the clear is durable, a second clear writes
nothing); a superseding binding carries only its own. Red: the write
dropped it.
- freshell-server identity_sink: write-through, read back after a restart,
kept by a settings write, cleared. Red: nothing recorded.
- claude_snapshot: Fresh gives an empty 200-shaped snapshot (and no genesis
gives NotFound); Fork gives the parent's prefix through `at` with the turn
ids the materialized fork keeps; the prefix follows the chain (an
abandoned branch and a sidechain line stay out); gone parent or fork point
gives NotFound; unreadable store gives Io; a found transcript wins; the
rollback record floors the revision. Red: four failed with the genesis
arm reverted to NotFound.
- snapshot.rs (REST): a bound zero-turn id and a bound pending fork answer
200; an unbound id stays 404. Red: 404.
- pane_reconcile_freshagent: Absent + genesis + never on disk gives
`respawn` (Fresh and Fork); no genesis or once-observed gives
`dead_session`; the respawn cap still converges. Red: `dead_session` and
`fresh`.
- claude_launch: the stored genesis reads back; an unknown kind reads as
none.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sis clears once its transcript is on disk Follow-up to the C12 genesis: reconcile now answers `respawn` for a bound claude session that has a genesis and no transcript yet, so the attach that follows has to boot it. It does now, and the genesis is cleared lazily, as design §4.1-§4.3 describe. Still inert in production until C13/C23 write a genesis. - `decide_boot` (claude_launch.rs) takes the binding row's genesis and the §4.2 rows: a found transcript resumes whatever the genesis says; absent + `Fresh` boots a fresh conversation under the session's own id (T3); absent + `Fork` boots the same lazy fork of the parent under the session's own id, from the parent's recorded directory (T6), and is lost when the parent is gone; absent + none is lost; unreadable is transient. - `ClaudeLaunchSpec.session_id`: every create line now carries `sessionId` (null unless a fresh or forked boot writes under a minted id; the sidecar's `bootOptions` already reads it). - The attach-resume (and the handoff resume, which is one) boots through `FreshClaudeState::decide_attach_boot` (claude/genesis.rs). A launch that finds the transcript of a session whose row still has a genesis clears it. - The lazy clear at results: after every `sdk.result` the consumer checks the session's row (one memory read); with a genesis set it looks for the transcript up to 10 times, 200 ms apart, and clears the genesis at the first find. A transcript that never appears (a CLI death in the flush window) leaves it set, so the next launch boots from it again. - Logs: `freshagent.claude.genesis_boot` (info), `genesis_cleared` (info, trigger result|launch, attempt), `genesis_kept` (warn), `genesis_clear_failed` (warn). Tests: - claude_launch: each new `decide_boot` row (found wins over Fresh and Fork, Fresh boots under its own id, Fork boots the fork from the parent's directory, gone parent lost, unreadable parent transient, unreadable store transient) and the create line's `sessionId`. Red: 4 failed with the genesis ignored. - claude/genesis.rs: the clear waits for the flushed transcript; a transcript never flushed (or an unreadable store) keeps the genesis; a failed clear write is reported and keeps it. Red under two mutations (clear without the find; a single look). - New freshagent_claude_genesis.rs (RealSidecarSim, real ledger): a zero-turn session reattached after a restart boots fresh under its own id (the simulator's `query()` options), its first turn writes that id's transcript and the genesis clears; a pending fork reboots the same fork (resume = parent, resumeSessionAt = the fork point, sessionId = the child), its pending snapshot's turn ids lead the materialized fork's, the parent is untouched, and the genesis clears. Red against the previous commit's runtime: both attaches answered the lost frame (INVALID_SESSION_ID). Dropping only the result hook keeps the genesis forever (red). - freshagent_claude_real_cli.rs, design §11.5 item 10 on both rigs (RealSidecarSim and the pinned CLI against the local endpoint): a zero-turn restart boots fresh with the same id and clears its genesis. Red against the previous commit's runtime on both rigs. - The rig gains `seed_pending_session`, `wait_for_transcript`, `wait_for_genesis_cleared`, `RigConnection::attach` and `init_session_id`. Deviation from the design: the typed boot-failure retries of §4.2 (SESSION_ID_IN_USE once as a resume, CONVERSATION_NOT_FOUND once per the genesis) are not here; they belong with `establish_runtime`'s boot owner (C13 rollback, C20 `ensure_runtime`). A refused genesis boot is answered transient by the attach, as every refused resume is today. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ecar's output reader The lazy genesis clear's check after each `sdk.result` resolved the session's durable id inline in the consumer, which takes the sessions and cli_index locks: the reader that drains the sidecar's stdout must not wait on those (a send holding the sessions lock across a blocked stdin write, G27, would then wait on the reader). The whole check now runs in the detached task (`spawn_genesis_clear_after_result`); the reader only reads whether a sink is installed. No behavior change: the genesis lanes (RealSidecarSim and the pinned CLI) and the freshagent lib suite are green. Also documents the genesis row in handle_attach's decision table. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ator matches it
Decision 2 of the core design (undo refuses honestly when Claude folded
another message into the turn) rests on a CLI fact no scenario pinned yet.
Probing the pinned CLI 2.1.237 against the loopback provider showed:
- a send absorbed during a tool call is written into that turn as an
`attachment{type: queued_command, source_uuid}`, never as a prompt line;
- a fork that undoes that turn with `resumeDropsTurn` declaring its prompt
is refused at boot: "range contains absorbed queued content".
New contract scenario `absorbed-undo` and fact (T10 T5) pin both on the real
CLI. The simulator diverged on both (it wrote the absorbed send as a user
line and only checked the first discarded entry), so the conformance
comparison was red. It now records absorbed sends as queued_command
attachments and runs a port of the CLI's validator: leading furniture is
skipped, the declared prompt must start the range, and a second prompt, a
task notification, a compaction summary, absorbed content or a
non-furniture attachment in the range refuses, with the CLI's own reasons.
Unit tests pin the port's other ranges (a second prompt, a notification,
a range not starting with the declared prompt).
Red: conformance for absorbed-undo differed (the sim booted the guarded
fork). Green: the contract subset (absorbed, T5, T6, T10, fork, turns
conformance) 8/8; claude-cli-sim + sidecar unit tests 154/154.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One predicate, `is_rollback_anchor`, now decides where an undo step starts (core design §4.5). It replaces `is_user_anchor` and shares its pieces with the transcript projector (`is_synthetic_line`, the local-command output parser, the task-notification check), so a step starts exactly where the pane renders a turn the user started: a prompt or a slash-command line. On real CLI 2.1.237 lines, the old predicate also started a step at the `<local-command-stdout>Compacted` line after `/compact` and at a background agent's task notification. Undoing "to" the compaction output undid only that output, and undoing after a background agent reported back undid only the notification turn. Now the `/compact` step goes whole and the notification turn belongs to the step that launched the agent. A step that starts with a slash command refills the composer with the command as typed (`/compact keep it short`), not its XML. The `resumeDropsTurn` guard follows the CLI's real validator (pinned by the `absorbed-undo` contract scenario): it declares exactly one turn, so `drops_turn_guard` arms it only when the fork discards exactly one prompted turn, never a command step (the CLI does not attribute the command's output line to it) and never several turns. It replaces `raw_chain_successor` and `ResumePoint.guard_uuid`; undo and redo both use it. Red: the four new real-fixture tests and the guard unit test failed under the old predicate (mutation check). Green: `cargo test -p freshell-freshagent --lib claude` 429/429; `freshagent_claude_rollback` 7/7. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cation; the simulator writes it The second CLI fact decision 2 rests on, probed and pinned like the first: a background agent's completion is written into the turn that launched it as a user line of origin `task-notification` (no prompt of its own, no SDK message), so a guarded fork that undoes that turn is refused at boot: "range contains an externally-sourced user entry". New contract scenario `notification-undo` and fact (T19 T5). The simulator wrote no notification line at all for a CLI-initiated turn (red in the conformance comparison: the transcript shape differed and the guarded fork booted). Its CLI turn now writes the notification line (with the task's id, tool-use id, status, summary and result) and the context attachment before the reply, as the pinned CLI does. Green: contract subset (notification, absorbed, T19, cli-turn conformance) 7/7; claude-cli-sim + sidecar unit tests 158/158. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The rollback killed the live sidecar, respawned a fork with no prompt and
then waited up to 45 s for a `system/init` the real CLI only sends with a
prompt (T1), so every undo and redo failed against the pinned CLI and left
the pane with no runtime. Now (core design §4.5, §4.6, C13):
- The rollback mints the replacement's durable id C and boots it through
`establish_runtime` while the original A is still live: a lazy fork of
the session's EFFECTIVE transcript (`Fork{source, at, dropsTurn}`), or a
fresh conversation for a first-turn undo. The sidecar answers `created`
once the CLI booted it (T2/T6), so no init is ever awaited. C registers
already adopted (map key = envelope stamp = durable id = C) and takes over
A's turn lock, busy truth and attention clock.
- Source collapse: undoing a pending fork forks its parent at the earlier
point (chains never nest), so two undos with no send in between work.
- One awaited ledger batch writes C's binding (genesis, A's settings and
plugins, `supersedes: A`) with the rollback record re-keyed to C. Then
ownership moves A→C in one coordinator step (`rekey_live` now takes the
original's map key as the expected owner), every id that named A (its map
key, its durable id, their aliases, its naming handle) resolves to C, and
A's sidecar is stopped through the graceful teardown path (#859).
- Decision 2: a fork the CLI refuses because the undone turn absorbed
another message (the drops-turn guard) is ROLLBACK_REFUSED with "Claude
folded another message into that turn, so it can't be undone to that
point."; a fork point the CLI no longer has is ROLLBACK_REFUSED too. Never
retried without the guard. Any failure before the move compensates the
record and tears C down; A is untouched and live.
- A send that waited on the turn lock while the runtime was replaced goes
to C through the durable supersession chain (`PaneIdentitySink::
chain_terminus`, the ledger's `lookup_by_session`), never to the stopped
A and never to a revived A. A restart before the next send boots C again
from its genesis (C12).
- Deleted: `read_session_init`, `rollback_spawn_create`, the init pre-read,
`RollbackAdoption` and the consumer's preseeded adoption (the completion
variant of the adoption is test-only now). The handler moves to
`claude/rollback.rs`.
- Client: a claude `materialized` re-key to a durable id makes that id the
session's durable id; the replaced session's `cliSessionId` no longer
survives the merge and points the pane's sessionRef back at A (C reports
no init before its first prompt).
- New JSONL events: freshagent.claude.rollback_boot (info),
rollback_refused (warn), rollback_binding_write_failed (error),
rollback_commit_stale (invariant), rollback_completed (info),
send_followed_replacement (info); runtime_established/not_established
carry lane "rollback".
Tests (red first: on the old code every RealSidecarSim undo was unanswered
within 20 s because no init ever arrived):
- freshagent_claude_rollback.rs rewritten onto RealSidecarSim with a real
ledger and the coordinator (13): undo answers in time with A→C
materialized and the guarded fork under C; the first send writes C; redo;
two undos without a send (source collapse); first-turn undo boots fresh
and redo restores; decision 2 refusal with A live and taking a send; a
fork that cannot boot leaves A live; a parked send lands on C through the
ledger chain; a restart keeps the pending fork's prefix; the fork's log
binds to C; the three G28 unreadable-store cases.
- Real-CLI lane (both rigs): undo, send, redo refused after the send, undo
and redo, two undos without a send, send.
- Launch parity: the rollback now completes (it awaited only the fork's
launch before).
- Server sink: the supersession chain leads to the live replacement, across
a restart. Client slice: the claude re-key adopts the durable id.
- In-crate fake: a boot under a minted `sessionId` reports init at its
first send (T1); the rollback helper resolves the replacement.
- e2e fresh-agent-rollback-rust.spec.ts: the claude and kilroy lanes run
the production sidecar over the simulator; new lane: two undos, a server
restart before sending, the prefix is kept and the next prompt runs.
Deviations: a redo all the way to the root's tip still forks the root (at
its tip) instead of resuming it (design: `Resume{root}`), so the chain
never cycles. `name_transition` is not declared on the replacement's
binding (the server sink ignores it; the pending naming handle moves with
the record). The decision-2 lane uses a background agent's notification:
a send absorbed during a tool call is refused the same way by the CLI
(pinned by the contract), but until C17 such a session still reads busy
(G08), so its undo answers BUSY_TURN first.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A freshclaude or kilroy conversation can now be forked from any turn (or from its tip with /fork). The new claude/fork.rs mints the child's id, boots it as the SDK's lazy fork of the parent through the chosen turn (resume, resumeSessionAt, forkSession, sessionId) via establish_runtime while the parent stays live and untouched, records the child's binding row (Fork genesis, the parent's settings, the forking connection's provenance, no supersedes), commits the child's coordinator claim and answers freshAgent.forked on the requesting connection only. The pane switches to the child at once (decision 5) and closes the parent itself. - Keep point: the last chain line of the step holding input.atTurnId (a :row-N split id names its turn), or the tip (claude_snapshot::fork_point). A pending fork forks its own parent at the same point (source collapse). - Refusals, each a nested freshAgent.error on the requesting connection: BUSY_TURN while the parent runs a turn, FORK_REFUSED for an empty conversation, a turn the conversation does not hold or a point the CLI no longer has, FORK_IN_PROGRESS for a second fork of the same pane while the first boots, INVALID_FENCE for a half-sent owner pair, the lost frame for an unknown parent. A fork that fails tears down what it started. - The fork waits on the parent's turn lock and follows a replacement an undo made meanwhile (the pane re-keyed to it). - terminal.rs routes claude forks; the refusal table keeps amplifier only. The snapshot stamps capabilities.fork: true (goldens regenerated; only the stamp changed). - Session host: fork() routes claude to handle_fork. The claude transport's own id is the sidecar's, so the parent is accepted by its native id too, the transition is keyed by the native ids the actor holds, and the native identity points at the child at once (the bridge now announces from that watch, so the child's first turn announces nothing new). - New log events: freshagent.claude.fork_boot, fork_refused, fork_binding_write_failed, fork_commit_stale, fork_completed, fork_child_retire_failed. - Refactor: the effective-transcript reader moved from rollback.rs into claude/history.rs (shared by undo and fork); the boot refusal codes are claude_handshake constants. Deviations from the design: - A live runtime now remembers the genesis it was launched from (ClaudeSession.launch_genesis), and the snapshot and the effective transcript fall back to it when the ledger has none. The hosted session host has no ledger, so without this a pending child (and, before this, a hosted undo's replacement) had no snapshot and could not be forked again. - The pinned CLI appends a `last-prompt` line to a conversation's transcript when its CLI shuts down. The fork itself leaves the parent byte-identical; the tests check the parent before the pane closes it, and after that only that the child's turn writes nothing to it. Tests: freshagent_claude_fork.rs (5, RealSidecarSim); real-CLI lane item 6 on both rigs; session-host fork test; fork_point unit tests; the refusal-table test now pins dispatch; client capability test for freshclaude and kilroy; e2e freshclaude-fork-rust.spec.ts; the control spec's capability gate now expects /fork offered for claude. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…re-history PR-C branched from PR-B at f88f419; PR-B gained six commits since (c794647, 549c05a, 4b84de2, f42a579, 0fa4083, 2da423f). Merged rather than rebased so no published commit is rewritten. Conflicts: - crates/freshell-ws/tests/freshagent_claude_rollback.rs: kept C13's RealSidecarSim rewrite. 0fa4083 tightened the old fake-rig unreadable-store test to read `boot.kind` from the create log; the rewritten test (an_undo_with_an_unreadable_store_is_internal_error_not_ nothing_to_undo) already asserts the stronger fact that the simulator recorded no further query() at all, so nothing was forked. - test/e2e-browser/specs/freshagent-default-model-rust.spec.ts: kept PR-B's 2da423f (it also checks `boot.kind === 'resume'`); d526a30 on this branch made the same fix less strictly. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The genesis round-trip doc was stacked on top of the supersession-chain test, leaving genesis_writes_through_reads_back_and_clears undocumented. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…enesis (PRC-1, F1)
C13/C14 create sessions that have no transcript until their first send: an
undo's replacement (genesis `Fork{A, at}`, or `Fresh` for a first-turn undo)
and a fork's child. When such a session's sidecar died before that send, the
pane's Resume (`triggerRecovery`, a create carrying `sessionRef: C`) always
booted `Resume{C}`, which the CLI refuses (T5), so the pane got
`create.failed CONVERSATION_NOT_FOUND` and the undo or fork was lost until a
reload happened to go through attach.
The create's resume lane now decides its boot like every other launch (design
§4.2, §4.3 crash row): when C's binding row records a genesis, `decide_boot`
over the lane's store picks the genesis boot under C while C has no
transcript (with the fork parent's recorded cwd), and a resume once it has
one (clearing the genesis). A genesis with nothing left to boot (a fork whose
parent is gone) answers CONVERSATION_NOT_FOUND without spawning; an
unreadable store answers a retryable CLAUDE_CREATE_FAILED, never lost. The
booted genesis also rides the runtime (`launch_genesis`).
Deviation from F1's suggestion: a session with no recorded genesis still
boots the plain resume without a store check. Its only possible boot is that
resume, and the CLI, which reads the store from its own environment, answers
a missing conversation with the same typed CONVERSATION_NOT_FOUND; checking
first would add a way for a server whose view of the store differs from the
CLI's (no root found, an IO error) to refuse a resume the CLI would run.
decide_attach_boot and the create share one `decide_recorded_boot`; its
lines name the lane, and a lost decision is logged
(`freshagent.claude.boot_lost`).
Red first (RealSidecarSim, real ledger):
- freshagent_claude_rollback: an_undos_replacement_whose_sidecar_died_
resumes_through_the_create, a_fresh_replacement_whose_sidecar_died_
resumes_through_the_create (both answered create.failed
CONVERSATION_NOT_FOUND before the fix)
- freshagent_claude_fork: a_child_whose_sidecar_died_before_its_first_send_
resumes_through_the_create (same)
- freshagent_claude_genesis: a_create_resuming_a_pending_fork_of_a_gone_
parent_is_refused_without_a_boot (booted a doomed resume before the fix)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ive settings (PRC-2) An undo or redo built the replacement's launch only from the ledger (`identity_sink().load_settings`). The hosted session host keeps no pane ledger, so a hosted undo booted the replacement with no plugins and no model, effort or permission mode. Later sends re-apply model, effort and permission mode through configure, but plugins are boot-only, so the replacement and every later turn ran without the pane's plugins (G15 on the hosted lane). The rollback now reads the settings the live session runs with (`configuration.settings`, kept current by create, attach and configure) when it reads the session at entry, the same source a fork's child launches from, and uses them for the replacement's launch, its recorded settings and its binding row. A rollback always has a live record (it refuses an id with none), so no ledger fallback is needed. Red first: freshell-session-host a_hosted_claude_undo_keeps_the_panes_plugins_and_model (real index.mjs over the simulator): the replacement's query() had `plugins` absent and `model: null`. The finding's redo leg does not apply to this lane: redo needs the durable rollback record, which only a lane with a ledger keeps, so a hosted redo is REDO_UNAVAILABLE before it boots anything. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… booted hands out no child (PRC-3) A fork re-checked nothing after its child booted (about a second on the real CLI, decision 4). When the user closed the pane in that window, the kill of the parent succeeded (it takes neither the fork's single-flight nor the turn lock, and the coordinator grants it: the fork claims only the child's key), and the fork still wrote the child's Bound binding row, committed it Live and answered `forked` to a pane that no longer existed. Nothing ever closed that child: its sidecar and CLI ran until shutdown and its row could be offered for restore. The fork now checks whether the pane closed the parent twice: once its child is established, and again once the child's row is written (from there to the answer nothing awaits, so a close lands before the check or after the pane was answered). Closed means a kill of the parent is in flight (its close is pending) or done (its runtime left the live map without a dead-session record). A parent whose sidecar merely died is still the pane's, so its fork goes on. A closed parent's child is torn down through `unwind_fork_child` (retiring its row once written) and the fork answers FORK_REFUSED; logged as `freshagent.claude.fork_parent_closed`. Test seam: `hold_fork_child` parks a fork once its child is established, before the row write, so the close lands in the window every time. Red first: freshagent_claude_fork closing_the_pane_while_the_child_boots_leaves_no_child_behind (answered `freshAgent.forked` with a live child before the fix). Guard test: a_parent_that_dies_while_the_child_boots_still_gets_its_fork. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ws the replacement (F2)
C13 moves the session to a new map key (the replacement C) while
handle_rollback holds the session's turn lock. Only the send and the fork
re-resolved their session after waiting on that lock; a configure or a
/compact queued behind an undo (from this or another device) still used the
key it resolved before the wait, the retired A. The configure answered
CLAUDE_SETTINGS_FAILED "Claude session is no longer available." and the
compact a lost-session frame (INVALID_SESSION_ID), so the setting or the
compaction was dropped. Before C13 the replacement re-registered under the
same key, so both landed on the post-rollback session.
Both now re-resolve after the lock through `live_key_after_turn_wait` (the
durable ledger chain, then the same-lock alias), like the send and the fork
(design §3.3: the turn lock serializes send, configure, compact, rollback and
fork). A request that followed a replacement answers under the
replacement's id, the id the pane holds after its `materialized` re-key (the
fork already answers that way). The helper takes the request kind
(`TurnLockedRequest`) so each follow is logged under its own event
(`freshagent.claude.{send,configure,compact,fork}_followed_replacement`; the
send's name is unchanged).
Red first (RealSidecarSim, the undo held by hold_rollback_boot):
- a_configure_parked_behind_an_undo_applies_to_the_replacement (answered
CLAUDE_SETTINGS_FAILED "Claude session is no longer available.")
- a_compact_parked_behind_an_undo_runs_on_the_replacement (answered
INVALID_SESSION_ID)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Design §4.2 (and §4.9: establish_runtime does "typed retries per §4.2") retries two boot refusals once; C12 deferred them and C13 did not add them, so a genesis boot whose transcript was flushed between the store check and the spawn surfaced as a transient attach failure. `claude_launch::retry_refused_boot` is the pure rule, decided again from a fresh look at the store: - SESSION_ID_IN_USE of a boot under an id (T4): the transcript appeared between the check and the spawn, so resume that conversation (where it was recorded, when the look finds it). - CONVERSATION_NOT_FOUND of a resume, with a genesis (T5): the transcript the check found is gone, so boot from the genesis (`decide_boot` on Absent). - Anything else stands: a resume with no genesis is lost, and a fork's own refusals (FORK_GUARD_REFUSED, RESUME_POINT_NOT_FOUND) are the caller's typed answers, never retried (decision 2). `establish_runtime` applies it once, re-arming the lease's kill handle and the start's cancellation on the retried sidecar, and logs `freshagent.claude.boot_retry` (lane, code, refused and retried boot kinds). The plan carries the retry context (`BootRetry`: the durable id and its recorded genesis) for the launches that boot a durable session from its recorded state, the attach-resume (the handoff resume is one) and the create's resume lane; an undo's replacement and a fork's child boot under ids they minted and carry none. Red first: - claude_launch unit tests, one per rule: a_boot_refused_in_use_is_retried_ as_a_resume_of_that_conversation, a_resume_refused_not_found_is_retried_ from_the_genesis (both failed against a stub), plus the guard other_refusals_are_never_retried. - freshagent_claude_genesis (RealSidecarSim, the store changed between the query() and the simulated CLI's boot): a_genesis_boot_refused_in_use_is_ retried_once_as_a_resume (the attach answered CLAUDE_ATTACH_RESUME_FAILED: SESSION_ID_IN_USE before), a_resume_refused_not_found_is_retried_once_ from_the_genesis (the create answered create.failed before). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
C11 added the shared real-ledger sink (`tests/common/ledger_sink.rs`) but left two copies, in cross_kind_liveness.rs and freshagent_claude_kill_after_restart.rs, and C12 had to add the genesis and plugins plumbing to each. Both now use the shared sink; the copies are gone (about 750 lines). The cross-kind copy also ran the production bridge's authoritative-write ownership consult, which the shared sink omitted. The consult moves into the shared sink (`authoritative_write_refusal`, run inside the write as in `freshell-server/src/identity_sink.rs`), with the coordinator given by `LedgerSink::with_ownership`; an authoritative write to a sink with none is refused, as in production. The claude rig now hands its sink the one coordinator it wires into the lane, so its handoff lanes run the same consult production does. Behavior the two suites now get from the shared sink that their copies stubbed: the tri-state provenance policy, provenance and create-request read-backs, the supersession chain and the observed pair on binding writes (all as production has them). Results: cross_kind_liveness 51/51 (run in the sandbox, per its header), freshagent_claude_kill_after_restart 2/2, the claude rig suites (launch_parity, rollback, fork, genesis, lifecycle) all green. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…achinery (F4) C13's rollback registers its replacement already adopted and swaps the original's map entry and ids onto it in one step under the map locks (`replace_runtime`), so two pieces of the deleted teardown/respawn/init pre-read flow had nothing left to serve: - The adoption's `supersedes` parameter: every production caller passed None. It is removed from adopt_session_init, its deferral, its test-only completion variant and the test driver, together with the "preseed" fork-rekey branch, the supersession edge on the adoption's binding write and the InternalContinuation naming arm it selected. The in-stream remint gate (an init naming a durable id other than the one the session carries publishes the re-key bookkeeping only, never ownership) stays: C24's conversation reset needs it. - The MID_ROLLBACK_PARK_TICK loops in send, configure and compact (and InFlightRegistry::contains, which only they used): they parked a request whose session the old rollback had removed before re-inserting it. A resolution miss now means the session is gone. The three handlers share one resolver (`turn_locked_handles`). Tests: the_deferred_fork_adoption_carries_supersedes_and_never_claims_the_ new_key pinned only the removed pre-registration preseed path and is deleted. a_rollback_fork_rekey_never_adopts_ownership_under_the_new_id, a_failed_fork_rekey_publication_fails_the_adoption_through_the_completion and the handoff runner's re-key test drove the same assertions through the supersedes argument; they now drive them through the in-stream remint arm, which applies to the same sessions (they already carry another durable id), and their docs say so. The InFlightRegistry test now pins single-flight through try_acquire. Comments describing the removed window are corrected. freshell-freshagent lib: 1479/1479; claude rollback, fork and control-reply ws suites green. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…for every launch (F5) Design §4.9 runs every launch through "claim → lease(id) → decide_boot → spawn … → binding write". establish_runtime held only the middle, so each lane hand-rolled the rest: four copies of the provider-lease claim with its expired-holder kill-and-retry (create-resume, attach, handoff resume, rollback) and two copies of the genesis binding upsert (rollback, fork). F1 was a concrete cost: the create lane missed the boot decision. establish.rs now holds both: - `claim_launch_lease(durable, request_id) -> LaunchLease` runs the claim and the one expired-holder kill (tree confirmed dead by ownership tag, then one retry), logging the unconfirmed-kill invariant itself; each lane keeps only its own answer to Acquired / BoundLive / Held / ExpiredUnconfirmed (adopt, rebind, refuse), unchanged. - `record_genesis_binding(GenesisBinding)` writes a minted session's row (genesis, launch settings, supersedes, provenance, fenced with the launch's ticket), awaited; a lane with no ledger records nothing. The boot decision itself already runs in every lane that boots a durable id (decide_attach_boot, decide_create_resume_boot, and the rollback's and fork's own effective-transcript reads), and the typed retries in establish_runtime (F3). No behavior change. Green: freshell-freshagent claude + session_handoff unit tests (514), the claude ws suites (rollback, fork, genesis, launch parity, attach, kill after restart), and in the sandbox freshagent_session_lease (9/9, its expired-holder kill path) and cross_kind_liveness (51/51). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
No browser lane exercised ROLLBACK_REFUSED: the refusal and the pane staying usable afterwards were pinned only by the Rust test an_undo_of_a_turn_claude_folded_a_message_into_is_refused_and_the_session_ stays_live, so a client regression in how the refusal shows (the composer refill, the pane left waiting on the undo) would pass every e2e spec. New lane in fresh-agent-rollback-rust.spec.ts (real sidecar over the CLI simulator): one prompt, then a background agent (`AGENT:async`) whose completion notice the CLI writes into the launching turn (T19); /undo is refused with the server's pinned reason on the notice banner, the simulator booted exactly one guarded fork (never a guard-less retry), the pane keeps its durable id, its conversation and an empty composer, and the next prompt runs in the same conversation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in #864 (Windows/Linux tree-guard fixes and the shared process_probe test support), #866/#868/#869 (flaky-test fixes) and #867 (managed helper create retry) on top of core PR-C. No textual conflicts. Seven files changed on both sides and merged cleanly; each was checked by hand: - claude.rs: main's await_create_ownership_committed / await_create_adoption_committed helpers and their call sites land in PR-C's slimmed test module unchanged (12 uses, same as main). - claude_snapshot.rs: main's "never clear HOME" fix applies to the locate_transcript test; PR-C's genesis tests do not touch HOME. - claude/teardown_tests.rs, lib.rs: main's zombie-aware ProcessProbe module and the REST turn test-pause handshake sit beside PR-C's module reshuffle. - opencode_ws.rs, session_handoff/tests.rs: independent hunks. - fresh-agent-rollback-rust.spec.ts: main's wait for the codex undo refill before typing /redo applies inside PR-C's reworked spec. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…relay process The two "exits on its own" stdout-loss cases need the ONE write a sidecar makes after losing its stdout reader to fail with EPIPE. They closed the reader inside the Vitest process, which also spawns children for other test files; each child holds a copy of every descriptor from fork until exec, and for a large Vitest process that window is long. A write in it lands in the copy, no EPIPE follows, and the test sees no stdout.closed record (seen in a 79-file focused run: both cases failed, exit codes right, stdout.closed missing). Reproduced by repeating the boot-refused case while another test file in the same process held 4 GB and spawned /bin/true in a loop: 20 of 20 runs missed the EPIPE with the reader closed in the test process, 0 of 20 with the reader in the relay (twice each). The relay (fixtures/stdout-reader-relay.mjs) runs the sidecar entry as its only child, forwards stdin and stdout lines, inherits stderr, and on SIGUSR2 closes the only reader and answers relay.reader_dropped; it exits the way the sidecar exits. The mid-stream case keeps closing the reader in-process: its sidecar keeps writing, so a later write fails. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ssProbe The fork and rollback integration tests each carried a private copy of `process_gone`, with a weaker rule than the ProcessProbe that #864 added for the freshagent tests: any `Z` state counted as gone (a killed multi-threaded node whose other threads are still exiting read as gone early) and the pid was not pinned to its start time (a reused pid read as still running until the budget ran out). freshell-ws's test harness now includes the same process_probe.rs file, and ClaudeRig gains `runtime_pids` and `runtime_processes`, which opens a probe on the sidecar and simulated CLI while they run (asserting they do, so a wrong pid can no longer make the wait pass vacuously). The rollback test opens A's probes before the undo; the fork test opens the child's while its fork is parked, before the pane closes. Both tests then wait with `RuntimeProcesses::wait_until_exited`. 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.
This is core PR-C of the freshclaude core-runtime redesign (
docs/plans/2026-10-22-freshclaude-core-runtime.md§10, steps C11 to C14). It builds on PR-A (#863) and PR-B (#865), which are already merged.What users get
What changed, step by step
claude_launch.rsbuilds every launch from oneClaudeLaunchSpec/create_request, and plugins are saved with the session.claude/establish.rsis the single path that claims the session, decides how to boot it and spawns it.handle_createreturnsClaudeCreateOutcome(I1), and the boot decision (decide_boot) reads aTranscriptLocation(I8).ROLLBACK_REFUSED, A stays live and still accepts messages. A spawn failure also leaves A live. A message waiting behind the undo goes to C. Undo steps now start exactly where the pane shows a turn the user started (N7), so/compactoutput and background-agent notices no longer count as their own steps. Two new contract scenarios record how the pinned CLI refuses such a fork: a message absorbed during a tool call, and a background agent's notice. The CLI simulator now matches both.handle_forkboots the child at once and checks that it started.freshAgent.forkedgoes only to the connection that asked. The child is live immediately (decision 5), and its first message writes its transcript under the same id. A fork while the parent is busy answersBUSY_TURN.Review fixes
CONVERSATION_NOT_FOUND. It now boots from the recorded origin./compactthat was waiting behind an undo now applies to the replacement. Before, it was dropped with "session no longer available".SESSION_ID_IN_USE(the transcript appeared after the boot check) now resumes the conversation. A resume refused withCONVERSATION_NOT_FOUNDboots from the recorded origin. Each case is retried once, from a fresh check of what is on disk.establish.rsand every launch uses them, replacing four copies of the lease claim and two copies of the origin record write.stdout-loss-shutdown.test.ts, which is already on main, failed under heavy load: other test files' child processes could briefly hold the sidecar's output pipe open. A small relay process now holds that pipe on its own. The bug reproduced 20 out of 20 times before the fix and 0 out of 20 after.ProcessProbethat fix(freshagent): confirm a sidecar tree gone only once every member has exited, and stop the teardown tests' zombie flake #864 added. It counts a process as exited only when no thread is left, ties each process id to the process's start time so a reused id isn't mistaken for it, and confirms the processes are running when it starts watching them.Merge with main
origin/main(#864, #866 to #869) merged in with no conflicts (4f2edcf7c). Seven files changed on both sides and merged cleanly. I checked each by hand, and main's fixes in them are kept, including the 12 uses ofawait_create_ownership_committed/await_create_adoption_committed. The live-transcript PR is not on main yet and is not part of this PR.Tests
On the merged tree:
cargo fmt --checkpasses.cargo clippy --workspace --exclude freshell-tauri --all-targets -D warningspasses.The pre-push hook ran
cargo test --workspace --exclude freshell-tauri(6553 passed, 0 failed) and the TypeScript typecheck, then printedpre-push: OK. After the last commit, it ran again for the changed crates (freshell-freshagent, freshell-server, freshell-session-host, freshell-ws) with fmt, clippy and the typecheck, andcargo testfor those crates passed 4267 tests with 0 failures, then printedpre-push: OK.cargo test -p freshell-freshagent: lib 1481 passed,claude_sidecar_interrupt_dispatch1,claude_sidecar_teardown15,transcript_cwd_export2. 0 failed.freshell-ws claude and fresh-agent test binaries, 0 failed:
claude_restore_unavailableclaude_session_rebindfreshagent_claude_attachfreshagent_claude_checkpointed_sendfreshagent_claude_forkfreshagent_claude_genesisfreshagent_claude_kill_after_restartfreshagent_claude_kill_interruptfreshagent_claude_launch_parityfreshagent_claude_lifecyclefreshagent_claude_real_cli(pinned CLI against the local fake endpoint, plus the simulator)freshagent_claude_rollbackfreshagent_control_replyfreshagent_create_disabled_refusalfreshagent_rollback_refusalfreshagent_session_leasepane_reconcile_freshagentrest_claude_identityAfter the shared-probe change,
freshagent_claude_fork(8) andfreshagent_claude_rollback(17) pass. Each also passed 12 out of 12 repeat runs, 8 at a time undernice -n 19, leaving no stray processes. As a check, I pointed the undo test's wait at the replacement's runtime, which is still running. It failed after the time limit with "process … is still running", which shows the wait really detects a running process.Session host claude tests: 1 + 23 unit,
claude_source_observation4,native_history4.freshell-serveridentity_sink: 33. 0 failed.pnpm run typecheckpasses.Vitest (
test/unit/claude-sidecar,test/integration/providers, client fresh-agent tests): 79 files and 1584 tests passed.Cloud e2e, 4 shards, on
4f2edcf7c: 51 passed, 0 failed, 0 flaky. The specs werefresh-agent-rollback-rust,freshclaude-fork-rust,fresh-agent-control-rust,freshagent-default-model-rust,fresh-agent-error-turn-attention,freshclaude-restart-parity-rustandfreshclaude-identity-persistence-rust.No test talks to the real Anthropic API. The pinned-CLI lane uses a local fake endpoint with a dummy key.
🤖 Generated with Claude Code