feat(sessions): terminal-resource reconciler for terminated sessions (#2811)#2931
feat(sessions): terminal-resource reconciler for terminated sessions (#2811)#2931anuj7511 wants to merge 2 commits into
Conversation
illegalcall
left a comment
There was a problem hiding this comment.
Really nice design overall — state-driven off the durable is_terminated fact (with the 30s ticker as a capped-backoff retry backstop rather than terminal detection), runtime-first teardown ordering, idempotent generation-stamped facts, dirty-worktree preservation, and the reviewer-liveness gate. The keyedMutex refcounting and the shutdown close-queue choreography (unsubscribe blocks the in-flight Publish before close(queue), so no send-on-closed) are correct. 4 of the 5 core invariants hold cleanly.
One item I'd treat as gating before this leaves draft, plus two smaller notes.
1. (gating) The per-session lock doesn't actually cover Restore/Spawn — "single disk writer" is only half-enforced.
sessionLocks.lock is taken in FinalizeTerminalSession (finalize.go:288), Kill (manager.go:655) and finalizeOne (manager.go:1711) — but not in Spawn (manager.go:287), RestoreWithMode (manager.go:797) or RestoreAll (manager.go:1082). The doc comment at manager.go:165-167 states the lock serialises finalize "so a reconciler finalize can't race a concurrent Kill or Restore over the same runtime/workspace" — the Restore half isn't implemented.
Race: a terminated session whose session_worktrees are still active isn't skipped by finalize; live-wake enqueues it and it begins releaseTerminalResources → runtime.Destroy → git worktree remove. Concurrently the user clicks Restore → RestoreWithMode runs workspace.Restore + runtime.Create + un-terminate/gen-bump. The generation guard (re-read CleanupGeneration != generation → drop persist) only protects the facts write, not the physical Destroy already in flight — so the finalizer can pull the worktree out from under a freshly relaunched live session.
Fix: take sessionLocks.lock(id) in RestoreWithMode/Spawn (and per-session inside RestoreAll); the generation re-check then aborts the release correctly once the lock is re-acquired. A concurrent restore-vs-finalize test (and a Kill-vs-finalize serialization test) would lock this down. At minimum the doc comment should stop claiming Restore is covered.
2. (note) Shutdown grace (5s) < attempt timeout (2m).
run waits DefaultShutdownGrace for workerDone then returns, but a detached finalize attempt runs on a 2m context.Background() timeout and isn't cancelled on shutdown by design. A slow git op can therefore keep calling GetSession/UpsertSessionCleanupFacts after store.Close() (the deferred cleanup in daemon startup). The idempotent boot-scan covers correctness on the next start, but the write-after-close window is real — consider joining workerDone before close, or giving the attempt a shutdown-aware deadline that precedes close.
3. (nit) finalizeOne lacks the early idempotent-skip that FinalizeTerminalSession has.
FinalizeTerminalSession short-circuits when facts already record a terminal disposition for the current generation; finalizeOne (manager.go:1711) doesn't, so a bulk Cleanup over an already-removed session re-runs releaseTerminalResources and re-persists. Harmless (idempotent) but redundant, and it's an inconsistency between the two entry points — consider funneling both through the same guard.
Happy to talk any of these through. The bones are good; #1 is the one thing I'd want closed before undraft.
|
Thanks for the thorough review. Addressed all three (latest push):
Now that #2853 and #2800 have merged, I've rebased this onto current |
…rrupting
The AO reviewer runs as a stable pane ("review-"+workerID) reused across
review passes, but Cancel only interrupted it — so a cancelled reviewer
left a live pane and agent process running (the AgentWrapper#2715 leak family).
Add a real teardown: extend the launcher with Teardown(handleID) over the
runtime's Destroy, expose Engine.TeardownReviewer(workerID) (idempotent,
serialised via lockWorker so it can't race a concurrent respawn), and call
it from Cancel so the pane is destroyed rather than merely interrupted.
Reuses the reviewer-pane Destroy seam added for harness-respawn.
…gentWrapper#2811) State-driven, idempotent reconciler that converges every terminated session's runtime + workspace toward safe release (supersedes the one-shot callback in workspace, or records why it can't (dirty worktree, transient retry, exhausted). - Shared release core (runtime-first; continue-past-dirty so a dirty child can't strand clean siblings; structured disposition rollup). - FinalizeTerminalSession: idempotent, under a refcounted per-session lock; skips restore-pending sessions; generation guard drops a stale result across a Restore→Terminate cycle; capped-backoff retry + attempt cap (→ failed). - The per-session lock is also taken in Spawn, RestoreWithMode and per session in RestoreAll (restoreSavedSession), so a finalize can't git-worktree-remove out from under a concurrent restore/kill — the generation re-check then aborts the release under the lock. - Kill + bulk Cleanup funnel through the core and persist facts on every terminal path (incl. no-workspace → not_applicable); both share the idempotent-skip. Cleanup keeps reclaiming unresumable restore-pending leftovers (explicit user intent), unlike the automatic reconciler. - Reconciler runner (internal/observe/reconciler): live CDC session_updated wake (non-blocking, isTerminated-filtered so a facts-write can't self-wake it), sessions-driven boot scan for the leaked backlog, periodic retry sweep. Attempts run on a detached base context force-cancelled after the shutdown grace and joined before exit — bounded shutdown, no write-after-store.Close. - Never reclaim a worktree with a live reviewer: tear the pane down + gate on its IsAlive (review engine gains ReviewerAlive; manager takes an optional reviewer). - MarkSpawned bumps sessions.cleanup_generation in the same UPDATE that un-terminates. reconcileReap deleted (subsumed by the finalizer boot pass). - Boot: run Reconcile/RestoreAll to completion, then start the reconciler (subscribes before its boot snapshot); its done-channel drains before cdcPipe. Tests: finalizer clean/dirty/runtime-fail-retry/exhaustion/idempotent/ generation-guard/not-applicable/reviewer-gate; workspace-project mixed clean+dirty; Kill writes facts; restore-vs-finalize and Kill-vs-finalize serialization (verified they fail without the lock); reconciler live-wake + boot-scan + facts-only-ignore; and a wake-path e2e through the real CDC chain.
49e1df8 to
ef3d4f7
Compare
|
Marking this ready for review — the gating lock item and the two smaller notes are addressed, and CI is green across the board (build-test, lint, api-drift, native on macOS/Ubuntu/Windows, container, scan). One merge-ordering note: this still stacks on #2854 (reviewer teardown), so #2854 should go in first — I'll then rebase this to drop it and it merges on its own. Happy to reorder if you'd prefer otherwise. |
|
@illegalcall — the full #2811 redesign is now up as 5 stacked PRs:
Merge order is top-down: #2854 → #2931 → #3051 → #3052. Each downstream is stacked on the one above, so once an upstream lands I rebase the rest to drop it (git auto-drops the already-merged commits). #3051/#3052 are drafts only because they show their ancestors' commits until those merge — happy to un-draft whenever you'd like to look. One concern per PR throughout. |
Terminal-resource reconciler redesign for #2811, implementing state-driven design. This supersedes PR #2819 (the one-shot reclaim callback): on
mainthat seam never existed, so this is a fresh reconciler rather than a removal — #2819 can be closed once this lands.The shift
is_terminated=trueis the durable intent that a session no longer wants runtime resources; the daemon must converge runtime + workspace toward safe release (cleanup may be pending, retrying, or blocked on a dirty worktree). A callback is the wrong seam:is_terminatedis written on three paths (reaper death, activity/SessionEnd exit,MarkTerminated) but the old callback fired on only two reactions, so the two most common endings reclaimed nothing. A reconciler keyed off the durable fact covers every terminal transition and is crash/transient-failure recoverable.What's here
FinalizeTerminalSession— idempotent, under a refcounted per-session lock; skips restore-pending sessions; capturessessions.cleanup_generationand drops a stale result if a Restore→Terminate cycle advanced it; capped-backoff retry with a capped attempt count (exhaustion →failed, stops auto-retry).not_applicable) so the sessions-driven boot scan can't re-enqueue an ever-killed session forever. Kill keeps its contract (fail-closed on runtime / non-dirty-workspace failure; preserved-dirty still terminates withfreed=false).internal/observe/reconciler, modelled on the reaper) — live CDCsession_updatedwake (non-blocking send, filtered onisTerminatedso a facts-write can't self-wake it), a sessions-driven boot scan that drains the pre-existing leaked backlog async, and a periodic capped-backoff retry sweep. Attempts run on a detached-but-bounded context; shutdown waits a short grace and unsubscribes before the CDC pipe stops.IsAlive.MarkSpawnedbumpssessions.cleanup_generationin the same UPDATE that un-terminates, so the reset is atomic-with the un-terminate.reconcileReapdeleted — its boot runtime-Destroy is a strict subset of the finalizer's runtime-first release. Boot reorder: runReconcile/RestoreAllto completion, then start the reconciler (subscribing before its boot snapshot to close the boot-window race).Behavior change to call out
Crash/reaper-terminated and activity-exit-terminated sessions — which leak today — now have their clean worktrees + live runtime auto-reclaimed. Uncommitted work is protected solely by the dirty→preserve rule (never force-removed).
Stacked PR
Depends on #2853 (storage foundation) and #2854 (reviewer teardown, itself on #2800). Until those merge, this diff also shows their commits — review the
reclaim-terminal-reconcilertop commit. Will rebase once the prerequisites land.Tests
Finalizer clean / dirty / runtime-fail-retry / exhaustion / idempotent / generation-guard / not-applicable / reviewer-gate; workspace-project mixed clean+dirty; Kill writes facts; reconciler live-wake + boot-scan + facts-only-ignore; and a wake-path e2e through the real CDC chain (store trigger → poller → broadcaster → reconciler → finalizer).
Validation
go build ./...,go test ./...,go test -race ./...,go vet ./..., golangci-lint (v2.12.2) — all green; no sqlc drift.