Skip to content

Merge-triggered termination leaves orphaned worktree: MarkTerminated skips teardown, Kill button vanishes, no auto-cleanup #2811

Description

@i-trytoohard

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

  1. Spawn a worker session on a branch
  2. Let the agent open a PR
  3. Merge the PR (either the agent or the user merges)
  4. 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
  5. 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}/killservice.Killsession_manager.Kill (backend/internal/session_manager/manager.go:439):

  1. lcm.MarkTerminated — flag
  2. store.DeleteSessionWorktrees — clear restore markers
  3. runtime.Destroy — kill the runtime process
  4. workspace.Destroyremove 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 (SessionStatusPillSTATUS_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:

  1. Have the lifecycle layer delegate to the teardown pathMarkTerminated 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).
  2. 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.
  3. 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).
  • #1458runtimeHandle 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).

Metadata

Metadata

Labels

P1Core workflow broken with no safe workaround.bugSomething isn't working

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions