fix: fence sandbox resets until deletion succeeds - #1768
Conversation
454a750 to
da12a03
Compare
BinaryBourbon
left a comment
There was a problem hiding this comment.
Approving the mechanism, with one thing I'd like you to reconsider before this lands.
First, the CI red is noise: the two failing entries come from run 34481301871, which was cancelled when the base moved, not from a real failure. CI required, Coverage gate and Elixir static analysis all pass on 34481301915. Worth a glance so nobody else re-triages it.
The fix is right. Releasing admission's lock before calling the provider left a gap a new turn could enter, and a failed delete still freed the quota slot — so the fence has to commit under the same lock and capacity has to survive until deletion is confirmed. Committing the fence first, calling the provider after commit, and retiring the row only on :ok is the correct order. Extending Quotas.active_sandboxes/0 to count fenced rows even when parked, and making the :exclude replacement exclusion unable to spend a fenced slot (s.id != ^excluded or not is_nil(s.reset_requested_at)), closes the half that would otherwise hand the slot straight back.
:utc_datetime_usec for reset_requested_at while updated_at stays truncated looked like an oversight until I checked the migration — the column really is usec and the truncation on updated_at is for its own :utc_datetime column. Both deliberate.
The thing to reconsider: a single transient provider error strands the sandbox permanently, and the documented recovery is something this project forbids.
Trace what one Sprites 500 does:
finish_sandbox_reset/1returns{:error, :sandbox_reset_pending}, leavingreset_requested_atset and the status stillready/suspended.Quotas.active_sandboxes/0keeps counting it — deliberately.SandboxReaperskips it in both queries (is_nil(s.reset_requested_at)).reset_sandbox/2refuses a retry,check_attachable/4refuses attach,maybe_reuse_sandbox/1refuses reuse.- Nothing anywhere sets
reset_requested_atback tonil— I grepped the whole branch.
So the slot is consumed forever. On a small account that's material: the default clamp is SANDBOX_CAP_FLOOR 2, so a tenant can lose half their concurrency to one blip.
Fail-closed is the right instinct here — freeing capacity for a machine that may still be running at the provider is the bug you're fixing, and I wouldn't want it the other way. My objection is narrower: the API tells the user "contact the operator before retrying", docs/concepts/sandboxes.md says "The operator must reconcile the provider outcome", and the operator has no lever. The only way to clear a fence today is a hand-written UPDATE against the production database — which is exactly what we've committed to never doing.
I don't think that blocks the fence itself. But one of these should come with it, or land immediately after:
- an admin-only unfence that re-probes the provider and clears or retires the row, so the documented procedure exists; or
- a sweep that retries the delete on fenced rows with backoff, which is the "automatic reconciliation" the ADR defers; or
- at minimum, docs that say what the operator actually does, so the sentence isn't pointing at nothing.
Separately, and smaller: the resettable set narrows from "anything not terminated/failed" to exactly ["ready", "suspended"], so resetting a starting or pending machine now returns {:sandbox_not_resettable, status} where it used to proceed. I think that's an improvement, but it's a user-visible API change that isn't in the PR description.
(The refusing down migration is much more defensible here than in #1820 — a half-applied reset genuinely isn't re-derivable from the provider, so refusing to drop the evidence earns its keep.)
ADR 0042 records the whole decision, including the two places this re-cut differs from the original proposal: the claim is a compare-and-swap fenced on the version it observed, with stale-claim recovery folded into it rather than run as a separate pass, and the SDKs omit `/api/sandbox-queue` with the reason written down. It also records that #1768's reset fence composes with this rather than fighting it: a slot an unconfirmed reset holds is simply capacity the replay does not find. ADR 0005 gets the addendum that says its cap now has a bounded wait in front of it. `decisions/index.md` is regenerated by `scripts/decisions-index.sh`, and `okf validate decisions` passes. `docs/api.md` gains a "Wait for capacity" section, `docs/configuration.md` the two new environment variables, `docs/architecture.md` the drainer's cron row and the pruner's new window, and the prices guide the operator-facing version. The marketing claim "starts beyond your limit are refused, not queued" was true and is no longer, so it and its test change together. The sentence about persistent-mode turn capacity is deliberately untouched: that ceiling is `sandbox_at_capacity` on one machine, which this queue does not cover. All three prose gates pass on the changed pages. **SDK version**: 1.28.0. 1.27.0 was claimed and published by the #1637 stack while this one was being written, and the release gate refuses a version npm already carries. Concurrent bumps land in ascending order, so re-check `sdk/typescript/package.json` on `main` immediately before merging and re-roll the four edits (`package.json`, `package-lock.json` twice, `src/http.ts`, `CHANGELOG.md`) if `main` has moved past 1.27.0 again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S9jevFQT5MkF3rJUieeiHW
ADR 0042 records the whole decision, including the two places this re-cut differs from the original proposal: the claim is a compare-and-swap fenced on the version it observed, with stale-claim recovery folded into it rather than run as a separate pass, and the SDKs omit `/api/sandbox-queue` with the reason written down. It also records that #1768's reset fence composes with this rather than fighting it: a slot an unconfirmed reset holds is simply capacity the replay does not find. ADR 0005 gets the addendum that says its cap now has a bounded wait in front of it. `decisions/index.md` is regenerated by `scripts/decisions-index.sh`, and `okf validate decisions` passes. `docs/api.md` gains a "Wait for capacity" section, `docs/configuration.md` the two new environment variables, `docs/architecture.md` the drainer's cron row and the pruner's new window, and the prices guide the operator-facing version. The marketing claim "starts beyond your limit are refused, not queued" was true and is no longer, so it and its test change together. The sentence about persistent-mode turn capacity is deliberately untouched: that ceiling is `sandbox_at_capacity` on one machine, which this queue does not cover. All three prose gates pass on the changed pages. **SDK version**: 1.28.0. 1.27.0 was claimed and published by the #1637 stack while this one was being written, and the release gate refuses a version npm already carries. Concurrent bumps land in ascending order, so re-check `sdk/typescript/package.json` on `main` immediately before merging and re-roll the four edits (`package.json`, `package-lock.json` twice, `src/http.ts`, `CHANGELOG.md`) if `main` has moved past 1.27.0 again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S9jevFQT5MkF3rJUieeiHW
BinaryBourbon
left a comment
There was a problem hiding this comment.
Requesting changes. The fence itself is the right design — commit the intent before provider I/O, release capacity only on confirmed deletion, and refuse a blind retry. The admission path, the quota accounting and the reaper exclusions are all done carefully, and the tests are the good kind: the expect(destroy) callback that reaches back in and proves turn admission is already refused while the provider call is in flight is a much stronger statement than checking the row afterwards.
What blocks it is the chokepoint change, not the fence.
1. update_sandbox/2's new refusal crashes about a dozen existing callers
update_sandbox/2 now does if current.reset_requested_at, do: Repo.rollback(:sandbox_reset_pending) (conversations.ex:232), before prevent_sandbox_revival/1, so every write to a fenced row is refused including a terminal one. Its own docstring, unchanged by this PR, says "Every sandbox status change in the system goes through here." That is accurate, and it is the problem: most of those callers pattern-match {:ok, _} =.
Two reaper queries were patched to skip fenced rows. Nothing else was.
Failure scenario, end to end. Sprites times out on a reset. The home stays status: "ready" with reset_requested_at set — this PR's own "an uncertain destroy retains capacity and cannot be retried or swept" test asserts exactly that state. Now:
- An operator opens
/admin/sandboxesand clicks reap, which is the obvious lever for a stuck machine.admin_controller.ex:372→Conversations._unsafe_reap_sandbox/1→conversations.ex:166,{:ok, _} = update_sandbox(sandbox, %{status: "terminated", terminated_at: now})→MatchErroron{:error, :sandbox_reset_pending}→ 500. Same throughadmin_live/sandboxes.ex:45. - The owner gives up and deletes the agent.
agents.ex:271→_unsafe_destroy_homes_for_agent/1→_unsafe_destroy_home/1→_unsafe_retire_home/1→conversations.ex:2762, same{:ok, _} =→MatchError→ 500. The agent cannot be deleted while the fence stands. - The machine goes idle and a
ConversationServerparks it.lifecycle.ex:372,{:ok, _} = Conversations.update_sandbox(sandbox, %{status: "suspended"})→ raises inside the GenServer. The reaper's own park path (sandbox_reaper.ex:313) is safe only because query 228 was filtered;Lifecycle.park/4has no such filter. - Seven
{:ok, _} = Conversations.update_sandbox(sandbox, %{status: "failed"})sites inconversation_server.ex(582, 732, 772, 800, 813, 909, 937, 949) are reachable the moment any server on that machine hits an error path.
accounts/deletion.ex:205 survives only because it does not match on the result and sits under a rescue — so account deletion completes but leaves a non-terminal row behind with its owner gone.
The tests prove the refusal (assert {:error, :sandbox_reset_pending} = Conversations.update_sandbox(...)) but no test drives a caller that matches {:ok, _}, which is why CI is green.
Please either audit those call sites in this PR, or make the refusal reachable only from the paths that should see it — a separate _unsafe_ writer for the reset machinery, or a force:/internal opt for the retire-and-reap paths whose whole job is to finish the row off.
2. Nothing clears reset_requested_at
I grepped the branch: the column is written in exactly one place (conversations.ex:2829) and read in eight. There is no admin route, no release task, no mix task, no context function that clears it. docs/concepts/sandboxes.md says "The operator must reconcile the provider outcome" and "Automatic reconciliation is not implemented" — but the operator has no instrument. The admin reap button 500s (above), and the remaining option is a manual UPDATE against the tenant database, which is not a thing this project does.
Combined with (1), a single provider timeout permanently consumes one of the tenant's sandbox slots — SANDBOX_CAP_FLOOR is 2, so on a small balance that is half the budget — and wedges that agent's home with no supported way out.
This does not need automatic reconciliation to land. It needs one deliberate operator action: an admin-only "abandon this reset" that clears the fence and terminates the row, audited, with the provider-side leak called out in the flash. Until that exists the doc paragraph is describing a procedure that does not exist, which is the failure mode ADR 0013's "do not describe unbuilt behavior as existing" rule is about.
3. An uncertain reset changes tenant state and records nothing
On the {:error, _} branch of finish_sandbox_reset/1, the transaction has already committed: the fence is set and runtime_session_id is nulled on every non-terminal conversation on the machine. Then the function returns {:error, :sandbox_reset_pending} and the with short-circuits before Audit.record/1, which the test asserts — refute Enum.any?(... &(&1.action == "sandbox.reset")).
That is right for sandbox.reset, which did not happen. But something did, and the operator asked to reconcile it has no record of who requested the reset, when, from what IP, or with what reason. Suggest a sandbox.reset_requested event recorded after the fence commits, with sandbox.reset kept for the confirmed destroy. That also gives the eventual un-fence lever something to point at.
Smaller things
- The status gate tightened silently.
reset_sandbox/2went from refusing onlyterminated/failedto requiringstatus in ["ready", "suspended"], so apendingorstartinghome now returns{:sandbox_not_resettable, "starting"}. I think that is right — you cannot fence a machine that is still being built — but the docstring still describes the old rule ("a terminated or failed one is already gone") anddocs/concepts/sandboxes.mddoes not mention it. Please update both. - The
downmigration is one-way in practice. It raises if any row carries evidence, and a successful reset retainsreset_requested_atby design. So after the first reset in production, rolling back past this migration fails permanently, including a rollback wanted for an unrelated reason. #1820 has the identical shape. Intended? {:error, :provider_transaction_open}is open-coded here and also added by #1826 asrequire_provider_commit_boundary/0. Same atom, same check, two implementations — collapse them when the stack rebases.- Nit:
check_attachable/4at line 3088 putsdo:on thewhenline while the clause directly below it putsdo:on its own. The formatter accepts both; matching the neighbour reads better.
Review of #1768 found the fence was a dead end. `update_sandbox/2` refused every write to a fenced row, and its own docstring says "every sandbox status change in the system goes through here" — which is true, and most of those callers match `{:ok, _}`. Two reaper queries were filtered against this; nothing else was. Reproduced all three before fixing: reap: {:RAISED, MatchError} delete_agent: {:RAISED, MatchError} park: {:RAISED, MatchError} So one Sprites timeout left a `ready` row nobody could reap (500 from /admin/sandboxes), whose agent could not be deleted (500), which crashed its ConversationServer at idle, and which held a quota slot for good. With `SANDBOX_CAP_FLOOR` at 2 that is half a small tenant's budget, and the only documented remedy was "the operator must reconcile", with no lever to do it. The rule is now that the fence stops the machine being re-used, not finished off. `update_sandbox/2` lets a write through when the resulting status is terminal, and refuses anything else. That covers the reset's own confirmed destroy, an operator reap, agent deletion, account deletion and a server giving up — each of which is the fence ending correctly. Reaping is therefore the supported way out, and it already exists in the console and the admin API; it just crashed. No new surface. Park is the one reachable non-terminal writer, so it skips a fenced machine instead, matching the reaper queries, in both `Lifecycle.park/4` and `HomeCheckpoint.on_park/1` — there is no checkpoint worth taking of a disk that is meant to be gone. Also from the review: - An unconfirmed reset committed the fence and cleared every `runtime_session_id` on the machine, then recorded nothing. `sandbox.reset` stays for the confirmed destroy; `sandbox.reset_requested` now records the fence, so whoever reconciles it can see who asked, when and why. - The status gate had tightened from "not terminated/failed" to `ready`/`suspended` with no note. Documented in the docstring and in docs/concepts/sandboxes.md, with the reason: a machine still under construction has no disk to replace. - The `down` migration refused whenever any row carried evidence, and a *successful* reset keeps its `reset_requested_at` by design — so the first reset in production would have been the last time the migration could be reversed. It now drops the column, which is the correct rollback semantic (the code being rolled back to has no fence concept), and logs each machine that had an unconfirmed delete so the operator can check it. Verified on a throwaway database with a fenced row present; `id::text` because a raw query hands back the 16-byte UUID and the Logger formatter raises on it. #1820 has the same one-way `down` and is worth the same look. - `check_attachable/4` now formats like the clause beneath it. The existing test asserting `update_sandbox(home, %{status: "terminated"})` is refused asserted the bug; it now covers a `suspended` write, which still is. New coverage: reap, agent deletion, a failed write, park-is-skipped, both audit rows, and the pending/starting refusal. apps/fountain: 994 tests, 0 failures across conversations, quotas, the reaper and the audit guardrail. credo --strict, format, and the three prose gates clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0181T6GGULcZfUa6dQtgNwtY
|
Pushed 1. The chokepoint refusal crashing The fix draws the line at what the write does rather than at who calls it: Park is the one reachable caller that is not retiring, so it skips a fenced machine instead, which is what the two reaper queries in this PR already do. Guarded in both 2. The missing operator lever. It turns out reaping already was the lever; it just crashed. 3. No audit row on an uncertain reset. 4. The status gate. Documented rather than reverted, in the 5. The one-way 6. The existing test asserting 994 tests green across One thing I did not do: #1820 has the same one-way |
ADR 0042 records the whole decision, including the two places this re-cut differs from the original proposal: the claim is a compare-and-swap fenced on the version it observed, with stale-claim recovery folded into it rather than run as a separate pass, and the SDKs omit `/api/sandbox-queue` with the reason written down. It also records that #1768's reset fence composes with this rather than fighting it: a slot an unconfirmed reset holds is simply capacity the replay does not find. ADR 0005 gets the addendum that says its cap now has a bounded wait in front of it. `decisions/index.md` is regenerated by `scripts/decisions-index.sh`, and `okf validate decisions` passes. `docs/api.md` gains a "Wait for capacity" section, `docs/configuration.md` the two new environment variables, `docs/architecture.md` the drainer's cron row and the pruner's new window, and the prices guide the operator-facing version. The marketing claim "starts beyond your limit are refused, not queued" was true and is no longer, so it and its test change together. The sentence about persistent-mode turn capacity is deliberately untouched: that ceiling is `sandbox_at_capacity` on one machine, which this queue does not cover. All three prose gates pass on the changed pages. **SDK version**: 1.28.0. 1.27.0 was claimed and published by the #1637 stack while this one was being written, and the release gate refuses a version npm already carries. Concurrent bumps land in ascending order, so re-check `sdk/typescript/package.json` on `main` immediately before merging and re-roll the four edits (`package.json`, `package-lock.json` twice, `src/http.ts`, `CHANGELOG.md`) if `main` has moved past 1.27.0 again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S9jevFQT5MkF3rJUieeiHW
BinaryBourbon
left a comment
There was a problem hiding this comment.
Approving. All three blocking points are addressed, and I verified each one rather than reading for it. Two findings below — neither blocks, but the first one means a comment in the migration is describing something the code does not do.
What I checked, and how
1. The chokepoint audit. I reverted only the escape — put if not is_nil(current.reset_requested_at), do: Repo.rollback(:sandbox_reset_pending) back — and re-ran sandbox_reset_test.exs on the fixed tree:
3) deleting the agent still retires its home
** (MatchError) no match of right hand side value: {:error, :sandbox_reset_pending}
(fountain) lib/fountain/conversations.ex:2773: Fountain.Conversations._unsafe_retire_home/1
(fountain) lib/fountain/agents.ex:271: Fountain.Agents.delete_agent/2
2) an operator reaps the row, which releases the slot
** (MatchError) ... lib/fountain/conversations.ex:166: Conversations._unsafe_reap_sandbox/1
1) a server that gives up can still mark the machine failed
left: {:ok, failed} right: {:error, :sandbox_reset_pending}
Three failures, exactly the three crash paths, each raising at the line the previous review named. That is the right ratio: the other 19 pass either way because they are about the fence holding. The park case stayed green under that revert, which is correct — it is guarded upstream now, and reverting the two park guards instead fails it alone at lifecycle.ex:377. Each guard is pinned by its own test.
2. The remaining non-terminal {:ok, _} = writers. I re-walked the list rather than take "park is the one reachable" on trust. Four sites write something non-terminal and match {:ok, _}:
conversation_server.ex:813("starting") and:909("ready") are insidedo_fresh_provision_inner/6, which is only reached throughcreate_fresh_sandbox_and_start/4— and the only arms that call it are:create_newand the registry:timeout.maybe_reuse_sandbox/1now returns{:error, :sandbox_reset_pending}for a fenced row before either, so both writes land on a freshly created row. Theinterrupted?re-entry needsstatus == "starting", whichreset_sandbox/2can no longer fence. Unreachable.provisioning.ex:783(provider_meta, no status, soget_field(:status)returns"ready"and the fence would refuse) sits underrecord_sandbox_url/2's function-levelrescue, which catchesMatchError. Degrades to a warning.home_checkpoint.ex:101is only reached fromon_park/1, now guarded.
3. The operator lever is real. Quotas.active_sandboxes/0 counts a fenced row only while status not in ["terminated", "failed"], so a reap genuinely returns the slot — assert Quotas.active_sandbox_count(ctx.user.id) == 0 is not a tautology. And the docs are right to say operator: the tenant-facing DELETE /api/sandboxes/:id is reset_sandbox/2 (sandbox_controller.ex:95), so it still answers 409. The owner cannot dig themselves out; admin reap is the whole set.
4. The down migration. Rebuilt a throwaway database, migrated up, inserted a ready row with reset_requested_at set, rolled back through it:
[warning] rollback: sandbox 52f838bd-… (sprites/sprite-stuck) had an unconfirmed reset.
Its machine may still exist at the provider; check it by hand.
[info] alter table sandboxes
[info] == Migrated 20260909050000 in 0.0s
Reversible, and the row is named on the way out. Much better than the version that would have bricked the rollback path after the first production reset.
sandbox.reset_requested mirrors sandbox.reset field for field, is recorded outside the transaction, and audit_guardrail_test.exs is green. 997 tests, 0 failures across test/fountain/conversations, the reaper, agents, accounts and docs. credo --strict, mix format --check-formatted, and all three prose gates clean (0 errors and 0 warnings on the new sandboxes.md paragraphs; only STE.Vocabulary suggestions, which advise).
The LOCK TABLE no longer runs before the count
down/0 reads:
execute "LOCK TABLE sandboxes IN ACCESS EXCLUSIVE MODE" # "Keep a concurrent reset
# from adding evidence
warn_about_unconfirmed_resets() # between the count and the drop."Ecto.Migration.execute/1 queues a command for the runner to flush; repo().query!/1 inside warn_about_unconfirmed_resets/0 goes straight to the connection. So the SELECT runs first and the lock is taken after it. I held ACCESS SHARE on sandboxes from a second session and ran the rollback:
[info] == Running 20260909050000 …FenceSandboxResets.down/0
[debug] QUERY OK db=2.3ms
SELECT id::text, sprite_name, provider FROM sandboxes WHERE reset_requested_at IS NOT NULL …
[warning] rollback: sandbox … had an unconfirmed reset …
[info] execute "LOCK TABLE sandboxes IN ACCESS EXCLUSIVE MODE" ← blocks here
The warning is printed, then the migration blocks on the lock. The old version did not have this problem: its check was a queued execute of a DO $$ … $$ block, so it ordered behind the LOCK.
The consequence is small — a reset committing inside that window loses its warning line, on a path an operator runs during an incident — but the comment above it asserts the opposite of what happens, which is the part worth fixing. Either take the lock through the same door:
repo().query!("LOCK TABLE sandboxes IN ACCESS EXCLUSIVE MODE")
warn_about_unconfirmed_resets()or leave the execute and put flush() after it.
HomeCheckpoint.on_park/1's fence clause is not covered
I deleted the new clause and left everything else in place:
22 tests, 0 failures
assert :skipped = HomeCheckpoint.on_park(Repo.reload!(ctx.home)) passes without the guard, because config/test.exs sets checkpoint_creation_enabled: false, so Managoat.Sandbox.supports?(:sprites, :checkpoint) is false and on_park/1 falls to the "nothing to do" branch either way. reject(Managoat.Sandbox.Sprites, :create_checkpoint, 2) is vacuous for the same reason — no checkpoint was ever going to be attempted. home_checkpoint_test.exs has to stub(Managoat.Sandbox, :supports?, fn :sprites, :checkpoint -> true end) before a checkpoint happens at all.
Both callers of on_park/1 filter fenced rows upstream today, so nothing is broken — but this guard is the one that would matter when a third caller appears, and right now it is a guard that would look identical if it stopped guarding. One line fixes it:
stub(Managoat.Sandbox, :supports?, fn :sprites, :checkpoint -> true end)in that test, which makes both the :skipped assertion and the reject mean what they say.
Smaller
- The CHANGELOG entry is narrower than the behaviour. "A reset that the provider does not confirm records
sandbox.reset_requested" reads as if the row is conditional. It is recorded on every reset — your own"a confirmed reset records both the request and the reset"test pins that, anddocs/concepts/sandboxes.mdstates it correctly ("sandbox.reset_requestedwhen the fence commits andsandbox.resetonly when the provider confirms"). Match the doc. Lifecycle.park/4reads the row, then checkpoints, then writes. The fence check at :374 is on a struct read beforeHomeCheckpoint.on_park/1, which on a checkpoint-capable provider is a retried provider call. A reset committing in that window still reaches{:ok, _} = update_sandbox(sandbox, %{status: "suspended"})and raises. It is the same shape as the pre-existingstatus not in ["terminated", "failed"]race directly above it, so this is not new — noting it only because the window grew from "two statements" to "two statements around a provider round trip."- CHANGELOG collision with #1840. Both this branch and #1840 insert a new
### Changedsection at the same position, immediately after thesandbox_api_accessbullet. Whichever rebases second gets a conflict there; worth expecting rather than discovering.
The :provider_transaction_open duplication with #1826 and the one-way down in #1820 are both still open from the last round, and both are still stack-level rather than this PR's.
ADR 0042 records the whole decision, including the two places this re-cut differs from the original proposal: the claim is a compare-and-swap fenced on the version it observed, with stale-claim recovery folded into it rather than run as a separate pass, and the SDKs omit `/api/sandbox-queue` with the reason written down. It also records that #1768's reset fence composes with this rather than fighting it: a slot an unconfirmed reset holds is simply capacity the replay does not find. ADR 0005 gets the addendum that says its cap now has a bounded wait in front of it. `decisions/index.md` is regenerated by `scripts/decisions-index.sh`, and `okf validate decisions` passes. `docs/api.md` gains a "Wait for capacity" section, `docs/configuration.md` the two new environment variables, `docs/architecture.md` the drainer's cron row and the pruner's new window, and the prices guide the operator-facing version. The marketing claim "starts beyond your limit are refused, not queued" was true and is no longer, so it and its test change together. The sentence about persistent-mode turn capacity is deliberately untouched: that ceiling is `sandbox_at_capacity` on one machine, which this queue does not cover. All three prose gates pass on the changed pages. **SDK version**: 1.28.0. 1.27.0 was claimed and published by the #1637 stack while this one was being written, and the release gate refuses a version npm already carries. Concurrent bumps land in ascending order, so re-check `sdk/typescript/package.json` on `main` immediately before merging and re-roll the four edits (`package.json`, `package-lock.json` twice, `src/http.ts`, `CHANGELOG.md`) if `main` has moved past 1.27.0 again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S9jevFQT5MkF3rJUieeiHW
ADR 0042 records the whole decision, including the two places this re-cut differs from the original proposal: the claim is a compare-and-swap fenced on the version it observed, with stale-claim recovery folded into it rather than run as a separate pass, and the SDKs omit `/api/sandbox-queue` with the reason written down. It also records that #1768's reset fence composes with this rather than fighting it: a slot an unconfirmed reset holds is simply capacity the replay does not find. ADR 0005 gets the addendum that says its cap now has a bounded wait in front of it. `decisions/index.md` is regenerated by `scripts/decisions-index.sh`, and `okf validate decisions` passes. `docs/api.md` gains a "Wait for capacity" section, `docs/configuration.md` the two new environment variables, `docs/architecture.md` the drainer's cron row and the pruner's new window, and the prices guide the operator-facing version. The marketing claim "starts beyond your limit are refused, not queued" was true and is no longer, so it and its test change together. The sentence about persistent-mode turn capacity is deliberately untouched: that ceiling is `sandbox_at_capacity` on one machine, which this queue does not cover. All three prose gates pass on the changed pages. **SDK version**: 1.28.0. 1.27.0 was claimed and published by the #1637 stack while this one was being written, and the release gate refuses a version npm already carries. Concurrent bumps land in ascending order, so re-check `sdk/typescript/package.json` on `main` immediately before merging and re-roll the four edits (`package.json`, `package-lock.json` twice, `src/http.ts`, `CHANGELOG.md`) if `main` has moved past 1.27.0 again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S9jevFQT5MkF3rJUieeiHW
Signed-off-by: Jake Gaylor <jhgaylor@gmail.com>
Signed-off-by: Jake Gaylor <jhgaylor@gmail.com>
Review of #1768 found the fence was a dead end. `update_sandbox/2` refused every write to a fenced row, and its own docstring says "every sandbox status change in the system goes through here" — which is true, and most of those callers match `{:ok, _}`. Two reaper queries were filtered against this; nothing else was. Reproduced all three before fixing: reap: {:RAISED, MatchError} delete_agent: {:RAISED, MatchError} park: {:RAISED, MatchError} So one Sprites timeout left a `ready` row nobody could reap (500 from /admin/sandboxes), whose agent could not be deleted (500), which crashed its ConversationServer at idle, and which held a quota slot for good. With `SANDBOX_CAP_FLOOR` at 2 that is half a small tenant's budget, and the only documented remedy was "the operator must reconcile", with no lever to do it. The rule is now that the fence stops the machine being re-used, not finished off. `update_sandbox/2` lets a write through when the resulting status is terminal, and refuses anything else. That covers the reset's own confirmed destroy, an operator reap, agent deletion, account deletion and a server giving up — each of which is the fence ending correctly. Reaping is therefore the supported way out, and it already exists in the console and the admin API; it just crashed. No new surface. Park is the one reachable non-terminal writer, so it skips a fenced machine instead, matching the reaper queries, in both `Lifecycle.park/4` and `HomeCheckpoint.on_park/1` — there is no checkpoint worth taking of a disk that is meant to be gone. Also from the review: - An unconfirmed reset committed the fence and cleared every `runtime_session_id` on the machine, then recorded nothing. `sandbox.reset` stays for the confirmed destroy; `sandbox.reset_requested` now records the fence, so whoever reconciles it can see who asked, when and why. - The status gate had tightened from "not terminated/failed" to `ready`/`suspended` with no note. Documented in the docstring and in docs/concepts/sandboxes.md, with the reason: a machine still under construction has no disk to replace. - The `down` migration refused whenever any row carried evidence, and a *successful* reset keeps its `reset_requested_at` by design — so the first reset in production would have been the last time the migration could be reversed. It now drops the column, which is the correct rollback semantic (the code being rolled back to has no fence concept), and logs each machine that had an unconfirmed delete so the operator can check it. Verified on a throwaway database with a fenced row present; `id::text` because a raw query hands back the 16-byte UUID and the Logger formatter raises on it. #1820 has the same one-way `down` and is worth the same look. - `check_attachable/4` now formats like the clause beneath it. The existing test asserting `update_sandbox(home, %{status: "terminated"})` is refused asserted the bug; it now covers a `suspended` write, which still is. New coverage: reap, agent deletion, a failed write, park-is-skipped, both audit rows, and the pending/starting refusal. apps/fountain: 994 tests, 0 failures across conversations, quotas, the reaper and the audit guardrail. credo --strict, format, and the three prose gates clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0181T6GGULcZfUa6dQtgNwtY
3739b97 to
b117bc7
Compare
ADR 0042 records the whole decision, including the two places this re-cut differs from the original proposal: the claim is a compare-and-swap fenced on the version it observed, with stale-claim recovery folded into it rather than run as a separate pass, and the SDKs omit `/api/sandbox-queue` with the reason written down. It also records that #1768's reset fence composes with this rather than fighting it: a slot an unconfirmed reset holds is simply capacity the replay does not find. ADR 0005 gets the addendum that says its cap now has a bounded wait in front of it. `decisions/index.md` is regenerated by `scripts/decisions-index.sh`, and `okf validate decisions` passes. `docs/api.md` gains a "Wait for capacity" section, `docs/configuration.md` the two new environment variables, `docs/architecture.md` the drainer's cron row and the pruner's new window, and the prices guide the operator-facing version. The marketing claim "starts beyond your limit are refused, not queued" was true and is no longer, so it and its test change together. The sentence about persistent-mode turn capacity is deliberately untouched: that ceiling is `sandbox_at_capacity` on one machine, which this queue does not cover. All three prose gates pass on the changed pages. **SDK version**: 1.28.0. 1.27.0 was claimed and published by the #1637 stack while this one was being written, and the release gate refuses a version npm already carries. Concurrent bumps land in ascending order, so re-check `sdk/typescript/package.json` on `main` immediately before merging and re-roll the four edits (`package.json`, `package-lock.json` twice, `src/http.ts`, `CHANGELOG.md`) if `main` has moved past 1.27.0 again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S9jevFQT5MkF3rJUieeiHW
Reset released admission's lock before deleting the provider sandbox: a new turn could enter the gap, and a failed delete still freed quota. Commit a reset fence under the shared admission lock, call the provider after commit, and retire the row only after successful deletion. Timeout or caller loss retains the fence and capacity for reconciliation.
An enclosing caller transaction now returns
provider_transaction_openbefore deletion or fence writes. Pending resets refuse turns, wakes, repeat resets and updates; the reaper skips them, and migration rollback preserves reset evidence. Main's locked retirement guard is retained.Refreshed onto #1796: 10 files, +248/-55. The new guard adds 18 lines across two existing files. Automatic reconciliation, instance-conditional deletion and cross-action arbitration remain separate work.
Validation: 62 focused reset/sandbox/reaper tests pass. The new regression fails on the previous handler because provider deletion starts inside the caller transaction; the fixed test commits the refusal and verifies unchanged state. Full
mix precommit --seed 828329passes: 4,892 tests +6 doctests, zero failures. Staged secret scan passes. The unchanged migration's rollback safeguards and 24 independent PostgreSQL interleavings have prior validation recorded in this PR. CI passes on the refreshed base (run); the base change cancelled the superseded push run before tests.Part of #1864