Skip to content

feat: bounded auto-resume + loud exited-pane banner for crashed agent CLIs (katas 7w4h, xkhx) - #560

Merged
danshapiro merged 22 commits into
mainfrom
feat/agent-crash-resilience
Jul 28, 2026
Merged

feat: bounded auto-resume + loud exited-pane banner for crashed agent CLIs (katas 7w4h, xkhx)#560
danshapiro merged 22 commits into
mainfrom
feat/agent-crash-resilience

Conversation

@danshapiro

Copy link
Copy Markdown
Owner

katas 7w4h + xkhx — crash resilience for coding-agent terminal panes (motivating incident: an overnight claude pane crashed exit-1 at 03:57 and sat silently grey for 6 hours).

(1) Bounded server-side auto-resume: non-zero agent CLI exits retry the provider resume with backoff and user-visible typed notices; never for clean exits, plain shells, user kills, or idle-reaped terminals (discriminator is structural — council-verified); retry exhaustion settles exited; respawn cap bounds crash loops; auto-resume respawns acquire the shared spawn gate (mutation-verified pin).

(2) Loud exited-pane banner: "process exited (code N)" + one-click Relaunch resuming the SAME session (never silently fresh — resume path hardcodes the session id).

(3) Status integrity: chime/dedupe survive server-driven terminal replacement (pinned).

Council review (7-lens): LAND, zero blockers; two MEDIUM fixes applied post-review: auto-resume notices now carry typed attempt/maxAttempts/exitCode frame fields (client regexes on prose deleted; contract regenerated, both pins updated, test:port 38/38) and the auto-resume hub is now self-supervising (catch_unwind + bounded escalating restart backoff — a driver panic can no longer silently end auto-resume). Remaining non-blocking council findings tracked as kata znhn.

Verification: cargo workspace 2091 green, coordinated JS green (4327/4640/350), lint clean, e2e 19/19 incl. full restore-contract-wall.

danshapiro and others added 22 commits July 27, 2026 11:37
…dation findings

Stage-2 validation of 24 assumptions (18 verified, 4 falsified) against the
actual repo. Falsifications fixed:
- exited panes clear terminalId -> lifecycle slice re-keyed by paneId with
  lastTerminalId frame-matching (Tasks 6/7, D-2)
- second exit-hook site in fenced freshagent crate -> REST panes documented
  out of auto-resume scope (Task 2)
- codex/opencode/amplifier session ids discovered at first prompt -> per-
  provider provenance documented in D-5
- terminal-pane BindingRow launch fields are None -> Task 4 derives params
  from state.settings

Verified-with-amendment: SpawnGate acquire added to the respawn seam; lease
holder_conn + release discipline pinned; binding-still-Bound pre-respawn
guard; corrected-flag semantics fixed with mandatory reconcile-after-
replacement pin test; fold payload mirrors pane-reconcile.ts:428-436.
…ash-during-launch path, lease-shaped driver trait

Independent cross-model review (iteration 1) found three major executable
defects in the agent-crash-resilience plan; all fixed:

1. Stale auto-resume notice masked the settled error bar: recordTerminalExit
   now clears any notice (pinned by a new slice test), Task 7's false
   'frames guarantee degradation' note replaced with a mandated TTL-expiry
   re-render backstop for silent settles.

2. Crash-during-launch path analyzed: recordTerminalExit moved to the top of
   the matched terminal.exit path (before the pendingDurableReplacement /
   exitedDuringLaunch early returns), showExitBanner extended to agent panes
   settled 'error' with a recorded non-zero exit (new scenario 6), and the
   subscriber-only exit fan-out concern closed via the verified
   synthesized-exit replay on attach.

3. Task 5 driver trait reshaped to the real asymmetric lease API:
   claim_session(+create_request_id) / complete_claim(new_terminal_id)->bool /
   fail_claim, plus pre_respawn_guard distinguishing session_owned_live from
   pane_closed; hub sketch, driver bullets, and unit-test list updated with
   two new scenarios (pane_closed guard, lease_completion_lost).
…NTIL survive semantics, test/unit/client test paths

1. Task 9 e2e test 4: fold FAKE_CRASH_UNTIL into the Step 1 fixture with
   explicit precedence over FAKE_CRASH_MODE (crash while invocation <= N,
   then SURVIVE — never falling through to the 'clean' exit-0 default),
   rewrite test 4's arrangement comment with a non-vacuous liveness
   assertion, and drop the incoherent once+pre-seed alternative.
2. Tasks 6/7/8 + File Structure table: move all 22 client test path
   references from src/{store,components}/ to the verified repo
   convention test/unit/client/{store,components}/ and replace the
   conditional placement hedges with the verified convention statement.

Adds a 'Fresh-Eyes Review Fixes (iteration 2 addendum)' section with the
re-run self-review.
…s in Task 6/7 test code

The iteration-2 fix relocated the fully-written client test files to
test/unit/client/{store,components}/ but left relative './' import
specifiers inside the provided test code, making both tasks' red->green
verification gates unachievable (module-not-found before AND after
implementation):

- Task 6 slice test now imports '@/store/terminalLifecycleSlice'
  (repo convention: tsconfig.json '@/*' alias, matching sibling
  test/unit/client/store/turnCompletionSlice.test.ts)
- Task 7 banner test now imports '@/components/TerminalExitBanner'

The '../store/terminalLifecycleSlice' import inside the
src/components/TerminalExitBanner.tsx implementation sketch is correct
as-is and untouched. Iteration-3 addendum documents the fixes and the
re-run self-review.
Task 2 of the agent-crash-resilience lane (Lane D1):

- WsState gains `auto_resume_tx: UnboundedSender<CrashEvent>`; the receiver
  is consumed by the auto-resume hub (Task 5) — until then tests drain it
  directly and other construction sites drop it (best-effort sends).
- The handle_create inline `on_exit` closure is extracted verbatim into
  `build_pty_exit_hook(ExitHookDeps, ...)` so the Task 4 respawn seam can
  reuse the exact hook for respawned generations. Only additions: probe +
  create_request_id reads BEFORE finish/retire mutate state, the `finished`
  capture from `finish_pty_exit`, and the gated CrashEvent send (natural
  exits only — user kills return false and never send).
- `CrashEvent` promoted pub (rides the public WsState field); its dead_code
  allow removed. The 6 remaining allows in auto_resume.rs stay (consumers
  land in Task 5) with updated comments; module doc now records the
  WS-created-only coverage boundary (REST/freshagent panes out of scope).
- New integration test `auto_resume_events.rs`: natural nonzero exit sends
  one CrashEvent with code/mode/createRequestId/lifetime; user kill sends
  none. Harness gains `spawn_server_with_specs_and_auto_resume_rx`.

🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
…uto-resume

🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
… spawn

Task 4 of the agent-crash-resilience lane: extracts the connection-independent
respawn seam the Task 5 auto-resume orchestrator will call. Mirrors
handle_create's CLI branch step-for-step (resume argv via
resolve_coding_cli_command with LaunchIntent::Resume, SpawnGate acquire,
spawn_blocking registry.create with the SAME createRequestId as the dead
generation, shared build_pty_exit_hook so respawned generations report their
own crashes, set_meta/identity/ledger-binding/locator-arm bookkeeping,
terminals.changed broadcast).

Shared helpers extracted (behavior-identical for handle_create):
cli_provider_settings (codingCli.providers[mode] with the codex strip) and
plan_codex_managed_launch (DEV-0006 S4 flag-gated codex plan).

Known deviation (brief-sanctioned): FRESHELL_TAB_ID/FRESHELL_PANE_ID are wire
fields not derivable from the pane ledger, so auto-resumed generations omit
them.

🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
…ing/replaced frames

Task 5 of the agent-crash-resilience lane: the hub that consumes the PTY
exit hook's CrashEvents and drives the full recovery pipeline —
decide() (Task 1 policy) → terminal.status{recovering} frame → backoff
sleep → post-sleep guards (live session owner / pane closed) → headless
sessionRef lease claim (full bounded ingress discipline, minted holder
conn id) → respawn_agent_terminal (Task 4 seam) → lease completion →
terminal.replaced frame (Task 3). Every settle path logs
terminal.auto_resume.settled with its reason.

- auto_resume.rs: spawn_hub_with_driver + AutoResumeDriver trait (lease-
  shaped: complete_claim/fail_claim distinct; claim/complete are async —
  the production impl awaits the kill→confirm lease discipline) +
  WsAutoResumeDriver production impl + pub spawn_auto_resume_hub[_with_
  delays]. All six Task-1 dead_code allows removed (now live).
- terminal.rs: kill_session_ref_holder_and_confirm /
  confirm_pid_dead_within_500ms widened to pub(crate) for the driver's
  lease paths.
- pane_ledger.rs: is_enabled() accessor — an ENABLED ledger's None from
  bound_session_ref_for_terminal means retired (pane_closed); a disabled
  ledger's None means nothing.
- main.rs: hub spawned from the boot path (spawn_idle_monitor precedent);
  the Task-2 dropped-receiver placeholder removed.
- tests: 9 fake-driver unit scenarios (paused time) + auto_resume_e2e.rs
  (real registry: 3 spawns then settle exited, recovering/replaced frames
  on a subscribed client, and the mandatory reconcile-after-replacement
  pin: old terminalId reconciles to an attach verdict naming the NEW live
  terminal with corrected absent). Harness gained a hub-ON spawn variant
  with injected delays (in-process env writes would leak across tests).

🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
…ling

Ephemeral (never-persisted) terminalLifecycle slice keyed by paneId with a
lastTerminalId frame-matching key, recorded at the top of TerminalView's
matched terminal.exit path (before the pendingDurableReplacement /
exitedDuringLaunch early returns) so every exit path captures it.
terminal.status 'recovering' frames and the new terminal.replaced frame
match old-terminalId frames via lastTerminalId; replacement folds the new
terminalId into the pane via applyReconcileAttach (incl. serverInstanceId).

🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
…agent panes

Task 7 of the crash-resilience lane (D1 user-facing surface):
- TerminalExitBanner: pure presentational role=alert error bar
  ('process exited (code N)', codeless post-reload variant) with a real
  <button> aria-labelled 'Relaunch <mode> session', plus the role=status
  auto-resume notice strip (recovering/resumed).
- TerminalView wiring: agent-pane-only render conditions covering settled
  'exited' (non-zero or unknown exit) AND settled 'error' with a recorded
  non-zero exit (crash-before-attach-ready via failLaunch); clean exits and
  shells stay quiet. Relaunch dispatches resetPaneForReconcileCreate
  (intent 'respawn', same sessionRef) so the pane resumes its session.
- Deterministic TTL backstop: one scheduled re-render at notice expiry so a
  'recovering' notice orphaned by a silent settle degrades to the alert
  without waiting for an unrelated re-render.

🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
… replacement

Lane D1 Task 8. Pins with a real-store harness (tabs/panes/settings/
turnCompletion/terminalLifecycle reducers + terminalDetachMiddleware):
- terminal.idle on the NEW terminalId chimes exactly once after the fold
  (fresh per-terminalId idle-dedupe baseline; replay at same 'at' deduped)
- terminal.idle on the OLD terminalId is dropped by the owner-lookup guard
  (no false chime, no baseline consumed)
- resolvePaneActivity reads via the NEW terminalId immediately after the
  fold; a leftover record on the dead old id cannot wedge the pane busy
- the fold emits NO terminal.detach for the old id

Production change (test 4 was red): add applyReconcileAttach to the
detach-middleware skip list — the terminal.replaced fold rebinds the pane
while the old terminal is already exited server-side, so a detach would
only draw a server error (and in the reconcile flow the fold runs on a
fresh connection before any attach, so no live subscription can leak).

🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
…et, relaunch

Task 9 of the agent-crash-resilience lane: end-to-end proof of every user
story with a real RustServer + real browser.

- fixtures/fake-crashing-claude-cli.mjs: fake claude CLI with
  FAKE_CRASH_MODE=once|always|clean and FAKE_CRASH_UNTIL=N (precedence:
  crash while invocation <= N, then SURVIVE, bypassing the mode checks so
  the 'clean' default can never exit-0 the surviving invocation).
- specs/agent-crash-autoresume-rust.spec.ts: four tests — crash → bounded
  auto-resume with --resume <same id> + role=status notice; always-crash →
  exactly 3 invocations + role=alert 'process exited (code 1)' + Relaunch
  button; clean exit stays quiet; Relaunch drives invocation 4 with
  --resume <same id>, clears the alert, and the pane stays live >=1s.
- playwright.config.ts: RUST_ONLY_SPECS + rust-chromium testMatch appends.

All 4 tests pass against the Tasks 1-8 feature implementation.

🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
…slice

Full-suite gate (Task 10) surfaced 331 failures across 44 pre-existing
client test files: they build partial Redux stores (no terminalLifecycle
reducer) and render TerminalView, whose render-path selectors read
root.terminalLifecycle.byPaneId unguarded -> TypeError.

Fix mirrors the repo's paneRuntimeActivity defensive-access convention:
the *From selectors accept `undefined` slice state and the root wrappers
mark the slice optional, degrading to `undefined` (no banner/notice).
Production behavior is unchanged - store.ts always mounts the reducer.

Red->green: new regression pin in terminalLifecycleSlice.test.ts failed
with the exact production signature, passes after the fix;
TerminalView.renderer.test.tsx (previously failing) is green again.

🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
… shows no phantom crash banner

The exit-banner Relaunch handler dispatched resetPaneForReconcileCreate but
never cleared the pane's terminalLifecycle entry. If the relaunch create was
rejected (pane settles 'error' with no new terminal.exit), the PREVIOUS
crash's exit record still satisfied the 'error' && exitRecord &&
exitCode !== 0 arm of showExitBanner, resurrecting a stale
"process exited (code 1)" for what is actually a launch failure.

Relaunch now also dispatches clearTerminalLifecycle({ paneId }); a genuine
crash-during-relaunch still repopulates via recordTerminalExit. Test pins
both the record clearing on click and the quiet 'error' settle afterwards.

🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
…omments

- auto_resume.rs emit_recovering/emit_replaced: serialize failures now
  tracing::error! instead of being silently swallowed.
- auto_resume.rs auto_resume_delays(): warn when FRESHELL_AUTO_RESUME_DELAYS_MS
  is set but unparseable and the override falls back to defaults (parse
  semantics unchanged).
- auto_resume.rs attempts map: document that retaining exhausted/pane-closed
  entries is deliberate (eviction would refill the retry budget), not a leak.
- terminal.rs missing-probe fallback: document that lifetime_ms = i64::MAX is
  a deliberate "treat as healthy / fresh budget" sentinel, not "unknown".
- terminalLifecycleSlice.ts header: reconcile the denylist wording with
  store.ts's allowlist note (slices allowlisted; pane fields deny-stripped).
- terminalDetachMiddleware.ts: name BOTH applyReconcileAttach dispatch sites
  (crash fold + reconnect reconcile) and each one's detach-skip safety reason.

🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>

# Conflicts:
#	crates/freshell-ws/src/terminal.rs
#	test/e2e-browser/playwright.config.ts
…a enn3)

Post-merge integration of origin/main (83c5541) into the crash-resilience
lane:

- respawn_agent_terminal: pass the now-required cancel receiver to
  SpawnGate::acquire (gate relocated to freshell-freshagent by #559 with a
  cancellable acquire). Auto-resume is server-initiated with no connection
  to die, so it holds a never-fired sender (the REST-door convention).
- Pin the interaction: a queue-full spawn gate rejects a respawn loudly
  (LaunchUnresolvable, queue_rejections == 1) instead of spawning — a
  crash-loop storm is exactly what the gate bounds. Verified RED via a
  gate-bypass mutation before landing.
- Test harness fixups: add main's create_dedupe/shutdown_started fields to
  the lane's WsState builders, and the lane's auto_resume_tx field to
  main's restore_spawn_gate/rest_ws_shared_gate builders.

🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
… is never parsed

Council MEDIUM fix (7w4h/xkhx review, Sam + tester-breaker): the client
parsed attempt/max/exit out of the recovering notice's English text
(TerminalView regexes with invented ?? 2 / ?? 1 fallbacks), so rewording
UI copy would silently change behavior.

- terminal.status: add OPTIONAL maxAttempts/exitCode fields (TS type +
  Rust TerminalStatus with skip_serializing_if), populated by the
  auto-resume emit_recovering path.
- Client reads the FIELDS; both regexes and the fallbacks are deleted.
  reason prose is purely presentational now.
- Contract: npm run contract:generate (ws-server-messages.schema.json),
  Rust pin added to freshell-protocol roundtrip (terminal.status
  recovering fixture), ws e2e pin strengthened (maxAttempts/exitCode
  asserted on the wire). npm run test:port green (38 passed).
- Red-first: exitBanner client test sends a recovering frame with
  DELIBERATELY reworded prose + typed fields; failed on the regex code
  (banner showed invented 1/2 exit 1), passes reading fields (1/3, 137).

🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
… silently end auto-resume

Council MEDIUM fix (7w4h/xkhx review, crusty): main.rs discarded the hub
task's JoinHandle, so a future panic in the driver would silently end
auto-resume forever — reinstating the exact overnight-grey-pane incident
this feature prevents.

The hub now self-supervises: the supervisor task owns the crash-event
receiver and attempts map OUTSIDE a catch_unwind boundary, so a driver
panic drops only the in-flight body future. The panic is logged ERROR
(terminal.auto_resume.hub_panicked) and the loop restarted after a
bounded escalating backoff (1s/5s/30s/60s cap, counter reset after a
30s-healthy run) — a hot-panicking driver cannot spin, and auto-resume
is never permanently lost. (Respawning with a fresh channel would NOT
work: PTY exit hooks clone the sender at hook-build time; owning rx
across the unwind is the load-bearing part.)

Red-first: hub_survives_driver_panic_and_processes_subsequent_crashes —
a one-shot injected driver panic on event 1, then event 2 must still be
respawned. Failed on the unsupervised hub (10s timeout, second event
never processed); passes under supervision (~1s).

🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
@danshapiro
danshapiro merged commit 3a2da8c into main Jul 28, 2026
3 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