Skip to content

feat(freshclaude): one launch spec, conversation genesis, undo/redo that works with the real CLI, and fork (core PR-C) - #870

Merged
danshapiro merged 26 commits into
mainfrom
feat/freshclaude-core-history
Oct 23, 2026
Merged

danshapiro merged 26 commits into
mainfrom
feat/freshclaude-core-history

Conversation

@danshapiro

Copy link
Copy Markdown
Owner

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

  • Undo and redo work with the real Claude CLI and answer within their time limit. Before this change, every undo waited for a startup message that the real CLI only sends after a prompt, so undo always timed out. Now undo starts the shortened conversation as a new session, checks that it started, and only then stops the old one. An undo answers within 20 seconds, and usually in about a second.
  • Undo refuses with a clear reason when it can't be done safely. Sometimes Claude folds another message into a turn. This happens when you send a message while Claude is working, or when a background agent reports back. The CLI won't cut such a turn cleanly. In that case undo says why it refused, and the pane keeps its conversation and stays usable. It never retries without the safety check.
  • Forking a conversation opens the child pane right away. The child holds the parent's conversation up to the fork point. The parent's transcript is not changed. If you close the parent pane while the child is still starting, no orphaned child is left running.
  • A resumed pane keeps its plugins and hosted MCP. Every way of starting a session builds the same launch request: create, resume on attach, handoff, undo, redo and fork. Before, some of these dropped the pane's plugins and its connection to the Freshell MCP server.
  • An undone or forked conversation that hasn't sent a message yet survives a restart. The session's record now notes where the conversation came from: a fresh start, or a fork of another conversation at a given turn. After a server restart, the pane rebuilds it from there, with its earlier turns, instead of losing it.

What changed, step by step

  • C11: one launch spec and one establish path (G15). claude_launch.rs builds every launch from one ClaudeLaunchSpec / create_request, and plugins are saved with the session. claude/establish.rs is the single path that claims the session, decides how to boot it and spawns it. handle_create returns ClaudeCreateOutcome (I1), and the boot decision (decide_boot) reads a TranscriptLocation (I8).
  • C12: where a conversation came from, in its binding row. The binding row records the conversation's origin (its "genesis": a fresh start, or a fork of conversation A at a given turn). Snapshots use it: a fresh start shows empty, and a fork shows the parent's turns up to the fork point. So does the restart check, so a pending fork comes back instead of reading as dead. The origin is cleared once the session's own transcript is on disk. This step also adds a pending session booting from its origin, and moves that clearing check off the sidecar's output reader.
  • C13: undo and redo through a fork that is checked before it is used (G01). An undo creates the replacement's id (C) and boots a fork of A's transcript under C while A is still live. It then records C's binding (with its origin, and noting that C replaces A), moves ownership from A to C, and stops A last. If the CLI's safety check refuses the fork, the answer is 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 /compact output 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.
  • C14: fork a conversation (G09). handle_fork boots the child at once and checks that it started. freshAgent.forked goes 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 answers BUSY_TURN.

Review fixes

  1. A create that resumes a pending session boots from its origin (PRC-1, F1). When an undo's replacement or a fork's child lost its sidecar before its first message, the pane's Resume used to fail with CONVERSATION_NOT_FOUND. It now boots from the recorded origin.
  2. A hosted undo keeps the session's settings (PRC-2). The replacement launches with the live session's settings (plugins, model, effort and permission mode). Before, a hosted undo started the replacement with no plugins.
  3. Forking when the parent pane closes mid-fork (PRC-3). If the pane closes the parent while the child is starting, the fork refuses. The child's runtime is torn down, and no record offers the child for restore.
  4. Requests waiting behind an undo go to the replacement (F2). A configure or /compact that was waiting behind an undo now applies to the replacement. Before, it was dropped with "session no longer available".
  5. Typed boot retries (F3). A boot refused with SESSION_ID_IN_USE (the transcript appeared after the boot check) now resumes the conversation. A resume refused with CONVERSATION_NOT_FOUND boots from the recorded origin. Each case is retried once, from a fresh check of what is on disk.
  6. Old rollback code removed (F4). The adoption and waiting code from the old rollback design (pre-v2) served nothing after C13, so it is deleted.
  7. One lease claim and one origin record write (F5). These now live in establish.rs and every launch uses them, replacing four copies of the lease claim and two copies of the origin record write.
  8. One shared ledger for the WebSocket tests (F6). About 750 lines of copied test setup are gone. The shared copy now also runs production's ownership check on authoritative writes.
  9. E2E test for the honest undo refusal (F8, decision 2). A browser test checks that the refusal shows its reason, the pane keeps its conversation and an empty input box, and the next prompt runs in the same conversation.
  10. Origin round-trip doc comment (F9). This doc comment now sits on its own test.
  11. Sidecar shutdown test made stable under load. 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.
  12. One rule for "this process has exited" in the WebSocket tests. The fork and undo tests had their own weaker copy of this check. They now use the ProcessProbe that 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 of await_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 --check passes. cargo clippy --workspace --exclude freshell-tauri --all-targets -D warnings passes.

  • The pre-push hook ran cargo test --workspace --exclude freshell-tauri (6553 passed, 0 failed) and the TypeScript typecheck, then printed pre-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, and cargo test for those crates passed 4267 tests with 0 failures, then printed pre-push: OK.

  • cargo test -p freshell-freshagent: lib 1481 passed, claude_sidecar_interrupt_dispatch 1, claude_sidecar_teardown 15, transcript_cwd_export 2. 0 failed.

  • freshell-ws claude and fresh-agent test binaries, 0 failed:

    Test binary Passed
    claude_restore_unavailable 4
    claude_session_rebind 1
    freshagent_claude_attach 8
    freshagent_claude_checkpointed_send 4
    freshagent_claude_fork 8
    freshagent_claude_genesis 5
    freshagent_claude_kill_after_restart 2
    freshagent_claude_kill_interrupt 4
    freshagent_claude_launch_parity 4
    freshagent_claude_lifecycle 12
    freshagent_claude_real_cli (pinned CLI against the local fake endpoint, plus the simulator) 15
    freshagent_claude_rollback 17
    freshagent_control_reply 17
    freshagent_create_disabled_refusal 2
    freshagent_rollback_refusal 2
    freshagent_session_lease 9
    pane_reconcile_freshagent 13
    rest_claude_identity 6
  • After the shared-probe change, freshagent_claude_fork (8) and freshagent_claude_rollback (17) pass. Each also passed 12 out of 12 repeat runs, 8 at a time under nice -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_observation 4, native_history 4. freshell-server identity_sink: 33. 0 failed.

  • pnpm run typecheck passes.

  • 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 were fresh-agent-rollback-rust, freshclaude-fork-rust, fresh-agent-control-rust, freshagent-default-model-rust, fresh-agent-error-turn-attention, freshclaude-restart-parity-rust and freshclaude-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

danshapiro and others added 26 commits October 22, 2026 21:13
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>
@danshapiro
danshapiro merged commit c98bc37 into main Oct 23, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant