Repository navigation
fix(platform): let agent turns ride out a sandbox spawner restart - #4585
Merged
Merged
Conversation
yannickmonney
force-pushed
the
fix/sandbox-turn-overhead
branch
from
October 9, 2026 01:45
8896051 to
e3cd5b5
Compare
yannickmonney
force-pushed
the
fix/sandbox-turns-ride-out-spawner-restarts
branch
from
October 9, 2026 01:45
e356bbc to
43ee668
Compare
yannickmonney
force-pushed
the
fix/sandbox-turn-overhead
branch
2 times, most recently
from
October 9, 2026 02:40
a0d642d to
74075dc
Compare
yannickmonney
force-pushed
the
fix/sandbox-turns-ride-out-spawner-restarts
branch
2 times, most recently
from
October 9, 2026 02:42
1b596b1 to
7c36943
Compare
yannickmonney
force-pushed
the
fix/sandbox-turn-overhead
branch
from
October 9, 2026 02:54
74075dc to
eb77bf6
Compare
yannickmonney
force-pushed
the
fix/sandbox-turns-ride-out-spawner-restarts
branch
from
October 9, 2026 03:00
7c36943 to
2638b9d
Compare
yannickmonney
force-pushed
the
fix/sandbox-turns-ride-out-spawner-restarts
branch
from
October 9, 2026 03:23
2638b9d to
242cf8d
Compare
yannickmonney
enabled auto-merge
October 9, 2026 03:47
A spawner restart (a crash, an OOM at its 1 GiB limit, a deploy, a dockerd restart) or a short partition failed every in-flight task and automation agent run on the host: the resilient drain allowed five consecutive failed re-attaches on a linear 0.5..2.5 s backoff, about 7.5 s, and counted a refused connection, a 502/503 and a timeout alike, so the drive window threw and both hosts cancelled the exec and settled the run turn_crashed, while runnerd would have kept the CLI working for its 30-minute sliding orphan window. The drain now tells transport from verdict. A spawner it cannot reach (every fetch failure, the refused/reset/unresolved connections the existing classifier waits out included), a stream that broke mid-read, a 429/502/503/504 or a timed-out call is an outage: a drive window waits the spawner's retry-after, or the acquire/create backoff doubling from 250 ms to 5 s (the same SpawnerRetryBudget, bounded by the window), and attaches again after its cursor without spending its failure budget. A 404, a replay or protocol gap and a stream-reported error stay verdicts, and drains outside the drive windows keep the old budget. A window that ends with the stream still lost, or whose checkpoint read the spawner did not answer, ends running with the outage's start; the host chains the next window 5 s later with spawnerOutageSince in the drive payload, so it resumes from the checkpoint and the outage is measured across windows. Once it reaches 10 minutes (at most a third of TALE_EXTERNAL_TURN_DEADLINE_MS, so the exec found again is the one left) the host cancels the exec and settles the run failed through the same exactly-once fences as a drain failure; the work-turn deadline still applies at every window. The re-attach watchdog is unchanged: a queued delayed window counts as a live chain, and it probes before it claims.
A restarting spawner opened its port only after the host lock, the boot sweep, the re-adoption of every running session (a backend listing plus an endpoint read per session, eight at a time, and runnerd probes) and the device hub's placement load. Every platform call in that window met a refused connection, which only the acquire, create and drive-window drain wait out; on a host with many sessions, or a docker daemon slow after its own restart, the window ran long enough to fail starts. The listener now opens once the host lock and the boot sweep have run, before adoption. Until adoption has run and the hub has loaded its placements, the calls whose answer depends on them (every session route, the workspace inventory, the capacity read, an organization teardown and a device disconnect) answer 503 session_unavailable with retry-after: 1, the same answer session-routes already gives for a session it cannot yet place, and never a 404 the platform would take for a lost session. A restart thus reads as a short "not now" the platform waits out at its retry-after. /health, /v1/limits and /v1/devices answer as before, and the signal handlers are installed with the listener, so a SIGTERM during adoption stops the listener and releases the host lock as after boot.
With the listener open before boot adoption, POST /v1/drain and GET /v1/drain-status were reachable mid-adoption. A drain latched then made adoptExisting and adoptSession stop at their isDraining checks, so every session not yet adopted answered 404, which the platform settles as session_gone; and drain-status counted only the registry adoption had filled so far, often 0, so a configuration activation read the spawner as drained and ran docker restart under live sessions. Both routes now sit behind the adoption gate and answer 503 session_unavailable with retry-after: 1 until adoption and the hub's placement load are done. The CLI's control call exits 1 on that answer: a configuration activation, whose pending intent is written before the drain, throws and stays pending for its retry, and tale deploy's best-effort drainSandbox proceeds as it does for an unreachable spawner.
Opening the listener before boot adoption also made /health answer 200
during adoption, while before it could not be reached until adoption and
the hub's placement load had finished. Every probe changed meaning: on
the documented Kubernetes Deployment (RollingUpdate, readinessProbe on
/health) a new spawner Pod went Ready mid-adoption and the old Pod was
retired while the new one answered 503 to every session call, and
Docker's healthcheck and the CLI's waitForRuntime passed early enough to
drain a spawner part-way through adoption.
While adoption is pending /health now answers 503 {"status":"starting"}
without probing the backend, so ready again means adopted. The session
routes still answer 503 rather than refuse the connection, and Compose,
which routes by network alias whatever the health, keeps delivering
them during a Docker restart.
The point of opening the listener before boot adoption lived only in main(), which no test can run: the router tests stubbed BootAdoption.prototype.pending, so moving Bun.serve back after adoption, or ending the gate before the hub had loaded its placements, left every test green. main() now runs the listener, adoption and the hub load through the exported listenThenAdopt(steps, adoption). It begins the gate before the listener accepts a call, logs an adoption that throws without skipping the hub load (as main's catch did), and ends the gate however the steps end. The periodic session sweep moves unchanged into startSessionSweep. Tests pin the order (listen, adopt, hub, all with the gate held, then released), a failed adoption and a failed hub load, and drive the module's own gate through a pending adoption: /health answers starting, the deploy drain, drain status and a session read answer 503, and drain status answers 200 once adoption ends. Swapping listen after adopt fails two of them.
The drain that rides out a spawner outage read every 503 as "not now", the device hub's 503 device_offline included: the refused-answer path cancelled the body unread, so the x-tale-sandbox-device header and the device_offline body were never consulted. A turn whose device went away (a laptop closed) then waited the full 10-minute outage budget instead of failing in about 7.5 s, and settled saying the sandbox service could not be reached, though the service had answered. An exec, attach or checkpoint answer of 503 carrying the device header is now read, and a device_offline body becomes SandboxDeviceOfflineError, a verdict: the drain spends its five consecutive failures on it (0.5 to 2.5 s apart, about 7.5 s) and throws it, so the automation host's failure reason names the device; the checkpoint read retries it like any 503 and then throws it, and the drive window rethrows it rather than ending running. A device spawner's own 503 session_unavailable, which carries the same header, is still ridden out.
TALE_EXTERNAL_TURN_DEADLINE_MS now also sets how long a running agent turn waits out an unreachable sandbox service before its run fails: SPAWNER_OUTAGE_BUDGET_MS = min(10 min, deadline / 3), so 10 minutes at the 30-minute default and shorter whenever the deadline is lowered below 30 minutes. The operator reference in EN, DE and FR and .env.example described only runnerd's orphan window, leaving nothing to tell an operator that a run may now wait about 10 minutes before failing, or that lowering this variable shortens that wait.
The automation coverage map now keeps one file per suite, so parallel changes stop appending to the same lines; this change's rows move there, after their own anchor row.
yannickmonney
force-pushed
the
fix/sandbox-turns-ride-out-spawner-restarts
branch
from
October 9, 2026 03:55
242cf8d to
5a4e28b
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Any spawner restart killed every in-flight agent run on the host, even though the CLI kept running in its healthy container: a crash, an OOM kill at its 1 GiB limit, a deploy or a dockerd restart. A short network partition did the same. The platform's exec drain gave up after five failed re-attaches over about 7.5 s. Both drive hosts then settled the run as failed, while the agent in the sandbox kept working until runnerd's 30-minute orphan deadline.
Stacked on #4543, and reuses its transient-connection classifier and retry budget. After #4543 merges, this is retargeted to main and rebased.
Changes
fix(platform): let agent turns ride out a sandbox spawner outage
A spawner restart (a crash, an OOM at its 1 GiB limit, a deploy, a
dockerd restart) or a short partition failed every in-flight task and
automation agent run on the host: the resilient drain allowed five
consecutive failed re-attaches on a linear 0.5..2.5 s backoff, about
7.5 s, and counted a refused connection, a 502/503 and a timeout alike,
so the drive window threw and both hosts cancelled the exec and settled
the run turn_crashed, while runnerd would have kept the CLI working for
its 30-minute sliding orphan window.
The drain now tells transport from verdict. A spawner it cannot reach
(every fetch failure, the refused/reset/unresolved connections the
existing classifier waits out included), a stream that broke mid-read,
a 429/502/503/504 or a timed-out call is an outage: a drive window waits
the spawner's retry-after, or the acquire/create backoff doubling from
250 ms to 5 s (the same SpawnerRetryBudget, bounded by the window), and
attaches again after its cursor without spending its failure budget. A
404, a replay or protocol gap and a stream-reported error stay verdicts,
and drains outside the drive windows keep the old budget.
A window that ends with the stream still lost, or whose checkpoint read
the spawner did not answer, ends running with the outage's start; the
host chains the next window 5 s later with spawnerOutageSince in the
drive payload, so it resumes from the checkpoint and the outage is
measured across windows. Once it reaches 10 minutes (at most a third of
TALE_EXTERNAL_TURN_DEADLINE_MS, so the exec found again is the one left)
the host cancels the exec and settles the run failed through the same
exactly-once fences as a drain failure; the work-turn deadline still
applies at every window. The re-attach watchdog is unchanged: a queued
delayed window counts as a live chain, and it probes before it claims.
fix(sandbox): listen before boot adoption and hold session calls on 503
A restarting spawner opened its port only after the host lock, the boot
sweep, the re-adoption of every running session (a backend listing plus
an endpoint read per session, eight at a time, and runnerd probes) and
the device hub's placement load. Every platform call in that window met
a refused connection, which only the acquire, create and drive-window
drain wait out; on a host with many sessions, or a docker daemon slow
after its own restart, the window ran long enough to fail starts.
The listener now opens once the host lock and the boot sweep have run,
before adoption. Until adoption has run and the hub has loaded its
placements, the calls whose answer depends on them (every session route,
the workspace inventory, the capacity read, an organization teardown and
a device disconnect) answer 503 session_unavailable with retry-after: 1,
the same answer session-routes already gives for a session it cannot yet
place, and never a 404 the platform would take for a lost session. A
restart thus reads as a short "not now" the platform waits out at its
retry-after. /health, /v1/limits and /v1/devices answer as before, and
the signal handlers are installed with the listener, so a SIGTERM during
adoption stops the listener and releases the host lock as after boot.
fix(sandbox): hold the deploy drain calls while boot adoption runs
With the listener open before boot adoption, POST /v1/drain and GET
/v1/drain-status were reachable mid-adoption. A drain latched then made
adoptExisting and adoptSession stop at their isDraining checks, so every
session not yet adopted answered 404, which the platform settles as
session_gone; and drain-status counted only the registry adoption had
filled so far, often 0, so a configuration activation read the spawner
as drained and ran docker restart under live sessions.
Both routes now sit behind the adoption gate and answer 503
session_unavailable with retry-after: 1 until adoption and the hub's
placement load are done. The CLI's control call exits 1 on that answer:
a configuration activation, whose pending intent is written before the
drain, throws and stays pending for its retry, and tale deploy's
best-effort drainSandbox proceeds as it does for an unreachable spawner.
fix(sandbox): answer /health starting until boot adoption has run
Opening the listener before boot adoption also made /health answer 200
during adoption, while before it could not be reached until adoption and
the hub's placement load had finished. Every probe changed meaning: on
the documented Kubernetes Deployment (RollingUpdate, readinessProbe on
/health) a new spawner Pod went Ready mid-adoption and the old Pod was
retired while the new one answered 503 to every session call, and
Docker's healthcheck and the CLI's waitForRuntime passed early enough to
drain a spawner part-way through adoption.
While adoption is pending /health now answers 503 {"status":"starting"}
without probing the backend, so ready again means adopted. The session
routes still answer 503 rather than refuse the connection, and Compose,
which routes by network alias whatever the health, keeps delivering
them during a Docker restart.
refactor(sandbox): lift the boot's adoption order into a tested step
The point of opening the listener before boot adoption lived only in
main(), which no test can run: the router tests stubbed
BootAdoption.prototype.pending, so moving Bun.serve back after adoption,
or ending the gate before the hub had loaded its placements, left every
test green.
main() now runs the listener, adoption and the hub load through the
exported listenThenAdopt(steps, adoption). It begins the gate before the
listener accepts a call, logs an adoption that throws without skipping
the hub load (as main's catch did), and ends the gate however the steps
end. The periodic session sweep moves unchanged into startSessionSweep.
Tests pin the order (listen, adopt, hub, all with the gate held, then
released), a failed adoption and a failed hub load, and drive the
module's own gate through a pending adoption: /health answers starting,
the deploy drain, drain status and a session read answer 503, and drain
status answers 200 once adoption ends. Swapping listen after adopt
fails two of them.
fix(platform): fail a drain on an offline device instead of waiting
The drain that rides out a spawner outage read every 503 as "not now",
the device hub's 503 device_offline included: the refused-answer path
cancelled the body unread, so the x-tale-sandbox-device header and the
device_offline body were never consulted. A turn whose device went away
(a laptop closed) then waited the full 10-minute outage budget instead
of failing in about 7.5 s, and settled saying the sandbox service could
not be reached, though the service had answered.
An exec, attach or checkpoint answer of 503 carrying the device header
is now read, and a device_offline body becomes SandboxDeviceOfflineError,
a verdict: the drain spends its five consecutive failures on it (0.5 to
2.5 s apart, about 7.5 s) and throws it, so the automation host's
failure reason names the device; the checkpoint read retries it like
any 503 and then throws it, and the drive window rethrows it rather than
ending running. A device spawner's own 503 session_unavailable, which
carries the same header, is still ridden out.
docs(platform): say the turn deadline also bounds a spawner outage
TALE_EXTERNAL_TURN_DEADLINE_MS now also sets how long a running agent
turn waits out an unreachable sandbox service before its run fails:
SPAWNER_OUTAGE_BUDGET_MS = min(10 min, deadline / 3), so 10 minutes at
the 30-minute default and shorter whenever the deadline is lowered below
30 minutes. The operator reference in EN, DE and FR and .env.example
described only runnerd's orphan window, leaving nothing to tell an
operator that a run may now wait about 10 minutes before failing, or
that lowering this variable shortens that wait.
Verification
session_client(93 tests)external_turn_shared(71)agent_run_host.reap(13)agent_host.spawner_outagesuite. It runs the real drain and window over a fake transport: the spawner dies mid-stream, then refuses connections. The run continues and the next window attaches from the checkpoint and settles once. Past the budget it settles failed once.server.test.tscovers the boot gate. It checks the listen → adopt → hub order with the gate held throughout, and that a failed adoption still ends the gate. While adoption is pending,/healthreadsstartingand drain and drain-status answer 503.@tale/platformand@tale/sandboxtypecheck and lint pass, and lint:manual is ok./healthreported ready before adoption had finished, so a Kubernetes rollout could retire the old Pod too early.Behaviour changes
TALE_EXTERNAL_TURN_DEADLINE_MS, which is 10 minutes by default. Past that it fails as before.session_unavailablewithretry-after: 1to session calls, instead of refusing connections./healthanswers 503startinguntil adoption has run.Part of the sandbox footprint and robustness pass (#4533, #4538 and siblings).