fix(reaper): fail closed if a timed-out worker is not confirmed dead

The off-loop reaper's kill+join phase caught any exception and continued unconditionally, with no
post-kill liveness check. If kill_pid_tree/join failed or the process stayed alive, the reaper still
wrote a terminal result, enqueued a retry, and respawned — and because a subagent retry reuses the same
task id/drive, that retry could run concurrently with the still-live original, violating the Variant-A
invariant that terminal write + retry happen only after the original process is dead.

Now the reaper confirms death after kill/join; if the process is not provably dead it makes a final hard
kill, and if it STILL will not confirm dead it forces will_retry=False so no colliding retry is ever
enqueued (a non-retry failed terminal still resolves the UI; the orphan reparents to init and the custody
reaper ends it). Flagged by the v6.38.0 claudexor codex (gpt-5.5) release review.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ouroboros 2026-06-19 09:06:25 +03:00
parent 872b0a31a2
commit 632f34a21d
2 changed files with 65 additions and 0 deletions

View file

@ -128,6 +128,36 @@ def reap_timed_out_task(job: Dict[str, Any]) -> None:
proc.join(timeout=2)
except Exception:
log.warning("Reaper: failed to terminate worker %d for task %s", worker_id, task_id, exc_info=True)
# Fail-closed (Variant-A invariant): the terminal write + retry may proceed only once the ORIGINAL
# process is PROVABLY dead — otherwise a retry that reuses the same task id/drive could run
# concurrently with a still-live worker. If kill/join did not confirm death (kill_pid_tree/join
# raised, or the process is still alive), make a final hard kill; if it STILL will not confirm
# dead, force will_retry=False so no colliding retry is ever enqueued (terminal write proceeds as
# a non-retry failure for UI resolution; the orphan reparents to init and the custody reaper ends it).
proc_confirmed_dead = proc is None
if proc is not None:
try:
proc_confirmed_dead = not proc.is_alive()
except Exception:
proc_confirmed_dead = False # cannot confirm -> fail closed (treat as still alive)
if not proc_confirmed_dead:
try:
from ouroboros.platform_layer import kill_pid_tree
if getattr(proc, "pid", None):
kill_pid_tree(proc.pid, exclude_pids=_q._kept_service_pids())
proc.join(timeout=2)
proc_confirmed_dead = not proc.is_alive()
except Exception:
log.debug("Reaper: final hard-kill of worker %d failed for %s", worker_id, task_id, exc_info=True)
proc_confirmed_dead = False
if not proc_confirmed_dead and will_retry:
log.error("Reaper: worker %d for task %s did NOT confirm dead after kill/join; forcing a "
"NON-retry terminal so a same-id/drive retry cannot race the still-live process.",
worker_id, task_id)
will_retry = False
try:
from ouroboros.tools.services import archive_task_service_logs

View file

@ -282,3 +282,38 @@ def test_task_is_readonly_subagent_gate():
{"delegation_role": "subagent", "task_constraint": {"mode": "acting_subagent"}}) is False
assert task_is_readonly_subagent({"id": "root", "type": "task"}) is False
assert task_is_readonly_subagent(None) is False
def test_reaper_fails_closed_when_worker_not_confirmed_dead(tmp_path, monkeypatch):
"""Variant-A invariant: if the worker process is NOT provably dead after kill/join, the reaper
must NOT enqueue a retry (a same-id/drive retry could race the still-live worker) it forces a
NON-retry terminal instead. Guards the codex-flagged release blocker."""
from supervisor import queue as q
from supervisor import workers as w
from ouroboros import platform_layer
from ouroboros.task_results import STATUS_FAILED, load_task_result
class _AliveProc:
pid = 4242
def is_alive(self):
return True # never confirms dead, even after kill attempts
def join(self, timeout=None):
return None
workers = {5: SimpleNamespace(busy_task_id=None, proc=_AliveProc(), reaping=True)}
_patch_queue(q, w, monkeypatch, tmp_path, workers)
monkeypatch.setattr(q, "_kept_service_pids", lambda: set(), raising=False)
monkeypatch.setattr(platform_layer, "kill_pid_tree", lambda *a, **k: None) # kill is a no-op
q._reap_timed_out_task({
"worker_id": 5, "proc": _AliveProc(), "task_id": "wedged1",
"task": {"id": "wedged1", "type": "task"}, "task_type": "task",
"terminal_reason": "idle_timeout", "attempt": 1,
"will_retry": True, "retry_task_id": "wedged1",
})
assert q.PENDING == [], "a worker that did not confirm dead must NOT get a colliding retry enqueued"
res = load_task_result(tmp_path, "wedged1")
assert res["status"] == STATUS_FAILED, "must write a NON-retry (failed) terminal, not interrupted/retry"