agent-orchestrator/docs/superpowers/plans/2026-06-24-crash-proof-sess...

643 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Crash-proof Session Reconcile Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** On every daemon boot, reconcile live tmux + DB state so a SIGKILL/crash/force-quit that skips `SaveAndTeardownAll` no longer leaks an orphaned daemon, tmux sessions, or worktrees.
**Architecture:** Add `Manager.Reconcile(ctx)` to the session manager: a live pass (adopt alive sessions, stash+terminate dead ones), a reap pass (`Destroy` tmux of terminated sessions whose pane survived), then the existing `RestoreAll` body. Wire it in place of the bare `RestoreAll` call at daemon boot. On the frontend, add a kill+replace branch for a wedged orphan daemon on launch. tmux is the persistence layer, so adopting a crash-surviving session is a no-op.
**Tech Stack:** Go 1.x (backend, `go test`), TypeScript/Electron (frontend, `npm test` in `frontend/`). tmux runtime adapter. SQLite store.
## Global Constraints
- No em dashes (`—`) or en dashes (``) anywhere: prose, code comments, commit messages. Use a period, comma, colon, semicolon, or parentheses.
- Go: run `gofmt`/`goimports`; keep `golangci-lint` clean (the repo's CI gates on it).
- Git author email: `dev@theharshitsingh.com`. Commit with `git -c user.email=dev@theharshitsingh.com commit ...`.
- TDD: write the failing test first, watch it fail, implement minimally, watch it pass, commit.
- Reconcile is best-effort: per-session failures log and never abort the pass or block boot (same contract as the existing `RestoreAll` call site at `backend/internal/daemon/daemon.go:147`).
- Reconcile never deletes worktree directories and never spawns a new agent. Dirty worktrees are always preserved.
---
## File Structure
- `backend/internal/session_manager/manager.go` — add `Reconcile`, `reconcileLive`, `reconcileReap` methods; widen the `runtimeController` interface with `IsAlive`. The existing `RestoreAll` body is reused (called as the restore phase).
- `backend/internal/session_manager/manager_test.go` — add `IsAlive` to `fakeRuntime` (scriptable per handle); add `Reconcile` unit tests.
- `backend/internal/daemon/lifecycle_wiring.go` — add `Reconcile` to the `sessionLifecycle` interface.
- `backend/internal/daemon/daemon.go` — replace the `RestoreAll(ctx)` boot call with `Reconcile(ctx)`.
- `backend/internal/daemon/wiring_test.go` — update the `sessionLifecycle` fake/mock if it asserts the interface.
- `backend/internal/integration/lifecycle_sqlite_test.go` — add a reconcile integration case.
- `frontend/src/main.ts` — add the wedged-orphan kill+replace branch in `startDaemonInner`.
- `frontend/src/main.test.ts` (or the existing main-process test file) — test the kill+replace decision.
---
## Task 1: Widen `runtimeController` with `IsAlive` and adopt-alive live pass
**Files:**
- Modify: `backend/internal/session_manager/manager.go:64-67` (interface), add methods near `manager.go:558-623`
- Test: `backend/internal/session_manager/manager_test.go:138-152` (fake), new test fn
**Interfaces:**
- Consumes: `domain.SessionRecord` (`.IsTerminated`, `.Metadata.WorkspacePath`, `.Metadata.Branch`, `.Metadata.RuntimeHandleID`); `runtimeHandle(meta)` -> `ports.RuntimeHandle`; `workspaceInfo(rec)` -> `ports.WorkspaceInfo`; `m.workspace.StashUncommitted`, `m.lcm.MarkTerminated`, `m.store.ListAllSessions`.
- Produces: `func (m *Manager) reconcileLive(ctx context.Context, rec domain.SessionRecord) error`; widened `runtimeController` with `IsAlive(ctx context.Context, handle ports.RuntimeHandle) (bool, error)`.
- [ ] **Step 1: Add `IsAlive` to the test `fakeRuntime`**
In `manager_test.go`, extend the fake so `IsAlive` is scriptable per handle id and records calls:
```go
type fakeRuntime struct {
createErr error
created, destroyed int
lastCfg ports.RuntimeConfig
// aliveByHandle maps a RuntimeHandle.ID to its liveness; missing = false.
aliveByHandle map[string]bool
aliveErr error
destroyedIDs []string
}
func (r *fakeRuntime) IsAlive(_ context.Context, handle ports.RuntimeHandle) (bool, error) {
if r.aliveErr != nil {
return false, r.aliveErr
}
return r.aliveByHandle[handle.ID], nil
}
```
Also record the destroyed handle id in the existing `Destroy`:
```go
func (r *fakeRuntime) Destroy(_ context.Context, handle ports.RuntimeHandle) error {
r.destroyed++
r.destroyedIDs = append(r.destroyedIDs, handle.ID)
return nil
}
```
- [ ] **Step 2: Write the failing test for the live pass**
Add to `manager_test.go`. A live (`is_terminated=0`) session whose tmux is GONE must be stashed and marked terminated; an ALIVE one must be left untouched.
```go
func TestReconcileLive_DeadSessionStashedAndTerminated(t *testing.T) {
st := newFakeStore()
rt := &fakeRuntime{aliveByHandle: map[string]bool{}} // handle not alive
ws := &fakeWorkspace{stashRef: "refs/ao/preserved/s1"}
lcm := &fakeLCM{}
m := newManager(t, st, rt, ws, lcm)
rec := domain.SessionRecord{
ID: "s1",
ProjectID: "p1",
IsTerminated: false,
Metadata: domain.SessionMetadata{
Branch: "ao/s1/root", WorkspacePath: "/wt/s1", RuntimeHandleID: "s1",
},
}
if err := m.reconcileLive(context.Background(), rec); err != nil {
t.Fatalf("reconcileLive: %v", err)
}
if ws.stashCalls != 1 {
t.Fatalf("StashUncommitted calls = %d, want 1", ws.stashCalls)
}
if lcm.terminated["s1"] != 1 {
t.Fatalf("MarkTerminated(s1) = %d, want 1", lcm.terminated["s1"])
}
if rt.destroyed != 0 {
t.Fatalf("Destroy calls = %d, want 0 (dead session: no tmux to kill)", rt.destroyed)
}
}
func TestReconcileLive_AliveSessionAdoptedNoop(t *testing.T) {
st := newFakeStore()
rt := &fakeRuntime{aliveByHandle: map[string]bool{"s2": true}}
ws := &fakeWorkspace{}
lcm := &fakeLCM{}
m := newManager(t, st, rt, ws, lcm)
rec := domain.SessionRecord{
ID: "s2", ProjectID: "p1", IsTerminated: false,
Metadata: domain.SessionMetadata{Branch: "ao/s2/root", WorkspacePath: "/wt/s2", RuntimeHandleID: "s2"},
}
if err := m.reconcileLive(context.Background(), rec); err != nil {
t.Fatalf("reconcileLive: %v", err)
}
if ws.stashCalls != 0 || lcm.terminated["s2"] != 0 || rt.destroyed != 0 {
t.Fatalf("adopt should be a no-op: stash=%d term=%d destroy=%d", ws.stashCalls, lcm.terminated["s2"], rt.destroyed)
}
}
```
> If `fakeWorkspace` lacks a `stashCalls` counter or `fakeLCM` lacks a `terminated` map, add them: increment `stashCalls` inside `fakeWorkspace.StashUncommitted`, and `l.terminated[id]++` (init the map in the fake) inside `fakeLCM.MarkTerminated`. If `newManager` has a different signature in this file, match the existing constructor used by other tests rather than inventing one.
- [ ] **Step 3: Run the test, verify it fails**
Run: `cd backend && go test ./internal/session_manager/ -run TestReconcileLive -v`
Expected: FAIL — `m.reconcileLive` undefined, and `IsAlive` not in `runtimeController`.
- [ ] **Step 4: Widen the interface and implement `reconcileLive`**
In `manager.go`, widen the interface (around line 64):
```go
type runtimeController interface {
Create(ctx context.Context, cfg ports.RuntimeConfig) (ports.RuntimeHandle, error)
Destroy(ctx context.Context, handle ports.RuntimeHandle) error
// IsAlive reports whether the handle's runtime session still exists. Used by
// Reconcile on boot to adopt crash-surviving sessions and reap leaked ones.
IsAlive(ctx context.Context, handle ports.RuntimeHandle) (bool, error)
}
```
Add the method (place it near `saveAndTeardownOne`, around line 623):
```go
// reconcileLive handles a single non-terminated session on boot. If its runtime
// session is still alive (tmux is the persistence layer, so it survives a daemon
// crash) we adopt it: a no-op, the agent keeps running. If the runtime is gone,
// the agent died with the daemon, so we capture any uncommitted work into a
// preserve ref (best-effort) and mark the session terminated. We never relaunch
// here (that is spawn policy) and never delete the worktree.
func (m *Manager) reconcileLive(ctx context.Context, rec domain.SessionRecord) error {
if rec.Metadata.WorkspacePath == "" || rec.Metadata.Branch == "" {
return nil
}
handle := runtimeHandle(rec.Metadata)
if handle.ID != "" {
alive, err := m.runtime.IsAlive(ctx, handle)
if err != nil {
// A failed probe is not proof of death: leave the session as-is.
return fmt.Errorf("reconcile %s: probe: %w", rec.ID, err)
}
if alive {
return nil // adopt: the session survived the crash.
}
}
// Runtime is gone: preserve work (best-effort) then mark terminated.
if _, err := m.workspace.StashUncommitted(ctx, workspaceInfo(rec)); err != nil {
m.logger.Warn("reconcile: stash uncommitted failed; marking terminated anyway", "sessionID", rec.ID, "error", err)
}
if err := m.lcm.MarkTerminated(ctx, rec.ID); err != nil {
return fmt.Errorf("reconcile %s: mark terminated: %w", rec.ID, err)
}
return nil
}
```
- [ ] **Step 5: Run the tests, verify they pass**
Run: `cd backend && go test ./internal/session_manager/ -run TestReconcileLive -v`
Expected: PASS (both cases).
- [ ] **Step 6: Build to confirm the widened interface still satisfies the concrete runtime**
Run: `cd backend && go build ./...`
Expected: success (the concrete `runtimeselect.Runtime`/`tmux.Runtime` already implement `IsAlive`).
- [ ] **Step 7: Commit**
```bash
cd backend && gofmt -w internal/session_manager/manager.go internal/session_manager/manager_test.go
git add internal/session_manager/manager.go internal/session_manager/manager_test.go
git -c user.email=dev@theharshitsingh.com commit -m "feat(session): reconcile live pass (adopt alive, stash+terminate dead)"
```
---
## Task 2: Reap pass and the `Reconcile` entry point
**Files:**
- Modify: `backend/internal/session_manager/manager.go` (add `reconcileReap`, `Reconcile`; the latter reuses the existing `RestoreAll` body)
- Test: `backend/internal/session_manager/manager_test.go`
**Interfaces:**
- Consumes: `m.store.ListAllSessions`, `m.runtime.IsAlive`, `m.runtime.Destroy`, `reconcileLive` (Task 1), the existing `RestoreAll` method (`manager.go:637`).
- Produces: `func (m *Manager) Reconcile(ctx context.Context) error`; `func (m *Manager) reconcileReap(ctx context.Context, rec domain.SessionRecord) error`.
- [ ] **Step 1: Write the failing reap test**
Add to `manager_test.go`. A terminated session whose tmux is still alive must have its tmux `Destroy`d; a terminated session whose tmux is gone must not.
```go
func TestReconcileReap_TerminatedButAliveTmuxDestroyed(t *testing.T) {
st := newFakeStore()
rt := &fakeRuntime{aliveByHandle: map[string]bool{"t1": true}}
ws := &fakeWorkspace{}
lcm := &fakeLCM{}
m := newManager(t, st, rt, ws, lcm)
rec := domain.SessionRecord{
ID: "t1", ProjectID: "p1", IsTerminated: true,
Metadata: domain.SessionMetadata{RuntimeHandleID: "t1"},
}
if err := m.reconcileReap(context.Background(), rec); err != nil {
t.Fatalf("reconcileReap: %v", err)
}
if len(rt.destroyedIDs) != 1 || rt.destroyedIDs[0] != "t1" {
t.Fatalf("destroyedIDs = %v, want [t1]", rt.destroyedIDs)
}
}
func TestReconcileReap_TerminatedAndDeadTmuxLeftAlone(t *testing.T) {
st := newFakeStore()
rt := &fakeRuntime{aliveByHandle: map[string]bool{}} // t2 not alive
m := newManager(t, st, rt, &fakeWorkspace{}, &fakeLCM{})
rec := domain.SessionRecord{
ID: "t2", ProjectID: "p1", IsTerminated: true,
Metadata: domain.SessionMetadata{RuntimeHandleID: "t2"},
}
if err := m.reconcileReap(context.Background(), rec); err != nil {
t.Fatalf("reconcileReap: %v", err)
}
if rt.destroyed != 0 {
t.Fatalf("Destroy calls = %d, want 0", rt.destroyed)
}
}
```
- [ ] **Step 2: Run the test, verify it fails**
Run: `cd backend && go test ./internal/session_manager/ -run TestReconcileReap -v`
Expected: FAIL — `m.reconcileReap` undefined.
- [ ] **Step 3: Implement `reconcileReap` and `Reconcile`**
Add `reconcileReap` near `reconcileLive`:
```go
// reconcileReap kills the leaked tmux session of a session the DB already marks
// terminated. This covers the teardown that marked the row terminated but failed
// to kill the runtime (e.g. ForceDestroy/Destroy errored after MarkTerminated).
// Destroy is idempotent, so an already-gone session is a no-op.
func (m *Manager) reconcileReap(ctx context.Context, rec domain.SessionRecord) error {
handle := runtimeHandle(rec.Metadata)
if handle.ID == "" {
return nil
}
alive, err := m.runtime.IsAlive(ctx, handle)
if err != nil {
return fmt.Errorf("reconcile reap %s: probe: %w", rec.ID, err)
}
if !alive {
return nil
}
if err := m.runtime.Destroy(ctx, handle); err != nil {
return fmt.Errorf("reconcile reap %s: destroy: %w", rec.ID, err)
}
return nil
}
```
Add the entry point. Place it just above `RestoreAll` (manager.go:625) and have it call the existing `RestoreAll` as the restore phase:
```go
// Reconcile is the boot-time consistency pass. It replaces the bare RestoreAll
// call so that however the previous daemon died (clean shutdown, SIGKILL, or
// crash), live reality matches the DB:
//
// 1. Live pass: for each non-terminated session, adopt it if its runtime
// survived, else capture work and mark terminated (reconcileLive).
// 2. Reap pass: for each terminated session whose runtime leaked, kill it
// (reconcileReap). Runs before restore so a restored session does not
// collide with a leaked tmux of the same name.
// 3. Restore pass: relaunch shutdown-saved sessions (existing RestoreAll).
//
// Best-effort throughout: a per-session failure is logged and never aborts the
// pass or blocks boot.
func (m *Manager) Reconcile(ctx context.Context) error {
recs, err := m.store.ListAllSessions(ctx)
if err != nil {
return fmt.Errorf("reconcile: list sessions: %w", err)
}
for _, rec := range recs {
if rec.IsTerminated {
continue
}
if err := m.reconcileLive(ctx, rec); err != nil {
m.logger.Error("reconcile: live pass failed, skipping", "sessionID", rec.ID, "error", err)
}
}
for _, rec := range recs {
if !rec.IsTerminated {
continue
}
if err := m.reconcileReap(ctx, rec); err != nil {
m.logger.Error("reconcile: reap pass failed, skipping", "sessionID", rec.ID, "error", err)
}
}
return m.RestoreAll(ctx)
}
```
> Note: the live pass re-reads `rec.IsTerminated` from the pre-pass snapshot, so a session terminated _by_ the live pass is not also reaped in the same run. That is fine: its tmux is already gone (that is why it was terminated), so reaping would be a no-op anyway.
- [ ] **Step 4: Run the tests, verify they pass**
Run: `cd backend && go test ./internal/session_manager/ -run 'TestReconcile' -v`
Expected: PASS (live + reap tests).
- [ ] **Step 5: Commit**
```bash
cd backend && gofmt -w internal/session_manager/manager.go internal/session_manager/manager_test.go
git add internal/session_manager/manager.go internal/session_manager/manager_test.go
git -c user.email=dev@theharshitsingh.com commit -m "feat(session): reconcile reap pass and Reconcile entry point"
```
---
## Task 3: Wire `Reconcile` into daemon boot
**Files:**
- Modify: `backend/internal/daemon/lifecycle_wiring.go:64-67` (interface)
- Modify: `backend/internal/daemon/daemon.go:144-149` (boot call)
- Test: `backend/internal/daemon/wiring_test.go`
**Interfaces:**
- Consumes: `Manager.Reconcile` (Task 2).
- Produces: `sessionLifecycle` interface gains `Reconcile(ctx context.Context) error`.
- [ ] **Step 1: Update the wiring test/mock**
In `wiring_test.go`, find the type used as a `sessionLifecycle` test double (it implements `RestoreAll` and `SaveAndTeardownAll`). Add a `Reconcile` method and, if the test asserts boot behavior, assert `Reconcile` is the method called on boot:
```go
func (m *fakeSessionLifecycle) Reconcile(ctx context.Context) error {
m.reconcileCalls++
return m.reconcileErr
}
```
(If `wiring_test.go` has no such double and only checks construction, add a compile-time assertion instead: `var _ sessionLifecycle = (*sessionmanager.Manager)(nil)` in the test, which fails to compile until both the interface and the concrete method exist.)
- [ ] **Step 2: Run the test, verify it fails**
Run: `cd backend && go test ./internal/daemon/ -run Wiring -v`
Expected: FAIL — `Reconcile` not in the interface / not asserted.
- [ ] **Step 3: Add `Reconcile` to the interface**
In `lifecycle_wiring.go`:
```go
type sessionLifecycle interface {
Reconcile(ctx context.Context) error
RestoreAll(ctx context.Context) error
SaveAndTeardownAll(ctx context.Context) error
}
```
- [ ] **Step 4: Replace the boot call**
In `daemon.go`, change the boot restore call (currently lines 144-149) to call `Reconcile`:
```go
// Reconcile sessions on boot: adopt crash-surviving runtimes, capture and
// terminate dead ones, reap leaked tmux, then restore shutdown-saved
// sessions. Best-effort: a failure is logged but never blocks boot. Placed
// before srv.Run so sessions are consistent before the server serves.
if reconcileErr := sessMgr.Reconcile(ctx); reconcileErr != nil {
log.Error("reconcile sessions on boot failed", "err", reconcileErr)
}
```
- [ ] **Step 5: Run the tests, verify they pass**
Run: `cd backend && go test ./internal/daemon/ -v`
Expected: PASS.
- [ ] **Step 6: Build everything**
Run: `cd backend && go build ./... && go vet ./internal/daemon/ ./internal/session_manager/`
Expected: success.
- [ ] **Step 7: Commit**
```bash
cd backend && gofmt -w internal/daemon/daemon.go internal/daemon/lifecycle_wiring.go internal/daemon/wiring_test.go
git add internal/daemon/daemon.go internal/daemon/lifecycle_wiring.go internal/daemon/wiring_test.go
git -c user.email=dev@theharshitsingh.com commit -m "feat(daemon): run Reconcile on boot in place of bare RestoreAll"
```
---
## Task 4: Integration test over the sqlite store
**Files:**
- Modify: `backend/internal/integration/lifecycle_sqlite_test.go`
**Interfaces:**
- Consumes: the real `Manager.Reconcile`, a real sqlite store, and the test's runtime fake (find how this file already fakes the runtime; reuse it, scripting `IsAlive` per handle).
- [ ] **Step 1: Read the existing integration harness**
Open `backend/internal/integration/lifecycle_sqlite_test.go`. Identify how it constructs a `Manager` with a real `sqlite.Store` and what runtime double it injects. Reuse that exact wiring; only add `IsAlive` scripting to the double if it is missing.
- [ ] **Step 2: Write the failing integration test**
Add a test that seeds two sessions through the store, runs `Reconcile`, and asserts the resulting DB state. Use the file's existing seeding helpers and constructor names (match them; do not invent new ones):
```go
func TestReconcile_TerminatesDeadLiveSessionAndReapsLeakedTmux(t *testing.T) {
// ... build store + manager the same way other tests in this file do ...
// Seed A: is_terminated=0 but its runtime is gone (crash-killed agent).
// Seed B: is_terminated=1 but its tmux is still alive (leaked teardown).
// Script the runtime double: A's handle -> not alive, B's handle -> alive.
if err := mgr.Reconcile(ctx); err != nil {
t.Fatalf("Reconcile: %v", err)
}
// A is now terminated in the store.
a, _, _ := store.GetSession(ctx, "A")
if !a.IsTerminated {
t.Fatalf("session A: want terminated after reconcile")
}
// B's leaked tmux was destroyed.
if !runtimeDouble.wasDestroyed("B") {
t.Fatalf("session B: want leaked tmux destroyed")
}
}
```
> Replace `mgr`, `store`, `ctx`, `runtimeDouble`, and the seeding with the file's actual identifiers. If the file's runtime double cannot report `wasDestroyed`, assert via the store/observable side effects it already uses.
- [ ] **Step 3: Run it, verify it fails**
Run: `cd backend && go test ./internal/integration/ -run TestReconcile_TerminatesDeadLiveSessionAndReapsLeakedTmux -v`
Expected: FAIL (until seeding + assertions match real helpers; iterate until it compiles and fails for the right reason, then passes once Reconcile runs).
- [ ] **Step 4: Make it pass**
The production code from Tasks 1-3 already implements the behavior. Adjust only the test scaffolding (identifiers, seeding) until it passes.
Run: `cd backend && go test ./internal/integration/ -run TestReconcile -v`
Expected: PASS.
- [ ] **Step 5: Run the full backend suite**
Run: `cd backend && go test ./...`
Expected: PASS (no regressions in session_manager, daemon, integration).
- [ ] **Step 6: Commit**
```bash
cd backend && gofmt -w internal/integration/lifecycle_sqlite_test.go
git add internal/integration/lifecycle_sqlite_test.go
git -c user.email=dev@theharshitsingh.com commit -m "test(integration): reconcile terminates dead-live sessions and reaps leaked tmux"
```
---
## Task 5: Frontend wedged-orphan kill+replace branch
**Files:**
- Modify: `frontend/src/main.ts` (in `startDaemonInner`, around lines 457-495)
- Test: `frontend/src/main.test.ts` or the existing main-process test file
**Interfaces:**
- Consumes: existing `inspectExistingDaemon`, `resolveDaemonFromPort`, `readDaemonProbe`, `killDaemon`, `parseRunFile`/`defaultRunFilePath`, `expectedDaemonPort`.
- Produces: a pure decision helper, e.g. `function planDaemonTakeover(probe: DaemonProbe | null): "reuse" | "replace"`, unit-testable without spawning.
- [ ] **Step 1: Read the current launch flow**
Read `frontend/src/main.ts:432-512`. Confirm: `inspectExistingDaemon` returns a status when the run-file agrees with a live daemon; `resolveDaemonFromPort` attaches when a daemon answers the port. The gap: when a process holds the port but is unhealthy (no `/healthz` + `/readyz`) or identity-mismatched, today the code falls through to `spawn`, and the Go child then refuses the port and exits 1. We add: detect that case and kill the holder first.
- [ ] **Step 2: Write the failing unit test for the decision helper**
In the frontend test file:
```ts
import { planDaemonTakeover } from "./main";
test("healthy probe -> reuse", () => {
expect(planDaemonTakeover({ healthy: true, pid: 123, port: 3001 })).toBe("reuse");
});
test("port held but unhealthy probe -> replace", () => {
expect(planDaemonTakeover({ healthy: false, pid: 123, port: 3001 })).toBe("replace");
});
test("no probe (nothing on port) -> replace (spawn fresh)", () => {
expect(planDaemonTakeover(null)).toBe("replace");
});
```
> Match `DaemonProbe`'s real shape from `frontend/src/shared/` (the `readDaemonProbe` return type). If it exposes health via a different field (e.g. presence of both `healthz` and `readyz`), encode that in `planDaemonTakeover` and the test rather than a `healthy` boolean.
- [ ] **Step 3: Run it, verify it fails**
Run: `cd frontend && npm test -- planDaemonTakeover`
Expected: FAIL — `planDaemonTakeover` not exported.
- [ ] **Step 4: Implement the helper and wire the branch**
Add the pure helper (top-level, exported) in `main.ts`:
```ts
// planDaemonTakeover decides what to do with whatever currently holds the daemon
// port on launch. A healthy daemon is reused (it kept sessions alive across a
// crash). Anything else - an unhealthy/wedged holder, or nothing answering - means
// spawn fresh; the caller kills a live-but-unhealthy holder first.
export function planDaemonTakeover(probe: DaemonProbe | null): "reuse" | "replace" {
return probe?.healthy ? "reuse" : "replace";
}
```
Then, in `startDaemonInner`, after the existing `inspectExistingDaemon` + `resolveDaemonFromPort` attach attempts fail (i.e. just before `spawn`), add: probe the expected port; if something answers but is unhealthy, SIGTERM the holder via the run-file PID and wait for the port to free before spawning. Concretely, before the `spawn(...)` at line 505:
```ts
// A process may hold the port without being a healthy daemon we can attach to
// (wedged orphan from a crash, or a PID-dead-but-port-held run-file). Spawning
// then would make the Go child collide and exit 1. Detect it and clear it.
const holderProbe = await readDaemonProbe(expectedDaemonPort(process.env));
if (planDaemonTakeover(holderProbe) === "replace" && holderProbe) {
const runFile = parseRunFile(await readRunFileSafe(defaultRunFilePath()));
if (runFile?.pid) {
try {
process.kill(-runFile.pid, "SIGTERM");
} catch {
try {
process.kill(runFile.pid, "SIGTERM");
} catch {
/* already gone */
}
}
}
await waitForPortFree(expectedDaemonPort(process.env), 8_000);
await rmRunFileSafe(defaultRunFilePath());
}
```
> Use the file's existing run-file read/parse helpers (`parseRunFile`, `defaultRunFilePath`). If `readRunFileSafe`/`rmRunFileSafe`/`waitForPortFree` do not exist, add small local helpers: `readRunFileSafe` wraps `fs.readFile` returning `""` on ENOENT; `rmRunFileSafe` wraps `fs.rm` ignoring ENOENT; `waitForPortFree` polls `readDaemonProbe` until it returns null or the timeout elapses. Keep each to a few lines, matching the file's existing async style.
- [ ] **Step 5: Run the tests, verify they pass**
Run: `cd frontend && npm test -- planDaemonTakeover`
Expected: PASS.
- [ ] **Step 6: Type-check and lint the frontend**
Run: `cd frontend && npm run typecheck && npm run lint`
Expected: success (commands per `frontend/package.json`; if names differ, use the repo's configured equivalents).
- [ ] **Step 7: Commit**
```bash
cd frontend
git add src/main.ts src/main.test.ts
git -c user.email=dev@theharshitsingh.com commit -m "feat(frontend): kill+replace a wedged orphan daemon on launch"
```
---
## Task 6: Full verification and branch wrap-up
- [ ] **Step 1: Backend suite + lint**
Run: `cd backend && go test ./... && gofmt -l . && go vet ./...`
Expected: tests PASS, `gofmt -l` prints nothing. If `golangci-lint` is installed: `golangci-lint run ./internal/session_manager/... ./internal/daemon/...` clean.
- [ ] **Step 2: Frontend suite**
Run: `cd frontend && npm test`
Expected: PASS.
- [ ] **Step 3: Manual smoke (optional, real hardware)**
With the app/daemon running and at least one session live, `kill -9` the daemon PID (from `~/.ao/running.json`), then relaunch. Expect: the live session's tmux is adopted (still listed, agent intact), no duplicate daemon, `running.json` repointed to the new PID. Then kill a session's agent and `kill -9` the daemon: expect that session marked terminated with its work in a `refs/ao/preserved/<id>` ref, and no leaked tmux.
- [ ] **Step 4: Review the diff against the spec**
Confirm every spec section maps to a task: live pass (T1), reap + entry point (T2), boot wiring (T3), integration (T4), frontend takeover (T5). Confirm no worktree directory is ever deleted by reconcile and no agent is relaunched outside the existing `RestoreAll`.
- [ ] **Step 5: Push the branch**
```bash
git push -u origin feat/crash-proof-session-reconcile
```
---
## Self-Review notes (planning)
- **Spec coverage:** Component 1 (live + reap matrix) -> Tasks 1-2; Component 2 (order of phases) -> Task 2 `Reconcile`; Component 3 (frontend kill+replace) -> Task 5; error-handling contract -> best-effort logging in every pass; testing section -> Tasks 1, 2, 4, 5. Deferred `ListSessions` is explicitly not implemented (matches spec Deferred).
- **Type consistency:** `IsAlive(ctx, ports.RuntimeHandle) (bool, error)` matches the concrete tmux/conpty signature (`tmux.go:176`). `Reconcile(ctx) error`, `reconcileLive(ctx, domain.SessionRecord) error`, `reconcileReap(ctx, domain.SessionRecord) error` are used identically across tasks. `runtimeHandle`/`workspaceInfo` helpers exist at `manager.go:1135,1139`.
- **Placeholder scan:** test bodies that depend on existing fakes' field names (`stashCalls`, `terminated`, `newManager`) carry an explicit instruction to match the file's real identifiers; this is unavoidable without the fakes in front of the implementer and is called out at each use, not left as a silent TODO.