fix(issue-30): review round 2 - the watcher is stopped with KILL, and a strict last gate sits between the notice and the keys

grok N1: the wrapper spent up to 5 s on a TERM budget before it finalized the
job, so when the watcher swallowed the TERM the silent job's close came ~5 s
late and a `completed` written in that gap won (test_issue14 idle-grace).
codex D1: `_notify` blocks on a publish for up to 120 s, and the prompt went
out right after it; a completed, a re-dispatch or an unreadable record in that
window still got the nudge. codex D2: the wrapper pid was checked only at the
loop head, so a watcher whose wrapper vanished during a wait could still
notify and nudge once.

- multi-agent-mux-delegate-job `stop_stall_watch`: KILL + wait, no budget.
  The watcher holds nothing to flush (its TERM handler is a bare os._exit),
  and KILL cannot be swallowed.
- lib_py/stall_watch.py: `_wrapper_gone()` after every wait (before any
  notice) and at the loop head; `_may_prompt()` right before the keys - the
  wrapper is alive and the record, read then, is open and this dispatch;
  `_dispatch_open(strict=True)` treats an unreadable record as "no".
- SKILL.md: the lifetime contract states the one-wait lag and that nothing is
  sent after the wrapper is seen gone.
- tests/test_issue30_stall_watch_stop.py: the stop returns at once against a
  TERM-ignoring stand-in (took 6 s on the round-2 code); completed,
  re-dispatch and a torn record during the notice publish withhold the nudge;
  a wrapper gone during the wait gets no notice and no nudge. All five fail
  on the round-2 code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
This commit is contained in:
GodopuandClaude Opus 5.5 committed 2026-09-26 15:28:07 +09:00
1 parent af0a2a2557
commit 9200562c91
4 files changed
+144 -31

No files matched your search

@@ -48,8 +48,10 @@ multi-agent-mux-delegate-job submit \
# argv string is capped at 131,072 bytes on Linux. The loop always uses it.
# MAM_JOB_TIMEOUT_S: the budget when --timeout is not given (default 3600).
# MAM_STALL_IDLE_S (600) / MAM_STALL_HERDR_FAIL_S (120) / MAM_STALL_WATCH=0: the stall watcher (Issue #30).
# The watcher lives exactly as long as the job's wrapper: the wrapper stops it (TERM, 5 s, then KILL)
# when its subscriber ends, and the watcher exits on its own once the wrapper pid is gone.
# The watcher lives as long as the job's wrapper: the wrapper stops it (KILL, at once)
# when its subscriber ends, and the watcher exits on its own once the wrapper pid is gone
# (checked after every herdr wait, so it can outlive a killed wrapper by one wait, <= 60 s,
# and never sends a notice or a nudge after it saw the wrapper gone).
# --parent-job <id> (Issue #34): this job is delegated from job <id>, which
# must exist in the registry (else exit 1). A loop waiting on <id> waits for
# it too and shows it to reviewers. Default: $MAM_JOB_ID (the remote runner