fix(watchdog): stop false-orphaning executions parked before the agent spawns them (abilityai/trinity#2433) - #2435
Conversation
…t spawns them
The cleanup watchdog's proof-of-life (GET agent/api/executions/running:
running ∪ recently-completed) could not see an admitted execution that was
waiting in the backend's global agent-call queue, in the agent's CPU-sized
default thread pool, behind the agent chat lock, or in the post-exit drain
before unregister(). After the 60s grace it wrote a false `failed`
("completed on agent but status not reported"), released the slot, and the
parked call then ran anyway — billed, overbooked, its late 200 silently
overwriting the row (#378). Reproduced twice locally; three mechanisms, one
string.
Orphan now means: the agent does not know the execution AND no live backend
dispatcher owns it.
- agent server: /api/executions/running gains `pending_ids` (accepted at
/api/task, /api/chat and the #1083 async spawn but not yet spawned; lazily
expired) and `recently_completed_ids` covers exited-but-registered handles.
Cancel-while-pending is consumed by register() (SIGKILL at spawn, #679 marker
kept); the pre-spawn 409 is only an optimisation. Headless runs use a
dedicated 32-thread pool pinned to MAX_PARALLEL_TASKS_CEILING_MAX; the Gemini
runtime now registers its subprocess at both Popen sites (it never did).
- backend: every outbound agent call is registered for its whole lifetime
(track_inflight_dispatch — queue wait, connect retries, POST) in an
in-process registry plus a cross-worker Redis liveness marker
execution:inflight:{id} (60s TTL, one refresher task per process, 15s tick).
The watchdog reads a tri-state verdict (alive / absent / unknown) and
withholds recovery on `alive`, and on `unknown` only while a dispatcher could
still own the row; a process with no Redis reads `absent` (its own registry
is the whole truth). CleanupReport.dispatch_inflight_skipped counts withheld
rows; the orphan error string states what was observed.
- a park no longer spends the run's budget: at grant, a park ≥ 5s restamps
started_at (admission kept in queued_at, the drained-backlog shape, CAS on
RUNNING + NULL lease) and renews the slot lease (ZADD XX + EXPIRE together);
the refresher renews the slot every tick while parked.
- parked rows are cancellable and agent-scoped: terminate consults the
in-process registry, then the cross-worker cancel key; a parked phase is
finalized CANCELLED and the grant raises BackendAgentCallCancelled, where the
dispatcher writes CANCELLED itself (never FAILED; the /chat arm answers 409).
- terminate_execution gains ONE agent-scope gate at its entry for all three
arms: the row behind the caller-supplied task_execution_id must belong to the
agent the route proved (uniform 404; an unreadable row fails closed with
503). The proxy arm's 404 scoped only execution_id while the CANCELLED CAS
was keyed on task_execution_id, so a caller authorised on agent A could flip
agent B's running row (found by the /cso --diff verifier; report under
docs/security-reports/).
- packaging: BACKEND_AGENT_CALL_LIMIT / BACKEND_AGENT_CALL_QUEUE_TIMEOUT_S
forwarded in prod + hosted compose and documented in .env.example; the >5s
queue-wait warning fires on both acquire branches.
Verified: full unit suite under CI conditions 12969 passed / 0 failed
(baseline origin/dev 12863 / 0); Repro A 10/10 success (2 parked 485s,
withheld at both watchdog cycles, re-anchored at dispatch); Repro B 8/8
success (5 parked, two waves); live pending_ids probe on the agent. The
agent-side half needs a rebuilt base image; the backend half alone covers old
images through the whole-call marker.
Fixes #2433
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
dolho
left a comment
There was a problem hiding this comment.
Review — #2435 (fix #2433)
Read the whole diff, built a worktree off cfc2cfef, and ran the suites locally.
Verification I did
pytest tests/unit/test_2433_*.py→ 106 passedpytest tests/unit -k "cleanup or watchdog or slot or limiter or terminate or 679 or 921 or 1332 or 1094 or 2127 or schedule_status or 226 or 1804"→ 534 passed, 2 skipped- Verified the
duration_msclaim end-to-end: it is computed inupdate_execution_statusfrom the DBstarted_at(db/schedules/executions.py:439), not from the in-coroutinestart_time(task_execution_service.py:1164, which is stamped before capacity acquire and still spans the park). So the restamp genuinely fixesduration_ms— the PR body is right, and the reason is worth keeping in the docstring because the localexecution_time_msstill measures the park. - Redis ACL: both
~*, so the newexecution:*keyspace is not blocked.agent_runtime_stateexemption note is correct — the parity test only grepsagent:*.
Overall: the diagnosis is right and the two-sided design (pending_ids on the agent + a live-dispatcher marker on the backend) is the correct shape — proof-of-life had exactly one half. The tri-state alive/absent/unknown and the "a read that could not be asked ≠ a read that said no" rule are applied consistently, and the terminate_execution agent-scope gate is a real fix on a real hole. Three things below I'd want addressed before merge; the first is a new race, the second turns a pre-existing leak into a permanent one.
1. Cross-worker cancel reads a marker phase that is up to 15s stale → cancel-without-terminate on a running turn
acquire_agent_call_slot flips entry.phase = "calling" in memory at grant, but nothing writes the marker at that transition — the payload is only rewritten by _refresher_loop on its next INFLIGHT_TICK_SECONDS (15s) tick. So for up to 15s after a park→dispatch, execution:inflight:{id} still says "phase": "parked".
_cancel_inflight_if_parked trusts that value:
phase = agent_call_limiter.cancel_inflight(eid, agent_name=name) # in-process: accurate
if phase is None:
phase = await agent_call_limiter.request_cross_worker_cancel(...) # marker: up to 15s stale
if phase != "parked":
return NoneWith --workers 2 (prod), roughly half of cancels land on the worker that does not own the coroutine, so they take the marker path. If the cancel arrives in that 15s window:
terminate_executionreturnscancelled_while_parkedand never reaches the proxy arm — the agent is never asked to terminate;- the row is written
CANCELLEDand the capacity slot is released viarelease_if_matches; - the other worker is already inside
client.post(...)— its grant-time cancel check has already passed — so the agent runs the full turn, billed, with its slot given away (overbooking); - when the 200 comes back, the SUCCESS write loses the CAS to the standing
CANCELLED, so the work is silently discarded.
That is the #378 symptom this PR exists to remove, reproduced in a narrower window. The in-process arm is fine — cancel_inflight reads the live entry.phase; it is only the marker arm that can lie.
Cheapest fix: make the parked→calling flip write the marker synchronously (one SET in a to_thread at grant, alongside the existing _cancel_requested_cross_worker_sync round-trip you already pay for on a ≥5s park), so the marker can only ever be stale in the safe direction. Belt-and-braces alternative: on the cross-worker path, set the cancel key and then fall through to the proxy arm regardless of reported phase — a genuinely parked execution 404s on the agent, so falling through costs nothing and the grant-time check still catches the true park.
2. list_recently_completed_ids now reports exited-but-registered ids with no age bound
The new union is right in principle — an exited process whose owner hasn't reached finally: unregister() is semantically "completed, not yet reported". But _recently_completed has RECENTLY_COMPLETED_TTL_SECONDS = 300, and this new set has no clock at all:
for eid, entry in self._processes.items():
if entry["process"].poll() is not None:
ids.add(eid)PoC against this branch:
running ids: []
recently_completed (t=0): ['leaked-eid']
recently_completed after clearing the 300s TTL buffer: ['leaked-eid']
A registry entry that leaks — an exception between register() and the finally: unregister() — is now reported to the backend as agent-known forever, so the orphan watchdog never recovers that row. Before this change the same leak was self-healing: list_running() filters on poll() is None, so the row was still orphan-eligible. The registry-blind Phase-1 stale sweep is still the backstop, so this is not unbounded — but it means a leak now costs the full 120-min sweep with a fabricated duration_ms instead of one watchdog cycle.
And the PR makes that leak newly reachable rather than theoretical. In both gemini_runtime.execute() and execute_headless() the new register() sits before process.stdin.write(prompt), while the finally: unregister() only wraps the run_in_executor further down:
get_process_registry().register(_registered_id, process, metadata={...})
process.stdin.write(prompt) # <-- exception here leaks the entry permanently
process.stdin.close()The new kill-at-spawn path makes an exception there likely: register() SIGKILLs the group, control returns, and the very next statement writes to a pipe whose reader is gone → BrokenPipeError. claude_code.py has the same register-before-stdin-write shape (pre-existing, its try/finally starts after the write), so it inherits the same newly-permanent consequence.
Two small changes cover it: bound the exited-but-registered set by entry["started_at"] against RECENTLY_COMPLETED_TTL_SECONDS (the entry already carries the timestamp), and move Gemini's register() inside the block the finally: unregister() guards.
3. restamp_execution_dispatch runs sync sqlite on the event loop
In _on_dispatch_granted:
restamped = db.restamp_execution_dispatch(execution_id) # sync DB, on the loop
...
renewed = await asyncio.to_thread(get_slot_service().renew_slot, ...) # correctly off-loopagent_call_limiter's own module docstring exists because sync sqlite inside an async coroutine stalls the loop, and this write happens while both semaphores are held, at the moment the queue is by definition congested. The line immediately below already does the right thing. Wrap it the same way.
Smaller things
/api/chatpending entries expire well before a chat can (chat.py) —execute_taskpassestimeout_seconds=request.timeout_seconds or 900, but the chat handler passes none, so the entry gets the 900s default → a 960s deadline.ChatRequestcarries notimeout_secondsfield at all (models.py:19-24), and a chat waiting on the execution lock behind a long turn can legitimately wait up to the agent'sexecution_timeout_seconds(7200s ceiling). The entry is then evicted mid-wait andpending_idsstops covering it. The backend in-flight marker covers this case today, so it is not a live regression — but the defence-in-depth layer silently stops defending, which is worth a comment at minimum and a plumbed timeout ideally./api/chatfinally: discard_pendingis inside the lock,register_pendingis outside it — a request cancelled while waiting onget_execution_lock()(client disconnect) never discards. Backstopped by the deadline; still, movingregister_pendingunder the sametrywould make the pairing structural._process_stale_slot_reclaimsdoes one_inflight_verdict_map([execution_id])per row inside the loop — i.e. one Redis MGET round-trip per candidate — while_reconcile_orphaned_executionsdeliberately batches into one. Under a backlog those are the same cycle. Worth batching for symmetry.register_pendinglogs at INFO unconditionally, so every/api/taskand/api/chatnow emits an extra line into Vector. DEBUG seems right for the happy path; the eviction WARNING is the line that matters.renew_slotmoves the ZSET score before re-EXPIREing the metadata hash. If the hash has already expired,zadd XXstill succeeds and the function returnsTruewhileexpireis a no-op — leaving exactly the ZSET-without-hash state canary S-03 reports asmissing. Not introduced here (the hash can expire first today), butrenew_slotcan now perpetuate it and reports success. Ahset-if-missing, or returning False when the hash is gone, would keep the return value honest.prod/hostedrunmaxmemory-policy allkeys-lru, so an in-flight marker can be evicted before its TTL under memory pressure →absent→ false orphan. Same exposure the slot ZSETs already have, so not a blocker; worth one line in the architecture block so the next reader knows the marker is not eviction-proof.
Things I checked and am happy with
terminate_execution's entry gate is correctly placed above all three arms, fails closed on an unreadable row (503), uniform-404s a foreign row, and coversgetattr(..., None) != nameso a row object missing the attribute also refuses. All three routers (chat.py,public.py,client_portal/service.py) funnel through it, so the fix is complete, andtest_proxy_arm_cannot_flip_a_foreign_row_via_task_execution_idreplays the actual exploit.restamp_execution_dispatch'sfunc.coalesce(queued_at, started_at)in the SET clause reads the pre-update row on both dialects — correct, and the CAS onRUNNING + lease_expires_at IS NULLcorrectly leaves pull-mode rows to the lease reaper._inflight_verdict_map's "anything that is not a dict of known verdicts collapses toabsent" guard, and the eager module-level import with the stub-leak rationale, are the right lesson applied — a leakedsys.modulesMagicMock here would have silently disabled the whole watchdog.BackendAgentCallCancelledas a subclass so every existingexcept BackendAgentCallBudgetExhaustedkeeps working, with the terminal branched to CANCELLED — good, andemit_task_terminal_eventalready maps every non-SUCCESS terminal toagent.task.failedwith the precise status in the payload, so no event-vocabulary change is needed.register_inflightdoing_PENDING_DELETES.discard(execution_id)— that is what keeps the #678/SUB-003 same-execution-id retries from having their fresh marker deleted by the previous attempt's queued delete. Easy to miss, correctly handled._get_client(use_negative_cache=False)on the watchdog read only, with the reason stated — right call; the negative cache exists for the 15s refresher, not for a 5-min sweep.- Packaging parity (
prod+hosted+.env.example) with a test pinning it — the #1039 class closed properly rather than only in the file that was noticed.
Nice work on the write-up and the repro evidence; the reasoning in the docstrings is genuinely the useful kind. The stale-marker race (1) is the one I'd insist on before merge.
…d-but-registered set (#2435 review) Review of the #2433 fix found that it reintroduced the #378 symptom in a narrower window and turned a pre-existing registry leak into a permanent one. 1. Cross-worker cancel acted on a marker phase that predated its own write. `entry.phase` flipped parked->calling in memory only; the marker was rewritten by the 15s refresher, so `execution:inflight:{id}` advertised `parked` for up to a full tick after the POST had begun. Under --workers 2 about half of all cancels are served by the worker that does NOT own the coroutine and therefore read it: the row was finalized CANCELLED and its slot released while the agent ran the turn to a billed completion whose SUCCESS then lost the CAS. Closed by ordering, not by narrowing — the owner publishes the transition in the SAME round-trip that reads the cancel key (`_publish_calling_and_check_cancel_sync`), and the remote sets the cancel key BEFORE re-reading the phase (`_set_cancel_then_reread_phase_sync`), so an observed `parked` gives W_remote(cancel) < R_remote(marker) < W_owner(marker) < R_owner(cancel) and the grant is guaranteed to see the key. Neither side pays an extra round-trip. The owner gates the publish on the ENTRY's age rather than this attempt's park, because `track_inflight_dispatch` wraps the whole retry loop and a retry can grant instantly under a marker a tick left saying `parked`; the remote's scope check stays on its first read, so no key is written for a foreign agent. 2. `list_recently_completed_ids` reported exited-but-registered ids with no age bound, so a leaked entry was agent-known forever and the watchdog never recovered that row — a regression against pre-#2433, where `list_running()` self-healed it. Now bounded by the same 300s TTL as the buffer, measured from when the exit was first OBSERVED (not `started_at`, which would drop a long turn the moment it entered its drain). The leak is also closed at source: `register()` SIGKILLs the group for a cancel that arrived while pending, so the following `stdin.write` can raise BrokenPipeError — all three prompt-writing runtimes (claude_code, gemini x2) now pair that write with `unregister()` on failure. 3. `restamp_execution_dispatch` is a sync sqlite write and ran on the event loop, while both semaphores are held and the queue is by definition congested. Now `asyncio.to_thread`, like the slot renewal beside it. Smaller items from the same review: - /api/chat sizes its pending entry to PENDING_CHAT_TIMEOUT_SECONDS (7200s): `ChatRequest` carries no timeout and a chat can wait on the execution lock for the agent's whole budget, so the /api/task default evicted the entry mid-wait. Its discard now wraps the lock acquisition, so a request cancelled while waiting (client disconnect) cannot leak one. - Phase 3 batches its in-flight verdict read (one MGET per cycle, not per row), matching Phase 0. - `renew_slot` refuses, score untouched, when the metadata hash has already expired: `ZADD XX` succeeds while `EXPIRE` no-ops, so it used to report a renewal it had not performed and re-anchor exactly the ZSET-without-hash state canary S-03 calls `missing`. - `register_pending` logs at DEBUG (it fires on every /api/task and /api/chat). - Documented that the in-flight marker is not eviction-proof under the prod `allkeys-lru` policy. Tests: tests/unit/test_2433_review_fixes.py (15) — 11 of them fail against cfc2cfe, verified in a worktree. Full unit suite under CI conditions (clean origin/dev worktree, no submodules): 12985 passed, 0 failed. Refs #2433 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Thanks — the verification you did (worktree off 1. Stale marker phase → cancel-without-terminate — fixed, and closed rather than narrowedConfirmed exactly as you traced it. I took the cheap fix you suggested but ordered both sides, because the synchronous write alone still leaves a window: if the remote reads the marker before the owner's write and then writes the cancel key after the owner's read, the owner dispatches and the remote finalizes CANCELLED anyway. It is microseconds instead of 15s, but it is the same bug. The fix is store-then-load on both sides: If the remote observes Two details worth flagging:
I did not take the fall-through-to-proxy alternative: for a genuine park the agent 404s, so the user gets 2. Unbounded exited-but-registered set — fixed, plus the source of the leakBounded by the same You were right that this PR made the leak reachable rather than theoretical, so I also closed it at source: 3. Sync sqlite on the event loop — fixed
Smaller things
Verification
Docs updated in the same commit: |
dolho
left a comment
There was a problem hiding this comment.
Re-review — da1c0b07
Built a worktree off the new head, and a second one off cfc2cfef to check the claims against the code they replace rather than against their own description.
All three blockers are closed, and the reply's claims hold where I checked them.
| Claim | Verified |
|---|---|
"11 of 15 fail against cfc2cfef" |
Ran the new file in a cfc2cfef worktree: 11 failed, 4 passed — exact. Real regression tests, not restatements |
| Ordering closes the race, not narrows it | _marker_payload reads entry.phase, and the flip to "calling" precedes the pipeline, so the published value is genuinely calling |
"gates on the ENTRY's age, _tick's own filter" |
_tick: (now - e.registered_at) >= INFLIGHT_MARKER_GRACE_SECONDS; grant: entry_age >= INFLIGHT_MARKER_GRACE_SECONDS. Same constant, and both on time.monotonic() — the equivalence is real, not approximate |
| Exit bound measured from first observation | Stamped under self._lock, and register() builds a fresh dict so a re-registration cannot inherit a stale exited_seen_at |
| Codex not exposed | stdin=subprocess.DEVNULL, no write |
"--ignore-all-space shows only the intended lines" |
156 changed lines → 13 insertions, 5 deletions. Exactly the re-indent claimed |
restamp off the loop |
await asyncio.to_thread(db.restamp_execution_dispatch, …) |
The store-then-load argument is right, and I like that the scope check stays on the first read — writing before verifying would have let a caller authorised on agent A poison agent B's cancel key, which is a fresh hole in the fix for a different one.
Two things left. Neither blocks; the first is a claim that is wider than the code.
1. The AST guard enumerates two files — it does not discover, so a fourth runtime can ship the unpaired shape
"There's an AST guard so a fourth runtime can't ship the unpaired shape."
_stdin_write_is_guarded is sound, but its caller is a hardcoded list:
@pytest.mark.parametrize("rel", [
"docker/base-image/agent_server/services/gemini_runtime.py",
"docker/base-image/agent_server/services/claude_code.py",
])Proven — dropped a mistral_runtime.py into agent_server/services/ with register() followed by a bare process.stdin.write(prompt):
2 passed, 13 deselected
The guard did not notice. That is the class this repo keeps re-learning: #1677's caller-parity guard walks every call site for exactly this reason, and Invariant #5 records "a guard that walks only one of the two trees is not a guard". Here it walks two of N.
Fix is small — glob agent_server/services/*_runtime.py plus claude_code.py and assert over what it finds. One adjustment when you do: _stdin_write_is_guarded returns total > 0 and guarded == total, so a discovered file with no stdin write would read as a failure; that needs to become "vacuously true when total == 0".
The three real sites are correctly paired (except BaseException: unregister(); raise — and BaseException is the right width here).
2. The publish half fails soft in the unsafe direction, and the docstring only describes the other half
"Fails soft — a Redis error reads as 'no cancel', exactly as the bare read it replaces did."
True of the read. The write is new, and its failure has a consequence the bare read never had:
except Exception as e:
_note_redis_failure(e)
return False # -> `cancelled` stays False -> the POST proceedsOn a transient pipeline error the marker is never republished, so it keeps saying parked — and if the remote worker's own pipeline succeeds where the owner's failed, the remote reads parked, finalizes CANCELLED, releases the slot, and the owner POSTs anyway. That is the original symptom, on the Redis-error path.
Narrow, and worth saying why: a hard unavailability is safe by construction — _get_client() is None in the owning process means its refresher never wrote a marker either, so the remote's first read returns None and routes through the agent. It needs a per-connection transient failure on one worker while the other is healthy.
Not asking for a mechanism — a retry cannot close it either. Asking for the docstring to say it, because the reply is otherwise scrupulous about exactly this shape ("true of the path it tested and false of the other one"), and a future reader will take "fails soft" as covering both halves.
Also verified
renew_slotreads the metadata hash first and refuses when it is gone — so it neither reports a renewal it did not perform nor re-anchors S-03'smissingstate, and it deliberately does not rebuild the hash (itstimeout_secondsis unknowable from there). Correct on both counts.PENDING_CHAT_TIMEOUT_SECONDS = 7200is plumbed through to theregister_pendingcall, not just defined.- The
finallynow wraps the lock acquisition, so a client disconnect during the wait cannot leak a pending entry — structural, as claimed. da1c0b07still test-merges clean against currentdev(3 commits ahead;architecture.mdthe only overlap).tests/unit/test_2433_*.py→ 122 passed on this head; the wider blast radius (cleanup / watchdog / slot / limiter / terminate / 679 / 921 / 1332 / 1804 / process_registry / schedule_status) → 554 passed, 2 skipped.
Recommendation
Approve once #1 is addressed — it is a five-line change to the guard, and the guard is the only thing standing between this fix and its own recurrence. #2 is a docstring sentence. Everything substantive in the three blockers is genuinely closed, and closed with tests that fail against the commit they fix.
vybe
left a comment
There was a problem hiding this comment.
Validated: root-caused watchdog false-orphaning fix (#2433) with extensive per-surface tests (14 new test files incl. cross-worker cancel race and slot-renew coverage); pg-migrations green; full pytest matrix green. Merged origin/dev to resolve the learnings.md append conflict (kept both sides).
…d two residuals Follow-up to #2435, whose re-review landed after the PR had already auto-merged. Three things, all of them corrections to claims that shipped wider than the code. 1. The guard enumerated two files, so it did not guard. Proven by dropping a `mistral_runtime.py` into the tree with an unpaired `stdin.write`: "2 passed, 13 deselected". It now DISCOVERS — every `agent_server/**/*.py` that drives the process registry — and requires each `stdin.write` to be covered. A `*_runtime.py` glob would not have been enough either: it misses `claude_code.py` AND `headless_executor.py`, and the latter is a real fourth site the enumeration had never counted. `headless_executor` is safe by a DIFFERENT shape — `_run_headless_subprocess` registers and writes stdin with no local guard, but it is handed to `run_in_executor` inside `execute_headless_task`'s guarded try, so an exception reaches that `finally: unregister()`. The guard models both shapes, and a companion test pins WHICH shape covers each known site, because caller-pairing is name-based and a collision would otherwise mask a real offender. Also fixes the vacuity bug that blocked this move: the old helper returned `total > 0 and guarded == total`, so a discovered file with no stdin write (codex, `stdin=DEVNULL`) would have read as a FAILURE the moment enumeration stopped. It now returns an offender list, and a separate test pins the discovery floor so a broken walk fails loudly instead of passing on an empty set. 2. `_publish_calling_and_check_cancel_sync` said "fails soft", which is true of the read half it replaced and NOT of the write half it adds. If the publish pipeline raises transiently while the other worker's connection is healthy, the marker keeps saying `parked`, so the remote can still finalize CANCELLED under a live POST — the original #378 symptom on the Redis-error path. Documented rather than mechanised: bounded by the 30s negative cache plus one tick, a hard outage is safe by construction (a process whose client is None never wrote a marker, so the remote routes through the agent), and failing closed was rejected because every other Redis touch here is fail-open. architecture.md and chat-turn-cancellation.md had inherited the too-wide "closed" claim and are qualified to match. 3. `restamp_execution_dispatch` records WHY the re-stamp reaches `duration_ms` — it is computed from that DB column, not from the in-coroutine `start_time`, which is taken before the capacity acquire and is why the sibling `execution_time_ms` still spans the park and is not a bug. No behaviour change outside the docstrings; the guard is the substantive part. Tests: tests/unit/test_2433_review_fixes.py 15 -> 20, verified against the PoC above by putting a real file in the tree (caught by file:line, then removed). Full unit suite under CI conditions on this base: 13098 passed, 0 failed. Fixes #2448 Refs #2433, #2435 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Heads-up: this merged (11:52 UTC) while I was still working through the re-review, so items 1 and 2 from your re-review did not land here. Both are in #2450 (issue #2448), plus the Two things worth flagging from doing it, since one changes the shape of the fix you suggested: A Your vacuity note was load-bearing, exactly as you said: On residual #2 — the docstring now says the fail-soft applies to the read half only, that the write half's failure leaves the marker saying Thanks for catching the guard — "the guard is the only thing standing between this fix and its own recurrence" was the right call, and the fourth site is the proof. |
…d two residuals Follow-up to #2435, whose re-review landed after the PR had already auto-merged. Three things, all of them corrections to claims that shipped wider than the code. 1. The guard enumerated two files, so it did not guard. Proven by dropping a `mistral_runtime.py` into the tree with an unpaired `stdin.write`: "2 passed, 13 deselected". It now DISCOVERS — every `agent_server/**/*.py` that drives the process registry — and requires each `stdin.write` to be covered. A `*_runtime.py` glob would not have been enough either: it misses `claude_code.py` AND `headless_executor.py`, and the latter is a real fourth site the enumeration had never counted. `headless_executor` is safe by a DIFFERENT shape — `_run_headless_subprocess` registers and writes stdin with no local guard, but it is handed to `run_in_executor` inside `execute_headless_task`'s guarded try, so an exception reaches that `finally: unregister()`. The guard models both shapes, and a companion test pins WHICH shape covers each known site, because caller-pairing is name-based and a collision would otherwise mask a real offender. Also fixes the vacuity bug that blocked this move: the old helper returned `total > 0 and guarded == total`, so a discovered file with no stdin write (codex, `stdin=DEVNULL`) would have read as a FAILURE the moment enumeration stopped. It now returns an offender list, and a separate test pins the discovery floor so a broken walk fails loudly instead of passing on an empty set. 2. `_publish_calling_and_check_cancel_sync` said "fails soft", which is true of the read half it replaced and NOT of the write half it adds. If the publish pipeline raises transiently while the other worker's connection is healthy, the marker keeps saying `parked`, so the remote can still finalize CANCELLED under a live POST — the original #378 symptom on the Redis-error path. Documented rather than mechanised: bounded by the 30s negative cache plus one tick, a hard outage is safe by construction (a process whose client is None never wrote a marker, so the remote routes through the agent), and failing closed was rejected because every other Redis touch here is fail-open. architecture.md and chat-turn-cancellation.md had inherited the too-wide "closed" claim and are qualified to match. 3. `restamp_execution_dispatch` records WHY the re-stamp reaches `duration_ms` — it is computed from that DB column, not from the in-coroutine `start_time`, which is taken before the capacity acquire and is why the sibling `execution_time_ms` still spans the park and is not a bug. No behaviour change outside the docstrings; the guard is the substantive part. Tests: tests/unit/test_2433_review_fixes.py 15 -> 20, verified against the PoC above by putting a real file in the tree (caught by file:line, then removed). Full unit suite under CI conditions on this base: 13098 passed, 0 failed. Fixes #2448 Refs #2433, #2435 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…d two residuals Follow-up to #2435, whose re-review landed after the PR had already auto-merged. Three things, all of them corrections to claims that shipped wider than the code. 1. The guard enumerated two files, so it did not guard. Proven by dropping a `mistral_runtime.py` into the tree with an unpaired `stdin.write`: "2 passed, 13 deselected". It now DISCOVERS — every `agent_server/**/*.py` that drives the process registry — and requires each `stdin.write` to be covered. A `*_runtime.py` glob would not have been enough either: it misses `claude_code.py` AND `headless_executor.py`, and the latter is a real fourth site the enumeration had never counted. `headless_executor` is safe by a DIFFERENT shape — `_run_headless_subprocess` registers and writes stdin with no local guard, but it is handed to `run_in_executor` inside `execute_headless_task`'s guarded try, so an exception reaches that `finally: unregister()`. The guard models both shapes, and a companion test pins WHICH shape covers each known site, because caller-pairing is name-based and a collision would otherwise mask a real offender. Also fixes the vacuity bug that blocked this move: the old helper returned `total > 0 and guarded == total`, so a discovered file with no stdin write (codex, `stdin=DEVNULL`) would have read as a FAILURE the moment enumeration stopped. It now returns an offender list, and a separate test pins the discovery floor so a broken walk fails loudly instead of passing on an empty set. 2. `_publish_calling_and_check_cancel_sync` said "fails soft", which is true of the read half it replaced and NOT of the write half it adds. If the publish pipeline raises transiently while the other worker's connection is healthy, the marker keeps saying `parked`, so the remote can still finalize CANCELLED under a live POST — the original #378 symptom on the Redis-error path. Documented rather than mechanised: bounded by the 30s negative cache plus one tick, a hard outage is safe by construction (a process whose client is None never wrote a marker, so the remote routes through the agent), and failing closed was rejected because every other Redis touch here is fail-open. architecture.md and chat-turn-cancellation.md had inherited the too-wide "closed" claim and are qualified to match. 3. `restamp_execution_dispatch` records WHY the re-stamp reaches `duration_ms` — it is computed from that DB column, not from the in-coroutine `start_time`, which is taken before the capacity acquire and is why the sibling `execution_time_ms` still spans the park and is not a bug. No behaviour change outside the docstrings; the guard is the substantive part. Tests: tests/unit/test_2433_review_fixes.py 15 -> 20, verified against the PoC above by putting a real file in the tree (caught by file:line, then removed). Full unit suite under CI conditions on this base: 13098 passed, 0 failed. Fixes #2448 Refs #2433, #2435 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…2450 review) `_unguarded_stdin_writes` collected both `ast.Name` ids and `ast.Attribute` attrs into the caller-pairing set, so ANY method call inside ANY guarded try exempted every same-named function in the module. An ordinary dispatch shape try: return runtime.execute(prompt, process, execution_id) finally: get_process_registry().unregister(execution_id) therefore exempted a `def execute(...)` that registered and then wrote stdin unpaired — a real leak, invisible. `execute` / `run` / `send` are exactly the names a dispatch try calls, so this was reachable rather than theoretical, and it landed on the one case discovery exists for: the four known sites are pinned by the mechanism test, but a NEW module has no such pin. Verified both directions before and after: the planted shape is a false negative with the attribute half and is caught without it, while the real tree is unchanged — the single legitimate caller-paired site passes a bare name (`run_in_executor(_HEADLESS_EXECUTOR, _run_headless_subprocess, ctx)`), so the attribute half bought nothing. Removed from both copies of the logic (`_unguarded_stdin_writes` and `_pairing_mechanisms`), with the boundary and its remedy stated in the docstring: a future attribute-paired site is reported rather than silently exempted, and the fix is to reference the function by name or add a justified allowlist entry — never to re-add the attribute half. Also records why scope keys on `register(` and not `register_pending(` — a chosen boundary (no such module exists today), not an oversight. Mutation battery, all as expected: real tree 21 passed; a new unpaired runtime, gemini with its local pairing stripped, and claude_code with its local pairing stripped each fail. Refs #2448, #2435 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…d two residuals (#2448) (#2450) Follow-up to #2435, whose re-review landed after that PR had already auto-merged. - The guard enumerated two files, so it did not guard. It now DISCOVERS every `agent_server/**/*.py` that drives the process registry, and models both accepted pairing shapes (local try/except, and caller-paired via a bare name reference). A `*_runtime.py` glob would not have sufficed either: it misses `claude_code.py` and `headless_executor.py`, the latter a live fourth site the enumeration never counted. - The caller-pairing exemption no longer collects `ast.Attribute` attrs — that made any method call in any guarded try exempt every same-named function in the module, hiding a real leak behind an ordinary dispatch shape. - `_publish_calling_and_check_cancel_sync` records that its fail-soft covers the read half only; the write half's failure leaves a stale `parked` marker, bounded by the 30s negative cache plus one tick. architecture.md and chat-turn-cancellation.md are qualified to match. - `restamp_execution_dispatch` records why the re-stamp reaches `duration_ms` and why `execution_time_ms` still spans the park. Source changes are docstrings only (verified by AST comparison with docstrings stripped). Reviewed and approved by dolho after a mutation battery. Fixes #2448
Summary
agent_call_limiter), in the agent's CPU-sized default thread pool, behind the agent/api/chatlock, or in the post-exit drain beforeunregister()— because its proof-of-life (GET agent/api/executions/running) could not see any of those. It wrotefailed("completed on agent but status not reported"), released the slot, and the parked call then ran anyway: billed, overbooked, its late 200 silently overwriting the row (bug: Execution shows Failed then Success after cleanup (race condition) #378)./api/executions/runninggainspending_ids(accepted at/api/task,/api/chatand the refactor: fire-and-forget dispatch — a hung turn holds zero backend resource #1083 async spawn but not yet spawned) andrecently_completed_idscovers exited-but-registered handles. Backend side: every outbound agent call is registered for its whole lifetime (track_inflight_dispatch, in-process registry + a cross-worker Redis liveness markerexecution:inflight:{id}, 60s TTL / 15s refresh, ONE refresher task per process); the watchdog reads a tri-state verdict (alive/absent/unknown) and withholds recovery accordingly (CleanupReport.dispatch_inflight_skipped).started_at(admission kept inqueued_at, the drained-backlog shape) and renews the slot lease, so the registry-blind Phase-1 sweep, the slot TTL, canary E-01 andduration_msall measure the run, not the wait.parkedphase is finalized CANCELLED and the grant raisesBackendAgentCallCancelled, where the dispatcher writes CANCELLED itself (never FAILED). Cancel-while-pending on the agent is consumed byregister()(SIGKILL at spawn), closing the check-then-Popen window.MAX_PARALLEL_TASKS_CEILING_MAX.BACKEND_AGENT_CALL_LIMIT/BACKEND_AGENT_CALL_QUEUE_TIMEOUT_Sforwarded in prod + hosted compose and documented in.env.example(they lived only indocker-compose.yml, the #1039 class); the >5s queue-wait warning fires on both acquire branches./cso --diff, fixed here):terminate_executionwrote CANCELLED keyed on the caller-suppliedtask_execution_idwhile onlyexecution_idwas scoped by the agent's 404 — a caller authorised on agent A could flip agent B's running row. One agent-scope gate at the function entry now covers all three arms (uniform 404, fail-closed 503). Report:docs/security-reports/cso-diff-2026-08-28-2433-watchdog-parked-executions.md.Changes
docker/base-image/agent_server/:services/process_registry.py(pending registry, cancel-consumed-at-register, exited-but-registered ids),routers/chat.py(/api/task,/api/chat,/api/executions/running),services/result_callback.py,services/headless_executor.py(_HEADLESS_EXECUTOR, pre-spawn 409),services/gemini_runtime.py(registers)src/backend/services/:agent_call_limiter.py(in-flight registry + marker refresher + cancel +on_granted),task_execution_service.py(whole-call tracking, re-stamp + slot renew at grant, CANCELLED terminal),chat_execution_service.py(entry agent-scope gate, parked-cancel arm, 409/503 split),cleanup_service.py(tri-state skip, honest orphan string,dispatch_inflight_skipped),slot_service.py(renew_slot),agent_runtime_state.py(exempt-by-construction note)src/backend/db/schedules/executions.py+database.py:restamp_execution_dispatch(CAS on RUNNING + NULL lease)docker-compose.prod.yml,docker-compose.hosted.yml,.env.examplearchitecture.md(new "In-Flight Dispatch Proof-of-Life (bug: watchdog fails admitted-but-undispatched executions as "completed on agent but status not reported", releasing their slots and masking the real terminal #2433)" block + Cleanup/Redis rows),requirements/infrastructure.md, feature flows (cleanup-service,task-execution-service,capacity-management,parallel-headless-execution,chat-turn-cancellation),learnings.md(2 entries), security reporttests/unit/test_2433_*.pyfiles (registry, task handler, headless pool, Gemini, limiter, slot renew, DB restamp, dispatch wiring incl. the exploit replay, watchdog verdicts, packaging parity); 6 existing files adjusted (mock hygiene,_EXPECTED_UPDATE_SITES)Test Plan
origin/devworktree, no submodules): 12969 passed, 0 failed (baselineorigin/dev: 12863 passed, 0 failed)python tests/lint_sys_modules.py— no new violations; secret scan on the diff cleanstarted_atrestamped, slot lease renewed),duration_ms≈ the 486s run; 0 orphan recoveries, 0 lost CAS, 0 active slots afterpending_idsprobe: two concurrent/api/chatturns on one agent — the second reported aspendingwhile waiting on the chat lock, thenrunning, with the first inrecently_completed_idsFixes #2433
Generated with Claude Code