Skip to content

fix(sandbox): reclaim, fair reconcile, pins, scope, safe rollback - #3175

Draft
larryro wants to merge 5 commits into
mainfrom
fix/sandbox-session-integrity
Draft

fix(sandbox): reclaim, fair reconcile, pins, scope, safe rollback#3175
larryro wants to merge 5 commits into
mainfrom
fix/sandbox-session-integrity

Conversation

@larryro

@larryro larryro commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Six verified sandbox-session defects from the backend deep review (mediums), fixed on fedc8cc15 (post-#3163/#3168 — none were covered by those). Two spawner-side leaks, one dead-end, one data-loss path, one reaper fairness gap, one authz dead-end.

Per-finding outcome

# Finding Outcome Evidence
1 Ended automation runs leak live stopped session rows forever fixed watchdogs.ts RECLAIM pass: workflow_run rows stopped/expired whose run is terminal (or purged) past a 10-min grace go through the spawner's if_idle destroy; a busy session or a spawner error leaves the row for the next tick; the row settles only after the spawner confirmed the compute gone. Integration probe: ended → destroyed, purged-run orphan → destroyed, live run's active and hibernated rows untouched, busy left stopped.
2 Watchdog reconcile probes only the 25 globally-oldest rows, no rotation fixed Migration 0070 adds sandbox_sessions.last_reconciled_at_ms (+ walk-order index); both spawner-facing passes order by least-recently-visited (NULLS FIRST) and stamp every visited row, so every row is reached within ceil(rows / batch) ticks. Integration probe: batch of 2 over 3 rows reaches all 3 by tick 2 (base never reached the third).
3 Multi-bound runs' knowledge reads are hub-only — own bound projects' files invisible fixed shim.ts org_run with bindings → scope over every bound project that exists (team, shared teams, archived label) + hub, via the same helper the project binding uses (fail-closed when a bound project is gone); document_find lists the whole authorized project set (listDocumentsForAgent.projectIds), not projectIds[0]. Integration probe through the real POST /api/tools/execute door: org run lists both bound projects' docs, no unbound leak; pinned run lists only its own.
4 DockerSessionBackend.stopSession swallows docker rm failure, orphaning a running container fixed dockerRm returns the CLI result (it never rejected — a timeout is exit 124); removeContainer throws on any outcome other than clean exit / "no such container", which is the stop/destroy throw-on-hiccup contract the reaper already retries on. Create-failure cleanups and the legacy sweeps log failed rms and count only verified removals.
5 adoptExisting drops pinned, always-on sessions reaped after a spawner restart fixed SessionBackend.setPinned records the pin on durable backend state — Docker: <hostSessionRoot>/.pins/<id>.pinned (outside the workspace; cleared on create/stop/destroy), Kubernetes: tale.dev/pinned Pod annotation (merge-patch; Role gains patch on pods) — listSessions reports it, adoptExisting carries it, GET /v1/sessions/:id exposes it.
6 handleCreate rollback destroys a preserved workspace on a resume fixed createSession returns { resumed }; the route rolls a resume back with stopSession (workspace kept for the retry) and only a fresh create with destroySession — mirroring both backends' own failed-create cleanup.

Tests (all red on base, green on branch)

  • services/sandbox (bun): 251 pass (+11 new) — fake-docker (via DOCKER_BIN) stop/destroy honour the rm result (4), durable pin marker lifecycle + listSessions (2), k8s pin annotation patch + read-back (1), create rollback stop-vs-destroy (2), pin survives adoptExisting + sweep (1), pin toggle recorded on the backend (folded into the existing pinned test).
  • services/platform (vitest server): watchdogs.test.ts (5: fair order + stamp, stamp-on-error, reclaim outcomes, reclaim SQL guards, skipReconcile), shim.knowledge-scope.test.ts (5), workspace_tools_bridge.test.ts bound document_find updated to the project-set shape. Full server+pii suite: 72,718 pass; the 3 app/routes/*.test.tsx load failures under the server project are pre-existing on base (Denied ID … @fontsource … woff2?url).
  • backend:integration on a throwaway tale-db + MinIO: base 375/375, branch 377/377 — two new probes (fair reconcile + ended-run reclaim with a scripted spawner; org-run document_find + knowledge scope over two bound projects).

Verification

  • Sandbox: bun test, tsc --noEmit, oxlint --type-aware, oxfmt --check — clean.
  • Platform: tsc --noEmit, workspace-wide oxlint --type-aware, oxfmt --check — clean; commitlint on all commits.
  • Read-only verified: the K8s patch verb lands in services/sandbox/docs/kubernetes.md (the deployed Helm Role lives outside this repo — needs the same verb, otherwise the pin falls back to in-memory with a warning).

Notes

  • Migration 0070 is the next contiguous number on main; a sibling draft (fix(platform): SSO team-sync provenance, 2FA grace anchor, SCIM guards #3173) also claims 0070 — whichever merges second renumbers.
  • Cross-class discoveries (not fixed here): the platform never destroys the spawner workspace of an expired non-automation session (TTL flips the row, the spawner only stops); a phantom heal re-provisions a pinned session's deterministic id as an unpinned new row (pinned is not carried into the healed incarnation); handleSetPinned's durable record is best-effort on backends whose Role lacks patch.

dockerRm never rejects (runDocker resolves with exit 124 on a host-side
timeout), so removeContainer's .catch never fired and stopSession returned
"existed" while the container kept running; sweepExpired then dropped the
registry entry and the live container was orphaned until a restart
re-adopted it. removeContainer now judges the rm result and throws on any
outcome other than a clean exit or "no such container", which is the
stop/destroy throw-on-hiccup contract the reaper already retries on. The
create-failure cleanups and the legacy one-shot sweeps log a failed rm and
count only verified removals.
handleCreate rolled back a create whose endpoint could not be resolved
with destroySession — the data-deleting verb — even when the create had
RESUMED a stopped session onto its preserved workspace. On Kubernetes
resolveEndpoint reads the Pod IP and can blip, so a transient API error on
a resume deleted the user's PVC. createSession now reports whether the
workspace pre-existed and the route rolls a resume back with stopSession
(compute released, data kept for the retry); only a fresh create destroys
the half-made workspace it provisioned itself, mirroring both backends'
own failed-create cleanup.
The registry's pinned flag was in-memory only, and adoptExisting rebuilt
every entry unpinned with expiresAtMs = createdAt + maxLifetime — so a
deploy or crash forgot every pin and the first sweep TTL/idle-stopped the
user's always-on session; nothing platform-side re-pushes the pin until
the user toggles it again. The pin is now recorded on the backend object's
durable state — a marker under the host session root (outside the
workspace) on Docker, a tale.dev/pinned Pod annotation on Kubernetes —
listSessions reports it back, adoptExisting carries it into the registry,
and GET /v1/sessions/:id exposes it. A new create always starts unpinned
(the platform row is the truth) and stop/destroy clear the record. The
spawner Role gains patch on pods.
The sandbox watchdog's reconcile probed the 25 globally-oldest rows by
created_at_ms every tick with no rotation: long-lived healthy sessions (a
pinned always-on agent never expires) sat at the head forever, so a
younger phantom active row behind them was never probed and held one of
the org's few slots until its 24h TTL — indefinitely when pinned. And an
automation run's terminal door only hibernated its per-execution sessions
(stopped — a LIVE status the Sandboxes page lists and the spawner keeps a
workspace for): nothing ever destroyed them, so every agent-node run left
a dead row and a host workspace behind, forever.

Migration 0070 adds sandbox_sessions.last_reconciled_at_ms; every
spawner-facing pass now walks least-recently-visited first and stamps the
rows it visited, so every row is reached within ceil(rows / batch) ticks.
A RECLAIM pass destroys the sessions of ended (or purged) runs past a
10-minute grace through the spawner's if_idle door — a late node still
executing is left for the next tick, a spawner error leaves the row alone
— and settles the row only once the spawner confirmed the compute gone.
The spawner verbs are injectable, so the unit layer and the real-Postgres
probe drive both passes with a scripted spawner.
resolveKnowledgeToolAccess mapped every org-wide automation run to the
hub-only scope (projectIds: []) and ignored the automation's bound
projects, while resolveSessionActionContext confined the same run's task
and document actions to exactly those projects. rag_search, rag_fetch and
document_find on an unpinned run of a two-project automation therefore
answered not_found for the very documents the run was deployed over; the
bridge's document_find additionally listed only projectIds[0]. The scope
now spans every bound project that still exists (its team, shared teams,
archived label) plus the hub — one helper shared with the project-session
binding, fail-closed when a bound project is gone — and the listing door
takes the whole authorized project set.
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.

1 participant