7.4 KiB
Crash-proof session reconcile — design
Date: 2026-06-24
Status: approved (brainstorming), pending implementation plan
Branch: feat/crash-proof-session-reconcile
Problem
Closing the app can leave orphaned state behind: a detached daemon still
holding its port, live tmux sessions, and worktrees on disk. Observed
directly: app closed, running.json pointed at a dead PID, two tmux sessions
(ao-agents-11, the orchestrator ao-agents-12) still alive, and three
worktrees on disk.
Root cause
SaveAndTeardownAll (the save-on-close teardown) is gated entirely behind
srv.Run returning (backend/internal/daemon/daemon.go:151,163). srv.Run
only returns on a catchable signal (signal.NotifyContext for SIGINT/SIGTERM)
or POST /shutdown. A SIGKILL, a crash, or the AppTranslocation mount
vanishing satisfies none of these: srv.Run never returns, so teardown
never runs. The DB confirmed it for the incident: sessions 11 and 12 were
still is_terminated=0 with no termination or marker writes after the last
activity.
The daemon is spawned detached (frontend/src/main.ts:509), so on a
non-clean app exit it is orphaned (reparented to launchd), keeps holding the
port and its tmux sessions, and later dies by SIGKILL without ever tearing
down.
Key principle
You cannot guarantee a clean shutdown. Any fix that only hardens the shutdown
path leaves the SIGKILL/crash hole open. Correctness must come from
idempotent boot-time reconcile: every daemon start makes live reality
(tmux + worktrees) match the DB, regardless of how the previous run ended.
Scope
In scope: a no-leak guarantee. After any app exit (clean, force-quit, crash),
the next boot reconciles so there are no orphaned daemon/tmux/worktrees, and
every live session is either adopted or cleanly terminated.
Out of scope (deliberately unchanged — separate decision):
- Orchestrator re-spawn-vs-restore policy and stale
session_worktreesmarker
cleanup (the "orchestrator spam" bug). - Auto-relaunching crash-killed agents. Reconcile preserves work and marks
terminated; it never spawns a new agent.
Design
Component 1 — Manager.Reconcile(ctx) (daemon side, the core)
A single idempotent pass that replaces the bare RestoreAll call at
daemon.go:147, run before the server starts serving. It folds the existing
restore logic in as one branch. Iterating ListAllSessions:
Reconcile iterates ListAllSessions and acts per session:
| DB state | tmux via IsAlive(handle) |
Action |
|---|---|---|
is_terminated=0 |
alive | Adopt — no-op, leave live. Agent keeps running. |
is_terminated=0 |
gone | StashUncommitted (best-effort) -> MarkTerminated. No relaunch. |
is_terminated=1 |
alive | Reap — Destroy the leaked tmux session. |
is_terminated=1, has marker |
gone | Existing RestoreAll restore branch, unchanged. |
is_terminated=1, no marker |
gone | Leave terminated (user-killed before shutdown; untouched). |
Adoption is safe and lossless because tmux is the persistence layer: the
detached tmux session survives a daemon crash, and the session's
runtime_handle_id (the tmux session name) is in the DB. A matching live
handle means the session genuinely survived; adopting is a no-op.
The reap of a terminated-but-still-alive tmux session uses the existing
per-handle IsAlive + Destroy; no session enumeration is needed because
every leak tied to a session has a DB row (confirmed for the incident: 9, 11,
12 all have rows). Reap must run before the restore branch so a restored
session gets a fresh runtime rather than colliding with a leaked tmux of the
same name.
Worktrees: dirty worktrees are always preserved (this is why an
intentionally-preserved dirty worktree like session 9 survives — correct, by
design; matches the interactive Destroy ErrWorkspaceDirty refusal).
Reconcile does not delete worktree directories; worktree lifecycle stays with
the existing teardown/restore/cleanup paths.
Component 2 — order of operations
Reconcile runs three phases over ListAllSessions, in order:
- Live pass (
is_terminated=0): adopt ifIsAlive, else stash +
MarkTerminated. - Reap pass (
is_terminated=1with live tmux):Destroythe leaked
session. - Restore pass: the existing
RestoreAllbody (terminated + marked
sessions), unchanged.
Deferred (YAGNI): reaping a tmux session that has no DB row at all (a true
orphan). Not observed in the incident and not reachable through normal spawn
(every tmux session is created for a DB-backed session). If it ever appears, it
is a follow-up that adds a Runtime.ListSessions enumerator scoped to this
daemon's session-id namespace (so a co-resident AO install's sessions —
observed: aa-107, aa-109 — stay untouched). Out of scope here.
Component 3 — Frontend "replace wedged orphan" branch
The healthy-attach path already exists: inspectExistingDaemon +
resolveDaemonFromPort (frontend/src/main.ts:457-485) attach to a healthy
existing daemon. The gap is the failure branch. Add: when the port is held but
the daemon is unhealthy / identity-mismatched / PID-dead-but-port-held,
SIGTERM the process group, wait for the port to free, clear the stale
running.json, then spawn fresh (which runs Reconcile). A healthy orphan is
reconnected exactly as today, untouched.
Behaviour for the observed incident
- 11 & 12 (alive tmux) -> adopted, nothing lost.
- A future crash where tmux also died -> work stashed, marked terminated, no
orphan left. - Orphan daemon on next launch -> reused if healthy, else killed + replaced.
- A terminated session whose tmux survived teardown -> reaped (
Destroy). - Dirty worktrees (like 9) -> preserved.
Error handling
- Per-session reconcile failures are logged and never abort the pass (same
pattern asSaveAndTeardownAll/RestoreAll). Reconcileis best-effort and must never block boot: a failure is logged,
boot continues (same contract as the currentRestoreAllcall site).StashUncommittedon a crash-dead worktree is best-effort; a failure logs
and still proceeds toMarkTerminated(no work is destroyed — the worktree
stays on disk).- Orphan-reap
Destroyfailures are logged and do not abort the loop.
Testing
- Unit: table-test
Reconcileover each matrix row with a fake runtime whose
IsAliveis scriptable per handle (alive / gone), asserting DB transitions
(MarkTerminated),StashUncommittedcalls, and runtimeDestroy(reap)
calls. - Unit: assert the live pass adopts (no
Destroy, noMarkTerminated) when
IsAliveis true. - Integration: extend the sqlite lifecycle test with a seeded
is_terminated=0-but-dead session and ais_terminated=1-but-alive session;
assert the post-reconcile DB state and the reapDestroycall.
Open question (resolved during planning)
Orphan-reap is done per-session via IsAlive over DB rows, so there is no
enumeration and no namespace-matching risk in this iteration. The riskier
"reap a tmux session with no DB row" case is deferred (see Component 2,
Deferred), which removes the original namespace-scoping question from scope.