Skip to content

fix(sessions): settle executions after runner or sandbox loss - #6501

Open
mmabrouk wants to merge 70 commits into
feat/session-durable-cancelfrom
feat/session-execution-watchdog
Open

fix(sessions): settle executions after runner or sandbox loss#6501
mmabrouk wants to merge 70 commits into
feat/session-durable-cancelfrom
feat/session-execution-watchdog

Conversation

@mmabrouk

@mmabrouk mmabrouk commented Sep 2, 2026

Copy link
Copy Markdown
Member

Context

When a runner died mid-turn, the session stayed "running" forever. When the runner came back after the watchdog had already ended the turn, its late output landed in the transcript beside the ending, so one turn showed two endings. This PR is the watchdog half of the Stop package in the session-control design (PR #6495), stacked on the durable Stop command in PR #6503.

Changes

The branch carries three groups of commits. Read them in order.

Watchdog and quarantine (the original slice). A sweep every 60 s ends any turn whose runner heartbeat is older than 90 s with one execution_lost error and one done, keyed on heartbeat age rather than on the owner lease. Records that arrive after that ending are kept but marked quarantined_at and hidden from every transcript read, so one ending stays effective. reject is one setting away.

Two sweep fixes found on the integrated stack. A stopped row whose runner died before its terminal record got no ending; now the sweep writes it and releases the dead turn's alive lock. Each sweep pass is bounded, and a pass that times out is logged.

Review fixes from 3 September. These came from the staff review of the design and touch the command path as well, so they sit here rather than on #6503:

  • The rollback switch AGENTA_SESSIONS_DURABLE_STOP and the legacy cancel response moved down into PR feat(sessions): deliver durable Stop directly to the runner #6503 on 2026-09-04, so feat(sessions): deliver durable Stop directly to the runner #6503 is safe alone. This PR keeps AGENTA_SESSIONS_LATE_OUTPUT (quarantine default or reject) and everything below. Rebased on the new feat(sessions): deliver durable Stop directly to the runner #6503 head; 26 commits; head 43d77f1989.
  • A pending Stop whose runner is still alive is redelivered with the same command id, bounded by max_deliveries; a Stop whose runner is gone settles lost. Before this, the sweep skipped that case and the session read "stopping" forever.
  • One terminal outcome per execution is enforced by the database: a new core table session_executions takes a compare-and-set from the runner outcome route and from the watchdog; the loser gets a clear "lost the race" result. Records ingest only reads that state, fails open when core Postgres is down, and quarantines only output written after an involuntary ending (lost, or stopped by the other writer). A usage that trails its own done is ordinary history.
  • Command settle, execution terminal claim, stopping-marker clear, liveness mirror, and interaction cancel commit in one core transaction; the Redis write happens after commit and the sweep repairs a missed one. The cancel notice is published after commit.
  • Runner: a Stop on a session parked on an approval now rejects and clears the gates, waits for the prompt to settle inside the cancel window, and parks warm. A fresh user message after a denied tool part goes to session.prompt with the new text instead of resuming the old prompt. Before this, the next message after an approval Stop answered the tool denial and never saw the new question, on both providers.

Before: POST /sessions/{id}/cancel during an approval → 202, gate cancelled, next message → "The command was refused. How would you like to proceed?"
After: the same Stop → 202, gate cleared and parked warm, next message → the answer to the new question in the same sandbox.

Tests

What to QA

Live cells run on the integrated stack with the release-gate driver from PR #6518 (session_control.py). The morning report on PR #6505 lists the results per harness and provider. Watch for: stop-approval on Pi local and Daytona (the fix above), post-stop-row (is_running false within seconds of the Stop), stale-tail (late done quarantined), restart-after-stop (native session survives).

Fixes of 2026-09-04 (found by the new failure cells)

  • Runner-gone: the watchdog was started without the commands service, so an abandoned Stop was never settled lost and a resurrected runner's late report won the compare-and-set. The service is wired at startup with a lifespan test (6d3add4b58).
  • Runner restart: the ending-only sweep branch never cleared the dead runner's owner lease, so the next Send was refused "already running a turn" for up to 120 s. The branch now clears the lease, only for the dead turn's own owner (8b809fafef).
  • Concurrent Stops: the sweep only saw the session's current turn, so a stopped turn of a session that moved on never got its ending. The ending selection is now execution-centric over session_executions (bd5b330b89), with a nullable ending_written_at mark set by the watchdog and by records ingest, a partial index, descending order, and a stopped-shaped ending for a user Stop (migration oss000000026, eee7fd7d33).
    All three were reviewed by the agents that found them. Live re-runs on the merged head follow.

Fixes of 2026-09-04, afternoon (found by the full matrix on the merged head)

  • Stop during a parked approval: the Stop required a settled harness cancel; on a client without cancelSession it threw, tore down the warm sandbox, and wrote no execution row. When no cancel can be sent, the Stop now reparks the sandbox warm and settles as stopped (3e9d5c1651).

  • Watchdog loop dead on the first error: the loop's except branch called log.exception on MultiLogger, which had no such method, so the first failed pass killed the loop for the life of the process with zero log lines. The loop logs with exc_info and MultiLogger gained exception (6e0962cd14, d9df2f2cd4).

  • Sweep poisoned by one unknown command row: a row with a kind this build cannot map raised inside the batch and left every abandoned Stop pending. Both the claim and the abandoned paths now skip unmappable rows through one shared mapper and warn once per batch (757900145c, c663b43b50).

  • Lost settlement left the row running: the watchdog settled the execution lost but session_streams.is_running stayed true until the runner's own beat. The same pass now clears the flag, the running lock, and the mirror on the row that still names the dead turn (d40dd7c9d0).

  • Returning runner re-beat a dead turn: after the sweep cleared the row, an unpaused runner's late heartbeat set it running again. The sweep tombstones every swept turn, and the heartbeat path refuses a tombstoned turn (a8e7920e0f).

  • Hard kill keeps the session affinity: a runner killed with no grace never released owner:session:<id>, so the restarted runner's first heartbeat for the next turn lost the non-stealing owner claim and refused the message with "already running a turn" for up to 120 s; the earlier fix only covered a turn the sweep declared lost. The heartbeat now reclaims affinity from a departed replica when the running lock is free or its own; the alive lock stays the single arbiter. Not behind the flag, because the key and the lock primitives are shared with the legacy path (15d58add1c).

  • The lost settlement did not clear the running flag (finding 7): the watchdog pass wrote the session row's flags through the ORM after nested sessions had detached the row, so the write never reached the database while a plain UPDATE in the same pass did. Both session_streams writes in the sweep are now plain UPDATEs from captured ids; a Postgres test replays the real pass (2cbd1d3cdd, 2613968468).

  • A deleted Daytona sandbox left a dead turn beating for 30 minutes: the provider's proxy keeps answering "not found" for a deleted sandbox, which the liveness probe counted as alive, and the transport's error died unhandled inside the protocol library. A "not found" answer now ends the turn with one error record within seconds; the detector arms only after the sandbox is acquired so a start-up race cannot trip it (1ddea5e81e). Follow-ups: the two-minute first-byte timer never fires; the Python SDK adapter's idle timeout (180 s) is shorter than the runner's own (360 s).

Live on the integration stack after these fixes (Pi local and Codex local, last-message shape): stop-approval, runner-gone-late, stale-tail, sandbox-gone, concurrent-stops, repeat-stop, stop-during-completion, and records-outage pass. The Codex runner-gone-late re-run on 15d58add1c and the runner-gone read-while-paused cell are still open.

Agent-generated, low weight. Not merged.

https://claude.ai/code/session_01GAqSs7fw6QRi2n1ZJ2tmAV

A user Stop aborted the run signal and nothing else. The turn ended with
stopReason "cancelled", shouldPark answered false for every aborted run, and
the sandbox was deleted, so the next message paid a cold start and lost the
native harness session. The abort never told the harness anything either: it
only made the runner stop waiting, leaving an open prompt and a running tool
that only the teardown ever stopped.

Cancel the harness first, then park. On the cancelled path the turn now sends
the ACP session/cancel notification for the live session and waits a bounded
time for the harness to answer its open prompt. ACP requires the agent to end
that prompt with stopReason "cancelled", so a settled prompt is the harness
reporting it is idle. Only a settled cancel parks; a cancel that cannot be
sent, or that the harness never answers inside the budget, leaves the
environment unknown and still destroys it.

sandbox-agent refuses a manual session/cancel ("Use destroySession(sessionId)
instead"). The guard is in the TypeScript client only, so the pnpm patch adds
cancelSession(id), which sends the same managed cancel destroySession sends
without marking the session record destroyed. The daemon inside the sandbox
proxies ACP and holds no such rule, so no Daytona snapshot rebuild is needed.

The cancel deliberately does not abort env.mcpAbort. That controller belongs to
the environment, not the turn, and a parked environment must keep its tool-MCP
server; the approval-park path already skips it for the same reason.

Client-disconnect behavior is unchanged. The clientGone check moved above the
abort check so a disconnect still destroys whatever the abort says.

Claude-Session: https://claude.ai/code/session_01GAqSs7fw6QRi2n1ZJ2tmAV
Answers the six questions the work package asked, with path:line evidence:
where the session/cancel guard lives (the vendored client, not the daemon),
what the harness reports after a cancel, what happens to a running tool call,
why every cancellation path destroyed the sandbox before this change, the
eight-line client patch, and why Daytona needs no rebuilt snapshot.

Also records the live protocol and its results for Pi and Codex, a negative
control that forces the settle budget to 1 ms and shows the destroy path, the
recommended settlement timeout for D-016, a release-gate cell, and the three
things the spike did not cover.

Claude-Session: https://claude.ai/code/session_01GAqSs7fw6QRi2n1ZJ2tmAV
The first cut inferred the Stop from `stopReason: "cancelled"`, but the turn
sets that value whenever the run signal aborts, whatever aborted it. Any
future `controller.abort()` anywhere in the runner would then silently start
parking sandboxes nobody had checked, which is the failure the teardown
allowlist exists to prevent.

The one call site that means a cooperative Stop, the heartbeat interrupt in
server.ts, now labels its abort, and shouldPark requires that label alongside
the cancelled stop reason and the settled harness cancel. The mechanism is the
standard AbortController.abort(reason), so nothing new is threaded through the
engine, the coordinator or the turn.

Also from the review:

- A stopped session parks on its own window, defaulting to the 600 s approval
  window locally because the user is about to type, and to the ordinary 120 s
  idle window on Daytona where a parked sandbox is billed compute. One named
  field, one env var, so the two windows collapse again with one value.
- The terminal done record carries stopReason "cancelled" as well as "paused".
  Without it a stopped turn is indistinguishable from a completed one in
  Postgres, so neither the frontend nor the release gate can tell a Stop from a
  finish. Kept as a two-value allowlist so a harness-reported end_turn cannot
  start appearing there by accident.
- Corrects the comment claiming the abort severs the harness fetch. It does
  not: the signal reaches the client's health wait only, never the ACP
  transport, which is why the cancelled branch has to send a real
  session/cancel.

Claude-Session: https://claude.ai/code/session_01GAqSs7fw6QRi2n1ZJ2tmAV
…windows

Answers the reviewers' remaining questions with measurements rather than
expectations.

The finding that needs a decision: a stopped Codex turn leaves its shell
command running inside the parked sandbox, and Pi does not. Measured by
cancelling a running sleep and having the next turn list processes; one probe
returned two leftovers at once, from two different sessions. Running the same
scenario down the destroy path left none, so parking is what makes the child
survive rather than something this change merely revealed. The fix belongs in
the Codex ACP bridge, which this repo already patches on both image surfaces,
and unlike the runner-side cancel it would need a Daytona snapshot rebuild.

Also records the current park windows and the new stopped-session window, why
the abort now carries an explicit reason, that cancel, steer and kill are
indistinguishable to the runner until the durable command plane lands, the
terminal-record fix with its Postgres evidence, and two release-gate assertions
including one that fails on Codex today on purpose.

Claude-Session: https://claude.ai/code/session_01GAqSs7fw6QRi2n1ZJ2tmAV
The runner change that carries `stopReason: "cancelled"` through to the
terminal done record had live evidence but no test. Three runner assertions
now pin it: a Stop carries the reason, a pause still does, and a completed
turn plus every harness-reported reason carry nothing. The last one is the
point of the two-value allowlist, so it is the one worth having.

On the frontend, transcript reconstruction reads only "paused"
(transcriptToMessages), so a cancelled done falls through to the ordinary
terminator and closes the turn like a completed one. That is the behaviour we
want, and "the new value is inert here" is a claim worth a test rather than a
comment, so two cases pin it: a stopped turn does not swallow the next turn
the way a pause does, and it is not marked paused.

Verified: 159 runner files / 2650 tests, and 52 agenta-chat transcript tests.

Claude-Session: https://claude.ai/code/session_01GAqSs7fw6QRi2n1ZJ2tmAV
… window

The first cut shipped a decision that is not mine to make: it defaulted a
stopped local session to the 600 s approval window. The field now defaults to
the ordinary idle window on both providers, so introducing it changes no
timing at all, and it exists only so the value is one named setting with one
env var when somebody decides to move it.

The recommendation stays, written where the reader who changes it will be
standing: make it the approval window on the local provider, because a user
who stops is about to type and the 60 s idle window can throw the sandbox away
while they are still writing. Daytona would not follow, because a parked
Daytona sandbox is billed compute and its 120 s window is already that
decision. Try it with AGENTA_RUNNER_SESSION_STOPPED_TTL_MS.

A third test covers the env var moving the stopped window without disturbing
the ordinary idle one.

Claude-Session: https://claude.ai/code/session_01GAqSs7fw6QRi2n1ZJ2tmAV
…rk window

Adds one table near the top saying what was actually tested and on what, so a
reader does not have to infer coverage from the prose: Pi and Codex live on
the local sandbox, Claude not tested for want of an Anthropic key, Daytona not
tested at all, and per harness what the cancel does to the in-flight tool.
Also records that before this change the abort sent no cancel to Claude Code
or Codex at all, which is why the difference between the two harnesses was
invisible until now.

Corrects the park-window section and the matching open question to the value
that actually ships. The stopped window defaults to the ordinary idle window
and changes no timing; moving it to 600 s locally is a recommendation for
Mahmoud, not something this spike decided.

Claude-Session: https://claude.ai/code/session_01GAqSs7fw6QRi2n1ZJ2tmAV
A user Stop reached the runner only as the absence of a Redis lock, noticed on
the next heartbeat up to 30 seconds later. Nothing recorded that a Stop had been
asked for, so a Stop against an unreachable runner was simply lost and no
execution ever reached a terminal outcome anyone could read.

Add session_commands: one row per durable request to change an execution. Two
columns that are never merged carry the two questions a caller actually asks.
state says where the COMMAND is (pending, claimed, applied, obsolete). outcome
says what happened to the EXECUTION (stopped, not_running,
superseded_by_newer_turn, failed, lost). A client drawing a Stop button reads the
execution; a client retrying safely reads the command id.

Every transition is one UPDATE ... WHERE <expected state> RETURNING *, decided by
scalar_one_or_none, the same compare-and-set transition_interaction already uses.
That is what stops two API replicas both winning a claim or both writing a
terminal outcome. Idempotency has two layers: the caller's Idempotency-Key on
(project_id, session_id, idempotency_key), and a collapse onto any open command
for the same target execution, which is what makes two Stops in a row correct
without asking the browser to send a key.

session_streams gains two columns. stopping_turn_id names the execution an
accepted Stop is waiting on, written in the same transaction as the command
insert. turn_started_at records when the row's current turn_id started, because
the stale-Stop guard has to compare a Stop's arrival time with the running
execution's start time and there was nowhere to read that: updated_at is the
heartbeat timestamp and moves every 30 seconds, runner-minted turn ids are uuid4
and carry no time, the Redis lock value is a bare turn id a Lua compare reads
whole, and the session_turns append is fire-and-forget so a running turn may have
no row. It is stamped only when the id actually changes, so the repeated
heartbeats that restamp the same id never move it.

Both columns backfill to NULL. A row written before this migration yields no
comparison and the guard does not fire, deliberately: a guard that refused every
Stop it could not verify would break the common case to protect a rare one.

Claude-Session: https://claude.ai/code/session_01GAqSs7fw6QRi2n1ZJ2tmAV
Control delivery sits behind a port so the transport can be swapped without
touching a route, a data shape or a state transition. This adds the first
adapter: a direct call to the runner's own POST /cancel over the same
authenticated hop that already carries hard kill. No held connection, no poll
loop, no per-session Redis channel. Agenta runs one runner, and the parts that
carry the correctness are the record and the guards, which are identical
whichever transport delivers.

The order is not negotiable. The command row is committed BEFORE the runner is
called. Calling first and recording afterwards gives back every failure the
record exists to close: a crash between the call and the insert leaves an aborted
execution with no terminal outcome written anywhere.

Where the direct call fails is that env.runner.internal_url is one service
address, so with two runner replicas behind a load balancer it reaches the right
process only by luck. That failure is quiet, because the wrong process honestly
answers "I do not hold that session", which is also what a session that really
ended answers. Two things make it loud. Each heartbeat now adds one sorted-set
entry naming its replica, and the adapter refuses to deliver at all when more
than one replica has beaten inside the census window, so the command stays
durable instead of being posted into the dark. And a not_held for a session whose
row is alive with a fresh heartbeat is the wrong-replica case and nothing else,
so the service settles it lost rather than telling the user the work had already
finished.

AGENTA_SESSIONS_CONTROL_ADAPTER picks the transport and defaults to direct. The
lease, the delivery cap, the sweep interval and the admission deadline join it in
one SessionsCommandsConfig block, read through the shared env object.

publish_session_ended becomes public on the streams service because a settled
Stop publishes the same lifecycle notification an ordinary turn end publishes.
There is one ended event, not a Stop-shaped one and a turn-shaped one; a client
cannot be asked to tell them apart.

Claude-Session: https://claude.ai/code/session_01GAqSs7fw6QRi2n1ZJ2tmAV
Stop gets its own route instead of being the no-inputs, no-force corner of the
four-mode stream command. Cancel means cancel: one route, one meaning.

Admission stamps the arrival time first, before any read, then resolves the
target execution once from Redis running, falling back to alive so a session
parked awaiting an approval is reachable. That parked case is the one with no
control channel at all today, because a parked session stops heartbeating. Three
guards then decide. A stale expected_execution_id is refused with 409 and nothing
is written. With no expectation sent, an execution that started after the request
arrived is not the one the user meant, so the command is inserted already settled
and targets nothing. And the target is pinned once, so a turn that starts later
has a different id and a pinned command cannot reach it.

Redis is not written at admission. The stopping execution keeps alive and running
while it stops, which is what prevents a second message from starting underneath
it. At settlement the API tombstones the stopped turn before releasing running,
so a late beat from it cannot re-arm the locks it is about to lose, and it leaves
alive to its own time to live exactly as an ordinary turn end does. Force-
deleting alive is what makes today's cancel read as a session teardown, and warm
resume is the required outcome of Stop.

Settlement also cancels that execution's pending interactions, scoped to the one
turn so a newer turn's gates survive. An approval card whose execution was
stopped is a card whose buttons do nothing.

The route is deliberately NOT behind check_runner_concurrency_limit: refusing to
STOP work because a project is at its run limit is the exact wrong answer to a
busy project.

The runner reports what happened on an internal outcome route that authenticates
with the shared runner token rather than a project credential, because the runner
holds none for a command it was handed. The command id resolves the project, so
the exemption widens no tenant boundary: a caller can only settle a command whose
id it knows and whose claim it holds.

POST /sessions/streams/ keeps its exact current behaviour. It becomes a thin
wrapper over this command in a later change, so released clients keep working
until the browser and the wrapper flip together.

Claude-Session: https://claude.ai/code/session_01GAqSs7fw6QRi2n1ZJ2tmAV
The abort controller for a session-owned run was a local variable inside the
request handler. Nothing outside that closure could reach it, so the only way to
stop a turn was to take the session's Redis lock away and wait up to 30 seconds
for the heartbeat to notice. POST /cancel reaches the run directly.

A module-level registry maps <projectId>:<sessionId> to the live execution, the
same key shape poolKeyFor builds, because two projects may use one session id and
the project segment is the tenant boundary. It records startedAt, which is what
makes a late Stop safe: the API pins the target at admission, but the runner's
comparison against its own memory is exact, so an execution that began after the
command was created is never aborted. Registration happens as soon as the abort
controller exists, so a Stop during environment acquisition still lands, and it
is removed in the same finally that releases the alive watchdog, scoped to the
turn id so a finishing turn cannot unregister its successor.

Applying a command twice is not harmless: by then the session may be running a
newer turn, and a second abort would kill work nobody asked to stop. So the set
of applied command ids lives beside the session pool rather than inside any
request or loop, and an entry is written when a command is ACCEPTED, not when the
cancel finishes, so a duplicate arriving mid-cancel is also a no-op. An
already-applied command is a no-op that STILL acknowledges, which repairs a lost
acknowledgement without a second abort.

/cancel answers 202 when it holds the session and 404 when it does not, and it
resolves a parked approval through the keep-alive pool before answering 404 —
that session runs no turn, so the registry alone would miss exactly the case that
has no control channel today. The response is an acknowledgement, never an
outcome: what happened to the execution goes to the API's outcome route, so
settlement has one path on every transport.

The applier sits above the transport, so a long-poll adapter would call the same
applyCommand and change none of these guards.

Claude-Session: https://claude.ai/code/session_01GAqSs7fw6QRi2n1ZJ2tmAV
Stop posted the four-mode stream command with no inputs and no force, which drops
the session's Redis lock and leaves the runner to notice on its next heartbeat.
It also sent no execution id, so a Stop applied a fraction of a second after its
turn ended tombstoned whichever turn had started in between, for an hour.

It now posts the cancel route, naming the execution it means. The id is read
FRESH from the session row rather than from the project-wide liveness poll, which
is up to 15 seconds stale: a stale id is refused with a conflict and the user's
Stop would do nothing. When the row names no turn we send no expectation and the
API resolves the target, which is what happened before. A conflict is an answer,
not a failure — the run this tab was watching had already ended — so the call
refreshes the session's own state either way rather than retrying.

The call is awaited and the session state refreshed on the response, so the
Inspector and the liveness dot stop lying about a run that has been stopped.

The client uses raw axios rather than the Fern client, because the route is new
and the generated client does not know it yet. Move it onto Fern at the next
regeneration. Mobile is untouched.

Adds the API tests for the two things a Stop must never get wrong: missing the
run the user meant, and killing a run they did not. Fifteen admission cases cover
the arrival-time stamp, the stale expectation, the newer-turn guard, the parked
session, the collapse of two Stops, and the settlement that releases running
while leaving alive alone — that last one is what pins warm resume at the API
layer. Fourteen DAO cases run against a real Postgres, because what they test IS
the database: the unique constraint, FOR UPDATE SKIP LOCKED under two concurrent
claims, and the guarded terminal transition.

Claude-Session: https://claude.ai/code/session_01GAqSs7fw6QRi2n1ZJ2tmAV
Three defects, all found by driving a real Stop against a live stack, and all in
the delivery path rather than in the record.

THE REGISTRY NEVER HELD THE SESSION, so every Stop got a 404 and settled at once
while the turn ran to completion. The entry was keyed by <projectId>:<sessionId>,
but the project scope is not known when a run starts: runContext.project.id is
empty on the live invoke path, and the scope that forms the pool key comes from
the signed mount, which the coordinator resolves after the run is already in
flight. Register under the session id at once, and let the coordinator fill the
project in through onScopeResolved as soon as it knows it. A lookup matches only
when the stored project agrees, so another tenant is refused rather than
misrouted; until the project is known the entry matches, because refusing every
Stop in the first moments of a run is the bug this replaces.

THE OUTCOME REPORT WAS REFUSED WITH A 409, so the command stayed claimed and the
session stayed marked stopping forever. The API claimed on the runner's behalf
under a placeholder, while the runner reported under its own replica id, and the
settle guard compares the two. Read the id out of the runner's acknowledgement
and claim under that.

THE MULTI-REPLICA CENSUS REFUSED DELIVERY AFTER EVERY RESTART. A runner mints a
fresh replica id at boot when AGENTA_RUNNER_REPLICA_ID is unset, so its previous
id is still inside the census window and the count reads two. Refusing on that
count breaks Stop for the whole window after an ordinary deploy, which is worse
than the failure it guards against, and it was observed doing exactly that. The
census now logs at error level and delivers anyway. The exact detector was always
the other one: a not_held for a session whose row is alive and beating is the
wrong-replica failure and nothing else produces it.

Also close the collapse race properly. Two Stops in a row already collapsed, but
two in the SAME INSTANT did not: admission reads for an open command and then
inserts, and neither request can see a row the other has not committed. A unique
partial index on (project_id, session_id, kind, target_turn_id) over the open
states makes the database decide, and the losing insert reads the winner back.
Verified live: two simultaneous requests now return one command id.

Claude-Session: https://claude.ai/code/session_01GAqSs7fw6QRi2n1ZJ2tmAV
Brings the two design documents onto this branch so it stands alone, and adds the
slice record: what changed with path:line references, the measured live protocol,
the three defects the live run found that no unit test saw, what is left for the
long-poll adapter and the stream-route wrapper, and five open questions.

The measurement worth keeping: a Stop reaches the running turn in 72ms, the
harness confirms at 90ms, the command and the execution settle at 116ms, and the
sandbox parks warm just under a second later. The budget was five seconds.

The deviation worth flagging: the work package asked the direct adapter to refuse
delivery when more than one runner replica has heartbeated in five minutes. It
warns instead. A runner mints a fresh replica id at boot, so its own restart puts
two ids in the window and refusing there breaks Stop after every deploy, which
was observed. The exact detector is the not_held rule, which needs no census.

Claude-Session: https://claude.ai/code/session_01GAqSs7fw6QRi2n1ZJ2tmAV
…nsus

Two corrections after rebasing onto Spike A's final tip and re-reading the
revised design.

THE ABORT NEEDED THE USER-STOP LABEL. Spike A now parks only an abort the runner
can prove was a cooperative Stop: shouldPark requires isUserStopAbort alongside
the cancelled stop reason and the settled harness cancel, because inferring the
Stop from the stop reason alone would let any future controller.abort() park a
sandbox nobody had checked. The execution registry handed the applier a bare
controller.abort(), so after the rebase every Stop delivered as a command would
have ended the turn cancelled and then DESTROYED the sandbox, which is the exact
failure Stop exists to avoid. It now aborts with USER_STOP_ABORT_REASON. A
command from the control plane is the clearest user Stop the runner ever sees.

Two tests pin it, one on each side of the contract: an abort carrying the label
leaves the turn parkable, and an unlabelled one does not. Verified live after the
rebase: the runner logs park-cancelled with the stopped-session window, and the
next message resumed in the same sandbox.

THE REPLICA CENSUS IS GONE. The revised design keeps it as an optional extra and
names the exact detector as the one to build. The census cost a Redis write on
every heartbeat, and it could not tell two live replicas from one that had
restarted, because a runner mints a fresh id at boot. Removing it deletes a
per-beat write, two settings, and a whole module.

What remains is the detector that is exact: a not_held for a session whose row
says alive with a fresh heartbeat means some process is running that session and
it is not the one we called. It now also names the owner replica from the Redis
owner key, so the log says where the Stop should have gone rather than only that
it did not arrive, and it settles the command lost rather than not_running, so
the user is told the Stop failed instead of that the work had already finished.

Claude-Session: https://claude.ai/code/session_01GAqSs7fw6QRi2n1ZJ2tmAV
… removal

Re-measures the live protocol against the rebased runner (82ms to the abort,
126ms to settlement, 993ms to the warm park), records the fourth defect the
rebase exposed, and rewrites the census entry: it is removed rather than
softened, on the revised design's guidance that the not_held detector is the one
worth building and the census is optional. That closes the one open question that
was a deviation from the brief.

Claude-Session: https://claude.ai/code/session_01GAqSs7fw6QRi2n1ZJ2tmAV
@vercel

vercel Bot commented Sep 2, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
agenta-documentation Ready Ready Preview Sep 4, 2026 5:17pm UTC

Request Review

@coderabbitai

coderabbitai Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

🗂️ Base branches to auto review (1)
  • release/.*

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Team

Run ID: c8ce55de-de4f-41b1-b254-66d329712212

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@mmabrouk mmabrouk changed the title [overnight] feat(sessions): settle executions whose runner or sandbox cannot report an outcome fix(sessions): settle executions after runner or sandbox loss Sep 3, 2026
Settlement released `running` in Redis and stopped there. The row kept
`is_running: true`, and the row is the only thing the product's liveness
polls read: `query_streams` serves Postgres and never looks at Redis.

Nothing else could correct it. Settlement tombstones the stopped execution
before it releases `running`, so the runner's own final `is_running=false`
heartbeat is refused by the tombstone check and returns before the mirror
write at the end of `heartbeat`. The order cannot be swapped: a late beat
that found `alive` free would take it straight back under the dead turn's
id. The runner reports its outcome as soon as it issues the abort, so the
tombstone always wins that race.

Measured on the local sandbox with Pi before the fix: the row read
`is_running: true` with a pre-Stop `updated_at` for the full 193 s of the
sample, while Redis had released `running` within 0.5 s. The tab that
pressed Stop therefore showed a "running somewhere else" strip over its own
session until the orphan sweep collapsed the row minutes later. After the
fix the row reads `is_running: false, is_alive: true` within 0.15 s of the
request, and the parked sandbox still resumes warm.

`mirror_liveness` re-reads Redis rather than writing a literal `false`, so a
newer turn that has already taken `running` is reported and not erased.

Claude-Session: https://claude.ai/code/session_01GAqSs7fw6QRi2n1ZJ2tmAV
Every liveness `refetchInterval` asked "is the alive set non-empty", which
is not the question. Stop ends the work and leaves the session alive so the
sandbox resumes warm, and an ordinary turn end does the same, so one stopped
session held all four polls at 15 s, in every open tab, for the hour that
`alive` lock lives.

One shared predicate now answers the real question: 15 s while something is
RUNNING, 60 s while a session is merely alive, and stop when nothing is
alive. The sidebar rail passes a 60 s idle floor instead of stopping,
because it must still discover a run it did not start; that baseline was
already deliberate and is unchanged.

The mobile gates poll keys on running directly, since a running turn is
what mints new gates.

Claude-Session: https://claude.ai/code/session_01GAqSs7fw6QRi2n1ZJ2tmAV
…olves

Admission resolves the execution to stop from the Redis `running` key with a
fallback to `alive`, so a session parked on an approval is reachable. The
`expected_execution_id` guard then compared against `running` alone.

A parked approval has released `running` and still holds `alive` under the
same turn id, so the guard read `running` as none, refused the request with
a conflict naming "current: none", and left the gate pending. The identical
Stop sent without an expectation was accepted and cancelled the gate. The
browser always sends the id it streamed, so pressing Stop on an approval
card was refused in the product while the integration approval cell passed,
because its driver sent no expectation. The guard fired on the one case it
exists to allow.

It now compares against the resolved target. The guard still refuses a
stale id: a Stop naming a finished turn on a session parked under a newer
one is refused, and the conflict now names the turn that would have been
stopped instead of none.

Claude-Session: https://claude.ai/code/session_01GAqSs7fw6QRi2n1ZJ2tmAV
…aimed

Admission inserts the command `pending`, hands it to the runner, and writes
`claimed` on the runner's behalf only after the runner answers. A runner
that aborts fast reports its outcome inside that window, while the row
still says `pending`.

The outcome route guarded on `claimed` alone, so that report was refused
with a conflict. Observed on a Stop during model output: the runner aborted
and parked correctly, logged `[control] outcome HTTP 409`, and the command
sat `claimed` for 2 min 17 s until the sweep settled it `obsolete` with
outcome `lost`. The user watched "stopping" for the whole sweep window and
a Stop that worked was recorded as lost.

The guard is now a set, and it is still one statement, so it is evaluated
at the moment of the write. Reading the state first and updating after
would reopen the same race: the claim can commit in between.

The replica guard is widened only where there is nothing to guard. A
`pending` row holds no claim, so a null `claimed_by` passes; a claimed row
must still be claimed by the reporter, and a report from any other replica
is still refused.

Claude-Session: https://claude.ai/code/session_01GAqSs7fw6QRi2n1ZJ2tmAV
…ndbox

A Stop pressed as the answer lands used to tear the sandbox down, so the
next message rebuilt cold. Reproduced on the local sandbox with Pi by
firing the Stop on the runner's own `prompt stopReason=end_turn` line:

  [control] aborted command=... turn=c81b783e...
  [control] outcome reported command=... state=stopped
  [keepalive] evict key=... reason=no-park:end_turn

and the next message took 7.2 s against 1.9 s warm.

Two things were wrong, in two places.

The execution stays registered through teardown, which writes the
transcript, exports the trace and parks the environment, and that takes
hundreds of milliseconds. A Stop arriving in that window found a live entry
and aborted it. The abort stopped nothing, because the prompt had already
settled, but the aborted signal then made `shouldPark` refuse to park a
healthy idle environment. The run is now marked settled the instant the
harness prompt settles, before teardown begins, and the applier does
nothing at all for a settled run. Nothing aborts, so the ordinary park path
runs. It reports `obsolete` with `not_running`, because the command stopped
nothing.

Past that window the runner has dropped the execution and answers
`not_held`, and the API judged that on whether the row was beating. A turn
that has just ended leaves `alive` set and a fresh beat behind it exactly
as a running one does, so every late Stop was settled `lost` and the user
was told their Stop failed when the work had simply finished. The
discriminator is now `running`: an execution holding it means a process is
running this session and it is not the one we called, which is the
wrong-replica failure the `lost` outcome exists for. With no `running`
owner nothing is executing anywhere, and `not_running` is the honest
answer.

Verified live on both windows: the command settles `obsolete` /
`not_running`, no eviction, and the next message reuses the parked sandbox
warm (`hit-continue`, 1.9 s).

Claude-Session: https://claude.ai/code/session_01GAqSs7fw6QRi2n1ZJ2tmAV
`claim_commands` ended in `[map_command_dbe_to_dto(dbe) for dbe in claimed]`, the
same batch map that broke the abandoned-command sweep. A newer API replica can
write a command `kind` this older replica's enum does not know, and
`map_command_dbe_to_dto` raises ValueError on that row; one such row in a claimed
batch threw away the whole claim, including a Stop the runner could act on.

Generalize the sweep's defensive mapper into `_map_commands_skipping_unmappable`
and route both the claim and the abandoned-command path through it: skip the rows
this replica cannot map, warn once per batch with the kinds, count, and which
batch (claimed or abandoned), and return the rest. The unknown row is left for a
replica that knows its kind. The enum and the write path are unchanged.

A unit test claims an unknown-kind row next to a claimable Stop and asserts the
Stop is returned, the unknown row is dropped, and the skip is warned once naming
the claimed context.

Claude-Session: https://claude.ai/code/session_01GAqSs7fw6QRi2n1ZJ2tmAV
… lost

When the watchdog settled an execution lost, it wrote the terminal records but
left the session_streams row reading is_running: true whenever that row was not
one the orphan query collapsed. The SEND gate reads that flag, so the next
message was refused until the runner returned -- which, for a lost turn, may be
never. Observed on the integration stack (run 1c, cell runner-gone): the
execution was settled lost at 13:23:56 but the stream row still read is_running
true, and the flag only flipped when the paused runner came back and beat.

The RFC's rule is that the lost settlement writes the ending, clears is_running,
releases alive, and updates the mirror in the same pass. The went-silent collapse
already does this for the rows the orphan query matches. This extends the
ending-without-collapse branch to every other lost turn: it clears is_running on
the row that STILL names the dead turn (keeping is_alive so the session stays
resumable), clears the Redis running lock, and publishes the mirror change.
Everything is guarded on turn_id, so a row that has advanced to a newer running
turn is never disturbed.

Two unit tests: a lost execution whose row still names it reads is_running false
right after the pass and its running lock is cleared; a lost OLD execution leaves
a newer running turn's flag and lock untouched.

Claude-Session: https://claude.ai/code/session_01GAqSs7fw6QRi2n1ZJ2tmAV
@mmabrouk

mmabrouk commented Sep 4, 2026

Copy link
Copy Markdown
Member Author

Two more watchdog hardenings

1. A runner's claim survives a row it cannot map (c663b43b50)

claim_commands ended in the same batch map that broke the abandoned-command sweep: [map_command_dbe_to_dto(dbe) for dbe in claimed]. A newer replica can write a command kind this older replica's enum does not know, and one such row in a claimed batch threw away the whole claim, including a Stop the runner could act on. The sweep's defensive mapper is now shared as _map_commands_skipping_unmappable(rows, context=...) and both the claim and abandoned paths route through it: skip the rows this replica cannot map, warn once per batch naming the kinds, count, and which batch (claimed or abandoned), and return the rest. A unit test claims an unknown-kind row next to a claimable Stop and asserts the Stop is returned and the unknown row dropped.

2. The watchdog clears is_running when it marks an execution lost (d40dd7c9d0)

Run 1c, cell runner-gone (session 5a1df540, execution 4d6df0b3): the execution was settled lost by the watchdog at 13:23:56, but the session_streams row still read is_running true; the flag flipped only at 13:24:52 when the paused runner returned and beat. The SEND gate reads is_running, so with no runner return the next Send is refused forever.

Cause: the lost settlement wrote the terminal records but cleared is_running only on the rows the orphan query collapsed. A lost turn whose row that query did not return (a different row for the session, or an older execution whose row has advanced) kept is_running true. The ending-without-collapse branch now, for every lost turn not collapsed, clears is_running on the row that STILL names the dead turn (keeping is_alive so the session stays resumable), clears the Redis running lock (release_running, turn-guarded), and publishes the mirror change. All guarded on turn_id, so a row that has advanced to a newer running turn is untouched. Two unit tests cover both the clear and the newer-turn guard.

Checks

ruff@0.15.12 format and check: clean. pytest oss/tests/pytest/unit/sessions: 570 passed, 70 skipped (Postgres-gated DAO integration tests, unrelated).

Caveat worth a second look

The live trace also fits a heartbeat-vs-sweep race: a beat from the not-yet-fully-paused runner could re-set is_running true just after the collapse committed. This fix guarantees the sweep leaves is_running false for a lost turn, but does not itself close a racing late beat (that path is the mark_turn_superseded tombstone). Flagging in case run 1c repeats the symptom after this lands.

…is_running

After the sweep cleared is_running on a lost turn's row, a runner that returned
seconds later beat that same turn_id and re-set is_running true, and the SEND gate
refused the next message again (observed live: run 1e, session a70c22d4, turn
e49c060b, a beat 3.5 s after the settle). The heartbeat path already refuses a
turn the sweep has tombstoned (is_turn_superseded), but the orphan-collapse path
tombstoned only the turns that still held the Redis alive/running keys. A turn
whose keys a prior Stop settlement had already cleared held nothing to displace,
so it was collapsed but never tombstoned, and its returning beat was admitted.

Tombstone the collapsed row's own turn_id unconditionally, alongside any displaced
key owners. The tombstone carries the 1-hour superseded TTL, so a runner that
returns within the hour has its dead-turn beat refused; the ending-without-collapse
branch already tombstones its turns the same way. This is the smallest fix on the
mechanism the heartbeat path already trusts and adds no read to the beat hot path.

A unit test sweeps an orphan whose turn holds no Redis keys and asserts the turn
is tombstoned afterwards.

Claude-Session: https://claude.ai/code/session_01GAqSs7fw6QRi2n1ZJ2tmAV
@mmabrouk

mmabrouk commented Sep 4, 2026

Copy link
Copy Markdown
Member Author

Finding 6: a returning runner re-set is_running on a swept turn (a8e7920e0f)

Run 1e (session a70c22d4, turn e49c060b): the sweep settled the execution lost and cleared is_running at 13:47:39.83; 3.5 s later the unpaused runner beat turn=e49c060b and re-set is_running true, so the next Send was refused again.

The heartbeat path already refuses a turn the sweep has tombstoned (is_turn_superseded). The gap was that the orphan-collapse path tombstoned only the turns still holding the Redis alive/running keys. A lost turn whose keys a prior Stop settlement had already cleared held nothing to displace, so it was collapsed but never tombstoned, and its returning beat was admitted.

Fix: tombstone the collapsed row's own turn_id unconditionally, alongside any displaced key owners. The tombstone carries the 1-hour superseded TTL, so a runner returning within the hour has its dead-turn beat refused. This uses the mechanism the heartbeat already trusts and adds no read to the beat hot path. A unit test sweeps an orphan holding no Redis keys and asserts the turn is tombstoned.

Note on inc6's session_heartbeat_guard: it is a per-session concurrency mutex that serializes heartbeat ownership changes, not an execution-terminal check, so re-ordering the stack would not have covered this. A durable execution-terminal check in the heartbeat path (refuse a beat whose execution row is already terminal, independent of the tombstone TTL) is the stronger long-term guard, but it needs a new executions read on the beat hot path; the tombstone fix here closes the observed case within the 1-hour window.

Checks: ruff@0.15.12 clean; pytest oss/tests/pytest/unit/sessions 571 passed, 70 skipped (Postgres-gated, unrelated).

…running turn

Symptom. Matrix run 3, cell runner-gone-late, harness codex, provider local. A Stop
settled cleanly: the command read applied/stopped, one terminal record was written,
and the row read is_running false. The runner was then killed. 2.1 s after it
reported healthy, the recovery Send came back with "This session is already running
a turn", although no turn was running anywhere. Session
2fdf43f0-c728-42e5-987e-2371501fe748, first turn 97aa3357, recovery turn c8cd62c7.

Cause. The cell kills the runner with no grace period, so the shutdown handler was
killed while it was tearing down a parked keepalive session and never reached the
step that hands back owner:session:<id>. That key kept naming the dead replica for
the rest of its 120 s lease. claim_owner never steals from a different owner, so the
restarted runner's first heartbeat for the new turn lost the claim and the API
answered is_current_turn false. The runner prints INTERRUPTED for any false
is_current_turn and then refuses admission, which is why the log and the user both
read a live-turn conflict that did not exist. The sweep's owner clear (8b809fa)
does not cover this: it fires only for a turn the sweep declares lost, and this turn
had ended cleanly 19 ms before the kill.

Fix. The owner key says which box is SERVING a session, and only an in-flight turn's
heartbeat ever refreshes it, so a claim held by a replica with no running turn
protects nothing. The heartbeat now takes it instead of refusing for the rest of the
lease. The reclaim runs only from the beat of a real running turn, and only when
`running` is unheld or held by the caller's own turn, which is the same discriminator
the alive-lock handover already uses. It is clear_owner (release-if-owner) then the
ordinary non-stealing claim_owner, so a concurrent claim by a third replica wins and
is reported truthfully. The alive lock stays the single arbiter of one execution per
session and is untouched.

Not behind AGENTA_SESSIONS_DURABLE_STOP. The affinity key and the lock primitives
(claim_owner, clear_owner, get_running_owner) are shared with the legacy path, which
has the identical defect, so gating the fix would leave that path broken.

Scope. A runner killed MID-turn still waits for a sweep tick, not one beat, because
`running` is held with a 3600 s TTL. The sweep's clear covers that case; the two are
disjoint.

Tests. New unit file with 6 tests, no Postgres needed. Three reproduce the live
failure and fail without the reclaim. Three are guards that must pass either way: a
live turn on another replica is still refused, a turn-end beat never reclaims, and a
beat with no turn id never reclaims. api/oss/tests/pytest/unit/sessions reads 577
passed, 70 skipped.

Claude-Session: https://claude.ai/code/session_01GAqSs7fw6QRi2n1ZJ2tmAV
@mmabrouk

mmabrouk commented Sep 4, 2026

Copy link
Copy Markdown
Member Author

Agent-generated, low weight.

Affinity reclaim: the next Send after an ungraceful runner death (15d58ad)

Symptom

Matrix run 3, cell runner-gone-late, harness codex, provider local, on the integration stack.
The Stop worked: the command settled applied/stopped, one terminal record was written, and
the row read is_running: false. The runner was then killed. 2.1 s after it reported healthy,
the recovery Send came back with:

This session is already running a turn. Your message was not sent. Wait for the reply, or stop the turn, then send again.

No turn was running anywhere. Session 2fdf43f0-c728-42e5-987e-2371501fe748, first turn
97aa3357, recovery turn c8cd62c7.

Cause

The cell kills the runner with docker restart -t 0, so the SIGTERM handler was SIGKILLed
while it was tearing down a parked keepalive session and never reached the step that hands back
owner:session:<id> (services/runner/src/server.ts:1319). That key kept naming the dead
replica for the rest of its 120 s lease (services/runner/src/sessions/contract.ts:17), and
REPLICA_ID is a fresh uuid per process (services/runner/src/sessions/alive.ts:46).

claim_owner never steals from a different owner
(api/oss/src/dbs/redis/sessions/locks.py:398), so the restarted runner's first heartbeat lost
the claim and the API returned is_current_turn: false from the affinity guard in
api/oss/src/core/sessions/streams/service.py (lines 590 and 607 on this branch). The runner
prints INTERRUPTED for any false is_current_turn
(services/runner/src/sessions/alive.ts:183 and :191) and then refuses admission with a
message that asserts a live-turn conflict, which is why both the log and the user read a
conflict that did not exist.

The alive-lock path would have been fine on its own. running had been released by the
turn-end beat, so the handover branch would have taken alive from the stopped turn and
admitted the new one. The affinity guard returns before that branch is reached.

The sweep's owner clear (8b809fa) does not cover this. It is gated on the sweep having torn
a stale alive lock off a turn it declared lost, and this turn ended cleanly and reported
stopped 19 ms before the kill, so the sweep never looks at it.

Corroboration in the same log: 0.13 s later the SAME replica beat normally for a different
session, so the refusal was session-scoped state left by the dead replica, not anything
replica-wide and not a turn tombstone.

A note for anyone reading the run matrix: the Pi pass of this cell was not a harness
difference. Both runs hit the same outcome-reported-then-died race. On the Pi run the
keepalive pool happened to be empty, so the shutdown handler reached the affinity release
0.5 ms after SIGTERM and won. A real SIGKILL, an OOM kill, or a crashed node reaches no handler
at all, so the product cannot depend on a graceful shutdown here.

Fix

The owner key says which box is SERVING a session, and only an in-flight turn's heartbeat ever
refreshes it, so a claim held by a replica with no running turn protects nothing. The heartbeat
now takes it instead of refusing for the rest of the lease.

New _reclaim_affinity_from_a_departed_replica, called before the existing refusal, which is
itself unchanged. It runs only from the beat of a real running turn, and only when running is
unheld or held by the caller's own turn. That is the same discriminator the alive-lock handover
already uses, and a running lock held by the caller's own turn is legitimate because
_start_turn arms alive and running before the runner's first beat. The reclaim is
clear_owner (release-if-owner) then the ordinary non-stealing claim_owner, so a concurrent
claim by a third replica wins and is reported truthfully.

The alive lock stays the single arbiter of one execution per session and is untouched. The
owner key is read nowhere else except a diagnostic log in the commands path, so there is no
routing to break.

Flag

Not behind AGENTA_SESSIONS_DURABLE_STOP. The affinity key and the lock primitives
(claim_owner, clear_owner, get_running_owner) are shared with the legacy path, which has
the identical defect. Gating the fix would leave that path broken. This does mean legacy
behaviour changes.

Scope. A runner killed MID-turn still waits for a sweep tick, not one beat, because
running is held with a 3600 s TTL. The sweep's clear covers that case. The two are disjoint,
and neither can do the other's job.

Tests

New api/oss/tests/pytest/unit/sessions/test_heartbeat_departed_replica_affinity.py, 6 tests,
no Postgres needed.

Test Before After
test_next_turn_is_admitted_after_the_owning_runner_is_killed FAIL pass
test_recovery_turn_takes_the_nest_the_dead_turn_left FAIL pass
test_a_turn_that_already_holds_running_may_reclaim FAIL pass
test_a_live_turn_on_another_replica_still_refuses_the_newcomer pass pass
test_a_turn_end_beat_never_reclaims_affinity pass pass
test_a_beat_with_no_turn_never_reclaims_affinity pass pass

api/oss/tests/pytest/unit/sessions reads 577 passed, 70 skipped. The skips are the
Postgres-gated session DAO tests. Ruff 0.15.12 format and check are clean on both files.

Known limits

Three, all rated LOW in review, none blocking.

  1. A turn parked awaiting an approval also holds alive with no running, so on a
    multi-replica deployment a second replica can now take affinity from a live first one and
    the handover then kills the pending approval. Not new: nothing refreshes owner on a parked
    session, so the key expires after 120 s and the same handover follows. The fix makes it up
    to 120 s sooner, on a topology the direct control adapter cannot route to anyway. On one
    replica the caller already equals the owner and the reclaim is never entered. Documented in
    the method docstring.
  2. No test pins the third-replica race inside the gap between clear_owner and claim_owner.
    The code is right, because clear_owner is release-if-owner against the incumbent read a
    moment earlier, but a fourth guard test would lock the two-step in.
  3. The owner key was the last cross-replica discriminator for two runners beating the SAME turn
    id. Turn ids are one per execution and no path re-dispatches one execution to two runners,
    so this is informational.

Follow-ups for other lanes

  1. The refusal message is false for three of its four reasons. The runner asserts "another
    turn owns this session" for every is_current_turn: false, and the user is told to stop a
    turn that does not exist. The API should report a reason and the runner should print it.
    That code is on the admission lane (fix(runner): reject a second turn before touching the sandbox #6500), not here.
  2. releaseOwnedSessions is the LAST step of the runner's shutdown handler, behind a
    keepalive drain that talks to sockets which are already dead
    (services/runner/src/server.ts:1304). Releasing affinity first would cost nothing and
    would win far more often. It is an optimisation, not a substitute for this fix, since it
    cannot cover SIGKILL.
  3. void onCleanup().catch(() => {}) at services/runner/src/server.ts:1275 swallows a
    cleanup failure with no log, which makes this whole class of shutdown bug invisible.

Matrix cells to re-run

runner-gone-late on Codex local with a non-empty keepalive pool (run the full shape in order),
runner-gone-late on Pi local, restart-after-stop on both harnesses, double-send on both as
the guard case, plus stop-warm and concurrent-stops as the standing heartbeat pair. The fix
is API-only, so the runner image needs no rebuild.

https://claude.ai/code/session_01GAqSs7fw6QRi2n1ZJ2tmAV

@mmabrouk mmabrouk left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review summary

I recommend fixing these two correctness risks before merge.

1. Long-running tools are aborted after 30 minutes

Risky: services/runner/src/engines/sandbox_agent/run-limits.ts:36-38

The new defaults treat 30 minutes without an emitted event, or 30 minutes inside one tool call, as proof that the turn is stuck. That is not valid for our supported use cases. A tool can invoke a subagent that works for more than an hour without emitting an ACP event. In that valid flow, the idle timer or per-tool timer aborts the parent turn and records a failure while the tool is still working correctly.

Please do not use a fixed 30-minute silence as proof that a tool is dead. The smallest safe fix for this PR is to disable the idle and per-tool limits by default, or set their defaults at least as high as the supported total turn duration. A better follow-up is a tool-specific policy or a real liveness/progress signal from long-running tools and subagents. Add a test where a tool remains quiet for more than 30 minutes and then completes successfully.

2. A tracing lookup failure can permanently lose the ending

Risky: api/oss/src/tasks/asyncio/sessions/orphan_sweep.py:238-246

When settled_turns() raises, the affected keys enter neither the settled nor unsettled set. The caller still collapses the rows in orphans, so the next watchdog pass cannot select them again. The user can continue, but the previous turn may remain permanently without execution_lost and done.

Please exclude lookup-failed keys from row collapse, Redis cleanup, and tombstoning so a later pass can retry. Healthy projects in the same batch should still proceed. Add a two-pass test where the tracing lookup fails first and succeeds on the next pass.

Reviewed at 15d58add1c88e01e1cd9a8051ab942e6ecb4e6ce.

Symptom: with the runner paused, a watchdog pass settled the execution
lost and settled the Stop command, but session_streams.flags kept
is_running true on the row that still named the dead turn. The pass
logged "settled a session_stream whose runner went silent" for that row.
A read 2.65 s later still saw is_running true, and live forensics found
no other writer.

Cause: the collapse wrote the flags by ORM attribute assignment on the
rows loaded at the top of the pass. TransactionsEngine.session is an
async_scoped_session keyed by the current asyncio task, so every nested
engine.session() in the same pass returns the SAME session and calls
session.close() in its finally. That close expunges every ORM row the
pass had loaded. The records lookup and the command settlement both open
such a nested session before the collapse runs, so by then the rows were
detached, no session tracked the mutation, and the final commit emitted
no flags UPDATE. A Core UPDATE is not tied to ORM instance state, which
is why the command settle's stopping_turn_id write in the same pass
persisted while the flags write vanished.

Fix: capture the id, project_id, session_id and turn_id of each orphan
row as plain values before any nested session runs, then collapse the
rows with one Core UPDATE keyed by those ids. The Redis and watch steps
that follow read the captured tuples, never the detached rows.

Tests: a new Postgres-gated integration test replays the real pass end
to end with the real DAOs on a database it creates and drops per run,
then reads the row back through a fresh session. Against the pre-fix
code it fails, and the SQLAlchemy statement log shows exactly one
UPDATE session_streams in the whole pass, the command settle's
stopping_turn_id, with no flags UPDATE at all. The three fake-session
watchdog suites now apply the Core UPDATE to their in-memory rows.

Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk
@mmabrouk

mmabrouk commented Sep 4, 2026

Copy link
Copy Markdown
Member Author

Agent-generated, low weight.

Pushed 2cbd1d3cdda2511805c1ea63530a9a2b70c07606: the watchdog collapse now persists.

Symptom. With the runner paused, one watchdog pass settled the execution lost and settled the Stop command, but session_streams.flags kept is_running: true on the row that still named the dead turn. The pass logged watchdog: settled a session_stream whose runner went silent for that row. A read 2.65 s later still saw is_running: true, and live forensics found no other writer.

Cause. The collapse wrote the flags by ORM attribute assignment (row.flags = ...) on the rows loaded at the top of the pass. TransactionsEngine.session is an async_scoped_session keyed by current_task, so every nested engine.session() inside the same pass returns the SAME session and calls session.close() in its finally. That close expunges every ORM row the pass had loaded. The records lookup and the command settlement both open such a nested session before the collapse runs, so by then the rows were detached, no session tracked the mutation, and the final session.commit() emitted no flags UPDATE. A Core UPDATE is not tied to ORM instance state, which is why the command settle's stopping_turn_id write in the same pass persisted while the flags write vanished.

Fix. Capture each orphan row's id, project_id, session_id and turn_id as plain values before any nested session runs, then collapse the rows with one Core UPDATE keyed by those ids. The Redis and watch steps that follow read the captured tuples, never the detached rows. No behaviour change beyond persistence: the collapsing set and the doomed_turns tombstones carry the same values as before.

Tests.

  • New api/oss/tests/pytest/unit/sessions/test_watchdog_collapse_persistence.py, Postgres-gated. It creates and drops its own database per run, so it pollutes nothing. It seeds one alive-and-running stream naming a turn with a stale heartbeat and a pending Stop, runs one real run_orphan_sweep with the real DAOs and services, reads the row back through a fresh session, and asserts is_alive false, is_running false, stopping_turn_id NULL, the execution lost by watchdog, and the command obsolete/lost.
  • Against the pre-fix sweep that test fails on assert flags["is_alive"] is False, and the SQLAlchemy statement log for the whole pass shows exactly one UPDATE session_streams, the command settle's stopping_turn_id. The flags UPDATE was never emitted. Post-fix the log shows both.
  • oss/tests/pytest/unit/sessions is 648 passed, 0 skipped. ruff@0.15.12 format --check and check clean on the five changed files.

Two notes for whoever rebases.

  • feat/session-durable-approvals (inc6) already rewrote this collapse as a per-row guarded Core UPDATE and added synchronize_session=False in 2841306a99. That branch is stacked on this one, so the rebase conflicts in three hunks around the collapse and the Redis follow-up loop. Keep the inc6 version: it is a strict superset, guarded on turn_id and updated_at with a rowcount check.
  • The newly_lost is_running clear a few lines above still mutates ORM rows by attribute. It is safe today only because those rows are re-selected after the last nested session and nothing nested runs before the commit. Anything that adds a DB call in between would break it the same way.

The collapse was moved to a Core UPDATE in 2cbd1d3. The other write to
session_streams in the same pass, the is_running clear for a turn settled
lost whose row the collapse does not own, still went through ORM
attribute assignment. That write was correct only by adjacency: its rows
are re-selected after the last nested engine.session(), and no database
call runs between that select and the commit.

Adjacency is not a property anyone can see. engine.session() is an
async_scoped_session keyed by current_task, so any nested call closes the
shared session and detaches those rows, and the write is then dropped at
commit with no error and no log line. One new database call between the
select and the write reintroduces finding 7 on this branch.

Capture the row id, project id, session id and new flags as plain values,
then perform both session_streams writes from those values just before
the commit, each through a Core UPDATE with synchronize_session=False. No
ORM attribute write on session_streams remains in the sweep. The write is
buffered until the commit either way, so no observable ordering changes.

Tests: a second Postgres-gated test seeds a row that beats normally and
names a turn already settled lost, which is the branch the clear owns. It
patches release_alive to open and close a nested engine.session() between
the row load and the write, then reads the row back through a fresh
session and asserts is_running false with is_alive kept. Restore the ORM
attribute write in this layout and that test fails on is_running still
true. The three fake-session watchdog suites now apply a Core UPDATE
keyed by a single id as well as by a list.

Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk
@mmabrouk

mmabrouk commented Sep 4, 2026

Copy link
Copy Markdown
Member Author

Agent-generated, low weight. Follow-up to the comment above.

Pushed 2613968468ba7fc204b2828137206ecd8f063425, which closes the residual I flagged. The newly_lost is_running clear no longer writes by ORM attribute. Its row id, project id, session id and new flags are captured as plain values, and both session_streams writes now happen from those values just before the commit, each as a Core UPDATE with synchronize_session=False. No ORM attribute write on that table remains anywhere in the sweep. The write was buffered until the commit either way, so nothing observable reorders.

One correction to what I wrote above. That clear never failed in production. It was correct by adjacency: its rows are re-selected after the last nested engine.session(), and no database call ran between that select and the commit. The risk was that adjacency is invisible, so one new call in that gap would have reintroduced finding 7 silently.

A second Postgres-gated test pins the property. It seeds a row that beats normally and names a turn already settled lost, which is the branch the clear owns, patches release_alive to open and close a nested engine.session() between the row load and the write, and reads the row back through a fresh session. It asserts is_running false with is_alive kept. Restoring the ORM attribute write in this layout makes it fail on is_running still true, so it has teeth. oss/tests/pytest/unit/sessions is 649 passed, 0 skipped, and ruff@0.15.12 format --check and check are clean on the five changed files.

Prove that a conflicting execution authority rolls back the command transition, and that replaying the winning execution outcome reports only the original insert as the CAS winner. Pin watchdog record lookups to one batch per project and exercise the production flag-off quarantine path with the execution DAO wired.

Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk
Show that an abandoned run remains alive to execute its own teardown when it eventually settles. Pin the sandbox health URL derivation for both local and Daytona inspector URL shapes.

Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk
Symptom: on Daytona, deleting the sandbox under a running turn did not end the
turn. The runner kept sending running=true heartbeats for five minutes, the row
kept reading is_running with no stopping_turn_id, and the turn ended only when
the runner process took SIGTERM. The same cell passes on every local provider.

Cause: two blind spots meet. The liveness probe counts any HTTP answer as alive
by design, and Daytona keeps its proxy host up after a sandbox is deleted and
answers 404 with x-daytona-error-code: SANDBOX_NOT_FOUND, so the probe never
counted one failure. The transport did see the truth, but the ACP client calls
failReadable on that 404 and the protocol SDK's read loop never rejects its
pending responses, so the session/prompt promise the turn awaits stayed pending
and the escaped rejection was only logged as an unhandled rejection.

Fix: treat a provider answer that names THIS SANDBOX as gone as a verdict rather
than a network symptom. sandbox-gone.ts recognises it, narrowly: the answer must
be an HTTP error, and either the provider's own error code names the sandbox or
the error body does. A bare 404 and a 401 still count as alive, and the
resumable SANDBOX_STOPPED and SANDBOX_ARCHIVED states do not count as death. The
liveness probe now ends the turn on the first such answer instead of after three
failures, and the turn's own ACP fetch reports the same answer onto a per
environment latch the probe fires on at once. The latch is armed only after
acquireSandbox resolves, because the SDK's health wait rides the same fetch and
tolerates a provider error by design while a sandbox comes up. The probe hands
its listener back on dispose, so a warm environment keeps no finished turns.

The turn then ends through the run-limit trip path every other limit uses, so it
writes one error terminal with code sandbox_gone. A normal Stop, warm parking
and native session reuse are untouched.

Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk
@mmabrouk

mmabrouk commented Sep 4, 2026

Copy link
Copy Markdown
Member Author

Agent-generated, low weight.

Pushed 1ddea5e81e on top of 1d84cd4bec. It closes the sandbox-gone matrix cell on Daytona (Pi, run 5, session f2de65b0-0ddf-4a24-b990-d8ec36cd0b8c).

Symptom. Deleting the Daytona sandbox under a running turn did not end the turn. The runner sent running=true heartbeats for five more minutes, the row kept reading is_running with stopping_turn_id: null, and the turn ended only when the runner process took SIGTERM. The same cell passes on Pi local, Codex local and Claude Code local.

Cause. Two blind spots meet.

  • The liveness probe counts any HTTP answer as alive by design, and Daytona keeps its proxy host up after the sandbox is deleted. It answered 404 with x-daytona-error-code: SANDBOX_NOT_FOUND for that host, so the probe never counted a single failure and onGone never fired. A local sandbox refuses the socket instead, which is why every local provider passes.
  • The transport did see the truth and could not act on it. acp-http-client calls failReadable on the 404, and the protocol SDK's read loop (@agentclientprotocol/sdk/dist/acp.js:738) has a try with only a finally, so it never rejects #pendingResponses. The session/prompt promise the turn awaits stayed pending, and the escaped rejection was only logged as an unhandledRejection.

Fix. Treat a provider answer that names THIS SANDBOX as gone as a verdict rather than a network symptom.

  • New sandbox-gone.ts recognises it, narrowly. The answer must be an HTTP error, and either the provider's own error code names the sandbox or the error body does. A bare 404 and a 401 still count as alive, and the resumable SANDBOX_STOPPED and SANDBOX_ARCHIVED states are not death.
  • The liveness probe ends the turn on the first such answer instead of after three failures, and its probe is now optional.
  • The turn's own ACP fetch reports the same answer onto a per-environment latch the probe fires on at once, so the turn ends in milliseconds rather than one probe interval later.
  • The latch is armed only after acquireSandbox resolves. The SDK's health wait rides the same fetch and tolerates a provider error by design while a sandbox comes up, and the latch is one-way, so a report from that window is discarded.
  • The probe hands its listener back on dispose, so a warm environment keeps no finished turns.

The turn then ends through the run-limit trip path every other limit already uses, so it writes one error terminal with code sandbox_gone. A normal Stop, warm parking and native session reuse are untouched.

Tests. pnpm run typecheck clean. pnpm run test:unit -- --maxWorkers=3: 163 files, 2725 tests, all pass. 28 are new, across tests/unit/sandbox-gone.test.ts (new), tests/unit/sandbox-liveness.test.ts and tests/unit/sandbox-agent-acp-fetch.test.ts. They cover recognition in both directions, the startup window (the same gone header must not latch before acquire and must latch after), the unsubscribe, and the one-death rule.

Two follow-ups this does not fix.

  1. The 2 minute time-to-first-byte deadline did not fire on this turn. It is armed at run start and the turn never received a first byte, so it should have ended the turn at about 16:09:56. The whole runner log holds zero [run-limits] lines. Only a first progress event through runLimits.wrapEmit or a real human pause disarm it, and neither happened.
  2. sdks/python/agenta/sdk/agents/adapters/sandbox_agent.py:145 defaults its idle read timeout to 180 s, while sdks/python/agenta/sdk/agents/utils/ts_runner.py:16 documents that it must stay strictly wider than the runner's own deadline so the runner always trips first, and uses 360 s. That 180 s is what ended the client's stream at 16:10:58 with a generic "agent run failed" while the runner kept beating.

Symptom: an isolated re-run showed the full cost of the gap. The sandbox was
deleted at 16:26:31, the runner's own socket was told SANDBOX_NOT_FOUND at
16:26:37, and the turn still beat running=true for thirty minutes. What ended it
was the 30 minute per-tool-call deadline, and only then did the turn's error and
done records persist, 27 minutes after the client had given up.

Cause: everything downstream of the turn ending was already correct. The error
terminal, the records, the running=false beat that clears the row and the
teardown all happen within two seconds of the trip. The only defect was WHEN the
turn ended, so the previous commit's unit tests pinned the trigger without
showing the terminal it produces.

Fix: drive the whole path through the real environment wiring with a fake
socket. One test proves a provider answer naming the sandbox ends the turn as a
sandbox_gone error terminal and reclaims the sandbox in the teardown. Its
control proves an ordinary 502 on the same socket leaves the turn running, so a
proxy blip cannot kill a healthy run.

Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk
@mmabrouk

mmabrouk commented Sep 4, 2026

Copy link
Copy Markdown
Member Author

Agent-generated, low weight. Follow-up to my previous comment, with one correction to it.

Pushed 755ef53300. Tests only, no source change.

Correction. My first follow-up said the 2 minute time-to-first-byte deadline was dead. That was wrong, and I withdraw it. The isolated re-run's runner log shows what actually ended the turn:

16:56:33.289  [run-limits] tool call call_JNdz...|fc_0474... exceeded 1800000ms

The harness had opened a tool call before the sandbox died. noteToolCallStart calls noteProgress, which correctly disarms time-to-first-byte and arms the 30 minute per-tool-call and idle timers. The sandbox then died mid tool call, so the only remaining deadline was 30 minutes away. Nothing is broken there. That also explains run 5, where SIGTERM arrived at minute 5 of the same 30 minute wait.

What the isolated re-run adds. Everything downstream of the turn ending was already correct. From the trip, the whole chain completes in 2.2 seconds:

offset from the trip event
+0.0 s [run-limits] trips, the race returns RUN_LIMIT_TRIPPED
+1.8 s [keepalive] evict ... reason=no-park:failed
+1.9 s ingest OK ... type=tool_call, type=error, type=done
+2.2 s heartbeat OK ... running=false

So the eviction is not the cause, it is one step of the same teardown. The single defect was WHEN the turn ended, and that is what this branch moves from minute 30 to second 6.

The four effects, and where each happens.

  1. The heartbeat stops. Covered. startAliveWatchdog's interval is cleared only by release() in the run's finally (sessions/alive.ts:240 and :269), which is reached once the prompt race settles. onGone now settles it in milliseconds.
  2. The error and done records persist at that moment. Covered. The trip throws into the shared catch at run-turn.ts:1520, which emits the error terminal with classified.code. The new test in sandbox-agent-orchestration.test.ts asserts that terminal is sandbox_gone and that the teardown reclaims the sandbox, with a 502 control proving a proxy blip does not end a healthy turn.
  3. The row reads is_running false. Covered. release() sends the final is_running=false beat, and the API's release_running branch (streams/service.py:878) clears it. is_alive staying true is by design, not a defect — that same branch says alive outlives the turn on its own TTL and is cleared only by a kill, which is what makes a session reattachable. The watchdog's lost-settle is the only other writer. I would not change it, and I did not.
  4. Ownership released so a new Send is admitted. Covered, indirectly and by the existing design. Per-turn ownership is never released. release_owner is only the runner's process-shutdown beat (streams/service.py:673). What actually gates a new Send is running: on the runner-minted turn path the first heartbeat reaches the stale-alive handover at streams/service.py:800-850, and it refuses only when running_owner is a different live turn. During the phantom, running was held by the dead turn for 30 minutes, so a new Send was refused for 30 minutes. Once the turn ends, running is released by the same final beat as effect 3.

That also answers the open question in the re-run's note. Yes, a Send was refused for the whole window, and on the _start_turn path (streams/service.py:1237) acquire_alive is nx=True, so it raises SessionTurnInUse.

Tests. pnpm run typecheck clean. pnpm run test:unit -- --maxWorkers=3: 163 files, 2727 tests, all pass. 30 new across the two commits.

Standing follow-up, now the only one. sdks/python/agenta/sdk/agents/adapters/sandbox_agent.py:145 defaults its idle read timeout to 180 s, while sdks/python/agenta/sdk/agents/utils/ts_runner.py:16 documents that it must stay strictly wider than the runner's own deadline so the runner always trips first, and uses 360 s. That 180 s is what ends the client's stream with a generic "agent run failed" while the runner is still working. With this branch the runner now trips in seconds for a dead sandbox, so the two no longer race in this scenario, but the mismatch stands for every other slow turn.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

changes requested lgtm This PR has been approved by a maintainer

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant