fix: rebind pane session identity when the CLI switches sessions mid-pane (codex fork lineage, claude SessionStart hook) + association starvation fixes - #567
Merged
Conversation
…ation findings Validated 13 load-bearing assumptions (7 verified, 6 falsified); fixes: - Task 3/4: fork-candidate predicate now requires thread_source=='user' (subagent forks dominate ~100:1 on the real substrate and land in-window); fixtures/tests model the subagent collision; source is polymorphic. - NEW Task 7b: ledger-chain correction rung in terminal-pane reconcile (fresh-agent G3 pattern) closing the absent-client + restart hole. - Task 2: corrected cross-tab rationale (local-wins merge; per-tab WS + pane.reconcile are the real defense) + local-wins pin test. - Task 8: windowless same-cwd rollout test + codex paste-burst dependency note. - Task 9: orphan pending-marker GC restricted to periodic sweep, TTL 24h->7d, honest zero-reader rationale. - Tasks 11-12: claude /resume does not fork by default (--fork-session is opt-in); predicate stays lineage-free; hook degradation notes. - Tasks 13-14: amplifier/opencode rebind DESCOPED to audit-doc-only (no user-switch lineage substrate; correlation rebind is hijack-capable). - Global constraints: A12 stale-expectedSessionRef benignity + never emit SESSION_IDENTITY_MISMATCH without chain-awareness; A1 no-bump precedent. Ledger: .worktrees/.the-usual-logs/stale-resume-identity/load-bearing-ledger.md
…ated Additive, optional, server->client only. WS_PROTOCOL_VERSION deliberately NOT bumped: the field is not client-validated (old clients ignore it and keep the conflict veto), and a bump hard-disconnects every live client.
…ousSessionId When a terminal.session.associated frame carries previousSessionId equal to the pane's current sessionRef.sessionId (same provider), the client accepts the rebind instead of vetoing as a conflict: it dispatches the existing reconcileTerminalSessionRefByTerminalId reducer and flushes the persisted layout so the rebound ref reaches localStorage before a reload. Write-once behavior is preserved: absent or mismatched previousSessionId still returns 'conflict'. Only frame-driven call sites (App.tsx message handler, TerminalView terminal.session.associated handler) thread the field; synthesized call sites (terminal.inventory, terminal.created, terminal.attach.ready) are untouched. Also pins the existing local-wins cross-tab hydrate merge that protects a rebound tab from a stale tab's flush. 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
…lineage
Probe::Candidate becomes a struct variant carrying forked_from_id and
thread_source (trim-nonempty, matching parse/codex.rs semantics) so
Task 4's ForkWatch lane can distinguish a user /resume fork
(thread_source:"user") from the far more common subagent spawn.
The polymorphic sibling `source` key (string on user sessions, object
{"subagent":{...}} on subagent sessions — validated A4) is deliberately
NOT read; tests cover both shapes via the new write_rollout_full helper
(write_rollout now delegates to it).
🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)
Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
…n in-TUI fork detection Second, independent lane for a BOUND pane: watch_fork snapshots the rollout file set, note_fork_submit opens a 30s Enter-anchored scan window, tick_forks claims NEW rollouts whose session_meta carries forked_from_id == the watched session id AND thread_source == "user" (lineage alone is not proof -- subagent forks dominate ~100:1 on the real substrate; the user filter makes the match positive proof, so no cwd census applies). One-shot per fork with auto-advance for chained forks; ambiguity (n>=2 user forks in one window) refuses; disarm() clears the watch. Task 5 consumes ForkLocated to drive the rebind. 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
…entity move, tailer re-attach, previousSessionId broadcast The server-side rebind write path for in-TUI /resume forks (plan 2026-07-28-stale-resume-identity.md, Task 5): - codex_identity.rs: extract the shared hijack/misbind guards into codex_claim_refused() and the pinned write tail into apply_codex_identity() (order unchanged: identity.upsert -> set_meta -> awaited ledger -> associated THEN meta.updated -> activity hub). Add rebind_codex_identity() -- a guarded identity move requiring (1) the pane to be the LIVE owner of the superseded id (D7 predicate), (2) no live owner of the new id (A13), (3) the shared adoption guards on the claimed id. broadcast_terminal_session_associated gains previous_session_id and sets the Task 1 frame field. - codex_association.rs: register watch_fork after a successful adopt, open the fork-scan window on EVERY Enter (note_fork_submit, deliberately ungated -- bound panes must reach it), and drain tick_forks results into rebind_codex_identity on the same sweep. - activity.rs: document that attach_codex_lane's insert REPLACES the lane (dropping the old RolloutTailer + watcher) -- now load-bearing for the fork re-attach. - tests: codex_fork_rebind integration happy path (associated frame carries NEW id + previousSessionId == OLD; registry meta follows); pane-ledger regression guard for resolve_pending without a marker (supersession + corrected chain lookup). 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
… follow-up) The fork-lane calls added in Task 5 (watch_fork after a successful adopt, tick_forks in the fork drain) ran their bounded sessions-tree walks directly on a tokio async worker, contradicting the module's stated spawn_blocking discipline. Wrap both in tokio::task::spawn_blocking with the same Arc-clone and JoinError-warn handling as the adoption tick. No semantic changes: same call order, arguments, and drain behavior. 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
…; correct locator premise doc Resume-launched codex panes (spawned `codex resume <id>` from a create carrying sessionRef -- including every restart-restored pane) are correctly refused by the adoption arm() gate, but previously got NO fork watch: an in-TUI /resume fork (intermittent, upstream openai/codex#34972) would leave the pane permanently stale (incident 2026-07-27). - terminal.rs: register `watch_fork(terminal_id, resume_session_id)` at create, inside the existing spawn_blocking arm block (the watch snapshot is a bounded fs walk -- same blocking-pool discipline as 30a3436). - codex_locator.rs: correct the FALSE module-doc premise -- CLI-launch `codex resume <id>` appends to the ORIGINAL rollout; in-TUI /resume MAY fork to a NEW rollout with forked_from_id lineage. - codex_fork_rebind.rs: new e2e test proving a sessionRef-created pane gets fork detection (rebind broadcast NEW + previousSessionId OLD, registry meta NEW); ENV_LOCK serializes the binary's env mutation. 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
…iler re-attach regressions
Task 7 crown coverage for the stale-resume-identity plan (incident
2026-07-27, 019fa60f -> 019fa613), extending codex_fork_rebind.rs:
- after_rebind_a_recreate_resumes_the_new_session_id: adopt OLD ->
in-TUI fork rebind to NEW -> kill the pane -> recreate with
sessionRef {codex, NEW} + restore:true (exactly what a client that
accepted the rebind replays after a server restart). Argv capture
proves the respawned CLI runs `resume NEW` and never `resume OLD`
(the incident's actual harm); registry meta is NEW.
- fork_targeting_a_live_owned_session_is_refused: a forged fork
(payload.id == pane2's LIVE session, forked_from_id == pane1's
binding) passes every locator filter but dies on the A13 rebind
guard -- no associated frame moves pane1; both panes' registry meta
hold. Genuineness proven by mutation: pointing the forgery at an
UNOWNED id rebinds pane1 and fails the absence assertion.
- in_tui_fork_rebinds_the_pane_identity gains the tailer phases: a
pre-fork turn on rollout OLD emits turn.complete keyed OLD (positive
baseline so the absence proof cannot pass vacuously), a post-rebind
turn on rollout NEW emits turn.complete keyed NEW (tailer followed
the fork), and the same turn shape on rollout OLD after the rebind
emits NOTHING (the superseded tailer is detached).
All phases pass against Tasks 4-6 as landed (pinning tests, expected
per the task brief -- the write path was completed in dde2003 +
146c5c3; this is the end-to-end user-story regression net).
🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)
Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
… through the pane-ledger chain Closes the absent-client + restart hole (A3): rung between registry resolution and the echo-claim fallback follows supersededBy to the terminus with corrected:true, mirroring the fresh-agent G3 reader rule. 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
…, ending permanent codex association starvation P2 audit result recorded: opencode_locator has no cross-terminal census (per-terminal ambiguity only) and amplifier_locator uses a first-come claimed mutex -- the permanent starvation shape was codex-only; no changes needed in those locators.
…TTL, periodic sweep only) Safe vacuously: no production reader of pending markers exists at any age (pending_for_terminal/list_pending_raw have zero non-test callers). Boot path passes None -- its live set is empty pre-serve, so running the rule there would sweep every old marker at every boot. 7-day TTL preserves the multi-day forensic window this plan's own diagnosis used.
… sessions, not subagents (P5)
…ion-id signal files; FRESHELL_TERMINAL_ID env P4 of the stale-resume-identity plan: claude writes NO lineage field on disk, so the deterministic in-TUI session-switch signal is a SessionStart hook injected via --settings. The hook writes its stdin JSON (session_id, source, cwd) atomically (tmp+rename) to $HOME/.freshell/session-signals/claude/<FRESHELL_TERMINAL_ID>__<nonce>.json; Task 12 consumes the files (the __ delimiter is load-bearing -- terminal ids contain '-'). - New constants CLAUDE_SESSION_START_COMMAND_UNIX/_WINDOWS with A7 degradation notes (disableAllHooks, allowManagedHooksOnly, --bare silently disable the hook -- graceful degradation, never corruption). - claude_settings_json rewritten to serde_json construction (preserve_order keeps SessionStart before Stop); the string-concat literal and its json_escape_into helper retired. Stop bell hook unchanged. - Goldens CLAUDE_SETTINGS_UNIX/_WIN updated FIRST (verified RED against the old implementation), then implementation landed (GREEN). - FRESHELL_TERMINAL_ID env injection: already provided for EVERY PTY (shell + coding-CLI) by build_terminal_base_env in crates/freshell-ws/src/terminal.rs (pinned by base_env_carries_all_freshell_vars); no terminal.rs change needed. 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
…files (P4 -- deterministic, no heuristic coordinator port) Consumes the signal files Task 11's SessionStart hook writes to $HOME/.freshell/session-signals/claude/<FRESHELL_TERMINAL_ID>__<nonce>.json: - New crates/freshell-ws/src/claude_signal.rs: ClaudeSignalWatcher drains (read+parse+DELETE) every *.json in the root, recovering the terminal id by splitting the file stem on the LAST __ (rsplit_once -- the nonce can never contain __, so a LAST-split always recovers the full id). drain_and_rebind_claude moves a LIVE claude pane's identity when the CLI reports a NEW session id (in-TUI /resume, /clear), guarded: same-id signals are silent no-ops (SessionStart also fires on startup and EVERY compaction, A7), the claimed id must have no live owner (A13), must not be bound elsewhere retired-inclusive (ledger A8), and must not be a fresh-agent session. Side effects in the pinned codex-tail order: identity -> meta -> ledger (awaited) -> associated THEN meta.updated. - codex_identity::broadcast_terminal_session_associated is now pub(crate) and provider-parameterized (codex callers pass "codex") -- one shared broadcaster, no copy. - freshell-server boot spawns the 1s signal sweep next to the locator sweeps, rooted at ClaudeSignalWatcher::default_root(); NO new WsState field (the watcher is owned by the sweep task). - Tests: watcher unit test (parse+delete incl. malformed); integration tests/claude_session_rebind.rs covers rebind broadcast/meta (phase 1), the restart story --resume B (phase 2), and the A13 hijack refusal (phase 3, guard genuineness proven by mutation). 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
… no session:start lineage; predicate matches only subagents)
… writes nothing; time_updated has non-switch writers; no fork lineage)
…inalSession guards (P6) The codex fork-handoff commit path now threads the parent (pre-swap) session id into the terminal.session.bound internal event and the record-scoped terminal.session.associated sends; server/index.ts forwards it onto the fanout as previousSessionId so the client accepts Node-originated mid-session rebinds. Adds the previously-missing swapTerminalSession guard coverage (five behaviors pinned). 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
…ontested-cwd wording, claude drain off-runtime Finding 1 (Important): tick_forks eagerly advances the fork watch to the child session id BEFORE the ws-layer rebind guards run; a REFUSED rebind (e.g. A13 hijack guard) left the watch tracking a session the pane does not own, silently missing any later genuine fork of the pane's real session. The fork drain's refused branch now re-registers the watch with the OLD id via spawn_blocking watch_fork (mirroring the adoption-lane pattern), which also re-snapshots known_files so the refused child can never re-fire. Covered by a locator-level unit test (refused_rebind_recovery_rewatch_still_detects_a_later_genuine_fork) and an extension of the hijack integration test: after the refused hijack, a genuine user fork of pane1's real session still rebinds end-to-end. Finding 2 (hygiene): the contested-cwd warn (and the tick() doc bullet + module-doc bullet with the same stale phrasing) said '>=2 armed terminals share this cwd' but the census counts CONTENDERS (armed terminals with in-flight evaluation windows). Message/comment text only. Finding 3 (hygiene): claude_signal's drain() did synchronous fs I/O on the async runtime inside the 1s sweep; the call site in drain_and_rebind_claude now runs it via tokio::task::spawn_blocking (JoinError -> warn + skip cycle), mirroring the codex fork lanes (30a3436). drain() itself stays synchronous; guard chain unchanged. 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
Weaves #566's launcher-assigned amplifier identity onto this branch's rebind lanes (codex fork-lineage, claude SessionStart hook, pane-ledger supersede, previousSessionId wire field). Conflict resolution: - crates/freshell-ws/src/amplifier_association.rs (modify/delete): accepted main's deletion. #566 removed the amplifier association module entirely (launcher assigns amplifier session identity at spawn, so post-hoc association is gone). Our branch's only edit to that file was adding previous_session_id: None to its broadcast -- moot once the module is deleted. No amplifier rebind lane exists on this branch (audit-descoped), so nothing is lost. - crates/freshell-ws/tests/claude_session_rebind.rs (semantic): WsState changed on main -- amplifier_locator field removed (#566), auto_resume_tx field added. Updated our test's WsState literal to match, same as main did for all pre-existing ws tests. Semantic cross-check: #566's fixed-at-launch amplifier identity is consistent with this branch's amplifier audit conclusion (session id cannot change mid-pane; rebind not applicable). previousSessionId on TerminalSessionAssociated coexists with main's fresh-agent previous_session_id field; contract regen is diff-clean. 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
This was referenced Jul 29, 2026
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.
Summary
Fixes pane↔session identity binding to track when users switch sessions inside a CLI (e.g., codex in-TUI
/resumewhich forks to a new session id). Panes now rebind to the new session identity deterministically via CLI-emitted signals (codex fork lineage file, claude SessionStart hook), guarded by per-provider audit findings. Also fixes contested-cwd session starvation and orphaned pending-marker cleanup.Root Cause
Pane identity was write-once: assigned at spawn and never updated. When users switched sessions within the CLI (codex's
/resumeforks to a new session id), freshell kept the pane bound to the old (forked-from) id, causing resume to resurrect stale conversation context. This was invisible because the old id was still valid — just wrong.What Was Delivered
Codex Fork-Watch Rebind (P1)
forked_from_<session_id>) when a session forkspreviousSessionIdwire field (client authoritative acceptance; won't rebind to an unknown session), ledger-chain reconcile rung at startup (prevents orphaning), ledger retain policy (no early pruning)FRESHELL_FORK_REBIND=0kill switch disables the rebind, falls back to today's behaviorClaude SessionStart Hook Injection (P2)
Node Fork Handoff (P3)
swapTerminalSessionnow emitspreviousSessionIdwire fieldContested-CWD Session Starvation (P4)
onFirstSetCwdlogic couldn't disambiguate which session the CWD "belonged" toOrphaned Pending-Marker Cleanup (P5)
User-Fork Sidebar Visibility (P6)
Audit Docs
/resume, session id bound at construction). Rebind descoped as not applicable.Tests & Verification
✅ Full Rust suite passes (clippy -D warnings clean)
✅ 4075 JavaScript unit tests passing
✅ Integration tests: codex fork-watch, claude hook injection, Node fork handoff parity
✅ Smoke test: Manual verification of fork→rebind→restart with real codex binary
✅ Configuration freeze: No external schema changes
Files Changed
Generated with Amplifier