fix(sessions): reclaim the worktree on merge-triggered termination (#2811)#2819
fix(sessions): reclaim the worktree on merge-triggered termination (#2811)#2819anuj7511 wants to merge 1 commit into
Conversation
…gentWrapper#2811) A merged/closed PR (or a terminal tracker issue) terminated the session via MarkTerminated, which only flips is_terminated and tears down nothing, so every merged-PR session left an orphaned worktree on disk that the UI could no longer reclaim once the Kill button disappeared. Route the fact-driven termination through the teardown the session manager already owns: lifecycle gains a narrow optional reclaimer seam (wired to the session manager in startSession), and reclaims the worktree only on the termination transition. Dirty worktrees are preserved and any teardown error is logged, not surfaced. Backend-only; no API/SQLite regen.
|
Thanks for taking this on. The PR identifies the immediate failure correctly, and there is valuable work here that we should preserve. After tracing all of the terminal-state paths, startup wiring, reconciliation, restore behavior, and frontend visibility, I think we should adjust the core approach before treating this as the permanent fix. Architecture we want to followThe durable invariant should be:
Terminal state is the durable cleanup intent; it does not promise that cleanup has already succeeded. Cleanup may still be pending, retrying, or intentionally blocked because a worktree is dirty. That keeps the existing ownership boundaries intact:
The intended flow is: The important distinction from the current PR is that cleanup must not depend on a one-time callback from a particular termination source. The callback can fail after the terminal transaction commits, the process can crash, or another path can terminate the session. The durable terminal fact must be enough for the daemon to discover and finish the work later. This also resolves the documented ownership tension cleanly: lifecycle's MarkTerminated can continue to record facts only, while session_manager remains the only component that knows how to release resources. Why the current callback should not be the final seamSetTerminationReclaimer has the right goal—avoiding runtime/workspace dependencies in lifecycle—but it introduces a mutable, post-construction dependency and makes correctness depend on startup order. Today the observers can start before startSession constructs the session manager and installs the callback. A merge observed during that window can commit termination with no reclaimer. Since SCM discovery excludes terminal sessions, that session may never be retried. The callback is also tied only to the SCM/tracker reaction sites. Runtime observations and activity-exit signals can set is_terminated directly, so new or existing terminal paths can bypass reclamation. Finally, the current path is effectively at-most-once: reclaim only runs on the changed=true transition, reclaim errors are swallowed, and repeat observations cannot repair the side effect. An immediate signal is useful for responsiveness, but it cannot be the correctness mechanism. Implementation steps1. Remove the lifecycle reclaimer dependency
2. Introduce one idempotent session-manager finalization operationFactor a per-session operation with a contract equivalent to FinalizeTerminalSession(ctx, sessionID). It should:
Runtime and workspace destruction are already effectively idempotent for missing resources, so a crash between phases can be recovered by repeating this operation. Explicit Kill and bulk Cleanup should reuse the same low-level release primitive where practical, while their user-visible contracts can remain distinct. 3. Add terminal-resource reconciliationAdd a small reconciler/finalizer runner, owned beside session_manager rather than inside lifecycle. It should:
Do not use change_log as a durable jobs queue. It can wake the reconciler, but current database state must remain authoritative. A generic jobs/outbox framework is unnecessary here because is_terminated already provides durable intent. Boot ordering should be adjusted so lifecycle and session_manager are fully constructed first, adoption/restore reconciliation runs, terminal-resource reconciliation runs, and only then observers/reaper begin producing new observations. 4. Persist reliable cleanup factsworkspacePath is not cleanup state: it remains populated after successful destruction and cannot represent runtime cleanup or dirty preservation. Persist a compact resource-finalization record. A separate table is likely clearer than overloading session_worktrees. Store raw operational facts, for example:
Do not persist another display session status. The API can derive Cleaning up, Worktree preserved, or Retry available from these facts. The result must be versioned or atomically reset across Restore -> Terminate cycles. Otherwise a successful result from an earlier termination could incorrectly satisfy a later one. Add the migration, queries, sqlc generation, and trigger-backed CDC changes required by the chosen representation. 5. Add the per-session APIAdd an idempotent endpoint along the lines of: It should:
Keep the existing project-scoped bulk cleanup as the administrative path, but make it delegate to the same per-session primitive. Regenerate OpenAPI and frontend types with npm run api. 6. Restore UI recoverabilityThe frontend currently removes the session from active navigation and hides Kill as soon as the session becomes merged/terminated. The Done/Terminated card or inspector should expose resource cleanup information. Recommended behavior:
Keep the primary Merged/Terminated display status unchanged. Cleanup is secondary resource state. The frontend should retain the raw terminal fact rather than infer eligibility from status === terminated, because merged sessions are also terminal. 7. Serialize cleanup and restoreUse a shared per-session keyed lock for finalization, manual cleanup, Kill, and Restore paths. The finalizer must re-check terminal state while holding the lock and before destructive phases. This prevents a background cleanup attempt from racing a user-initiated restore. 8. Expand the tests around the invariantPlease retain the useful tests already in this PR and add coverage for:
What this PR has already done wellThe following work is directionally correct and should be reused:
Those are good foundations. In particular, the helper extraction, dirty-worktree cases, and integration-test setup should survive the redesign. What remains before this is the complete fixThe remaining architectural work is:
So the requested change is not to discard this PR. It is to keep its teardown work and tests, then replace the one-shot merge callback with an idempotent terminal-resource reconciler. That gives us the automatic behavior requested by #2811 while staying consistent with the rewrite architecture and remaining maintainable as more termination sources are added. |
|
Thanks for contributing to Agent Orchestrator. This PR is being picked up by the current external contributor on-call pair: If someone is already working on this, please continue as usual. For faster context or live questions, you can also join the AO Discord. Join the session here: Come by if you want to see what is being built, ask questions, or just hang around with the community. |
|
Thanks for the thorough review, @illegalcall. I've worked the design through and I'm confident in the direction. Here's the plan. It's 5 PRs, and this PR (#2819) becomes PR 2. 2 decisions before starting implementation:
PR 1 — Storage foundation (no behavior change). A cleanup-facts table ( PR 2a — Reviewer runtime teardown (pre-existing-bug fix, stacked on #2800). The AO reviewer runs in the worker's worktree as a separate pane PR 2 — Finalizer + reconciler (this PR). Removes the callback seam (lifecycle back to fact-only, which also breaks the construction cycle). Extracts one low-level release core — runtime-first (abort + retry if runtime destroy fails; don't treat a nil PR 3 — Per-session cleanup API. PR 4 — Frontend recoverability. Cleanup state + retry affordance on the Done/Terminated card, keyed off the raw Mapping to your 8 steps: (1)(2)(3)(7) → PR 2, (4) → PR 1, (5) → PR 3, (6) → PR 4, (8) tests throughout — including an end-to-end test that flips To set expectations on when the leak closes: PR 2 lands automatic reclaim of clean worktrees + live runtime for every terminal path plus capped-backoff retry of transient failures; preserved-dirty and exhausted-failure cases are safely preserved at PR 2 but only become user-recoverable at PR 3–4. Does this staging look right before I start? I'd especially value your read on (a) reusing |
|
@illegalcall I've opened the first two prerequisites: #2853 (storage foundation — Small update for PR2a: since #2800 already introduces the reviewer Destroy, #2854 stacks on it and adds the Teardown / TeardownReviewer + cancel wiring rather than adding Destroy itself. The harness-change supersede case is already handled by #2800 destroy-on-spawn, so 2a only wires teardown into Cancel. |
Fixes #2811.
When a PR is merged (or closed, or a tracker issue reaches a terminal state), the lifecycle reaction terminates the session with
MarkTerminated— which only setsis_terminatedand tears down no external resources. The full teardown (runtime + worktree) lives on the Kill path (POST /sessions/{id}/kill), and that button is hidden the moment a session is terminated. So every merged-PR session leaves an orphaned worktree on disk that you can't reclaim from the UI. On an active project this piles up fast. The only recovery today is the project-scopedao session cleanup, which isn't discoverable once the session drops out of the sidebar.There's no automatic reclaim anywhere either: the reaper skips terminated sessions, and the boot-time Reconcile reap only kills a leaked runtime, never the worktree.
What this does
Bridges the fact-driven termination to the teardown the session manager already owns, without dragging runtime/workspace deps into the lifecycle layer:
session_manager: pull the per-session teardown out ofCleanup's loop intoreclaimTerminatedWorktree(no behavior change), and addReclaimOnTerminate(id)— best-effort, skips dirty worktrees (never force-removed), no-ops for an unknown or still-live session. It runs the teardown undercontext.WithoutCancelso a daemon shutdown can't interruptgit worktree removehalf-way (same reasoning as fix: use context.Background for spawn rollback cleanup #2731).lifecycle: a narrow optionalterminationReclaimerseam satisfied by the session manager, wired after construction viaSetTerminationReclaimer(the lcm and session manager are built over each other, so it can't be a constructor option).MarkTerminatedis split so the reclaim fires only on the actual termination transition — repeat observations on an already-terminal PR or issue stay no-ops. The merge/close and tracker-terminal reaction sites now reclaim; Kill's ownMarkTerminatedis untouched, so nothing double-tears-down.daemon: one line instartSessionto wire it up.The reclaim is best-effort throughout: a dirty worktree is preserved and a teardown error is logged, never surfaced, so a filesystem hiccup can't wedge the observation pipeline or leave the row un-terminated.
Backend-only, no API or SQLite regeneration.
Tests
ReclaimOnTerminatereclaims runtime + worktree, preserves a dirty worktree, and no-ops for a live or unknown session.internal/integration/reclaim_worktree_test.go): over a real SQLite store and a real git-worktree adapter, a merged-PR observation driven through the real PR service physically removes the worktree directory from disk, and a dirty worktree is preserved.Cleanuptests still pass over the extracted helper.go build,go vet,go test ./...,go test -race, and golangci-lint all pass.Known boundary
A session already terminated by another path before its PR merges (e.g. the reaper marking it terminated on runtime death) hits the no-transition path and isn't reclaimed by the later merge; reaper-death sessions are also left alone since they may hold uncommitted work. Reclaiming those uniformly needs a boot-time or periodic reconcile pass, which I've left out to keep this change scoped to the merge path.