Bug
When a PR is merged, the lifecycle reaction terminates the session via MarkTerminated — which only sets a flag (IsTerminated=true, Activity.State=exited) and explicitly does not tear down external resources (worktree, runtime). The full teardown path (Kill) is only reachable from the UI Kill button or the POST /sessions/{id}/kill endpoint, but once IsTerminated=true the Kill button disappears from the UI. The result: every merge-terminated session leaves an orphaned worktree on disk that the user cannot reclaim from the UI.
Source: Discord #bug-triage thread (2026-07-19) | Reported by: @prateek | Analyzed against: a2d4ab6b1 (origin/main, 2026-07-18)
Confidence: High — exact code path traced
Reproduction
- Spawn a worker session on a branch
- Let the agent open a PR
- Merge the PR (either the agent or the user merges)
- Observe: the session disappears from the sidebar, the Kill button vanishes from the topbar, and the session only appears in the board's collapsed "Done / Terminated" section
- Check
~/.ao/data/worktrees/<project>/ — the session's worktree directory is still on disk
In Prateek's case (PR #2807, "fix: update landing demo video"), the activity timeline correctly shows Exited (4h ago) → Merged PR #2807 (3h ago) → Done (3h ago), but the worktree was never reclaimed.
Root cause
Two termination paths exist in the Go backend, and only one tears down resources:
Path A — User clicks Kill button → full teardown ✅
POST /sessions/{id}/kill → service.Kill → session_manager.Kill (backend/internal/session_manager/manager.go:439):
lcm.MarkTerminated — flag
store.DeleteSessionWorktrees — clear restore markers
runtime.Destroy — kill the runtime process
workspace.Destroy — remove the worktree
Path B — Lifecycle reaction (merge/close/tracker-terminal) → flag only ❌
backend/internal/lifecycle/reactions.go:132-141:
if o.Merged || o.Closed {
done, err := m.sessionComplete(ctx, id)
if err != nil { return err }
if done {
return m.MarkTerminated(ctx, id) // ← flag only
}
return nil
}
MarkTerminated (backend/internal/lifecycle/manager.go:255):
// MarkTerminated marks a session terminated without tearing down external resources.
func (m *Manager) MarkTerminated(ctx context.Context, id domain.SessionID) error {
// ... sets IsTerminated=true, Activity.State=exited, nothing else
}
The reaper then skips terminated sessions entirely (backend/internal/observe/reaper/reaper.go:128), so it neither notices nor cleans up. No periodic job calls Cleanup for merge-terminated sessions. The only paths to reclaim the worktree are the manual ao session cleanup CLI command, the POST /api/v1/sessions/cleanup endpoint, or a full TeardownProject — none of which are discoverable from the Electron UI after the session vanishes from the sidebar.
Why the Kill button disappears (UI side)
frontend/src/renderer/components/ShellTopbar.tsx:198:
{!isOrchestrator && session && sessionIsActive(session) ? <TopbarKillButton session={session} /> : null}
frontend/src/renderer/types/workspace.ts:275:
export function sessionIsActive(session: WorkspaceSession): boolean {
return session.status !== "merged" && session.status !== "terminated";
}
Once MarkTerminated flips IsTerminated, deriveStatus returns merged/terminated, sessionIsActive returns false, and the Kill button — the only UI affordance that runs the full teardown — is hidden.
Secondary symptom: status pill shows "Exited"
The topbar status pill (SessionStatusPill → STATUS_PILL[workerDisplayStatus(session)]) should render label "Done" for a merged/terminated session. Prateek's screenshot shows "Exited" — the label from the inspector's ACTIVITY_PILL.exited (SessionInspector.tsx:363), keyed by raw SessionActivityState, not display status. This may be build-specific (nightly) or a separate rendering inconsistency; it compounds the confusion because the user reads "Exited" and concludes the session never reached a clean terminal state.
Fix
The merge/close/tracker-terminal lifecycle reactions need to trigger full resource teardown, not just a flag. Options:
- Have the lifecycle layer delegate to the teardown path —
MarkTerminated is in internal/lifecycle/manager.go which deliberately stays out of the session_manager's runtime/workspace teardown. Bridge the layers so a merge-terminated session schedules (or immediately runs) the equivalent of Kill's teardown steps. The lifecycle manager already has access to the session record's RuntimeHandleID and WorkspacePath, so it can call runtime.Destroy + workspace.Destroy + store.DeleteSessionWorktrees directly (mirroring session_manager.Kill).
- Auto-schedule cleanup — have the reaper (or a new periodic job) call
manager.Cleanup for terminated sessions after a grace period, so worktrees are reclaimed without coupling lifecycle to teardown.
- Keep a "Clean up" affordance in the UI for terminated-but-not-cleaned sessions — either keep the Kill button visible for terminated sessions that still have a
WorkspacePath, or add a "Clean up" action in the "Done / Terminated" board section that calls POST /sessions/cleanup. This is the smallest UX fix and complements (1) or (2).
Option 1 is the most correct (worktrees should never survive a merge). Option 2 is the least invasive. Option 3 alone doesn't stop the disk leak, only makes it recoverable.
Impact
- Disk leak: every merged-PR session leaves a full worktree clone on disk until the user discovers and runs
ao session cleanup manually. On an active project this accumulates GBs.
- UX confusion: the session "vanishes" (sidebar) while its worktree persists, and the only cleanup affordance (Kill) disappears at the same moment. The status pill inconsistency ("Exited" vs "Done") makes it look like the session is in a half-state.
- Workaround:
ao session cleanup (CLI) or POST /api/v1/sessions/cleanup — functional but not discoverable from the Electron UI.
Priority: medium — resource leak with a workaround, no data loss, no security impact.
Related
- #1524 — TS-era predecessor:
ao session cleanup leaves worktrees for terminal sessions. Same symptom class, different codebase and mechanism (TS kill() short-circuit vs Go lifecycle MarkTerminated flag-only).
- #1458 —
runtimeHandle and tmuxName dropped to null on lifecycle → terminated, losing routing info permanently. Compounds cleanup: once the handle is gone, runtime.Destroy cannot find the process.
- #2110 — Sidebar lists all terminated sessions indefinitely. Related sidebar/terminated-session UX.
- #1933 — stuck session with exited runtime never auto-promotes to terminated. Inverse failure mode (never terminates vs terminates without cleanup).
Bug
When a PR is merged, the lifecycle reaction terminates the session via
MarkTerminated— which only sets a flag (IsTerminated=true,Activity.State=exited) and explicitly does not tear down external resources (worktree, runtime). The full teardown path (Kill) is only reachable from the UI Kill button or thePOST /sessions/{id}/killendpoint, but onceIsTerminated=truethe Kill button disappears from the UI. The result: every merge-terminated session leaves an orphaned worktree on disk that the user cannot reclaim from the UI.Source: Discord #bug-triage thread (2026-07-19) | Reported by: @prateek | Analyzed against:
a2d4ab6b1(origin/main, 2026-07-18)Confidence: High — exact code path traced
Reproduction
~/.ao/data/worktrees/<project>/— the session's worktree directory is still on diskIn Prateek's case (PR #2807, "fix: update landing demo video"), the activity timeline correctly shows
Exited (4h ago) → Merged PR #2807 (3h ago) → Done (3h ago), but the worktree was never reclaimed.Root cause
Two termination paths exist in the Go backend, and only one tears down resources:
Path A — User clicks Kill button → full teardown ✅
POST /sessions/{id}/kill→service.Kill→session_manager.Kill(backend/internal/session_manager/manager.go:439):lcm.MarkTerminated— flagstore.DeleteSessionWorktrees— clear restore markersruntime.Destroy— kill the runtime processworkspace.Destroy— remove the worktreePath B — Lifecycle reaction (merge/close/tracker-terminal) → flag only ❌
backend/internal/lifecycle/reactions.go:132-141:MarkTerminated(backend/internal/lifecycle/manager.go:255):The reaper then skips terminated sessions entirely (
backend/internal/observe/reaper/reaper.go:128), so it neither notices nor cleans up. No periodic job callsCleanupfor merge-terminated sessions. The only paths to reclaim the worktree are the manualao session cleanupCLI command, thePOST /api/v1/sessions/cleanupendpoint, or a fullTeardownProject— none of which are discoverable from the Electron UI after the session vanishes from the sidebar.Why the Kill button disappears (UI side)
frontend/src/renderer/components/ShellTopbar.tsx:198:frontend/src/renderer/types/workspace.ts:275:Once
MarkTerminatedflipsIsTerminated,deriveStatusreturnsmerged/terminated,sessionIsActivereturns false, and the Kill button — the only UI affordance that runs the full teardown — is hidden.Secondary symptom: status pill shows "Exited"
The topbar status pill (
SessionStatusPill→STATUS_PILL[workerDisplayStatus(session)]) should render label "Done" for a merged/terminated session. Prateek's screenshot shows "Exited" — the label from the inspector'sACTIVITY_PILL.exited(SessionInspector.tsx:363), keyed by rawSessionActivityState, not display status. This may be build-specific (nightly) or a separate rendering inconsistency; it compounds the confusion because the user reads "Exited" and concludes the session never reached a clean terminal state.Fix
The merge/close/tracker-terminal lifecycle reactions need to trigger full resource teardown, not just a flag. Options:
MarkTerminatedis ininternal/lifecycle/manager.gowhich deliberately stays out of the session_manager's runtime/workspace teardown. Bridge the layers so a merge-terminated session schedules (or immediately runs) the equivalent ofKill's teardown steps. The lifecycle manager already has access to the session record'sRuntimeHandleIDandWorkspacePath, so it can callruntime.Destroy+workspace.Destroy+store.DeleteSessionWorktreesdirectly (mirroringsession_manager.Kill).manager.Cleanupfor terminated sessions after a grace period, so worktrees are reclaimed without coupling lifecycle to teardown.WorkspacePath, or add a "Clean up" action in the "Done / Terminated" board section that callsPOST /sessions/cleanup. This is the smallest UX fix and complements (1) or (2).Option 1 is the most correct (worktrees should never survive a merge). Option 2 is the least invasive. Option 3 alone doesn't stop the disk leak, only makes it recoverable.
Impact
ao session cleanupmanually. On an active project this accumulates GBs.ao session cleanup(CLI) orPOST /api/v1/sessions/cleanup— functional but not discoverable from the Electron UI.Priority: medium — resource leak with a workaround, no data loss, no security impact.
Related
ao session cleanupleaves worktrees for terminal sessions. Same symptom class, different codebase and mechanism (TSkill()short-circuit vs Go lifecycleMarkTerminatedflag-only).runtimeHandleandtmuxNamedropped to null on lifecycle → terminated, losing routing info permanently. Compounds cleanup: once the handle is gone,runtime.Destroycannot find the process.