Skip to content

Make a cancelled run's sandbox release reportable — and actually release it - #46

Merged
weilei0120 merged 79 commits into
mainfrom
feat/cancel-release-confirmation
Sep 21, 2026
Merged

weilei0120 merged 79 commits into
mainfrom
feat/cancel-release-confirmation

Conversation

@zoroyihan7

@zoroyihan7 zoroyihan7 commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator

What was asked, and what it turned out to require

The handoff asked for one field: let a caller tell an accepted cancellation whose sandbox stop failed from one that worked. POST /v1/tasks/:taskId/cancel now carries it.

{ "ok": true, "cancelled": 1, "released": "confirmed" }    // the stop returned 2xx or 404
{ "ok": true, "cancelled": 1, "released": "unconfirmed" }  // logged and swallowed today
{ "ok": true, "cancelled": 0, "released": "nothing_held" } // no handle was ever recorded

Making that field true turned out to require fixing the teardown it reports on. The short version:

The API read the wrong KV bucket, so no API-side sandbox teardown had ever run. sandbox-stopper built its handle map over BRAIN_REGISTRY; Brain registers handles in DAG_HANDLES. Same map class, same keys, same encoding, no error — it asked a bucket that was always empty. And deploy/nats-values.yaml agreed with the bug, granting $KV.BRAIN_REGISTRY.dag-handles.> — a permission for a key nobody has ever written — and nothing for KV_DAG_HANDLES. A code-only fix would have passed every test in the repo and still done nothing, because a denied publish does not raise in the NATS client.

Reporting on top of that would have turned a silent leak into a confident nothing_held, which is worse than the silence. So the wiring is fixed here, and so is what fixing it exposed.

What the diff contains

The reporting. safeStopWorkload returns its outcome instead of only logging it; stopAllHandlesForDag aggregates — confirmed only if every handle was confirmed and nothing is outstanding on record and no handle appeared during the teardown.

Never inferring release from the handle map, in both directions. destroy runs before the stop, so the map is empty either way. The handoff warns against reading that as success; it arrives just as easily as nothing_held, which is the subtler half. An outcome short of confirmed is recorded on the DAG root's claw_tasks.metadata — durable, atomic per row, keyed by workload identity, digest-keyed so a handle named token is not redacted away, and visible through GET /v1/tasks/:taskId so the caller learns which handle leaked.

Teardown no longer destroys live sandboxes. Activating the path exposed four ways it would: the sweeper reaping standalone tasks mid-run and pods the next task in the session had reused, agent_done releasing a handle a sibling was still using, and re-cancelling a finished DAG stopping what a newer DAG had adopted.

Handle ownership. Reuse never registered ownership at all, so a DAG that adopted a warm sandbox held one the map did not name. Rebuild left the map naming a stopped workload, because the replacement's registration hit create's overwrite refusal and the exception was swallowed. Both are fixed, with a replace that is a single write — destroy-then-create leaves the handle briefly absent, and an absent handle is exactly how teardown concludes a DAG holds nothing.

Concurrency. Handle removal and registration are both revision-conditional now; previously a plain read-modify-write on either side could discard a handle the other had just committed, losing a live workload's only reference.

Known-open, and deliberately not claimed otherwise

  • Two acquisition windows. A workload exists before its handle is registered (create), and a sandbox is adopted before ownership moves (adopt). A cancel landing in either still answers nothing_held. Closing this needs explicit acquisition phases with per-attempt identity and a cancel barrier the API can read — reviewed and agreed as its own change. A cheaper marker was designed and rejected: it would have made a DAG read unconfirmed for ever, with no failure involved.
  • confirmed means SaFE accepted the stop, which is the handoff's own definition and the strongest thing SaFE's API can be asked. It is not proof the GPU is free: teardown is asynchronous under a finalizer, and the apiserver's read is database-backed, so a verification read cannot establish it either. That reasoning is recorded at the call site so it is not reattempted.
  • Two live DAGs sharing one reused workload — cancelling one stops the other.

⚠️ Before rolling out

DAG_HANDLES has no TTL and has never been swept, because the sweeper has been reading the wrong bucket for its whole existence. The first sweep after this lands walks the accumulated backlog, and its destroy condition includes a DAG root row that is merely missing:

nats kv ls DAG_HANDLES | wc -l

A large number means a burst of SaFE stop calls, and entries of unknown age whose owners are gone will be treated as orphans.

Review

Twelve adversarial review rounds; 34 defects fixed, several of them regressions introduced by earlier rounds' fixes and caught by later ones. Key fixes are mutation-verified — the test is reverted against the fix to confirm it actually fails. CI green on every commit; 2619 tests and every claw/scripts/lint-*.sh passing.

🤖 Generated with Claude Code

`Launcher.cancel` on Dispatron's side means "the resources are gone". Against
Claw nothing could establish that, because three places each dropped the
answer: `safeStopWorkload` logged a non-2xx, a timeout or a missing
`SAFE_API_URL` and returned `void`; `cancelTask`'s DAG-root branch returned
`{ok: true}` after awaiting it; and the route forwarded that. So an accepted
cancellation whose SaFE stop failed was byte-identical to one that worked, a
GPU workload kept running, and the counter built to catch exactly that stayed
at zero.

`POST /v1/tasks/:taskId/cancel` now carries `released`, one of `confirmed` /
`unconfirmed` / `nothing_held`. `safeStopWorkload` returns its outcome instead
of only logging it, and `stopAllHandlesForDag` aggregates: confirmed only if
every handle was confirmed, so one failure among several cannot be averaged
away by its neighbours. A 404 from SaFE is `confirmed` -- the workload is gone,
which is the state the stop was reaching for.

Three things this deliberately does not do:

  - It does not infer release from the handle map. `destroy` runs BEFORE the
    stop, so the map is empty whether the stop worked or not; the return value
    is the only place the answer exists, and one test asserts the map is empty
    in precisely the case the answer is `unconfirmed`.
  - It does not let a failed release fail the cancel. The verdict is written
    first and stays written; the sweeper and Dispatron both rely on that.
  - It does not change the status code or any existing field. `released` is
    added, and omitted rather than guessed on the non-root branch, which stops
    no sandbox and so establishes nothing -- a client that does not know about
    the field reads an unchanged response.

A handle registered with no SaFE workload id behind it (agent-sandbox, written
that way by Brain's ensureHands) now reports `unconfirmed` rather than sharing
a falsy early return with "no such handle". Something is held and this path has
never been able to release it, so `nothing_held` would assert the opposite of
what is true.

Tests cover the acceptance cases that could not be written before: a non-2xx, a
timeout, an unset `SAFE_API_URL` (its own file, since config reads it once at
module scope), a DAG where one of several stops fails, a task that never
recorded a handle, and a 404.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@zoroyihan7
zoroyihan7 requested a review from a team as a code owner September 14, 2026 10:04
zoroyihan7 and others added 3 commits September 14, 2026 10:35
Four defects from the first review round, two of which let the forbidden
inference back in through a door this change had opened itself.

**A repeat or concurrent cancel reported `nothing_held` for a release that had
failed.** `stopAllHandlesForDag` read an empty handle map as "nothing was ever
held" — but the map is emptied *before* the stop is attempted, so a second
cancel after a failed one read exactly the emptiness the first cancel created
and issued a clean bill of health for a workload still holding a GPU. That is
the one inference the whole feature exists to refuse, arriving from the other
side. The same hole was reachable whenever anything else got to the handles
first: a concurrent cancel, the agent_done path, or the sweeper. An outcome
short of `confirmed` is now written to a `dag-unreleased.<root>` record, in its
own key space because the handle entry is deleted exactly when it would be
needed, and an empty map answers `nothing_held` only when that record has
nothing outstanding. A confirmed release clears its entry, so the record is not
a latch that makes a DAG unconfirmed forever.

**A 2xx from SaFE's stop was reported as `confirmed`.** It is not a release:
`stopWorkload` sets the Workload's phase and issues a Kubernetes delete, then
returns, while the job-manager tears the data-plane objects down afterwards —
requeuing every 10s for as long as any remain and only then dropping
`WorkloadFinalizer`. So a 200 and a Pod still holding a GPU coexist on any
normal controller latency, and persist on a stuck controller. Calling that
`confirmed` would have retold the exact lie this field was added to stop
telling, one layer further in. That same finalizer makes the truth cheap: the
Workload survives in etcd until the teardown finishes, so one GET separates
"gone" from "still going". A 2xx stop is now followed by a single 5s read, and
answers `confirmed` only on a 404/410. It waits for no teardown — a workload
still present is `unconfirmed`, because at that instant it is.

**Cleanup that threw produced a 500 for a cancellation already written.**
`loadPlatformKeyForSession`'s query and the registry destroy both sat outside
`safeStopWorkload`'s try/catch, so a database or KV error propagated through the
aggregate and out of `cancelTask` — after the verdict was committed, and
abandoning every handle after the first. Each is now contained per handle,
converted to `unconfirmed`, and the loop continues. Containment lives in the
stopper rather than at the callers so all three entry points get it.

**A registry that could not be read was reported as `nothing_held`.** The KV
adapter turned every `get` failure, connection errors included, into `null`,
which `listForDag` renders as `{}`. An unreachable NATS would have reported a
clean release for every DAG in the fleet. `get` now returns `null` only for an
absent key; a corrupt payload throws rather than reading as absent.

Also from the round: the sweeper reached `handleMap()` directly, bypassing the
seam, and counted DAGs rather than releases — it now goes through the seam and
logs how many of a tick's reconciliations could not be established, since it is
the one teardown path with no caller to report to.

Tests: R7 now asserts the per-handle branch, which the aggregate cannot tell
apart; R10 goes over HTTP via `app.inject` instead of matching the handler's
source text, which could not see a status code — this needed an
`interruptDelivery` seam, and it lets R10 also assert the interrupt is really
published. R11-R16 cover the four defects above: repeat cancel, the confirmed
release clearing, a stop accepted but unfinished, the confirming read timing
out, an unreadable registry, and cleanup that throws.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…cked nothing — review round 2

Two of round 1's own fixes were wrong, in opposite directions. Both are
corrected here, with the reasoning recorded so neither is reattempted.

**The unreleased record was in a bucket that forgets.** It went into
`BRAIN_REGISTRY` beside the handle map, which is short-lived coordination state
with a bucket-wide TTL — `BRAIN_REGISTRY_TTL_MS`, five minutes by default,
sized for `lock.<key>`. Evidence of a leaked GPU that expires after five
minutes is evidence only for as long as nobody was going to look, and the
second cancel that round 1 was about is exactly the caller who arrives later.
It was also a JSON blob under one key, so read-modify-write: two cancels racing
lose a mark, and the lost one is a workload reported as released.

It now lives on the DAG root's `claw_tasks.metadata`. Durable, and each write
is a single jsonb merge statement, atomic on the row, that preserves siblings
rather than replacing them — `derived.handle_last_user` sits one key over and
losing it would take the DAG's whole teardown plan with it. It is also now
readable: `publicTaskRow` strips only the three credential fields, so
`GET /v1/tasks/:taskId` shows *which* handle was not released and what workload
it was, which `released: "unconfirmed"` alone cannot say.

**The confirming read could not confirm anything.** Round 1 established that a
2xx from SaFE's stop is not a release — `stopWorkload` sets the phase and
issues a Kubernetes delete, then returns, while the job-manager tears the data
plane down afterwards under `WorkloadFinalizer` — and closed the gap by
following the stop with `GET /api/v1/workloads/<id>`, confirming only on a 404.
That read is database-backed, not etcd-backed: `GetWorkload` filters on
`is_deleted = false`, and the stop path writes `SetWorkloadStopped` (phase,
end_time, deletion_time) and never `is_deleted`. It answers 200 for a workload
that stopped perfectly normally, so the check would have reported `unconfirmed`
for every successful cancellation in the fleet. A field that cries wolf
constantly is worse than the silence it replaced, because the one real failure
becomes indistinguishable from the noise.

So the read is gone and `confirmed` means what the handoff defined it to mean:
SaFE accepted the stop. The gap is documented at the call site instead of
papered over — the finalizer state that would answer the question lives on the
CR in etcd and no endpoint Claw can reach exposes it, so closing it properly
needs a data-plane-completion signal from SaFE. What this change delivers is
still what was asked: the refused stop, the timeout, the unreachable SaFE and
the unconfigured one are no longer silently dropped.

**A tombstone is not a corrupt entry.** Making the KV adapter propagate read
failures (round 1, so an unreadable registry could not read as `nothing_held`)
turned every deleted key into an error: `kv.get` does not filter DEL/PURGE — it
answers with the tombstone, whose value is empty — and an empty body is exactly
what a strict JSON parse calls corrupt. Every handle this module destroys
leaves one behind, so a completely clean teardown would have read as unreadable
and every later cancel of that DAG answered `unconfirmed` forever. DEL/PURGE
and empty values are absent; only a genuinely unparseable payload is unknown.
`scanPrefix` now makes the same three distinctions, where it previously skipped
unparseable entries and handed the sweeper a short list it called complete.

The adapter moved behind `makeKvStore(kv)`, taking the bucket instead of
closing over the module's own, because which answers mean "absent" and which
mean "unknown" is now load-bearing for whether a DAG can be reported as holding
nothing.

Tests: R13 and R14 replaced — R13 now asserts the *absence* of the second
request, R14 covers the tombstone, the empty value, the corrupt payload and the
unreachable bucket. New `unreleased-record-scenario.test.ts` runs the record's
real statements against PGlite with `clawTasksSchemaSql()`, since an in-memory
Map cannot disagree with its own implementation while hand-written jsonb merges
against a shared column very much can: U1-U7 cover sibling survival, per-handle
accumulation, partial and full clears, a no-op clear, writing to the root and
not a node of the same DAG, and a missing root reading as unknown.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… — review round 3

The round found that the feature was reporting on a teardown that had never
run, and three ways the report could still confirm a workload nobody released.

**The API read the wrong KV bucket, so no API-side teardown has ever torn
anything down.** `sandbox-stopper` built its `DagHandleMap` over
`infra/nats.kv`, which is `BRAIN_REGISTRY`. Brain writes handles to a bucket of
its own, `DAG_HANDLES` (`brain/src/sandbox/handles.ts`). Both sides used the
same map class, key shape and encoding, and nothing ever errored — the reader
simply asked a bucket that had nothing in it. Every teardown the API ran found
an empty map and issued no stop: on the cancel path, the agent_done path and
the sweeper alike, a DAG's sandboxes outlived their DAG.

That is the precise shape of bug this whole feature was asked to make visible,
so reporting over the top of it would have turned a silent leak into a
confident `nothing_held` — worse than the silence, because now something is
asserting. The API now attaches to `DAG_HANDLES` with `ttl: 0` and
`widenOnly`: a handle has to outlive its DAG, which for a long evaluation is
hours, and `BRAIN_REGISTRY`'s five-minute TTL — sized for `lock.<key>` — is
part of why the wrong bucket looked plausible. `widenOnly` because this process
is not the authority on a bucket Brain owns.

`sandbox-handle-bucket.test.ts` pins the two names to each other, which is what
it takes: a name that is wrong but *consistent* is invisible to a unit test,
and to any integration test that stubs the registry.

**The record was written after the attempt, so the window between them
confirmed.** `destroy` removes the mapping first; until the outcome was
recorded the handle was in neither place, and a concurrent cancel landing there
saw an empty map and an empty record and answered `confirmed` for a workload
whose only stop was still in flight. A process dying mid-stop left no trace the
attempt had happened. The mark now precedes the stop, so the window fails safe:
the worst it produces is an `unconfirmed` that a completed release clears.

**The aggregate trusted its own loop.** `stopAllHandlesForDag` snapshots the
handles registered *now*; one that leaked earlier is no longer among them,
because agent_done tears a handle down as soon as its last user finishes. So a
later cancel that released everything it could see reported `confirmed` over the
top of the workload nobody released. It now consults the record before
confirming.

**A payload of the wrong shape read as an empty map.** `JSON.parse` accepts
`null`, `false` and `7`, and the cast that followed accepted all three;
`DagHandleMap` then reads them as a DAG holding no handles — an unknown wearing
the one answer that must never be invented. Shape is as much of the contract as
syntax. Also: every catch on this path exists to keep a cancellation alive, so
reading `.message` off a rejection that is not an `Error` would have defeated
the containment at the moment it was needed.

Tests the round proved were passing for the wrong reason: R12 started from an
empty record, so it passed with the `clear` deleted — it now seeds the DAG as
already leaking. R15 replaced `listForDag` with a throwing function, asserting
only that the caller catches; it now drives the real adapter, which is the part
that used to turn read failures into `nothing_held`. New R17 (a handle that
leaked earlier is not confirmed away) and R18 (on record before the stop runs).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@zoroyihan7 zoroyihan7 changed the title feat(api): let a cancel say whether it actually released the sandbox feat(api): let a cancel say whether it released the sandbox — and destroy it out of the right bucket Sep 14, 2026
zoroyihan7 and others added 2 commits September 14, 2026 11:01
Found while auditing the bucket change from the last commit, and it is the
other half of why that bug survived: the NATS allow-list agreed with it.

The api user was granted `$KV.BRAIN_REGISTRY.dag-handles.>` — a permission for
a key nobody has ever written, since brain registers handles under
`$KV.DAG_HANDLES.dag-handles.*` — and nothing granted `KV_DAG_HANDLES` at all.
So the code and the deployment described the same wrong layout, consistently,
and a subject that is wrong but agreed on by both sides looks exactly like a
subject that is right.

It also means the previous commit alone would have failed in the cluster while
passing every test in the repo. A denied publish does not raise in the NATS
client, so the new code's failure mode there is a teardown that silently does
nothing — indistinguishable from the failure mode it just fixed, and precisely
the kind of invisibility this PR exists to end.

The api user now holds every subject class the stopper reaches, each for a
different operation: `$KV.DAG_HANDLES.dag-handles.*` for put and delete
(`.*`, not `.>`, because a handle key is one token — a task id has no dots, and
it is the grant brain already holds); STREAM INFO/CREATE/UPDATE for the
reconcile, CREATE included because api can start first on a fresh cluster;
DIRECT.GET and the MSG.GET fallback for reads; the ordered-consumer lifecycle
for the sweeper's `kv.keys()`; and `$JS.FC.KV_DAG_HANDLES.>`, without which
that scan stops yielding — without throwing — once the bucket is large enough
for the server to apply backpressure, handing the sweeper a short list of DAGs
and no indication of it.

The stale BRAIN_REGISTRY grant is removed. Left in place it is a standing
invitation to put the code back: a grant describing a layout nothing
implements, sitting in the file somebody reads to find out what the layout is.

B3 and B4 pin the allow-list to the bucket constant the code names. Nothing
tied the two together before, which is how they drifted and then agreed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Half the reason the record lives on `claw_tasks.metadata` rather than in KV,
and the half nothing asserted. A `released: "unconfirmed"` says a sandbox was
not released; it cannot say which handle, or what workload to go and look for,
and an operator holding only the first has nothing to act on.

`publicTaskRow` strips the three credential fields and redacts the rest, so
whether the record is on the readable side of that is a property of somebody
else's function. A redactor that grew a rule for `*_id`, or for anything under
an unfamiliar key, would take the actionable half away silently and leave an
endpoint that still answers and no longer helps.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@zoroyihan7

Copy link
Copy Markdown
Collaborator Author

Review trail, and two things that need a human

Three adversarial review rounds against this branch; 13 defects found and fixed, four of them mine from earlier rounds. Full history is in the commit messages. CI has been green on every commit (47/47); locally 2586 tests, 0 failures, all claw/scripts/lint-*.sh passing.

The rounds were worth it — the two most serious findings were not in the feature at all, but underneath it:

1. No API-side sandbox teardown has ever run. sandbox-stopper read BRAIN_REGISTRY; Brain registers handles in DAG_HANDLES. Same map class, same key shape, same encoding, no error — the reader just asked a bucket that was always empty. Every cancel, every agent_done, every sweeper pass tore down nothing.

2. The NATS allow-list agreed with it. The api user was granted $KV.BRAIN_REGISTRY.dag-handles.> — a permission for a key nobody has ever written — and nothing for KV_DAG_HANDLES. So a code-only fix would have passed every test in the repo and still done nothing in the cluster, because a denied publish does not raise in the NATS client.

Both are fixed here, and sandbox-handle-bucket.test.ts now pins code, bucket and allow-list to each other — nothing tied them together before, which is how they drifted and then agreed.


⚠️ Rollout: this makes a dead path live, and there may be a backlog

DAG_HANDLES has no TTL, so entries have accumulated for as long as the bucket has existed, and nothing has ever reaped them. On the first deploy with this merged, reapOrphanHandles walks all of them, and its destroy condition includes a DAG root row that is missing — not just terminal.

So please check the bucket before rolling out:

nats kv ls DAG_HANDLES | wc -l

Two things follow from that number. A large one means a burst of SaFE stop calls on the first sweep. More importantly, any entry whose DAG root row is gone will be treated as an orphan and stopped — and with a backlog of unknown age, I cannot rule out that some of those point at workloads still legitimately in use. The sweeper's own comment already warns about the related hazard of two replicas concluding together and tearing down a sandbox Brain has just rebuilt; that warning has been unreachable for as long as the bucket was wrong.

I did not want to decide this one silently. Happy to split the bucket + allow-list fix into its own PR so it can be rolled out deliberately and watched, leaving this one as the reporting change alone. The reason I did not is that shipping released on top of a teardown that never runs would turn a silent leak into a confident nothing_held — an assertion that is worse than the silence. Say the word either way.

The remaining honest gap: confirmed means accepted

That is the handoff's own definition (// the stop returned 2xx or 404), and it is the strongest thing SaFE's API can be asked. It does not mean the GPU is free: stopWorkload sets the phase and issues a Kubernetes delete, then returns, while the job-manager tears the data plane down afterwards under WorkloadFinalizer.

I tried to close that gap with a follow-up GET /api/v1/workloads/<id>, confirming only on a 404, and had to revert it — the apiserver's read is database-backed, GetWorkload filters is_deleted = false, and the stop path writes SetWorkloadStopped and never is_deleted. It answers 200 for a workload that stopped perfectly normally, so that version reported unconfirmed for every successful cancellation in the fleet. The reasoning is recorded at the call site so it is not reattempted.

Closing it properly needs a data-plane-completion signal from SaFE — a SaFE-side change, flagged rather than faked. Worth deciding whether that belongs on someone's list before Dispatron's cancel comment is updated to lean on this field.

zoroyihan7 and others added 8 commits September 14, 2026 11:17
The last way a false clear could get in, found tracing every path to
`stopAllHandlesForDag` for one.

`destroy` removes the handle mapping and returns the workload id. When that
call throws, two things may have happened: nothing, or the server executed the
delete and the response was lost. The second leaves the handle gone from the
map with nothing recorded anywhere — so the next caller reads an empty map, an
empty record, and answers `nothing_held` for a workload that was never stopped.
The previous commit reasoned that the aggregate consulting the record covers
this; it does not, because in this branch there is no record to consult.

It is now recorded with an empty workload id, which is the truth: `destroy` is
what would have returned one. The deliberate cost is that nothing can ever
clear this entry — no later teardown revisits a handle that is no longer in the
map — so the DAG answers `unconfirmed` from then on. That is the right way
round to be wrong. A standing false alarm on a DAG whose KV read failed is
visible and checkable; a false clear on a live GPU is neither, and is the whole
reason the field exists.

Also recorded, at `rememberOutcome`: a failed record write is not a silent loss
of evidence in the way it first appears. The record lives on `claw_tasks`, and
a database that cannot take this write could not have taken the cancellation's
own verdict either — that write happens first and throws, so the request fails
loudly long before reaching here. What the catch covers is the narrow case of a
write failing on its own, where this caller still gets its answer and only the
next caller's view of it is lost.

R19 covers both halves: the lost response is recorded, and the next cancel over
the now-genuinely-empty map still answers `unconfirmed`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`stubSafe`'s doc still described the confirming read that round 2 removed, and
carried a `present` option nothing used any more — so the file explained the
fake in terms of a design the code no longer has, which is the sort of comment
that gets believed over the code next to it.

It now says what the `read` counter is actually for: R13 asserts it is empty,
because the absent second request IS the property under test. The reason the
read cannot work is kept with it, since a reader who does not know it will
reasonably wonder why the obvious check is missing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The bucket fix two commits ago makes a teardown path live that has never run.
The round found two ways that path destroys a sandbox somebody is still using.
Both are older than this branch; both become reachable because of it, which
makes them this branch's to fix. A change whose whole purpose is to stop
sandboxes leaking must not start killing live ones on the way.

**The sweeper reaped every standalone task's sandbox, while it was running.**
`reapOrphanHandles` looked up the handle's owner with
`task_id = $1 AND dag_node_id = '__dag_root__'`. That is not a narrowing of the
same row — for half the handles it is a different row. Brain registers under
`dag_root_task_id ?? task_id`, so a standalone task owns a handle under its own
task id, and a standalone task's `dag_node_id` is NULL. Every one of them
matched nothing, read as `missing`, and was torn down mid-run. Keyed on the
primary key now; a row that is absent is an orphan, a row that is present and
not terminal owns its sandbox whatever shape of task it is.

**`agent_done` released handles other nodes were still using.**
`handle_last_user` is the last node in *topological* order that names the
handle, not the last one to finish. Two siblings that both use a handle created
upstream order as [root, A, B], so the map says B — and B finishing first tore
the sandbox down with A still running on it. Giving B the higher priority makes
that the normal case, not a rare one. The teardown is now deferred while any
sibling is non-terminal; the DAG-root transition tears the handles down a
moment later anyway, so the wait is bounded by the DAG's own remaining work.

Three more from the round, all in this branch's own code:

**The window between `destroy` and the first record.** `destroy` is the point
of no return — it drops the mapping, and anything not written down by then is
unrecoverable. A concurrent cancel landing between the drop and the mark read
an empty map and an empty record and answered `nothing_held`; a process dying
there left that state permanently. The handle is now looked up and recorded
*before* the mapping is dropped, at the cost of one KV read on a path already
making an HTTP call.

**The record was keyed by handle name, so a rebuild's outcome could clear
another workload's.** A handle name is reused; an older stop succeeding and
clearing a newer workload's failure is a confirmed release invented out of two
unrelated events. Entries are keyed by workload identity now, and cleared only
for the workload whose release was established.

**And that key could be redacted away entirely.** `redactPublicJson` replaces
the value under any key containing a sensitive *word* — `isSensitiveKey` splits
and matches each — and handle names are chosen by whoever wrote the DAG. A DAG
may legitimately declare a handle called `token`, and `token`, `w-1:token` and
`handle_token` are all redacted alike, taking the workload id with them and
leaving the caller knowing a sandbox leaked but not which one. The key is a
`sha256(handle \0 workload)` digest: hex only, so it cannot spell any word the
redactor looks for. Both names live in the value, where they are data. U9 runs
that round trip for a handle actually named `token`.

L1-L4 cover the two live-sandbox cases in both directions — not reaped while
running, still reaped when terminal; not released with a sibling live, released
once they are terminal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two holes the last round named that I reported as fixed and had not fixed.

B3 asserted only the bare `$JS.API.CONSUMER.CREATE.KV_DAG_HANDLES`. The server
names an ordered consumer itself, so the request subject has no trailing token
-- and addresses it as `<stream>.<name>` when it does. Both forms are granted;
only one was checked, so the `.>` grant could be deleted with this test green
and `kv.keys()` failing in the cluster. INFO is now asserted too, for the same
reason.

And the check matched raw file text, so a subject *named in a comment* read as
a permission. This file explains several, including the stale grant B4 exists
to keep out, so the one place that reads it had to distinguish a grant from a
sentence about a grant. It now compares against the parsed grant lines, as B4
already did.

Neither changes the deployment: both grants were already in nats-values.yaml.
What changes is whether removing one is caught.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
I had reported this one as deferred, on the grounds that fixing it properly
means giving `KVStore` a revision and that is a contract two packages
implement. The deferral was wrong, for a reason I only found when checking
whether the race predates this branch: **it does not.**

`DagHandleMap.destroy` rewrites the whole DAG row and its write carries no
revision. This module is the only caller of it anywhere — Brain registers and
looks up, never destroys — and its calls went to the wrong bucket until this
branch fixed that. So the lost update has never been reachable, and pointing
the API at the bucket Brain actually writes is what makes it reachable. A race
this branch introduces is this branch's to avoid, not to note.

What it costs is not a misreport:

    API      reads `{a: Wa}` and prepares to write the row without `a`
    Brain    registers `b`, writing `{a: Wa, b: Wb}`
    API      writes `{}` — `b` is gone, and with it the only reference to Wb

Nothing then knows Wb exists: not the cancel, not the unreleased record, not a
later sweep. It holds its GPU until something outside Claw notices. Every other
defect in this branch has been a wrong answer about a workload; this one
destroys the evidence that the workload is there at all, which is strictly
worse and is the opposite of what the PR is for.

The removal is now conditional on the revision the row was read at, retrying on
conflict — five attempts, because each retry means a registration landed, which
is rare and self-limiting, while a row that will not settle is a broken
invariant rather than a busy one and is raised rather than spun on inside a
cancel request. A conflict is matched narrowly, so a genuine failure is raised
instead of being retried into the attempt limit and raised later with worse
context. Giving up must not return `null`: that reads as "no such handle" and
renders as `nothing_held`, inventing the one answer this must never invent out
of a bucket that was merely busy.

It lives in `sandbox-stopper` rather than in `DagHandleMap` because that class
is shared with Brain. Giving `KVStore` a revision is still the right fix and
still belongs in its own review; what does not belong there is a race this
branch is about to create.

C1-C4 drive it against a bucket that enforces `previousSeq` the way NATS does,
since the property under test is the revision check itself — a fake without one
would pass whether the code sent a revision or not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…und 4

**The sweeper stopped the sandbox the NEXT task in the session was reusing.**
The registering task being terminal does not mean the sandbox is idle: Brain
keeps a finished task's pod warm as `hands.<session>`, and the next message in
the same session reuses it via `tryReuseSessionSandbox` without moving the DAG
handle's ownership. T1 completes, T2 picks up the same workload, and a sweep
reading only T1 stops the pod T2 is running on. The two are sequential — this
needs no race, it is the ordinary shape of a session, and the previous commit's
fix did not touch it because L1/L2 only ever had one owner. Ownership should
move with the reuse and that is Brain's to do; until it does, a session with
live work keeps its sandboxes. L5 covers it.

**Recording before the destroy was not protection, because the destroy ran
anyway.** `rememberOutcome` swallows its write failure — deliberately, so
bookkeeping cannot fail a cancellation — and the code then dropped the mapping
regardless. That loses the handle exactly as completely as not recording at
all: a concurrent cancel reads an empty map and an empty record and answers
`nothing_held`. The mark now gates the destroy. A sandbox that stays registered
until something retries costs a deferred reap; dropping the only reference to
it costs the sandbox.

**A destroy that found nothing left a false alarm nothing could clear.** With
the lookup now preceding it, a slower caller marks, then finds the handle
already gone — taken by a faster caller who marked, stopped and cleared. The
slower caller's mark is speculative, made before it knew it had anything to do,
and no later teardown revisits a handle that is no longer in the map: the DAG
reports `unconfirmed` for ever over a workload that was released perfectly
well, which teaches an operator to ignore the field. It is retracted, safely,
because the other caller's record stands until their release is established.

Tests the round proved were passing for the wrong reason, both confirmed by
mutation here:

- **L1 survived restoring the exact predicate it exists to forbid.** Its stub
  answered the new query by prefix before reaching the old one. Reordered so
  the old predicate is matched first; restoring it now fails L1, checked by
  reverting the line and running the file.
- **R18 watched `fetch`, so it passed with the record written after the
  destroy** — the ordering it exists to forbid. It now observes inside
  `destroy`, which is the instant the evidence must already exist.
- **U10 is new**: the digest key had no direct regression test, and a key made
  of the handle name alone passed every other test in the file, because every
  other test uses one workload per name. It pins two workloads under one name
  as separate entries, with a clear on the older leaving the newer outstanding.

R19's comment is corrected: the workload id now comes from the lookup that
precedes the destroy, so the entry names the workload even when `destroy` never
answered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…— round 5

Four of this round's six findings are defects the previous round's fixes
introduced. The worst is mine and is the exact failure this PR exists to stop.

**The retraction deleted the winner's failure record.** Round 4 added it on the
reasoning that a caller who loses the destroy race can safely clear its own
speculative mark, "because the winner's record stands". It does not: both
callers derive the same key from the same (handle, workload) pair, so there is
one record. Two cancels, the winner's stop returns 503 and is recorded, the
loser's `destroy` returns null and clears it — and the DAG then answers
`nothing_held` for a workload nobody released. A false clear on a live GPU is
the one answer this field must never invent, so the retraction was a worse bug
than the standing false alarm it was fixing. Reverted, with the cost it leaves
written down rather than re-discovered.

**The CAS retry could remove a workload it never recorded.** The revision check
stops a concurrent registration being overwritten; it does not stop a *retry*
removing the wrong thing. Between re-reads a rebuild can register a different
workload under the same handle name, and deleting that entry drops the only
reference to a live sandbox — the failure the revision check exists to prevent,
arriving by the other door. The removal is now bound to the workload the caller
recorded, and a changed identity aborts rather than deletes.

**The aggregate confirmed over handles registered while it ran.** It walked a
snapshot and then checked only the unreleased record, so a handle registered
mid-teardown — a rebuild, or a node starting after the snapshot — was a live
sandbox neither stopped nor counted, and the interrupt is not published until
after this returns. It re-reads now. This bounds what the call may claim; it
does not fix the race, because a workload exists before its handle is
registered, and that is recorded as needing coordination that does not exist.

**The agent-sandbox branch bypassed the gate** the workload-id branch had
gained, dropping the mapping when the record had not landed — the same total
loss of the handle, on the one path that cannot stop the sandbox by any other
means.

**The record was written to a row that did not exist.** `mark`/`clear`/`any`
demanded `dag_node_id = '__dag_root__'`, which is not the owner row for a
standalone task — Brain registers those under their own task id, and their
`dag_node_id` is NULL. The statement matched nothing and reported success, so
`rememberOutcome` returned true for a record that was never made and the caller
dropped the mapping on the strength of it. Keyed on `task_id` now, and a write
matching no row raises instead of passing.

**The CAS tests were partly checking the fake.** It treated an absent revision
as a permanent conflict, where real NATS treats it as an unconditional write —
so C1 passed whether or not the code sent a revision, and C2 never checked the
conditionality in its own title. The fake now matches NATS, C2 asserts both
halves, and C5 is new for the identity binding. Verified by mutation: dropping
either revision argument now fails four of the five.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
I claimed the CAS tests failed under either revision being dropped. That was
wrong, and the review was right: under a faithful mutation -- passing
`undefined` as the revision rather than switching to `put` -- only C3 failed.
My own mutation swapped in `kv.put`, which the fake rejects outright, so the
tests failed for a reason unrelated to the revision check and the gap stayed
hidden behind a green result.

C1 looks like it covers the rewrite path and does not: its row empties, so its
first write is the DELETE, and it pins that argument alone. The two writes are
separate arguments in separate branches and need separate cover. C6 removes a
handle from a row that does not empty, so the write is a rewrite, and lands a
registration after the read that an unconditional rewrite would discard.

Verified by the faithful mutation: C3 and C6 fail, the rest pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@zoroyihan7

Copy link
Copy Markdown
Collaborator Author

Six review rounds in: this should be split, and here is the evidence

I am marking this draft. Two counterexamples survive that need no race at all — both reproduced against the real registry, CAS and stopper — and neither can be fixed from the API side, because in both the handle registry is simply wrong about what exists.

1. After a rebuild, a cancel stops the old workload and reports confirmed.
Brain stops W-old but does not remove its DAG handle, then creates W-new. The same-name registration is rejected by DagHandleMap.create, ensure-hands.ts swallows the exception, and W-new runs unregistered. A later cancel sees only W-old, its stop is accepted, the re-read and the record are both empty — confirmed, with W-new never sent a stop.

2. A reused sandbox is never registered against the DAG that reuses it, so a cancel reports nothing_held.
ensureHands returns early on a successful session reuse, skipping DAG handle registration entirely. Standalone T1 creates W and finishes; DAG R2 in the same session reuses W; the handle still belongs to T1, so R2's map is empty. Cancelling R2 answers nothing_held and issues zero stops.

Both are sequential — the ordinary shape of a session — and no amount of CAS, re-reading or record-keeping in sandbox-stopper can recover a fact the registry never recorded.

What this means for the handoff's premise

The handoff says "which workload does this task hold" is solved. It is not. The registry misses sandboxes acquired by reuse and points at stale ones after a rebuild. That is why every round here found new P1s: the reporting layer is being asked to assert something the layer beneath it cannot support.

To be precise about what is and is not sound, because the distinction matters:

  • Reporting the outcome of a stop that was issued — sound, and well covered (a non-2xx, a timeout, an unset SAFE_API_URL, a 404, one failure among several).
  • Asserting DAG-wide releaseconfirmed and nothing_held — not sound, and not fixable here.

Proposed split

  1. Ownership first. The bucket rewiring, the allow-list, and the teardown ownership fixes, together with the Brain changes they need: register the handle on reuse, replace it by identity on rebuild rather than swallowing a rejected create, and transfer ownership with the sandbox. This is where the real GPU leak lives, and it is a cross-component change deserving its own review.
  2. Reporting second, rebased on that, with released meaning what it can then honestly mean.

Reverting the wiring to the old bucket and shipping the reporting alone is not an alternative: against the wrong bucket every DAG answers nothing_held, which is a false statement made confidently, and worse than today's silence.

What is in the branch and worth keeping either way

Fourteen defects fixed across six rounds, each with regression cover, several verified by mutation: the bucket/allow-list mismatch that meant no API-side teardown had ever run; the sweeper reaping live standalone tasks and live session-reused sandboxes; agent_done releasing a handle a sibling was still using; a revision-conditional handle removal so a concurrent registration is not silently discarded; and the release record itself — durable, identity-keyed, redaction-proof.

Happy to carve it up along those lines on request; I did not want to restructure someone else's roadmap unasked.

@zoroyihan7
zoroyihan7 marked this pull request as draft September 14, 2026 12:32
zoroyihan7 and others added 4 commits September 14, 2026 12:55
Round 6's two blocking findings. I had reported them as unfixable "from the
API side" and stopped there, which was the wrong boundary to stop at: Brain is
in this repo, and the review had already said where the fix belongs.

Both are sequential -- no race -- and both ended with a cancel reporting a
release that never happened.

**Reuse never registered.** `ensureHands` returns early when it adopts a warm
sandbox, and that return skipped handle registration entirely. T1 creates W and
ends; a DAG in the same session reuses W; the handle still names T1, so the
reusing DAG's map is empty. Cancelling it issues zero stops and answers
`nothing_held` -- and the sweeper, reading only T1, tears W down under the DAG
now running on it. Ownership now moves with the sandbox, at the moment it
moves.

**Rebuild left the map naming a corpse.** The old workload is stopped and its
handle left in place, so registering the replacement hit `DagHandleMap.create`'s
refusal to overwrite, the exception was swallowed at the call site, and the new
sandbox ran unreferenced. A later cancel stopped the dead workload, got its
404, and called the sandbox released.

`create`'s refusal is right against a mistaken double-create and wrong at the
two moments a handle legitimately changes hands. `replaceDagHandle` is that
second operation: destroy then create, so the write is a replacement rather
than an overwrite `create` rejects, logging the previous workload id because it
is the one identifier that otherwise disappears exactly when someone needs it.
It moves the name and frees nothing -- the rebuild path has already stopped the
old workload, and the reuse path must not stop a sandbox it is adopting.

`registerDagHandle` is removed rather than kept beside it. It had no callers
left, and leaving a "refuse if the name exists" primitive next to a "take the
name over" one preserves the exact footgun that caused this: three call sites
reached for the stricter of the two and swallowed the rejection.

H1-H4 cover the takeover, the create-when-free case, sibling handles surviving
a replacement (one row holds them all, and replace destroys before it creates),
and the reuse path registering before it returns.

The existing SaFE-namespace test selected its call by first occurrence, which
silently retargeted it at the new reuse registration; it now selects by what
the call is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…andle

I introduced `replaceDagHandle` in the previous commit as destroy-then-create,
and raised the non-atomicity as something worth challenging. Checking it myself
rather than waiting: the exposure is real, and it is the same false clear this
whole line of work exists to remove, reintroduced one layer down.

Backend's teardown decides a DAG holds no sandbox by finding no handle. Between
the destroy and the create the name resolves to nothing, so a cancel landing in
that window reads an empty map, finds nothing on record, and answers
`nothing_held` for a workload that is running. Replacing a name must never look,
even briefly, like never having had one.

`DagHandleMap.replace` is therefore a single put: read the row, set the one
key, write it back, and return what the name pointed at before. That is where
the operation belongs anyway -- beside `create` and `destroy`, which share the
row and the encoding -- rather than composed out of them by a caller that
cannot see the gap it is opening.

It moves the name and frees nothing. Stopping the workload that was there is
the caller's business: the rebuild path has already done it, and the reuse path
must not stop a sandbox it is adopting.

The protocol package had no test for this class at all. The new file covers the
atomicity directly, by watching every write rather than only the end state --
an intermediate row without the handle is invisible to an after-the-fact
assertion, since it is gone by the time the call returns. Verified by mutation:
spelling `replace` as destroy-then-create fails it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… round 7

Round 7's remaining blocker, plus the CAS half of its Q2. (Its other finding,
the destroy-then-create window, was already closed by the previous commit,
which it had not seen.)

**A swallowed registration left a live sandbox nobody could account for.** All
three sites caught the failure, logged it, and returned the sandbox anyway --
so `ensureHands` handed back a workload while the DAG's handle map stayed
empty, Backend's teardown found nothing, and a cancel reported `nothing_held`
and issued zero stops. Reproduced by the review on both the reuse and rebuild
paths.

The old comment called this non-fatal because "the DAG can still complete with
sandbox-per-node semantics". It can -- but the handle is also the only record
of what the DAG holds, and losing reuse is a cost while losing the ability to
account for a GPU is not one to take silently. The registration now throws and
the callers propagate it: failing the turn is loud and retryable, succeeding
quietly is how the leak becomes invisible.

**Brain's unconditional row write could resurrect what Backend had removed.**
Backend now removes handles under a revision-conditional write; a plain
read-modify-write from this side puts back a stale snapshot, reviving a
reference to a stopped workload or undoing the removal of a running one. The
registration is written against the bucket with `create`/`update` and the
revision it read, retrying on conflict -- the same shape as Backend's
`destroyHandleCas`, because the two writers have to agree on a row version.
Still one write that sets the key, never a delete and a create, so the handle
is never momentarily absent.

**H4 was a source-level assertion that proved nothing.** The review showed an
early `return` inserted at the top of the helper left both its regexes
matching. It is behavioural now: a bucket that refuses every write, asserting
the turn fails rather than returning an unowned sandbox. H5 is new for the
conflict retry.

Five existing tests started failing on this change, and that is the finding
rather than the breakage: `ensure-hands-create-identity*.test.ts` are named for
"the identity it registered" and were green while the registration inside threw
`dag-handles.not_initialized` and the call site ate it. They bind a bucket with
real revision semantics now. In the kubernetes file it is imported inside the
hook, because that suite sets its env at module scope and a static import that
reaches config.js reads them too early -- which turned it into a suite
asserting it was "meaningless outside kubernetes mode".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rade

The first question anyone will ask about making the registration throw is
whether it newly fails turns. It does not, for a reason that is not local to
the function: `initDagHandles` is awaited unqualified during boot in index.ts
and nothing catches it, so a process that is serving requests has a bound map
by construction, and `dag-handles.not_initialized` is reachable only from a
test.

The failures this surfaces are writes that were genuinely refused. That is the
trade being made on purpose, and it is the only one -- worth stating where the
throw is, so the next reader does not re-derive it or soften the throw on a
guess.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@zoroyihan7

Copy link
Copy Markdown
Collaborator Author

Correction: the split recommendation above is superseded

My earlier comment argued this should be split, on the grounds that the two remaining counterexamples "cannot be fixed from the API side". The first half was true and the conclusion did not follow — Brain is in this repo, and the review had already located the fix there. I drew a scope boundary at the claw/packages/api package and then treated it as a wall. Both are now closed:

  • Reuse never registered. ensureHands returned early when adopting a warm sandbox, skipping DAG handle registration, so the reusing DAG's map was empty — a cancel answered nothing_held with zero stops, and the sweeper reaped the pod under the live DAG. Ownership now moves with the sandbox.
  • Rebuild left the map naming a stopped workload. Re-registering the replacement hit DagHandleMap.create's overwrite refusal, the exception was swallowed, and the new sandbox ran unreferenced. replaceDagHandle is that second operation, and registerDagHandle is deleted so the stricter primitive cannot be reached for again.

Three further defects surfaced from those fixes and are also closed: the replacement had to become one write, because destroy-then-create leaves the handle briefly absent and an absent handle is exactly how teardown concludes a DAG holds nothing; Brain's row write had to become revision-conditional, or it resurrects an entry Backend's CAS removal had just deleted; and the swallowed registration failure had to become fatal, since a registration is the only record of what a DAG holds.

That last one had a tell worth repeating: making it throw turned five existing tests red — ensure-hands-create-identity*.test.ts, named for "the identity it registered", which had been green while the registration inside threw dag-handles.not_initialized and the call site ate it. The swallow was hiding the failure from the tests written to catch it.

So the layering argument for splitting is gone; the review confirmed there is no obstacle to finishing in one PR. The rollout question in my earlier comment still stands and is unaffected by any of this: DAG_HANDLES has no TTL and has never been swept, so please check nats kv ls DAG_HANDLES | wc -l before rolling out — the first sweep after this lands will reap a backlog of unknown age, and its destroy condition includes a DAG root row that is merely missing.

Review round 8 is in flight against the current head; I will report its verdict and take this out of draft if it is clean.

zoroyihan7 and others added 6 commits September 14, 2026 14:07
…andle name

Three of round 8's findings.

**Making registration fatal fixed the wrong half.** The turn now fails, but the
workload it created is still running: `hands.<session>` is already READY and
keepalive is already registered by then, and the failure cleanup above only
reaps PENDING entries. So the leak survived the fix that was supposed to stop
it -- with nothing pointing at the workload at all, which is worse than the
misreport it replaced. Both create paths now tear the sandbox down before
rethrowing, in the same shape as the rollback the pending KV write already
does. A rollback that itself fails logs the workload id, because that is all
anyone will have to find it with.

**Re-cancelling a finished DAG stopped a sandbox a newer DAG was reusing.** The
DAG-root branch tore handles down even when its UPDATE matched no rows.
Sequentially: D1 finishes without its agent_done teardown firing (the
topological last user deferred to a live sibling, and the sibling that finished
last was not the last user), D2 reuses the warm sandbox and adds its own
reference, and a second cancel of D1 stops that workload -- killing D2 and
answering `cancelled: 0, released: "confirmed"`. Both halves wrong, and the
`confirmed` is the worse one: it names as cleanly released the thing it just
broke. A DAG already terminal is no longer cancelled again, and its leftovers
stay the sweeper's, under the session guard. The wider problem -- two DAGs
sharing one workload while both are live -- is not solved by this and is
recorded at the site.

**A handle named `__proto__` registered successfully and stored nothing.**
`row[name] = info` hits `Object.prototype`'s setter, so the row serialised as
`{}` while the call reported success, and Backend read the DAG as holding
nothing. Admission accepts the name, so this is reachable by writing a DAG.
Rows are written with `defineProperty` and read by own-property only, in the
map and in Brain's writer.

Writing my own test caught a second copy of it I had missed: `listForDag` and
`listAll` build their OUTPUT with `out[name] = info`, so the stored entry would
have been correct and the DAG would still have enumerated as empty -- and that
enumeration is what teardown walks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The last of round 8's blocking findings.

**A cancel during provisioning missed the workload entirely.** The workload
exists from the moment SaFE assigns its id; the handle was registered at the
end of `ensureHands`, after poll, bootstrap and health. Everything in between
was time a cancel could arrive, find no handle, conclude the DAG held nothing,
and say exactly that -- while the workload it missed kept its GPU. This is the
gap the code has carried a comment about for several rounds, on the grounds
that it needed cancel and provisioning to coordinate.

It did not. `onProvisioned` already exists and already fires on workload
assignment, precisely so the `hands.<session>` entry can be written before any
of that work -- and it already rolls the workload back if that write fails. The
DAG handle is registered there now, carrying what teardown needs: the id, the
key to stop it with, the namespace to poll. `hands_url` and `token` are not
known yet and the registration at the end fills them in by replacing the entry.
A registration that cannot be written rolls the workload back the same way the
pending write does, because a workload nothing can account for must not outlive
the call that made it.

**An existing empty row wedged the registration.** "Is there a row to build on"
and "does the key exist" are different questions, and they come apart for an
entry present with an empty value: nothing to build on, but the key is there.
Conflated, the write went out as a `create` against an existing key, was
refused, and was refused again for all five attempts -- failing against a row
it could have updated. A DEL/PURGE tombstone is the opposite case and still
takes `create`, which is what the client does over a tombstone.

**The unsupported-handle branch destroyed without binding to what it recorded.**
It records `(handle, "")` and then removed whatever currently answered to the
name. An entry since replaced by a real SaFE workload was deleted there -- and
that branch discards the id `destroy` returns, so the workload was neither
recorded nor stopped, and lost its only reference. It is bound to the empty id
now, so a changed identity aborts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ence

Two of round 9's three findings, plus the test gaps it proved by mutation. The
third — the provisioning window — is not fixed; see below.

**A failed reuse registration left the adoption half-done.**
`acceptExistingSandbox` has already cleared the idle markers and registered
keepalive by the time the DAG registration runs, and the runner has not been
handed the identity yet -- so throwing unwound neither, `reapPendingHands`
skips the READY entry, and the turn's own teardown answers `no_sandbox`. The
sandbox stayed active, owned by a session whose turn had just failed and
claimed by no DAG. The adoption is undone now: the ticker is stopped and the
idle marker put back, in reverse order so the entry is never active with
nobody pinging it. Deliberately not stopped -- this path did not create the
sandbox, and another session's warm pod is not this turn's to destroy on the
way out.

**`retryTask` could delete a leak record written while it ran.** It wrote back
`{...task.metadata, retried_into}` -- a snapshot read before the INSERT -- and
`updateTask` merges at the top level, so anything written to the row in between
was replaced by its older value. The sweeper writes exactly such a thing, and
losing it turns a workload it could not stop into `nothing_held` for whoever
asks next. It patches the one key it means to set; a patch cannot lose a
sibling it never mentions.

**Three tests were passing for the wrong reason, each confirmed by reverting
the protection here.** H7 matched "a stop exists somewhere in the hook", and
the hook already contained one for the pending KV write's own rollback -- so
deleting the new rollback outright left it green; it now requires the stop to
follow *this* registration's failure and precede its rethrow. The protocol
`__proto__` test covered the write and `listForDag` but not `listAll`, which is
what the sweeper walks. And nothing covered Brain's own row writer at all:
`replaceDagHandle` writes the row itself rather than going through
`DagHandleMap`, so reverting it alone to a plain assignment left every test in
both packages green. H8 covers it.

**Still open: a cancel during provisioning.** Registering at `onProvisioned`
narrowed the window but did not close it -- the workload exists before that
hook commits, and nothing on the create path checks `options.signal`, so an
abort does not stop a creation already in flight. Closing it needs a real
barrier between cancel and provisioning rather than an earlier write, and the
agent-sandbox path has no early registration at all. Recorded rather than
claimed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Narrows the provisioning window round 9 reported still open. It does not close
it, and the commit before this one says so; this is the part of it that is a
choice rather than a design problem.

Both records are written in `onProvisioned` for the same reason -- the workload
exists from the moment SaFE assigns its id, and poll, bootstrap and health all
happen afterwards. Their order was arbitrary and is not: only one of them is
what a cancel reads. Backend decides whether a DAG holds a sandbox from the
handle map, so every instant in which the session entry exists and the handle
does not is an instant a cancel answers `nothing_held` over a live workload.

Reversed, the worst a cancel can see is a handle whose session entry has not
landed yet, which errs towards reporting a workload that is there -- the
direction this whole change exists to err in.

What remains open is the part an ordering cannot fix: the workload exists
before this hook is called at all, and nothing on the create path checks
`options.signal`, so an abort does not stop a creation already in flight. That
needs a barrier between cancel and provisioning. The agent-sandbox path also
still has no early registration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… session

Round 10, including a regression I shipped two commits ago.

**`retryTask` was destroying the whole metadata column.** Round 9 reported the
original write as losing a concurrently-written record, and I "fixed" it by
patching only `{retried_into}` on the belief that `updateTask` merges its
patch. It does not: `updateTask` assigns (`metadata = $1`); the function that
merges is `applyTaskStatusTransition`, whose SQL I had read and attributed to
the wrong caller. So the fix replaced the column with a single key and
destroyed `derived` -- handle_last_user, root_node_id, schema_digest -- along
with the unreleased record, deterministically and with no concurrency needed.
Worse than the bug it was fixing.

Both wrong versions look right on the page, which is the argument for doing it
in the statement: `COALESCE(metadata,'{}') || jsonb_build_object(...)` reads
nothing into this process, so nothing can go stale between a read and a write,
and every key the call does not name survives.

**The adoption undo targeted a session that does not exist.** It preferred
`identity.sessionId`, which for agent-sandbox is the Router's session id, while
keepalive registrations and the `hands.<session>` key are both keyed by Claw's.
The unregister and the idle write went somewhere else entirely and the undo
reported success. It uses the Claw session now.

**And it could not have noticed either way.** `markHandsIdle` reports failure
instead of throwing -- `superseded` on a revision conflict, `failed` otherwise
-- so the catch around it established nothing. A conflict is the ordinary case
here, since the entry is live and its TTL is being refreshed underneath; left
unchecked, the local registration is gone while KV still says active, and the
next tick finds the workload again and pings a sandbox no turn owns. The
outcome is checked and an incomplete undo is logged as such.

U11 covers the retry write against a real database, after a first attempt at it
proved vacuous: `retryTask` refuses chat rows and anything with a
`dag_root_task_id`, so seeding a DAG root meant the write never ran and the
test passed with the regression in place. It seeds a standalone row -- which is
also the only kind this can affect -- and asserts the retry actually happened.
Verified by mutation: restoring the full-replace fails it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Round 11's one new finding. The clone's metadata expression already subtracts
the fields a replacement must not inherit; `sandbox_release` is a fourth, and
the sharpest, because it is evidence about a workload the PREVIOUS run held.

Carried over, it lands on a row that has never had a sandbox and can never
clear it: releasing the replacement's own workload clears the replacement's own
entry, never the inherited one. Meanwhile the original's copy IS cleared when
that workload is finally released. So the evidence migrates to exactly the row
it is not about, and stays there, reporting a leak that does not exist while
the one that does goes unrecorded.

U12 covers it; verified by mutation.

Also of note from the round, since it changes what I was about to build: the
acquisition-intent marker I proposed -- write the handle with an empty
workload id before acquiring -- is not sound in that form. Two reasons worth
recording. Empty workload ids are legitimately permanent for agent-sandbox, so
"empty" cannot also mean "acquisition in flight"; and a cancel landing on the
marker records `(handle, "")` persistently and drops the mapping, after which
the real release clears `(handle, realId)` and the DAG reads unconfirmed for
ever -- no failure required. The direction is right and needs explicit
acquisition phases with per-attempt identity, which is its own change.

So the two acquisition windows -- create and adopt -- remain open and remain
documented as open.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
zoroyihan7 and others added 6 commits September 15, 2026 18:00
Six conflicted files, all two independent features landing in the same place.
Both sides' intents kept throughout; where they could not be, it is stated.

nats-values.yaml and infra/nats.ts — this branch adds the DAG_HANDLES bucket
(and the grant fix behind it: the old allow-list named a bucket nobody writes,
which is why DAG teardown destroyed nothing), main adds DOORBELL_FLOOR. Both
buckets are needed and both sets of grants kept, in all five api sub-lists. A
missing NATS subject fails at runtime with a permission error rather than at
build time, so the risk here is asymmetric and the merge was checked grant by
grant against both stages. One clause was dropped deliberately: main's comment
said api may not write DAG_HANDLES rows, which this branch's cancel teardown
must do to free a handle — its surviving reasoning is folded into the kept
comment, and the bucket is still attached bindOnly so api never creates it.
Both sides also bumped "four buckets" to "five" counting different fifths; the
count is now stated with the bucket named.

routes/tasks.ts, tasks/sweeper.ts — independent additions, kept side by side.

tasks/lifecycle.ts — the one place the two features genuinely interact, since
cancellation is what both touch. Import sets unioned; the merged flow orders
main's cancelUnheldRun and transitionCancellation against this branch's
sandbox release rather than concatenating them.

cancel-release-confirmation.test.ts — R9 is the only case reaching the
non-root branch, which main now settles through transitionCancellation, i.e.
inTransaction: a pooled connection of its own that a db.query stub never sees,
so the transaction went through to a real database. Switched to
test/support/db-stub.ts, which main added for exactly this, and added an
assertion that a transaction was opened so the case cannot pass by returning
early. Confirmed it still fails if the branch leaks a `released` field.

Verified: utils 76, protocol 94, hands 222, brain 1810, api 1689 — 0 fail.
Build, typecheck and lint-tests-must-resolve clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ntory

Round 43 rejected the premise of the previous fix, and was right to: I had
ordered the handle release before retention on the grounds that a failed
release leaves everything retryable. A delete whose ACK is lost has committed,
so that is not true -- the container ends with no handle, no retention record
and no session binding, because the caller provisions a replacement whose
pending write takes the binding. The same window opens on a crash between the
two, and for a DAG that REUSED the container its handle was also its only way
back into that path.

So the reference that replaces the handle now lands first. What that costs is a
stale handle when the release fails, and that is made recoverable rather than
permanent: `replaceDagHandle` takes an optional `mayTakeFrom`, and a
registration refused by a workload that has been RETAINED may take the name.
Answered from the retention ledger -- what `retainContainer` writes first and
what a lost projection is restored from -- because "the release threw" says
nothing about whether the delete landed, while the ledger settles whose the
container is. An unreadable ledger answers "not retained", keeping the refusal.
All four registration sites supply it; a guard nobody routes through never
fires.

**The occupancy check reads both key names.** Writing the canonical one was half
of it: `readHandsEntry` is canonical-first and returns the first non-null it
finds, which is right for "which binding is in force" and wrong for "is this
slot free". With both keys present and the canonical one a tombstone, an empty
PUT or this workload's own row, it never saw another workload holding the legacy
name, and the migration then deleted that live binding as the older of the pair.

**The inventory understands a pending handle.** `isUsable` already accepts a
session row with `status: "pending"` and no endpoint; the DAG-handle conversion
did not translate the flag, so an ordinary queued workload was counted
`unreadable` -- a census reporting a row it understands perfectly well as one it
could not parse.

The ordering test from the previous commit asserted the order this reverses. It
now pins the settled contract, plus the failure the ordering exists for: a
release that throws must not cost the container its retention record.

Verified by the ten suites covering these paths (127 tests, 0 failures) and
both typechecks. The full suites are not measurable on this host right now --
it is shared and its load average has been 100-330 throughout, which turns every
wall-clock assertion into a coin flip; CI is the gate for those.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…stale handle

**B1: reading both names was not checking both.** The loop broke on the first
usable row before knowing whose it was, so a canonical row belonging to this
very workload -- or unparseable bytes there -- hid a live row under the legacy
name, and the migration then deleted that live binding as the older of the pair.
Every name is examined now, anything unreadable or anybody else's refuses, and
unreadable is explicitly not evidence that the slot is free.

Worth recording what the two names actually are: `handsSessionKey` encodes only
ids that need re-keying, so for an ordinary session id the canonical name IS the
legacy spelling and there is one name. They diverge only for ids starting with
the retention prefix. That is narrower than it first sounded, and it is why an
earlier version of this test passed with the fix removed -- it used an invented
canonical key, so neither name matched anything and nothing was read at all.

**B2 (1): releasing a retention now frees any handle still naming it.** The
hand-over frees that handle itself, but a release that did not land leaves it
behind -- and the retention record was the only evidence letting a later
registration take the name back. Deleting the record when the work finishes
stripped that evidence at the moment it stops being reproducible: the session
binding is already gone, so nothing re-enters the hand-over path and every
replacement is refused for the life of the DAG.

**B2 (2): a cancel does not stop a container retention is protecting.** The
argument is the review's and it is right: with the handle release landed, this
cancel would never have seen the workload at all, so a failed bookkeeping write
cannot be what shortens a protected container's life. The check sits beside the
existing shared-holder gate, reads rather than infers, and answers `unconfirmed`
when it cannot read -- the direction every other unknown on this path takes.

That check first went in off-seam and turned all 24 cancel tests into
`unconfirmed`, because its failure direction is refusal and no test binds that
bucket. It is on `handleRegistry` now, where the file's comment already explains
why every registry read on this path has to be substitutable.

Three of the four mutations were not caught by the tests I first wrote for them.
R26 passed with the retention gate deleted, because the shared-holder check
threw first -- so R28 is now the control that proves the gate is what refuses.
H26 passed with the loop's early exit restored, for the invented-key reason
above. H27 covers the retention-release cleanup structurally, and says why: its
behaviour needs a whole keepalive sweep.

Verified by the twelve suites covering these paths (167 tests, 0 failures) and
both typechecks. Full suites are CI's -- this host has been at load 100-330 all
session, which makes every wall-clock assertion here a coin flip.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CI caught what my local runs could not: L2, L4 and L8 in
teardown-must-not-kill-live went red because the retention gate's read was not
stubbed there. I had fixed exactly that in the cancel suite and never looked for
the other files on the same path -- `grep -rl handleRegistry` names three, and I
had checked one. The gate now defaults to "nothing retained" in that file too.

Round 45's three findings, two of them flat bugs in what I wrote:

**The empty owner was a regression I introduced.** `owner && owner !== workloadId`
skips the comparison when the owner is empty -- and a legitimate agent-sandbox
READY binding carries `workloadId: ""`, its identity being the Router session.
So a SaFE rollback could overwrite a live Router binding with its own PENDING
row. The strict inequality I had before was right; the guard I added to it was
the bug.

**The retention gate never fired.** `hands.retained-*` is not a prefix wildcard:
NATS treats `*` as one only when it is a whole token, so that filter was a
literal nobody writes and every scan returned empty. Nothing could have caught
it, because every test of that gate stubs the method -- the filter itself was
never executed. It reads `hands.*` and `retention.*` now and screens by prefix,
reads the LEDGER as well as the projection (the ledger is written first and the
projection restored from it, so a projection-only read misses a retained
container), and throws on an unreadable record instead of skipping it, which is
what "unreadable answers unconfirmed" was supposed to mean.

**The release order was wrong again, mirrored.** Deleting the retention records
before freeing the handle leaves, on a failure between them, a handle with no
evidence behind it and no sweep that will revisit it -- the entry is gone from
the retention set. Freeing the handle first leaves the retention standing when
it fails, and the next sweep runs it again. The opposite residue is harmless: a
freed handle whose records outlive it by one sweep.

B5 asserts the filter shapes against Brain's own key builders, since a drift
there reads as "nothing is retained". It failed on its own comment first -- the
comment names the broken filter to explain it -- so it reads code, not prose.

Round 45 also corrected two of my premises: an incomplete census does not skip
retentions already found, and ids beginning `=` re-key too, so its round-44
two-name findings narrow to re-keyed ids only.

Verified by 14 suites, 192 tests, 0 failures -- the list built with `grep -rl`
this time rather than from memory. Five mutations, each caught.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CI's red on the last head was mine: the reordered handle release threw bare into
the retention sweep, and the outer handler marked the whole sweep incomplete,
which disturbed the walk's budget and queue. Six roster-tick tests said so, with
a virtual clock, so it was not this host's noise.

Containing it to its own entry was not enough either: in those fixtures the DAG
handle KV is unbound, so the release throws every sweep and nothing is ever
released. That is a missing binding, not a transient failure, so it goes through
a seam -- `KeepaliveDeps.releaseDagHandles`, beside `listDagHandles`, whose
comment already gives the reason ("needs JetStream otherwise").

**Round 46's remaining blocker: the release's return contract.**
`releaseHandlesForWorkload` retried a CAS conflict five times and then returned
as though it had succeeded. The caller deleted the retention records on the
strength of that, so a burst of conflicts ended with the handle still naming the
workload and its evidence gone -- unrecoverable once the conflict passed.
Exhaustion now throws. The early exits are separated while I was there: an
absent row, a tombstone or an empty value means nothing names this workload, so
that IS released; an unparseable row is not, for the same reason unreadable is
never evidence elsewhere in this change.

Round 46 also confirmed what I had asked about and could not settle myself: the
strict `owner !== workloadId` cannot lock agent-sandbox out, because the only
callers of that rollback are inside SaFE's `makeOnProvisioned` and SaFE rejects
an empty id before the hook runs; reading the ledger before the projection is
the right order; and the reverse residue of the release ordering is idempotent,
though it lasts until the next successful cleanup rather than exactly one sweep.

And it caught one I would have missed: cancel-release-no-safe-url passed with
the retention check THROWING -- its log said `retention_check_failed` while the
assertion expected `unconfirmed` anyway. Green for the wrong reason, and the
gate's total absence would have looked the same. Stubbed now.

Verification note: node is no longer installed on this host, so the release
contract above is the one change here not run locally -- typecheck only, before
the toolchain vanished. Everything else was green beforehand: roster-tick 25/25,
dag-handle-ownership 27/27, cancel-release-no-safe-url 1/1 with a clean log.
CI is the check for the rest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ns in

Round 47 confirmed round 46's three closed -- it enumerated all four release
call sites and found none that relied on the function never throwing -- and
found one more of mine.

The handle cleanup I put in the retention read phase is a full-table scan
followed by a CAS per handle, and only the enumeration carries a limit. The
phase's declared worst case is `CENSUS_READ_BUDGET_MS + LIVE_WORK_READ_CEILING_MS`
= 50s, and every sweep span and refresh interval is derived from it. A read
answering `clear` at 48s followed by a 9s cleanup puts the phase at 57s with
nothing having timed out, so configurations near the allowed minimum would be
derived from a number the phase can exceed.

So the cleanup is bounded on this side of the call, like every other term in
that ceiling, and the ceiling now includes the bound. Exceeding it is not a
failed sweep: the retention records stay and the next sweep retries, which is
the recovery a refused CAS already had.

Round 47 also narrowed a claim of mine. "An unparseable row blocks the release"
holds for the re-read inside release handling, not generally: the initial scan
still skips bad JSON and the protocol layer filters malformed handles, both of
which predate this PR.

Verification: node is still absent from this host, so this change is unrun
locally -- the test added with it is likewise unrun. CI is the check. The
previous head is 51/51 green, which covered the release contract that was in
the same position a commit ago.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread claw/packages/brain/test/keepalive-roster-tick.test.ts Fixed
Comment thread claw/packages/brain/test/keepalive-roster-tick.test.ts Fixed
zoroyihan7 and others added 2 commits September 16, 2026 03:05
…ved anything

CI caught what I could not run: `test/keepalive-roster-tick.test.ts(1310,54):
error TS2304: Cannot find name 'now'`. I copied the tick invocation from a
neighbouring test without its `let now = 0` -- the line reads like context and
is actually a declaration.

Two more in the same test, neither of which the compiler would have caught:

`kv.get(key) !== undefined` asserts nothing. `kv` here is a real KV surface, so
`get` returns a promise and a promise is never `undefined` -- the check passes
whatever the store holds. The file's own convention is to read the backing map,
which is what `values.has(...)` does.

And the timing assertion could pass without the sweep ever reaching the release:
a bound that does not exist looks exactly like a phase that returned early. It
now records that the release was entered and asserts that first, which is the
same trap as R26 passing with its gate deleted and H26 passing on an invented
key.

Still unrun locally -- node remains absent from this host.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The guard added in the last commit did its job on the first run: "the sweep
never reached the handle release, so this proves nothing about its bound". It
was right -- the fixture seeded a retention but bound no provider, so the
live-work read never answered `clear` and the release was never entered. The
timing assertion would have passed on a phase that returned early, which is
indistinguishable from a bound that does not exist.

It binds a provider that answers with a clear marker now, the way the
retention-release tests in this file already do.

Worth saying plainly: that guard is the only reason this did not land as a
green test asserting nothing. Three of my tests this week passed for the wrong
reason -- a gate deleted, an invented key, a promise compared to undefined --
and the cheapest defence has been to assert that the code under test was
actually entered, before asserting anything about what it did.

Still unrun locally; node remains absent from this host.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@zoroyihan7

Copy link
Copy Markdown
Collaborator Author

Review converged — round 48 returns a clean gate and recommends merge at d7adfb1f. CI 51/51.

Forty-eight rounds is a lot for a change that adds one response field, so here is what actually happened, and what a reviewer is accepting.

What the field cost

released is meant to tell a caller whether a cancelled run's sandbox was really stopped. Making that answer true meant turning on an API-side teardown that had never once run: the handle map lived in one KV bucket, the reader looked in another, and the NATS allow-list agreed with the bug, so the code had been a silent no-op since it was written. Switching it on surfaced four ways it could destroy a live sandbox, and fixing those pulled in the question underneath — who is allowed to stop a workload?

The rule that came out of it: a session entry is not a licence to stop what it names. hands.<sessionId> is one slot shared by concurrent DAGs, so every teardown asks whose workload it is, and asks immediately before the irreversible step rather than at a layer above it — each read in between is a window in which the answer changes.

Rounds 41–48 were spent on interactions with #35 and #36, which landed mid-review and restructured the same functions. Several defects existed in neither parent, only in the merge.

Residual risks a merger is accepting

  • A deadline stops the caller waiting; it does not cancel the underlying scan or CAS. A persistently slow or unavailable registry lengthens how long a retention and a stale handle survive. Cleanup within one sweep is not promised.
  • confirmed means SaFE accepted the stop — the handoff's own definition. It is not a statement that the GPU is physically free at that instant; proving that needs a data-plane signal SaFE does not offer.
  • A database record left by a legitimate retention or an unconfirmed release does not always clear itself. Not every unconfirmed means a stop request failed.
  • Behaviour change in start-up validation: the retention phase ceiling moves 50s → 60s, so the default total sweep ceiling moves 275s → 285s. Custom spans of 276–285s under the default registry TTL are now rejected at boot. Defaults need no change.
  • Teardown paths destroy resources a successor attempt is using (multi-node waiter, shell reap, adopt-conflict) #47 and DAG_HANDLES is keyed by DAG, but teardown asks by workload: the deferred set from #46 #48 remain open and are not claimed to be fixed here — successor teardown in adjacent subsystems, the two acquisition windows, and the scan-based ownership checks that stand in for a workload→holders index.

Verification note

Node disappeared from the development host partway through, so the last four commits were written without a local typecheck or test run; CI was the only verifier. That is visible in the history — the ceiling test needed three pushes, and one of those was its own guard catching it asserting nothing. The guard stayed.

zoroyihan7 and others added 7 commits September 16, 2026 07:36
…und it

Review found the sweeper's orphan branch cannot stop anything. rememberOutcome
writes the unreleased record to the DAG root claw_tasks row before the destroy
AND before the stop; unreleasedRecord.mark deliberately throws when that row
does not exist, so the gate turns a bookkeeping failure into "unconfirmed"
with no stop issued at all. Proven against real Postgres with a SaFE stand-in:
root row present, one POST /api/v1/workloads/<id>/stop and the mapping
destroyed; row absent and everything else identical, zero requests, five more
sweeps change nothing.

The detail that decides the severity is that a missing root row is not an odd
state — it is the definition of the orphan reapOrphanHandles exists to reap
(`status = owner?.status ?? "missing"`). So the one path built to clean up
stranded handles was dead for its own primary case and could not self-heal,
and DAG_HANDLES is created with no TTL by design, so the dangling reference
outlives Postgres.

mark now distinguishes "the record could not be written" from "there is
nowhere for a record to live" via a typed NoRecordHome, and only the former
withholds the stop. The record exists to inform the next reader through the
DAG root row; if that row is gone there is no reader, so withholding protects
nothing. mark still throws where a row exists — that is what stops a mapping
being dropped on evidence that never landed — which keeps this consistent with
the reviewer's own finding about clear (checked and agreed not-real: mark and
clear carry the identical WHERE, so clear's zero-row case means "no row",
i.e. "no record", not a stale one left behind).

Four more in the same file:

  The shared-holder and retention checks moved ahead of both the record and
  the destroy. They mean "do not destroy", so evaluating them afterwards left
  a permanent unreleased marker on a container that was legitimately retained
  or shared, and the DAG reported a leak that did not exist.

  otherDagHolding and retained() no longer walk the registry serially under
  one shared deadline. Both did a read per handle per other DAG root against a
  ttl:0 bucket whose rows only accumulate, so past the timeout every cancel
  refused to stop anything — and since the interrupt is published only after
  cancelTask returns, cancel latency grew with the bucket. Bounded fan-out,
  first-answer-wins.

  The unstoppable agent-sandbox branch no longer writes a record nothing can
  clear. Not reachable on this cluster (it runs safe-workload) but real in
  kubernetes mode, where those DAGs reported "unconfirmed" for life and each
  rebuild added another row.

Elsewhere: replaceDagHandle's refusal guard no longer treats an empty
workload_id as evidence that the existing handle is stale, and attemptRemedies
sleeps between rollback attempts rather than only in its catch, so three
attempts are no longer burned in the same few milliseconds of one transient
failure.

Two comments claimed api reconciles the DAG_HANDLES bucket. It does not:
bindDagHandles attaches with bindOnly, and DAG_HANDLES is deliberately kept
out of ensureKvBuckets. Brain owns that configuration for the life of the
cluster. Both are corrected, and they say what the real cost is — the same
false claim was cited on a sibling review to justify accepting a defect, which
is what a fallback nobody implements is good for.

Verified: utils 76, protocol 94, hands 222, brain 1817, api 1706 — 0 fail.
Build, typecheck and lint-tests-must-resolve clean. Every regression test was
confirmed to fail without its fix; the one timing-sensitive assertion was run
five times.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
main now carries #49 (post-v2 cleanup and the CodeQL queue). No conflicts: its
74 files do not overlap this branch's changes in any conflicting region.

Verified after the merge: utils 76, protocol 94, hands 222, brain 1854,
api 1751 -- 0 fail. Build, typecheck and lint-tests-must-resolve clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`scanPrefix` awaited `kv.get(key)` inside `for await (const key of kv.keys())`.
`kv.keys()` is an ordered-consumer subscription; awaiting another JetStream
request inside that loop stalls its message pump and the consumer ends early,
with no error anywhere. Measured against the live DAG_HANDLES bucket, 21 keys
all live: getting inside the loop returned 1, draining first returned 21.

Silent truncation is the worst shape this could take, because every
registry-wide scan is built on this one call and a short list reads exactly
like a small registry. `DagHandleMap.listAll()` is the entry point of
`reapOrphanHandles`, so orphan collection has been walking a fraction of the
registry; `workloadHeldByOtherDag` and `releaseHandlesForWorkload` scan the
same way, which is how a shared workload could be seen as held by a single-key
read and unheld by a scan in the same instant.

Verified on the real bucket before and after: listAllDagHandles() returned 1
row naming neither of two rows that lookupDagHandle read back fine; after, 23
rows including both.

This is pre-existing on main — neither open PR touched this file — and is
kept as its own commit so it can be taken separately if wanted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
sandbox-stopper: a shared workload survived a concurrent release. Two DAGs
holding one workload both read that the other still held it, both deleted
their own handle, both skipped the stop, and neither left a record — so the
workload kept running with every reference to it gone. Reproduced three times
in a row against real NATS/Postgres: zero stop requests, both rows null, the
follow-up answering nothing_held, which is the exact false assertion this
branch exists to stop inventing, arriving on the feature's own path.

That one is a regression from my own previous fix, and running the identical
harness against both orderings is what established it rather than argument.
With the check after the destroy, "both decline" needs a cycle and cannot
happen; moving it ahead of the destroy put all four reads before both writes,
so both observations hold at once. The move did buy something real — the old
order left a permanent unreleased marker on a container that never leaked — so
this keeps the checks where they are and makes the DECISION follow the ACT on
the shared-decline branch: once this DAG's own row is provably gone, ask once
more. Whichever side removes its row last sees an empty answer and becomes the
last holder, and the cycle is back.

sweeper: the orphan sweep stopped sandboxes that were still working. Ending a
chat retains background shells but leaves the sandbox in the ordinary
hands.<session> binding with no retention record, so once no task was running
the sweep passed its liveness check and killed a live workload — reproduced
with a real background process still running at /stop. Two independent review
chunks found this separately. The sweep now reads the session binding's
background verdict, with the workload id matched first so a session that has
rebuilt cannot hold its dead workload's handle open, and defers for at most
one verdict lifetime so a fleet that publishes no verdicts cannot make the
sweep permanently dead — which was itself a P1 on this branch.

That verdict predicate was moved into @claw/protocol rather than copied, along
with the idle-period rules and the field declarations it depends on. Testing
bgRunning > 0 alone accepts a verdict from a previous idle period, and two
copies drifting apart is a defect shape this repo has shipped before.

ensure-hands: a DAG could not recover onto its own sandbox. A create node
never consulted its own handle row, so a session whose slot named a different
or dead workload sent the DAG to adopt that one and then failed registration
against its own handle — a recoverable sandbox failure became a task failure,
with an extra workload created and stopped per attempt. The own handle is now
consulted first, and acted on only when the two records disagree, which is
what keeps cross-DAG warm-pod sharing intact.

All four were re-verified after the KV scan fix in the preceding commit, since
that changed what every registry-wide scan can see.

Verified: utils 76, protocol 101, hands 222, brain 1861, api 1768 — 0 fail.
Build, typecheck and lint-tests-must-resolve clean. Every regression test
confirmed to fail without its fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ling

The orphan sweep deferred a reap whenever ANY task in the session was
non-terminal, whatever workload that task was on. So T1 finishes on W1; T2 in
the same session changes image, cannot reuse W1 and creates W2; T2 sits
preparing; and W1 is held indefinitely with nothing using it. When W1 occupies
the GPU W2 is queued for, that is a deadlock the sweep itself creates — the
deferral prevents the work that would end the deferral — and it only clears
when T2 times out. Reproduced with a one-slot GPU fake, so "T2 made progress"
is an assertable outcome rather than a boolean.

The guard now asks whether live work holds one of THIS candidate's workloads,
from three sources with different failure directions: the DAG handle registry
read from the leader (Brain registers at SaFE-id time, while the task is still
preparing and its pod may be queued — and the load-bearing answer here is the
negative one, which a replica read would get wrong in the one direction that
costs a running pod), claw_tasks.sandbox_workload_id (written only once a run
reports itself running, so it can only add to the set, never remove), and the
hands.<session> binding (a property of the session rather than of a task, so
applied separately — a live task can be one read away from adopting that slot).

Every way the question can go unanswered defers rather than reaps, each with
its own log line: the live-task query throwing, more live rows than the scan
limit (a truncated list is a different question, not a smaller answer), a
leader read throwing, a live task not yet named by any store, an unreadable
binding, and a candidate with no workload at all — which falls back to exactly
the old session-wide question.

This narrowing was not available before the preceding scanPrefix commit. With
the scan truncating to one row out of twenty-three, "who holds this workload"
could not be answered, and the session-wide guard was the right call. Verified
that prerequisite against the live bucket before relying on it.

The comment that argued for the coarse form is rewritten rather than left
standing: it names the new question, the deadlock the old form produced, and
keeps the original trade explicit — the sweep still never permits a stop it
cannot establish is safe; what changed is that "cannot establish" now means
the answer was unavailable rather than that some unrelated task was running.

Ten new cases, three confirmed failing at baseline, and the must-not-regress
guards proven to bite by a seven-mutation matrix — including that a genuine
orphan is still reaped and that the background-work guard still fires.

Verified: utils 76, protocol 101, hands 222, brain 1861, api 1778 — 0 fail.
Build, typecheck and lint-tests-must-resolve clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four defects an independent review reproduced. Three of them are gaps in fixes
landed earlier on this branch, and all three end the same way: a sandbox with
background work still running in it gets stopped. Each of those fixes was
verified; none checked whether the evidence that work is live survives the path
it added.

The last-holder re-check asked too narrow a question. It re-asks whether any
other DAG holds the workload, but reuses the `retained` value computed before
the decline — so when A drops its handle and B then hands the workload to
retention and releases the last one, the re-check stops a container whose
background work is now retained. Reproduced through the production
retainContainer and handle-release functions: the retention record is still
valid, /stop is issued, and the call answers "confirmed". The re-check itself
stays — it closed a race where a shared workload survived a concurrent release
with every reference deleted — it now just re-reads retention too.

The bounded fan-out bounded only its second half. The listAll() that runs
before it is still a serial read-per-key, so the 10s budget can be gone before
the concurrent phase starts: measured with 1,400 handles and 8ms per read, the
whole stop path returns "unconfirmed" at 10,011ms having issued zero stops.

The own-handle recovery path returned a sandbox without restoring the
bookkeeping that keeps it alive. With the session record expired and the DAG
handle still naming a healthy workload, recovery succeeded, markHandsIdle then
answered "gone", the orphan sweep read no_binding as reclaimable, and stopped a
workload with live background processes. It now restores the binding before it
returns.

And a scan that could truncate silently: isValidDagHandleToken awaited kv.get()
inside `for await (kv.keys())`, the ordered-consumer stall this branch already
fixed twice. A truncated scan there does not fail loudly — a token that IS
valid is not found, registry.ts caches that in deniedTokens, and the
sandbox.use node it belongs to is answered 401 from then on. api's
infra/dag-handles.ts had the same shape and is fixed with it. Every other KV
scan in the tree was checked and already drains first.

Reported rather than fixed, because no correct fix exists in the file that
would hold it: switching image stops a sandbox that still has background
shells. The reuse path abandons W1 for a replacement and the new binding
overwrites W1's background evidence, so the guard is not wrong about what it
reads — what it reads stopped being able to answer for W1 the moment W2 was
bound. The fix belongs in ensure-hands.ts, where retainInsteadOfDestroying
already does the right two things: write the retention before the slot moves,
and release the handles naming that workload so this sweep never reaches it.
The sweeper comment now names that function, and the api-side contract it has
to satisfy is pinned by a test.

Verified: utils 76, protocol 101, hands 222, brain 1865, api 1788 — 0 fail.
Build, typecheck and lint-tests-must-resolve clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The last of the four defects from this review round, and the one whose fix did
not live in the file that found it.

When a rebuild abandons the session's sandbox rather than releasing it — the
spec-changed and unhealthy paths, both of which return null at
`skipped_other_owner` because another DAG still holds the workload — the caller
then writes the new sandbox's binding over `hands.<session>`. That slot is a
single slot, so the moment it moves it can no longer answer for the old
container, and the api-side background-work guard reads `other_sandbox` and
lets the cleanup through. A sandbox with the user's background shells still
running in it is stopped. Reproduced through the production reuse and
registration paths.

The container is now retained before the slot can move, so the evidence that
it is working stops depending on a slot that is about to name something else:
the retention is keyed by the container's own generation, carries the whole
binding and the live-work verdict, and is what api's retained() already
consults and refuses a stop on.

Two deliberate departures from the plan this was handed with, both checked
against the source rather than assumed:

It is gated on the live-work count, not unconditional. `clear` leaves the path
byte-for-byte as it was and the container is still reaped — retaining
everything would break a rebuild that legitimately should discard the old
sandbox, and re-create the GPU deadlock the orphan sweep exists to break.

It does not release the handles naming the workload, and
retainInsteadOfDestroying grew a parameter so it can say so. That function's
own comment records its precondition — it is only reached when
entryOwnedByAnother said no other DAG holds the workload — and this new call
site is reached precisely when that check said the opposite. Releasing here
would take a live sibling DAG's only reference to the sandbox it is running in:
the same mis-stop entryOwnedByAnother exists to prevent, by a quieter route.
Leaving the handle is an already-supported state — replaceDagHandle may take
the name from a retained workload, and api's own comment says a handle still
naming a retained container is a bookkeeping failure rather than a licence to
stop it — and the keepalive retention sweep frees it when the work finishes.

What collects the container was read, not assumed: runRetentionReadPhase makes
every retention a census target, re-runs countLiveWork inside the container each
sweep, and on `clear` releases the handles and then the retention, in that
order.

Verified: brain 1869, api 1788 — 0 fail. Build, typecheck and
lint-tests-must-resolve clean. Two of the four new cases fail without the fix
on the outcome that matters (the workload stopped while its background shell
was alive); the other two are controls that pass either way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@zoroyihan7
zoroyihan7 force-pushed the feat/cancel-release-confirmation branch from ed43a2e to 59a3b10 Compare September 17, 2026 18:04
…t get back

Five defects an independent review reproduced, all in code I added over the last
two rounds. Four of the five are the same mistake wearing different clothes: a
signal that is close enough to look like an answer being used for a question it
cannot actually answer.

retainDisplacedSandbox read a binding and then deleted the key unconditionally.
Between those, another caller can move the session's one slot to a new sandbox —
so the delete took the NEW binding, and the replacement's background protection
went from running to no_binding even when no replacement had been created. The
delete is now conditional on the revision the decision was read at.

The retention collector freed every handle naming a workload the moment its
shell count came back clear. But a shell count answers whether anything is
RUNNING in the container, and the question before a release is whether any DAG
still HOLDS it — a DAG sitting between two nodes runs nothing and still owns the
handle its next node resolves, so clear is silent about it rather than negative.
The collector now re-reads the handle table immediately before the release and
keeps both the handle and the retention records when anyone still holds it.

That test is a handle read and not a lease read, and the difference is not
stylistic: runScope is the run gate's key, ws.<workspaceId> under the default,
and a HandleInfo carries neither the lock key nor the workspace — so a lease
lookup keyed off a handle would answer "no lease" for a perfectly live sibling
and delete its handle. That is the defect, not the fix.

It sits at the call site rather than inside releaseHandlesForWorkload because
the other three callers need the release to be absolute: each runs after a
confirmed stop, where a handle left naming a dead workload makes replaceDagHandle
refuse every replacement for the life of that DAG. Only the keepalive caller
frees handles for a workload that is still alive, and only it knows that.

The last-holder re-check made its two reads parallel, which changed which
orderings are possible without making the pair atomic: retention could answer
false, another holder could then write the retention and release its handle, and
the DAG read that followed would answer null. And a credentials read that failed
returned without recording anything, on a path that may already have dropped its
own handle — leaving both handles gone, no stop issued, and every later teardown
answering nothing_held.

Also: a sandbox the provider had already confirmed dead was retained for ever,
because counting live work in a dead container answers unknown, and unknown
retains — so the collector could never reach clear either.

Verified: brain 1877, api 1790 — 0 fail. Build, typecheck and
lint-tests-must-resolve clean. Every fix has a regression test confirmed to fail
without it, and each guard has a control proving it still lets the ordinary case
through — an unheld retained container is still released and collected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@zoroyihan7
zoroyihan7 force-pushed the feat/cancel-release-confirmation branch from 59a3b10 to fdfc038 Compare September 17, 2026 18:09
zoroyihan7 and others added 5 commits September 17, 2026 18:43
Two regressions from the commit before this one, both reproduced against real
NATS, and both found by measuring rather than reading.

A dead shared sandbox's stale handle blocked its own replacement. The previous
round stopped retaining a container the provider had confirmed dead — correct,
since a retention over a dead container is a record nothing can ever release —
but the new branch returned without freeing the handle that still named it.
replaceDagHandle will not point a name away from a workload still on record, so
the next registration was refused and the replacement workload was created and
immediately stopped. Measured: at the previous commit a replacement registers;
at HEAD two consecutive retries both fail.

The displaced-sandbox path refuses this release for a good reason — a sibling
may be running in that container — and the provider's confirmation is what
removes it. A handle naming a dead workload is not a reference to anything: the
sibling's next node probes it and fails either way, and keeping the row also
stops that sibling ever re-registering. Releasing is strictly better for the
sibling, not merely acceptable. The confirmation itself is narrow enough to rely
on: only a Router 404/410 raises SandboxGoneError, while an unreachable backend
is a 502 and never reaches this branch.

And the holder check added last round doubled a scan rather than replacing one.
dagsHoldingWorkload enumerated the handle table and then
releaseHandlesForWorkload enumerated it again internally, both inside the one
release budget. Measured against a real bucket with 200 rows and replicated-read
latency: one pass is 201 gets and 5.48s, so two passes miss the 10s ceiling —
the baseline released on its first sweep, HEAD timed out on two consecutive
ones, and a timed-out release never runs the retention delete either, so an idle
container stayed retained and pinged for ever. The holders now come from the
same pass the release uses.

The agent that wrote the doubled scan stated in its own report that the holder
read happened "in place of the release, never alongside it". It had not checked
that the release enumerates internally. That is the second unverified-cost claim
on these branches, so the fix for this one is measured, not argued: the numbers
above are from a run, not an estimate.

Verified: brain 1880, api 1790 — 0 fail. Build, typecheck and
lint-tests-must-resolve clean. Each fix has a regression test confirmed to fail
without it, and a control proving the displaced-sandbox path still retains
rather than releases while there is a live container to argue about.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reproduced against real NATS and a real TaskRunner: a sibling DAG registering a
handle concurrently costs five CAS conflicts, the release throws, and the task
reports failure and ACKs. No nak, no redelivery, and the race would have cleared
in milliseconds. At the commit before the gone-path release, the same scenario
registers its replacement and continues.

The release's failure propagates rather than being swallowed, and that stays —
a release that cannot commit means the registration after it would be refused
anyway, so throwing names the cause one GPU create earlier. What was wrong is
that the throw could not say what kind of failure it was. Its message read
"N attempts exhausted, or its row could not be read": honest about not knowing,
and the not-knowing was the defect, because those two need opposite answers and
nothing downstream could tell them apart without matching on prose.

So the throw site splits them, since it is the last place the difference still
exists: a genuinely contended row raises DagHandleContendedError, a row that is
null or not an object raises an ordinary Error naming it. replaceDagHandle in
the same file already separates its own two this way. The policy — a lost race
earns a redelivery — is one line in isRetryable beside the other retryable
classes, matched by name rather than by import, so the retry policy does not end
up pointing at the module that owns the DAG_HANDLES bucket.

Every other way that release can fail was enumerated and left terminal: a
malformed row, a non-JSON value, the not-initialised boot-order error, the scan
timeout, and raw KV errors. One case moves from terminal to retryable, and one
is split out of the same throw specifically so it does not move with it.

The asymmetry is why the direction matters: a nak spends one delivery from a
bounded budget, an ack ends the task and no later reader can tell it existed.

The retry converges, read rather than assumed: a SaFE workload id is never
reissued so the probe answers dead again and the same branch is selected; a
partial release is not redone because the scan only enters rows that still name
the gone workload; and the winning DAG's registration is simply not one of them.

Also corrected: that function's own doc asserted "It is also retryable: the next
attempt re-probes, gets dead again, and tries the release again." That was false
at HEAD — it is the guarantee this commit makes true. It is the fourth comment
on these branches to assert a mechanism that did not exist.

Verified: brain 1883, api 1790 — 0 fail. Build, typecheck and
lint-tests-must-resolve clean. The regression test asserts the delivery was
naked and the retry succeeded, not which error class was thrown.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…y it

Three findings from the same review round, all downstream of the previous
commit's error classification: it named a contended DAG-handle row as worth a
redelivery, and the three ways that judgement failed to take effect.

The default path did not consult it. BRAIN_LAZY_SANDBOX defaults to true and is
true in the deployment, so an ordinary chat turn opens its sandbox at the first
tool call -- inside agent-loop's tool-dispatch catch, which renders anything
thrown as result text for the model. The class became a sentence, the model read
it, the turn ended normally, and the delivery was acked and reported
failed: false. The eager path naked correctly the whole time; which of the two
raised the error is not a distinction a redelivery should turn on. The open is
now tagged with SandboxAttachError and the loop rethrows its cause when
isRetryable accepts it -- narrow on purpose: a permanent open failure is still
better told to the model, and a transient failure of a tool that RAN is still a
tool result.

A scan that overran its ceiling was classified as permanent on the reasoning
that the store had failed. Measured, it does not hold: with a 10.5s enumeration
delay injected the release failed at ~10,005ms and acked, and with the delay
lifted the same release completed in 2.89ms. A timeout produces no read at all
and forecloses nothing about the next one -- and the busy bucket that overruns a
scan is the same one that loses a CAS race, so the old grouping denied a second
delivery to the condition most likely to need it. Both scan sites now raise
DagHandleScanTimeoutError so they cannot drift apart.

And the redelivery itself could strand a record. releaseHandlesForWorkload walks
one DAG row at a time with no transaction over the set, so a release that frees
a sibling's name and then exhausts its attempts on its own row throws with the
first deletion already durable. The retry re-runs against a table where the
sibling reference is gone, entryOwnedByAnother answers false where it answered
true, and the branch that knew the container was absent is not taken -- leaving
mayDestroy to read unknown from a container that is not there and write a
retention nothing can ever release. The gone check now also sits before that
retention, after the destroy that a gone container still needs.

Three comments claiming a scan timeout was permanent were corrected rather than
left to outlive the behaviour they described.

brain 1893 pass / api 1790 pass / 0 fail. Each fix reverted individually to
confirm its test fails without it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three findings, all created by the previous commit. It made a failed lazy
sandbox open reach the task runner so the delivery could be naked -- and a nak
re-runs the whole turn.

The P1: the rethrow leaves the loop before the turn's results are appended to
the history, so a redelivery repeats every tool that already completed in that
turn. For a read that is waste; for an MCP call that wrote outside this process
it is a second write, measured as a doubled external append. The eager path is
safe from this for a reason rather than by luck -- it opens the sandbox before
any tool has run. So the rethrow now carries that condition with it: nothing in
this run may have executed yet. Otherwise the redelivery is given up and the
model is told, which is what shipped before any of this. A lost retry beats a
side effect nothing downstream can undo, and a resumed run -- whose earlier work
is in the recovered history -- stays on the same conservative side.

Tagging the open also broke the stage that needs no loop at all. Marketplace
tool installs, plugin resolution and pre-run hooks call sandbox() before the
agent loop starts and propagate straight to the runner, where the wrapper's name
replaced the class isRetryable matches on: a previously-retryable contention
became terminal. isRetryable now unwraps the tag. That stage is also the safest
place to retry, since nothing has run yet.

And the sub-agent entry point opens the sandbox itself and renders its own
errors as text, so a turn whose first tool is a `task` -- the ordinary shape for
a delegating agent -- still lost its redelivery. Same guard, same call.

The earlier tests missed all three because they drove a stubbed router: they
never ran a second tool, never resumed, and never took the task branch. The new
ones do.

brain 1897 pass / api 1790 pass / 0 fail. Each fix reverted individually: the
side-effect guard fails 2 tests, the task branch 1, the unwrap 1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The guard added last commit asked "has anything in this run executed yet" and
read `totalToolCalls` for the answer. That counter is incremented after the
start event is published, and `runTaskTool` is dispatched with Promise.all over
a batch -- so a sibling suspended inside its own start event had not reached its
increment. The first task's failing open read 1, concluded it was alone, and
naked. Promise.all does not cancel anything: the sibling then started, ran its
sub-agent and wrote, and the redelivery wrote again. Reproduced against a real
ledger: two rows where the baseline has one.

The counter was never wrong; it was answering a different question. It is a
reporting figure, published after the fact and restored from a checkpoint, and
neither property is a defect there. So the guard gets its own registration
instead: `toolsStarted`, incremented as the first statement of both tool paths,
before any await can yield. An async function runs to its first await when it is
called, so by the time `batch.map` has built its promises every task in the
batch is counted.

Both counters are consulted, because neither covers the other's half.
`toolsStarted` knows what this process has begun, including a sibling that has
not published anything yet; `totalToolCalls` arrives from the checkpoint and
knows about work done under an earlier delivery, which `toolsStarted` has no
memory of.

The new test holds one task inside its start event and fails the other's open
during that window, which is the interleaving as reported.

brain 1898 pass / api 1790 pass / 0 fail. Reverting the new predicate fails that
test alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@weilei0120
weilei0120 merged commit 0d57088 into main Sep 21, 2026
51 checks passed
@weilei0120
weilei0120 deleted the feat/cancel-release-confirmation branch September 21, 2026 03:54
zoroyihan7 added a commit that referenced this pull request Sep 21, 2026
Two branches that changed the same three mechanisms, resolved by keeping the
question each side was answering rather than the code each side wrote.

The retry strip in lifecycle.ts is a union: this branch dropped `sandbox` and
main dropped `sandbox_release`, for the same reason stated twice -- both are
evidence about a workload the PREVIOUS run held, and inheriting either one
attributes a leak to a task that never had a sandbox.

reapOrphanHandles keeps main's rewrite -- the registry census, the unreleased
and deferred counters, and the task_id keying whose old `dag_node_id` predicate
matched a different row for half the handles -- with this branch's leadership
check re-applied at the top of the iteration. Its accounting test moves from
`sweeperPorts.handleMap` to `handleRegistry.listAll`, because after the rewrite
the port is not what the traversal reads: a test stubbing it would have driven a
loop that ignored the stub and passed for no reason. `sweeperPorts` loses the
entry for the same reason.

reapPendingHands ends with ONE ownership gate, not two. Both branches built one
-- this branch by comparing when the entry was stamped against when this run
asked, main by comparing the task that wrote it -- and they disagreed about an
entry carrying no task id: this branch skipped it, main reaped it. Main is
right, and the argument is not the one either side made. Every entry this build
writes carries both a task id and a runScope, so a predecessor's entry is named
by its own task and the age test has nothing left to decide. An entry with
NEITHER field can only come from a process older than both, and `runScope` is
what `collectAbandonedPending` collects by -- so skipping it does not defer the
teardown, it leaks the workload for good. The fixtures that modelled a
predecessor without a task id were modelling a row this build cannot produce.

One fix on top of the merge, which the merge is what made small: main gave
`stopNamedSandbox` an outcome, and `destroyHands` consults it, but
`collectAbandonedPending` was left on the old void contract and read "it
returned" as "it stopped". It returns normally when it cannot address the entry
and when the deployment can issue no stop at all -- so the collector could
revoke the token and delete the entry behind a workload still running, which is
the leak it exists to end, reached through its own cleanup. The comment above
the catch already made this argument for the throwing branch; it now covers both.

Also drops two Chinese comment lines that reached main through #46.

brain 1925 pass / api 1940 pass / 0 fail, build and lint clean. Reverting the
collector fix fails its new test alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
zoroyihan7 added a commit that referenced this pull request Sep 21, 2026
…eld it lacks

codex found two defects in the work that answered the last review, and a third
older one. Both of mine are here.

The merge that combined the two ownership gates kept only the task-id one, on
the argument that an entry carrying no task id could only come from a process
older than both fields and so had no collector behind it -- making a reap the
only thing between the fleet and a permanent leak. That argument is refuted by
this branch's own history: 94b63ef wrote `runScope` into the pending entry and
not yet `taskId`, so a rolling upgrade across that commit produces exactly the
scoped-but-unnamed entry it said could not exist. The merge reaped it, which is
the mis-kill this branch was opened to stop -- a lazy chat turn that never asked
for a sandbox, failing, and tearing down the workload a sibling is still
queueing for.

The first correction deferred every scoped entry to the collector and was wrong
the other way, which a test that was already there caught: the bucket's TTL is
DEFAULT_BRAIN_REGISTRY_TTL_MS, 5 minutes, and collectAbandonedPending does not
look until SANDBOX_PENDING_ABANDONED_AFTER_MS, 2 hours. An entry nobody
refreshes evaporates hours before the collector could reach it, so deferring it
is not deferral -- it is the workload leaking with nothing left that names it.

So the gate is neither field: it is whether the lease behind the entry is still
held, read the same way the collector reads it (readRunLeaseState over
lock.<runScope>). Held means somebody is alive and the workload is theirs. Free,
unscoped or unreadable means this path is the only teardown it will get.

The second: the `collected` event added last commit fired even when
deleteHandsEntryIfRevision lost its CAS -- the workload was down but the record
it was asked to remove was still there, which is the same overclaim the split
was made to end, one step further along. It now returns before that line.

Not fixed here, and not this PR's: codex also reports that handleRegistry's
retained() treats a stale-replica empty read as "no retention", which can let a
protected container be stopped. That code came in with #46 and is already on
main (0d57088); it belongs in its own change rather than in a merge-resolution
commit.

brain 1929 pass / api 1940 pass / 0 fail. Each fix reverted individually fails
its own test and nothing else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

3 participants