Skip to content

feat(sandbox): add a stop action, and queue lifecycle jobs - #241

Merged
quickbeard merged 2 commits into
mainfrom
fix/sandbox-job-queue
Aug 15, 2026
Merged

quickbeard merged 2 commits into
mainfrom
fix/sandbox-job-queue

Conversation

@quickbeard

@quickbeard quickbeard commented Aug 15, 2026 •

Copy link
Copy Markdown
Owner

Two sandbox lifecycle changes, plus a README that had gone stale.

1. Jobs no longer cancel each other across sandboxes

createSandboxController ran create/reset/destroy through one abort controller and one job state slot. beginJob opened with an unconditional jobAbort?.abort(), so starting a job on any sandbox aborted whatever job was in flight on any other sandbox.

The failure is silent. A cancelled create resolves undefined, which addSandbox treats as a clean user cancel — nothing persisted, no error surfaced. So deleting sandbox B part-way through sandbox A's ~800 MB image download threw the download away and left A uncreated, with nothing to say why. The UI made it easy to hit: busy() only disabled the menu of the sandbox that held the job, so every other sandbox's actions stayed live.

Jobs now queue. They still run one at a time, deliberately: create and reset stage guest images through one shared cache directory whose resumable-download bookkeeping explicitly assumes a single writer (image.ts removeStalePartials), so running them concurrently would have them delete each other's partials. The change is that a job now waits its turn rather than killing the job ahead of it.

  • A second job for the same sandbox still supersedes the first — that was what the single-flight guard was actually for, and it's preserved.
  • Cancelling a job that is still queued returns immediately instead of waiting out the running job (the Add dialog sits on that promise), and the job behind a cancelled one does not jump ahead of the job that is actually running.
  • updateSandboxMounts and cancelJob now consider queued jobs too.

Wire shape: SandboxesState.job: SandboxJob | null → jobs: SandboxJob[] (running first, at most one per sandbox), each job carrying status: "running" | "queued". The renderer mirror moves with it and the three consumers read the list; a waiting sandbox says so rather than claiming to be working (sandbox.job.queued).

2. Sandboxes can be stopped

initialize() started every persisted sandbox at launch and nothing could shut one down short of deleting it, so N sandboxes meant N guests holding memory and CPU whether or not anyone was working in them (vz defaults: 4 cpus / 4 GiB each).

stopSandbox cancels a start already in flight (the stale-attempt guard makes the pending start discard its instance), stops the guest, and records stopped on the persisted config. initialize() skips those.

The flag persists on purpose. A stop that a relaunch undoes would not address the problem it exists for. Only an explicit start clears it — the internal restarts (initialize, reset, a mount change) leave the user's intent alone, and resetting a stopped sandbox re-provisions it without bringing it up. Persisted records written before this existed have no flag and keep starting, so nothing changes for an existing install until the user stops something.

Stop appears in the sandbox menu (Settings → Servers) when a sandbox is ready or still starting — a start hanging on a slow image or a wedged VM is exactly when you want out of it.

One knock-on: the Work side's empty state said "it appears here once it is ready", which is true for a starting or failed sandbox but a false promise for a stopped one. It now has a stopped variant pointing at where Start lives.

3. README

packages/codev-sandbox/README.md was a phase log that ended with "Still to come: the Windows managed-WSL provider, the desktop main-process wiring, and the packages/app UI" — all three shipped, along with mounts, the WSL guest firewall, and per-sandbox resource limits, none of which it mentioned. Rewritten as a description of what the package is now: platform core, the three backends, the image pipeline, and where the desktop and app halves live. It keeps one honest known gap — image upgrades only happen if the user happens to hit Reset.

Testing

  • packages/codev-sandbox: 129 pass / 2 skip. Seven new tests: a job on one sandbox neither aborts nor overtakes another's; cancelling a queued job returns at once without starting it or letting the next one jump the queue; a same-sandbox job still supersedes; stop keeps a sandbox down across a relaunch; start clears the flag; stopping a starting sandbox discards the instance it was waiting on; reset on a stopped sandbox re-provisions without starting.
  • Two existing tests needed an added await tick(): a job now starts a microtask after admission rather than synchronously, so the fake provider's resolver isn't assigned until the turn begins.
  • packages/app (663) and packages/desktop (96) suites pass; bun typecheck clean in all three packages.

DIVERGENCES rows for the sandbox and workspace i18n families updated with the new keys; two CHANGELOG entries added under Unreleased.

🤖 Generated with Claude Code

quickbeard and others added 2 commits August 15, 2026 10:49
Create, reset and destroy shared one abort controller and one state slot, so
beginJob aborted whatever was in flight regardless of which sandbox it belonged
to. Deleting or resetting sandbox B part-way through sandbox A's image download
silently abandoned that download: the cancelled create resolves undefined, which
addSandbox treats as a clean cancel, so nothing was persisted and no error was
raised. The UI only disabled actions for the sandbox holding the job, so every
other sandbox's menu stayed live and could trigger it.

Jobs now queue. They still run one at a time — create and reset stage images
through one shared cache whose resumable-download bookkeeping assumes a single
writer (image.ts removeStalePartials) — but each one waits its turn instead of
killing the one ahead of it. Only a second job for the *same* sandbox supersedes
the first, which is what the single-flight guard was for. Cancelling a job that
is still queued returns immediately rather than waiting out the running job, and
the one behind it does not jump ahead of the job actually running.

State exposes `jobs: SandboxJob[]` (running first) in place of `job`, each with
a `status`, so the app can tell "waiting behind another sandbox" from "working".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A sandbox ran from app launch until it was deleted: initialize() started every
persisted sandbox and the UI offered no way to shut one down, so three
sandboxes meant three guests holding memory and CPU regardless of whether any
work was happening in them.

Adds stopSandbox: it cancels a start already in flight (the stale-attempt guard
makes the pending start discard its instance), stops the running guest, and
records `stopped` on the persisted config. initialize() skips those, so the
decision survives a relaunch — a stop that a restart undoes would not address
the problem. Only an explicit start clears the flag; the internal restarts
(initialize, reset, a mount change) leave the user's intent alone, and
resetting a stopped sandbox re-provisions it without bringing it up.

Stop is offered while a sandbox is still starting as well as when it is ready:
a start hanging on a slow image or a wedged VM is exactly when the user wants
out of it.

The Work side's empty state gets a stopped variant. It told the user "it
appears here once it is ready", which is true of a sandbox that is starting or
has failed but a false promise for one that is stopped and will never come up
on its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@quickbeard quickbeard changed the title fix(sandbox): queue lifecycle jobs instead of cancelling the last one feat(sandbox): add a stop action, and queue lifecycle jobs Aug 15, 2026
@quickbeard
quickbeard merged commit 766e1d1 into main Aug 15, 2026
0 of 5 checks passed
@quickbeard
quickbeard deleted the fix/sandbox-job-queue branch August 15, 2026 04:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant