mirror of
https://github.com/razzant/ouroboros.git
synced 2026-08-05 00:29:46 +00:00
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:
parent
872b0a31a2
commit
632f34a21d
2 changed files with 65 additions and 0 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue