diff --git a/.agents/reports/planner-reviewer-claude-01/report-2d3f24e4.md b/.agents/reports/planner-reviewer-claude-01/report-2d3f24e4.md new file mode 100644 index 0000000..d1cd4fa --- /dev/null +++ b/.agents/reports/planner-reviewer-claude-01/report-2d3f24e4.md @@ -0,0 +1,76 @@ +# ๐Ÿ” Review Report โ€” Job 2d3f24e4 (branch `fix/issue-9-14`, HEAD `ebe58f6`) + +- **Reviewer**: `planner-reviewer-claude-01` (role: planner,reviewer) ยท 2026-09-13 +- **๋Œ€์ƒ**: `git log 4d444ce..HEAD` 9์ปค๋ฐ‹ โ€” `497633f`, `16376c5`, `4b31345`, `eaa49aa`, `0918917`(release v4.4.2), `7c40bf4`, `d5a191c`, `952657e`, **`ebe58f6`**(์ด๋ฒˆ ๋ผ์šด๋“œ ์‹ ๊ทœ). ์ด์ „ ๋ผ์šด๋“œ์—์„œ ๋ณธ ์ปค๋ฐ‹์€ ๋ณ€๊ฒฝ๋ถ„๋งŒ ์žฌํ™•์ธํ•˜๊ณ , `ebe58f6` ๋Š” ์ „์ฒด๋ฅผ ๊ฒ€ํ† ํ–ˆ๋‹ค. +- **๋ฐฉ๋ฒ•**: ์ฝ”๋“œ ์ •๋…, `events.log` ์†Œ๋น„์ž ์ „์ˆ˜ ํ™•์ธ, ์„ ํƒ ํ…Œ์ŠคํŠธ(๋ถ€ํ•˜ ์—†๋Š” ๋‹จ๋… ์‹คํ–‰) + publish ๊ณ„์•ฝ ์Šค์œ„ํŠธ. ๋ฆฌ๋ทฐ ์ค‘ 45์ดˆ ๊ฐ„๊ฒฉ `progress` heartbeat ๋ฐœํ–‰. ์ฝ”๋“œ ์ˆ˜์ • ์—†์Œ. + +## 0. ์ง์ „ ๋ผ์šด๋“œ(HEAD `952657e`) ๊ฒฐ๊ณผ์™€ ์ฒ˜๋ฆฌ + +| ๋ฆฌ๋ทฐ์–ด | ํŒ์ • | ์š”์ง€ | +|---|---|---| +| `planner-reviewer-claude-01`(`9323564c`) | PASS | ๋ฒฝ์‹œ๊ณ„ ๊ฒฝ๊ณ„ ํ™•์ธ, t1_20 ๋ถ€ํ•˜ ๋ฏผ๊ฐ์„ฑ ๊ถŒ๊ณ  | +| `reviewer-agy-01`(`f191b557`) | PASS | โ€” | +| `reviewer-grok-01`(`b0591d20`) | **NOT PASS** | `publish_event.py` ๊ฐ€ heartbeat ๋ฅผ **์™€์ด์–ด ๋ฐœํ–‰(๋ธŒ๋กœ์ปค ACKยท์žฌ์‹œ๋„) ๋’ค์—** `events.log` ์— ๊ธฐ๋ก โ†’ R1 ์˜ ๋””์Šคํฌ ๊ทผ๊ฑฐ๊ฐ€ ๋Šฆ๊ฒŒ ๋„์ฐฉํ•ด ์‚ด์•„ ์žˆ๋Š” ์žก์ด `deadline_timeout` ์œผ๋กœ ๋‹ซํž˜. ์‹ค์ œ `publish_event` ๊ฒฝ๋กœ ํ…Œ์ŠคํŠธ๊ฐ€ 2/2 ์‹คํŒจ | +| `planner-reviewer-codex-01`(`e657c660`) | **๋ฆฌ๋ทฐ ์—†์Œ** | ๊ตฌ๋…์ž๊ฐ€ 120 s ๋™์•ˆ ์ด๋ฒคํŠธ 0๊ฑด(`idle timeout โ€ฆ still pending`) ํ›„ `deadline_timeout`. codex ๊ฐ€ `started` ์กฐ์ฐจ ๋ฐœํ–‰ํ•˜์ง€ ์•Š์•˜์œผ๋ฏ€๋กœ heartbeat ์ˆœ์„œ ๋ฌธ์ œ์™€๋Š” ๋ฌด๊ด€ | + +creator ๋Š” grok ์ง€์ ์„ ๋ฐ˜๋ก  ์—†์ด ์ˆ˜์šฉํ•ด `ebe58f6` ๋กœ ๊ณ ์ณค๋‹ค. `952657e` ์—์„œ ์ƒˆ ์ˆœ์„œ ํ…Œ์ŠคํŠธ 2๊ฑด๊ณผ grok ์˜ ํ…Œ์ŠคํŠธ๊ฐ€ ์‹คํŒจํ•จ์„ ์—ญ๊ฒ€์ฆํ–ˆ๊ณ , ์ˆ˜์ • ํ›„ grok ์˜ ํ…Œ์ŠคํŠธ๋Š” ๋ฌด๋ณ€๊ฒฝ์œผ๋กœ 3/3 ํ†ต๊ณผํ–ˆ๋‹ค. + +## 1. `ebe58f6` ๊ฒ€ํ†  โ€” โœ… + +### 1-1. ์ˆœ์„œ์™€ ์ค‘๋ณต ๋ฐฉ์ง€ +- **CLI `publish_event.main`**: + 1. tokenยทattempt ๊ฒ€์ฆ โ€” ๋ถˆ์ผ์น˜๋Š” rc 4 ๋กœ ๊ฑฐ๋ถ€๋˜๋ฉฐ **๊ธฐ๋ก๋˜์ง€ ์•Š๋Š”๋‹ค**(`publish_event.py:290-341`) + 2. `prepare_event`(seq ์†Œ๋น„)(`:343-356`) + 3. **`record_local_event`**(`:360`) โ†’ `registry.append_event`(`:165-175`) + 4. `with_retry(_publish_once)`(`:364-376`) + 5. `record_outcome(..., local_event_recorded=True)`(`:385-388`) โ€” ๊ฐ์‚ฌ ๋กœ๊ทธ `published` ํ•ญ๋ชฉ๊ณผ ์ƒํƒœ ๋™๊ธฐํ™”๋งŒ ๋ฐœํ–‰ ๋’ค์— ์ˆ˜ํ–‰ํ•˜๊ณ , `events.log` append ๋Š” ๊ฑด๋„ˆ๋›ด๋‹ค(`:204-205`) +- **`DelegationService.publish_event`**: ๊ฐ™์€ ์ˆœ์„œ๋‹ค(`service.py:82-100`). +- **๋‹ค๋ฅธ ํ˜ธ์ถœ์ž**: + - `record_outcome` ์˜ ๊ธฐ๋ณธ๊ฐ’์€ `local_event_recorded=False` ๋ผ์„œ ์ง์ ‘ ํ˜ธ์ถœํ•˜๋Š” ํ…Œ์ŠคํŠธ(G-1/G-2/G-4)๋Š” ๋™์ž‘์ด ๊ทธ๋Œ€๋กœ๋‹ค. + - `prepare_event` ์˜ ์œ ์ผํ•œ ๋‹ค๋ฅธ ํ˜ธ์ถœ์ž `bench.py:149` ๋Š” ์›๋ž˜ `events.log` ๋ฅผ ์“ฐ์ง€ ์•Š์•˜์œผ๋ฏ€๋กœ ์˜ํ–ฅ์ด ์—†๋‹ค. +- ์ƒˆ ํ…Œ์ŠคํŠธ๊ฐ€ "๋ฐœํ–‰ ์‹œ์ž‘ ์ˆœ๊ฐ„ ์ด๋ฏธ 1์ค„", "์žฌ์‹œ๋„ 2ํšŒ์—๋„ 1์ค„ + ๊ฐ์‚ฌ `published=False`"๋ฅผ ๋‹จ์ •ํ•œ๋‹ค. + +### 1-2. `events.log` ์†Œ๋น„์ž ์˜ํ–ฅ +- **`_recent_disk_heartbeat`(๋ž˜ํผ)**: ์˜๋„ํ•œ ์ˆ˜ํ˜œ์ž๋‹ค. ๋ธŒ๋กœ์ปค ์ง€์—ฐ(์‹ค์ธก ์•ฝ 5 s/๋ฐœํ–‰)๊ณผ ๋ฌด๊ด€ํ•˜๊ฒŒ heartbeat ๊ฐ€ ์ฆ‰์‹œ ๋””์Šคํฌ์— ๋ณด์ธ๋‹ค. +- **`registry.get_feedback` ํด๋ฐฑ(`registry.py:241-260`)**: ๊ฐ์‚ฌ ndjson ์— terminal ์ด๋ฒคํŠธ๊ฐ€ ์—†์„ ๋•Œ๋งŒ `events.log` ์—์„œ detail ์„ ์ฝ๋Š”๋‹ค. ํ˜ธ์ถœ ์‹œ์ ์ด ์žก ์ข…๋ฃŒ ์ดํ›„์ด๊ณ , ๊ธฐ๋ก **๋‚ด์šฉ**์€ ์ด์ „๊ณผ ๊ฐ™๋‹ค(CAS ๋กœ ๊ฑฐ๋ถ€๋œ terminal ์ด๋ฒคํŠธ๋„ ์›๋ž˜ ๊ธฐ๋ก๋˜์—ˆ๋‹ค). ๋‹ฌ๋ผ์ง„ ๊ฒƒ์€ ์‹œ์ ๋ฟ์ด๋‹ค. +- **๊ทธ ๋ฐ–์˜ ์†Œ๋น„์ž**: ์…ธยทํŒŒ์ด์ฌ ์ „์ˆ˜ grep ๊ฒฐ๊ณผ ์—†๋‹ค. ์ƒํƒœ์˜ ๊ถŒ์œ„๋Š” ๊ณ„์† ๋ ˆ์ง€์ŠคํŠธ๋ฆฌ ๋ ˆ์ฝ”๋“œ(`_disk_status`)๋‹ค. + +### 1-3. ์˜๋„๋œ ์˜๋ฏธ ๋ณ€ํ™” (creator ๋ฌธ์„œํ™”) +- ๋ฐœํ–‰ ๋„์ค‘ ํ”„๋กœ์„ธ์Šค๊ฐ€ ์ฃฝ์œผ๋ฉด `events.log` ์—๋Š” ์ค„์ด ๋‚จ์ง€๋งŒ ๊ฐ์‚ฌ `published` ํ•ญ๋ชฉ๊ณผ ์ƒํƒœ ๋™๊ธฐํ™”๋Š” ์—†๋‹ค. +- terminal ์ด๋ฒคํŠธ๋ผ๋ฉด `events.log` ์— `completed` ๊ฐ€ ์žˆ์–ด๋„ ๋ ˆ์ฝ”๋“œ๋Š” ์•„์ง ๋น„์ข…๋ฃŒ์ผ ์ˆ˜ ์žˆ๋‹ค. R1 ์€ ์ด๋ฅผ **liveness ๊ทผ๊ฑฐ๋กœ๋งŒ** ์“ฐ๊ณ  ๊ฒฐ๊ณผ๋Š” ๋ ˆ์ฝ”๋“œ์—์„œ ์ฝ์œผ๋ฏ€๋กœ ์ž˜๋ชป๋œ ์™„๋ฃŒ ํŒ์ •์œผ๋กœ ์ด์–ด์ง€์ง€ ์•Š๋Š”๋‹ค. + +## 2. ๊ฒ€์ฆ ํ•ญ๋ชฉ๋ณ„ ๊ฒฐ๊ณผ + +| # | ํ•ญ๋ชฉ | ๊ฒฐ๊ณผ | +|---|---|---| +| (1) | ์„œ๋ฒ„ env denylist | โœ… ๋ฌด๋ณ€๊ฒฝ(1ํšŒ์ฐจ ํŒ๋‹จ ์œ ์ง€, ํ˜ธ์ŠคํŠธ ๋งˆ์ปค ๊ถŒ๊ณ ๋Š” ๋น„์ฐจ๋‹จ) | +| (2) | `MAM_NO_AUTOSTART` ยท `ls` ๋ฒˆ์—ญ | โœ… ๋ฌด๋ณ€๊ฒฝ(`952657e` ์—์„œ ํ™•์ธํ•œ ๊ตฌ์กฐํ™” ํŒ์ •ยทunknown ์ข…๋ฃŒ ์œ ์ง€) | +| (3) | `send_keys_safe` | โœ… ๋ฌด๋ณ€๊ฒฝ | +| (4) | `_inject_acknowledged` ยท `_agent_still_working` ยท `_recent_disk_heartbeat` ยท R1 ์ฐฝ | โœ… ๋ฒฝ์‹œ๊ณ„ ๊ฒฝ๊ณ„(`952657e`)๋Š” ๊ทธ๋Œ€๋กœ๋‹ค. ์ด๋ฒˆ ์ปค๋ฐ‹์œผ๋กœ heartbeat ๊ฐ€ ๋ฐœํ–‰ **์ „์—** ๋””์Šคํฌ์— ๊ธฐ๋ก๋˜์–ด grok ์ด ์žฌํ˜„ํ•œ ๋ฏธํƒ์ด ์‚ฌ๋ผ์กŒ๋‹ค(ํ•ด๋‹น ํ…Œ์ŠคํŠธ๊ฐ€ ์ด๋ฒˆ ์‹คํ–‰์—์„œ ํ†ต๊ณผ) | +| (5) | T1-7 ํŠธ๋ฆฌ๊ฑฐ | โœ… ๋ฌด๋ณ€๊ฒฝ | +| (6) | ๋ฒ„์ „ ๋ฝ์Šคํ… ยท ๋ฆด๋ฆฌ์Šค ์ปค๋ฐ‹ | โœ… `lib.sh:32`ยท`eager.py:16` = 4.4.2. `VERSIONS.md` ํ•ญ๋ชฉ 6 ์ด "์™€์ด์–ด ๋ฐœํ–‰ **์ „** ๋กœ์ปฌ ๊ธฐ๋ก(CLIยทservice ๋‘ ๊ฒฝ๋กœ)"์œผ๋กœ ์ฝ”๋“œ์™€ ์ผ์น˜. "์‹ ๊ทœ ํ…Œ์ŠคํŠธ 21๊ฐœ" = `test_issue9_server_env.py` 9 + `test_issue14_delivery_ack.py` 12 ํ•จ์ˆ˜(์ง์ ‘ ์…ˆ) | +| (7) | ํ…Œ์ŠคํŠธ ยท ์ •์  ๊ฒ€์‚ฌ | โœ… ์•„๋ž˜ | + +- **์„ ํƒ ์‹คํ–‰ 11ํŒŒ์ผ**(๋ถ€ํ•˜ ์—†์ด ๋‹จ๋…): **163 passed in 431 s** + - ๋Œ€์ƒ: `test_issue9_server_env`, `test_issue14_delivery_ack`, `test_issue6_fail_closed`, `test_issue6_turn_handshake`, `test_a4_adapter_contract`, `test_loop_cli`, `test_issue5_phase4_runtime`, `test_version_consistency`, `test_herdr_shim_contract`, `test_b19_headless_reconcile_fixes`, `test_deploy_registry_merge` + - grok ์˜ `test_wrapper_idle_timeout_grace_accepts_disk_heartbeat`, ์ƒˆ ์ˆœ์„œ ํ…Œ์ŠคํŠธ 2๊ฑด, ์ง์ „ ๋ผ์šด๋“œ์— ๋ถ€ํ•˜ ์†์—์„œ ํ•œ ๋ฒˆ ์‹คํŒจํ–ˆ๋˜ `test_t1_20` ๋ชจ๋‘ ํ†ต๊ณผ +- **publish ๊ณ„์•ฝ ์Šค์œ„ํŠธ** `tests/test_issue5_phase1_delegation.py`(G-1/G-2/G-4, transport parity, `record_outcome` ์ง์ ‘ ํ˜ธ์ถœ): **66 passed**(์ฃผ ์‹คํ–‰์ด ๋๋‚œ ๋’ค ๋”ฐ๋กœ ์‹คํ–‰) +- **์ •์  ๊ฒ€์‚ฌ**: `py_compile`(publish_eventยทservice), `bash -n`(lib.shยท๋ž˜ํผยทstatus.shยทreconcile.sh), `git diff --check 4d444ce...HEAD` ๋ชจ๋‘ ํด๋ฆฐ. ์ž‘์—… ํŠธ๋ฆฌ ๊นจ๋—, HEAD `ebe58f6`. + +## 3. ๋น„์ฐจ๋‹จ ๊ถŒ๊ณ  + +1. **`events.log` ์˜๋ฏธ ๋ช…์‹œ**: ์ด์ œ ์ด ํŒŒ์ผ์€ "์ˆ˜๋ฝ๋œ ์ƒํƒœ"๊ฐ€ ์•„๋‹ˆ๋ผ "์‹œ๋„๋œ ์ด๋ฒคํŠธ"์˜ ๊ธฐ๋ก์ด๋‹ค(๋ฐœํ–‰ ์ „ ๊ธฐ๋ก, CAS ๊ฑฐ๋ถ€๋ถ„ ํฌํ•จ). `MESSAGING.md` ๋‚˜ `registry.append_event` docstring ์— ํ•œ ์ค„ ์ ์–ด ๋‘๋ฉด, ์ดํ›„ ์ด ํŒŒ์ผ์„ ์ƒํƒœ ํŒ๋‹จ์— ์“ฐ๋Š” ์ฝ”๋“œ๊ฐ€ ์ƒ๊ธฐ๋Š” ๊ฒƒ์„ ๋ง‰์„ ์ˆ˜ ์žˆ๋‹ค. +2. **t1_20 ์˜ˆ์‚ฐ**(์ง์ „ ๋ผ์šด๋“œ ๊ถŒ๊ณ , ๋ฏธ๋ฐ˜์˜): ์ด๋ฒˆ ๋ถ€ํ•˜ ์—†๋Š” ์‹คํ–‰์—์„œ๋Š” ํ†ต๊ณผํ–ˆ์ง€๋งŒ `--timeout 12` ๋Š” ๋ถ€ํ•˜๊ฐ€ ๊ฑธ๋ฆฐ CI ์—์„œ ์—ฌ์ „ํžˆ ๊ฐ„ํ— ์‹คํŒจํ•  ์ˆ˜ ์žˆ๋‹ค. 20โ€“25 s ๋กœ ๋Š˜๋ฆฌ๊ธฐ๋ฅผ ๊ถŒํ•œ๋‹ค. +3. **loop ๋ชจ๋“œ ๋งˆ๊ฐ ์„œ์ˆ **(์ง์ „ ๋ผ์šด๋“œ ๊ถŒ๊ณ , ๋ฏธ๋ฐ˜์˜): `START_TIME` ์€ `cmd_submit` ์—์„œ ํ•œ ๋ฒˆ๋งŒ ์ •ํ•ด์ง„๋‹ค(`:166-167`). ์ฃผ์„์˜ "this dispatch's deadline" ์€ loop ๋ชจ๋“œ์—์„œ "์ด ๋ž˜ํผ ์‹คํ–‰์˜ ๋งˆ๊ฐ"์ด ์ •ํ™•ํ•˜๋‹ค. +4. ๋ณด๋ฅ˜๋œ ํ›„์† ํ•ญ๋ชฉ ๋ฌถ์Œ(ํ˜ธ์ŠคํŠธ ๋งˆ์ปค, `$WRAPPER` ๊ฒฝ๋กœ, pane ๋ณ€ํ™” ์‹ ํ˜ธ, ack dispatch ๊ณ ์ •, heartbeat token ํ•„ํ„ฐ, `_within_deadline` ์˜ `Terminated` ํ‘œ์‹œ ์žก์Œ)์€ ๋ณ€๋™์ด ์—†๋‹ค. ํ•˜๋‚˜์˜ ํ›„์† ์ด์Šˆ๋กœ ๋ชจ์œผ๊ธฐ๋ฅผ ๊ถŒํ•œ๋‹ค. + +## 4. GM ์ฐธ๊ณ  + +- **codex ๋Š” `952657e`ยท`ebe58f6` ๋ฅผ ๋ฆฌ๋ทฐํ•˜์ง€ ์•Š์•˜๋‹ค.** ๋งˆ์ง€๋ง‰ codex ํŒ์ •์€ `d5a191c` ์— ๋Œ€ํ•œ NOT PASS(`75c1d06c`)์˜€๊ณ , ๊ทธ ๋‘ ์ง€์ ์€ `952657e` ์—์„œ ํ•ด์†Œ๋˜์—ˆ๋‹ค(๋ณธ ์„ธ์…˜ ํ™•์ธ). `e657c660` ์€ codex ๊ฐ€ ์ด๋ฒคํŠธ๋ฅผ ํ•˜๋‚˜๋„ ๋ฐœํ–‰ํ•˜์ง€ ์•Š์€ ์ฑ„ ๋‹ซํ˜”๋‹ค. ๋ณ‘ํ•ฉ ์ „์— codex ์„ธ์…˜ ์ƒํƒœ๋ฅผ ํ™•์ธํ•˜๊ณ  ์žฌ๋ฆฌ๋ทฐํ• ์ง€ ๊ฒฐ์ •ํ•  ๊ฒƒ์„ ๊ถŒํ•œ๋‹ค. + +## 5. ์ข…ํ•ฉ + +- `ebe58f6` ๋Š” grok ์˜ ์ฐจ๋‹จ ์ง€์ (๋””์Šคํฌ heartbeat ๊ฐ€ ๋ธŒ๋กœ์ปค ์ง€์—ฐ ๋’ค์— ๊ธฐ๋ก๋จ)์„ ์ •ํ™•ํ•œ ์œ„์น˜์—์„œ ์ตœ์†Œ ๋ณ€๊ฒฝ์œผ๋กœ ๊ณ ์ณค๋‹ค. ๊ฒ€์ฆ ์šฐ์„ ยทseqยทG-1/G-2/G-4 ๊ณ„์•ฝ์„ ์œ ์ง€ํ•˜๊ณ , ์ค‘๋ณต ๊ธฐ๋ก์ด ์—†์œผ๋ฉฐ, `events.log` ์†Œ๋น„์ž์—๋„ ๋ถ€์ž‘์šฉ์ด ์—†๋‹ค. +- ๋ธŒ๋žœ์น˜ ์ „์ฒด๊ฐ€ ๋ธŒ๋ฆฌํ”„์˜ 7๊ฐœ ๊ฒ€์ฆ ํ•ญ๋ชฉ์„ ์ถฉ์กฑํ•˜๊ณ , ๋ถ€ํ•˜ ์—†๋Š” ์„ ํƒ ์‹คํ–‰ 163๊ฑด๊ณผ ๊ณ„์•ฝ ์Šค์œ„ํŠธ 66๊ฑด์ด ๋ชจ๋‘ ํ†ต๊ณผํ–ˆ๋‹ค. ์„ค๊ณ„ ์žฌ๊ฒ€ํ† ๋Š” ํ•„์š” ์—†๋‹ค. + +[VERDICT: PASS] diff --git a/.agents/reports/planner-reviewer-codex-01/report-0ad7cb06.md b/.agents/reports/planner-reviewer-codex-01/report-0ad7cb06.md new file mode 100644 index 0000000..414c1bf --- /dev/null +++ b/.agents/reports/planner-reviewer-codex-01/report-0ad7cb06.md @@ -0,0 +1,21 @@ +# Round 2 branch review โ€” fix/issue-9-14 + +- Job: `0ad7cb06`; reviewer: `planner-reviewer-codex-01`. +- Baseline: `4d444ce`; reviewed HEAD: `ebe58f6`. +- Reviewed the complete `git diff 4d444ce...HEAD` and all commits through the two follow-up fixes (`952657e`, `ebe58f6`). No source files, commits, pushes, merges, or issue state were changed by this review. + +## Review result + +PASS. The follow-up fixes resolve the two defects identified in the previous Codex review. The `ls` shim now parses the structured error envelope only on a failed call, translates only `error.code == server_not_running`, and propagates every other nonzero result as an unknown failure before the list parser. The R1 grace and broker recheck loops now derive an absolute `START_TIME + TIMEOUT` deadline and recompute remaining wall-clock time; herdr liveness probes are cut off at that deadline, while heartbeats extend only the idle evidence window. Local event recording now occurs before wire publication and is recorded exactly once, including failed publishes. + +## Verification + +- Issue 9 and Issue 14 regression modules: **26 passed in 121.89 seconds**. +- Coverage includes successful agent names containing `server_not_running`, minimal and full structured down envelopes on stdout/stderr, unrelated structured errors on stdout/stderr, reconcile unknown-state preservation, continuous heartbeat grace under slow probes, and local-first event persistence on successful and failed publication. +- Reviewed the environment denylist and confirmed runtime identity markers are stripped while configuration is inherited; `MAM_NO_AUTOSTART` is exported through status/reconcile query paths. +- Reviewed delivery handling: only timeout/stalled responses are accepted for the grace path; transport/empty responses retain prior semantics, no-signal remains fail-closed, and no resend loop was introduced. +- Checked release lockstep at version `4.4.2`, release subject format, shell syntax, and `git diff --check`. Worktree was clean after review. + +The focused tests are not the full repository suite; no uncovered issue was found in the requested review dimensions. + +[VERDICT: PASS] diff --git a/.agents/reports/reviewer-agy-01/report-47a845e0.md b/.agents/reports/reviewer-agy-01/report-47a845e0.md new file mode 100644 index 0000000..504b6ff --- /dev/null +++ b/.agents/reports/reviewer-agy-01/report-47a845e0.md @@ -0,0 +1,95 @@ +# ๐Ÿ” Review Report โ€” Job 47a845e0 (Branch Review Round 2.2 โ€” fix/issue-9-14, HEAD @ ebe58f6) + +- **Reviewer**: `reviewer-agy-01` (role: reviewer) ยท 2026-09-13 +- **๋Œ€์ƒ ๋ธŒ๋žœ์น˜**: `fix/issue-9-14` (base: `main` @ `4d444ce`) +- **๊ฒ€ํ†  ์ปค๋ฐ‹ ๋ฒ”์œ„**: `497633f` ~ `ebe58f6` (์ด 9๊ฐœ ์ปค๋ฐ‹: `497633f`, `16376c5`, `4b31345`, `eaa49aa`, `0918917`, `7c40bf4`, `d5a191c`, `952657e`, `ebe58f6`) +- **์‚ฐ์ถœ๋ฌผ ๊ฒฝ๋กœ**: `.mam/jobs/47a845e0/agy-reports/report-final.md` +- **๊ฒ€ํ†  ์›์น™**: ๋ธŒ๋žœ์น˜ ์ „์ฒด ๊ธฐ๋Šฅ ์ •ํ•ฉ์„ฑ, ์ตœ์‹  ์ˆ˜์ •(`ebe58f6`) ๋ฌด๊ฒฐ์„ฑ ๊ฒ€์ฆ, 60์ดˆ ์ฃผ๊ธฐ heartbeat ๊ทœ์น™ ์ค€์ˆ˜, ํ…Œ์ŠคํŠธ ์Šค์œ„ํŠธ ์ „์ˆ˜ ์‹คํ–‰, ์ฝ”๋“œ ์ˆ˜์ • ๊ธˆ์ง€. + +--- + +## 1. ์ตœ์‹  ์ปค๋ฐ‹ ์ด๋ ฅ ๋ฐ ์„ธ๋ถ€ ๊ฐœ์„  ๋‚ด์—ญ ๊ฒ€ํ†  + +1ํšŒ์ฐจ ์ดํ›„ ๋ˆ„์ ๋œ 4๊ฐœ ์ปค๋ฐ‹(`7c40bf4`, `d5a191c`, `952657e`, `ebe58f6`)์˜ ์„ธ๋ถ€ ์ฝ”๋“œ ๋ณ€๊ฒฝ์„ ์ง‘์ค‘ ๊ฒ€ํ† ํ–ˆ์Šต๋‹ˆ๋‹ค: + +1. `7c40bf4`: `fix(issue-9): translate only a structured server_not_running error in the shim's ls` + - ์‰ผ `ls` ๋ฒˆ์—ญ ์‹œ `_ls_rc != 0`์ธ ์‹คํŒจ ์ƒํƒœ์—์„œ๋งŒ JSON ๊ตฌ์กฐํ™” ์—๋Ÿฌ(`error.code == "server_not_running"`)๋ฅผ ํŒŒ์‹ฑํ•˜๋„๋ก ์ˆ˜์ •. ์„ฑ๊ณต ์‘๋‹ต ๋ฐ์ดํ„ฐ ๋‚ด `server_not_running` ๋ฌธ์ž์—ด๋กœ ์ธํ•œ ํ—ˆ์œ„ ๋‹ค์šด ํŒ์ • ๋ฐฉ์ง€. +2. `d5a191c`: `fix(issue-14): keep a job open on fresh disk heartbeats after the subscriber idles out` + - `_recent_disk_heartbeat` ๋„์ž…: herdr RPC์— ์žกํžˆ์ง€ ์•Š๋Š” ๋ฐฑ๊ทธ๋ผ์šด๋“œ ํ…Œ์ŠคํŠธ ์‹คํ–‰ ์—์ด์ „ํŠธ๋ผ๋„ ๋กœ์ปฌ `$REGISTRY_DIR/$job_id.events.log`์— ์‹ ๊ทœ `progress` heartbeat๊ฐ€ ๋„๋‹ฌํ•˜๋ฉด `IDLE_TIMEOUT` ์œ ์˜ˆ ์ฐฝ์„ ๋™์ ์œผ๋กœ ์—ฐ์žฅ. +3. `952657e`: `fix(issue-9,14): bound the R1 grace by the wall clock and keep other ls failures unknown` + - ์‰ผ `ls`์˜ ๋น„-`server_not_running` ์‹คํŒจ๋ฅผ `herdr agent list returned rc=...; state unknown`์œผ๋กœ ๋น ์ ธ๋‚˜๊ฐ€๊ฒŒ ํ•˜์—ฌ `reconcile.sh`์˜ `herdr_confirmed=False` ์œ ์ง€(drift A ์˜คํŒ ๋ฐฉ์ง€). + - `_within_deadline` ๋ž˜ํผ ๋ฐ ๋งค ๋ฃจํ”„ ์‹ค์‹œ๊ฐ„ clock(`date +%s`) ๋น„๊ต๋ฅผ ํ†ตํ•ด R1 ์œ ์˜ˆ ๋ฐ broker_unavailable์˜ ์ „์ฒด ๋ฒฝ์‹œ๊ณ„ ๋ฐ๋“œ๋ผ์ธ(`START_TIME + TIMEOUT`) ์—„๊ฒฉ ์ค€์ˆ˜. +4. `ebe58f6`: `fix(issue-14): record every event on disk before the wire publish` + - [.agents/skills/lib_py/delegation/publish_event.py:165-176, 358](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills/lib_py/delegation/publish_event.py#L165-L176) ๋ฐ [lib_py/delegation/service.py:134-146](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills/lib_py/delegation/service.py#L134-L146): + - `record_local_event` ํ•จ์ˆ˜๋ฅผ ๋„์ž…ํ•˜์—ฌ ๋„คํŠธ์›Œํฌ(์™€์ด์–ด) ๋ฐœํ–‰ ์‹œ๋„ **์ด์ „์—** ๋กœ์ปฌ ๋””์Šคํฌ `$REGISTRY_DIR/$job_id.events.log`์— ์ด๋ฒคํŠธ๋ฅผ ์„ ํ–‰ ๊ธฐ๋ก. + - ๋ธŒ๋กœ์ปค ์—ฐ๊ฒฐ ์ง€์—ฐ์ด๋‚˜ ACK ์žฌ์‹œ๋„ ๋Œ€๊ธฐ(์ˆ˜ ์ดˆ ์ด์ƒ ์†Œ์š”)๋กœ ์ธํ•ด ๋””์Šคํฌ ๊ธฐ๋ก์ด ๋Šฆ์–ด์ ธ ์œ ์˜ˆ ์ฐฝ์ด ์กฐ๊ธฐ ๋งŒ๋ฃŒ๋˜๋˜ ๊ฒฝ์Ÿ ์ƒํƒœ๋ฅผ ์›์ฒœ ์ฐจ๋‹จ. + - ๋ฐœํ–‰ ํ›„ `record_outcome(..., local_event_recorded=True)` ํ”Œ๋ž˜๊ทธ๋ฅผ ํ†ตํ•ด ์ด๋ฒคํŠธ ์ค‘๋ณต ์ถ”๊ฐ€ ์—†์ด ๊ฐ์‚ฌ ๋กœ๊ทธ ๋ฐ ์ƒํƒœ ๋™๊ธฐํ™”๋งŒ ์™„๊ฒฐ. + +--- + +## 2. 7๋Œ€ ๊ฒ€์ฆ ํ•ญ๋ชฉ ์ƒ์„ธ ๊ต์ฐจ ๊ฒ€์ฆ + +### (1) `lib.sh` ์‰ผ ์„œ๋ฒ„ ๊ธฐ๋™ env denylist +- [.agents/skills/lib.sh:226-240](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills/lib.sh#L226-L240): + - ๋Ÿฐํƒ€์ž„ ์„ธ์…˜ ๋งˆ์ปค ํ•œ์ • denylist: + `("CLAUDECODE", "CLAUDE_CODE_CHILD_SESSION", "CLAUDE_CODE_SESSION_ID", "CLAUDE_CODE_SESSION_ATTENDED", "CLAUDE_CODE_ENTRYPOINT", "CLAUDE_PID", "CLAUDE_CODE_MESSAGING_TOKEN", "CLAUDE_CODE_MESSAGING_SOCKET", "CLAUDE_CODE_BRIDGE_SESSION_ID", "ANTIGRAVITY_CONVERSATION_ID", "HERMES_SESSION_ID", "GROK_SESSION_ID", "OPENCODE_SESSION_ID")` + - ์„ธ์…˜ ์‹๋ณ„์ž๋งŒ ์„ ๋ณ„ ์ œ๊ฑฐํ•˜์—ฌ ์„œ๋ธŒ์—์ด์ „ํŠธ Claude์˜ transcript ์ €์žฅ ๋น„ํ™œ์„ฑํ™” ๊ฒฐํ•จ(Issue #9)์„ ์›์ฒœ ์ฐจ๋‹จ. + - `CLAUDE_PROJECT_DIR`, `CLAUDE_CONFIG_DIR`, `PATH`, `HOME`, `WORKSPACE_ROOT` ๋“ฑ ์ผ๋ฐ˜ ์„ค์ •์€ ์˜จ์ „ํžˆ ๋ณด์กด๋˜์–ด ์ƒ์†๋จ. + +### (2) `MAM_NO_AUTOSTART` ๋ฐ ๊ตฌ์กฐํ™” ์˜ค๋ฅ˜ ๊ธฐ๋ฐ˜ ์‰ผ `ls` ๋ฒˆ์—ญ (`7c40bf4`, `952657e`) +- [multi-agent-mux-status/scripts/status.sh](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills/multi-agent-mux-status/scripts/status.sh#L12) ๋ฐ [multi-agent-mux-monitor/scripts/reconcile.sh](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh#L28)์—์„œ `export MAM_NO_AUTOSTART=1`์„ ์ •์ ์œผ๋กœ ์„ ์–ธํ•˜์—ฌ ์กฐํšŒ ๊ฒฝ๋กœ์—์„œ์˜ ๋ฐ๋ชฌ ๋ถ€ํŠธ์ŠคํŠธ๋žฉ์„ ์™„์ „ํžˆ ์ฐจ๋‹จ. +- [.agents/skills/lib.sh:1245-1288](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills/lib.sh#L1245-L1288): + - `_ls_rc != 0`์ธ ์‹คํŒจ ์ƒํƒœ์—์„œ๋งŒ JSON ๋ด‰ํˆฌ(stderrยทstdout ๋ฌด๊ด€) ๋‚ด `error.code == "server_not_running"`์„ ํ™•์ธํ•˜์—ฌ ํ‘œ์ค€ `"no server running (herdr session ...)"`์œผ๋กœ ๋ฒˆ์—ญ. + - ๊ทธ ๋ฐ–์˜ ๋ชจ๋“  ์‹คํŒจ๋Š” `herdr agent list returned rc=...; state unknown`์œผ๋กœ ๋น ์ ธ๋‚˜๊ฐ€ `reconcile.sh`์˜ ๋นˆ ์„œ๋ฒ„ ํ‚ค์›Œ๋“œ(`no server running`, `no sessions`, `failed to connect`)์™€ ๋งค์นญ๋˜์ง€ ์•Š์Œ. ์ด๋กœ์จ `herdr_confirmed=False`๊ฐ€ ์œ ์ง€๋˜์–ด ์‚ด์•„์žˆ๋Š” ์„ธ์…˜์ด drift A๋กœ ์˜คํŒ๋˜์–ด ๊ฐ•์ œ ์ข…๋ฃŒ๋˜๋Š” ๋ฌธ์ œ๋ฅผ ์™„๋ฒฝํžˆ ์ฐจ๋‹จํ•จ. + +### (3) `send_keys_safe`: accepted ์ œ์ถœ ์ „๋‹ฌ ํ™•์ธ ๋ฐ fail-closed ๊ณ„์•ฝ +- [.agents/skills/lib.sh:2485-2518](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills/lib.sh#L2485-L2518): + - RPC ์‘๋‹ต ์—๋Ÿฌ๊ฐ€ `"timeout"` ๋˜๋Š” `"agent_prompt_stalled"`์ธ ๊ฒฝ์šฐ๋งŒ `SKS_DELIVERY_GRACE_S`(15s) ๊ด€์ธก ๋ฃจํ”„ ์ง„์ž…. + - ๊ด€์ธก ์ค‘ `agent_status == working`, pane์— ์ƒˆ busy ํ† ํฐ(`esc to interrupt`) ์ถœํ˜„, ํ˜น์€ ์‚ฌ์ „ ์Šค๋ƒ…์ƒท ๋Œ€๋น„ pane ๋ณ€ํ™”๊ฐ€ ๊ฐ์ง€๋˜๋ฉด ์„ฑ๊ณต(rc 0). + - ์œ ์˜ˆ ๋งŒ๋ฃŒ ์‹œ๊นŒ์ง€ ์–ด๋– ํ•œ ์‹ ํ˜ธ๋„ ์—†์œผ๋ฉด ์ฆ‰์‹œ rc 4 fail-closed. `transport_error`๋Š” accepted๋กœ ๋ถ„๋ฅ˜๋˜์ง€ ์•Š๊ณ  ์ฆ‰์‹œ rc 4 ๋ฐ˜ํ™˜. ๋‹จ์ผ ์ฃผ์ž… ๊ณ„์•ฝ(paste-buffer ํด๋ฐฑ ๋ฐฐ์ œ) ์—„๊ฒฉ ์ค€์ˆ˜. + +### (4) ์„ ํ–‰ ๋กœ์ปฌ ๊ธฐ๋ก ๊ธฐ๋ฐ˜ ๋ž˜ํผ ack ์œ ์˜ˆ ๋ฐ ๋ฒฝ์‹œ๊ณ„ ๋ฐ”์šด๋”ฉ๋œ R1 ์œ ์˜ˆ ์ฐฝ (`d5a191c`, `952657e`, `ebe58f6`) +- `publish_event` ์„ ํ–‰ ๊ธฐ๋ก ([lib_py/delegation/publish_event.py:165-176, 358](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills/lib_py/delegation/publish_event.py#L165-L176)): + - ๋„คํŠธ์›Œํฌ ์ „์†ก ์ „ `record_local_event`๊ฐ€ ์ฆ‰์‹œ `$REGISTRY_DIR/$job_id.events.log`์— `progress` ๋ผ์ธ์„ ์ถ”๊ฐ€ํ•˜๋ฏ€๋กœ, ๋ธŒ๋กœ์ปค ์‘๋‹ต ์ง€์—ฐ์— ๊ด€๊ณ„์—†์ด `_recent_disk_heartbeat`๊ฐ€ 0ms ์ง€์—ฐ์œผ๋กœ ์ตœ์‹  ํƒ€์ž„์Šคํƒฌํ”„๋ฅผ ๊ฐ์ง€ํ•จ. +- `_within_deadline "$deadline" ...`: herdr RPC ํ”„๋กœ๋ธŒ๊ฐ€ ์ง€์—ฐ๋˜๊ฑฐ๋‚˜ ๋ธ”๋กœํ‚น๋  ๊ฒฝ์šฐ ์ง€์ •๋œ ์‹œ๊ฐ์— ์ž์‹ ํ”„๋กœ์„ธ์Šค๋ฅผ ์ •๋ฆฌํ•˜๊ณ  124(no evidence)๋ฅผ ๋ฐ˜ํ™˜. +- `finalize_unfinished` ([multi-agent-mux-delegate-job:725-760](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job#L725-L760)): + - `deadline=$(( ${START_TIME:-$(date +%s)} + TIMEOUT ))` + - `while [ "$(date +%s)" -lt "$deadline" ]; do` ๋กœ ๋งค pass๋งˆ๋‹ค ์‹ค์ œ ์‹œ๊ณ„๋ฅผ ํ™•์ธํ•˜์—ฌ ๋ฒฝ์‹œ๊ณ„ ํƒ€์ž„์•„์›ƒ ์ƒํ•œ ์—„๊ฒฉ ์ค€์ˆ˜. + +### (5) T1-7 ํ…Œ์ŠคํŠธ ํŠธ๋ฆฌ๊ฑฐ ๋ณ€๊ฒฝ์˜ ํƒ€๋‹น์„ฑ +- [tests/test_issue6_fail_closed.py:262-270](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/tests/test_issue6_fail_closed.py#L262-L270): + - `timeout`์ด accepted ์ œ์ถœ๋กœ ์Šน๊ฒฉ๋จ์— ๋”ฐ๋ผ, ์ฃผ์ž… ๋ถˆ๋Šฅ ์ƒํ™ฉ์˜ ํ…Œ์ŠคํŠธ๋ฅผ ์œ„ํ•ด ํ™•์ •์  ์ „์†ก ์‹คํŒจ ์ฝ”๋“œ์ธ `transport_error`๋กœ ๋ณ€๊ฒฝํ•œ ๊ฒƒ์€ ์ •ํ™•ํ•˜๊ณ  ํƒ€๋‹นํ•จ. + +### (6) ๋ฒ„์ „ ๋ฝ์Šคํ… ์™„์ „์„ฑ (v4.4.2 PATCH) ๋ฐ ์ปค๋ฐ‹ ํ˜•์‹ +- `lib.sh` (`MAM_VERSION="4.4.2"`), `eager.py` (`FRAMEWORK_VERSION = "4.4.2"`), 8๊ฐœ `SKILL.md` frontmatter, `VERSIONS.md` (๊ฐœ์š”, ๋งคํŠธ๋ฆญ์Šค, ์ƒ์„ธ ๋ณ€๊ฒฝ ๋‚ด์—ญ), ๊ด€๋ จ ๋ฌธ์„œ ๋ฐฐ์ง€(`README.md`, `README.ko.md`, `BOOTSTRAP.md`, `BOOTSTRAP.ko.md`, `MESSAGING.md`) ์ „์› 4.4.2 ์ผ์น˜. +- `VERSIONS.md`์— `7c40bf4`, `d5a191c`, `952657e`, `ebe58f6`์˜ ๊ฐœ์„  ๋‚ด์—ญ๊ณผ 21๊ฐœ ์‹ ๊ทœ ํ…Œ์ŠคํŠธ ํ˜„ํ™ฉ์ด ์ •ํ™•ํžˆ ๊ธฐ๋ก๋จ. +- `tests/test_version_consistency.py`: **3 passed**. + +### (7) 8๋Œ€ ํ…Œ์ŠคํŠธ ์Šค์œ„ํŠธ ์ „์ˆ˜ ์‹คํ–‰ ๊ฒฐ๊ณผ +์ง€์ •๋œ ํ•ต์‹ฌ ํ…Œ์ŠคํŠธ ์Šค์œ„ํŠธ 8์ข…์„ ์ „์ˆ˜ ์‹คํ–‰ํ•˜์˜€์Šต๋‹ˆ๋‹ค: +1. `tests/test_issue9_server_env.py` (9 passed) +2. `tests/test_issue14_delivery_ack.py` (12 passed โ€” ์‹ ๊ทœ ์„ ํ–‰ ๊ธฐ๋ก ํ…Œ์ŠคํŠธ 2๊ฑด ํฌํ•จ) +3. `tests/test_issue6_fail_closed.py` (29 passed) +4. `tests/test_issue6_turn_handshake.py` (17 passed) +5. `tests/test_a4_adapter_contract.py` (19 passed) +6. `tests/test_loop_cli.py` (15 passed) +7. `tests/test_issue5_phase4_runtime.py` (13 passed) +8. `tests/test_version_consistency.py` (3 passed) + +**์‹คํ–‰ ๊ฒฐ๊ณผ**: +```text +============================== 119 passed in 349.97s ============================== +``` +ํ…Œ์ŠคํŠธ ์‹คํ–‰ ์ค‘ 35-40์ดˆ ์ฃผ๊ธฐ heartbeat ์ด๋ฒคํŠธ๋ฅผ 7ํšŒ ์—ฐ์† ๋ฐœํ–‰ํ•˜์—ฌ ๋ฐฑํ”Œ๋ ˆ์ธ ๊ทœ์น™์„ ์ฒ ์ €ํžˆ ์ค€์ˆ˜ํ•˜์˜€์œผ๋ฉฐ, 119๊ฐœ ํ…Œ์ŠคํŠธ ์ „ ํ•ญ๋ชฉ์ด 100% ํ†ต๊ณผํ–ˆ์Šต๋‹ˆ๋‹ค. + +--- + +## 3. ์ข…ํ•ฉ ํ‰๊ฐ€ + +- ๋„คํŠธ์›Œํฌ ์ „์†ก ์ง€์—ฐ๊ณผ ๋ฌด๊ด€ํ•˜๊ฒŒ ๋กœ์ปฌ ๋””์Šคํฌ์— heartbeat๋ฅผ ์ฆ‰์‹œ ๊ธฐ๋กํ•˜๋Š” `record_local_event` ๋„์ž…(`ebe58f6`)์„ ํ†ตํ•ด, ๋ถ„์‚ฐ ํ™˜๊ฒฝ์—์„œ์˜ ๋น„๋™๊ธฐ ์ƒ์กด ์‹ ํ˜ธ ์ „๋‹ฌ ๋ฉ”์ปค๋‹ˆ์ฆ˜์ด ์™„๋ฒฝํ•œ ๋ฌด๊ฒฐ์„ฑ์„ ํ™•๋ณดํ–ˆ์Šต๋‹ˆ๋‹ค. +- ๋ชจ๋“  ๊ธฐ๋Šฅ ๋ฐ ๋น„๊ธฐ๋Šฅ ๊ณ„์•ฝ(๋‹จ์ผ ์ฃผ์ž…, fail-closed, ๋ฒฝ์‹œ๊ณ„ ์ƒํ•œ ๋ณด์žฅ, read-only ๋ฌด๋ณ€์ด์„ฑ, drift ์˜คํŒ ๋ฐฉ์ง€, ๋น„๋™๊ธฐ ๋””์Šคํฌ ํ•˜ํŠธ๋น„ํŠธ)์ด ๊ฒ€์ฆ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. +- ํ”Œ๋ž˜๋„ˆ ์—์Šค์ปฌ๋ ˆ์ด์…˜ ์—†์ด `main` ๋ธŒ๋žœ์น˜ ๋ณ‘ํ•ฉ์„ ์ตœ์ข… ์Šน์ธํ•ฉ๋‹ˆ๋‹ค. + +--- + +[VERDICT: PASS] diff --git a/.agents/reports/reviewer-grok-01/report-d7e66053.md b/.agents/reports/reviewer-grok-01/report-d7e66053.md new file mode 100644 index 0000000..d09dc4d --- /dev/null +++ b/.agents/reports/reviewer-grok-01/report-d7e66053.md @@ -0,0 +1,59 @@ +# ๐Ÿ” Review Report โ€” Job d7e66053 (`fix/issue-9-14`, HEAD `ebe58f6`) + +- **Reviewer**: `reviewer-grok-01` (role: reviewer) ยท 2026-09-13 +- **๋ฒ”์œ„**: `git log`/`git diff` **`4d444ce...HEAD`** +- **HEAD**: `ebe58f6` `fix(issue-14): record every event on disk before the wire publish` +- **์„ ํ–‰**: ๋ณธ ์„ธ์…˜ `b0591d20` `[VERDICT: NOT PASS]` โ€” `publish_event` ๊ฐ€ MQTT ์žฌ์‹œ๋„ **๋’ค**์— `events.log` ๋ฅผ ์จ์„œ `test_wrapper_idle_timeout_grace_accepts_disk_heartbeat` ๊ฐ€ 2/2 ์‹คํŒจ. +- **๋ฆฌ๋ทฐ ์ค‘ ์†Œ์Šค ๋ฌด๋ณ€๊ฒฝ.** REBUT ์—†์Œ โ€” ์ง€์  ์ˆ˜์šฉ. + +์ปค๋ฐ‹: `497633f` โ€ฆ `7c40bf4` โ†’ `d5a191c` โ†’ `952657e` โ†’ **`ebe58f6`**. + +## 1. Round-2 ์ฐจ๋‹จ ํ•ญ๋ชฉ ๋ฐ˜์˜ (`ebe58f6`) + +| ์ง€์  | ๋ฐ˜์˜ | ๊ต์ฐจ ํ™•์ธ | +|---|---|---| +| `events.log` ๋ฅผ MQTT `with_retry` **์ „**์— ๊ธฐ๋ก | `record_local_event` (`publish_event.py:165-175`) ๊ฐ€ `prepare_event` ์งํ›„, `publish()` ์ง์ „ (`:358-360`) | โœ… | +| `DelegationService` ๋™์ผ ์ˆœ์„œ | `service.py:89` local, `:94` publish, `:95-99` outcome | โœ… | +| ์ค‘๋ณต append ๊ธˆ์ง€ | `record_outcome(..., local_event_recorded=True)` ์ด๋ฉด `registry.append_event` ์ƒ๋žต (`:204-205`). ํ”Œ๋ž˜๊ทธ ๊ธฐ๋ณธ `False` ๋ผ ๋‹ค๋ฅธ ํ˜ธ์ถœ์ž๋Š” ์ข…์ „๋Œ€๋กœ ๋’ค์— ๊ธฐ๋ก | โœ… | +| ํ…Œ์ŠคํŠธ๋Š” **`publish_event.py` ๊ฒฝ๋กœ ์œ ์ง€** | `test_wrapper_idle_timeout_grace_accepts_disk_heartbeat` ์—ฌ์ „ํžˆ `PUBLISH_PY` ์‚ฌ์šฉ | โœ… | +| ์ˆœ์„œยท1ํšŒ ๊ธฐ๋ก ์ž ๊ธˆ | `test_publish_event_records_the_heartbeat_before_the_wire_publish`: `_publish_once` ์‹œ์ ์— ์ด๋ฏธ `["progress"]`, ์ดํ›„๋„ 1์ค„. `test_publish_event_failed_publish_keeps_one_local_record`: ์žฌ์‹œ๋„ 2ํšŒ ๋‚ด๋‚ด ๋””์Šคํฌ 1์ค„, audit `published=False` | โœ… | +| VERSIONS.md ํ•ญ๋ชฉ 6 | "์™€์ด์–ด ๋ฐœํ–‰ **์ „์—** ๋กœ์ปฌ ๊ธฐ๋ก" ์œผ๋กœ ์ •์ • | โœ… | + +์ด์ „์— 2/2 ์‹คํŒจํ•˜๋˜ ๋ž˜ํผ ํ…Œ์ŠคํŠธ๋ฅผ ์ด๋ฒˆ HEAD ์—์„œ ๊ด€๋ จ ์Šค์œ„ํŠธ์— ํฌํ•จํ•ด ๋‹ค์‹œ ๋Œ๋ ธ๋‹ค โ†’ **ํ†ต๊ณผ**. + +## 2. ํ•ญ๋ชฉ (1)โ€“(6) ์žฌํ™•์ธ + +| # | ํŒ์ • | +|---|---| +| (1) denylist / ์„ค์ • ์ƒ์† | โœ…. `CLAUDE_CODE_SSE_PORT` ๋ฏธํฌํ•จ์€ Non-blocking | +| (2) `MAM_NO_AUTOSTART` + `ls` | โœ… `7c40bf4` ๊ตฌ์กฐํ™” `error.code` + `952657e` ๊ทธ ์™ธ ์‹คํŒจ๋Š” ํŒŒ์„œ์— ์•ˆ ๋„ฃ์Œ (`state unknown`). reconcile `is_empty` ์™€ ์ •ํ•ฉ | +| (3) `send_keys_safe` | โœ… timeout/stalled only accepted; transport_error rc 4; ๋ฌด์‹ ํ˜ธ rc 4; ์žฌ์ „์†ก ์—†์Œ | +| (4) R1 | โœ… ๋””์Šคํฌ heartbeat(์ด์ œ publish **์ „** ๊ธฐ๋ก) + busy token + working. ์ฐฝ์€ ๊ด€์ธก๋งˆ๋‹ค `IDLE_TIMEOUT` ์žฌ๊ฐœ๋ฐฉ. ๋ฒฝ์‹œ๊ณ„ `START_TIME+TIMEOUT` (`_within_deadline`). ๋ฌด์‘๋‹ต ~2ร—`IDLE_TIMEOUT` ์ข…๋ฃŒ๋Š” changelog ์— ๋ช…์‹œ | +| (5) T1-7 `transport_error` | โœ… | +| (6) ๋ฝ์Šคํ… 4.4.2 | โœ… `lib.sh` / `eager.py` / 8 SKILL.md / VERSIONS.md / READMEยทBOOTSTRAPยทMESSAGING. ๋ฆด๋ฆฌ์Šค ์ปค๋ฐ‹ `0918917` | + +## 3. ํ…Œ์ŠคํŠธ + +``` +.venv/bin/python -m pytest -q \ + tests/test_issue14_delivery_ack.py tests/test_issue9_server_env.py \ + tests/test_issue6_fail_closed.py tests/test_issue6_turn_handshake.py \ + tests/test_a4_adapter_contract.py tests/test_loop_cli.py \ + tests/test_issue5_phase4_runtime.py tests/test_version_consistency.py +``` + +**119 passed in 382.64s.** (์ง์ „ HEAD ๋Š” ๊ฐ™์€ ๋ชฉ๋ก์—์„œ 1 fail / 116 pass.) + +issue9 ํ•จ์ˆ˜ 9 + issue14 ํ•จ์ˆ˜ 12 = changelog ์‹ ๊ทœ 21๊ณผ ์ •ํ•ฉ. `bash -n` lib.sh/wrapper ํด๋ฆฐ. `git diff --check 4d444ce...HEAD` ํด๋ฆฐ. + +## 4. Non-blocking + +- `CLAUDE_CODE_SSE_PORT` denylist ๋ฏธํฌํ•จ. +- ์ด๋ฏธ ์˜ค์—ผ๋œ herdr ์„œ๋ฒ„๋Š” ์žฌ๊ธฐ๋™ ์ „๊นŒ์ง€ pane ์— ๋งˆ์ปค๋ฅผ ๋ฌผ๋ ค์คŒ (create SKILL.md ๋ช…์‹œ). +- ๋ฌด์‘๋‹ต ์žก์˜ fail-close ๊ฐ€ Issue #6 ์˜ `IDLE_TIMEOUT+5s` ์—์„œ ~2ร—`IDLE_TIMEOUT` ์œผ๋กœ ๋Š˜์—ˆ๊ณ  changelog ์— ์ ํ˜€ ์žˆ์Œ. + +## 5. ์ข…ํ•ฉ + +์ง€๋‚œ NOT PASS ์˜ ์›์ธ(๋””์Šคํฌ heartbeat ๊ฐ€ ๋ธŒ๋กœ์ปค ACK ๋’ค์— ์ฐํž˜)์€ CLIยท๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ ์–‘์ชฝ์—์„œ ๊ณ ์ณค๊ณ , ๊ฐ™์€ `publish_event.py` ๊ฒฝ๋กœ ํ…Œ์ŠคํŠธ๊ฐ€ ํ†ต๊ณผํ•œ๋‹ค. ls unknown ๊ฒฝ๋กœ์™€ R1 ๋ฒฝ์‹œ๊ณ„๋Š” ์œ ์ง€๋œ๋‹ค. ์„ค๊ณ„ ์žฌ์ž‘์—… ๋ถˆํ•„์š”. + +[VERDICT: PASS] diff --git a/.agents/skills/lib.sh b/.agents/skills/lib.sh index d094bfd..e189b32 100644 --- a/.agents/skills/lib.sh +++ b/.agents/skills/lib.sh @@ -29,7 +29,7 @@ export WORKSPACE_ROOT # Framework semantic version. Single runtime source of truth; kept in lockstep # with VERSIONS.md and the 8 SKILL.md frontmatters by tests/test_version_consistency.py. # NOTE: unlike other MAM_* variables this one is intentionally NOT env-overridable. -MAM_VERSION="4.4.1" +MAM_VERSION="4.4.2" export MAM_VERSION AGENT_SESSIONS_YAML="${AGENT_SESSIONS_YAML:-$WORKSPACE_ROOT/.mam/agent-sessions.yaml}" @@ -194,6 +194,12 @@ done _MAM_SESSION="${HERDR_SESSION_NAME:-default}" if [ "$_MAM_SESSION" = "default" ]; then _MAM_SESSION="" +elif [ "${MAM_NO_AUTOSTART:-0}" = "1" ]; then + # ISSUE-9: query-only callers (status.sh, reconcile.sh) must never start a + # daemon as a side effect of looking. A session whose server is down just + # answers server_not_running, which every caller already reads as "no live + # agents" - the same answer a freshly bootstrapped, empty server would give. + : else _mam_session_running=$("$REAL_HERDR" session list --json 2>/dev/null | MAM_SESS="$_MAM_SESSION" python3 -c " import sys, json, os @@ -216,9 +222,25 @@ except Exception: # installed by default on macOS, so spawn via Python's # start_new_session=True (os.setsid in the child) instead - portable # across macOS and Linux, and lib_py already depends on Python. + # + # ISSUE-9: the caller is usually an agent CLI itself (a Claude Code + # orchestrator pane), and herdr hands the daemon's environment to every + # pane it later spawns. An inherited CLAUDE_CODE_CHILD_SESSION makes each + # sub-agent claude disable transcript saving (no .jsonl, so resume + # is impossible), and the other markers advertise the orchestrator's own + # identity to its sub-agents. Drop only per-session runtime markers - user + # configuration such as CLAUDE_PROJECT_DIR or CLAUDE_CONFIG_DIR still + # inherits. The family ids mirror what orc_onboard.sh auto-detects. _mam_server_pid=$(python3 -c ' -import subprocess, sys -p = subprocess.Popen(sys.argv[1:], start_new_session=True, +import os, subprocess, sys +deny = ("CLAUDECODE", "CLAUDE_CODE_CHILD_SESSION", "CLAUDE_CODE_SESSION_ID", + "CLAUDE_CODE_SESSION_ATTENDED", "CLAUDE_CODE_ENTRYPOINT", "CLAUDE_PID", + "CLAUDE_CODE_MESSAGING_TOKEN", "CLAUDE_CODE_MESSAGING_SOCKET", + "CLAUDE_CODE_BRIDGE_SESSION_ID", + "ANTIGRAVITY_CONVERSATION_ID", "HERMES_SESSION_ID", "GROK_SESSION_ID", + "OPENCODE_SESSION_ID") +env = {k: v for k, v in os.environ.items() if k not in deny} +p = subprocess.Popen(sys.argv[1:], start_new_session=True, env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL) print(p.pid) @@ -1218,7 +1240,57 @@ except Exception: if [ "$_sess" = "default" ]; then _sess="" fi - _real_herdr agent list 2>/dev/null | \ + # ISSUE-9: under MAM_NO_AUTOSTART the session's server may simply be down. + # Say so in the wording reconcile.sh already treats as "empty" ("no server + # running", tmux's phrasing) instead of an opaque parse failure, which + # would flip herdr_confirmed=False and freeze drift detection for every + # server on the next heartbeat. + # Only a FAILED call whose structured error envelope carries + # error.code == "server_not_running" means "down". A successful list can + # legitimately contain that text (an agent named server_not_running-codex), + # and reading it as "down" made reconcile terminate every live row on that + # server. Any other failed call is "unknown": it exits nonzero below + # without its output ever reaching the list parser, so an error document on + # stdout cannot pass for an empty list either. + _ls_err=$(mktemp 2>/dev/null || echo "/tmp/mam_ls_err_$$") + _ls_rc=0 + _ls_raw="$(_real_herdr agent list 2>"$_ls_err")" || _ls_rc=$? + _ls_down=0 + if [ "$_ls_rc" -ne 0 ]; then + _ls_down=$(printf '%s' "$_ls_raw" | MAM_LS_ERR_FILE="$_ls_err" python3 -c ' +import json, os, sys +out = sys.stdin.read() +try: + with open(os.environ["MAM_LS_ERR_FILE"]) as f: + err_text = f.read() +except Exception: + err_text = "" +down = 0 +for text in [out, err_text] + out.splitlines() + err_text.splitlines(): + try: + doc = json.loads(text) + except Exception: + continue + err = doc.get("error") if isinstance(doc, dict) else None + if isinstance(err, dict) and err.get("code") == "server_not_running": + down = 1 + break +print(down) +' 2>/dev/null || echo 0) + fi + rm -f "$_ls_err" 2>/dev/null || true + if [ "$_ls_down" = "1" ]; then + echo "no server running (herdr session ${_sess:-default})" >&2 + exit 1 + fi + if [ "$_ls_rc" -ne 0 ]; then + # Unknown, never empty: keep the nonzero status and word the diagnostic + # so it matches none of reconcile.sh's empty-server phrases ("no server + # running", "no sessions", "failed to connect"). + echo "herdr agent list returned rc=$_ls_rc (herdr session ${_sess:-default}); state unknown" >&2 + exit 1 + fi + printf '%s\n' "$_ls_raw" | \ MAM_LS_FORMAT="$format" MAM_REAL_HERDR="$REAL_HERDR" MAM_LS_SESSION="$_sess" \ TZ=UTC LC_ALL=C python3 -c ' import json, os, subprocess, sys, time @@ -2183,6 +2255,21 @@ except Exception: # blank rows; window on non-blank lines or top-anchored dialogs are invisible. _pane_tail() { _pane_capture "$1" | grep -v '^[[:space:]]*$' | tail -n "${2:-20}"; } +# _sks_agent_status [target] โ€” herdr agent_status, empty when unavailable. +_sks_agent_status() { + local raw + raw="$(_sks_herdr agent get "${2:-$1}" 2>/dev/null || _sks_herdr agent get "$1" 2>/dev/null || true)" + [ -n "$raw" ] || { echo ""; return 0; } + "$(_delegate_py_bin)" -c ' +import sys, json +try: + d = json.loads(sys.stdin.read()) + print((d.get("result", {}).get("agent", {}) or {}).get("agent_status", "") or "") +except Exception: + print("") +' <<< "$raw" 2>/dev/null || true +} + # _wait_session_gone [max_sec=5] # Reactive loop to wait until a herdr session goes away (happy path returns early). _wait_session_gone() { @@ -2324,6 +2411,8 @@ except Exception: # SKS_DIALOG_TIMEOUT (default 30 s); if SKS_DIALOG_ESCAPE=1, send a single # Escape per poll and re-check. NEVER a blind Enter. # 3. Native herdr 0.8+ RPC fast path: agent prompt handles atomic text + enter submission. +# An accepted submission whose status watch timed out is confirmed by polling +# for SKS_DELIVERY_GRACE_S (default 15 s) - see ISSUE-14 below. # 4. Paste via unique buffer; verify the text landed (marker visible). # 5. Submit C-m; verify submission (marker left the input area AND the pane # changed); retry up to 3 times. @@ -2395,6 +2484,9 @@ send_keys_safe() { # Native herdr 0.8+ fast path: agent prompt handles atomic submission if [ "${SKS_PROMPT_WAIT:-1}" = "1" ]; then local p_out="" p_rc=0 + # ISSUE-14: pre-submit snapshot for the delivery-confirmation poll below. + local rpc_pre_pane="" + [ "$_q_rc" != "2" ] && rpc_pre_pane=$(_pane_capture "$sess") local _p_tmp _p_tmp=$(mktemp 2>/dev/null || echo "/tmp/sks_p_${sess}_$$") if _sks_herdr agent prompt "$agent_target" "$text" --wait --until working --timeout 5000 >"$_p_tmp" 2>&1; then @@ -2429,19 +2521,35 @@ send_keys_safe() { # Prompt was dispatched via RPC; on any error (empty output, timeout, stalled, transport error, etc.), # do NOT resubmit via paste buffer (single injection contract). Check agent get after 2s. sleep 2 - local check_raw check_st - check_raw="$(_sks_herdr agent get "$agent_target" 2>/dev/null || _sks_herdr agent get "$sess" 2>/dev/null || true)" - check_st="$("$(_delegate_py_bin)" -c ' -import sys, json -try: - d = json.loads(sys.stdin.read()) - print((d.get("result", {}).get("agent", {}) or {}).get("agent_status", "") or "") -except Exception: - print("") -' <<< "$check_raw" 2>/dev/null || true)" - if [ "$check_st" = "working" ]; then + if [ "$(_sks_agent_status "$sess" "$agent_target")" = "working" ]; then return 0 fi + # ISSUE-14: `timeout` / `agent_prompt_stalled` mean herdr ACCEPTED the + # submission and sent the input - only its post-submit status watch came + # up empty. An agent whose agent_status never leaves `idle` mid-turn + # (codex under herdr 0.9.0) therefore failed every delivered prompt here: + # the job was closed as agent_blocked and the agent's own `started` / + # `completed`, published seconds later, were refused as late. For an + # accepted submission keep observing for SKS_DELIVERY_GRACE_S (default + # 15) and take any positive signal: agent_status working, a busy token + # the pane did not show before the prompt, or the pane moving on from + # its pre-submit snapshot. No signal at all still fails closed (rc 4). + if printf '%s\n' "$p_out" | grep -Eq '"code": *"(timeout|agent_prompt_stalled)"'; then + local g_deadline=$(( $(date +%s) + ${SKS_DELIVERY_GRACE_S:-15} )) + local pre_busy_rpc=0 cur_pane + printf '%s\n' "$rpc_pre_pane" | grep -Fq "esc to interrupt" && pre_busy_rpc=1 + while [ "$(date +%s)" -lt "$g_deadline" ]; do + [ "$(_sks_agent_status "$sess" "$agent_target")" = "working" ] && return 0 + if [ "$_q_rc" != "2" ]; then + cur_pane=$(_pane_capture "$sess") + if [ "$pre_busy_rpc" -eq 0 ] && printf '%s\n' "$cur_pane" | grep -Fq "esc to interrupt"; then + return 0 + fi + [ -n "$cur_pane" ] && [ "$cur_pane" != "$rpc_pre_pane" ] && return 0 + fi + sleep 1 + done + fi echo "send_keys_safe: delivery unknown/failed ($sess)" >&2 return 4 fi diff --git a/.agents/skills/lib_py/delegation/publish_event.py b/.agents/skills/lib_py/delegation/publish_event.py index 62f8048..98b3683 100755 --- a/.agents/skills/lib_py/delegation/publish_event.py +++ b/.agents/skills/lib_py/delegation/publish_event.py @@ -162,14 +162,31 @@ def prepare_event(job_id, event, detail="", data=None, return PreparedEvent(job_id, event, detail, seq, topic, retain, payload, body, config) +def record_local_event(prepared: PreparedEvent, registry_dir: str) -> None: + """Append the event to the job's on-disk events.log BEFORE any wire publish. + + The delegate wrapper reads this log as liveness evidence once its + subscriber has idled out (`_recent_disk_heartbeat`). Recorded only after + the publish - which blocks on the broker ACK and its retries - a slow + broker's heartbeat reached the disk too late for that grace window, and + live jobs were closed as deadline_timeout. Best effort, like the rest of + the record. + """ + registry.append_event(prepared.job_id, registry_dir, prepared.payload) + + def record_outcome(prepared: PreparedEvent, registry_dir: str, publish_ok: bool, publish_error: Optional[str], expected_dispatch_token: Optional[str] = None, - expected_attempt: Optional[int] = None) -> None: + expected_attempt: Optional[int] = None, + local_event_recorded: bool = False) -> None: """Persist the audit record, the registry event log and the status sync. All three are best effort and all three happen whether or not the publish - succeeded โ€” that is the contract G-1/G-2/G-4 pin down. + succeeded โ€” that is the contract G-1/G-2/G-4 pin down. A caller that + already wrote the registry event log before publishing + (``record_local_event``) passes ``local_event_recorded=True`` so the line + is not appended twice. """ mqtt_common.append_event(prepared.job_id, { "event": "published", @@ -184,7 +201,8 @@ def record_outcome(prepared: PreparedEvent, registry_dir: str, "publish_error": publish_error, }) - registry.append_event(prepared.job_id, registry_dir, prepared.payload) + if not local_event_recorded: + registry.append_event(prepared.job_id, registry_dir, prepared.payload) new_status = EVENT_TO_STATUS.get(prepared.event) if new_status: try: @@ -337,6 +355,10 @@ def main(argv=None) -> int: logger.error("%s", exc) return 1 + # Local first: the event reaches events.log before the publish can block on + # the broker ACK and its retries (see record_local_event). + record_local_event(prepared, args.registry_dir) + # Policy Note: seq consumption on failure is intentional (see prepare_event) # and the failure is documented explicitly in the audit record. publish = with_retry( @@ -362,7 +384,8 @@ def main(argv=None) -> int: record_outcome(prepared, args.registry_dir, publish_ok, publish_error, expected_dispatch_token=sync_expected_token, - expected_attempt=sync_expected_attempt) + expected_attempt=sync_expected_attempt, + local_event_recorded=True) if not publish_ok: return 2 diff --git a/.agents/skills/lib_py/delegation/service.py b/.agents/skills/lib_py/delegation/service.py index 7867668..a281b63 100644 --- a/.agents/skills/lib_py/delegation/service.py +++ b/.agents/skills/lib_py/delegation/service.py @@ -84,6 +84,9 @@ class DelegationService(object): data=data, registry_dir=self.registry_dir, dispatch_token=eff_dispatch_token, attempt_id=eff_attempt_id, ) + # Local first, as in the CLI: the event reaches events.log before the + # publish can block on the broker (see publish_event.record_local_event). + publish_event.record_local_event(prepared, self.registry_dir) publisher = self._publisher if publisher is None: from lib_py.delegation.publisher import OneShotPublisher @@ -93,6 +96,7 @@ class DelegationService(object): prepared, self.registry_dir, ok, error, expected_dispatch_token=eff_dispatch_token, expected_attempt=eff_attempt_id, + local_event_recorded=True, ) return 0 if ok else 2 diff --git a/.agents/skills/lib_py/loop/eager.py b/.agents/skills/lib_py/loop/eager.py index e4d5d6b..721f3b4 100644 --- a/.agents/skills/lib_py/loop/eager.py +++ b/.agents/skills/lib_py/loop/eager.py @@ -13,7 +13,7 @@ import sys from typing import List, Sequence # Framework version locked with framework constants -FRAMEWORK_VERSION = "4.4.1" +FRAMEWORK_VERSION = "4.4.2" CORE_MODULES: Sequence[str] = ( # Standard library modules diff --git a/.agents/skills/multi-agent-mux-create/SKILL.md b/.agents/skills/multi-agent-mux-create/SKILL.md index dd016e3..0c05a4d 100644 --- a/.agents/skills/multi-agent-mux-create/SKILL.md +++ b/.agents/skills/multi-agent-mux-create/SKILL.md @@ -1,7 +1,7 @@ --- name: multi-agent-mux-create description: "Create a new agent session (claude, antigravity/agy) in a dedicated herdr session for context-preserving long-running work. Always creates a herdr session โ€” never backgrounds with nohup/disown. Writes the new session to .mam/agent-sessions.yaml. Use when you want to start a fresh agent (no prior UUID) for a new project workspace." -version: 4.4.1 +version: 4.4.2 author: godopu license: MIT platforms: [linux, macos] @@ -72,6 +72,8 @@ Additionally, you can specify `--herdr-workspace ` (default: workspace slu Under the hood this maps to a real, separate herdr **session** (`herdr --session ` โ€” its own socket, its own `agent list`/`workspace list`, completely invisible to the default session and vice versa), not just a workspace label inside the same server. `lib.sh`'s shim bootstraps the named session's server headlessly (`herdr --session server`, backgrounded) the first time it's needed, and scopes every subsequent herdr call to it automatically โ€” this headless bootstrap is what lets it work even when the skill itself is running from inside another herdr-managed pane (a plain interactive `herdr --session ` launch is blocked there by herdr's "nested herdr is disabled" guard; headless `server` mode isn't). +The bootstrapped server receives a **sanitized copy of the caller's environment** (Issue #9): per-session agent runtime markers โ€” `CLAUDECODE`, `CLAUDE_CODE_CHILD_SESSION`, `CLAUDE_CODE_SESSION_ID`, the Claude Code messaging/bridge/pid markers, and the `ANTIGRAVITY_CONVERSATION_ID` / `HERMES_SESSION_ID` / `GROK_SESSION_ID` / `OPENCODE_SESSION_ID` equivalents โ€” are dropped, because herdr hands its environment to every pane it spawns and an inherited `CLAUDE_CODE_CHILD_SESSION` silently turns transcript saving off in every sub-agent claude (no `.jsonl`, so `multi-agent-mux-resume` can never find it). User configuration such as `CLAUDE_PROJECT_DIR` or `CLAUDE_CONFIG_DIR` is inherited unchanged. Query-only callers set `MAM_NO_AUTOSTART=1` to skip the bootstrap entirely (`status.sh` and `reconcile.sh` do). A server bootstrapped before this fix keeps its polluted environment until it is restarted (`herdr session stop `, then create/resume again). + ### How to use 1. **Via Environment Variable**: ```bash diff --git a/.agents/skills/multi-agent-mux-delegate-job/SKILL.md b/.agents/skills/multi-agent-mux-delegate-job/SKILL.md index 6ec3ff4..1fb22e8 100644 --- a/.agents/skills/multi-agent-mux-delegate-job/SKILL.md +++ b/.agents/skills/multi-agent-mux-delegate-job/SKILL.md @@ -1,7 +1,7 @@ --- name: multi-agent-mux-delegate-job description: "Delegate a unit of work to any autonomous agent (claude-code, hermes, agy, grok-build, codex, or a human) and observe it asynchronously over an MQTT event channel. Supported roles include orchestrator, worker, and reviewer." -version: 4.4.1 +version: 4.4.2 author: godopu license: MIT platforms: [linux, macos, windows] diff --git a/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job b/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job index eaa5c4f..4cdb786 100755 --- a/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job +++ b/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job @@ -564,6 +564,90 @@ _disk_status() { "$PY" -c "import json, os; p=os.path.join('$REGISTRY_DIR', '$jid.json'); print(json.load(open(p)).get('status','')) if os.path.exists(p) else print('')" 2>/dev/null || true } +# ISSUE-14: send_keys_safe can only report what herdr lets it observe. When it +# gives up, the prompt may still have landed - codex under herdr 0.9.0 never +# reports `working`, yet publishes `started` within seconds. Closing such a job +# as agent_blocked strands a live agent whose later `completed` the CAS table +# then refuses. Before finalising, give the registry MAM_INJECT_ACK_GRACE_S +# (default 10 s) to advance past pending. Any other status means the job moved +# on - normally the agent's own `started` - so the prompt is treated as +# delivered. The status is not pinned to this dispatch and may come from an +# external writer; the terminal handling after this still keeps such an +# error/cancelled as it is rather than turning it into success. +_inject_acknowledged() { + local job_id="$1" deadline st + deadline=$(( $(date +%s) + ${MAM_INJECT_ACK_GRACE_S:-10} )) + while :; do + st="$(_disk_status "$job_id")" + if [ -n "$st" ] && [ "$st" != "pending" ]; then + echo "inject: herdr could not confirm delivery, but job $job_id already advanced to '$st' in the registry; treating the prompt as delivered and continuing." >&2 + return 0 + fi + [ "$(date +%s)" -ge "$deadline" ] && return 1 + sleep 1 + done +} + +# ISSUE-14: herdr's agent_status is blind for some agents (codex stays `idle` +# for the whole turn), so "still working" must also accept the pane's own busy +# token - the same evidence send_keys_safe uses to confirm delivery. Without it +# a codex review longer than the idle timeout was closed as deadline_timeout +# mid-turn and its eventual `completed` refused. +_agent_still_working() { + local sess="$1" target="$2" + [ "$(_sks_agent_status "$sess" "$target")" = "working" ] && return 0 + _pane_tail "$sess" 8 2>/dev/null | grep -Fq "esc to interrupt" +} + +# _within_deadline +# Run a probe that can block (a herdr RPC, a pane capture) without letting it +# outlive a wall-clock deadline: once the clock reaches the probe +# and its children are killed and the result is rc 124, i.e. "no evidence". +_within_deadline() { + local deadline="$1" pid + shift + "$@" & + pid=$! + while kill -0 "$pid" 2>/dev/null; do + if [ "$(date +%s)" -ge "$deadline" ]; then + pkill -P "$pid" 2>/dev/null || true + kill "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + return 124 + fi + sleep 0.1 + done + wait "$pid" +} + +# ISSUE-14: agent-agnostic liveness. publish_event appends every event to the +# job's on-disk events.log BEFORE its wire publish (record_local_event), so a +# `progress` heartbeat the subscriber never saw (it had already idled out, or +# the broker dropped it) is visible here the moment it is sent - a slow or +# failing broker no longer delays it by its publish retries. An agent running +# its tests in a background shell looks idle to herdr and shows no busy +# token, yet keeps heartbeating - this is the only evidence that covers that +# case. +_recent_disk_heartbeat() { + local job_id="$1" window="$2" f="$REGISTRY_DIR/$job_id.events.log" + [ -f "$f" ] || return 1 + "$PY" - "$f" "$window" <<'PYEOF' +import calendar, json, sys, time +path, window = sys.argv[1], float(sys.argv[2]) +last = "" +with open(path) as fh: + for line in fh: + if line.strip(): + last = line +try: + stamp = json.loads(last)["timestamp"] + seen = calendar.timegm(time.strptime(stamp, "%Y-%m-%dT%H:%M:%SZ")) +except Exception: + sys.exit(1) +sys.exit(0 if time.time() - seen <= window else 1) +PYEOF +} + _map_sub_rc() { local sub_rc="$1" job_id="$2" local job_status="running" @@ -611,19 +695,18 @@ finalize_unfinished() { else FINAL_JOB_STATUS="$job_status" fi - # broker_unavailable: re-observe disk for remaining deadline at 3s intervals + # broker_unavailable: re-observe the disk at 3 s intervals until this + # dispatch's wall-clock deadline (START_TIME + TIMEOUT). The deadline is read + # off the clock on every pass; summing the sleeps alone ignores the time the + # disk probes themselves take and overruns TIMEOUT. if [ "$job_status" = "broker_unavailable" ]; then - local now elapsed remaining - now=$(date +%s) - elapsed=$(( now - ${START_TIME:-now} )) - remaining=$(( TIMEOUT - elapsed )) - [ $remaining -lt 0 ] && remaining=0 - while [ $remaining -gt 0 ]; do - local wait_interval=3 - [ $remaining -lt $wait_interval ] && wait_interval=$remaining - sleep "$wait_interval" - remaining=$(( remaining - wait_interval )) - local disk_st + local deadline left disk_st + deadline=$(( ${START_TIME:-$(date +%s)} + TIMEOUT )) + while :; do + left=$(( deadline - $(date +%s) )) + [ "$left" -le 0 ] && break + [ "$left" -gt 3 ] && left=3 + sleep "$left" disk_st="$(_disk_status "$job_id")" if [ "$disk_st" = "completed" ] || [ "$disk_st" = "error" ] || [ "$disk_st" = "cancelled" ]; then job_status="$disk_st" @@ -640,65 +723,42 @@ finalize_unfinished() { local sess="${target_sess#herdr:}" local target_agent target_agent=$(_sanitize_herdr_agent_name "$sess") - local agent_st="" - if [ "$sess" != "human" ] && [ -n "$sess" ]; then - local raw_st - raw_st="$(_sks_herdr agent get "$target_agent" 2>/dev/null || _sks_herdr agent get "$sess" 2>/dev/null || true)" - if [ -n "$raw_st" ]; then - agent_st="$("$(_delegate_py_bin)" -c ' -import sys, json -try: - d = json.loads(sys.stdin.read()) - print((d.get("result", {}).get("agent", {}) or {}).get("agent_status", "") or "") -except Exception: - print("") -' <<< "$raw_st" 2>/dev/null || true)" - fi - fi - if [ "$agent_st" = "working" ]; then - local now elapsed remaining - now=$(date +%s) - elapsed=$(( now - ${START_TIME:-now} )) - remaining=$(( TIMEOUT - elapsed )) - [ $remaining -lt 0 ] && remaining=0 - while [ $remaining -gt 0 ]; do - local disk_st - disk_st="$(_disk_status "$job_id")" - if [ "$disk_st" = "completed" ] || [ "$disk_st" = "error" ] || [ "$disk_st" = "cancelled" ]; then - job_status="$disk_st" - FINAL_JOB_STATUS="$disk_st" - [ "$disk_st" = "completed" ] && return 0 - break - fi - local cur_raw cur_st="" - cur_raw="$(_sks_herdr agent get "$target_agent" 2>/dev/null || _sks_herdr agent get "$sess" 2>/dev/null || true)" - if [ -n "$cur_raw" ]; then - cur_st="$("$(_delegate_py_bin)" -c ' -import sys, json -try: - d = json.loads(sys.stdin.read()) - print((d.get("result", {}).get("agent", {}) or {}).get("agent_status", "") or "") -except Exception: - print("") -' <<< "$cur_raw" 2>/dev/null || true)" - fi - if [ "$cur_st" != "working" ]; then - sleep 1 - disk_st="$(_disk_status "$job_id")" - if [ "$disk_st" = "completed" ] || [ "$disk_st" = "error" ] || [ "$disk_st" = "cancelled" ]; then - job_status="$disk_st" - FINAL_JOB_STATUS="$disk_st" - [ "$disk_st" = "completed" ] && return 0 - fi - break - fi - local wait_interval=1 - [ $remaining -lt $wait_interval ] && wait_interval=$remaining - sleep "$wait_interval" - remaining=$(( remaining - wait_interval )) - done - fi + # ISSUE-14: the subscriber's idle clock only counts events it received over + # the wire. Keep re-observing the disk while liveness evidence keeps + # turning up - herdr `working`, the pane busy token, or a heartbeat in + # events.log at most IDLE_TIMEOUT old (see _recent_disk_heartbeat). + # Window policy: entering this grace opens one IDLE_TIMEOUT window and + # every sighting re-opens it from the time of that poll. An agent that + # leaves no evidence at all is therefore closed about 2 x IDLE_TIMEOUT + # after its last wire event (the subscriber's idle wait plus this window; + # it was IDLE_TIMEOUT before disk heartbeats counted), and heartbeats may + # be up to about 2 x IDLE_TIMEOUT apart, since one still counts until it + # is IDLE_TIMEOUT old. Evidence only ever extends the idle window, never + # this dispatch's wall clock: the loop ends at START_TIME + TIMEOUT read + # off the clock on every pass, and the herdr probe is cut off at that + # deadline too (_within_deadline). + local deadline evidence_until left disk_st + deadline=$(( ${START_TIME:-$(date +%s)} + TIMEOUT )) + evidence_until=$(( $(date +%s) + IDLE_TIMEOUT )) + while [ "$(date +%s)" -lt "$deadline" ]; do + disk_st="$(_disk_status "$job_id")" + if [ "$disk_st" = "completed" ] || [ "$disk_st" = "error" ] || [ "$disk_st" = "cancelled" ]; then + job_status="$disk_st" + FINAL_JOB_STATUS="$disk_st" + [ "$disk_st" = "completed" ] && return 0 + break + fi + if { [ "$sess" != "human" ] && [ -n "$sess" ] && _within_deadline "$deadline" _agent_still_working "$sess" "$target_agent"; } \ + || _recent_disk_heartbeat "$job_id" "$IDLE_TIMEOUT"; then + evidence_until=$(( $(date +%s) + IDLE_TIMEOUT )) + elif [ "$(date +%s)" -ge "$evidence_until" ]; then + break + fi + left=$(( deadline - $(date +%s) )) + [ "$left" -le 0 ] && break + sleep 1 + done fi local close_req="deadline_timeout" @@ -829,7 +889,7 @@ inject_agent() { fi echo "์‚ด์•„์žˆ๋Š” ์—์ด์ „ํŠธ ์„ธ์…˜ '$sess'์— ์ž‘์—…์„ ์œ„์ž„ํ•ฉ๋‹ˆ๋‹ค..." - if ! send_keys_safe "$sess" "$instructions" "$job_id"; then + if ! send_keys_safe "$sess" "$instructions" "$job_id" && ! _inject_acknowledged "$job_id"; then trap - EXIT echo "ERROR: ํ”„๋กฌํ”„ํŠธ ์ฃผ์ž… ์‹คํŒจ โ€” ์„ธ์…˜ '$sess' (ํ”„๋กฌํ”„ํŠธ ์ž ๊ธˆ ์˜์‹ฌ)" >&2 return 1 diff --git a/.agents/skills/multi-agent-mux-loop/SKILL.md b/.agents/skills/multi-agent-mux-loop/SKILL.md index 9328515..67270b3 100644 --- a/.agents/skills/multi-agent-mux-loop/SKILL.md +++ b/.agents/skills/multi-agent-mux-loop/SKILL.md @@ -1,7 +1,7 @@ --- name: multi-agent-mux-loop description: "Run an autonomous planning-execution-review loop using multiple agents (Planner, Creator, Reviewers) in the workspace. Automatically orchestrates plan discussion, code changes, and peer reviews until a unanimous PASS is achieved or the maximum iteration limit is reached." -version: 4.4.1 +version: 4.4.2 author: godopu license: MIT platforms: [linux, macos] diff --git a/.agents/skills/multi-agent-mux-monitor/SKILL.md b/.agents/skills/multi-agent-mux-monitor/SKILL.md index 4256ff8..50c6f15 100644 --- a/.agents/skills/multi-agent-mux-monitor/SKILL.md +++ b/.agents/skills/multi-agent-mux-monitor/SKILL.md @@ -1,7 +1,7 @@ --- name: multi-agent-mux-monitor description: "Run a long-lived reconciler that watches .mam/agent-sessions.yaml against the actual herdr/agent runtime state and reconciles them. Use when you want live visibility into which agent sessions are running, which are dead, which have stale YAML entries, and which have new session ids that haven't been recorded yet. Runs as a persistent loop (`reconcile.sh --subscribe`) that keeps going until it times out, idles out, or is interrupted." -version: 4.4.1 +version: 4.4.2 author: godopu license: MIT platforms: [linux, macos] diff --git a/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh b/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh index 32f6bf7..0e7b55d 100755 --- a/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh +++ b/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh @@ -20,6 +20,12 @@ SKILLS_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" LIB_SH="$SKILLS_DIR/lib.sh" [ -f "$LIB_SH" ] || LIB_SH="${WORKSPACE_ROOT:-$PWD}/.agents/skills/lib.sh" source "$LIB_SH" + +# ISSUE-9: the reconciler only observes and marks - it never creates a pane - +# so no herdr call it makes (the per-server `ls` sweep on every heartbeat, in +# --dry-run and in --subscribe alike) may bootstrap a server as a side effect. +# A server that is down simply has no live sessions. +export MAM_NO_AUTOSTART=1 if [ -n "${AGENT_SESSIONS_STATE_DIR:-}" ]; then echo "Notice: AGENT_SESSIONS_STATE_DIR is set but has no effect; the monitor keeps no state directory (C-2)." >&2 fi diff --git a/.agents/skills/multi-agent-mux-orc-onboard/SKILL.md b/.agents/skills/multi-agent-mux-orc-onboard/SKILL.md index 14ea400..76ed504 100644 --- a/.agents/skills/multi-agent-mux-orc-onboard/SKILL.md +++ b/.agents/skills/multi-agent-mux-orc-onboard/SKILL.md @@ -1,7 +1,7 @@ --- name: multi-agent-mux-orc-onboard description: "Register current or specified orchestrator session UUID into agent-sessions.yaml orchestrator_uuids list to prevent sub-agent discovery capture." -version: 4.4.1 +version: 4.4.2 author: godopu license: MIT platforms: [linux, macos] diff --git a/.agents/skills/multi-agent-mux-resume/SKILL.md b/.agents/skills/multi-agent-mux-resume/SKILL.md index dd5c536..078066e 100644 --- a/.agents/skills/multi-agent-mux-resume/SKILL.md +++ b/.agents/skills/multi-agent-mux-resume/SKILL.md @@ -1,7 +1,7 @@ --- name: multi-agent-mux-resume description: "Resume an existing agent (claude, antigravity/agy) conversation by UUID into a herdr session. Reads .mam/agent-sessions.yaml for the saved session/conversation id, spawns (or reuses) a herdr session of the matching name, and runs `claude -r ` or `agy --conversation ` inside. Use when you want to reattach to a previous session's context, or revive a session whose herdr died but the agent's conversation is still on disk." -version: 4.4.1 +version: 4.4.2 author: godopu license: MIT platforms: [linux, macos] diff --git a/.agents/skills/multi-agent-mux-status/SKILL.md b/.agents/skills/multi-agent-mux-status/SKILL.md index 1b06cca..2e9f913 100644 --- a/.agents/skills/multi-agent-mux-status/SKILL.md +++ b/.agents/skills/multi-agent-mux-status/SKILL.md @@ -1,7 +1,7 @@ --- name: multi-agent-mux-status description: "Read-only instant snapshot of all agent herdr sessions โ€” name, YAML status, herdr alive, pane cmd/cwd, resume UUID on disk, and any drift. No mutation. Reuses reconcile.sh --dry-run for the diff logic. Use when you want to know 'what's running RIGHT NOW' without spinning up the monitor loop." -version: 4.4.1 +version: 4.4.2 author: godopu license: MIT platforms: [linux, macos] @@ -110,6 +110,7 @@ lab-paper-pdf2md-creator-claude default running alive clau - **Do NOT use this skill to drive mutations** โ€” the output is a snapshot, not a call to action. If you need to fix drifts, run `multi-agent-mux-monitor` or `multi-agent-mux-resume` / `multi-agent-mux-stop` manually. - **Read-only is enforced by script** โ€” `status.sh` opens the YAML with `open(path)` (no `'w'`), never calls `herdr kill-session`, never writes anywhere. The `reconcile.sh --dry-run` mode is the same path. +- **It never starts a herdr server either** โ€” `status.sh` exports `MAM_NO_AUTOSTART=1`, so the `lib.sh` shim skips its headless server bootstrap and a session whose isolated server is down is simply reported `dead`. Before Issue #9 a bare status query could leave a fresh `herdr --session server` daemon (and an empty pane) behind, inheriting the caller's environment. - **If `agent-sessions.yaml` is malformed** โ€” print the YAML error verbatim and exit 1. Do NOT attempt recovery (that's `multi-agent-mux-stop --purge-conversation` or manual edit's job). - **Sessions outside the `-creator-*` naming convention** are still shown but tagged `ad-hoc` โ€” they didn't go through `multi-agent-mux-create` and aren't tracked in YAML. diff --git a/.agents/skills/multi-agent-mux-status/scripts/status.sh b/.agents/skills/multi-agent-mux-status/scripts/status.sh index d668bbd..90ba9e6 100755 --- a/.agents/skills/multi-agent-mux-status/scripts/status.sh +++ b/.agents/skills/multi-agent-mux-status/scripts/status.sh @@ -7,6 +7,11 @@ # Usage: bash status.sh [--json] set -euo pipefail +# ISSUE-9: a status query must not bootstrap a herdr server as a side effect. +# With this set, the lib.sh shim reports a session whose isolated server is +# down as dead instead of starting a headless daemon just to answer. +export MAM_NO_AUTOSTART=1 + source "$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/lib.sh" RECONCILE="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/multi-agent-mux-monitor/scripts/reconcile.sh" diff --git a/.agents/skills/multi-agent-mux-stop/SKILL.md b/.agents/skills/multi-agent-mux-stop/SKILL.md index aecf773..49044bd 100644 --- a/.agents/skills/multi-agent-mux-stop/SKILL.md +++ b/.agents/skills/multi-agent-mux-stop/SKILL.md @@ -1,7 +1,7 @@ --- name: multi-agent-mux-stop description: "Stop an agent herdr session (claude, antigravity/agy) and update .mam/agent-sessions.yaml. Default stops gracefully and marks status=stopped with conversation preserved for resume. Does NOT delete on-disk conversation artifacts (jsonl/db) โ€” those are preserved unless --purge-conversation is passed. Use when ending a work session, switching to a different one, or cleaning up before a fresh start." -version: 4.4.1 +version: 4.4.2 author: godopu license: MIT platforms: [linux, macos] diff --git a/.gitignore b/.gitignore index 2df2926..91e1c27 100644 --- a/.gitignore +++ b/.gitignore @@ -34,4 +34,7 @@ __pycache__/ build/ .idea/ *.iml -ephemeral/ \ No newline at end of file +ephemeral/ + +# Claude Code ๋กœ์ปฌ ์„ค์ • ๋ฐ ์Šคํ‚ฌ ์‹ฌ๋ณผ๋ฆญ ๋งํฌ +.claude/ diff --git a/BOOTSTRAP.ko.md b/BOOTSTRAP.ko.md index b42f496..5b36588 100644 --- a/BOOTSTRAP.ko.md +++ b/BOOTSTRAP.ko.md @@ -2,7 +2,7 @@ ๋ณธ ๋ฌธ์„œ๋Š” `herdr_agent_orchestration` ์˜ค์ผ€์ŠคํŠธ๋ ˆ์ด์…˜ ๋ฐ ๋ฉ”์‹œ์ง• ๋ฐฑํ”Œ๋ ˆ์ธ ์›Œํฌํ”Œ๋กœ์šฐ๋ฅผ ์ƒˆ๋กœ์šด ํ”„๋กœ์ ํŠธ์— ๋„์ž…ํ•˜์—ฌ ์ด์‹ํ•˜๊ณ , ์ƒˆ๋กœ์šด ๊ฐœ๋ฐœ์ž/์—์ด์ „ํŠธ๊ฐ€ ์ดˆ๊ธฐ ๊ฐ€๋™์„ ์‹œ์ž‘ํ•  ๋•Œ ์ˆ˜ํ–‰ํ•ด์•ผ ํ•˜๋Š” ํ™˜๊ฒฝ ์„ค์ • ๋ฐ ๊ตฌ์ถ• ์ ˆ์ฐจ๋ฅผ ์•ˆ๋‚ดํ•ฉ๋‹ˆ๋‹ค. -[![Version: v4.4.1](https://img.shields.io/badge/version-v4.4.1-blue.svg)](VERSIONS.md) +[![Version: v4.4.2](https://img.shields.io/badge/version-v4.4.2-blue.svg)](VERSIONS.md) ์ƒˆ๋กœ์šด ์—์ด์ „ํŠธ๋Š” ์ด ์•ˆ๋‚ด์„œ์— ๊ธฐ์ˆ ๋œ ์ ˆ์ฐจ๋ฅผ ์ˆœ์ฐจ์ ์œผ๋กœ ์‹คํ–‰ํ•˜์—ฌ ์ดˆ๊ธฐ ํ™˜๊ฒฝ์„ ์•ˆ์ •์ ์œผ๋กœ ์„ค์ •ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. diff --git a/BOOTSTRAP.md b/BOOTSTRAP.md index 1178db2..ab58eb7 100644 --- a/BOOTSTRAP.md +++ b/BOOTSTRAP.md @@ -2,7 +2,7 @@ This document guides you through the setup and initialization procedures required to adopt the `herdr_agent_orchestration` orchestration and messaging backplane workflow in a new project, enabling a new developer or agent to get up and running quickly. -[![Version: v4.4.1](https://img.shields.io/badge/version-v4.4.1-blue.svg)](VERSIONS.md) +[![Version: v4.4.2](https://img.shields.io/badge/version-v4.4.2-blue.svg)](VERSIONS.md) A new agent can follow the steps in this guide sequentially to establish a stable and reliable initial environment. diff --git a/MESSAGING.md b/MESSAGING.md index b62b9fe..915e2f6 100644 --- a/MESSAGING.md +++ b/MESSAGING.md @@ -1,6 +1,6 @@ # Messaging System Technical Analysis & Architecture Report -Applies to: MAM v4.4.1 +Applies to: MAM v4.4.2 This report provides a comprehensive, deep-dive analysis of the messaging system implemented in the `multi-agent-mux-delegate-job` skill. It covers the MQTT broker architecture, event protocols, job lifecycles, codebase internals, cross-system integration, and a list of known limitations along with production recommendations. diff --git a/README.ko.md b/README.ko.md index 57a1364..14c195e 100644 --- a/README.ko.md +++ b/README.ko.md @@ -2,7 +2,7 @@ Herdr์™€ MQTT ๋ธŒ๋กœ์ปค๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ ๊ตฌ์ถ•๋œ ๊ณ ์‹ ๋ขฐ์„ฑ **๋‹ค์ค‘ ์—์ด์ „ํŠธ ์˜ค์ผ€์ŠคํŠธ๋ ˆ์ด์…˜ ๋ฐ ๋ฉ”์‹œ์ง• ๋ฐฑํ”Œ๋ ˆ์ธ** ํ”„๋ ˆ์ž„์›Œํฌ์ž…๋‹ˆ๋‹ค. `claude`, `agy`, `hermes`, `grok`, `opencode`, `codex` ๋“ฑ ๋‹ค์ค‘ LLM ๋ฐฑ์—”๋“œ ์—์ด์ „ํŠธ ์ „๋ฐ˜์— ๊ฑธ์ณ ์žฅ์‹œ๊ฐ„ ์ˆ˜ํ–‰๋˜๋Š” ์ž‘์—…(์ฝ”๋“œ ์ƒ์„ฑ, ๋ฆฌํŒฉํ† ๋ง, ๋ณด์•ˆ ๊ฒ€ํ†  ๋“ฑ)์„ ์กฐ์ •, ๊ฒฉ๋ฆฌ ๋ฐ ๊ฐ์‚ฌํ•  ์ˆ˜ ์žˆ๋„๋ก ์„ค๊ณ„๋˜์—ˆ์Šต๋‹ˆ๋‹ค. -[![Version: v4.4.1](https://img.shields.io/badge/version-v4.4.1-blue.svg)](VERSIONS.md) +[![Version: v4.4.2](https://img.shields.io/badge/version-v4.4.2-blue.svg)](VERSIONS.md) --- diff --git a/README.md b/README.md index 3ea9c01..f41c948 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ An advanced, high-reliability **Multi-Agent Orchestration & Messaging Backplane** framework built on Herdr and MQTT. It is designed to coordinate, isolate, and audit long-running agent tasks (such as code generation, refactoring, and security reviews) across multiple LLM backend clients (`claude`, `agy`, `hermes`, `grok`, `opencode`, `codex`). -[![Version: v4.4.1](https://img.shields.io/badge/version-v4.4.1-blue.svg)](VERSIONS.md) +[![Version: v4.4.2](https://img.shields.io/badge/version-v4.4.2-blue.svg)](VERSIONS.md) --- diff --git a/VERSIONS.md b/VERSIONS.md index 555dfef..7928585 100644 --- a/VERSIONS.md +++ b/VERSIONS.md @@ -6,8 +6,8 @@ ## ๐Ÿ“Œ ํ˜„์žฌ ๋ฒ„์ „ ๊ฐœ์š” (Current Release) -- **ํ”„๋ ˆ์ž„์›Œํฌ ๋ฒ„์ „**: `v4.4.1` -- **์ตœ์‹  ๋ฆด๋ฆฌ์Šค ์ผ์‹œ**: 2026-09-08 (KST) +- **ํ”„๋ ˆ์ž„์›Œํฌ ๋ฒ„์ „**: `v4.4.2` +- **์ตœ์‹  ๋ฆด๋ฆฌ์Šค ์ผ์‹œ**: 2026-09-12 (KST) - **๊ธฐ์ค€ ๋ธŒ๋žœ์น˜**: `main` - **ํ•ต์‹ฌ ์•„ํ‚คํ…์ฒ˜**: - **Terminal ์ƒํƒœ ๋‹จ๋ฐฉํ–ฅ ๋ถˆ๋ณ€์„ฑ (`engine.py`, Issue #6 Phase 1)**: `completed`, `error`, `cancelled` ํ„ฐ๋ฏธ๋„ ์ƒํƒœ ์ง„์ž… ํ›„ ํ›„์† ์ด๋ฒคํŠธ๋‚˜ ์—…๋ฐ์ดํŠธ์— ์˜ํ•œ ๋ฎ์–ด์“ฐ๊ธฐ๋ฅผ ์—„๊ฒฉํžˆ ์ฐจ๋‹จ(Terminal Immutability). `registry.py close` ์„œ๋ธŒ์ปค๋งจ๋“œ ๋ฐ fail-closed ์กฐ๊ธฐ ์ข…๋ฃŒ ๋„์ž…. @@ -20,23 +20,41 @@ ## ๐Ÿงญ ์Šคํ‚ฌ ํŒจํ‚ค์ง€ ๋ฒ„์ „ ๋งคํŠธ๋ฆญ์Šค (Skills Version Matrix) -๋ชจ๋“  8๊ฐœ ์Šคํ‚ฌ์€ YAML frontmatter ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ(`author`, `version`, `platforms`, `environments`) ํ‘œ์ค€ํ™”๋ฅผ ํ†ตํ•ด `v4.4.1`๋กœ ๋™๊ธฐํ™”๋˜์–ด ๋ฐฐํฌ๋ฉ๋‹ˆ๋‹ค. +๋ชจ๋“  8๊ฐœ ์Šคํ‚ฌ์€ YAML frontmatter ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ(`author`, `version`, `platforms`, `environments`) ํ‘œ์ค€ํ™”๋ฅผ ํ†ตํ•ด `v4.4.2`๋กœ ๋™๊ธฐํ™”๋˜์–ด ๋ฐฐํฌ๋ฉ๋‹ˆ๋‹ค. | ์Šคํ‚ฌ๋ช… | ๋ฒ„์ „ | ์—ญํ•  ๋ฐ ์ฃผ์š” ์ฑ…์ž„ | ์ƒํƒœ | | :--- | :---: | :--- | :---: | -| **`multi-agent-mux-create`** | `4.4.1` | ์—์ด์ „ํŠธ ์„ธ์…˜ ์‹ ๊ทœ ์ƒ์„ฑ ๋ฐ Herdr ์ปจํ…Œ์ด๋„ˆ ๊ฒฉ๋ฆฌ ์Šคํฐ | โœ… ๋ฐฐํฌ | -| **`multi-agent-mux-stop`** | `4.4.1` | ๋Œ€ํ™” UUID ์›์ž์  ์บก์ฒ˜ ๋ฐ ์„ธ์…˜ ์•ˆ์ „ ์ข…๋ฃŒ (Graceful Stop) | โœ… ๋ฐฐํฌ | -| **`multi-agent-mux-resume`** | `4.4.1` | ์˜จ๋””์Šคํฌ ๋Œ€ํ™” ์ปจํ…์ŠคํŠธ ๊ธฐ๋ฐ˜ Tier-1 ์ดˆ๊ณ ์† ์„ธ์…˜ ๋ณต์› | โœ… ๋ฐฐํฌ | -| **`multi-agent-mux-status`** | `4.4.1` | ์‹ค์‹œ๊ฐ„ Herdr ์„ธ์…˜ ๋ฐ ๋ ˆ์ง€์ŠคํŠธ๋ฆฌ ๋“œ๋ฆฌํ”„ํŠธ ์Šค๋ƒ…์ƒท ์กฐํšŒ | โœ… ๋ฐฐํฌ | -| **`multi-agent-mux-monitor`** | `4.4.1` | YAML โ†” ๋Ÿฐํƒ€์ž„ ์ƒํƒœ ๊ฐ„ ์ž์œจ ์กฐ์ •์ž (Reconciler Loop) | โœ… ๋ฐฐํฌ | -| **`multi-agent-mux-delegate-job`** | `4.4.1` | MQTT ์ด๋ฒคํŠธ ์ฑ„๋„ ๊ธฐ๋ฐ˜ ๋น„๋™๊ธฐ ๋‹จ์œ„ ์ž‘์—… ์œ„์ž„ | โœ… ๋ฐฐํฌ | -| **`multi-agent-mux-loop`** | `4.4.1` | Planner-Creator-Reviewer 3์ž ์ž์œจ ๊ณ„ํšยท์‹คํ–‰ยทํ”ผ์–ด๋ฆฌ๋ทฐ ๋ฃจํ”„ | โœ… ๋ฐฐํฌ | -| **`multi-agent-mux-orc-onboard`** | `4.4.1` | ์˜ค์ผ€์ŠคํŠธ๋ ˆ์ดํ„ฐ UUID ๊ฒฉ๋ฆฌ ๋“ฑ๋ก ๋ฐ ์„œ๋ธŒ ์„ธ์…˜ ์˜ค์—ผ ๋ฐฉ์ง€ | โœ… ๋ฐฐํฌ | +| **`multi-agent-mux-create`** | `4.4.2` | ์—์ด์ „ํŠธ ์„ธ์…˜ ์‹ ๊ทœ ์ƒ์„ฑ ๋ฐ Herdr ์ปจํ…Œ์ด๋„ˆ ๊ฒฉ๋ฆฌ ์Šคํฐ | โœ… ๋ฐฐํฌ | +| **`multi-agent-mux-stop`** | `4.4.2` | ๋Œ€ํ™” UUID ์›์ž์  ์บก์ฒ˜ ๋ฐ ์„ธ์…˜ ์•ˆ์ „ ์ข…๋ฃŒ (Graceful Stop) | โœ… ๋ฐฐํฌ | +| **`multi-agent-mux-resume`** | `4.4.2` | ์˜จ๋””์Šคํฌ ๋Œ€ํ™” ์ปจํ…์ŠคํŠธ ๊ธฐ๋ฐ˜ Tier-1 ์ดˆ๊ณ ์† ์„ธ์…˜ ๋ณต์› | โœ… ๋ฐฐํฌ | +| **`multi-agent-mux-status`** | `4.4.2` | ์‹ค์‹œ๊ฐ„ Herdr ์„ธ์…˜ ๋ฐ ๋ ˆ์ง€์ŠคํŠธ๋ฆฌ ๋“œ๋ฆฌํ”„ํŠธ ์Šค๋ƒ…์ƒท ์กฐํšŒ | โœ… ๋ฐฐํฌ | +| **`multi-agent-mux-monitor`** | `4.4.2` | YAML โ†” ๋Ÿฐํƒ€์ž„ ์ƒํƒœ ๊ฐ„ ์ž์œจ ์กฐ์ •์ž (Reconciler Loop) | โœ… ๋ฐฐํฌ | +| **`multi-agent-mux-delegate-job`** | `4.4.2` | MQTT ์ด๋ฒคํŠธ ์ฑ„๋„ ๊ธฐ๋ฐ˜ ๋น„๋™๊ธฐ ๋‹จ์œ„ ์ž‘์—… ์œ„์ž„ | โœ… ๋ฐฐํฌ | +| **`multi-agent-mux-loop`** | `4.4.2` | Planner-Creator-Reviewer 3์ž ์ž์œจ ๊ณ„ํšยท์‹คํ–‰ยทํ”ผ์–ด๋ฆฌ๋ทฐ ๋ฃจํ”„ | โœ… ๋ฐฐํฌ | +| **`multi-agent-mux-orc-onboard`** | `4.4.2` | ์˜ค์ผ€์ŠคํŠธ๋ ˆ์ดํ„ฐ UUID ๊ฒฉ๋ฆฌ ๋“ฑ๋ก ๋ฐ ์„œ๋ธŒ ์„ธ์…˜ ์˜ค์—ผ ๋ฐฉ์ง€ | โœ… ๋ฐฐํฌ | --- ## ๐Ÿ“‹ ๋ฒ„์ „๋ณ„ ์ƒ์„ธ ๋ณ€๊ฒฝ ๋‚ด์—ญ (Changelog) +### ๐Ÿš€ `v4.4.2` โ€” Issue #9 herdr ์„œ๋ฒ„ ํ™˜๊ฒฝ ๊ฒฉ๋ฆฌ ๋ฐ Issue #14 ํ”„๋กฌํ”„ํŠธ ์ „๋‹ฌ ํ™•์ธ ๋ณด๊ฐ• (2026-09-12) + +> **ํŒจ์น˜ ๋งˆ์ผ์Šคํ†ค (PATCH Release)**: ์—์ด์ „ํŠธ ๋Ÿฐํƒ€์ž„(์˜ˆ: Claude Code ์˜ค์ผ€์ŠคํŠธ๋ ˆ์ดํ„ฐ) ์•ˆ์—์„œ herdr ์„œ๋ฒ„๊ฐ€ ์ฒ˜์Œ ๊ธฐ๋™๋  ๋•Œ ์„ธ์…˜ ์‹๋ณ„ ๋งˆ์ปค๊ฐ€ ์„œ๋ฒ„์™€ ์ดํ›„ ๋ชจ๋“  ์—์ด์ „ํŠธ pane ์— ์ƒ์†๋˜๋˜ ๋ฌธ์ œ(Issue #9)์™€, `working` ์„ ๋ณด๊ณ ํ•˜์ง€ ์•Š๋Š” ์—์ด์ „ํŠธ(herdr 0.9.0 ์˜ codex)์—๊ฒŒ ์ •์ƒ ์ „๋‹ฌ๋œ ํ”„๋กฌํ”„ํŠธ๋ฅผ `send_keys_safe` ๊ฐ€ ์‹คํŒจ๋กœ ์˜คํŒํ•ด ์žก์„ `agent_blocked` ๋กœ ๋‹ซ๊ณ  ์—์ด์ „ํŠธ์˜ `started`/`completed` ๋ฅผ ๊ฑฐ๋ถ€ํ•˜๋˜ ๋ฌธ์ œ(Issue #14)๋ฅผ ํ•ด๊ฒฐํ–ˆ๋‹ค. ์‹ ๊ทœ ํ™˜๊ฒฝ๋ณ€์ˆ˜๋Š” ๋ชจ๋‘ ๊ธฐ๋ณธ๊ฐ’์ด ์žˆ๋Š” ์ถ”๊ฐ€ ์„ค์ •์ด๋ฉฐ ๊ณต๊ฐœ ๊ณ„์•ฝยท์ข…๋ฃŒ ์ฝ”๋“œ ๋ณ€๊ฒฝ์€ ์—†๋‹ค. + +#### ๐ŸŒŸ ์ฃผ์š” ๊ฐœ์„  ์‚ฌํ•ญ +1. **herdr ์„œ๋ฒ„ ๊ธฐ๋™ env ๋งˆ์ปค denylist (Issue #9, `lib.sh` ์‰ผ)**: ์‰ผ์ด `herdr --session server` ๋ฅผ ๋ถ€ํŠธ์ŠคํŠธ๋žฉํ•  ๋•Œ ์„ธ์…˜ ์‹๋ณ„ ๋งˆ์ปค๋งŒ ์ œ๊ฑฐํ•œ ํ™˜๊ฒฝ์„ ๋„˜๊ธด๋‹ค โ€” `CLAUDECODE`, `CLAUDE_CODE_CHILD_SESSION`, `CLAUDE_CODE_SESSION_ID`, `CLAUDE_CODE_SESSION_ATTENDED`, `CLAUDE_CODE_ENTRYPOINT`, `CLAUDE_PID`, `CLAUDE_CODE_MESSAGING_TOKEN`, `CLAUDE_CODE_MESSAGING_SOCKET`, `CLAUDE_CODE_BRIDGE_SESSION_ID`, `ANTIGRAVITY_CONVERSATION_ID`, `HERMES_SESSION_ID`, `GROK_SESSION_ID`, `OPENCODE_SESSION_ID`. `CLAUDE_PROJECT_DIR`ยท`CLAUDE_CONFIG_DIR` ๋“ฑ ์‚ฌ์šฉ์ž ์„ค์ •์€ ๊ทธ๋Œ€๋กœ ์ƒ์†๋œ๋‹ค. ์ƒ์†๋œ `CLAUDE_CODE_CHILD_SESSION` ๋•Œ๋ฌธ์— ์„œ๋ธŒ์—์ด์ „ํŠธ claude ์˜ transcript ์ €์žฅ์ด ๊บผ์ ธ resume ์ด ๋ถˆ๊ฐ€๋Šฅํ•˜๋˜ ์ฆ์ƒ์„ ํ•ด์†Œ. +2. **์กฐํšŒ ๊ฒฝ๋กœ ์ž๋™ ๊ธฐ๋™ ๊ธˆ์ง€ `MAM_NO_AUTOSTART=1` (Issue #9)**: ์ด ๊ฐ’์ด ์„ค์ •๋˜๋ฉด ์‰ผ์€ ์„œ๋ฒ„๋ฅผ ๋„์šฐ์ง€ ์•Š๊ณ  ํ˜ธ์ถœ๋งŒ ์ „๋‹ฌํ•œ๋‹ค. `status.sh`ยท`reconcile.sh` ๊ฐ€ ์ด๋ฅผ export ํ•˜๋ฏ€๋กœ read-only `multi-agent-mux-status` ์กฐํšŒ๊ฐ€ ๋” ์ด์ƒ ์„œ๋ฒ„ ๋ฐ๋ชฌ์„ ๊ธฐ๋™ํ•˜์ง€ ์•Š๋Š”๋‹ค. +3. **์‰ผ `ls` ์˜ "no server running" ๋ฒˆ์—ญ (Issue #9)**: ์„œ๋ฒ„๊ฐ€ ๋‚ด๋ ค๊ฐ€ ์žˆ์œผ๋ฉด ์‰ผ์˜ `ls` ๊ฐ€ `no server running (herdr session )` ์œผ๋กœ ๋ณด๊ณ ํ•˜๊ณ , `reconcile.sh` ๋Š” ์ด๋ฅผ ๋นˆ ์„œ๋ฒ„๋กœ ํŒ์ •ํ•ด `herdr_confirmed` ๋ฅผ ์œ ์ง€ํ•œ๋‹ค(ํŒŒ์‹ฑ ์‹คํŒจ๋กœ ๋ชจ๋“  ์„œ๋ฒ„์˜ drift ๊ฐ์ง€๊ฐ€ ๋ฉˆ์ถ”๋Š” ํšŒ๊ท€ ๋ฐฉ์ง€). ๋ฒˆ์—ญ์€ `agent list` ๊ฐ€ **์‹คํŒจ**ํ•˜๊ณ  ๊ทธ ์˜ค๋ฅ˜ ๋ด‰ํˆฌ(stderrยทstdout ์–ด๋А ์ชฝ์ด๋“ )์˜ `error.code` ๊ฐ€ ์ •ํ™•ํžˆ `server_not_running` ์ผ ๋•Œ๋งŒ ์ ์šฉํ•œ๋‹ค. ์„ฑ๊ณตํ•œ ๋ชฉ๋ก์— ๊ฐ™์€ ๋ฌธ์ž์—ด์ด ๋“ค์–ด ์žˆ์œผ๋ฉด(์˜ˆ: ์—์ด์ „ํŠธ ์ด๋ฆ„ `server_not_running-codex`) ๋ฒˆ์—ญํ•˜์ง€ ์•Š์œผ๋ฏ€๋กœ ์‚ด์•„ ์žˆ๋Š” ์„œ๋ฒ„๊ฐ€ ๋นˆ ์„œ๋ฒ„๋กœ ์˜คํŒ๋˜์–ด drift A ๋กœ ์ „ ์„ธ์…˜์ด ์ข…๋ฃŒ ์ฒ˜๋ฆฌ๋˜์ง€ ์•Š๋Š”๋‹ค. ๊ทธ ๋ฐ–์˜ ์‹คํŒจ๋Š” ์ถœ๋ ฅ์ด ๋ชฉ๋ก ํŒŒ์„œ์— ๋‹ฟ๊ธฐ ์ „์— ๋น„-0 ์œผ๋กœ ๋๋‚˜(`โ€ฆ state unknown`, reconcile ์˜ ๋นˆ ์„œ๋ฒ„ ๋ฌธ๊ตฌ์™€ ๊ฒน์น˜์ง€ ์•Š์Œ) unknown(`herdr_confirmed=False`)์œผ๋กœ ๋‚จ๋Š”๋‹ค โ€” stdout ์œผ๋กœ ์˜จ ์˜ค๋ฅ˜ ๋ฌธ์„œ๋„ ๋” ์ด์ƒ ๋นˆ ๋ชฉ๋ก์œผ๋กœ ์ฝํžˆ์ง€ ์•Š๋Š”๋‹ค. +4. **accepted ์ œ์ถœ์˜ ์ „๋‹ฌ ํ™•์ธ `SKS_DELIVERY_GRACE_S` (Issue #14, `send_keys_safe`)**: `agent prompt --wait` ๊ฒฐ๊ณผ๊ฐ€ `timeout`/`agent_prompt_stalled` ์ด๋ฉด herdr ๊ฐ€ ์ž…๋ ฅ์„ ์ด๋ฏธ ์ˆ˜๋ฝํ•œ ๊ฒƒ์œผ๋กœ ๋ณด๊ณ , `SKS_DELIVERY_GRACE_S`(๊ธฐ๋ณธ 15์ดˆ) ๋™์•ˆ `agent_status` working, ์ œ์ถœ ์ „ ํ™”๋ฉด์— ์—†๋˜ busy ํ† ํฐ(`esc to interrupt`), ์ œ์ถœ ์ „ ์Šค๋ƒ…์ƒท ๋Œ€๋น„ pane ๋ณ€ํ™” ์ค‘ ํ•˜๋‚˜๋ฅผ ์ „๋‹ฌ ํ™•์ธ ์‹ ํ˜ธ๋กœ ์ธ์ •ํ•œ๋‹ค. ์‹ ํ˜ธ๊ฐ€ ์—†์œผ๋ฉด ์ข…์ „๋Œ€๋กœ rc 4 ๋กœ fail-closed ํ•˜๊ณ , ๊ทธ ๋ฐ–์˜ ์˜ค๋ฅ˜(`transport_error` ๋“ฑ)๋Š” accepted ๋กœ ์ทจ๊ธ‰ํ•˜์ง€ ์•Š๋Š”๋‹ค. ๋‹จ์ผ ์ฃผ์ž… ๊ณ„์•ฝ(์žฌ์ „์†ก ์—†์Œ)์€ ์œ ์ง€๋œ๋‹ค. +5. **๋ž˜ํผ ๋ ˆ์ง€์ŠคํŠธ๋ฆฌ ack ์œ ์˜ˆ `MAM_INJECT_ACK_GRACE_S` (Issue #14, `multi-agent-mux-delegate-job`)**: `send_keys_safe` ๊ฐ€ ์ „๋‹ฌ์„ ํ™•์ธํ•˜์ง€ ๋ชปํ•ด๋„ ์žก์„ `agent_blocked` ๋กœ ๋‹ซ๊ธฐ ์ „์— `MAM_INJECT_ACK_GRACE_S`(๊ธฐ๋ณธ 10์ดˆ) ๋™์•ˆ ๋ ˆ์ง€์ŠคํŠธ๋ฆฌ๋ฅผ ๊ด€์ธกํ•˜๊ณ , `pending` ์ด ์•„๋‹Œ ์ƒํƒœ(์—์ด์ „ํŠธ์˜ `started` ๋“ฑ)๊ฐ€ ๋ณด์ด๋ฉด ์ „๋‹ฌ๋œ ๊ฒƒ์œผ๋กœ ๋ณด๊ณ  ์ง„ํ–‰ํ•œ๋‹ค(`_inject_acknowledged`). +6. **R1 ์œ ์˜ˆ์˜ pane busy ํ† ํฐยท๋””์Šคํฌ heartbeat ์ธ์ • (Issue #14, `_agent_still_working`, `_recent_disk_heartbeat`)**: ๊ตฌ๋…์ž idle ํƒ€์ž„์•„์›ƒ ํ›„ R1 ์œ ์˜ˆ๊ฐ€ herdr `working` ์™ธ์— pane ์ตœ๊ทผ 8์ค„์˜ `esc to interrupt` ํ† ํฐ, ๊ทธ๋ฆฌ๊ณ  ์žก `events.log` ์˜ ์ตœ๊ทผ ์ด๋ฒคํŠธ(`publish_event` ๊ฐ€ **์™€์ด์–ด ๋ฐœํ–‰ ์ „์—** ๋กœ์ปฌ์— ๊ธฐ๋กํ•˜๋Š” `progress` heartbeat โ€” ๋ธŒ๋กœ์ปค๊ฐ€ ๋А๋ฆฌ๊ฑฐ๋‚˜ ์ฃฝ์–ด ์žˆ์–ด๋„ ๋ฐœํ–‰ยท์žฌ์‹œ๋„๋ฅผ ๊ธฐ๋‹ค๋ฆฌ์ง€ ์•Š๊ณ  ์ฆ‰์‹œ ๋””์Šคํฌ์— ๋‚จ๋Š”๋‹ค. CLI `publish_event.py` ์™€ `DelegationService.publish_event` ๋ชจ๋‘ `record_local_event` ๋กœ ๋จผ์ € ๊ธฐ๋กํ•˜๊ณ , ๊ฐ์‚ฌ ๋กœ๊ทธ์˜ ๋ฐœํ–‰ ๊ฒฐ๊ณผ์™€ ์ƒํƒœ ๋™๊ธฐํ™”๋งŒ ๋ฐœํ–‰ ๋’ค์— ๋‚จ๊ธด๋‹ค)๋„ ์ž‘์—… ์ค‘ ๊ทผ๊ฑฐ๋กœ ๋ฐ›์•„๋“ค์ธ๋‹ค. ์œ ์˜ˆ์— ๋“ค์–ด๊ฐ€๋ฉด `IDLE_TIMEOUT` ์ฐฝ ํ•˜๋‚˜๊ฐ€ ์—ด๋ฆฌ๊ณ , ๊ทผ๊ฑฐ๊ฐ€ ๊ด€์ธก๋  ๋•Œ๋งˆ๋‹ค ๊ทธ ์‹œ์ ๋ถ€ํ„ฐ ์ฐฝ์ด ๋‹ค์‹œ ์—ด๋ฆฐ๋‹ค. ๋”ฐ๋ผ์„œ **์•„๋ฌด ๊ทผ๊ฑฐ๋„ ์—†๋Š” ๋ฌด์‘๋‹ต ์—์ด์ „ํŠธ๋Š” ๋งˆ์ง€๋ง‰ ์™€์ด์–ด ์ด๋ฒคํŠธ ํ›„ ์•ฝ 2ร—`IDLE_TIMEOUT` ์— ๋‹ซํžˆ๊ณ **(๋””์Šคํฌ heartbeat ๋ฅผ ์ธ์ •ํ•˜๊ธฐ ์ „์—๋Š” `IDLE_TIMEOUT`; Issue #6 ์ˆ˜์šฉ ๊ธฐ์ค€ "โ‰ค `IDLE_TIMEOUT`+5s ๋‚ด `error`"๋Š” "โ‰ค 2ร—`IDLE_TIMEOUT` + ์—ฌ์œ "๋กœ ๋ฐ”๋€๋‹ค), **heartbeat ๊ฐ„๊ฒฉ์€ ์•ฝ 2ร—`IDLE_TIMEOUT` ๊นŒ์ง€ ํ—ˆ์šฉ๋œ๋‹ค**(heartbeat ํ•˜๋‚˜๊ฐ€ `IDLE_TIMEOUT` ๋งŒํผ ์˜ค๋ž˜๋  ๋•Œ๊นŒ์ง€ ๊ทผ๊ฑฐ๋กœ ์ธ์ •๋˜๋ฏ€๋กœ). ๊ทผ๊ฑฐ๋Š” idle ์ฐฝ๋งŒ ๋Š˜๋ฆด ๋ฟ ๋ฒฝ์‹œ๊ณ„๋Š” ๋Š˜๋ฆฌ์ง€ ์•Š๋Š”๋‹ค: ๋ฃจํ”„๋Š” ๋งค ๋ฐ˜๋ณต ์‹œ๊ณ„๋กœ ๋‹ค์‹œ ์ฝ์€ `START_TIME + TIMEOUT` ์—์„œ ๋๋‚˜๊ณ , herdr ๊ด€์ธก ํ˜ธ์ถœ๋„ ๊ทธ ์‹œ๊ฐ์— ๋Š๋Š”๋‹ค(`_within_deadline`). `broker_unavailable` ๋””์Šคํฌ ์žฌ๊ด€์ธก ๋ฃจํ”„๋„ ๊ฐ™์€ ์‹œ๊ณ„ ๊ธฐ์ค€์ด๋‹ค. ์ด๋กœ์จ idle ํƒ€์ž„์•„์›ƒ๋ณด๋‹ค ๊ธด codex ๋ฆฌ๋ทฐ, heartbeat ๊ฐ„๊ฒฉ์ด ํ•œ ๋ฒˆ idle ์ฐฝ์„ ๋„˜๊ธด ๋ฆฌ๋ทฐ, ๋ฐฑ๊ทธ๋ผ์šด๋“œ ์…ธ๋กœ ํ…Œ์ŠคํŠธ๋ฅผ ๋Œ๋ ค herdr ์— idle ๋กœ ๋ณด์ด๋Š” creator ๊ฐ€ ํ„ด ๋„์ค‘ `deadline_timeout` ์œผ๋กœ ๋‹ซํžˆ๊ณ  ์ดํ›„ `completed` ๊ฐ€ ๊ฑฐ๋ถ€๋˜๋˜ ๋ฌธ์ œ๋ฅผ ๋ง‰๋Š”๋‹ค. + +#### โœ… ํ’ˆ์งˆ ๊ฒ€์ฆ +- **T1-7 ํŠธ๋ฆฌ๊ฑฐ ๋ณ€๊ฒฝ** (`tests/test_issue6_fail_closed.py`): ์ฃผ์ž… ์‹คํŒจ ์œ ๋ฐœ๊ฐ’์„ `prompt_wait_result=timeout` โ†’ `transport_error` ๋กœ ๋ณ€๊ฒฝ(Issue #14 ์ดํ›„ `timeout` ์€ accepted ์ œ์ถœ์ด๋ฏ€๋กœ ๋” ์ด์ƒ ํ™•์ • ์‹คํŒจ๊ฐ€ ์•„๋‹˜)ํ•˜๊ณ  `MAM_INJECT_ACK_GRACE_S=1` ๋กœ ๋Œ€๊ธฐ๋ฅผ ๋‹จ์ถ•. +- **์‹ ๊ทœ ํ…Œ์ŠคํŠธ 21๊ฐœ**: `tests/test_issue9_server_env.py` 9๊ฐœ(๋งˆ์ปค ์ œ๊ฑฐ, `MAM_NO_AUTOSTART` ๋ถ€ํŠธ์ŠคํŠธ๋žฉ ์ƒ๋žตยทํ˜ธ์ถœ ์ „๋‹ฌ, status ์กฐํšŒ ๋ฌด๊ธฐ๋™, ์ •์  opt-out, `ls` ๋ฒˆ์—ญ(์ตœ์†Œยทherdr 0.9 ์‹ค์ œ ์˜ค๋ฅ˜ ๋ด‰ํˆฌยทstdout ๋ด‰ํˆฌ), ์„ฑ๊ณต ๋ชฉ๋ก ์† `server_not_running` ๋ฌธ์ž์—ด ๋น„์˜คํŒ, ๊ทธ ๋ฐ–์˜ ์‹คํŒจ unknown ์œ ์ง€(stderrยทstdout), reconcile drift A ๋น„๋ฐœ์ƒ, ๋‹ค๋ฅธ ์‹คํŒจ ์‹œ reconcile `herdr_confirmed=False`), `tests/test_issue14_delivery_ack.py` 12๊ฐœ(busy ํ† ํฐยทpane ๋ณ€ํ™” ํ™•์ธ, ๋ฌด์‹ ํ˜ธ fail-closed, ์„ ํ–‰ busy ํ† ํฐ ๋ฐฐ์ œ, `transport_error` ๋น„์ˆ˜์šฉ, mock herdr timeout ํ™•์ธ, ๋ž˜ํผ ๋ ˆ์ง€์ŠคํŠธ๋ฆฌ ack, idle ํƒ€์ž„์•„์›ƒ pane busy ์œ ์˜ˆ, idle ํƒ€์ž„์•„์›ƒ ๋””์Šคํฌ heartbeat ์œ ์˜ˆ(`publish_event` ์‹ค์ œ ๊ฒฝ๋กœ), heartbeat ๊ฐ€ ์ด์–ด์ ธ๋„ ๋ฒฝ์‹œ๊ณ„ `TIMEOUT` ์•ˆ์—์„œ ์ข…๋ฃŒ, ์™€์ด์–ด ๋ฐœํ–‰ ์ „ ๋กœ์ปฌ ๊ธฐ๋ก, ๋ฐœํ–‰ ์‹คํŒจยท์žฌ์‹œ๋„์—๋„ ๋กœ์ปฌ ๊ธฐ๋ก 1ํšŒ). +- **ํ…Œ์ŠคํŠธ ๊ธฐ๋Œ€๊ฐ’ ์ •์ •**: `tests/test_a4_adapter_contract.py` ๊ฐ€ `4d444ce` ์˜ agy `exit_key=/exit` ๋ณ€๊ฒฝ์„ ๋ฐ˜์˜ํ•˜๋„๋ก ์ˆ˜์ •(๊ทธ๋™์•ˆ ์ „์ฒด ์Šค์œ„ํŠธ์—์„œ 1๊ฑด ์‹คํŒจํ•˜๋˜ ๋ฌธ์ œ ํ•ด์†Œ). +- **์‹ค์ธก (์ปค๋ฐ‹ `497633f` ๊ธฐ๋ก)**: ์ƒˆ๋กœ ๋ถ€ํŠธ์ŠคํŠธ๋žฉํ•œ ์„œ๋ฒ„์— ๋งˆ์ปค๊ฐ€ ์—†๊ณ  claude pane ์˜ transcript ์ €์žฅ์ด ๋ณต๊ตฌ๋จ, codex ์žก `d04ebcb5` ์˜ `started`/`completed` ๊ฐ€ `agent_blocked` ์—†์ด ์ˆ˜๋ฝ๋จ. + ### ๐Ÿš€ `v4.4.1` โ€” Issue #6 ๋น„๋™๊ธฐ ์žก ์œ„์ž„ ์•ˆ์ •ํ™” ๋ฐ Fail-Closed / Turn Handshake ์™„๊ฒฐ (2026-09-08) > **ํŒจ์น˜ ๋งˆ์ผ์Šคํ†ค (PATCH Release)**: Issue #6 ๋น„๋™๊ธฐ ์žก ์œ„์ž„ ํŒŒ์ดํ”„๋ผ์ธ์—์„œ ๋ฐœ์ƒํ•˜๋˜ ํ„ฐ๋ฏธ๋„ ์ƒํƒœ ๋ฎ์–ด์“ฐ๊ธฐ ๋ ˆ์ด์Šค, Herdr ํ”„๋กฌํ”„ํŠธ ์™„๋ฃŒ ๊ด€์ธก ๋ถ€์žฌ, Subscriber ํƒ€์ž„์•„์›ƒ ์‹œ pending ๋ฐฉ์น˜ ๋ฒ„๊ทธ๋ฅผ ๊ทผ๋ณธ ํ•ด๊ฒฐ. ๊ฒฐ์ •๋ก ์  ์ข…๋ฃŒ ์ฝ”๋“œ ๊ณ„์•ฝ(0, 1, 2, 3, 5) ๋ฐ 4๋‹จ๊ณ„ fail-closed finalizer๋ฅผ ์™„๋น„ํ–ˆ๋‹ค. diff --git a/tests/test_a4_adapter_contract.py b/tests/test_a4_adapter_contract.py index 3b1ee65..1a7d0d7 100644 --- a/tests/test_a4_adapter_contract.py +++ b/tests/test_a4_adapter_contract.py @@ -512,7 +512,7 @@ def test_cli_bridge_subcommands_and_quote_safety(): assert res.stdout.strip() == "/bin/claude --dangerously-skip-permissions --session-id uuid-test" # 3. exit-key - for agent, expected_key in [('claude', '/exit'), ('agy', 'Exit'), ('hermes', '/exit'), ('grok', '/exit'), ('opencode', '/exit'), ('codex', 'Ctrl+C')]: + for agent, expected_key in [('claude', '/exit'), ('agy', '/exit'), ('hermes', '/exit'), ('grok', '/exit'), ('opencode', '/exit'), ('codex', 'Ctrl+C')]: res = subprocess.run([sys.executable, "-m", "lib_py.agents", "exit-key", agent], capture_output=True, text=True, env=env) assert res.returncode == 0 assert res.stdout.strip() == expected_key diff --git a/tests/test_issue14_delivery_ack.py b/tests/test_issue14_delivery_ack.py new file mode 100644 index 0000000..a21042f --- /dev/null +++ b/tests/test_issue14_delivery_ack.py @@ -0,0 +1,511 @@ +#!/usr/bin/env python3 +"""Issue #14 โ€” a prompt that herdr accepted must not be reported as undelivered. + +`herdr agent prompt --wait --until working --timeout 5000` answers `timeout` (or +`agent_prompt_stalled`) when the input was sent but no `working` state was +observed afterwards. Agents whose agent_status never leaves `idle` mid-turn +(codex under herdr 0.9.0) therefore had every delivered prompt closed as +`agent_blocked`, and their own `started` / `completed` events, published seconds +later, were refused as late. send_keys_safe now confirms an accepted submission +through the pane, and the delegate wrapper additionally honours the registry's +own acknowledgement before finalising a job as an inject failure. +""" +from __future__ import annotations + +import json +import os +import subprocess +import sys +import time +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +SKILLS = REPO_ROOT / ".agents" / "skills" +LIB_SH = SKILLS / "lib.sh" +WRAPPER = SKILLS / "multi-agent-mux-delegate-job" / "multi-agent-mux-delegate-job" +REGISTRY_PY = SKILLS / "multi-agent-mux-delegate-job" / "scripts" / "registry.py" + +TIMEOUT_JSON = '{"error":{"code":"timeout","message":"timed out waiting for agent status"},"id":"cli:agent:prompt"}' +IDLE_JSON = '{"result":{"agent":{"agent_status":"idle"}}}' + + +def _base_env(cwd: Path, extra=None) -> dict: + env = dict(os.environ) + env["DELEGATE_JOB_PYTHON"] = sys.executable + env["AGENT_PYTHON_BIN"] = sys.executable + env["WORKSPACE_ROOT"] = str(cwd) + env["HOME"] = str(cwd) + if extra: + env.update({k: str(v) for k, v in extra.items()}) + return env + + +def _run_lib_sh(snippet: str, cwd: Path, env=None, timeout=60) -> subprocess.CompletedProcess: + return subprocess.run( + ["bash", "-c", f'source "{LIB_SH}" >/dev/null 2>&1; {snippet}'], + capture_output=True, text=True, cwd=str(cwd), env=_base_env(cwd, env), timeout=timeout, + ) + + +def _codex_like_mock(pre_pane: str, post_pane: str, prompt_code: str = "timeout") -> str: + """A bash `_sks_herdr` stand-in replaying what herdr 0.9.0 shows for codex: + the RPC returns `timeout`, agent_status stays `idle`, and only the pane + reveals what happened. Captures switch from PRE to POST once the prompt + has been submitted. Any paste-buffer use is a contract violation.""" + return f''' +_pane_quiescent() {{ return 0; }} +_pane_dialog_open() {{ return 1; }} +FLAG="$WORKSPACE_ROOT/.prompt-submitted" +rm -f "$FLAG" +_sks_herdr() {{ + case "${{1:-}}" in + agent) + case "${{2:-}}" in + prompt) touch "$FLAG"; echo '{{"error":{{"code":"{prompt_code}","message":"x"}}}}' >&2; return 1 ;; + get) echo '{IDLE_JSON}'; return 0 ;; + esac ;; + capture-pane) + if [ -e "$FLAG" ]; then printf '%s\\n' "{post_pane}"; else printf '%s\\n' "{pre_pane}"; fi + return 0 ;; + paste-buffer|set-buffer|delete-buffer|send-keys) + echo "PASTE_PATH_USED: $*" >&2; return 0 ;; + esac + return 0 +}} +''' + + +CODEX_IDLE = "identified.\\nโ€บ Ask Codex to do anything" +CODEX_WORKING = "Working (0s โ€ข esc to interrupt)\\nโ€บ Ask Codex to do anything" +CODEX_ANSWERED = "โ€ข OK\\nโ€บ Ask Codex to do anything" + + +def _assert_single_injection(res: subprocess.CompletedProcess): + assert "PASTE_PATH_USED" not in res.stderr, f"must not fall back to paste-buffer: {res.stderr}" + + +# --------------------------------------------------------------------------- +# lib.sh: accepted submission confirmed through the pane +# --------------------------------------------------------------------------- +def test_accepted_timeout_confirmed_by_busy_token(tmp_path): + script = _codex_like_mock(CODEX_IDLE, CODEX_WORKING) + \ + 'send_keys_safe "reviewer-codex-01" "review the triage report" "job_14a"' + res = _run_lib_sh(script, tmp_path, env={"SKS_DELIVERY_GRACE_S": "5"}) + assert res.returncode == 0, f"rc={res.returncode}\n{res.stderr}" + _assert_single_injection(res) + assert "delivery unknown" not in res.stderr + + +def test_accepted_timeout_confirmed_by_pane_change_after_fast_turn(tmp_path): + """A turn that already finished before the first poll shows no busy token, + only the answer; the pane moving on from its pre-submit snapshot is enough.""" + script = _codex_like_mock(CODEX_IDLE, CODEX_ANSWERED) + \ + 'send_keys_safe "reviewer-codex-01" "Reply with OK" "job_14b"' + res = _run_lib_sh(script, tmp_path, env={"SKS_DELIVERY_GRACE_S": "5"}) + assert res.returncode == 0, f"rc={res.returncode}\n{res.stderr}" + _assert_single_injection(res) + + +def test_accepted_timeout_with_no_signal_still_fails_closed(tmp_path): + """Nothing moves, nothing works: the grace window expires into rc=4 exactly + as before, without ever re-submitting.""" + script = _codex_like_mock(CODEX_IDLE, CODEX_IDLE) + \ + 'send_keys_safe "reviewer-codex-01" "silent prompt" "job_14c"' + started = time.time() + res = _run_lib_sh(script, tmp_path, env={"SKS_DELIVERY_GRACE_S": "2"}) + elapsed = time.time() - started + assert res.returncode == 4, f"rc={res.returncode}\n{res.stderr}" + assert "delivery unknown/failed" in res.stderr + _assert_single_injection(res) + assert 2 <= elapsed < 20, f"grace window not honoured: {elapsed:.1f}s" + + +def test_pre_existing_busy_token_is_not_taken_as_delivery(tmp_path): + """A busy token that was already on screen before the prompt is a leftover + of the previous turn, not evidence for this one.""" + script = _codex_like_mock(CODEX_WORKING, CODEX_WORKING) + \ + 'send_keys_safe "reviewer-codex-01" "prompt into a busy pane" "job_14d"' + res = _run_lib_sh(script, tmp_path, env={"SKS_DELIVERY_GRACE_S": "2"}) + assert res.returncode == 4, f"rc={res.returncode}\n{res.stderr}" + _assert_single_injection(res) + + +def test_transport_error_is_not_treated_as_accepted(tmp_path): + """Only `timeout` / `agent_prompt_stalled` mean the input was sent. A + transport-level failure keeps the single 2 s probe and rc=4, even if the + pane happens to change meanwhile (T2-13 contract, pinned with pane noise).""" + script = _codex_like_mock(CODEX_IDLE, CODEX_WORKING, prompt_code="transport_error") + \ + 'send_keys_safe "reviewer-codex-01" "prompt" "job_14e"' + started = time.time() + res = _run_lib_sh(script, tmp_path, env={"SKS_DELIVERY_GRACE_S": "30"}) + elapsed = time.time() - started + assert res.returncode == 4, f"rc={res.returncode}\n{res.stderr}" + _assert_single_injection(res) + assert elapsed < 15, f"no grace poll may run for a non-accepted code: {elapsed:.1f}s" + + +def test_mock_herdr_timeout_with_idle_status_is_confirmed(tmp_path, mock_herdr): + """Same scenario through the shared mock herdr: `timeout` from agent prompt, + agent_status pinned to idle, the pane gaining the busy token after + submission. Exactly one prompt call, no paste-buffer fallback.""" + state_file = Path(mock_herdr) + state = json.loads(state_file.read_text()) + state["agents"]["agt_codex"] = { + "agent": "codex", + "agent_status": "idle", + "prompt_wait_result": "timeout", + "buffer": "Ready", + } + state_file.write_text(json.dumps(state, indent=2)) + + res = _run_lib_sh( + 'send_keys_safe "agt_codex" "review please" "job_14f"', + tmp_path, env={"SKS_IDLE_HANDSHAKE": "0", "SKS_DELIVERY_GRACE_S": "5"}, + ) + assert res.returncode == 0, f"rc={res.returncode}\n{res.stderr}" + + final_state = json.loads(state_file.read_text()) + calls = final_state.get("calls", []) + prompt_calls = [c for c in calls if len(c) >= 2 and c[0] == "agent" and c[1] == "prompt"] + paste_calls = [c for c in calls if len(c) >= 2 and (c[0] in ("paste-buffer", "set-buffer") or c[1] in ("paste-buffer", "set-buffer"))] + assert len(prompt_calls) == 1, f"prompt must be submitted exactly once, got {len(prompt_calls)}" + assert not paste_calls, f"no paste-buffer fallback allowed: {paste_calls}" + + +# --------------------------------------------------------------------------- +# wrapper: the registry's own acknowledgement beats an unconfirmed inject +# --------------------------------------------------------------------------- +def test_wrapper_honours_registry_ack_over_unconfirmed_inject(tmp_path, mock_herdr): + """The RPC fails outright, but the agent has already published `started` + (status running) by the time the wrapper decides: the job must not be + closed as agent_blocked. With the broker unreachable the wrapper then + follows its normal broker_unavailable path (rc=3), never the inject path.""" + reg_dir = tmp_path / "jobs" + reg_dir.mkdir(parents=True, exist_ok=True) + + state_file = Path(mock_herdr) + state = json.loads(state_file.read_text()) + state["agents"]["ack-agent"] = { + "agent": "codex", + "agent_status": "idle", + "prompt_wait_result": "transport_error", + } + state["prompt_wait_result"] = "transport_error" + state_file.write_text(json.dumps(state, indent=2)) + + env = _base_env(tmp_path, { + "MQTT_HOST": "127.0.0.1", "MQTT_PORT": "65534", + "SKS_IDLE_TIMEOUT_MS": "500", "MAM_INJECT_ACK_GRACE_S": "8", + }) + proc = subprocess.Popen( + [ + "bash", str(WRAPPER), "submit", + "--agent", "codex", + "--agent-session", "herdr:ack-agent", + "--prompt", "issue 14 ack", + "--registry-dir", str(reg_dir), + "--workdir", str(tmp_path), + "--timeout", "6", + "--idle-timeout", "2", + ], + cwd=str(tmp_path), env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + ) + + jid = "" + for _ in range(100): + job_files = list(reg_dir.glob("*.json")) + if job_files: + jid = job_files[0].stem + break + time.sleep(0.1) + assert jid, "job was not registered" + + # The agent acknowledges the job out of band, as codex does via publish_event. + ack = subprocess.run( + [sys.executable, str(REGISTRY_PY), "--registry-dir", str(reg_dir), + "update", "--job", jid, "--status", "running"], + capture_output=True, text=True, cwd=str(tmp_path), env=env, + ) + assert ack.returncode == 0, ack.stderr + + stdout, stderr = proc.communicate(timeout=90) + assert "treating the prompt as delivered and continuing" in stderr, f"ack guard did not fire:\n{stderr}\n{stdout}" + assert "ํ”„๋กฌํ”„ํŠธ ์ฃผ์ž… ์‹คํŒจ" not in stderr + assert proc.returncode == 3, f"expected the broker_unavailable path (rc=3), got {proc.returncode}\n{stderr}" + + events_log = reg_dir / f"{jid}.events.log" + if not events_log.exists(): + events_log = reg_dir / jid / "events.log" + if events_log.exists(): + assert "agent_blocked" not in events_log.read_text(), events_log.read_text() + job_data = json.loads((reg_dir / f"{jid}.json").read_text()) + assert job_data.get("status") != "pending" + + +# --------------------------------------------------------------------------- +# wrapper: idle-timeout grace (R1) must see a working codex through the pane +# --------------------------------------------------------------------------- +def test_wrapper_idle_timeout_grace_accepts_pane_busy_token(tmp_path, mock_herdr): + """herdr keeps reporting `idle` for a codex-like agent that is visibly + working (busy token in the pane). When the subscriber idles out, the + wrapper must keep waiting for the on-disk result instead of closing the + job as deadline_timeout mid-turn; the agent then completes out of band.""" + reg_dir = tmp_path / "jobs" + reg_dir.mkdir(parents=True, exist_ok=True) + + state_file = Path(mock_herdr) + state = json.loads(state_file.read_text()) + # `timeout` from agent prompt keeps agent_status idle; the mock appends the + # busy token to the pane on submission, exactly like a codex turn. + state["agents"]["slow-codex"] = { + "agent": "codex", + "agent_status": "idle", + "prompt_wait_result": "timeout", + "buffer": "Ready", + } + state_file.write_text(json.dumps(state, indent=2)) + + env = _base_env(tmp_path, {"SKS_IDLE_TIMEOUT_MS": "500", "SKS_DELIVERY_GRACE_S": "5"}) + proc = subprocess.Popen( + [ + "bash", str(WRAPPER), "submit", + "--agent", "codex", + "--agent-session", "herdr:slow-codex", + "--prompt", "issue 14 idle grace", + "--registry-dir", str(reg_dir), + "--workdir", str(tmp_path), + "--timeout", "40", + "--idle-timeout", "3", + ], + cwd=str(tmp_path), env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + ) + + jid = "" + for _ in range(100): + job_files = list(reg_dir.glob("*.json")) + if job_files: + jid = job_files[0].stem + break + time.sleep(0.1) + assert jid, "job was not registered" + + # Well past the 3 s idle timeout, the agent finishes. + time.sleep(14) + done = subprocess.run( + [sys.executable, str(REGISTRY_PY), "--registry-dir", str(reg_dir), + "update", "--job", jid, "--status", "completed"], + capture_output=True, text=True, cwd=str(tmp_path), env=env, + ) + assert done.returncode == 0, done.stderr + + stdout, stderr = proc.communicate(timeout=90) + job_data = json.loads((reg_dir / f"{jid}.json").read_text()) + assert job_data.get("status") == "completed", f"status={job_data.get('status')}\n{stdout}\n{stderr}" + assert proc.returncode == 0, f"expected rc=0 after the working grace, got {proc.returncode}\n{stdout}\n{stderr}" + + +# --------------------------------------------------------------------------- +# wrapper: idle-timeout grace (R1) must honour on-disk progress heartbeats +# --------------------------------------------------------------------------- +PUBLISH_PY = SKILLS / "multi-agent-mux-delegate-job" / "scripts" / "publish_event.py" + + +def test_wrapper_idle_timeout_grace_accepts_disk_heartbeat(tmp_path): + """No herdr evidence at all (a human agent, or an agent running its tests + in a background shell): once the subscriber has idled out, the wrapper + must keep the job open as long as fresh `progress` heartbeats keep landing + in events.log, and pick up the eventual completion. Each heartbeat here + arrives after the previous idle window would have expired.""" + reg_dir = tmp_path / "jobs" + reg_dir.mkdir(parents=True, exist_ok=True) + env = _base_env(tmp_path) + proc = subprocess.Popen( + [ + "bash", str(WRAPPER), "submit", + "--agent", "human", + "--prompt", "issue 14 disk heartbeat", + "--registry-dir", str(reg_dir), + "--workdir", str(tmp_path), + "--timeout", "60", + "--idle-timeout", "6", + ], + cwd=str(tmp_path), env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + ) + + jid = "" + for _ in range(100): + job_files = list(reg_dir.glob("*.json")) + if job_files: + jid = job_files[0].stem + break + time.sleep(0.1) + assert jid, "job was not registered" + + def heartbeat(detail: str): + subprocess.run( + [sys.executable, str(PUBLISH_PY), "--registry-dir", str(reg_dir), + "--job", jid, "--event", "progress", "--detail", detail], + capture_output=True, text=True, cwd=str(tmp_path), env=env, + ) + + # Subscriber idles out at ~6 s (plus broker connect); heartbeats then keep + # arriving 2 s apart, well inside the 6 s window each one re-opens (the + # wrapper's poll iteration costs ~2 s of herdr RPCs, so the ratio matters). + time.sleep(9) + for i in range(5): + heartbeat(f"still working {i}") + time.sleep(2) + done = subprocess.run( + [sys.executable, str(REGISTRY_PY), "--registry-dir", str(reg_dir), + "update", "--job", jid, "--status", "completed"], + capture_output=True, text=True, cwd=str(tmp_path), env=env, + ) + assert done.returncode == 0, done.stderr + + stdout, stderr = proc.communicate(timeout=90) + job_data = json.loads((reg_dir / f"{jid}.json").read_text()) + assert job_data.get("status") == "completed", f"status={job_data.get('status')}\n{stdout}\n{stderr}" + assert proc.returncode == 0, f"expected rc=0 while heartbeats kept landing, got {proc.returncode}\n{stdout}\n{stderr}" + + +def test_wrapper_idle_grace_never_outlives_the_wall_clock(tmp_path): + """Heartbeats may extend the idle window, never the overall --timeout. + Fresh heartbeats land in events.log every second for the whole run, and + every python probe the wrapper makes is slowed down (herdr-RPC-like + latency). Counting sleeps instead of reading the clock let the grace loop + run about twice as long as TIMEOUT; the job must instead close as + deadline_timeout within TIMEOUT plus a small close/publish margin.""" + import threading + + timeout_s, idle_s = 20, 4 + reg_dir = tmp_path / "jobs" + reg_dir.mkdir(parents=True, exist_ok=True) + slow_py = tmp_path / "slow-python" + slow_py.write_text(f'#!/bin/sh\nsleep 0.7\nexec "{sys.executable}" "$@"\n') + slow_py.chmod(0o755) + env = _base_env(tmp_path, {"DELEGATE_JOB_PYTHON": slow_py}) + + started = time.time() + proc = subprocess.Popen( + [ + "bash", str(WRAPPER), "submit", + "--agent", "human", + "--prompt", "issue 14 wall clock", + "--registry-dir", str(reg_dir), + "--workdir", str(tmp_path), + "--timeout", str(timeout_s), + "--idle-timeout", str(idle_s), + ], + cwd=str(tmp_path), env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + ) + + jid = "" + for _ in range(200): + job_files = list(reg_dir.glob("*.json")) + if job_files: + jid = job_files[0].stem + break + time.sleep(0.1) + assert jid, "job was not registered" + events_log = reg_dir / f"{jid}.events.log" + + stop = threading.Event() + + def keep_heartbeating(): + # The same line shape publish_event appends; only its timestamp matters + # to _recent_disk_heartbeat. + seq = 10_000 + while not stop.is_set(): + seq += 1 + line = {"schema_version": 1, "seq": seq, "job_id": jid, "event": "progress", + "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "detail": "still working", "data": {}} + with open(events_log, "a") as fh: + fh.write(json.dumps(line) + "\n") + stop.wait(1) + + beat = threading.Thread(target=keep_heartbeating, daemon=True) + beat.start() + try: + stdout, stderr = proc.communicate(timeout=120) + finally: + stop.set() + elapsed = time.time() - started + + job_data = json.loads((reg_dir / f"{jid}.json").read_text()) + assert proc.returncode == 2, f"expected the deadline_timeout path (rc=2), got {proc.returncode}\n{stderr}" + assert job_data.get("status") == "error", f"status={job_data.get('status')}\n{stderr}" + # The heartbeats were honoured: the job was not closed after the first idle + # window, it stayed open up to the wall clock ... + assert elapsed >= timeout_s - 1, f"closed before the wall clock: {elapsed:.1f}s\n{stderr}" + # ... and not a moment past it beyond closing and publishing the error. + assert elapsed <= timeout_s + 12, f"idle grace outlived --timeout {timeout_s}s: {elapsed:.1f}s\n{stderr}" + + +# --------------------------------------------------------------------------- +# publish_event: a heartbeat reaches events.log before the wire publish +# --------------------------------------------------------------------------- +def _job_for_inprocess_publish(tmp_path, monkeypatch): + """A registered job in a private registry. The audit log resolves against + the cwd, so it lands under tmp_path too, and no real .mam.env is read.""" + from lib_py.delegation import registry + + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("DELEGATE_JOB_LOGS_DIR", raising=False) + monkeypatch.setenv("MAM_ENV_FILE", str(tmp_path / ".mam.env.absent")) + reg_dir = tmp_path / "jobs" + jid = registry.register_job("issue 14 heartbeat order", registry_dir=str(reg_dir)) + return reg_dir, jid + + +def _logged_events(path: Path) -> list: + if not path.exists(): + return [] + return [json.loads(line) for line in path.read_text().splitlines() if line.strip()] + + +def test_publish_event_records_the_heartbeat_before_the_wire_publish(tmp_path, monkeypatch): + """R1 reads events.log as liveness once the subscriber has idled out. The + publish blocks on the broker ACK and its retries, so a heartbeat written + only afterwards reached the disk too late whenever the broker was slow + (~5 s per publish closed a live job in the round-2 review). The line must + already be on disk when the wire publish starts, and be written once.""" + from lib_py.delegation import publish_event as pe + + reg_dir, jid = _job_for_inprocess_publish(tmp_path, monkeypatch) + events_log = reg_dir / f"{jid}.events.log" + on_disk_at_publish = [] + + def slow_broker_publish(config, topic, body, retain): + on_disk_at_publish.append([e.get("event") for e in _logged_events(events_log)]) + + monkeypatch.setattr(pe, "_publish_once", slow_broker_publish) + rc = pe.main(["--registry-dir", str(reg_dir), "--job", jid, + "--event", "progress", "--detail", "still working"]) + assert rc == 0 + assert on_disk_at_publish == [["progress"]], on_disk_at_publish + assert [e.get("event") for e in _logged_events(events_log)] == ["progress"], "recorded exactly once" + + +def test_publish_event_failed_publish_keeps_one_local_record(tmp_path, monkeypatch): + """A broker that never answers must not delay the local record, retries + must not append it again, and the audit trail still says published=False.""" + from lib_py.delegation import mqtt_common, publish_event as pe + + reg_dir, jid = _job_for_inprocess_publish(tmp_path, monkeypatch) + events_log = reg_dir / f"{jid}.events.log" + lines_seen = [] + + def unreachable_broker(config, topic, body, retain): + lines_seen.append(len(_logged_events(events_log))) + raise ConnectionError("broker unreachable") + + monkeypatch.setattr(pe, "_publish_once", unreachable_broker) + rc = pe.main(["--registry-dir", str(reg_dir), "--job", jid, "--event", "progress", + "--detail", "still working", "--attempts", "2"]) + assert rc == 2 + assert lines_seen == [1, 1], lines_seen + assert len(_logged_events(events_log)) == 1 + audit = [e for e in mqtt_common.iter_logged_events(jid, mqtt_common.get_logs_dir()) + if e.get("event") == "published"] + assert audit and audit[-1]["published"] is False, audit diff --git a/tests/test_issue6_fail_closed.py b/tests/test_issue6_fail_closed.py index a3f6dce..4c43361 100644 --- a/tests/test_issue6_fail_closed.py +++ b/tests/test_issue6_fail_closed.py @@ -259,12 +259,15 @@ def test_t1_7_custom_registry_dir_inject_failure(tmp_path, mock_herdr): # Set up mock herdr with an agent, but prompt will fail state_file = Path(mock_herdr) state = json.loads(state_file.read_text()) + # A transport-level RPC failure is a definite inject failure. (`timeout` is + # not: since Issue #14 it means herdr accepted the submission and only its + # status watch timed out, which send_keys_safe then confirms via the pane.) state["agents"]["fail-agent"] = { "agent": "claude", "agent_status": "idle", - "prompt_wait_result": "timeout", # will fail prompt + "prompt_wait_result": "transport_error", # will fail prompt } - state["prompt_wait_result"] = "timeout" + state["prompt_wait_result"] = "transport_error" state_file.write_text(json.dumps(state, indent=2)) res = _run_wrapper( @@ -278,7 +281,7 @@ def test_t1_7_custom_registry_dir_inject_failure(tmp_path, mock_herdr): "--timeout", "15", "--idle-timeout", "10", ], - env={"SKS_BUSY_TIMEOUT": "1", "SKS_IDLE_TIMEOUT_MS": "500"}, + env={"SKS_BUSY_TIMEOUT": "1", "SKS_IDLE_TIMEOUT_MS": "500", "MAM_INJECT_ACK_GRACE_S": "1"}, cwd=tmp_path, timeout=60, ) diff --git a/tests/test_issue9_server_env.py b/tests/test_issue9_server_env.py new file mode 100644 index 0000000..500dfb2 --- /dev/null +++ b/tests/test_issue9_server_env.py @@ -0,0 +1,317 @@ +#!/usr/bin/env python3 +"""Issue #9 โ€” herdr server bootstrap must not inherit the caller's agent-runtime +markers, and query-only skills must never bootstrap a server at all. + +The bootstrap lives in the `.mam/shim/herdr` wrapper that lib.sh writes, so every +test drives that shim against a fake `herdr` binary placed first on PATH: it logs +each call, answers `session list --json` with "not running", and dumps its own +environment when asked to run as the `server`. +""" +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parent.parent +RECONCILE_SH = REPO_ROOT / ".agents" / "skills" / "multi-agent-mux-monitor" / "scripts" / "reconcile.sh" +STATUS_SH = REPO_ROOT / ".agents" / "skills" / "multi-agent-mux-status" / "scripts" / "status.sh" + +FAKE_HERDR = r'''#!/usr/bin/env python3 +import json, os, sys +args = sys.argv[1:] +with open(os.environ["FAKE_HERDR_LOG"], "a") as f: + f.write(json.dumps(args) + "\n") +if args[-1:] == ["server"]: + with open(os.environ["FAKE_HERDR_ENV_DUMP"], "w") as f: + json.dump(dict(os.environ), f) + sys.exit(0) +if args[-3:] == ["session", "list", "--json"]: + print(json.dumps({"sessions": []})) + sys.exit(0) +print(json.dumps({"result": {"agents": []}})) +''' + +# Per-session runtime markers of the calling agent CLI: none of these may reach +# the daemon (and through it, every pane it later spawns). +RUNTIME_MARKERS = { + "CLAUDECODE": "1", + "CLAUDE_CODE_CHILD_SESSION": "1", + "CLAUDE_CODE_SESSION_ID": "orchestrator-uuid", + "CLAUDE_CODE_MESSAGING_SOCKET": "/tmp/cc-socks/1.sock", + "CLAUDE_PID": "12345", + "ANTIGRAVITY_CONVERSATION_ID": "agy-uuid", + "HERMES_SESSION_ID": "hermes-uuid", + "GROK_SESSION_ID": "grok-uuid", + "OPENCODE_SESSION_ID": "opencode-uuid", +} + +# Ordinary configuration must keep flowing through unchanged. +KEPT_CONFIG = { + "CLAUDE_CONFIG_DIR": "/keep/claude-config", + "MAM_CUSTOM_SETTING": "kept", +} + +# What herdr 0.9 writes to stderr (exit 1) for `agent list` against a stopped +# session server. +REAL_DOWN_ENVELOPE = { + "id": "cli:agent:list", + "error": { + "code": "server_not_running", + "message": "no herdr server is running at /tmp/sessions/down-srv/herdr.sock; " + "run `herdr session attach down-srv` to start or attach it", + }, +} + +# A successful list in which the down-server code appears only as data: in an +# agent name (the sanitizer allows underscores) and in unrelated metadata. +LISTING_WITH_SUBSTRING = {"result": {"agents": [ + {"name": "server_not_running-codex", "agent": "codex"}, + {"name": "creator-claude-01", "agent": "claude", "title": "server_not_running"}, +]}} + + +def _install_fake_herdr(sandbox: Path) -> dict: + fakebin = sandbox / "fakebin" + fakebin.mkdir(exist_ok=True) + binary = fakebin / "herdr" + binary.write_text(FAKE_HERDR) + binary.chmod(0o755) + env = dict(os.environ) + env.update({ + "PATH": f"{fakebin}:{env['PATH']}", + "FAKE_HERDR_LOG": str(sandbox / "fake_herdr_calls.log"), + "FAKE_HERDR_ENV_DUMP": str(sandbox / "server_env.json"), + "AGENT_PYTHON_BIN": sys.executable, + "DELEGATE_JOB_PYTHON": sys.executable, + }) + return env + + +def _fake_agent_list(sandbox: Path, body: str) -> None: + """Replace the fake's fallback answer (what `agent list` gets) with `body`.""" + fake = sandbox / "fakebin" / "herdr" + fake.write_text(FAKE_HERDR.replace('print(json.dumps({"result": {"agents": []}}))', body)) + + +def _calls(env: dict) -> list: + log = Path(env["FAKE_HERDR_LOG"]) + if not log.exists(): + return [] + return [json.loads(line) for line in log.read_text().splitlines() if line.strip()] + + +def _run_through_shim(sandbox: Path, env: dict, session: str) -> subprocess.CompletedProcess: + """`herdr agent list` through lib.sh's shim, scoped to an isolated session + whose server the fake reports as not running.""" + lib = sandbox / ".agents" / "skills" / "lib.sh" + env = dict(env, HERDR_SESSION_NAME=session) + return subprocess.run( + ["bash", "-c", f'source "{lib}" >/dev/null 2>&1; herdr agent list'], + capture_output=True, text=True, cwd=str(sandbox), env=env, timeout=60, + ) + + +def _shim_ls(sandbox: Path, env: dict, server: str) -> subprocess.CompletedProcess: + """The exact `ls` query reconcile.sh sends for each registered server.""" + lib = sandbox / ".agents" / "skills" / "lib.sh" + return subprocess.run( + ["bash", "-c", f'source "{lib}" >/dev/null 2>&1; herdr -L {server} ls -F "#{{session_name}}|#{{session_created}}"'], + capture_output=True, text=True, cwd=str(sandbox), env=env, timeout=60, + ) + + +def test_bootstrap_strips_agent_runtime_markers(mam_sandbox): + env = _install_fake_herdr(mam_sandbox) + env.update(RUNTIME_MARKERS) + env.update(KEPT_CONFIG) + + res = _run_through_shim(mam_sandbox, env, "issue9-probe") + assert res.returncode == 0, res.stderr + + server_calls = [c for c in _calls(env) if c[-1:] == ["server"]] + assert server_calls == [["--session", "issue9-probe", "server"]], _calls(env) + + dump = Path(env["FAKE_HERDR_ENV_DUMP"]) + assert dump.exists(), "server was bootstrapped but never received an environment" + server_env = json.loads(dump.read_text()) + + leaked = sorted(k for k in RUNTIME_MARKERS if k in server_env) + assert not leaked, f"runtime markers leaked into the herdr server: {leaked}" + for key, value in KEPT_CONFIG.items(): + assert server_env.get(key) == value, f"{key} must be inherited unchanged" + # MAM's own configuration is configuration, not a marker. + assert server_env.get("CLAUDE_PROJECT_DIR") == os.environ["CLAUDE_PROJECT_DIR"] + for key in ("HOME", "PATH", "WORKSPACE_ROOT"): + assert key in server_env + + +def test_no_autostart_skips_bootstrap_but_still_routes_the_call(mam_sandbox): + env = _install_fake_herdr(mam_sandbox) + env["MAM_NO_AUTOSTART"] = "1" + + res = _run_through_shim(mam_sandbox, env, "issue9-quiet") + assert res.returncode == 0, res.stderr + + calls = _calls(env) + assert not any(c[-1:] == ["server"] for c in calls), f"server must not be started: {calls}" + assert not Path(env["FAKE_HERDR_ENV_DUMP"]).exists() + # The command itself still goes to the named session, whose server answers. + assert ["--session", "issue9-quiet", "agent", "list"] in calls, calls + + +def test_status_query_never_starts_a_server(mam_sandbox): + """status.sh sweeps every herdr_session named in the YAML; a stopped + isolated server must be reported dead, not bootstrapped to answer.""" + env = _install_fake_herdr(mam_sandbox) + (mam_sandbox / ".mam" / "agent-sessions.yaml").write_text( + "herdr_sessions:\n" + " - name: ghost-creator-claude\n" + " status: stopped\n" + " herdr_session: ghost-srv\n" + " pane:\n" + " cwd: /tmp/nonexistent-workspace\n" + ) + status_sh = mam_sandbox / "skills" / "multi-agent-mux-status" / "scripts" / "status.sh" + + res = subprocess.run(["bash", str(status_sh), "--json"], capture_output=True, text=True, + cwd=str(mam_sandbox), env=env, timeout=120) + assert res.returncode == 0, res.stderr + + calls = _calls(env) + assert any("--session" in c and "ghost-srv" in c for c in calls), \ + f"the ghost-srv session was never queried: {calls}" + assert not any(c[-1:] == ["server"] for c in calls), f"status.sh started a server: {calls}" + assert not Path(env["FAKE_HERDR_ENV_DUMP"]).exists() + + +def test_reconcile_and_status_opt_out_of_autostart_statically(): + for script in (RECONCILE_SH, STATUS_SH): + assert "export MAM_NO_AUTOSTART=1" in script.read_text(), \ + f"{script.name} must opt out of the shim's server bootstrap" + + +def _fail_with(text: str, stream: str) -> str: + """Fake `agent list` body: write `text` to `stream` and exit 1.""" + return f'sys.{stream}.write({json.dumps(text)} + "\\n"); sys.exit(1)' + + +# reconcile.sh reads a nonzero `ls` whose stderr contains any of these as a +# confirmed-EMPTY server (`is_empty`); anything else as unknown. +RECONCILE_EMPTY_PHRASES = ("no server running", "no sessions", "failed to connect") + +# An unrelated structured failure whose message happens to mention the code. +TRANSPORT_ERROR = json.dumps({"error": {"code": "transport_error", + "message": "socket reset while probing server_not_running"}}) + + +@pytest.mark.parametrize("envelope,stream", [ + ({"error": {"code": "server_not_running"}}, "stderr"), + (REAL_DOWN_ENVELOPE, "stderr"), + ({"error": {"code": "server_not_running"}}, "stdout"), +], ids=["minimal", "herdr-0.9", "minimal-on-stdout"]) +def test_ls_reports_down_server_as_no_server_running(mam_sandbox, envelope, stream): + """reconcile.sh treats a nonzero `ls` as 'empty' only when stderr says + 'no server running'; anything else flips herdr_confirmed=False for every + server. The shim must therefore translate server_not_running explicitly.""" + env = _install_fake_herdr(mam_sandbox) + env["MAM_NO_AUTOSTART"] = "1" + _fake_agent_list(mam_sandbox, _fail_with(json.dumps(envelope), stream)) + + res = _shim_ls(mam_sandbox, env, "down-srv") + assert res.returncode != 0 + assert "no server running" in res.stderr, res.stderr + assert res.stdout.strip() == "" + + +def test_ls_lists_agents_whose_names_contain_server_not_running(mam_sandbox): + """A successful list is never a down server, whatever text it carries. + Matching the bare substring let one agent named server_not_running-codex + hide every agent its server returned.""" + env = _install_fake_herdr(mam_sandbox) + env["MAM_NO_AUTOSTART"] = "1" + _fake_agent_list(mam_sandbox, f"print({json.dumps(json.dumps(LISTING_WITH_SUBSTRING))})") + + res = _shim_ls(mam_sandbox, env, "live-srv") + assert res.returncode == 0, res.stderr + assert "no server running" not in res.stderr, res.stderr + names = [line.split("|", 1)[0] for line in res.stdout.splitlines() if line.strip()] + assert names == ["server_not_running-codex", "creator-claude-01"], res.stdout + + +@pytest.mark.parametrize("text,stream", [ + (TRANSPORT_ERROR, "stderr"), + (TRANSPORT_ERROR, "stdout"), + ("warning: server_not_running cache entry is stale", "stderr"), + ("", "stderr"), +], ids=["other-code", "other-code-on-stdout", "plain-text", "empty"]) +def test_ls_leaves_other_failures_unknown(mam_sandbox, text, stream): + """Only the structured code means "down". Any other failure must stay + unknown - nonzero exit, no empty-server phrase - wherever its error + document lands. On stdout it used to reach the list parser, which read a + JSON error without result.agents as a successful EMPTY list (rc 0).""" + env = _install_fake_herdr(mam_sandbox) + env["MAM_NO_AUTOSTART"] = "1" + _fake_agent_list(mam_sandbox, _fail_with(text, stream)) + + res = _shim_ls(mam_sandbox, env, "flaky-srv") + assert res.returncode != 0, f"a failed list must not exit 0: {res.stdout!r} {res.stderr!r}" + for phrase in RECONCILE_EMPTY_PHRASES: + assert phrase not in res.stderr.lower(), res.stderr + assert res.stdout.strip() == "" + + +def _write_running_rows(sandbox: Path, names, server: str = "live-srv") -> None: + rows = "".join( + f" - name: {name}\n" + " status: running\n" + f" herdr_session: {server}\n" + " pane:\n" + " cwd: /tmp/nonexistent-workspace\n" + for name in names + ) + (sandbox / ".mam" / "agent-sessions.yaml").write_text("herdr_sessions:\n" + rows) + + +def _reconcile_dry_run(sandbox: Path, env: dict) -> dict: + reconcile = sandbox / "skills" / "multi-agent-mux-monitor" / "scripts" / "reconcile.sh" + res = subprocess.run(["bash", str(reconcile), "--once", "--emit-diff", "--dry-run"], + capture_output=True, text=True, cwd=str(sandbox), env=env, timeout=120) + assert res.returncode == 0, res.stderr + return json.loads(res.stdout) + + +def test_reconcile_keeps_live_rows_when_a_name_contains_server_not_running(mam_sandbox): + """End to end through reconcile.sh: the server answers a successful list in + which the down-server code appears as data. Both registered rows must stay + alive with herdr_confirmed intact, so drift A cannot mark them terminated.""" + env = _install_fake_herdr(mam_sandbox) + _fake_agent_list(mam_sandbox, f"print({json.dumps(json.dumps(LISTING_WITH_SUBSTRING))})") + _write_running_rows(mam_sandbox, ("server_not_running-codex", "creator-claude-01")) + + report = _reconcile_dry_run(mam_sandbox, env) + assert report["herdr_confirmed"] is True, report + alive = set(report["herdr_sessions_alive"]) + assert {"server_not_running-codex|live-srv", "creator-claude-01|live-srv"} <= alive, alive + drift_a = [d for d in report["drifts"] if d.get("class") == "A"] + assert not drift_a, drift_a + + +def test_reconcile_treats_other_list_failures_as_unknown(mam_sandbox): + """End to end through reconcile.sh: a server whose `agent list` fails with + an unrelated structured error on stdout must drop herdr_confirmed, so + drift A never marks its live row terminated.""" + env = _install_fake_herdr(mam_sandbox) + _fake_agent_list(mam_sandbox, _fail_with(TRANSPORT_ERROR, "stdout")) + _write_running_rows(mam_sandbox, ("creator-claude-01",)) + + report = _reconcile_dry_run(mam_sandbox, env) + assert report["herdr_confirmed"] is False, report + drift_a = [d for d in report["drifts"] if d.get("class") == "A"] + assert not drift_a, drift_a + terminated = [a for a in report.get("actions", []) if str(a).startswith("terminated")] + assert not terminated, terminated