Skip to content

feat(sessions): per-session cleanup API + cleanup facts on read model (#2811)#3051

Draft
anuj7511 wants to merge 4 commits into
AgentWrapper:mainfrom
anuj7511:reclaim-cleanup-api
Draft

feat(sessions): per-session cleanup API + cleanup facts on read model (#2811)#3051
anuj7511 wants to merge 4 commits into
AgentWrapper:mainfrom
anuj7511:reclaim-cleanup-api

Conversation

@anuj7511

Copy link
Copy Markdown
Contributor

Part 3 of the terminal-resource reconciler redesign for #2811: the per-session cleanup API and the cleanup facts the UI renders from.

  • POST /api/v1/sessions/{sessionId}/cleanup — mirrors the Kill spine (route → nil-guard-501 handler → SessionService method → service delegate). Reclaims one terminated session's runtime + workspace and returns the resulting facts. New manager primitive CleanupSession: 404 for unknown, new ErrNotTerminal → 409 for a still-live session, and — unlike the automatic reconciler/bulk path — it does not idempotent-skip, so a user can retry a preserved_dirty or failed cleanup. Never marks a session terminated. Follows Kill's LAN precedent.
  • Cleanup facts on the session read model (required for the live UI): the read assembly joins session_cleanup_facts, and ControllersSessionView / CleanupSessionResponse carry a cleanup object (workspaceDisposition, runtimeReleasedAt, attemptCount, nextAttemptAt, failureCode). Derived at read time — no display status stored.
  • specgen cleanupSession operation + regenerated openapi.yaml + frontend/src/api/schema.ts.

Stacked PR

Depends on #2931 (reconciler) and #2854 (reviewer teardown). Until those merge this diff also shows their commits — review the reclaim-cleanup-api top commit. Rebases to stand alone once the ancestors land.

Tests

Manager (not-found / not-terminal / releases+returns facts / retries a terminal disposition without idempotent-skip); service delegation + 409 mapping; read model surfaces facts for a terminated session and omits them for a live one; controller happy-path + 409 envelope + nil-Svc 501.

Validation

go build/test/-race/vet, golangci-lint, httpd spec-drift/parity — all green.

anuj7511 added 3 commits July 24, 2026 00:22
…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.
…AgentWrapper#2811)

Adds the user-facing surface for the terminal-resource reconciler: an on-demand
per-session cleanup endpoint, and the cleanup facts the UI renders from.

- POST /api/v1/sessions/{sessionId}/cleanup — mirrors the Kill spine (route,
  nil-guard 501 handler, SessionService method, service delegate). It reclaims a
  single terminated session's runtime + workspace and returns the resulting
  facts. New manager primitive CleanupSession: 404 for an unknown session, a new
  ErrNotTerminal -> 409 for a still-live one, and — unlike the reconciler's
  FinalizeTerminalSession / bulk Cleanup — it does NOT idempotent-skip a session
  already in a terminal disposition, so a user can retry a preserved_dirty or
  failed cleanup. It never marks a session terminated. Follows Kill's LAN
  precedent (allowed behind bearer auth, not in lanControlBlockedPrefixes).
- Expose cleanup facts on the session read model (required for the live UI): the
  service read assembly (toSession) joins session_cleanup_facts, and
  ControllersSessionView / CleanupSessionResponse carry a `cleanup` object with
  workspaceDisposition, runtimeReleasedAt, attemptCount, nextAttemptAt,
  failureCode. Derived at read time — no display status is stored. isTerminated
  is already on the wire via the embedded session record.
- specgen: cleanupSession operation + CleanupSessionResponse / SessionCleanupView
  schemaNames entries; regenerated openapi.yaml + frontend schema.ts.

Tests: manager CleanupSession (not-found / not-terminal / releases+returns facts
/ retries a terminal disposition without idempotent-skip); service delegation +
ErrNotTerminal->409 mapping; read model surfaces facts for a terminated session
and omits them for a live one; controller happy-path + 409 envelope + nil-Svc
501.

Stacked on the reconciler (PR AgentWrapper#2931) and reviewer-teardown (AgentWrapper#2854).

@illegalcall illegalcall left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The endpoint/read-model shape is good, but two correctness issues need to be fixed before this leaves draft.

  1. Blocking — cleanup facts are exposed across terminal generations. Service.toSession attaches any session_cleanup_facts row without checking rec.IsTerminated or facts.SessionGeneration == rec.CleanupGeneration. After Restore, a live session can expose old cleanup state; after a second termination, the UI can receive the prior episode as removed until reconciliation updates it. Only surface facts for the current terminal generation, and add restored/live plus second-generation tests.

  2. Blocking — storage failures masquerade as HTTP 200. releaseAndPersistLocked logs and swallows both persistence and re-read failures, then returns synthesized or stale facts. The per-session endpoint promises refreshed facts and the normal API error envelope; a database failure should propagate through the service/controller as a 500 rather than report success. Change the helper to return (record, error) and test the envelope.

Follow-up: SessionCleanupView.workspaceDisposition is an unconstrained string in OpenAPI even though this is a closed domain enum. Use the domain type or an explicit enum tag so generated clients cannot silently accept unsupported values.

Requesting changes for the two API/read-model correctness issues.

…propagate storage failures

toSession attached any session_cleanup_facts row regardless of whether the
session was still terminated or the facts belonged to a prior generation, so a
restored session or a second termination could surface stale disposition.
releaseAndPersistLocked also swallowed persist/re-read errors and returned a
synthesized record, so a storage failure on the per-session cleanup endpoint
looked like a 200. Also tag workspaceDisposition as a closed enum in the DTO
and regenerate the OpenAPI spec and frontend schema.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants