fix(agents): the sweep stops ended native sandboxes
P08c of the rollout in "Bringing Codex or Claude Code Max plans into Luke" (https://tryluke.dev/stage/plan/89f19bfc-88b5-471a-886a-84b00180aa09). It's a follow-up to P08 (#4255) and P09 (#4244).
Why
When a native runner posts its END, P09's route calls runnerEnded, which stops the sandbox unless a Terminal holds it. If that stop fails, it is logged and never retried, so an unleased sandbox idles until its 24 h timeout. This PR adds the retry to the minute sweep.
What it does
- Store read:
readNativeRunStanding(agentId)incoding-agent-store.ts.- It is unscoped, because the sweep acts for every account.
- Given a sandbox's
lukeAgenttag, it returns the agent's conversation id and when its newest turn ended.endedAtis null while that turn is queued or running. That includes P05's WAITING, which P09 leaves running. It is also null before the agent has any turn. - It returns nothing for an eve agent, a deleted conversation, an unknown id, or a tag that isn't a UUID.
- It returns no user content.
- Vercel API:
listRunning(cursor)invercel-sandbox-api.ts. It callsGET /v2/sandboxeswithstatus=runningandsortBy=createdAt, oldest first on even minutes and newest first on odd ones, one page of 50 at a time, and follows thepagination.nextcursor. The API only accepts a status filter together with a sort by creation, so names are filtered on our side. - Sweep step:
maintenance/native-sandbox-sweep.ts(sweepNativeSandboxes), run after the terminal-lease sweep. It reportsnativeSandboxesin the sweep's answer.- Which sandboxes it considers: running ones named
luke-agent-*that carry alukeAgenttag (P08's naming). An eve sandbox is skipped on its name alone. Aluke-agent-*sandbox whose agent runs on eve is skipped too. - When it stops one: the conversation is native, its newest turn ended more than 2 minutes ago, and
sandboxHeldis false. The grace lets the END's own stop go first. The lease is read last, just before the stop. - Before it stops one: it reads the sandbox again by its session tag. It leaves the sandbox alone if it has moved on: gone, in another session, or with its tagged runner command still running, which means a message relaunched it. It then reads the run's end from the store again, and the lease last.
- How it stops one: as
runnerEndeddoes. It first resets the firewall to open-internet, which removes the repository credential, then callsstopSession. If the credential can't be removed, the sandbox isn't stopped; it's counted as failed and retried next minute. - Bounds per run: at most 4 pages (200 running sandboxes), 40 native sandboxes read against the store, and 10 stops, 5 at a time. Anything past the bounds is counted as
deferredand waits for the next minute. - Failures: if Vercel refuses the list, the step sweeps nothing and the sweeps after it still run. A failed stop is counted and logged by its status.
- Which sandboxes it considers: running ones named
RUNNER_TAGandSANDBOX_NAMEare now exported fromrunner-sessions.ts(where P21 moved them), so the sweep uses the launch's own names rather than copies of them.
Tests
coding-agent-store.test.ts(PGlite): native runs onclaude_codeandcodexread as ended once the newest turn settles, fails, or is cancelled. They don't read as ended while a turn is running or queued, or before any turn. Eve agents, deleted conversations, unknown ids and non-UUID tags read as none.native-sandbox-sweep.test.ts(real store, fake Vercel REST API):- An ended, unleased sandbox is stopped, with its credential removed first.
- A leased sandbox is left running.
- Running, queued, waiting and just-ended sandboxes are left running.
- eve sandboxes, including one tagged with a native agent's id, are never touched.
- The stop bound: 12 stoppable sandboxes give 10 stops, then 2 in the next sweep.
- The page bound: a native sandbox past 4 pages waits, and the next minute reaches it from the other end.
- A relaunched runner keeps its sandbox; a sandbox whose runner exited is stopped.
- A failed credential removal leaves the sandbox running.
- A refused list sweeps nothing.
maintenance-sweep.test.tsgains the new sweep.
pnpm check:affected: every affected test, biome, oxlint, the discarded-effect check, repository checks and migrations passed locally. Its parallel typecheck ran out of memory in my sandbox (exit 137), so I ran tsc --noEmit for database and service separately; both are clean (web's own tsc also ran out of memory here). CI runs the full set.
Notes
- The developer waived per-PR Preview runs for this rollout, so this wasn't run on Preview. Vercel's
status=runningfilter value comes from its documented list API and hasn't been tried live. - P08b (#4286) and P21 (#4284) landed first. Main is merged in, P08b's template sweep is kept beside this one (the sweep now has eleven steps), and the names come from P21's
runner-sessions.ts. - Budget: this step makes at most 4 list calls, 40 × 3 small queries and 10 × 2 stop calls, 5 at a time, so it fits well inside the sweep's 60 s.
- AGENTS.md's "The scheduled sweep" section doesn't mention this step. I left it alone, since edits to that file need approval.
Out of scope: routing (P11) and the template (P08b).
Made with Luke