refactor(issue-17): item 4 - the delegate-job wrapper's decisions move to Python #51

Merged
Godopu merged 9 commits from refactor/issue-17-item4 into main 2026-10-07 16:37:53 +00:00
Owner

Refs #17 (item 4 only; #17 stays open)

The delegate-job wrapper's bash decisions move to Python. No behaviour change: same exit codes, labels, registry writes, events and stall-watcher behaviour. No version bump; the version is set at merge.

Moved (old location → new location)

Old (bash) New
finalize_unfinished: FINALIZE_CLOCK choice, disk-heartbeat grace and broker re-observation, wall_clock_timeout / deadline_timeout / cancelled label, the close and the event lib_py/delegation/wrapper.py finalize-begin / finalize-end (finalize_begin, finalize_end, recent_disk_heartbeat)
start_stall_watch: whether to watch (MAM_STALL_WATCH, remote, human) wrapper.py stall-watch (stall_watch)
_map_sub_rc wrapper.py sub-status (sub_status, disk_status)
_rc_for (+ MAM_DELEGATE_FAIL_CLOSED) wrapper.py exit-code (exit_code, fail_closed)
subscriber early-exit check wrapper.py subscriber-up
cmd_wait rc normalisation wrapper.py wait-rc
lib_int.sh mam_int / mam_int_knob and the runner's _positive_int (two implementations of the #26 rule) one implementation: lib_py/ints.py (positive_int, knob). lib_int.sh and runner.py both call it

The wrapper reaches all of it through one _decide helper (python -m lib_py.delegation.wrapper <subcommand>). It branches only on the subcommand's exit code or one-line output.

Kept in bash

Spawn, wait and kill; key injection; signal cleanup; the one herdr busy probe, which is diagnostic only and runs between finalize-begin and finalize-end; and the thin CLI compatibility layer.

Stall-watcher lifetime (PR #41 contract)

Unchanged:

  • stop is an immediate KILL;
  • --watch-pid makes the watcher exit when the wrapper is gone;
  • the record is re-checked after publishing and before a key press.

The finalizer follows the same rule:

  • Review R1: finalize-end gets --watch-pid "$$". It checks the wrapper every 0.2 s through both waits and before the close and the event. When the wrapper is gone, it leaves without closing or publishing, as the bash loops did.
  • Review R1 round 2: kill(pid, 0) succeeds for a zombie. A wrapper that has exited but not been reaped is therefore also checked with ps -o stat= (POSIX, no /proc). State Z counts as gone.

How "no behaviour change" was checked

  • tests/test_issue17_item4_decisions.py: runs the old bash functions, copied verbatim from 7209750 into tests/data/issue17_item4_old_decisions.sh, and the new wrapper on the same inputs. It compares output, FINAL_JOB_STATUS and the registry and publish calls. It includes TERM inside each grace and TERM + communicate() with no reap. There are also unit tests for the full decision tables, invalid input and boundaries.

  • Orchestrator's independent old-vs-new run: 7209750 vs 518beda on the real CLIs, offline (broker pointed at a closed local port). All 179 cases have the same exit code and output (timestamps stripped):

    • mam_int / mam_int_knob over 19 inputs (010, -1, " 5", 1e3, abc, ٣, …);
    • the six integer flags of submit --dry-run over the same 19 inputs;
    • wait on records in 8 states.
  • Existing tests edited: the brief asks for the reason. These are static tests that grep the wrapper's source for the old bash literals; the logic they pinned is now behind _decide. They now pin the Python entry points and check the same mappings in Python:

    • tests/test_issue5_phase1_delegation.py (G-10 mapping);
    • tests/test_tier1_unit.py (frozen subscriber-rc mapping);
    • tests/test_issue26_ints.py (the two knobs are now read in Python);
    • tests/test_issue21_loop_b.py (stand-in control root needs lib_py/ints.py).

    The behaviour tests named in the brief are not modified: test_issue33_budget, test_issue30_stall_watch*, test_issue26_* except the knob list above, test_issue18_liveness_contract, test_issue14_delivery_ack, test_issue6_* and test_issue34_*.

  • tests/data/skill_code_blocks.json (#42 inventory): follows the line shifts.

Full suite

  • Linux, Python 3.14.7, herdr 0.9.3, at 518beda (detached worktree): 13 failed / 1717 passed / 20 skipped / 1 error.
    • 12 failures: the known baseline in tests/test_issue10_13_purge.py (#28).
    • 1 failure: tests/test_issue45_restore.py::test_b_only_the_resumed_sessions_restored_shell_is_closed. This also fails on main 7209750, rerun on both trees. herdr on this host moved from 0.9.1 to 0.9.3, which no longer restores a pane whose cwd was deleted into $HOME, as the test assumes. Not related to this PR.
    • 1 error: test_workspace_scope teardown. My own old-vs-new comparison wrote audit logs into the tree under test while the suite was running. It passes after removing them.
  • macOS / Python 3.9.6 / bash 3.2: not run. This host has neither. Every changed .py file parses with ast.parse(feature_version=(3, 9)), and there are no 3.10+ APIs. Please run the full suite on the Mac before merging.

Review

Loop: creator self-planned (no --plan), with 4 reviewers (claude, agy, grok, codex).

  • Round 1: NOT PASS → fix R1 (the finalizer ends with its wrapper).
  • Round 2: NOT PASS → fix R1 round 2 (a zombie wrapper is not a live owner).
  • Round 3: unanimous PASS.

🤖 Generated with Claude Code

Refs #17 (item 4 only; #17 stays open) The delegate-job wrapper's bash decisions move to Python. No behaviour change: same exit codes, labels, registry writes, events and stall-watcher behaviour. No version bump; the version is set at merge. ## Moved (old location → new location) | Old (bash) | New | |---|---| | `finalize_unfinished`: `FINALIZE_CLOCK` choice, disk-heartbeat grace and broker re-observation, `wall_clock_timeout` / `deadline_timeout` / `cancelled` label, the close and the event | `lib_py/delegation/wrapper.py` `finalize-begin` / `finalize-end` (`finalize_begin`, `finalize_end`, `recent_disk_heartbeat`) | | `start_stall_watch`: whether to watch (`MAM_STALL_WATCH`, remote, human) | `wrapper.py stall-watch` (`stall_watch`) | | `_map_sub_rc` | `wrapper.py sub-status` (`sub_status`, `disk_status`) | | `_rc_for` (+ `MAM_DELEGATE_FAIL_CLOSED`) | `wrapper.py exit-code` (`exit_code`, `fail_closed`) | | subscriber early-exit check | `wrapper.py subscriber-up` | | `cmd_wait` rc normalisation | `wrapper.py wait-rc` | | `lib_int.sh` `mam_int` / `mam_int_knob` and the runner's `_positive_int` (two implementations of the #26 rule) | one implementation: `lib_py/ints.py` (`positive_int`, `knob`). `lib_int.sh` and `runner.py` both call it | The wrapper reaches all of it through one `_decide` helper (`python -m lib_py.delegation.wrapper <subcommand>`). It branches only on the subcommand's exit code or one-line output. ## Kept in bash Spawn, wait and kill; key injection; signal cleanup; the one herdr busy probe, which is diagnostic only and runs between `finalize-begin` and `finalize-end`; and the thin CLI compatibility layer. ## Stall-watcher lifetime (PR #41 contract) Unchanged: - stop is an immediate KILL; - `--watch-pid` makes the watcher exit when the wrapper is gone; - the record is re-checked after publishing and before a key press. The finalizer follows the same rule: - **Review R1:** `finalize-end` gets `--watch-pid "$$"`. It checks the wrapper every 0.2 s through both waits and before the close and the event. When the wrapper is gone, it leaves without closing or publishing, as the bash loops did. - **Review R1 round 2:** `kill(pid, 0)` succeeds for a zombie. A wrapper that has exited but not been reaped is therefore also checked with `ps -o stat=` (POSIX, no `/proc`). State `Z` counts as gone. ## How "no behaviour change" was checked - **`tests/test_issue17_item4_decisions.py`:** runs the old bash functions, copied verbatim from 7209750 into `tests/data/issue17_item4_old_decisions.sh`, and the new wrapper on the same inputs. It compares output, `FINAL_JOB_STATUS` and the registry and publish calls. It includes TERM inside each grace and TERM + `communicate()` with no reap. There are also unit tests for the full decision tables, invalid input and boundaries. - **Orchestrator's independent old-vs-new run:** 7209750 vs 518beda on the real CLIs, offline (broker pointed at a closed local port). All 179 cases have the same exit code and output (timestamps stripped): - `mam_int` / `mam_int_knob` over 19 inputs (`010`, `-1`, `" 5"`, `1e3`, `abc`, `٣`, …); - the six integer flags of `submit --dry-run` over the same 19 inputs; - `wait` on records in 8 states. - **Existing tests edited:** the brief asks for the reason. These are static tests that grep the wrapper's source for the old bash literals; the logic they pinned is now behind `_decide`. They now pin the Python entry points and check the same mappings in Python: - `tests/test_issue5_phase1_delegation.py` (G-10 mapping); - `tests/test_tier1_unit.py` (frozen subscriber-rc mapping); - `tests/test_issue26_ints.py` (the two knobs are now read in Python); - `tests/test_issue21_loop_b.py` (stand-in control root needs `lib_py/ints.py`). The behaviour tests named in the brief are not modified: `test_issue33_budget`, `test_issue30_stall_watch*`, `test_issue26_*` except the knob list above, `test_issue18_liveness_contract`, `test_issue14_delivery_ack`, `test_issue6_*` and `test_issue34_*`. - **`tests/data/skill_code_blocks.json` (#42 inventory):** follows the line shifts. ## Full suite - **Linux, Python 3.14.7, herdr 0.9.3, at 518beda (detached worktree):** 13 failed / 1717 passed / 20 skipped / 1 error. - 12 failures: the known baseline in `tests/test_issue10_13_purge.py` (#28). - 1 failure: `tests/test_issue45_restore.py::test_b_only_the_resumed_sessions_restored_shell_is_closed`. **This also fails on main 7209750**, rerun on both trees. herdr on this host moved from 0.9.1 to 0.9.3, which no longer restores a pane whose cwd was deleted into `$HOME`, as the test assumes. Not related to this PR. - 1 error: `test_workspace_scope` teardown. My own old-vs-new comparison wrote audit logs into the tree under test while the suite was running. It passes after removing them. - **macOS / Python 3.9.6 / bash 3.2: not run.** This host has neither. Every changed `.py` file parses with `ast.parse(feature_version=(3, 9))`, and there are no 3.10+ APIs. **Please run the full suite on the Mac before merging.** ## Review Loop: creator self-planned (no `--plan`), with 4 reviewers (claude, agy, grok, codex). - Round 1: NOT PASS → fix R1 (the finalizer ends with its wrapper). - Round 2: NOT PASS → fix R1 round 2 (a zombie wrapper is not a live owner). - Round 3: unanimous PASS. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Godopu added 7 commits 2026-10-06 10:00:44 +00:00
The wrapper keeps spawn / wait / kill, key injection and the herdr
diagnostic probe; what it decided in bash is now lib_py/delegation/wrapper.py,
reached through one `_decide` helper:

- finalize_unfinished: the clock choice, the disk grace and broker
  re-observation, the close request / label, the close and the event
  (finalize-begin / finalize-end; bash runs only the busy probe between them)
- start_stall_watch: whether to watch (MAM_STALL_WATCH, remote, human)
- _map_sub_rc, _rc_for (+ MAM_DELEGATE_FAIL_CLOSED), the subscriber
  early-exit check and cmd_wait's rc normalisation
- mam_int / mam_int_knob run lib_py/ints.py, which is also the runner's
  _positive_int: one implementation of the Issue #26 rule

No behaviour change: tests/test_issue17_item4_decisions.py runs the old
bash functions (copied verbatim from 7209750) and the new wrapper on the
same inputs and compares output, FINAL_JOB_STATUS and the registry /
publish calls. Three static tests that pinned the old bash literals now
pin the Python entry points instead.

Refs #17

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
finalize-end ran the disk grace and the broker re-observation as a child
of the wrapper; TERM to the wrapper left it running, and at the deadline it
still closed the job and published the error (codex R1, reproduced with the
real registry). The bash loops it replaced ended with the wrapper.

The wrapper now passes its own pid (--watch-pid "$$", as for the stall
watcher). finalize-end checks it every 0.2 s through both waits and before
the close and the event, and leaves without either once it is gone.
A comparison test sends TERM to the old and the new wrapper inside each
grace: both exit at once, leave no process behind and make no call.

Also: no blank line at the end of the old-decisions fixture, and the #42
inventory follows main() one line down.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
kill(pid, 0) succeeds for a zombie. A caller that captures the wrapper's
output and calls communicate() after TERM leaves the wrapper unreaped while
the finalizer holds its pipes, so the owner check passed until the deadline
and the job was still closed (codex, round 2; reproduced with the real
registry). _alive now asks `ps -o stat=` (POSIX, no /proc) once kill(0)
succeeds and treats state Z as gone, as tests/conftest.py::_pid_alive does;
no ps answer keeps kill(0)'s verdict. The owner is looked at every 0.5 s.

Tests: TERM + communicate() with no wait() for both graces (stand-ins) and
for the timeout grace with the real registry, old vs new; a zombie is not
alive. All four fail on c5cebe6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Godopu added 1 commit 2026-10-07 15:53:10 +00:00
codex, PR #51 round 1. wrapper._sleep computed the span and the remainder
with time.time(). The broker re-observation (up to 3 s) and the disk-heartbeat
grace (1 s) use it, so a wall clock stepped back during the wait stretched it
by the step: on a mocked clock stepped back 10 s, an already completed job was
seen after 11 s / 13 s instead of 1 s / 3 s. The old bash ran `sleep 1` /
`sleep "$left"`, which do not follow the wall clock.

_sleep now measures on time.monotonic(). The deadline (START_TIME + TIMEOUT),
the evidence window and event timestamps stay on the wall clock (_now), as in
the bash.

test_u_a_wait_is_not_stretched_by_a_wall_clock_step_back (1 s, 3 s) swaps the
module's time for a clock stepped back 10 s during the first sleep; on the
previous code it measures 11.0 / 13.0 and fails. The comparison test and the
finalize-related behaviour tests (issue33, issue18, issue14) on macOS /
Python 3.9: 321 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
Godopu added 1 commit 2026-10-07 16:37:49 +00:00
Merge-workspace review: round 1 (@518beda) claude agy grok PASS, codex
NOT PASS (finalize wait measured on the wall clock); fixed in 4c86107.
Round 2 delta (@4c86107) claude agy grok PASS; codex (24b21b9c, after
817d1650 closed deadline_timeout with no report) found R1 closed and no
new defect, but withheld PASS only because its sandbox could not bind
the suite's loopback broker. The same two-file suite run here at
4c86107: 278 passed. Full suite at 518beda on macOS / Python 3.9:
1711 passed / 1 failed / 38 skipped; the failure
(test_issue45_restore, herdr 0.9.3 cwd restore) also fails on main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
Godopu merged commit 56d0bfda54 into main 2026-10-07 16:37:53 +00:00
Author
Owner

병합했습니다 (56d0bfd, v4.8.6).

  • 1회차 리뷰(@518beda): claude·agy·grok PASS, codex NOT PASS — finalize-end 의 _sleep 이 벽시계로 대기 길이를 재서, 시계가 뒤로 가면 대기가 늘어남. 4c86107 에서 monotonic 시계로 고침(마감 판정은 벽시계 유지) + 회귀 테스트.
  • 2회차 델타(@4c86107): claude·agy·grok PASS. codex 는 "R1 닫힘, 새 결함 없음" 이었지만 샌드박스가 루프백 브로커 bind 를 막아 필수 스위트를 못 돌렸다는 이유로만 NOT PASS. 같은 두 파일 스위트를 병합 워크스페이스에서 돌려 278 passed 확인.
  • 전체 스위트(518beda, macOS·Python 3.9): 1711 passed / 1 failed / 38 skipped. 실패한 test_issue45_restore::test_b_… 는 main 에서도 실패(herdr 0.9.3 의 cwd 복원 변화, 이 PR 과 무관).
  • 후속: herdr 0.9.3 에 맞춘 test_issue45 수정; claude 비차단 지적 N1(래퍼 모듈이 패키지 경유로 paho import — "stdlib only" 문서), N2(_decide 실패 시 exit ""→255, ${rc:-1} 제안), N3(lib_int.sh 가 PATH python3 사용), N5(PR 본문 0.2 s vs 코드 0.5 s).
병합했습니다 (56d0bfd, v4.8.6). - 1회차 리뷰(@518beda): claude·agy·grok PASS, codex NOT PASS — `finalize-end` 의 `_sleep` 이 벽시계로 대기 길이를 재서, 시계가 뒤로 가면 대기가 늘어남. 4c86107 에서 monotonic 시계로 고침(마감 판정은 벽시계 유지) + 회귀 테스트. - 2회차 델타(@4c86107): claude·agy·grok PASS. codex 는 "R1 닫힘, 새 결함 없음" 이었지만 샌드박스가 루프백 브로커 bind 를 막아 필수 스위트를 못 돌렸다는 이유로만 NOT PASS. 같은 두 파일 스위트를 병합 워크스페이스에서 돌려 278 passed 확인. - 전체 스위트(518beda, macOS·Python 3.9): 1711 passed / 1 failed / 38 skipped. 실패한 `test_issue45_restore::test_b_…` 는 main 에서도 실패(herdr 0.9.3 의 cwd 복원 변화, 이 PR 과 무관). - 후속: herdr 0.9.3 에 맞춘 test_issue45 수정; claude 비차단 지적 N1(래퍼 모듈이 패키지 경유로 paho import — "stdlib only" 문서), N2(`_decide` 실패 시 `exit ""`→255, `${rc:-1}` 제안), N3(lib_int.sh 가 PATH python3 사용), N5(PR 본문 0.2 s vs 코드 0.5 s).
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: tmpl/multi-agent-mux#51