Issue #33 (job budget, wall_clock_timeout vs deadline_timeout) and
Issue #30 (notify-only stall watcher). Reviews of PR #41 in the merge
workspace: round 1 (@1673818) claude N1 / codex R1 R2 NOT PASS, agy grok
PASS; round 2 (@af0a2a2) grok N1 / codex D1 D2 NOT PASS, claude agy PASS;
round 3 (@9200562) 4/4 PASS. Reports and the round-3 identity sidecars go
to .agents/reports/<session>/. The design consensus on items A-D is in the
VERSIONS.md entry.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
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
Round-1 review of PR #41 (claude N1, codex R1/R2).
N1 - the wrapper stopped its stall watcher with `kill` followed by an
unbounded `wait`. A TERM that lands in the watcher's fork-exec window is
swallowed (bash defers terminating signals in the forked child until the
exec), the watcher then installs its own handler and lives until the job
closes, and the wrapper hung until someone killed the watcher by hand
(reproduced twice in test_issue14's ack test; the wrapper trace stops at
`kill` + `wait`).
R1 - a wrapper that is itself terminated left the watcher orphaned.
R2 - a `completed` or a re-dispatch that landed during a blocking herdr
wait (up to 60 s) was still followed by the idle notice and the nudge.
- multi-agent-mux-delegate-job: `stop_stall_watch` sends TERM, polls up
to 5 s, then KILLs and reports "ignored TERM; killed"; `start_stall_watch`
passes `--watch-pid $$`.
- lib_py/stall_watch.py: `--watch-pid` (rc 0 once that pid is gone, checked
between herdr calls); `_dispatch_open()` re-reads the record after every
wait and right before the nudge - terminal status, another dispatch token
or attempt, or a missing record ends the run without a notice; a refused
`stalled:idle` publish withholds the nudge (`_notify` returns False).
- delegate-job SKILL.md: the watcher's lifetime contract; exit-code rows 2
and 3 name the idle clock and the wall-clock label; "before v4.7.5" ->
v4.8.0 (M5, NB2).
- tests/test_issue30_stall_watch.py (M8): the module-wide Linux mark is
narrowed to S6 (/proc); r4's stub stamps time.time() so its cross-process
comparison holds on macOS too (47 passed, 1 skipped here).
- tests/test_issue30_stall_watch_stop.py (new, macOS and Linux): the stop
routine against a TERM-ignoring stand-in returns in 4-12 s; the wrapper
returns (rc 3) with a TERM-ignoring watcher shim; `--watch-pid` ends the
watcher; a TERM to the real wrapper leaves no watcher; a job completed or
re-dispatched during the wait is not nudged; a refused notice withholds
the nudge. Every one of them hangs or fails on the round-1 code.
Not in this commit (follow-ups): M7 test hook in product code (no adapter
is unreliable, a seam is needed), M9/B3 public-broker latency (on this host
even `started` sometimes never reaches the subscriber), the leading-zero
budget parse (NB1) and M1/M2/M4/M6 go on the #17 item 4 list.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
- job_subscriber.py returns only named codes (RC_COMPLETED 0, RC_ERROR 1,
RC_NO_EVENT 2, RC_BROKER_UNAVAILABLE 3, RC_WALL_CLOCK 4). The wrapper
mirrors them as SUB_RC_* and compares subscriber codes only by name; a test
keeps the mirror equal to the Python constants and forbids numeric
comparisons of $sub_rc/$sub_exit and numeric `return` in the subscriber.
- `delegate-job wait` passed the subscriber's code straight through, so a
spent budget would have reached its callers as 4; it is 2 again (B7 fails
without the mapping).
- tier1 G-8 gave both clocks 0.5 s and raced; the wall clock now runs out
first by construction (idle 30 s) and the test asserts RC_WALL_CLOCK.
Other subscriber rc asserts use the names.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- input_region: in the prompt-line shape the input box starts only at a
column-0 prompt (codex indents a draft's continuation lines), and the
prompt and the (whole-line) placeholder are stripped from the box's first
non-blank line only, so `› Explain this terminal prompt:\n ›` is not empty.
- test_r4 runs with S=40 again: the defective code nudged ~30 s after a
missed transition, which S=10 could not tell from the fixed ~10 s.
Both new checks fail against the defective versions (stall_watch.py at
47e4274 -> 30.24 s < 40; input_region.py at bf0f5c1 -> nudge sent) and pass
now, each with its passing twin.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- R1 (P1): without rules the input region runs from the last prompt line to
the end of the pane; only a last line matching the adapter's footer pattern
(codex: ` <model> · ~/<cwd> · …`) is dropped, so a draft on a second line
keeps the nudge withheld.
- R2: while the synchronous submit runs, a relay thread finds the job it
registers (same session, created since the submit) and prints its
stalled:/stall_watch: notices; before, they appeared only after the job.
- R3: the broker-unavailable disk wait that runs into the deadline is the wall
clock: wall_clock_timeout (caller rc stays 3).
- R4: a state_change_seq change seen right before the judgement restarts the
idle window (decision 9: S seconds of continuous idle).
Regression tests are the reviewer's reproductions, each with its passing
twin; the wrapper-level #30 tests now use this environment's broker instead
of the #18 harness's public default.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
lib_py/stall_watch.py runs beside the wrapper's subscriber for a local herdr
job (started after delivery, stopped when the subscriber ends; MAM_STALL_WATCH=0
turns it off). It follows `herdr agent wait` transitions and publishes only
`progress` events through one publisher; it has no path that closes a job.
- blocked: `stalled:blocked <detection line>` once per episode, never answered.
- idle for MAM_STALL_IDLE_S (600) with no background descendants of the agent
pid (pane process-info + /proc; adapter resident_children and the heartbeat
timer excluded) and an empty input box: `stalled:idle` and one nudge.
- input box judged from adapter facts measured on herdr 0.9.1 (codex '›',
grok frame '│', opencode '┃' block + stop pattern); the whole-pane fallback
is gone. No facts: withheld + `stall_watch:input_check_unavailable` once;
never empty for 3 x S: `stall_watch:input_never_empty` once.
- herdr failures are not "no event": warning + `stall_watch:herdr_unavailable:
<code>` (or agent_not_found), backoff, rc 2 after MAM_STALL_HERDR_FAIL_S
(120); the wrapper prints the watcher's last lines.
- remote and human jobs are skipped with one log line.
- agent_status_reliable is an adapter field; false turns idle judgement off.
- the loop runner relays stalled:/stall_watch: notices once each.
- scoped_env drops a pane-inherited workspace scope for name lookups (#34 rule).
The module sits at the top of lib_py: delegation and loop may not import the
agent adapters (their sibling-import rules). Idle pane captures of the five
CLIs are fixtures; the mock herdr answers process-info in the real shape.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- wrapper: TIMEOUT falls back to MAM_JOB_TIMEOUT_S (--timeout > env > 3600);
an invalid value warns and keeps 3600.
- loop: --job-timeout <sec> (run_loop.sh passes it on; refused in the legacy
shell loop). The runner always hands the wrapper --timeout, so the budget
the wrapper registers is the one its wait caps come from.
- runner: wait_for_job and the MAM_CHILD_WAIT_S default are budget + 300 s
(MAM_WAIT_GRACE_S), today's 3900 with no option; an explicit
MAM_CHILD_WAIT_S still wins.
- subscriber: the wall-clock budget returns 4 (RC_WALL_CLOCK), silence keeps
2 (RC_NO_EVENT). The wrapper maps both to caller rc 2 and labels the close
from the rc: wall_clock_timeout vs deadline_timeout; the disk-heartbeat
grace that ends on the deadline is the wall clock too.
- registry close accepts wall_clock_timeout (engine: -> error); the remote
runner's deadline and command-timeout details lead with wall_clock_timeout;
the sub-job table files it CLOSED-UNCONFIRMED (first token, colon stripped).
Test expectations changed only where the contract changed: the loop's
contract_args now starts with --timeout, the subscriber's exit-code docstring
names rc 2/4, and the registry --help capture lists the new close kind.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>