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

27 KiB
Raw Blame History

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:

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:

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.

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):

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):

// 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
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 Destroyd; a terminated session whose tmux is gone must not.

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:

// 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:

// 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
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:

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:

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:

	// 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
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):

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
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:

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:

// 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:

// 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
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
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.