diff --git a/.agents/reports/planner-reviewer-claude-01/report-adc0ff3c.md b/.agents/reports/planner-reviewer-claude-01/report-adc0ff3c.md new file mode 100644 index 0000000..9f58e15 --- /dev/null +++ b/.agents/reports/planner-reviewer-claude-01/report-adc0ff3c.md @@ -0,0 +1,176 @@ +# ๐Ÿ” Review Report โ€” PR #35 ๋ณ‘ํ•ฉ ์ „ ๋…๋ฆฝ ๋ฆฌ๋ทฐ (job `adc0ff3c`) + +- **Reviewer**: `planner-reviewer-claude-01` (role: planner,reviewer) ยท 2026-09-23 +- **๋Œ€์ƒ**: PR #35 `fix/issue-23-24` **@ `f4ba205`**, base `main 1e78dae` (v4.7.1). 13์ปค๋ฐ‹, 22ํŒŒ์ผ `+2197/โˆ’118`, `git diff --check` ํด๋ฆฐ. +- **๊ฒ€์ฆ ๊ฒฝ๋กœ**: `/var/folders/.../T/mam-review/loop-20260923T142448Z-609954/1`. **identity ํ™•์ธ**: `HEAD = f4ba205cโ€ฆfd2f`, `HEAD^{tree} = 88cb4f34โ€ฆf181` โ€” ๋ธŒ๋ฆฌํ”„์˜ `head`ยท`tree` ์™€ ์ผ์น˜. +- **์•ˆ์ „ ๊ทœ์น™ ์ค€์ˆ˜**: ๊ฒ€์ฆ ๊ฒฝ๋กœยท์†Œ์Šค ์›ŒํฌํŠธ๋ฆฌ(`multi-agent-mux-wt/pr-35`)ยท๋ณธ ์ฒดํฌ์•„์›ƒ(`main 1e78dae`) ๋ชจ๋‘ ์ˆ˜์ •ยท์ปค๋ฐ‹ยทcheckout ์—†์Œ. ์‹คํ—˜์€ scratchpad ์ž„์‹œ ๋””๋ ‰ํ„ฐ๋ฆฌ(uuid4 ์ด๋ฆ„)์—์„œ๋งŒ, pytest ๋Š” `AGENT_PYTHON_BIN=<์ œ์–ด ๋ฃจํŠธ .venv>` + `--basetemp=<๊ฒ€์ฆ ๊ฒฝ๋กœ ๋ฐ–>` ์œผ๋กœ ์‹คํ–‰. ์‹ค์ œ herdr ์„œ๋ฒ„ยท์—์ด์ „ํŠธ ์„ธ์…˜ยท`.mam`ยท๋ฐ๋ชฌ ์กฐ์ž‘ ์—†์Œ(์ด pane ์˜ herdr ํ™˜๊ฒฝ๋ณ€์ˆ˜๋ฅผ **์ฝ๊ธฐ๋งŒ** ํ–ˆ๋‹ค), pushยทmergeยทPR ์ƒํƒœ ๋ณ€๊ฒฝ ์—†์Œ, purgeยทscrub ์‹คํ–‰ ์—†์Œ. + +## ๊ฒฐ๋ก  ์š”์•ฝ + +**๋ณ‘ํ•ฉ์„ ๋ง‰์„ ๊ฒฐํ•จ์€ ์—†๋‹ค.** ์„ธ ํ•ต์‹ฌ ๊ทœ์น™ โ€” ๊ต์ฐจ ์„œ๋ฒ„ ํŒ์ •, "์ด๋ฒˆ dispatch ์˜ started ๋งŒ ์ฆ๊ฑฐ", kill ๊ฒฐ๊ณผ ํ™•์ธ ๋กค๋ฐฑ โ€” ์„ ๊ฐ๊ฐ ๋…๋ฆฝ ์‹คํ—˜์œผ๋กœ ์žฌํ˜„ยทํ™•์ธํ–ˆ๊ณ , ์ƒˆ ํ…Œ์ŠคํŠธ๋Š” ์‹ค์ œ `registry.py`/`publish_event.py` ๋ฅผ ์“ฐ๋ฉฐ ํ™ˆยท๋ธŒ๋กœ์ปคยท์‹ค์ œ herdr ์— ๋‹ฟ์ง€ ์•Š๋Š”๋‹ค. ๋น„์ฐจ๋‹จ ์ง€์  6๊ฑด(F1โ€“F6)์„ ๋‚จ๊ธด๋‹ค. ๊ทธ์ค‘ F1 ์€ **๋ธŒ๋ฆฌํ”„์˜ codex ์ˆ˜๋ฝ ํ‚ค ์„œ์ˆ ๊ณผ ์ฝ”๋“œ๊ฐ€ ๋‹ค๋ฅด๋‹ค**๋Š” ์ ์ด๋ผ ์˜ค์ผ€์ŠคํŠธ๋ ˆ์ดํ„ฐ๊ฐ€ ํ™•์ธํ•  ํ•„์š”๊ฐ€ ์žˆ๋‹ค. + +## 1. #24 ์‹œ์ž‘ ๋‹ค์ด์–ผ๋กœ๊ทธ (ํ•ญ๋ชฉ 1) + +### 1-1. ์–ด๋Œ‘ํ„ฐ ์‹œ๊ทธ๋‹ˆ์ฒ˜ยท์ˆ˜๋ฝ ํ‚ค + +`python -m lib_py.agents facts ` ๊ฐ€ ์‹ค์ œ๋กœ ๋‚ด๋†“๋Š” ๊ฐ’(๊ฒ€์ฆ ๊ฒฝ๋กœ์—์„œ ์‹คํ–‰): + +| ์—์ด์ „ํŠธ | `MAM_STARTUP_DIALOGS` | ๊ฐ•ํ•œ ์ค€๋น„ ํ† ํฐ | +|---|---|---| +| claude | ๊ธฐ์กด ์‚ฌ๋‹ค๋ฆฌ 5๊ฐœ(trust Enter / `Yes, try it` Escape / `Yes, proceed` Down+Enter / `Resumingโ€ฆ` Enter / `Press Enterโ€ฆ` Enter)๊ฐ€ ๊ทธ๋Œ€๋กœ ์˜ฎ๊ฒจ์ง | `Anthropic\|Assistant\|โ€ฆ\|Haiku` | +| agy | `Do you trust the contents of this project\|Yes, I trust this folder` โ†’ `Enter`, trust | `Antigravity` | +| grok | `Do you trust the contents of this directory` โ†’ `y`, trust | `Grok\|xAI\|Assistant\|โฏ\|>>>` | +| codex | `Do you trustโ€ฆ\|1\. Yes, continue` โ†’ **`Enter`**, trust ยท `1\. Update now\|Skip until next version` โ†’ **`2`**, notice | `Codex\|Ask Codex to do anything` | +| opencodeยทhermes | `[]` (๋‹ค์ด์–ผ๋กœ๊ทธ ์—†์Œ) | ์žˆ์Œ โ†’ ํ•ธ๋“ค๋Ÿฌ๊ฐ€ ๋ฐฐ๋„ˆ์—์„œ ์ฆ‰์‹œ ๋ฐ˜ํ™˜ | + +**F1 (ํ™•์ธ ์š”๋ง, ๋น„์ฐจ๋‹จ)**: ๋ธŒ๋ฆฌํ”„๋Š” codex ์ˆ˜๋ฝ ํ‚ค๋ฅผ "`1`+Enter, ์—…๋ฐ์ดํŠธ ์•ˆ๋‚ด `3`+Enter" ๋กœ ์ ์—ˆ์œผ๋‚˜ ์ฝ”๋“œ๋Š” **Enter ๋‹จ๋…**(1๋ฒˆ ํ•ญ๋ชฉ์ด ๋ฏธ๋ฆฌ ์„ ํƒ๋ผ ์žˆ๋‹ค๋Š” ์ฃผ์„)๊ณผ **`2`**("2 = Skip" ์ด๋ผ๋Š” ๊ด€์ฐฐ ๊ธฐ๋ก, 2026-09-22) ๋‹ค. ์–ด๋А ์ชฝ์ด ์‹ค์ œ ํ™”๋ฉด์ธ์ง€๋Š” ์ด ๋ฆฌ๋ทฐ์—์„œ ์žฌํ˜„ํ•  ์ˆ˜ ์—†๋‹ค(์‹ค์ œ codex ๋ฅผ ๋„์šฐ๋Š” ๊ฒƒ์€ ๊ธˆ์ง€). PR ๋ณธ๋ฌธ์€ codex ๋ฅผ ํฌํ•จํ•œ 4๊ฐœ ์—์ด์ „ํŠธ์˜ ์‹ค์ œ herdr ์‹œํ—˜์ด ์‚ฌ๋žŒ ๊ฐœ์ž… ์—†์ด `started`(ํ† ํฐ ํฌํ•จ)โ†’`completed` ๊นŒ์ง€ ๊ฐ”๋‹ค๊ณ  ๊ธฐ๋กํ•˜๋ฏ€๋กœ **์ฝ”๋“œ ์ชฝ์ด ์‹ค์ธก**์œผ๋กœ ๋ณด์ด์ง€๋งŒ, ๋ธŒ๋ฆฌํ”„ ์ž‘์„ฑ์ž์˜ ๊ด€์ฐฐ๊ณผ ๋‹ค๋ฅด๋ฏ€๋กœ ๋ณ‘ํ•ฉ ์ „์— ํ•œ ๋ฒˆ ๋งž์ถฐ ๋ณผ ๊ฒƒ์„ ๊ถŒํ•œ๋‹ค. `_MAM_READY_TOKENS_CLAUDE` ์ƒ์ˆ˜๋Š” ์ œ๊ฑฐ๋˜๊ณ  ์–ด๋Œ‘ํ„ฐ์˜ `strong_ready_tokens` ๋กœ ๋Œ€์ฒด๋๋‹ค(๊ฐ’ ๋™์ผ). + +### 1-2. ์ค€๋น„ ํ† ํฐ์œผ๋กœ ์ˆ˜๋ฝ์„ ํŒ์ •ํ•˜๋Š” ์ด์œ  + +`handle_startup_dialogs`(`lib.sh:2979-`)๋Š” ์‹œ๊ทธ๋‹ˆ์ฒ˜๊ฐ€ ํ™”๋ฉด์— ๋‚จ์•„ ์žˆ์–ด๋„ ๊ฐ™์€ ์ธ๋ฑ์Šค์— ๋Œ€ํ•ด `MAM_DIALOG_RESEND_S`(๊ธฐ๋ณธ 5 s) ์•ˆ์—๋Š” ์žฌ์ „์†กํ•˜์ง€ ์•Š๊ณ (`_mam_dialog_pick` ์˜ cooldown), ๋ฐ˜ํ™˜ ์กฐ๊ฑด์€ **์˜ค์ง ์ค€๋น„ ํ† ํฐ**์ด๋‹ค. `--source visible` ์Šค๋ƒ…์ƒท์ด ์ˆ˜๋ฝ๋œ ๋‹ค์ด์–ผ๋กœ๊ทธ๋ฅผ ํ•œ๋™์•ˆ ๊ณ„์† ๋ณด์—ฌ ์ค€๋‹ค๋Š” #24 ์˜ ๊ด€์ฐฐ๊ณผ ๋งž๋ฌผ๋ ค ํƒ€๋‹นํ•˜๋‹ค โ€” ์˜› ์ฝ”๋“œ๋Š” ๋งค ํด๋ง๋งˆ๋‹ค Enter ๋ฅผ ๋‹ค์‹œ ๋ณด๋‚ด ์ด๋ฏธ ๋„˜์–ด๊ฐ„ TUI ์— ์ž…๋ ฅ์„ ํ˜๋ ธ๋‹ค. `test_c1_tui_readiness.py::test_c1b` ์˜ ๋‹จ์–ธ์ด `==30` ์—์„œ `1 โ‰ค n โ‰ค 30` ์œผ๋กœ ๋ฐ”๋€ ๊ฒƒ๋„ ๊ฐ™์€ ์ด์œ ์ด๋ฉฐ, "ํ•œ ํ”„๋ ˆ์ž„์— ๋‘ ๋ฒˆ ๋ณด๋‚ด์ง€ ์•Š๋Š”๋‹ค"๋Š” ์›๋ž˜ ๊ฐ€๋“œ๋Š” ๊ทธ๋Œ€๋กœ๋‹ค. + +### 1-3. `--no-auto-trust` / `MAM_AUTO_TRUST=0` + +`_mam_dialog_pick` ์€ `kind == "trust"` ์ด๊ณ  auto_trust ๊ฐ€ ๊บผ์ ธ ์žˆ์œผ๋ฉด `skip|idx|` ๋ฅผ ๋‚ด๊ณ , ํ•ธ๋“ค๋Ÿฌ๋Š” ๊ฒฝ๊ณ ๋งŒ ๋‚จ๊ธด๋‹ค(cooldown ๋งˆ๋‹ค 1ํšŒ). `notice` ๋Š” ํ•ญ์ƒ ๋‹ตํ•œ๋‹ค. create(`create_session.sh:86-94`)์™€ resume(`resume_session.sh:43-57`)์ด **๊ฐ™์€ ์Šค์œ„์น˜ยท๊ฐ™์€ env ๋ณ€์ˆ˜(`MAM_AUTO_TRUST`)ยท๊ฐ™์€ ํ˜ธ์ถœ(`handle_startup_dialogs "$SESSION" N "$AGENT"`)** ์„ ์“ด๋‹ค. S6/R3 ๊ฐ€ ๊ณ ์ •. + +### 1-4. ์ž๋™ ์ˆ˜๋ฝ์˜ ์ ์šฉ ๋ฒ”์œ„(๋ณด์•ˆ) + +pane ์€ `new-session -c "$WORKSPACE"` ๋กœ ๋œจ๊ณ  ์‹ ๋ขฐ ์ฐฝ์€ ๊ทธ cwd ์— ๋Œ€ํ•œ ๊ฒƒ์ด๋ฏ€๋กœ ์‹ค์งˆ์ ์œผ๋กœ `--workspace` ์—๋งŒ ์ ์šฉ๋œ๋‹ค. ๋‹ค๋งŒ ์‹œ๊ทธ๋‹ˆ์ฒ˜ ์ •๊ทœ์‹์€ **๋””๋ ‰ํ„ฐ๋ฆฌ ์ด๋ฆ„์„ ๊ฒ€์‚ฌํ•˜์ง€ ์•Š๋Š”๋‹ค** โ€” ํ™”๋ฉด์— ์–ด๋–ค ๊ฒฝ๋กœ๊ฐ€ ์ ํ˜€ ์žˆ๋“  ๋งค์น˜๋˜๋ฉด ์ˆ˜๋ฝํ•œ๋‹ค(F5). ์‹œ์ž‘ ์งํ›„์˜ ์‹ ๋ขฐ ์ฐฝ์ด ๋‹ค๋ฅธ ๋””๋ ‰ํ„ฐ๋ฆฌ๋ฅผ ๊ฐ€๋ฆฌํ‚ฌ ๊ฒฝ๋กœ๋Š” ์—†์–ด ์‹ค๋ฌด ์œ„ํ—˜์€ ๋‚ฎ๋‹ค. + +## 2. #23 ๊ต์ฐจ ์„œ๋ฒ„ ์Šค์ฝ”ํ”„ (ํ•ญ๋ชฉ 2) + +`_herdr_caller_server` / `_herdr_call_is_cross_server`(`lib.sh:1403-1447`)๋ฅผ lib.sh ์—์„œ ํ•จ์ˆ˜๋งŒ ์ถ”์ถœํ•ด `env -i` ๋กœ 9๊ฐ€์ง€ ์กฐํ•ฉ์„ ๋Œ๋ ธ๋‹ค(์ „๋ถ€ ๊ธฐ๋Œ€์™€ ์ผ์น˜): + +| `HERDR_SOCKET_PATH` | `HERDR_SESSION` | ๋Œ€์ƒ | ํŒ์ • | +|---|---|---|---| +| `~/.config/herdr/herdr.sock` | โ€“ | `mam` | caller=`default`, **CROSS** (๋ณด๊ณ ๋œ #23 ์‹œ๋‚˜๋ฆฌ์˜ค) | +| ใ€ƒ | โ€“ | (์—†์Œ) / `default` | same (์•ฐ๋น„์–ธํŠธ sentinel) | +| `~/.config/herdr/sessions/mam/herdr.sock` | โ€“ | `mam` | caller=`mam`, same โ†’ **pane ๋ฐฐ์น˜ยทworkspace ํŒจํ‚น ์œ ์ง€** | +| ใ€ƒ | โ€“ | `other` | CROSS | +| ใ€ƒ | `orch` | `mam` | caller=`orch`(HERDR_SESSION ์šฐ์„ ), CROSS | +| `/other/layout/herdr.sock` / ๋น„์–ด ์žˆ์Œ | โ€“ | `mam` | caller="" โ†’ same (pane ๋ฐ–: ๋ช…์‹œ ์Šค์ฝ”ํ”„ ์กด์ค‘) | +| `โ€ฆ/sessions/a b/herdr.sock` | โ€“ | `mam` | caller=`a b`, CROSS (๊ณต๋ฐฑ ์•ˆ์ „) | + +- ์ œ๊ฑฐ๋Š” `mam_herdr` ์•ˆ์—์„œ **๊ต์ฐจ์ผ ๋•Œ๋งŒ** `env -u HERDR_WORKSPACE_ID -u HERDR_PANE_ID -u HERDR_TAB_ID` ๋กœ ์ด๋ฃจ์–ด์ง€๊ณ (`lib.sh:1449-1461`), argv ๋ฐฐ์—ด + `if` ๋กœ ํŒ์ •ํ•˜๋ฏ€๋กœ `set -e` ํ˜ธ์ถœ์ž๋ฅผ ์ค‘๋‹จ์‹œํ‚ค์ง€ ์•Š๋Š”๋‹ค(S12). +- `lib_py/loop/watcher.py::caller_server/scoped_env` ๋Š” ๊ฐ™์€ ๊ทœ์น™์˜ ํŒŒ์ด์ฌ ์Œ๋‘ฅ์ด๋‹ค. ๊ฐ™์€ 5๊ฐ€์ง€ ์ž…๋ ฅ์œผ๋กœ ๋Œ๋ ค bash ์™€ **๋™์ผ ๊ฒฐ๊ณผ**๋ฅผ ํ™•์ธํ–ˆ๋‹ค(๊ฐ™์€ ์„œ๋ฒ„ยทpane ๋ฐ–ยท`default`ยท`None` ๋Œ€์ƒ์€ ids ์œ ์ง€, ๊ต์ฐจ๋งŒ ์ œ๊ฑฐ). +- **์‹ค์ œ ๋ ˆ์ด์•„์›ƒ ๊ทผ๊ฑฐ**: ์ด pane(herdr **0.9.1**)์˜ ํ™˜๊ฒฝ์€ `HERDR_SOCKET_PATH=~/.config/herdr/sessions/multi-agent-mux/herdr.sock`, `HERDR_SESSION=multi-agent-mux` ์˜€๋‹ค โ€” ๊ทœ์น™์ด ์ „์ œํ•˜๋Š” `sessions//herdr.sock` ํ˜•ํƒœ์™€ ์ผ์น˜ํ•˜๊ณ , `HERDR_SESSION` ๋„ export ๋œ๋‹ค. 0.8.2 ์˜ "๊ธฐ๋ณธ ์„œ๋ฒ„ pane ์—๋Š” `HERDR_SESSION` ์ด ์—†๋‹ค"๋Š” ์ „์ œ๋Š” PR ์˜ ์‹ค์ธก ๊ธฐ๋ก(์ฝ”๋“œ ์ฃผ์„)์— ์˜์กดํ•˜๋ฉฐ ์—ฌ๊ธฐ์„œ ์žฌํ˜„ํ•  ์ˆ˜ ์—†๋‹ค. ๋‘ ๊ฒฝ์šฐ ๋ชจ๋‘ ์†Œ์ผ“ ๊ฒฝ๋กœ ๊ทœ์น™์ด ๋’ท๋ฐ›์นจํ•˜๋ฏ€๋กœ ์–ด๋А ๋ฒ„์ „์ด๋“  ํŒ์ •์ด ์„ฑ๋ฆฝํ•œ๋‹ค. +- ์ธ์‹๋˜์ง€ ์•Š๋Š” ๋ ˆ์ด์•„์›ƒ์€ ""(= pane ๋ฐ–) ์œผ๋กœ ๋–จ์–ด์ ธ **์˜ค๋Š˜์˜ ๋™์ž‘์„ ์œ ์ง€**ํ•œ๋‹ค. ์ž˜๋ชป๋œ ์ œ๊ฑฐ๋ณด๋‹ค ์•ˆ์ „ํ•œ ์ชฝ์ด๋‹ค. + +## 3. #23 ์˜จ๋ณด๋”ฉ ์ „๋‹ฌ ํŒ์ • (ํ•ญ๋ชฉ 3) + +### 3-1. ๊ณต์œ  + +`delegate_publish_cmd`ยท`delegate_job_status`ยท`delegate_started_recorded`ยท`delegate_wait_started` ๊ฐ€ `lib.sh:2189-2313` ์— ํ•˜๋‚˜์”ฉ ์žˆ๊ณ , create ์˜จ๋ณด๋”ฉ(`create_session.sh:622-680`)๊ณผ ๋ž˜ํผ `_wait_started`(`multi-agent-mux-delegate-job:647-668`)๊ฐ€ **๊ฐ™์€ `delegate_started_recorded`** ๋ฅผ ๋ถ€๋ฅธ๋‹ค. ๋ž˜ํผ๋Š” `MAM_JOBS_DIR="$REGISTRY_DIR"` ์™€ `LOGS_DIR_ABS` ๋ฅผ ๋„˜๊ฒจ ์ž๊ธฐ ๋ ˆ์ง€์ŠคํŠธ๋ฆฌยท๊ฐ์‚ฌ ๋ฃจํŠธ๋ฅผ ๊ทธ๋Œ€๋กœ ์“ด๋‹ค(`:191`,`:659`). ์˜› `_disk_status != pending โ†’ ์„ฑ๊ณต` ์šฐํšŒ์™€ ๊ตฌ๋…์ž ์ฝ˜์†” grep ์€ **์‚ญ์ œ**๋๋‹ค(diff ๋กœ ํ™•์ธ). + +### 3-2. ์ฆ๊ฑฐ ๊ทœ์น™ ์‹ค์ธก + +`delegate_started_recorded` ๋งŒ ์ถ”์ถœํ•ด ๊ฐ€์งœ `events.log`/`events.ndjson` ์œผ๋กœ 10๊ฐ€์ง€๋ฅผ ๋Œ๋ ธ๋‹ค(์ „๋ถ€ ๊ธฐ๋Œ€์™€ ์ผ์น˜): + +| # | ์ƒํƒœ | ํ† ํฐ ์š”๊ตฌ | ๊ฒฐ๊ณผ | +|---|---|---|---| +| 1 | ํŒŒ์ผ ์—†์Œ | ์žˆ์Œ | NOT | +| 2 | ์ด์ „ dispatch ํ† ํฐ์˜ `started` | ์žˆ์Œ | **NOT** (R1: ์ด์ „ ์‹œ๋„์˜ ์ฆ๊ฑฐ ๊ฑฐ๋ถ€) | +| 3 | ใ€ƒ | ์—†์Œ | recorded (ํ† ํฐ ์—†์œผ๋ฉด ์ข…์ „๋Œ€๋กœ) | +| 4 | ์ด๋ฒˆ ํ† ํฐ์˜ `started` | ์žˆ์Œ | recorded | +| 5 | ๊ฐ์‚ฌ ๊ธฐ๋ก `dispatch_verified:true`, **fingerprint ์—†์Œ** | ์žˆ์Œ | **NOT** (์‹ ์› ์—†๋Š” ๊ณผ๊ฑฐ ๊ธฐ๋ก fail closed) | +| 6 | ใ€ƒ | ์—†์Œ | recorded | +| 7 | ์ด์ „ ์‹œ๋„์˜ fingerprint | ์žˆ์Œ | NOT | +| 8 | ์ด๋ฒˆ ํ† ํฐ์˜ fingerprint | ์žˆ์Œ | recorded | +| 9 | fingerprint ๋งž์ง€๋งŒ `dispatch_verified:false` | ์žˆ์Œ | NOT | +| 10 | ๊นจ์ง„ ์ค„ + `progress` ๋งŒ | ์žˆ์Œ | NOT (JSON ํŒŒ์‹ฑ, ํŒจํ„ด ๋งค์นญ ์•„๋‹˜) | + +fingerprint ๋Š” `sha256(token)` ์œผ๋กœ ๊ตฌ๋…์ž(`job_subscriber.py:303-306`)์™€ **๊ฐ™์€ ํ•ด์‹œ**์ž„์„ ํ™•์ธํ–ˆ๋‹ค. ๋ ˆ์ง€์ŠคํŠธ๋ฆฌ `status` ๋Š” ์–ด๋А ๊ฒฝ๋กœ์—์„œ๋„ ์ฆ๊ฑฐ๋กœ ์“ฐ์ด์ง€ ์•Š๋Š”๋‹ค. + +### 3-3. ์ข…๋ฃŒ๋œ ์žกยท#18 ์ •ํ•ฉ + +- create ๋Š” ๋” ์ด์ƒ ์—์ด์ „ํŠธ ๋Œ€์‹  `started` ๋ฅผ ๋ฐœํ–‰ํ•˜์ง€ ์•Š๋Š”๋‹ค(`:649-657` ์ฃผ์„๊ณผ ์ฝ”๋“œ; S7). ๋ฏธํ™•์ธ ์‹œ `delegate_job_status` ๊ฐ€ `completed|error|cancelled` ๋ฉด **๋ฎ์–ด์“ฐ์ง€ ์•Š๊ณ ** ๊ฒฝ๊ณ ๋งŒ ํ•œ๋‹ค(`:674-679`; S7m). +- ๋Œ€๊ธฐ ์‹œ๊ฐ„์€ `MAM_ONBOARD_ACK_S` โ†’ `MAM_INJECT_ACK_GRACE_S` โ†’ ์žก์˜ `ack_timeout_sec`(๊ธฐ๋ณธ 180) ์ˆœ์ด๋‹ค โ€” #18 ์˜ T=180 ๊ณผ ๊ฐ™์€ ๊ฐ’์ด๊ณ , heartbeat ๊ณ„์•ฝ์€ `started --heartbeat 30` ๋ช…๋ น์„ `delegate_publish_cmd` ๊ฐ€ ๋ž˜ํผ์™€ **์ธ์ž ๋‹จ์œ„๋กœ ๋™์ผ**ํ•˜๊ฒŒ ๋งŒ๋“ ๋‹ค(S7l). +- `inject_instructions`(= `send_keys_safe`) ์˜ rc 4 ๋Š” ์„ธ ์ž๋ฆฌ ๋ชจ๋‘ "์ „๋‹ฌ ๋ฏธ์ƒ"(`lib.sh:2746` busy ์ดˆ๊ณผ, `:2810` delivery unknown, `:2883` Enter ๋ฏธ์ˆ˜๋ฝ)์ด๋ผ `started` ๋Œ€๊ธฐ๋กœ ํŒ์ •์„ ๋„˜๊ธฐ๋Š” ๊ฒƒ์ด ๋งž๊ณ , rc 1(quiesce ์‹คํŒจ)ยท2ยท3(๋‹ค์ด์–ผ๋กœ๊ทธ ์ฐจ๋‹จ)์€ ์ฆ‰์‹œ ์‹คํŒจ๋กœ ๋‚จ๋Š”๋‹ค(S8/S8b). + +## 4. #23 ์‹คํŒจ ์ฒ˜๋ฆฌ (ํ•ญ๋ชฉ 4) + +- `cleanup_herdr_on_error`(`create_session.sh:318-346`): `kill-session` ์˜ rc ๋ฅผ ๋ฒ„๋ฆฌ์ง€ ์•Š๊ณ  ๋ฐ›์€ ๋’ค `has-session` ์œผ๋กœ ์žฌํ™•์ธ, ์‚ด์•„ ์žˆ์œผ๋ฉด ํ–‰์„ `running` + `last_visible_status: rollback-failed` + `delegate_job_id: null` ๋กœ ํ‘œ์‹œํ•˜๊ณ  **exit 43**. bash 3.2 ์—์„œ EXIT trap ์•ˆ์˜ `exit 43` ์ด ์›๋ž˜ ์ข…๋ฃŒ ์ฝ”๋“œ(1)๋ฅผ ๋ฎ์–ด์”€์„ ์ง์ ‘ ํ™•์ธํ–ˆ๋‹ค(`rc=43`). ์ƒํƒœ๊ฐ’์€ `atomic_yaml.py:18` ์˜ ์–ดํœ˜(`running/terminated/archived/stopped`) ์•ˆ์ด๊ณ  `last_visible_status` ๋Š” reconcile ์ด ์ด๋ฏธ ์“ฐ๋Š” ํ•„๋“œ๋‹ค. +- ์˜จ๋ณด๋”ฉ ๋ฏธํ™•์ธ: ์„ธ์…˜ ๋ณด์กด + ์žก `error`(์ด๋ฏธ ์ข…๋ฃŒ๋œ ์žก์€ ์ œ์™ธ) + ๊ฒฝ๊ณ  2์ค„, `ROW_APPENDED` ๋’ค๋ผ YAML ํ–‰์€ ์œ ํšจํ•œ `running` ์ด๋‹ค. ์‹คํŒจํ•œ ์žก์€ `JOB_TERMINALIZED=1` ๋กœ ํ‘œ์‹œ๋ผ ํŠธ๋žฉ์ด `cancelled` ๋ฅผ ๊ฒน์ณ ๋ฐœํ–‰ํ•˜์ง€ ์•Š๋Š”๋‹ค. +- create SKILL.md(`:101-131`)๊ฐ€ ์ด ๊ทœ์น™(started ๊ธฐ๋ฐ˜ ํŒ์ •, rc 4 ๊ฒฝ๊ณ , ์„ธ์…˜ ๋ณด์กด, `MAM_ONBOARD_ACK_S`, exit 42/43 ๊ณผ ํ–‰ ์ƒํƒœ)์„ ์ •ํ™•ํžˆ ์„œ์ˆ ํ•œ๋‹ค. + +## 5. ๊ธฐ์กด ํ…Œ์ŠคํŠธ ์กฐ์ • 6ํŒŒ์ผ (ํ•ญ๋ชฉ 5) + +| ํŒŒ์ผ | ๋ณ€๊ฒฝ | ํŒ์ • | +|---|---|---| +| `conftest.py` | `mam_sandbox` ์— `MAM_ONBOARD_ACK_S=3` ์ถ”๊ฐ€ | ํƒ€๋‹น. ์ƒŒ๋“œ๋ฐ•์Šค์—๋Š” `started` ๋ฅผ ๋ฐœํ–‰ํ•  ์‹ค์ œ ์—์ด์ „ํŠธ๊ฐ€ ์—†์–ด ์ด๊ฒƒ์ด ์—†์œผ๋ฉด create ํ…Œ์ŠคํŠธ๋งˆ๋‹ค 180 s ๋ฅผ ์†Œ๋ชจํ•œ๋‹ค. create ์ „์šฉ ๋ณ€์ˆ˜๋ผ ๋ž˜ํผ์˜ `MAM_INJECT_ACK_GRACE_S` ๋ฅผ ๋‹จ์–ธํ•˜๋Š” ํ…Œ์ŠคํŠธ์™€ ๊ฐ„์„ญํ•˜์ง€ ์•Š๋Š”๋‹ค | +| `test_b19_*` | `grep -q 'Yes, try it'` ๊ฐ€ lib.sh ์— ์žˆ๋Š”์ง€ โ†’ claude ์–ด๋Œ‘ํ„ฐ์˜ `('Yes, try it', ['Escape'], 'notice')` ๋กœ ์œ„์น˜ ์ด๋™ | ํ† ํฐ ๋ชฉ๋ก ๋‘ ๋‹จ์–ธ์€ ๊ทธ๋Œ€๋กœ. ๊ฒ€์ฆ ๋Œ€์ƒ์ด ์ด์‚ฌํ•œ ๊ฒƒ๋ฟ | +| `test_c1_tui_readiness.py` | `KEYS_COUNT==30` โ†’ `1 โ‰ค n โ‰ค 30`, `custom` ์—์ด์ „ํŠธ โ†’ `claude` | ยง1-2 ์ฐธ์กฐ. cooldown ๋„์ž…์œผ๋กœ "๋งค ๋ฐ˜๋ณต 1ํšŒ" ๊ฐ€ "ํ”„๋ ˆ์ž„๋‹น ์ตœ๋Œ€ 1ํšŒ" ๋กœ ๋ฐ”๋€Œ์—ˆ๊ณ  ์›๋ž˜ ๊ฐ€๋“œ(2ํšŒ ๊ธˆ์ง€)๋Š” ์œ ์ง€ยท๊ฐ•ํ™”. `custom` ์€ ์–ด๋Œ‘ํ„ฐ๊ฐ€ ์—†์–ด ๋‹ค์ด์–ผ๋กœ๊ทธ ๋ชฉ๋ก์ด ๋น„๋ฏ€๋กœ ์ด ์‹œํ—˜์—๋Š” `claude` ๊ฐ€ ๋งž๋‹ค | +| `test_issue14_delivery_ack.py` | ๋ ˆ์ง€์ŠคํŠธ๋ฆฌ ์ƒํƒœ๋งŒ `running` ์œผ๋กœ flip ํ•˜๋˜ "ack" ๋ฅผ **์‹ค์ œ `publish_event.py` + dispatch ํ† ํฐ์˜ `started`** ๋กœ ๊ต์ฒด | **๊ฐ•ํ™”**. ์˜› ์‹œํ—˜์€ PR ์ด ์—†์•ค "์ƒํƒœ๋งŒ ๋ณด๊ณ  ์„ฑ๊ณต" ์šฐํšŒ๋ฅผ ํ†ต๊ณผ ์กฐ๊ฑด์œผ๋กœ ์“ฐ๊ณ  ์žˆ์—ˆ๋‹ค. ์ƒˆ ์‹œํ—˜์€ ๋กœ์ปฌ `events.log` ์— ํ† ํฐ์ด ์‹ค๋ฆฐ ์ค„๊นŒ์ง€ ๋‹จ์–ธํ•œ๋‹ค | +| `test_issue18_liveness_contract.py` | acceptance5 ์˜ ๋ฐฐ๋‹ฌ ๋‹จ์–ธ์„ `recorded 'started'` ํ•˜๋‚˜๋กœ ์ขํž˜ | ๊ณ„์•ฝ์ƒ ์˜ณ๋‹ค(๋ ˆ์ง€์ŠคํŠธ๋ฆฌ ์ƒํƒœ๋Š” ์ฆ๊ฑฐ๊ฐ€ ์•„๋‹˜). ๋‹ค๋งŒ "๋ธŒ๋กœ์ปค๊ฐ€ retained `completed` ๋งŒ ๋ฐฐ๋‹ฌ" ํ•œ ๊ฒฝ์šฐ๊ฐ€ ์ด์ œ **์‹คํŒจ**ํ•œ๋‹ค โ€” `_skip_if_broker_did_not_deliver` ๋Š” ๊ทธ ์žก์˜ ์–ด๋–ค ์ด๋ฒคํŠธ๋„ ๋ชป ๋ฐ›์•˜์„ ๋•Œ๋งŒ skip ํ•˜๋ฏ€๋กœ. ๊ณต๊ฐœ ๋ธŒ๋กœ์ปค์—์„œ flake ๊ฐ€ ๋Š˜ ์ˆ˜ ์žˆ๋Š” ์ž๋ฆฌ(F4) | +| `test_issue5_phase2a_loop.py` | G9 ๊ฐ€๋“œ๊ฐ€ ์ฝ”๋“œ๋งŒ ์ฝ๋„๋ก(`ast` ๋กœ docstring **๋…ธ๋“œ**๋งŒ ์ œ๊ฑฐ, ์ฃผ์„์€ unparse ์—์„œ ์‚ฌ๋ผ์ง) + `g9b` ์ž์ฒด ๊ฒ€์ฆ | ์•ฝํ™” ์•„๋‹˜. ํ˜ธ์ถœ ํ˜•ํƒœ(`subprocess` argv ์˜ `lib.sh` ๊ฒฝ๋กœยท`source`, ์‹๋ณ„์ž)๋Š” ์—ฌ์ „ํžˆ ์ฝ”๋“œ ํ…์ŠคํŠธ์— ์žˆ์–ด ์žกํžˆ๊ณ , `g9b` ๊ฐ€ "๊ฐ™์€ ์ค„์˜ ์ฝ”๋“œ"(R2) ๋„ค ๊ฐ€์ง€๊นŒ์ง€ ๊ณ ์ •ํ•œ๋‹ค. `ast.unparse` ๋Š” 3.9+ โ€” venv 3.9.6 ์ด๋ผ ํ†ต๊ณผ | + +## 7. ๋ฌธ์„œ (ํ•ญ๋ชฉ 7) + +- create SKILL.md `:101-131`: started ๊ธฐ๋ฐ˜ ํŒ์ •ยทrc 4 ๊ฒฝ๊ณ ยท์„ธ์…˜ ๋ณด์กดยท`MAM_ONBOARD_ACK_S`ยท`--no-auto-trust`(codex ์˜ ๋กœ์ปฌ ์„ค์ • ๋กœ๋“œ ์œ„ํ—˜ ๋ช…์‹œ)ยท`MAM_DIALOG_RESEND_S`ยทexit 42/43 ๊ณผ ํ–‰ ์ƒํƒœ๊ฐ€ ์ฝ”๋“œ์™€ ์ผ์น˜ํ•œ๋‹ค. resume SKILL.md ๋Š” ์—์ด์ „ํŠธ ๋ถ„๊ธฐ `case` ๋ฅผ ์—†์• ๊ณ  ๊ฐ™์€ ํ˜ธ์ถœ๋กœ ๋ฐ”๊ฟจ๋‹ค. +- **F2 (๋น„์ฐจ๋‹จ, ๋ฌธ์„œ ๋ˆ„๋ฝ)**: ๊ฐ์‚ฌ ๊ธฐ๋ก์˜ ์ƒˆ ํ•„๋“œ `dispatch_verified`ยท`dispatch_fingerprint`ยท`attempt_id` ๋Š” `MESSAGING.md`ยทdelegate-job `SKILL.md` **์–ด๋””์—๋„ ์—†๋‹ค**(์ „์ˆ˜ grep 0๊ฑด). ๋‘ ๋ฌธ์„œ๋Š” `events.ndjson` ์„ ๋‹จ๊ณ„ ์ˆ˜์ค€์œผ๋กœ๋งŒ ์„œ์ˆ ํ•ด ํ•„๋“œ ๋ชฉ๋ก ์ž์ฒด๊ฐ€ ์—†์œผ๋ฏ€๋กœ ๋ชจ์ˆœ์€ ์—†์ง€๋งŒ, "๋ž˜ํผ์™€ create ๊ฐ€ ๋ฌด์—‡์„ ์ฆ๊ฑฐ๋กœ ์ฝ๋Š”๊ฐ€"๊ฐ€ ์ด๋ฒˆ PR ์˜ ํ•ต์‹ฌ์ด๋ผ ํ•œ ๋ฌธ๋‹จ์€ ์žˆ์–ด์•ผ ํ•œ๋‹ค. +- **F3 (๋น„์ฐจ๋‹จ, ๋ฌธ๊ตฌ ์ •๋ฐ€๋„)**: SKILL.md ๋Š” `MAM_ONBOARD_ACK_S` ๊ฐ€ ๋ž˜ํผ์˜ `MAM_INJECT_ACK_GRACE_S` ์™€ "๋ณ„๊ฐœ" ๋ผ๊ณ ๋งŒ ์ ์—ˆ์ง€๋งŒ ์ฝ”๋“œ์˜ ์šฐ์„ ์ˆœ์œ„๋Š” `MAM_ONBOARD_ACK_S` โ†’ `MAM_INJECT_ACK_GRACE_S` โ†’ ์žก์˜ `ack_timeout_sec` ์ด๋‹ค(`create_session.sh:658`). ์ฆ‰ ๋ž˜ํผ ๋ณ€์ˆ˜๋„ create ์˜ ๋Œ€๊ธฐ์— **2์ˆœ์œ„๋กœ ์˜ํ–ฅ**์„ ์ค€๋‹ค. +- `.claude/skills`: ๋ณธ ์ฒดํฌ์•„์›ƒ์˜ ๊ฒƒ์€ git ์— ์ถ”์ ๋˜์ง€ ์•Š๋Š” ๋กœ์ปฌ ์‹ฌ๋งํฌ(`../../.agents/skills/*`, 10๊ฐœ)๋ผ ์ž๋™ ๋™๊ธฐํ™”๋œ๋‹ค. ๊ฒ€์ฆ ๊ฒฝ๋กœ(์›ŒํฌํŠธ๋ฆฌ)์—๋Š” ์กด์žฌํ•˜์ง€ ์•Š๋Š”๋‹ค โ€” ๋ฐฐํฌ ์‚ฌ๋ณธ ์ •ํ•ฉ ๋ฌธ์ œ ์—†์Œ. + +## 6. ํ…Œ์ŠคํŠธ (ํ•ญ๋ชฉ 6) + +### 6-1. ๊ฒ€์ฆ ๊ฒฝ๋กœ์—์„œ์˜ ์‹คํ–‰ + +``` +cd <๊ฒ€์ฆ ๊ฒฝ๋กœ> && AGENT_PYTHON_BIN=<์ œ์–ด ๋ฃจํŠธ .venv/bin/python> PYTHONDONTWRITEBYTECODE=1 <๊ฐ™์€ ์ธํ„ฐํ”„๋ฆฌํ„ฐ> -m pytest \ + -p no:cacheprovider -q -rfEs --basetemp=<๊ฒ€์ฆ ๊ฒฝ๋กœ ๋ฐ–> \ + tests/test_issue23_24_startup_path.py tests/test_issue24_resume_dialogs.py tests/test_b19_headless_reconcile_fixes.py \ + tests/test_c1_tui_readiness.py tests/test_issue14_delivery_ack.py tests/test_issue18_liveness_contract.py \ + tests/test_issue5_phase2a_loop.py tests/test_herdr_shim_contract.py tests/test_loop_cli.py +โ†’ 273 passed in 700.58s (0:11:40) failed 0 ยท errors 0 ยท skipped 0 +``` + +์ƒˆ ํŒŒ์ผ 2๊ฐœ(73๊ฑด)์™€ ์กฐ์ •๋œ 6ํŒŒ์ผ, ๊ทธ๋ฆฌ๊ณ  ์…ธ shim ๊ณ„์•ฝยทloop CLI ๊นŒ์ง€ ๋ชจ๋‘ ํ†ต๊ณผ. ๋ธŒ๋กœ์ปค ์˜์กด ์‹œํ—˜(issue14ยทissue18)๋„ ์ด๋ฒˆ ์‹คํ–‰์—์„œ๋Š” flake ์—†์ด ํ†ต๊ณผํ–ˆ๋‹ค. + +### 6-2. ์ˆ˜์ • ์—†์ด๋Š” ์‹คํŒจํ•˜๋Š”๊ฐ€ โ€” ํ™•์ธํ•จ + +๊ฒ€์ฆ ๊ฒฝ๋กœ ์‚ฌ๋ณธ์—์„œ PR ์ด ๋ฐ”๊พผ ์†Œ์Šค 12ํŒŒ์ผ(lib.shยท์–ด๋Œ‘ํ„ฐ 5ยท`__main__.py`ยท`job_subscriber.py`ยท`watcher.py`ยทcreate/resume ์Šคํฌ๋ฆฝํŠธยท๋ž˜ํผ)๋งŒ base `1e78dae` ํŒ์œผ๋กœ ๋˜๋Œ๋ฆฌ๊ณ  ์ƒˆ ํ…Œ์ŠคํŠธ 2ํŒŒ์ผ์„ ๋Œ๋ ธ๋‹ค: + +``` +60 failed, 12 passed, 1 skipped in 275.50s +``` + +์‹คํŒจ 60๊ฑด์€ S1(๊ต์ฐจ ์„œ๋ฒ„)ยทS4~S6(๋‹ค์ด์–ผ๋กœ๊ทธ)ยทS7/W(started ์ฆ๊ฑฐ)ยทS8(rc 4)ยทS9/S10(๋กค๋ฐฑ)ยทR1~R4(resume) ์ „ ์˜์—ญ์— ๊ฑธ์นœ๋‹ค. ํ†ต๊ณผํ•œ 12๊ฑด์€ "๊ฐ™์€ ์„œ๋ฒ„๋Š” ์Šค์ฝ”ํ”„ ์œ ์ง€"(S2/S2b)ยท"shim ์€ ๋ฐ”๋€Œ์ง€ ์•Š์Œ"(S11)ยท"ํ˜ธ์ถœ์ž๊ฐ€ ์ค€ ํ† ํฐ์€ ๋ฎ์–ด์“ฐ์ง€ ์•Š์Œ"(S5e) ๊ฐ™์€ **๋ถˆ๋ณ€ ์กฐ๊ฑด**๋“ค์ด๋ผ ์ˆ˜์ •๊ณผ ๋ฌด๊ด€ํ•œ ๊ฒƒ์ด ๋งž๋‹ค. ์ฆ‰ ์ƒˆ ํ…Œ์ŠคํŠธ๋Š” ์ด ๋ณ€๊ฒฝ์„ ์ •ํ™•ํžˆ ๊ฒจ๋ƒฅํ•œ๋‹ค. + +### 6-3. ๊ฒฉ๋ฆฌยทํ”ฝ์Šค์ฒ˜ยท์‹œ๊ฐ„ + +| ํ•ญ๋ชฉ | ๊ฒฐ๊ณผ | +|---|---| +| ์‹ค์ œ `.mam/jobs` / `.mam/delegate_job_logs` ์ „ํ›„ | **์ฐจ์ด 0 / ์ฐจ์ด 0** | +| ์‹ค์ œ herdr ์„œ๋ฒ„ยทํ™ˆ | `mam_sandbox` ๊ฐ€ `HOME`/`HOME_DIR` ๋ฅผ `tmp_path` ๋กœ ๋ฐ”๊พธ๊ณ (`conftest.py:210-211`) herdr ๋Š” mock. ์ƒˆ ํŒŒ์ผ์—์„œ `hivemq`ยท์‹ค์ œ ์†Œ์ผ“ ์ฐธ์กฐ 0๊ฑด. ์ด pane ์˜ herdr ํ™˜๊ฒฝ๋ณ€์ˆ˜๋Š” S1d ๊ฐ€ **์ฝ๊ธฐ๋งŒ** ํ•œ๋‹ค | +| ์‹ค์ œ ์ƒ์‚ฐ์ž ์‚ฌ์šฉ | S7i~S7nยทW ๊ณ„์—ด์€ `REGISTRY_PY`/`PUBLISH_PY`(์‹ค์ œ `registry.py`ยท`publish_event.py`)๋กœ ์žก์„ ๋“ฑ๋กํ•˜๊ณ  `started` ๋ฅผ ๋ฐœํ–‰ํ•œ๋‹ค(`test_issue23_24_startup_path.py:620-660`). "์†์œผ๋กœ ๋งŒ๋“  ํ”ฝ์Šค์ฒ˜๊ฐ€ ๊ฒฐํ•จ์„ ๊ฐ€๋ ธ๋‹ค"๋Š” PR ์˜ ๊ตํ›ˆ์ด ํ…Œ์ŠคํŠธ์— ๋ฐ˜์˜๋ผ ์žˆ๋‹ค | +| ๊ฒ€์ฆ ๊ฒฝ๋กœ | tracked ํŒŒ์ผ ๋ณ€ํ™” ์—†์Œ(HEAD `f4ba205` ์œ ์ง€). ๋‚ด ์‹คํ–‰์ด ๋ฌด์‹œ๋˜๋Š” `.mam/` ์„ ๋งŒ๋“ค์—ˆ๋‹ค(#32 ๋ฆฌ๋ทฐ ๋•Œ์™€ ๊ฐ™์€ ํ˜„์ƒ, ๊ทธ๋Œ€๋กœ ๋‘ ) | +| ์‹คํ–‰ ์‹œ๊ฐ„ | ์ƒˆ ํŒŒ์ผ 2๊ฐœ๋Š” ๋Œ€์ฒด๋กœ ๋น ๋ฅด์ง€๋งŒ **`test_issue24_resume_dialogs.py` 4๊ฑด์ด ๊ฐ 39~43 s** ๋‹ค(resume ์˜ `handle_startup_dialogs โ€ฆ 20` ํƒ€์ž„์•„์›ƒ๊ณผ mock ์ค€๋น„ ๋Œ€๊ธฐ๊ฐ€ ๊ฒน์นœ๋‹ค). ์Šค์œ„ํŠธ ์ „์ฒด ์‹œ๊ฐ„์— +2.7 ๋ถ„์„ ๋”ํ•œ๋‹ค(F6) | + +## 8. ๋ณ‘ํ•ฉ ์ฐจ๋‹จ ์—ฌ๋ถ€ (ํ•ญ๋ชฉ 8) + +๋ฒ„์ „ ๋ฝ์Šคํ… bump ๋ถ€์žฌ(`lib.sh:32` `4.7.1`, ์˜ค์ผ€์ŠคํŠธ๋ ˆ์ดํ„ฐ๊ฐ€ 4.7.2 ๋กœ ์ฒ˜๋ฆฌ ์˜ˆ์ •) ์™ธ์— **๋ณ‘ํ•ฉ์„ ๋ง‰์„ ๊ฒฐํ•จ์€ ์—†๋‹ค**. + +| # | ๋น„์ฐจ๋‹จ ์ง€์  | ๊ทผ๊ฑฐ / ๊ถŒ๊ณ  | +|---|---|---| +| F1 | **codex ์ˆ˜๋ฝ ํ‚ค๊ฐ€ ๋ธŒ๋ฆฌํ”„ ์„œ์ˆ ๊ณผ ๋‹ค๋ฅด๋‹ค.** ์ฝ”๋“œ: ์‹ ๋ขฐ ์ฐฝ `Enter`(1๋ฒˆ ๋ฏธ๋ฆฌ ์„ ํƒ), ์—…๋ฐ์ดํŠธ ์•ˆ๋‚ด **`2`**(Skip, ์ด๋ฒˆ๋งŒ). ๋ธŒ๋ฆฌํ”„: `1`+Enter, `3`+Enter(Skip until next version). S4 ์˜ codex ํ™”๋ฉด ํ”ฝ์Šค์ฒ˜(`Update available / 1. Update now / 2. Skip / 3. Skip until next version`)๋ฅผ ๋ณด๋ฉด 3๊ฐœ ํ•ญ๋ชฉ์ด ์‹ค์ œ๋กœ ์žˆ๊ณ , ์ฝ”๋“œ๋Š” ๊ทธ์ค‘ **2** ๋ฅผ ๊ณ ๋ฅธ๋‹ค โ€” "์•„๋ฌด๊ฒƒ๋„ ๋ฐ”๊พธ์ง€ ์•Š๋Š”" ์„ ํƒ์ด๋ผ๋Š” ์ฃผ์„๊ณผ ์ผ์น˜ํ•˜๋‚˜ ๋‹ค์Œ ์‹œ์ž‘ ๋•Œ ๋‹ค์‹œ ๋œฌ๋‹ค. ์‹ค์ œ ์‹œํ—˜์€ PR ๋ณธ๋ฌธ๋Œ€๋กœ ํ†ต๊ณผํ–ˆ์œผ๋ฏ€๋กœ ๋™์ž‘์€ ์„ฑ๋ฆฝ | ๋ณ‘ํ•ฉ ์ „ ๋ธŒ๋ฆฌํ”„ ์ž‘์„ฑ์ž์™€ ํ•œ ๋ฒˆ ๋งž์ถœ ๊ฒƒ; `3` ์ด ์˜๋„๋ผ๋ฉด ์–ด๋Œ‘ํ„ฐ ํ•œ ์ค„ | +| F2 | ๊ฐ์‚ฌ ๊ธฐ๋ก์˜ ์ƒˆ ํ•„๋“œ(`dispatch_verified`ยท`dispatch_fingerprint`ยท`attempt_id`)๊ฐ€ MESSAGING.mdยทdelegate-job SKILL.md ์— ์—†๋‹ค | ํ•œ ๋ฌธ๋‹จ ์ถ”๊ฐ€ ๊ถŒ๊ณ  | +| F3 | `MAM_INJECT_ACK_GRACE_S` ๊ฐ€ create ์˜ ๋Œ€๊ธฐ์—๋„ 2์ˆœ์œ„๋กœ ์˜ํ–ฅ์„ ์ค€๋‹ค๋Š” ์ ์ด SKILL.md ์— ์—†๋‹ค(`create_session.sh:658`) | ๋ฌธ๊ตฌ ๋ณด๊ฐ• | +| F4 | acceptance5 ๊ฐ€ "retained `completed` ๋งŒ ๋ฐฐ๋‹ฌ" ๋œ ๊ฒฝ์šฐ๋ฅผ ์ด์ œ ์‹คํŒจ๋กœ ๋ณธ๋‹ค(๊ณ„์•ฝ์ƒ ์˜ณ์Œ). ๊ณต๊ฐœ ๋ธŒ๋กœ์ปค์—์„œ flake ๊ฐ€ ๋Š˜ ์ˆ˜ ์žˆ๋Š” ์ž๋ฆฌ | ๊ด€์ฐฐ๋˜๋ฉด skip ์กฐ๊ฑด ์žฌ๊ฒ€ํ†  | +| F5 | ๋‹ค์ด์–ผ๋กœ๊ทธ ์‹œ๊ทธ๋‹ˆ์ฒ˜๋Š” ํ™”๋ฉด์˜ ๋””๋ ‰ํ„ฐ๋ฆฌ ๊ฒฝ๋กœ๋ฅผ ๊ฒ€์‚ฌํ•˜์ง€ ์•Š๋Š”๋‹ค โ€” pane ์ด `-c $WORKSPACE` ๋กœ ๋œจ๋ฏ€๋กœ ์‹ค๋ฌด ์œ„ํ—˜์€ ๋‚ฎ๋‹ค | ์ •๋ณด | +| F6 | `test_issue24_resume_dialogs.py` 4๊ฑด์ด ๊ฐ ~40 s | ํƒ€์ž„์•„์›ƒ์„ env ๋กœ ์ค„์ผ ์ˆ˜ ์žˆ์œผ๋ฉด ๊ฐœ์„  | + +## 9. ๊ต์ฐจ๊ฒ€์ฆ ๊ฐ€์ด๋“œ + +```bash +RP=/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20260923T142448Z-609954/1 +PY=/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.venv/bin/python +git -C "$RP" rev-parse HEAD 'HEAD^{tree}' # f4ba205โ€ฆ / 88cb4f3โ€ฆ +# ๊ต์ฐจ ์„œ๋ฒ„ ๊ทœ์น™ (ํ•จ์ˆ˜๋งŒ ์ถ”์ถœ, env -i) +eval "$(sed -n '/^_herdr_caller_server() {/,/^}/p; /^_herdr_call_is_cross_server() {/,/^}/p' "$RP/.agents/skills/lib.sh")" +env -i HERDR_SOCKET_PATH=/x/.config/herdr/herdr.sock HERDR_SESSION_NAME=mam bash -c "$(declare -f _herdr_caller_server _herdr_call_is_cross_server); _herdr_call_is_cross_server && echo CROSS" +env -i HERDR_SOCKET_PATH=/x/.config/herdr/sessions/mam/herdr.sock HERDR_SESSION_NAME=mam bash -c "$(declare -f _herdr_caller_server _herdr_call_is_cross_server); _herdr_call_is_cross_server || echo same" +# ์ฆ๊ฑฐ ๊ทœ์น™: ์ด์ „ ํ† ํฐ์˜ started / fingerprint ์—†๋Š” ๊ฐ์‚ฌ ๊ธฐ๋ก์€ ํ† ํฐ ์š”๊ตฌ ์‹œ ๊ฑฐ๋ถ€ (ยง3-2 ์˜ 10 ์ผ€์ด์Šค) +# ์ˆ˜์ • ์—†์ด๋Š” ์‹คํŒจํ•˜๋Š”์ง€ (์‚ฌ๋ณธ์—์„œ๋งŒ): ์†Œ์Šค 12ํŒŒ์ผ์„ 1e78dae ํŒ์œผ๋กœ ๊ต์ฒด ํ›„ ์ƒˆ ํ…Œ์ŠคํŠธ 2ํŒŒ์ผ โ†’ 60 failed / 12 passed +``` + +[VERDICT: PASS] diff --git a/.agents/reports/planner-reviewer-claude-01/report-df626811.identity.json b/.agents/reports/planner-reviewer-claude-01/report-df626811.identity.json new file mode 100644 index 0000000..7bfad4b --- /dev/null +++ b/.agents/reports/planner-reviewer-claude-01/report-df626811.identity.json @@ -0,0 +1,14 @@ +{ + "loop_id": "loop-20260923T220355Z-76dd1d", + "base_sha": "f4ba205c9f755d817e2206c7a9cb390a3e61fd2f", + "head_sha": "05b0ea4762de3fad013e8986d45db21c4e6aa465", + "tree_sha": "8f5e8b0402017d2cc809364e60400c4c25b922f3", + "review_commit": "05b0ea4762de3fad013e8986d45db21c4e6aa465", + "reviewer": "planner-reviewer-claude-01", + "job_id": "df626811", + "verdict": "PASS", + "transport": "herdr", + "evidence": "review-tree", + "isolation": "worktree", + "verified_at": "2026-09-23T23:35:19Z" +} \ No newline at end of file diff --git a/.agents/reports/planner-reviewer-claude-01/report-df626811.md b/.agents/reports/planner-reviewer-claude-01/report-df626811.md new file mode 100644 index 0000000..dba4693 --- /dev/null +++ b/.agents/reports/planner-reviewer-claude-01/report-df626811.md @@ -0,0 +1,99 @@ +# ๐Ÿ” Review Report โ€” PR #35 ๋ธํƒ€ ๋ฆฌ๋ทฐ 2ํšŒ์ฐจ (job `df626811`) + +- **Reviewer**: `planner-reviewer-claude-01` (role: planner,reviewer) ยท 2026-09-24 +- **๋Œ€์ƒ ๋ธํƒ€**: `f4ba205..05b0ea4` (1์ปค๋ฐ‹ `05b0ea4`, 6ํŒŒ์ผ `+76/โˆ’5`, `git diff --check` ํด๋ฆฐ), base `main 1e78dae` (v4.7.1). +- **๊ฒ€์ฆ ๊ฒฝ๋กœ**: `/var/folders/.../T/mam-review/loop-20260923T220355Z-76dd1d/1`. **identity ํ™•์ธ**: `HEAD = 05b0ea47โ€ฆa465`, `HEAD^{tree} = 8f5e8b04โ€ฆ22f3` โ€” ๋ธŒ๋ฆฌํ”„์™€ ์ผ์น˜. +- **1ํšŒ์ฐจ ๋‚ด ํŒ์ •**: PASS (job `adc0ff3c`). ์ด๋ฒˆ์—๋Š” codex ํƒ์นจ 2๊ฑด์˜ ์ •์ •์ด ์‹ค์ œ๋กœ ๋‹ซํ˜”๋Š”์ง€์™€ ๋ถ€์ž‘์šฉ์„ ๋ณธ๋‹ค. +- **์•ˆ์ „ ๊ทœ์น™ ์ค€์ˆ˜**: ๊ฒ€์ฆ ๊ฒฝ๋กœยท์†Œ์Šค ์›ŒํฌํŠธ๋ฆฌยท๋ณธ ์ฒดํฌ์•„์›ƒ(`main 1e78dae`) ๋ชจ๋‘ ์ˆ˜์ •ยท์ปค๋ฐ‹ยทcheckout ์—†์Œ. ํ•จ์ˆ˜ ์ถ”์ถœ ์‹คํ—˜๊ณผ ๋˜๋Œ๋ฆฐ ์‚ฌ๋ณธ ์‹คํ–‰์€ scratchpad ์—์„œ๋งŒ, pytest ๋Š” `AGENT_PYTHON_BIN` + `--basetemp=<๊ฒ€์ฆ ๊ฒฝ๋กœ ๋ฐ–>`. ์‹ค์ œ herdrยท์„ธ์…˜ยท`.mam`ยท๋ฐ๋ชฌ ์กฐ์ž‘ ์—†์Œ, pushยทmergeยทPR ์ƒํƒœ ๋ณ€๊ฒฝ ์—†์Œ. + +## ๊ฒฐ๋ก  ์š”์•ฝ + +**๋‘ ์ •์ •์€ ํƒ์นจ์„ ๋‹ซ๊ณ , 1ํšŒ์ฐจ ํŒ์ •์„ ๋’ค์ง‘์„ ์š”์†Œ๋Š” ์—†๋‹ค.** P2 ์˜ `continue` ๋Š” S5/S5b ์˜ ์ฟจ๋‹ค์šด ๊ณ„์•ฝ์„ ๊ทธ๋Œ€๋กœ ์œ ์ง€ํ•˜๋ฉด์„œ ๊ฐ™์€ ํ™”๋ฉด์˜ ๋‹ค์Œ ๋‹ค์ด์–ผ๋กœ๊ทธ๋ฅผ ๋ณด๊ฒŒ ํ•˜๊ณ , P1 ์˜ `AUTO_TRUST="${MAM_AUTO_TRUST:-1}"` ๋Š” "ํ”Œ๋ž˜๊ทธ > ํ™˜๊ฒฝ > ๊ธฐ๋ณธ 1" ์˜ ์šฐ์„ ์ˆœ์œ„๋ฅผ ๋ฌธ์„œ๋Œ€๋กœ ๋งŒ๋“ ๋‹ค. ๋น„์ฐจ๋‹จ ๊ด€์ฐฐ 2๊ฑด(G1ยทG2). + +## 1. P2 โ€” `_mam_dialog_pick` ์˜ `continue` (ํ•ญ๋ชฉ 1) + +`lib.sh:2954-2958`. ํ•จ์ˆ˜๋งŒ ์ถ”์ถœํ•ด codex ์–ด๋Œ‘ํ„ฐ์˜ ๋‹ค์ด์–ผ๋กœ๊ทธ 2๊ฐœ(์‹ ๋ขฐ ์ฐฝ idx0 โ†’ `Enter`, ์—…๋ฐ์ดํŠธ ์•ˆ๋‚ด idx1 โ†’ `2`)์™€ **๋‘ ์ฐฝ์ด ํ•จ๊ป˜ ์žˆ๋Š” ํ™”๋ฉด**์œผ๋กœ 7๊ฐ€์ง€ ์ƒํƒœ๋ฅผ ๋Œ๋ ธ๋‹ค(cooldown 5 s): + +| # | ์ƒํƒœ | ๊ฒฐ๊ณผ | ์˜๋ฏธ | +|---|---|---|---| +| 1 | ๊ธฐ๋ก ์—†์Œ, ์ž๋™ ์‹ ๋ขฐ on | `send\|0\|Enter` | ์‹ ๋ขฐ ์ฐฝ์ด ๋จผ์ €(์–ด๋Œ‘ํ„ฐ ์ˆœ์„œ) | +| 2 | ์‹ ๋ขฐ ์ฐฝ์„ 1 s ์ „์— ๋ณด๋ƒ„(์ฟจ๋‹ค์šด ์ค‘), on | **`send\|1\|2`** | **P2 ์ •์ •**: ์ฟจ๋‹ค์šด ์ค‘์ธ ์ฐฝ์„ ๊ฑด๋„ˆ๋›ฐ๊ณ  ์•ˆ๋‚ด์— ๋‹ตํ•œ๋‹ค. ์˜› ์ฝ”๋“œ๋Š” ์—ฌ๊ธฐ์„œ `exit 1` ๋กœ ์•„๋ฌด๊ฒƒ๋„ ์•ˆ ํ–ˆ๋‹ค | +| 3 | ๊ธฐ๋ก ์—†์Œ, ์ž๋™ ์‹ ๋ขฐ off | `skip\|0\|` | ์‹ ๋ขฐ ์ฐฝ์€ ์‚ฌ๋žŒ ๋ชซ, skip ๊ธฐ๋ก | +| 4 | skip 1 s ๋’ค, off | **`send\|1\|2`** | **P2 ์ •์ •**: skip ์˜ ์ฟจ๋‹ค์šด์ด ์•ˆ๋‚ด๋ฅผ ๊ฐ€๋ฆฌ์ง€ ์•Š๋Š”๋‹ค | +| 5 | ๋‘ ์ฐฝ ๋ชจ๋‘ ์ฟจ๋‹ค์šด ์ค‘ | ์—†์Œ(rc 1) | **S5 ์œ ์ง€**: ์ฟจ๋‹ค์šด ์•ˆ์—์„œ๋Š” ์žฌ์ „์†ก ์—†์Œ | +| 6 | ๋‘ ์ฐฝ ๋ชจ๋‘ ์ฟจ๋‹ค์šด ๋งŒ๋ฃŒ(+5 s) | `send\|0\|Enter` | **S5b ์œ ์ง€**: ๋งŒ๋ฃŒ ํ›„ 1ํšŒ ์žฌ์‹œ๋„, ๋‹ค์‹œ ์‹ ๋ขฐ ์ฐฝ๋ถ€ํ„ฐ | +| 7 | off, ๋งŒ๋ฃŒ | `skip\|0\|` | ๊ฒฝ๊ณ ๊ฐ€ ์ฟจ๋‹ค์šด๋งˆ๋‹ค 1ํšŒ | + +**ํ‚ค ์ˆœ์„œยทํšŸ์ˆ˜**: ํ•ธ๋“ค๋Ÿฌ๋Š” ํ•œ ํด๋ง์— ํ•˜๋‚˜๋งŒ ๊ณ ๋ฅด๋ฏ€๋กœ(`_mam_dialog_pick` ์€ ์ฒซ actionable ํ•ญ๋ชฉ์—์„œ ์ข…๋ฃŒ), ๋‘ ์ฐฝ์ด ํ•œ ํ™”๋ฉด์— ์žˆ์œผ๋ฉด **1ํšŒ์ฐจ ํด๋ง์— `Enter`, ๋‹ค์Œ ํด๋ง(1 s ๋’ค)์— `2`** ์ˆœ์œผ๋กœ ๊ฐ 1ํšŒ ๊ฐ„๋‹ค. S5f ๊ฐ€ `["Enter", "2"]` ๋ฅผ, S6c ๊ฐ€ `["2"]` + ๊ฒฝ๊ณ ๋ฅผ ๊ทธ๋Œ€๋กœ ๊ณ ์ •ํ•œ๋‹ค. ์ฟจ๋‹ค์šด์€ **์ธ๋ฑ์Šค๋ณ„**(`state[idx]`)์ด๋ผ ์„œ๋กœ ๊ฐ„์„ญํ•˜์ง€ ์•Š๋Š”๋‹ค. + +## 2. P1 โ€” `AUTO_TRUST="${MAM_AUTO_TRUST:-1}"` (ํ•ญ๋ชฉ 2) + +`create_session.sh:72,87-88,96` ๊ณผ `resume_session.sh:33,42-43,57` ์ด ๊ฐ™์€ 3๋‹จ ๊ตฌ์กฐ(์ดˆ๊ธฐ๊ฐ’ โ† ํ™˜๊ฒฝ, ํ”Œ๋ž˜๊ทธ arm ์ด ๋ฎ์–ด์”€, ๋งˆ์ง€๋ง‰์— `export`)๋‹ค. ์„ธ ์ค„์„ ๊ทธ๋Œ€๋กœ ์˜ฎ๊ธด ํ•˜๋„ค์Šค๋กœ 8๊ฐ€์ง€๋ฅผ ๋Œ๋ ธ๋‹ค: + +| ํ™˜๊ฒฝ `MAM_AUTO_TRUST` | ํ”Œ๋ž˜๊ทธ | ํ•ธ๋“ค๋Ÿฌ๊ฐ€ ๋ฐ›๋Š” ๊ฐ’ | ํŒ์ • | +|---|---|---|---| +| ์—†์Œ | โ€“ | `1` (on) | ๊ธฐ๋ณธ | +| `0` | โ€“ | `0` (**off**) | **P1 ์ •์ •**: ๋ฌธ์„œ์˜ ํ™˜๊ฒฝ๋ณ€์ˆ˜ ํ˜•์‹์ด ์ด์ œ ๋‹ฟ๋Š”๋‹ค(์˜› ์ฝ”๋“œ๋Š” `1` ๋กœ ๋ฎ์–ด์ผ๋‹ค) | +| `0` | `--auto-trust` | `1` (on) | ํ”Œ๋ž˜๊ทธ > ํ™˜๊ฒฝ | +| `1` | `--no-auto-trust` | `0` (off) | ํ”Œ๋ž˜๊ทธ > ํ™˜๊ฒฝ | +| ์—†์Œ | `--no-auto-trust` | `0` (off) | | +| `yes` / `false` | โ€“ | ๊ทธ๋Œ€๋กœ ์ „๋‹ฌ โ†’ ํ•ธ๋“ค๋Ÿฌ `!= "0"` ๊ทœ์น™์œผ๋กœ **on** | `_mam_dialog_pick` ๋„ ๊ฐ™์€ ๊ทœ์น™(`MAM_DLG_TRUST != "0"`)์ด๋ผ ๋‘ ์ธต์ด ์ผ์น˜ํ•œ๋‹ค | +| `''`(๋นˆ ๋ฌธ์ž์—ด) | โ€“ | `:-1` ์ด ๋นˆ ๊ฐ’์„ ๋ฏธ์„ค์ •์œผ๋กœ ๋ด `1` (on) | ํ•ธ๋“ค๋Ÿฌ์˜ `${MAM_AUTO_TRUST:-1}` ์™€ ๊ฐ™์€ ํ•ด์„ | + +์ฆ‰ "0 ๋งŒ off, ๋‚˜๋จธ์ง€๋Š” ์ „๋ถ€ on" ์ด ์Šคํฌ๋ฆฝํŠธยทํ•ธ๋“ค๋Ÿฌยทpicker ์„ธ ์ธต์—์„œ ๋™์ผํ•˜๋‹ค(`lib.sh:2986`, picker ์˜ `!= "0"`). create SKILL.md `:119-120` ์ด ํ™˜๊ฒฝ๋ณ€์ˆ˜ ํ˜•์‹๊ณผ "ํ”Œ๋ž˜๊ทธ ์šฐ์„ "์„ ๋ช…์‹œํ•œ๋‹ค. **G1 (๋น„์ฐจ๋‹จ)**: `false`/`no` ๊ฐ€ on ์œผ๋กœ ํ•ด์„๋˜๋Š” ๊ฒƒ์€ ์ผ๊ด€๋˜์ง€๋งŒ ์ง๊ด€์— ๋ฐ˜ํ•œ๋‹ค โ€” ๋ฌธ์„œ์— "`0` ๋งŒ ๋ˆ๋‹ค" ๋ฅผ ํ•œ ๋งˆ๋”” ์ ์–ด ๋‘๋ฉด ์ข‹๋‹ค. resume SKILL.md ๋Š” `:107` ์—์„œ `MAM_AUTO_TRUST=0` ํ˜•์‹์€ ์ ์ง€๋งŒ "ํ”Œ๋ž˜๊ทธ๊ฐ€ ํ™˜๊ฒฝ๋ณด๋‹ค ์šฐ์„ " ์€ create ์ชฝ์—๋งŒ ์žˆ๋‹ค(G2, ์‚ฌ์†Œ). + +## 3. ํ…Œ์ŠคํŠธ (ํ•ญ๋ชฉ 1ยท3) + +### 3-1. ์ˆ˜์ • ์—†์ด๋Š” ์‹คํŒจํ•˜๋Š”๊ฐ€ โ€” ํ™•์ธํ•จ + +๊ฒ€์ฆ ๊ฒฝ๋กœ ์‚ฌ๋ณธ์—์„œ `lib.sh`ยท`create_session.sh`ยท`resume_session.sh` ์„ธ ํŒŒ์ผ๋งŒ `f4ba205` ํŒ์œผ๋กœ ๋˜๋Œ๋ฆฌ๊ณ  ์ƒˆ ํšŒ๊ท€์™€ ์ด์›ƒ ์‹œํ—˜์„ `-k "s6c or s5f or r3b or c3 or test_s5_ or s5b or test_s6_"` ๋กœ ๋Œ๋ ธ๋‹ค: + +``` +4 failed, 7 passed, 70 deselected in 153.65s +FAILED test_s6c_a_skipped_trust_dialog_does_not_hide_the_notice_below_it โ† P2 +FAILED test_s5f_a_dialog_inside_its_cooldown_does_not_block_the_next_one โ† P2 +FAILED test_r3b_โ€ฆ_on_resume[0-flag0-0] (ํ™˜๊ฒฝ๋ณ€์ˆ˜๋งŒ, ํ”Œ๋ž˜๊ทธ ์—†์Œ) โ† P1 +FAILED test_c3_โ€ฆ_on_create[0-flag0-0] (ํ™˜๊ฒฝ๋ณ€์ˆ˜๋งŒ, ํ”Œ๋ž˜๊ทธ ์—†์Œ) โ† P1 +``` + +์‹คํŒจ 4๊ฑด์ด ์ •ํ™•ํžˆ ๋‘ ํƒ์นจ์˜ ์žฌํ˜„ ์‚ฌ๋ก€๋‹ค. ํ†ต๊ณผํ•œ 7๊ฑด โ€” R3b/C3 ์˜ "ํ”Œ๋ž˜๊ทธ๊ฐ€ ํ™˜๊ฒฝ์„ ์ด๊ธด๋‹ค" 4์‚ฌ๋ก€(ํ”Œ๋ž˜๊ทธ๋Š” ์˜› ์ฝ”๋“œ์—์„œ๋„ ๋™์ž‘ํ–ˆ๋‹ค), S5ยทS5b(์ฟจ๋‹ค์šด ๊ณ„์•ฝ), S6(์‹ ๋ขฐ ์ฐฝ skip + ์•ˆ๋‚ด ์‘๋‹ต, ํ•œ ํ™”๋ฉด์— ํ•˜๋‚˜์”ฉ) โ€” ์€ ์ •์ • ์ „ํ›„๋กœ **๋ณ€ํ•˜์ง€ ์•Š์•„์•ผ ํ•˜๋Š”** ๊ณ„์•ฝ์ด๊ณ , ์‹ค์ œ๋กœ ๋ณ€ํ•˜์ง€ ์•Š์•˜๋‹ค. ์ฆ‰ `continue` ๊ฐ€ S5/S5b ๋ฅผ ๊นจ์ง€ ์•Š์•˜๋‹ค๋Š” ๊ฒƒ์ด ๋˜๋Œ๋ฆฐ ์‚ฌ๋ณธ๊ณผ PR ํŠธ๋ฆฌ ์–‘์ชฝ์—์„œ ์„ฑ๋ฆฝํ•œ๋‹ค(PR ํŠธ๋ฆฌ ์ชฝ์€ ยง3-2). + +### 3-2. PR ํŠธ๋ฆฌ(๊ฒ€์ฆ ๊ฒฝ๋กœ)์—์„œ์˜ ์‹คํ–‰ + +``` +tests/test_issue23_24_startup_path.py tests/test_issue24_resume_dialogs.py tests/test_c1_tui_readiness.py tests/test_b19_headless_reconcile_fixes.py +โ†’ 120 passed in 535.40s (0:08:55) failed 0 ยท skipped 0 +๊ฐ™์€ -k ๋ถ€๋ถ„์ง‘ํ•ฉ(์ƒˆ ํšŒ๊ท€ 4 + ์ด์›ƒ 7) ๋‹จ๋… โ†’ 11 passed in 148.21s +``` + +- ๋‹ค์ด์–ผ๋กœ๊ทธ ํ•ธ๋“ค๋Ÿฌ๋ฅผ ๊ฑด๋“œ๋ฆฌ๋Š” 4ํŒŒ์ผ ์ „๋ถ€ ํ†ต๊ณผ. `c1`ยท`b19` ์˜ "ํ”„๋ ˆ์ž„๋‹น 1ํšŒ"ยท`Yes, try it` ๊ฐ€๋“œ๋„ ์œ ์ง€๋œ๋‹ค. +- ์‹ค์ œ `.mam/jobs` ์ „ํ›„ **์ฐจ์ด 0**. ๊ฒ€์ฆ ๊ฒฝ๋กœ tracked ํŒŒ์ผ ๋ณ€ํ™” ์—†์Œ(HEAD `05b0ea4`), ๋ณธ ์ฒดํฌ์•„์›ƒ `main 1e78dae`. +- ์‹œ๊ฐ„: ์ด ํ˜ธ์ŠคํŠธ์—์„œ ๋‹ค๋ฅธ ์Šค์œ„ํŠธ 2๊ฐœ๊ฐ€ ๋™์‹œ์— ๋Œ๊ณ  ์žˆ์–ด resume ์‹œํ—˜ 6๊ฑด์ด ๊ฐ 37~41 s ์˜€๋‹ค(1ํšŒ์ฐจ F6 ๊ทธ๋Œ€๋กœ; R3b 3์‚ฌ๋ก€๊ฐ€ ๋”ํ•ด์ ธ +2 ๋ถ„). + +## 4. ๋ณ‘ํ•ฉ ์ฐจ๋‹จ ์—ฌ๋ถ€ (ํ•ญ๋ชฉ 3) + +1ํšŒ์ฐจ PASS ๋ฅผ ๋’ค์ง‘์„ ์š”์†Œ๋Š” ์—†๋‹ค. ๋ธํƒ€๋Š” ์„ธ ์ค„์˜ ์†Œ์Šค ๋ณ€๊ฒฝ(`lib.sh:2958` `continue`, `create_session.sh:72`ยท`resume_session.sh:33` ์˜ ํ™˜๊ฒฝ ๊ธฐ๋ณธ๊ฐ’)๊ณผ ๋ฌธ์„œ ํ•œ ์ค„, ํšŒ๊ท€ 4๊ฑด์ด๋ฉฐ, ๋‘ ํƒ์นจ์˜ ์žฌํ˜„ ์‚ฌ๋ก€๋งŒ ์ •ํ™•ํžˆ ์‹คํŒจโ†’ํ†ต๊ณผ๋กœ ๋ฐ”๋€Œ์—ˆ๋‹ค. ๋ฒ„์ „ ๋ฝ์Šคํ… bump ๋ถ€์žฌ(`4.7.1`, ์˜ค์ผ€์ŠคํŠธ๋ ˆ์ดํ„ฐ๊ฐ€ 4.7.2 ๋กœ ์ฒ˜๋ฆฌ ์˜ˆ์ •) ์™ธ์— **๋ณ‘ํ•ฉ์„ ๋ง‰์„ ๊ฒฐํ•จ์€ ์—†๋‹ค**. + +| # | ๋น„์ฐจ๋‹จ ๊ด€์ฐฐ | ๊ทผ๊ฑฐ | +|---|---|---| +| G1 | `MAM_AUTO_TRUST` ๋Š” `0` ๋งŒ ๋ˆ๋‹ค(`false`/`no` ๋Š” on). ์„ธ ์ธต(์Šคํฌ๋ฆฝํŠธยทํ•ธ๋“ค๋Ÿฌยทpicker)์ด ์ผ์น˜ํ•˜๋ฏ€๋กœ ๊ฒฐํ•จ์€ ์•„๋‹ˆ์ง€๋งŒ ๋ฌธ์„œ์— ํ•œ ๋งˆ๋”” ์žˆ์œผ๋ฉด ์ข‹๋‹ค | `create_session.sh:72`, `lib.sh:2986`, `_mam_dialog_pick` ์˜ `!= "0"` | +| G2 | "ํ”Œ๋ž˜๊ทธ๊ฐ€ ํ™˜๊ฒฝ๋ณด๋‹ค ์šฐ์„ " ์€ create SKILL.md(`:119-120`)์—๋งŒ ์žˆ๊ณ  resume SKILL.md(`:107`)์—๋Š” ํ™˜๊ฒฝ๋ณ€์ˆ˜ ํ˜•์‹๋งŒ ์žˆ๋‹ค | ์‚ฌ์†Œ | + +1ํšŒ์ฐจ F1(codex ์ˆ˜๋ฝ ํ‚ค์™€ ๋ธŒ๋ฆฌํ”„ ์„œ์ˆ ์˜ ์ฐจ์ด)์€ ์ด ๋ธํƒ€์˜ ๋ฒ”์œ„ ๋ฐ–์ด๋ฉฐ ๊ทธ๋Œ€๋กœ ์—ด๋ ค ์žˆ๋‹ค. + +## 5. ๊ต์ฐจ๊ฒ€์ฆ ๊ฐ€์ด๋“œ + +```bash +RP=/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20260923T220355Z-76dd1d/1 +PY=/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.venv/bin/python +git -C "$RP" rev-parse HEAD 'HEAD^{tree}' # 05b0ea4โ€ฆ / 8f5e8b0โ€ฆ +# picker ๋‹จ๋…: ์‹ ๋ขฐ ์ฐฝ ์ฟจ๋‹ค์šด ์ค‘์—๋„ ์•ˆ๋‚ด์— ๋‹ตํ•˜๋Š”์ง€ +eval "$(sed -n '/^_mam_dialog_pick() {/,/^}/p' "$RP/.agents/skills/lib.sh")" +D='[["Do you trust the contents of this directory|1\\. Yes, continue",["Enter"],"trust"],["1\\. Update now|Skip until next version",["2"],"notice"]]' +S=$'Do you trust the contents of this directory?\n > 1. Yes, continue\nUpdate available\n 1. Update now\n 2. Skip\n 3. Skip until next version' +_mam_dialog_pick "$S" "$D" 1 "0:1000" 1001 5 # send|1|2 (์ •์ • ์ „: ์ถœ๋ ฅ ์—†์Œ) +_mam_dialog_pick "$S" "$D" 1 "0:1000 1:1000" 1002 5 || echo nothing # S5 ์œ ์ง€ +# ๋˜๋Œ๋ฆฐ ์‚ฌ๋ณธ์—์„œ ํšŒ๊ท€๊ฐ€ ์‹คํŒจํ•˜๋Š”์ง€: lib.sh/create/resume ๋ฅผ f4ba205 ํŒ์œผ๋กœ ๊ต์ฒด โ†’ -k "s6c or s5f or r3b or c3 ..." โ†’ 4 failed / 7 passed +``` + +[VERDICT: PASS] diff --git a/.agents/reports/planner-reviewer-codex-01/report-81cd4006.identity.json b/.agents/reports/planner-reviewer-codex-01/report-81cd4006.identity.json new file mode 100644 index 0000000..55b18a0 --- /dev/null +++ b/.agents/reports/planner-reviewer-codex-01/report-81cd4006.identity.json @@ -0,0 +1,14 @@ +{ + "loop_id": "loop-20260923T220355Z-76dd1d", + "base_sha": "f4ba205c9f755d817e2206c7a9cb390a3e61fd2f", + "head_sha": "05b0ea4762de3fad013e8986d45db21c4e6aa465", + "tree_sha": "8f5e8b0402017d2cc809364e60400c4c25b922f3", + "review_commit": "05b0ea4762de3fad013e8986d45db21c4e6aa465", + "reviewer": "planner-reviewer-codex-01", + "job_id": "81cd4006", + "verdict": "PASS", + "transport": "herdr", + "evidence": "review-tree", + "isolation": "worktree", + "verified_at": "2026-09-23T23:35:19Z" +} \ No newline at end of file diff --git a/.agents/reports/planner-reviewer-codex-01/report-81cd4006.md b/.agents/reports/planner-reviewer-codex-01/report-81cd4006.md new file mode 100644 index 0000000..e93843a --- /dev/null +++ b/.agents/reports/planner-reviewer-codex-01/report-81cd4006.md @@ -0,0 +1,69 @@ +# PR #35 ๋ธํƒ€ ๋ฆฌ๋ทฐ โ€” ๋‘ ํƒ์นจ ์ง€์  ํ•ด์†Œ + +- Job: `81cd4006`; reviewer: `planner-reviewer-codex-01` +- Loop: `loop-20260923T220355Z-76dd1d` +- Base: `f4ba205c9f755d817e2206c7a9cb390a3e61fd2f` +- Head: `05b0ea4762de3fad013e8986d45db21c4e6aa465` +- Tree: `8f5e8b0402017d2cc809364e60400c4c25b922f3` +- ๊ฒ€์ฆ ๊ฒฝ๋กœ: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20260923T220355Z-76dd1d/1` + +๋‘ ๊ธฐ์กด ์ง€์ ์ด ํ•ด๊ฒฐ๋˜์—ˆ๋‹ค. ์ด๋ฒˆ ๋ธํƒ€์—์„œ ์ถ”๊ฐ€ ๋ณ‘ํ•ฉ ์ฐจ๋‹จ ๊ฒฐํ•จ์„ ๋ฐœ๊ฒฌํ•˜์ง€ ์•Š์•˜๋‹ค. ๋ธŒ๋ฆฌํ”„์— ๋ช…์‹œ๋œ 4.7.2 ๋ฒ„์ „ ๋ฝ์Šคํ… ๋ณ€๊ฒฝ์€ ์˜ค์ผ€์ŠคํŠธ๋ ˆ์ดํ„ฐ์˜ ํ›„์† ์ž‘์—…์œผ๋กœ ๋‚จ๋Š”๋‹ค. + +## P1: ํ™˜๊ฒฝ๋ณ€์ˆ˜ ์ž๋™ ์‹ ๋ขฐ ๊ฑฐ๋ถ€๊ฐ€ create/resume์—์„œ ๋ณด์กด๋จ + +๊ทผ๊ฑฐ: `.agents/skills/multi-agent-mux-create/scripts/create_session.sh:72`, `:87`, `:88`, `:96`; `.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh:33`, `:42`, `:43`, `:57`. + +์ดˆ๊ธฐ๊ฐ’์ด `AUTO_TRUST="${MAM_AUTO_TRUST:-1}"`๋กœ ๋ฐ”๋€Œ์–ด ํ™˜๊ฒฝ์˜ `0`์„ ๋ณด์กดํ•œ๋‹ค. ์ดํ›„ ๋ช…์‹œ์  ํ”Œ๋ž˜๊ทธ๊ฐ€ ๊ฐ’์„ ๋ฎ์–ด์“ฐ๋ฏ€๋กœ ๋ฌธ์„œ์— ์ถ”๊ฐ€๋œ ์šฐ์„ ์ˆœ์œ„์™€ ์ผ์น˜ํ•œ๋‹ค. ์‹ค์ œ create/resume ์Šคํฌ๋ฆฝํŠธ๋ฅผ ์ƒŒ๋“œ๋ฐ•์Šค์˜ mock herdr์™€ ์‹คํ–‰ํ•˜๋Š” C3/R3b ํšŒ๊ท€๊ฐ€ ๋‹ค์Œ ์—ฌ์„ฏ ์‚ฌ๋ก€๋ฅผ ๊ฒ€์ฆํ–ˆ๋‹ค. + +| ํ™˜๊ฒฝ๊ฐ’ | ๋ช…์‹œ ํ”Œ๋ž˜๊ทธ | create/resume ํ•ธ๋“ค๋Ÿฌ์— ์ „๋‹ฌ๋˜๋Š” ๊ฐ’ | +|---|---|---| +| `0` | ์—†์Œ | `0` | +| `0` | `--auto-trust` | `1` | +| `1` | `--no-auto-trust` | `0` | + +์ถ”๊ฐ€๋กœ ๋‘ ์Šคํฌ๋ฆฝํŠธ์—์„œ ์ถ”์ถœํ•œ ์ดˆ๊ธฐํ™”ยท์ธ์ž ์ฒ˜๋ฆฌ ์ฝ”๋“œ์™€ ์‹ค์ œ `_mam_dialog_pick` ํ•จ์ˆ˜๋ฅผ ์—ฐ๊ฒฐํ•˜์—ฌ, ๋ฏธ์„ค์ •ยท๋นˆ ๋ฌธ์ž์—ดยท`0`ยท`1`ยท`false`ยท`2`๋ฅผ ๊ฐ๊ฐ ๊ฒ€์‚ฌํ–ˆ๋‹ค(์ด 12์‚ฌ๋ก€). ๋ฏธ์„ค์ •/๋นˆ ๋ฌธ์ž์—ด์€ `1`๋กœ ๊ธฐ๋ณธํ™”๋˜๋ฉฐ, ์ •ํ™•ํžˆ `0`์ผ ๋•Œ๋งŒ ์‹ ๋ขฐ ์ฐฝ์„ ๊ฑด๋„ˆ๋›ด๋‹ค. `false`์™€ `2`๋Š” ๊ทธ๋Œ€๋กœ ์ „๋‹ฌ๋˜๊ณ  ์ž๋™ ์‹ ๋ขฐ๊ฐ€ ์ผœ์ง„๋‹ค. ์ด๋Š” ํ•ธ๋“ค๋Ÿฌ์˜ ๊ธฐ์กด `!= "0"` ๊ทœ์น™๊ณผ ์ผ์น˜ํ•˜๋ฉฐ ์ผ๋ฐ˜์ ์ธ boolean ๋ฌธ์ž์—ด ํŒŒ์„œ๋กœ ๋ฐ”๋€ ๊ฒƒ์€ ์•„๋‹ˆ๋‹ค. + +์—ญ๊ฒ€์ฆ: ๋ณ„๋„ ์ž„์‹œ ์ƒŒ๋“œ๋ฐ•์Šค์—์„œ ํ•ด๋‹น ์Šคํฌ๋ฆฝํŠธ์˜ ์ดˆ๊ธฐํ™” ํ•œ ์ค„๋งŒ `AUTO_TRUST=1`๋กœ ๋˜๋Œ๋ฆฐ ๋’ค, C3/R3b์˜ ํ™˜๊ฒฝ๊ฐ’ `0`, ํ”Œ๋ž˜๊ทธ ์—†์Œ ์‚ฌ๋ก€๋ฅผ ํ˜ธ์ถœํ–ˆ๋‹ค. create์™€ resume ๋ชจ๋‘ ์ƒˆ ํšŒ๊ท€์˜ assertion์ด ์‹คํŒจํ–ˆ๋‹ค. ์ˆ˜์ •๋œ HEAD์—์„œ๋Š” ๊ฐ™์€ ์‚ฌ๋ก€๊ฐ€ ํ†ต๊ณผํ–ˆ๋‹ค. ๊ธฐ์กด P1 ์ง€์ ์„ ํ•ด์†Œํ•œ๋‹ค. + +## P2: ์ฟจ๋‹ค์šด ์ค‘์ธ ์‹ ๋ขฐ ์ฐฝ์ด ๋’ค์˜ ์•ˆ๋‚ด๋ฅผ ๊ฐ€๋ฆฌ์ง€ ์•Š์Œ + +๊ทผ๊ฑฐ: `.agents/skills/lib.sh:2953` ๋ฐ `:2958`. ์ฟจ๋‹ค์šด์— ๊ฑธ๋ฆฐ ํ•ญ๋ชฉ์—์„œ ๊ฒ€์ƒ‰ ์ „์ฒด๋ฅผ ๋๋‚ด๋˜ `sys.exit(1)`์ด ๋‹ค์Œ ํ•ญ๋ชฉ์„ ๊ฒ€์‚ฌํ•˜๋Š” `continue`๋กœ ๋ฐ”๋€Œ์—ˆ๋‹ค. ํ•ญ๋ชฉ๋ณ„ ์ฟจ๋‹ค์šด ํŒ์ •๊ณผ ์ „์†ก ๊ธฐ๋ก์€ ์œ ์ง€๋œ๋‹ค. + +์žฌํ˜„ ํ™”๋ฉด์€ ์‹ ๋ขฐ ๋ฌธ๊ตฌ์™€ ์—…๋ฐ์ดํŠธ ์•ˆ๋‚ด๋ฅผ ํ•จ๊ป˜ ํฌํ•จํ•œ๋‹ค. + +```text +Do you trust the contents of this directory? + > 1. Yes, continue +Update available + 1. Update now + 2. Skip + 3. Skip until next version +``` + +S6c๋Š” `MAM_AUTO_TRUST=0`, ์ฟจ๋‹ค์šด 30์ดˆ, ํ•ธ๋“ค๋Ÿฌ ์‹คํ–‰ 3ํšŒ ์กฐ๊ฑด์—์„œ ์‹ ๋ขฐ ํ‚ค ์—†์ด `2`๋งŒ ํ•œ ๋ฒˆ ์ „์†ก๋จ์„ ๊ฒ€์ฆํ–ˆ๋‹ค. S5f๋Š” ์ž๋™ ์‹ ๋ขฐ๊ฐ€ ์ผœ์ง„ ์ƒํƒœ์™€ ์ฟจ๋‹ค์šด 60์ดˆ์—์„œ `Enter`, `2`๊ฐ€ ์ด ์ˆœ์„œ๋กœ ๊ฐ๊ฐ ํ•œ ๋ฒˆ ์ „์†ก๋จ์„ ๊ฒ€์ฆํ–ˆ๋‹ค. ๋‘˜ ๋‹ค ํ†ต๊ณผํ–ˆ๋‹ค. ๊ธฐ์กด S5/S5b๋ฅผ ํฌํ•จํ•œ ์„ ํƒ ํ…Œ์ŠคํŠธ๋„ ํ†ต๊ณผํ•˜์—ฌ, ์ฟจ๋‹ค์šด ์ค‘ ์žฌ์ „์†ก ์–ต์ œ์™€ ๋งŒ๋ฃŒ ํ›„ ์žฌ์‹œ๋„ ๋™์ž‘์ด ๋ณด์กด๋จ์„ ํ™•์ธํ–ˆ๋‹ค. + +์—ญ๊ฒ€์ฆ: ์ž„์‹œ ์‚ฌ๋ณธ์—์„œ ์ด `continue`๋งŒ ์ด์ „ `sys.exit(1)`๋กœ ๋˜๋Œ๋ ธ๋‹ค. S6c๋Š” ์‹ค์ œ ์ „์†ก ํ‚ค๊ฐ€ `[]`์—ฌ์„œ ์‹คํŒจํ–ˆ๊ณ , S5f๋Š” ์‹ค์ œ ์ „์†ก ํ‚ค๊ฐ€ `[Enter]`๋ฟ์ด์–ด์„œ ์‹คํŒจํ–ˆ๋‹ค. ์ฆ‰ ์ƒˆ ํ…Œ์ŠคํŠธ๋Š” ์•ˆ๋‚ด ์ „์†ก ๋ˆ„๋ฝ์„ ์‹ค์ œ๋กœ ๊ฒ€์ถœํ•œ๋‹ค. ๊ธฐ์กด P2 ์ง€์ ์„ ํ•ด์†Œํ•œ๋‹ค. + +## ๊ฒ€์ฆ ๊ฒฐ๊ณผ์™€ ๋ฒ”์œ„ + +๊ฒ€์ฆ ํŠธ๋ฆฌ์˜ HEAD/tree๊ฐ€ ๋ธŒ๋ฆฌํ”„์™€ ์ผ์น˜ํ•˜๊ณ  ์ž‘์—… ํŠธ๋ฆฌ๊ฐ€ ๊นจ๋—ํ•จ์„ ํ™•์ธํ–ˆ๋‹ค. `git diff f4ba205...HEAD`์˜ 6๊ฐœ ํŒŒ์ผ์„ ๊ฒ€ํ† ํ–ˆ๋‹ค. ์ œํ’ˆ ์ฝ”๋“œยท์†Œ์Šค ์›ŒํฌํŠธ๋ฆฌยท์‹ค์ œ ์—์ด์ „ํŠธ ์„ธ์…˜์€ ์ˆ˜์ •ํ•˜์ง€ ์•Š์•˜์œผ๋ฉฐ, ์—ญ๊ฒ€์ฆ ๋ณ€๊ฒฝ์€ `/tmp/mam-81cd-revert-*`์˜ ํ…Œ์ŠคํŠธ ์‚ฌ๋ณธ์—๋งŒ ์ ์šฉํ–ˆ๋‹ค. + +์‹คํ–‰ํ•œ ์„ ํƒ ํ…Œ์ŠคํŠธ: + +```text +tests/test_issue23_24_startup_path.py +tests/test_issue24_resume_dialogs.py +-k 's5 or s6 or environment_form or no_auto_trust_reaches' +``` + +- ๊ฒฐ๊ณผ: **20 passed, 61 deselected in 191.05s**. +- ๋ธŒ๋ฆฌํ”„์˜ Python ์ธํ„ฐํ”„๋ฆฌํ„ฐ๋ฅผ `AGENT_PYTHON_BIN`๊ณผ ์‹คํ–‰ ๊ฒฝ๋กœ์— ํ•จ๊ป˜ ์ง€์ •ํ–ˆ๋‹ค. `PYTHONDONTWRITEBYTECODE=1`, `-p no:cacheprovider`, ๊ฒ€์ฆ ๊ฒฝ๋กœ ๋ฐ–์˜ ๊ณ ์œ  `--basetemp=/tmp/mam-review-81cd4006.*`๋ฅผ ์‚ฌ์šฉํ–ˆ๋‹ค. +- ์ˆ˜์ • ์—ญ๊ฒ€์ฆ 4๊ฑด ๋ชจ๋‘ ์ƒˆ ํšŒ๊ท€ assertion์ด ์˜ˆ์ƒ๋Œ€๋กœ ์‹คํŒจํ–ˆ๋‹ค: ์‹ ๋ขฐ ๊ฑฐ๋ถ€+์•ˆ๋‚ด, ์‹ ๋ขฐ ์ˆ˜๋ฝ+์•ˆ๋‚ด, create ํ™˜๊ฒฝ๊ฐ’, resume ํ™˜๊ฒฝ๊ฐ’. +- ํ™˜๊ฒฝ๊ฐ’ ๊ฒฝ๊ณ„ ๊ฒ€์‚ฌ 12๊ฑด ํ†ต๊ณผ. +- `git diff --check f4ba205 HEAD`, ๋ณ€๊ฒฝ๋œ ์…ธ ํŒŒ์ผ 3๊ฐœ์˜ ๊ฐœ๋ณ„ `bash -n`, ๋ณ€๊ฒฝ๋œ Python ํ…Œ์ŠคํŠธ ํŒŒ์ผ 2๊ฐœ์˜ AST ๊ตฌ๋ฌธ ๊ฒ€์‚ฌ ํ†ต๊ณผ. + +์ง์ „ `f4ba205` ๋ฆฌ๋ทฐ์—์„œ ๊ธฐ๋กํ•œ 247๊ฑด ํ†ต๊ณผ๋Š” ์ด์ „ ํŠธ๋ฆฌ์˜ ๊ฒฐ๊ณผ๋‹ค. ์ด๋ฒˆ ํŠธ๋ฆฌ์—์„œ 247๊ฑด ์ „์ฒด๋ฅผ ์žฌ์‹คํ–‰ํ–ˆ๋‹ค๊ณ  ์ฃผ์žฅํ•˜์ง€ ์•Š์œผ๋ฉฐ, ์ด๋ฒˆ ๊ฒ€์ฆ์€ ๋‘ ์ •์ •๊ณผ ๊ด€๋ จ ํšŒ๊ท€์— ์ง‘์ค‘ํ–ˆ๋‹ค. ๋ธํƒ€๋Š” ๊ต์ฐจ ์„œ๋ฒ„ ์Šค์ฝ”ํ”„, dispatch ์ฆ๊ฑฐ, ๋กค๋ฐฑ ๊ตฌํ˜„์„ ๋ณ€๊ฒฝํ•˜์ง€ ์•Š๋Š”๋‹ค. ์ƒˆ ํšŒ๊ท€๋Š” ์ƒŒ๋“œ๋ฐ•์Šค์˜ mock herdr/ํ™”๋ฉด ๋ฌธ์ž์—ด์„ ์‚ฌ์šฉํ•˜๋ฏ€๋กœ ์‹ค์ œ CLI ํ™”๋ฉด ์ˆ˜์šฉ ์‹œํ—˜์„ ์ƒˆ๋กœ ์ˆ˜ํ–‰ํ–ˆ๋‹ค๋Š” ์˜๋ฏธ๋„ ์•„๋‹ˆ๋‹ค. + +๋‹จ์ˆœ ์ˆ˜์ •์œผ๋กœ ๋‘ ์ง€์ ์ด ๋‹ซํ˜”์œผ๋ฏ€๋กœ ์žฌ์„ค๊ณ„ ์—์Šค์ปฌ๋ ˆ์ด์…˜์€ ํ•„์š”ํ•˜์ง€ ์•Š๋‹ค. + +[VERDICT: PASS] diff --git a/.agents/reports/reviewer-agy-01/report-471806f8.identity.json b/.agents/reports/reviewer-agy-01/report-471806f8.identity.json new file mode 100644 index 0000000..d63d510 --- /dev/null +++ b/.agents/reports/reviewer-agy-01/report-471806f8.identity.json @@ -0,0 +1,14 @@ +{ + "loop_id": "loop-20260923T220355Z-76dd1d", + "base_sha": "f4ba205c9f755d817e2206c7a9cb390a3e61fd2f", + "head_sha": "05b0ea4762de3fad013e8986d45db21c4e6aa465", + "tree_sha": "8f5e8b0402017d2cc809364e60400c4c25b922f3", + "review_commit": "05b0ea4762de3fad013e8986d45db21c4e6aa465", + "reviewer": "reviewer-agy-01", + "job_id": "471806f8", + "verdict": "PASS", + "transport": "herdr", + "evidence": "review-tree", + "isolation": "worktree", + "verified_at": "2026-09-23T23:35:19Z" +} \ No newline at end of file diff --git a/.agents/reports/reviewer-agy-01/report-471806f8.md b/.agents/reports/reviewer-agy-01/report-471806f8.md new file mode 100644 index 0000000..70ac1ad --- /dev/null +++ b/.agents/reports/reviewer-agy-01/report-471806f8.md @@ -0,0 +1,69 @@ +# Pre-Merge Independent Delta Review Report: PR #35 (`fix/issue-23-24` Round 2) + +- **Job ID**: `471806f8` +- **Target Agent**: `agy` (Session: `herdr:reviewer-agy-01`) +- **Role**: Reviewer +- **Target Worktree Context**: + - **Loop ID**: `loop-20260923T220355Z-76dd1d` + - **Base Commit**: `f4ba205c9f755d817e2206c7a9cb390a3e61fd2f` (Round 1 target) + - **Head Commit**: `05b0ea4762de3fad013e8986d45db21c4e6aa465` (1 delta commit: +76 / โˆ’5) + - **Tree SHA**: `8f5e8b0402017d2cc809364e60400c4c25b922f3` + - **Verification Path**: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20260923T220355Z-76dd1d/1` + +--- + +## 1. Executive Summary + +This second review round examines delta commit `05b0ea4` ("fix(issue-23-24): merge review - the environment form of the auto-trust switch reaches the handler; a rate-limited dialog does not hide the next one"). The commit addresses two specific edge-case probes identified during the merge review of PR #35: +1. **Probe P1**: In `create_session.sh` and `resume_session.sh`, hardcoded `AUTO_TRUST=1` unconditionally overwrote the documented environment switch `MAM_AUTO_TRUST=0`. +2. **Probe P2**: In `lib.sh::_mam_dialog_pick`, hitting a dialog within its resend cooldown triggered `sys.exit(1)`, preventing subsequent modal notices (e.g. codex update skip) on the same screen from being answered. + +Both fixes, their regression test coverage (`test_issue23_24_startup_path.py` and `test_issue24_resume_dialogs.py`), and documentation updates in `multi-agent-mux-create/SKILL.md` were evaluated. All 18 targeted tests and git diff validation passed with zero regressions. + +--- + +## 2. Detailed Verification Against Checklist Items + +### (1) Resolution of Codex Probes P1 & P2 and Side-Effect Analysis +- **P1: Environment Variable Inheritance (`AUTO_TRUST="${MAM_AUTO_TRUST:-1}"`)**: + - Previously, `create_session.sh` and `resume_session.sh` set `AUTO_TRUST=1` and subsequently exported `MAM_AUTO_TRUST="$AUTO_TRUST"`, clobbering caller-provided `MAM_AUTO_TRUST=0` when no CLI flag was supplied. + - Initializing `AUTO_TRUST="${MAM_AUTO_TRUST:-1}"` allows `MAM_AUTO_TRUST=0` in the environment to take effect naturally. + - An explicit CLI flag (`--auto-trust` or `--no-auto-trust`) re-assigns `AUTO_TRUST` to `1` or `0`, correctly asserting command-line precedence over environment variables. + - Regression verified by `test_r3b` (resume) and `test_c3` (create) across all combinations (env=0 alone, env=0 with `--auto-trust`, and env=1 with `--no-auto-trust`). +- **P2: Dialog Inspection Fallthrough via `continue`**: + - In `lib.sh::_mam_dialog_pick`, when a dialog matched on screen is within its cooldown window (`0 <= now - last < cooldown`), changing the behavior from `sys.exit(1)` to `continue` permits evaluation of subsequent dialog signatures in the adapter's list. + - If a trust dialog was skipped via `--no-auto-trust` (recorded in `_MAM_DIALOG_SENT`), or if it was answered on the preceding frame and is now cooling, a co-located notice dialog (such as the codex update prompt) is properly evaluated and answered on subsequent iterations. + - Verified by new test `test_s6c` (skipped trust dialog does not hide notice below it) and `test_s5f` (dialog inside cooldown does not block answering the next dialog). +- **Preservation of Cooldown Protection**: + - `test_s5` and `test_s5b` continue to verify that an individual dialog cannot be resent while its cooldown is active, and can only be retried once the cooldown window expires. + - Multi-dialog ordering and execution count remain deterministic: trust dialog answered first (`Enter`), followed by notice dialog (`2`), each sent exactly once (`test_s5f`). + +### (2) Environment Precedence & Value Semantics +- In shell scripts (`create_session.sh` / `resume_session.sh`): + - `AUTO_TRUST="${MAM_AUTO_TRUST:-1}"` defaults to `1` when unset or empty. + - Any user-specified value (e.g. `0`) is preserved and exported. + - Command-line flags `--auto-trust` and `--no-auto-trust` overwrite `AUTO_TRUST` to `1` and `0` respectively, ensuring CLI options win over environment variables. +- In Python (`_mam_dialog_pick`): + - `auto_trust = os.environ.get("MAM_DLG_TRUST", "1") != "0"` cleanly maps string `"0"` to `False`, while any other non-"0" value defaults to `True`. + - Behavior between shell scripts and Python dialog picker is fully aligned. +- Documentation in `multi-agent-mux-create/SKILL.md` explicitly specifies `MAM_AUTO_TRUST=0` and documents that explicit flags take precedence over the environment. + +### (3) Overall Merge Readiness +- Commit `05b0ea4` reinforces the robustness and reliability of PR #35 without altering any public interfaces or state machine rules verified in Round 1. +- Formatting and git diff checks (`git diff f4ba205...HEAD --check`) reported 0 whitespace or formatting issues. +- Version bump to `v4.7.2` remains scheduled for the orchestrator release commit. +- No blockers or regressions detected. + +--- + +## 3. Test Execution Summary + +| Test Suite / Target | Result | Duration | Notes | +| :--- | :--- | :--- | :--- | +| `test_issue23_24_startup_path.py` (`s6c`, `s5f`, `s5`, `s5b`) | **11 / 11 PASS** | 182s | Probe P2 cooldown fallthrough & multi-dialog handling | +| `test_issue24_resume_dialogs.py` (`r3b`, `c3`, `r3`) | **7 / 7 PASS** | 8s | Probe P1 environment variable inheritance & CLI flag precedence | +| `git diff f4ba205...HEAD --check` | **0 errors** | 0.05s | Clean formatting and no whitespace errors | + +--- + +[VERDICT: PASS] diff --git a/.agents/reports/reviewer-agy-01/report-c786c83f.md b/.agents/reports/reviewer-agy-01/report-c786c83f.md new file mode 100644 index 0000000..9aa55bc --- /dev/null +++ b/.agents/reports/reviewer-agy-01/report-c786c83f.md @@ -0,0 +1,123 @@ +# Pre-Merge Independent Review Report: PR #35 (`fix/issue-23-24`) + +- **Job ID**: `c786c83f` +- **Target Agent**: `agy` (Session: `herdr:reviewer-agy-01`) +- **Role**: Reviewer +- **Target Worktree Context**: + - **Loop ID**: `loop-20260923T142448Z-609954` + - **Base Commit**: `1e78dae6ebcb01773df7c4715c98b1786965a24a` (v4.7.1) + - **Head Commit**: `f4ba205c9f755d817e2206c7a9cb390a3e61fd2f` + - **Tree SHA**: `88cb4f34f1e9f8220a373350cfc600a53735f181` + - **Verification Path**: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20260923T142448Z-609954/1` + +--- + +## 1. Executive Summary + +PR #35 addresses Issue #23 and Issue #24 ("๊ต์ฐจ ์„œ๋ฒ„ herdr ์Šค์ฝ”ํ”„์™€ ์—์ด์ „ํŠธ ๋ฌด๊ด€ ์‹œ์ž‘ ๋‹ค์ด์–ผ๋กœ๊ทธ, ์‹ค์ œ ์—์ด์ „ํŠธ๊ฐ€ ์ถฉ์กฑํ•˜๋Š” ์˜จ๋ณด๋”ฉ ์ „๋‹ฌ ํŒ์ •") across 13 commits and 22 files (+2197 / โˆ’118). + +The branch eliminates cross-server scope leaks (`HERDR_WORKSPACE_ID` / `HERDR_PANE_ID` / `HERDR_TAB_ID`), externalizes agent start-up modal handling into declarative adapter facts, standardizes protocol-level `started` acknowledgement for agent onboarding, and implements robust fail-closed rollback handling (exit code 43 on unkillable sessions). + +All verification tasks were performed in the isolated verification path. All 73 tests in the new test suites (`test_issue23_24_startup_path.py`, `test_issue24_resume_dialogs.py`) and all 177 regression tests in the adjusted suites passed without error. + +--- + +## 2. Detailed Verification Against Checklist Items + +### (1) Issue #24 Startup Dialogs +- **Adapter-Driven Declarative Modals**: + - Signatures, response keystrokes, and dialog classifications (`trust` vs. `notice`) are defined per agent in `lib_py/agents/adapters/*.py` (`startup_dialogs` property) and exposed via `lib_py.agents facts` (`MAM_STARTUP_DIALOGS`). + - Supported keys match target agent TUI specifications: + * `agy`: `'Do you trust the contents of this project|Yes, I trust this folder'` -> `['Enter']` (`trust`). + * `grok`: `'Do you trust the contents of this directory'` -> `['y']` (`trust`). + * `codex`: `'Do you trust the contents of this directory|1\\. Yes, continue'` -> `['Enter']` (`trust`); `'1\\. Update now|Skip until next version'` -> `['2']` (`notice`, selects "2. Skip"). + * `claude`: Full legacy ladder (`Enter` for trust/proceed, `Escape` for fullscreen upsell modal to preserve execution flags, `Down`+`Enter` for proceed). +- **Cooldown & Ready Token Gating**: + - Because herdr `--source visible` capture retains modal text on-screen temporarily after acceptance, re-sending keystrokes is rate-limited by `MAM_DIALOG_RESEND_S` (default 5s cooldown). + - TUI readiness is strictly judged by adapter ready tokens (`MAM_STRONG_READY_TOKENS` / `MAM_READY_TOKENS`), never by modal disappearance (`test_s5c`, `test_s5d`). +- **`--no-auto-trust` Flag & Workspace Scope**: + - Setting `--no-auto-trust` (`MAM_AUTO_TRUST=0`) leaves dialogs of kind `trust` unanswered (warning printed to stderr for operator confirmation) while continuing to auto-handle `notice` dialogs (`test_s6`). + - Crucial for `codex` security, where directory trust activates project-local configuration, hooks, and execution policies. + - Automatic trust is only sent to sessions launched inside the designated `--workspace`. +- **Contract Parity across Create and Resume**: + - `create_session.sh` and `resume_session.sh` both invoke `handle_startup_dialogs "$SESSION_NAME" "$AGENT"`, parse `--no-auto-trust`, and share `_mam_load_dialog_facts` (`test_r1`, `test_r2`, `test_r3`, `test_r4`). + +### (2) Issue #23 Cross-Server Herdr Scoping +- **Targeted Scope Variable Stripping**: + - In `lib.sh` (`mam_herdr`), `_herdr_call_is_cross_server` strips `HERDR_WORKSPACE_ID`, `HERDR_PANE_ID`, and `HERDR_TAB_ID` via `env -u ...` **only** when targeting a different server (`caller != target`). + - Same-server calls retain workspace/pane scoping, protecting workspace packing and pane isolation (`test_s2`, `test_s1e`). Calls outside panes retain explicit scopes (`test_s2b`). +- **Caller Server Identification**: + - `_herdr_caller_server` checks `HERDR_SESSION` first. If unset (e.g. herdr 0.8.2 on the default server), it parses `HERDR_SOCKET_PATH`: + * `*/.config/herdr/herdr.sock` -> `default` + * `*/.config/herdr/sessions//herdr.sock` -> `` + * Unknown formats -> `""` (treated safely as non-pane). + - Verified across both default and named session socket layouts on herdr 0.8.2 and 0.9.0 (`test_s1c`, `test_s1d`, `test_s1f`, `test_s1g`, `test_s1h`). +- **Python Watcher Parity**: + - `lib_py/loop/watcher.py` implements identical logic (`caller_server()` and `scoped_env()`) for direct subprocess executions of herdr (`test_s13`). + +### (3) Issue #23 Onboarding Delivery Verification +- **Unified Command Builder & Predicate**: + - `delegate_publish_cmd` in `lib.sh` generates the canonical publish command with `--expect-dispatch-token --attempt-id `. + - `test_s7l` confirms `delegate_publish_cmd` produces identical arguments to the delegate-job wrapper's `_pub_cmd`. + - Both `create_session.sh` and the wrapper (`_wait_started`) share `delegate_started_recorded` from `lib.sh`. +- **Dispatch Attribution & Single-Injection Contract**: + - Local events: `/.events.log` requires `event == "started"` and `data.dispatch_token == `. + - Remote events: `//events.ndjson` requires `source_event == "started"`, `dispatch_verified is True`, and `dispatch_fingerprint == sha256()`. + - Prevents a prior attempt's started record from acknowledging a subsequent re-dispatch (`test_w2`, `test_w7`). + - Legacy records lacking a fingerprint fail closed when a token is demanded (`test_w8`). +- **Elimination of Status Bypass**: + - The previous wrapper fallback (`disk_status != pending -> success`) was completely removed. Both create and wrapper now strictly verify `started` event attribution (`test_w1`, `test_w4`). +- **Terminal State Protection**: + - If a job has already transitioned to a terminal status (`completed`, `error`, `cancelled`), `create_session.sh` leaves the job untouched rather than issuing a conflicting state change (`test_s7f`, `test_s7m`). +- **Liveness Parity**: + - Respects Issue #18 liveness contract (T=180s timeout, configurable via `MAM_ONBOARD_ACK_S`). + +### (4) Issue #23 Rollback & Error Handling +- **Kill Confirmation & Exit Code 43**: + - `cleanup_herdr_on_error` checks the returncode of `kill-session` and affirmatively verifies session termination via `has-session`. + - If the session remains alive, the script updates `agent-sessions.yaml` to `status: running` with `last_visible_status: rollback-failed`, clears `delegate_job_id`, prints manual cleanup instructions, and exits with code **43** (`test_s9`). + - If cleanly killed, it marks `status: terminated`, `last_visible_status: rolled-back`, publishes `cancelled` for the onboarding job, and exits (`test_s10`). +- **Session Preservation on Handshake Timeout**: + - If an onboarding `started` event is not observed within `ONBOARD_ACK_S`, the onboarding job is marked `error`, but the session and its pane are **preserved** (`test_s7d`). Operators can re-submit onboarding via `multi-agent-mux-delegate-job submit` once the agent finishes initializing. + +### (5) Impact on Existing Test Suites (6 Files) +- `tests/conftest.py`: Injects `MAM_ONBOARD_ACK_S=3` into the `mam_sandbox` fixture to prevent sandbox runs without real agents from waiting 180s. +- `tests/test_b19_headless_reconcile_fixes.py`: Relocated modal assertion to inspect `claude.py` adapter facts. +- `tests/test_c1_tui_readiness.py`: Updated `test_c1b_hint_press_enter_sends_enter` to reflect time-based cooldown semantics (`MAM_DIALOG_RESEND_S`), verifying `1 <= count <= 30`. +- `tests/test_issue14_delivery_ack.py`: Updated out-of-band agent acknowledgement to publish a genuine `started` event with token via `publish_event.py`. +- `tests/test_issue18_liveness_contract.py`: Assertions match the protocol requirement (`recorded 'started'`). +- `tests/test_issue5_phase2a_loop.py`: Refined G9 guard using Python AST parsing (`_code_only`) to strip only docstring expression nodes while preserving same-line code statements. Rigorously tested by `test_g9b_the_guard_still_catches_an_invocation_and_ignores_prose`. + +### (6) Test Isolation & Reliability +- New suites (`test_issue23_24_startup_path.py` [1268 lines] and `test_issue24_resume_dialogs.py` [127 lines]) operate completely within isolated sandboxes. +- Real `registry.py` and `publish_event.py` utilities are exercised against isolated directories. +- Zero mutation of control plane `.mam`, live agent sessions, or daemon processes. + +### (7) Documentation Integrity +- `multi-agent-mux-create/SKILL.md` and `multi-agent-mux-resume/SKILL.md` accurately document `--no-auto-trust`, exit codes 42/43, and `MAM_ONBOARD_ACK_S`. +- Event and audit log schemas (`dispatch_fingerprint`, `dispatch_verified`) align with `MESSAGING.md`. + +### (8) Merge Readiness +- Version bump to `v4.7.2` is scheduled for the orchestrator release commit. +- Code style, formatting, and diff integrity checks (`git diff 1e78dae...HEAD --check`) reported 0 issues. +- Zero architectural, functional, or regression defects found. + +--- + +## 3. Test Execution Summary + +| Test Suite | Result | Duration | Notes | +| :--- | :--- | :--- | :--- | +| `tests/test_issue23_24_startup_path.py` | **68 / 68 PASS** | 358s | S1โ€“S13, S4โ€“S6, S7โ€“S10, W1โ€“W8 test matrix | +| `tests/test_issue24_resume_dialogs.py` | **5 / 5 PASS** | 8s | R1โ€“R4 resume dialog parity | +| `tests/test_issue5_phase2a_loop.py` | **91 / 91 PASS** | 3.1s | Loop orchestration & updated G9 AST guard | +| `tests/test_issue18_liveness_contract.py` | **31 / 31 PASS** | 112s | Protocol liveness, subscriber audit & broker fallback | +| `tests/test_c1_tui_readiness.py` | **23 / 23 PASS** | 45s | TUI readiness & rate-limited dialog responses | +| `tests/test_b19_headless_reconcile_fixes.py` | **16 / 16 PASS** | 1.8s | Reconcile fixes & dialog exclusion assertions | +| `tests/test_issue14_delivery_ack.py` | **12 / 12 PASS** | 98s | Delivery acknowledgement contracts | +| `tests/test_version_consistency.py` | **3 / 3 PASS** | 0.2s | Version consistency checks | +| `git diff 1e78dae...HEAD --check` | **0 errors** | 0.05s | Clean whitespace and formatting | + +--- + +[VERDICT: PASS] diff --git a/.agents/reports/reviewer-grok-01/report-59e62ce6.md b/.agents/reports/reviewer-grok-01/report-59e62ce6.md new file mode 100644 index 0000000..748b53c --- /dev/null +++ b/.agents/reports/reviewer-grok-01/report-59e62ce6.md @@ -0,0 +1,52 @@ +# ๐Ÿ” Review Report โ€” Job 59e62ce6 (PR #35 @ `f4ba205`) + +- **Reviewer**: `reviewer-grok-01` ยท 2026-09-22 +- **๊ฒ€์ฆ ๊ฒฝ๋กœ**: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20260923T142448Z-609954/1` +- **identity**: base `1e78dae` head `f4ba205` tree `88cb4f3` (์ผ์น˜) +- **๋ณธ ์ฒดํฌ์•„์›ƒ**: `main` `1e78dae` ์œ ์ง€. ์†Œ์Šค ์›ŒํฌํŠธ๋ฆฌยทherdr ๋ฏธ์กฐ์ž‘. + +`git diff --check 1e78dae...HEAD` ํด๋ฆฐ. 13์ปค๋ฐ‹. + +## (1) #24 ๋‹ค์ด์–ผ๋กœ๊ทธ + +์–ด๋Œ‘ํ„ฐ `startup_dialogs` (`facts` ๋กœ `MAM_STARTUP_DIALOGS`): + +| ์—์ด์ „ํŠธ | ํ‚ค | kind | +|---|---|---| +| agy | Enter | trust | +| grok | `y` | trust | +| codex trust | Enter (`1. Yes, continue` ์„ ์„ ํƒ) | trust | +| codex ์—…๋ฐ์ดํŠธ | **`2`** (Skip) | notice | +| claude | Enter / Escape / Down+Enter | ๊ธฐ์กด ์‚ฌ๋‹ค๋ฆฌ | + +๋ธŒ๋ฆฌํ”„์˜ โ€œ`3`+Enterโ€ ์™€ ๋‹ค๋ฆ„. ์ฝ”๋“œยทํ…Œ์ŠคํŠธ(`test_s4`, `test_s6`)๋Š” **`2`** ๋ฅผ ์ž ๊ทผ๋‹ค. ์ฃผ์„: 0.155.1 ํ™”๋ฉด์—์„œ 2 ๊ฐ€ Skip. `--source visible` ์ž”์ƒ ๋•Œ๋ฌธ์— ์žฌ์ „์†ก์€ `MAM_DIALOG_RESEND_S`(๊ธฐ๋ณธ 5s) ์ด๊ณ  ์ˆ˜๋ฝ์€ ready ํ† ํฐ๋งŒ (`lib.sh` `handle_startup_dialogs`). `--no-auto-trust` / `MAM_AUTO_TRUST=0` ์€ trust ๋งŒ ๊ฑด๋„ˆ๋›ฐ๊ณ  notice ๋Š” ๋‹ตํ•จ. ์‹ ๋ขฐ๋Š” `--workspace` ๋กœ ์—ฐ ๊ทธ ์„ธ์…˜์˜ ์ฐฝ. createยทresume ๋‘˜ ๋‹ค `handle_startup_dialogs "$SESSION" โ€ฆ "$AGENT"` ์™€ `MAM_AUTO_TRUST`. + +## (2) #23 ๊ต์ฐจ ์„œ๋ฒ„ + +`_herdr_call_is_cross_server` (`lib.sh:1437-1446`): ๋Œ€์ƒ์ด ๋น„๊ฑฐ๋‚˜ `default` ์ด๋ฉด ์ŠคํŠธ๋ฆฝ ์•ˆ ํ•จ(๊ฐ™์€ ์„œ๋ฒ„ยท#7 ํŒจํ‚น์˜ `HERDR_WORKSPACE_ID` ์œ ์ง€). ํ˜ธ์ถœ์ž ์„œ๋ฒ„ ์—†์Œ(pane ๋ฐ–)๋„ ์ŠคํŠธ๋ฆฝ ์•ˆ ํ•จ. `HERDR_SESSION` ์ด ์žˆ์œผ๋ฉด ์†Œ์ผ“๋ณด๋‹ค ์šฐ์„ . ์—†์œผ๋ฉด `HERDR_SOCKET_PATH` ๋ชจ์–‘: `โ€ฆ/.config/herdr/herdr.sock` โ†’ `default`, `โ€ฆ/.config/herdr/sessions//herdr.sock` โ†’ ``. 0.8.2 ๊ธฐ๋ณธ ์„œ๋ฒ„ pane ์€ `HERDR_SESSION` ์ด ์—†์–ด๋„ ์†Œ์ผ“์œผ๋กœ ํŒ๋ณ„. 0.9 ๋Š” ์†Œ์ผ“ ๊ทœ์•ฝ์ด ๊ฐ™๊ณ , ์ฃผ์„์˜ 0.9 ์ฐจ์ด๋Š” agent status ๋ฏธ๋ณด๊ณ (`lib.sh:2792`)์ด์ง€ ์†Œ์ผ“ ํ˜•ํƒœ๊ฐ€ ์•„๋‹˜. `watcher.scoped_env` ๊ฐ€ ๊ฐ™์€ ๊ทœ์น™. + +## (3) ์˜จ๋ณด๋”ฉ ์ „๋‹ฌ + +`delegate_publish_cmd` / `delegate_started_recorded` ๋ฅผ create ์™€ ๋ž˜ํผ `_wait_started` ๊ฐ€ ๊ณต์œ . ์ฆ๊ฑฐ๋Š” **์ด๋ฒˆ `dispatch_token`** ์˜ `events.log` `started`, ๋˜๋Š” ๊ฐ์‚ฌ `events.ndjson` ์˜ `dispatch_verified` **๊ทธ๋ฆฌ๊ณ ** `dispatch_fingerprint == sha256(token)`. ์ง€๋ฌธ ์—†๋Š” ์˜› ๊ธฐ๋ก์€ ํ† ํฐ์ด ์žˆ์„ ๋•Œ ๊ฑฐ๋ถ€. ๋ ˆ์ง€์ŠคํŠธ๋ฆฌ status ๋Š” ์ฆ๊ฑฐ๊ฐ€ ์•„๋‹˜(๋ž˜ํผ `:641-646`). ์ด๋ฏธ ํ„ฐ๋ฏธ๋„์ธ ์žก์€ `JOB_TERMINALIZED` ๋กœ ๋‘ ๋ฒˆ์งธ close ๋ฅผ ์•ˆ ํ•จ. T ๊ธฐ๋ณธ 180, heartbeat ๊ณ„์•ฝ ์œ ์ง€. create ๋Š” ๋” ์ด์ƒ ๋Œ€๋ฆฌ `started` ๋ฅผ ์•ˆ ์ฐ์Œ. + +## (4) ์‹คํŒจ ์ฒ˜๋ฆฌ + +๋กค๋ฐฑ: `kill-session` rc ํ›„ `has-session`. ์‚ด์•„ ์žˆ์œผ๋ฉด ํ–‰ `status: running`, `last_visible_status: rollback-failed`, `delegate_job_id` ๋น„์›€, **exit 43**. ์ฃฝ์—ˆ์œผ๋ฉด `terminated` / `rolled-back`. ์˜จ๋ณด๋”ฉ ๋ฏธํ™•์ธ์€ ์„ธ์…˜ ์œ ์ง€, ์žก `error`, `MAM_ONBOARD_ACK_S` ๊ฐ€ ๋Œ€๊ธฐ. create SKILL.md ์™€ ๋งž์Œ. + +## (5) ๊ธฐ์กด ํ…Œ์ŠคํŠธ + +G9 ๋Š” AST ์—์„œ docstring ๋…ธ๋“œ๋งŒ ๋นผ๊ณ  ๋‚˜๋จธ์ง€ ์ฝ”๋“œ๋ฅผ unparse (`test_issue5_phase2a_loop.py:710-729`). `test_g9b` ๊ฐ€ ํ˜ธ์ถœยท๊ฒฝ๋กœ ๋ฌธ์ž์—ด์€ ์—ฌ์ „ํžˆ ์žก๊ณ  ์ฃผ์„/๋…์ŠคํŠธ๋ง์€ ๋†“์Œ์„ ์ž ๊ธˆ. ์˜๋„ ์•ฝํ™” ์•„๋‹˜. issue14 ๋Š” started ์ฆ๊ฑฐ ๊ณ„์•ฝ์— ๋งž์ถฐ ์กฐ์ •. + +## (6) ์ƒˆ ํ…Œ์ŠคํŠธ + +`test_issue23_24_startup_path.py`ยท`test_issue24_resume_dialogs.py` ๋Š” mock herdr / tmp. ์‹ค์ œ herdrยทํ™ˆ ๋ฏธ์ ‘์ด‰. ๊ฒ€์ฆ ๊ฒฝ๋กœ์—์„œ `AGENT_PYTHON_BIN` ์œผ๋กœ **177 passed in 461.74s** (์œ„ ๋‘ ํŒŒ์ผ + issue5 phase2a + issue14). + +## (7)โ€“(8) ๋ฌธ์„œยท๋ฝ์Šคํ… + +create SKILL: `--no-auto-trust`, exit 43, `MAM_ONBOARD_ACK_S`. resume SKILL: `--no-auto-trust` ์™€ ๊ฐ™์€ ํ•ธ๋“ค๋Ÿฌ. **๋น„์ฐจ๋‹จ**: `MESSAGING.md` ์™€ delegate-job SKILL.md ๋ณธ๋ฌธ์— `dispatch_fingerprint` ๋‹จ์–ด๊ฐ€ ์—†์Œ. ํ•„๋“œ๋Š” `job_subscriber.py` ์ฃผ์„๊ณผ ๊ฐ์‚ฌ ๋ ˆ์ฝ”๋“œ์— ์žˆ๋‹ค. ๋ฝ์Šคํ…์€ 4.7.1; 4.7.2 ๋Š” ์˜ค์ผ€์ŠคํŠธ๋ ˆ์ดํ„ฐ ์˜ˆ์ •. ๋ณ‘ํ•ฉ ์ฐจ๋‹จ ์•„๋‹˜. + +## ์ข…ํ•ฉ + +#23 ๊ต์ฐจ ์„œ๋ฒ„ ์Šค์ฝ”ํ”„์™€ #24 ์—์ด์ „ํŠธ ๋ฌด๊ด€ ๋‹ค์ด์–ผ๋กœ๊ทธ, ์‹ค์ œ `started` ๋กœ๋งŒ ์˜จ๋ณด๋”ฉ์„ ํŒ์ •ํ•˜๋Š” ๊ณ„์•ฝ์ด ์ฝ”๋“œยทํ…Œ์ŠคํŠธ์™€ ๋งž๋‹ค. ์„ค๊ณ„ ์žฌ์ž‘์—… ๋ถˆํ•„์š”. + +[VERDICT: PASS] diff --git a/.agents/reports/reviewer-grok-01/report-8352b00e.identity.json b/.agents/reports/reviewer-grok-01/report-8352b00e.identity.json new file mode 100644 index 0000000..9208c6a --- /dev/null +++ b/.agents/reports/reviewer-grok-01/report-8352b00e.identity.json @@ -0,0 +1,14 @@ +{ + "loop_id": "loop-20260923T220355Z-76dd1d", + "base_sha": "f4ba205c9f755d817e2206c7a9cb390a3e61fd2f", + "head_sha": "05b0ea4762de3fad013e8986d45db21c4e6aa465", + "tree_sha": "8f5e8b0402017d2cc809364e60400c4c25b922f3", + "review_commit": "05b0ea4762de3fad013e8986d45db21c4e6aa465", + "reviewer": "reviewer-grok-01", + "job_id": "8352b00e", + "verdict": "PASS", + "transport": "herdr", + "evidence": "review-tree", + "isolation": "worktree", + "verified_at": "2026-09-23T23:35:19Z" +} \ No newline at end of file diff --git a/.agents/reports/reviewer-grok-01/report-8352b00e.md b/.agents/reports/reviewer-grok-01/report-8352b00e.md new file mode 100644 index 0000000..9c41950 --- /dev/null +++ b/.agents/reports/reviewer-grok-01/report-8352b00e.md @@ -0,0 +1,39 @@ +# ๐Ÿ” Review Report โ€” Job 8352b00e (PR #35 ๋ธํƒ€ `f4ba205...05b0ea4`) + +- **Reviewer**: `reviewer-grok-01` ยท 2026-09-24 +- **๊ฒ€์ฆ ๊ฒฝ๋กœ**: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20260923T220355Z-76dd1d/1` +- **identity**: head `05b0ea4` tree `8f5e8b0` (์ผ์น˜). 1ํšŒ์ฐจ grok PASS @ `f4ba205` ๋ฅผ ๋’ค์ง‘์ง€ ์•Š์Œ. +- **๋ณธ ์ฒดํฌ์•„์›ƒ**: `main` `1e78dae` ์œ ์ง€. + +`git diff --check f4ba205...HEAD` ํด๋ฆฐ. ์ปค๋ฐ‹ 1๊ฐœ. + +## (1) ํƒ์นจ ๋‘ ๊ฑด โ€” ๋‹ซํž˜ + +**P1.** `create_session.sh:72` ์™€ `resume_session.sh:33` ์ด `AUTO_TRUST="${MAM_AUTO_TRUST:-1}"` ํ›„ ํ”Œ๋ž˜๊ทธ case ๊ฐ€ ๋ฎ์–ด์“ฐ๊ณ , ๊ทธ ๋‹ค์Œ `export MAM_AUTO_TRUST`. ํ™˜๊ฒฝ๋งŒ `0` ์ด๋ฉด ํ•ธ๋“ค๋Ÿฌ์— `0`. `--auto-trust` / `--no-auto-trust` ๊ฐ€ ์ด๊น€. R3bยทC3 ๊ฐ 3์‚ฌ๋ก€ ํ†ต๊ณผ. + +**P2.** `_mam_dialog_pick` (`lib.sh:2954-2958`) ์ด ์ฟจ๋‹ค์šด ์ค‘์ด๋ฉด `sys.exit(1)` ๋Œ€์‹  `continue`. ๊ฐ™์€ ํ™”๋ฉด์˜ ๋‹ค์Œ ๋‹ค์ด์–ผ๋กœ๊ทธ๋ฅผ ๋ณธ๋‹ค. + +- S6c: `--no-auto-trust` + ์‹ ๋ขฐ ์ฐฝ๊ณผ ์—…๋ฐ์ดํŠธ ์•ˆ๋‚ด๊ฐ€ ๊ฐ™์ด ์žˆ์œผ๋ฉด ํ‚ค๋Š” `2` ํ•œ ๋ฒˆ, stderr ์— no-auto-trust. +- S5f: ์ž๋™ ์‹ ๋ขฐ๋ฉด ์ˆœ์„œ `Enter` ๋‹ค์Œ `2`, ๊ฐ 1ํšŒ. + +์ž„์‹œ ์‚ฌ๋ณธ์—์„œ `continue` ๋ฅผ `sys.exit(1)` ๋กœ ๋˜๋Œ๋ฆฌ๋ฉด S6cยทS5f **2 failed** (์•ˆ๋‚ด๋Š” ์•ˆ ๋‚˜๊ฐ). ์ •์ • ํŠธ๋ฆฌ์—์„œ๋Š” ํ†ต๊ณผ. + +**๋ถ€์ž‘์šฉ.** S5(์ˆ˜๋ฝ๋œ ์ฐฝ์€ ์ฟจ๋‹ค์šด ์•ˆ ์žฌ์ „์†ก)ยทS5b(๋งŒ๋ฃŒ ํ›„ 1ํšŒ) ๊ทธ๋Œ€๋กœ ํ†ต๊ณผ. ํ•œ ๋‹ค์ด์–ผ๋กœ๊ทธ๋งŒ ์žˆ์œผ๋ฉด ๋ฃจํ”„ ๋์ด ์—ฌ์ „ํžˆ `sys.exit(1)` ์ด๋ผ โ€œํ•  ์ผ ์—†์Œโ€. + +## (2) `0`/`1` ์™ธ ๊ฐ’ + +ํ•ธ๋“ค๋Ÿฌ๋Š” `MAM_DLG_TRUST != "0"` (`lib.sh` pick ์˜ `auto_trust`). ๋ฆฌํ„ฐ๋Ÿด `"0"` ๋งŒ ๋”. `"false"`/`"no"`/`"00"` ์€ ์ผœ์ง. ๋นˆ ํ™˜๊ฒฝ์€ `${MAM_AUTO_TRUST:-1}` ์ด `1`. ๋ฌธ์„œ๊ฐ€ ์•ฝ์†ํ•œ ํ˜•์‹์€ `MAM_AUTO_TRUST=0` ์ด๋ผ ์ผ์น˜. create SKILL.md ์— ํ™˜๊ฒฝ ํ˜•์‹๊ณผ โ€œ๋ช…์‹œ ํ”Œ๋ž˜๊ทธ๊ฐ€ ์ด๊น€โ€์ด ์žˆ์Œ. + +## (3) 1ํšŒ์ฐจยท๋ฝ์Šคํ… + +๊ต์ฐจ ์„œ๋ฒ„ ์Šค์ฝ”ํ”„, started-only ์˜จ๋ณด๋”ฉ, exit 43 ์€ ์ด ์ปค๋ฐ‹์ด ๊ฑด๋“œ๋ฆฌ์ง€ ์•Š์Œ. 4.7.2 bump ๋Š” ์˜ค์ผ€์ŠคํŠธ๋ ˆ์ดํ„ฐ ์˜ˆ์ •. ๋ณ‘ํ•ฉ ์ฐจ๋‹จ ์•„๋‹˜. + +## ํ…Œ์ŠคํŠธ (๊ฒ€์ฆ ๊ฒฝ๋กœ) + +S5, S5b, S5f, S6, S6c, R3bร—3, C3ร—3: **11 passed in 152.03s**. + +## ์ข…ํ•ฉ + +๋‘ ํƒ์นจ์ด ์ฝ”๋“œยทํšŒ๊ท€์™€ ๋งž๋‹ค. ์ฟจ๋‹ค์šด์€ ๊ฐ™์€ ๋‹ค์ด์–ผ๋กœ๊ทธ์˜ ์žฌ์ „์†ก๋งŒ ๋ง‰๊ณ , ๋‹ค์Œ ๋‹ค์ด์–ผ๋กœ๊ทธ๋Š” ๋ง‰์ง€ ์•Š๋Š”๋‹ค. ์„ค๊ณ„ ์žฌ์ž‘์—… ๋ถˆํ•„์š”. + +[VERDICT: PASS] diff --git a/.agents/skills/lib.sh b/.agents/skills/lib.sh index 1b0283a..c595e21 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.7.1" +MAM_VERSION="4.7.2" export MAM_VERSION AGENT_SESSIONS_YAML="${AGENT_SESSIONS_YAML:-$WORKSPACE_ROOT/.mam/agent-sessions.yaml}" @@ -71,7 +71,6 @@ done _MAM_MODAL_TOKENS='Do you trust the files|Yes, proceed|No, exit|Allow this|browser to authenticate|Resuming the full session|Resume from summary|Try the new fullscreen renderer\?' _MAM_HINT_TOKENS='Use arrow keys|Esc to cancel|Press Enter to continue' _MAM_DIALOG_TOKENS="${_MAM_MODAL_TOKENS}|${_MAM_HINT_TOKENS}" -_MAM_READY_TOKENS_CLAUDE='Anthropic|Assistant|Chat|Welcome|Claude Code|Opus|Sonnet|Haiku' # Workspace-relative defaults with environment overrides (Phase Z) HOME_DIR="${HOME_DIR:-$HOME}" @@ -1373,9 +1372,93 @@ EOF fi } +# _herdr_call_is_cross_server +# +# ISSUE-23: a herdr pane exports HERDR_WORKSPACE_ID / HERDR_PANE_ID / +# HERDR_TAB_ID, and those ids only mean something on the server that issued +# them. When a MAM script runs inside one server's pane (the orchestrator's) +# but targets another (the isolated MAM server named by HERDR_SESSION_NAME), +# the inherited ids reach the scope filters (_herdr_ws_scope, +# _herdr_agent_get_scoped, _resolve_herdr_pane_id) as a foreign workspace, and +# their fail-closed miss (lib.sh explicit-env miss, ISSUE-3 / F-2b) reports a +# live session as absent: has-session, kill-session, agent prompt and the +# delegate-job preflight all answer "no such session". +# +# _herdr_caller_server โ€” the name of the server whose pane this process runs +# in; empty when it is not in a pane at all. +# +# HERDR_SESSION is used when present, but herdr 0.8.2 does not export it to a +# pane of the *default* server (the orchestrator's usual home), which is +# exactly the pane the #23 leak comes from - the live trial of this branch +# found every call judged "not crossing" for that reason. HERDR_SOCKET_PATH is +# exported to every pane, and its layout names the server: +# +# $HOME/.config/herdr/herdr.sock -> default +# $HOME/.config/herdr/sessions//herdr.sock -> +# +# (the same convention lib.sh's shim already relies on to find a named +# server's socket). Matched by shape, not by $HOME, so a redirected HOME does +# not break it. An unrecognised layout yields "" - treated as "not in a pane", +# which keeps today's behaviour rather than guessing. +_herdr_caller_server() { + if [ -n "${HERDR_SESSION:-}" ]; then + printf '%s\n' "$HERDR_SESSION" + return 0 + fi + local sock="${HERDR_SOCKET_PATH:-}" + case "$sock" in + */.config/herdr/herdr.sock) + printf 'default\n' + ;; + */.config/herdr/sessions/*/herdr.sock) + local d="${sock%/herdr.sock}" + printf '%s\n' "${d##*/}" + ;; + *) + printf '\n' + ;; + esac + return 0 +} + +# True only for a call aimed at a *named* server other than the caller's own: +# +# * target unset or `default` (the lib.sh:97 fallback) is the shim's sentinel +# for "pass no session flag", so herdr answers on the ambient server, which +# for a caller inside a pane is that same caller's server - not a crossing; +# * no caller server (_herdr_caller_server empty) means the caller is not in +# a pane at all, so an exported HERDR_WORKSPACE_ID is a deliberate scope, +# not a leak (H-18/H-21/H-23); +# * caller == target is the same server. +# +# A caller in the default server's pane targeting a named server IS a crossing +# (the reported #23 scenario): `default` is a real server name on the caller's +# side, only as a *target* is it the "ambient" sentinel. +_herdr_call_is_cross_server() { + local caller target="${HERDR_SESSION_NAME:-}" + case "$target" in + ""|default) return 1 ;; + esac + caller="$(_herdr_caller_server)" + if [ -n "$caller" ] && [ "$caller" != "$target" ]; then + return 0 + fi + return 1 +} + mam_herdr() { _init_herdr_isolation - "$WORKSPACE_ROOT/.mam/shim/herdr" "$@" + # Built as an argv array and decided inside `if`: this runs under + # `set -euo pipefail` in most callers, where a bare failing test would abort + # the whole script instead of this one call. + local -a _scope_strip=() + if _herdr_call_is_cross_server; then + _scope_strip=(env -u HERDR_WORKSPACE_ID -u HERDR_PANE_ID -u HERDR_TAB_ID) + if [ "${MAM_SHIM_DEBUG:-0}" = "1" ]; then + echo "mam_herdr: dropping caller pane scope ids (caller server '$(_herdr_caller_server)' != target '${HERDR_SESSION_NAME:-}')" >&2 + fi + fi + ${_scope_strip[@]+"${_scope_strip[@]}"} "$WORKSPACE_ROOT/.mam/shim/herdr" "$@" } _herdr() { @@ -2090,6 +2173,145 @@ delegate_publish_event() { _delegation_py publish --job "$job_id" --event "$event" --detail "$detail" ${extra[@]+"${extra[@]}"} || true } +# delegate_publish_cmd [dispatch_token] [attempt_id] +# The publish command an agent is told to run, shell-quoted, with absolute +# script/registry/audit-log paths (Issue #21: an agent whose cwd is a worktree +# runs it as written). create_session's onboarding uses this; the delegate-job +# wrapper still builds its own (_pub_cmd), and S7l pins the two equal +# argument-for-argument, so both hand the agent the same shape: +# `--expect-dispatch-token T --attempt-id A` make publish_event write +# data.dispatch_token / data.attempt_id into the agent's own events, which is +# the only way a `started` can name the dispatch it answers (the trial-2 +# finding: create_session's hand-rolled command omitted them, the agent's +# `started` carried no token, and the token-checking waiter could never be +# satisfied by a real agent). publish_event also CAS-checks that token against +# the record, so a stale dispatch's event is refused at the source. +delegate_publish_cmd() { + local job_id="$1" tok="${2:-}" att="${3:-}" + local py_bin pub_script pub_reg pub_logs + py_bin="$(_delegate_py_bin)" + pub_script="$WORKSPACE_ROOT/.agents/skills/multi-agent-mux-delegate-job/scripts/publish_event.py" + [ -f "$pub_script" ] || pub_script="$(_delegate_script publish_event.py)" + pub_script="$(cd -P "$(dirname "$pub_script")" && pwd -P)/$(basename "$pub_script")" + pub_reg="${MAM_JOBS_DIR:-${WORKSPACE_ROOT:+$WORKSPACE_ROOT/.mam/jobs}}" + pub_reg="${pub_reg:-.mam/jobs}" + mkdir -p "$pub_reg" && pub_reg="$(cd -P "$pub_reg" && pwd -P)" + pub_logs="$(PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" "$py_bin" -c 'import os; from lib_py.delegation.mqtt_common import get_logs_dir; print(os.path.abspath(get_logs_dir()))' 2>/dev/null || true)" + local -a argv=("$py_bin" "$pub_script" --registry-dir "$pub_reg" --job "$job_id") + [ -n "$tok" ] && argv+=(--expect-dispatch-token "$tok") + [ -n "$att" ] && argv+=(--attempt-id "$att") + local out + out="$( + if [ -n "$pub_logs" ]; then printf 'DELEGATE_JOB_LOGS_DIR=%q ' "$pub_logs"; fi + printf '%q ' "${argv[@]}" + )" + printf '%s' "${out% }" +} + +# delegate_job_status โ€” the record's status, empty if unreadable. +delegate_job_status() { + local reg_dir="${MAM_JOBS_DIR:-${WORKSPACE_ROOT:-$PWD}/.mam/jobs}" + REC="$reg_dir/$1.json" python3 -c ' +import json, os +try: + print(json.load(open(os.environ["REC"])).get("status", "") or "") +except Exception: + print("") +' 2>/dev/null || true +} + +# delegate_started_recorded [dispatch_token] [logs_dir] +# THE definition of "this dispatch's started is on disk", shared by +# delegate_wait_started (create_session's onboarding ack) and the delegate-job +# wrapper's _wait_started. Two sources, one rule: +# +# * /.events.log - the local publisher's own line, whose +# data.dispatch_token must equal when one is given (a loop +# job keeps one events.log across attempts, and a stale attempt's started +# must not vouch for this one - review R1); +# * //events.ndjson - the subscriber's audit record of a +# `started` it received, the only evidence a REMOTE agent (no shared disk) +# can leave. The subscriber verifies the HMAC and drops any event whose +# data.dispatch_token differs from the job's expected token before it +# writes this record (dispatch_verified), and writes dispatch_fingerprint +# = SHA-256 of that token. The file is appended to across a job's +# attempts, so the flag alone would let an earlier attempt's record +# acknowledge a later dispatch (review round 4): with +# given, a record counts only if it is dispatch_verified AND its +# fingerprint equals SHA-256(). A record with no +# fingerprint (written before this field existed) names no dispatch and +# is rejected on that path - fail closed. Without a token, any received +# started counts, as before. +# +# Never the registry status: a record leaves `pending` when another writer +# closes it or another dispatch claims it. Lines are parsed as JSON, never +# pattern-matched. rc 0 = recorded, 1 = not (yet). +delegate_started_recorded() { + local job_id="$1" tok="${2:-}" logs_dir="${3:-}" + local reg_dir="${MAM_JOBS_DIR:-${WORKSPACE_ROOT:-$PWD}/.mam/jobs}" + [ -n "$logs_dir" ] || logs_dir="${DELEGATE_JOB_LOGS_DIR:-${WORKSPACE_ROOT:-$PWD}/.mam/delegate_job_logs}" + local ev_log="$reg_dir/$job_id.events.log" + local audit="$logs_dir/$job_id/events.ndjson" + [ -f "$ev_log" ] || [ -f "$audit" ] || return 1 + EV_LOG="$ev_log" EV_AUDIT="$audit" EV_TOK="$tok" python3 -c ' +import hashlib, json, os, sys +tok = os.environ.get("EV_TOK", "") +fp = hashlib.sha256(tok.encode("utf-8")).hexdigest() if tok else "" + +def lines(path): + if not os.path.isfile(path): + return + with open(path, encoding="utf-8", errors="replace") as f: + for line in f: + try: + e = json.loads(line) + except Exception: + continue + if isinstance(e, dict): + yield e + +for e in lines(os.environ["EV_LOG"]): + if e.get("event") != "started": + continue + data = e.get("data") if isinstance(e.get("data"), dict) else {} + if not tok or data.get("dispatch_token") == tok: + sys.exit(0) +for e in lines(os.environ["EV_AUDIT"]): + if e.get("event") != "received" or e.get("source_event") != "started": + continue + if not tok: + sys.exit(0) + if e.get("dispatch_verified") is True and e.get("dispatch_fingerprint") == fp: + sys.exit(0) +sys.exit(1) +' 2>/dev/null +} + +# delegate_wait_started [ack_timeout_s] [dispatch_token] +# +# ISSUE-23 / ISSUE-18: delivery is confirmed by the protocol, not by the +# screen. The evidence is delegate_started_recorded (above); the registry's +# status is deliberately NOT evidence - a record leaves `pending` when another +# writer closes it or another dispatch claims it (review R1). The local +# publisher writes the log line before it syncs the status, so nothing that +# really started is missed. Returns 0 on the evidence, 1 if it did not appear +# within T. Never re-injects: the single-injection contract is fail-closed. +delegate_wait_started() { + local job_id="$1" ack_s="${2:-180}" tok="${3:-}" + [ -n "$job_id" ] || return 1 + local deadline + deadline=$(( $(date +%s) + ack_s )) + while :; do + if delegate_started_recorded "$job_id" "$tok"; then + return 0 + fi + if [ "$(date +%s)" -ge "$deadline" ]; then + return 1 + fi + sleep 1 + done +} + # ensure_subscriber_daemon [workdir] # Start the monitor subscriber for this workspace unless one is already running. # Prints the PID(s) on stdout, exactly as start_watchdog always did. @@ -2163,7 +2385,7 @@ wait_for_tui_ready() { # handle it and do NOT declare ready on this iteration. tail_lines=$(printf '%s\n' "$content" | grep -v '^[[:space:]]*$' | tail -n 20) if printf '%s\n' "$tail_lines" | grep -E -q "$modal_pat" 2>/dev/null; then - handle_startup_dialogs "$sess" 1 || true + handle_startup_dialogs "$sess" 1 "$agent" || true sleep 1 continue fi @@ -2213,7 +2435,7 @@ wait_for_tui_ready() { fi # 4. Startup dialog & hint resolution if not ready - handle_startup_dialogs "$sess" 1 || true + handle_startup_dialogs "$sess" 1 "$agent" || true sleep 1 done echo "โš ๏ธ TUI readiness check timed out for '$sess'." >&2 @@ -2661,29 +2883,130 @@ send_keys_safe() { return 4 } -# handle_startup_dialogs [timeout_sec=20] -# Post-start/resume dialog policy for claude: accept the trust / bypass -# dialogs ONLY when their signature is positively on screen; return as soon -# as the TUI banner is ready. Replaces the blind Enter/Down/Enter sequence. +# _mam_load_dialog_facts +# Populate MAM_STARTUP_DIALOGS for , once per agent per shell, and the +# ready tokens (MAM_STRONG_READY_TOKENS / MAM_READY_TOKENS) only when the caller +# has not set them. `facts` is an eval contract (F8); evaluating all of it here +# would clobber tokens a caller deliberately set, so only these are taken, and +# the tokens only fill a gap. Without them the handler could never recognise a +# ready screen when called on its own - resume_session.sh does exactly that, +# with no wait_for_tui_ready before it - and idled through its whole timeout on +# every resume (review B-1). An agent the adapters do not know keeps an empty +# list and no tokens. +_mam_load_dialog_facts() { + local agent="$1" _f="" _d="" _s="" _r="" + if [ "${_MAM_DIALOGS_AGENT:-}" = "$agent" ]; then + return 0 + fi + _f="$("$(_delegate_py_bin)" -m lib_py.agents facts "$agent" 2>/dev/null)" || _f="" + _d="$(printf '%s\n' "$_f" | sed -n 's/^MAM_STARTUP_DIALOGS=//p')" + _s="$(printf '%s\n' "$_f" | sed -n 's/^MAM_STRONG_READY_TOKENS=//p')" + _r="$(printf '%s\n' "$_f" | sed -n 's/^MAM_READY_TOKENS=//p')" + if [ -n "$_d" ]; then + eval "MAM_STARTUP_DIALOGS=$_d" + else + MAM_STARTUP_DIALOGS='[]' + fi + if [ -z "${MAM_STRONG_READY_TOKENS:-}" ] && [ -n "$_s" ]; then + eval "MAM_STRONG_READY_TOKENS=$_s" + fi + if [ -z "${MAM_READY_TOKENS:-}" ] && [ -n "$_r" ]; then + eval "MAM_READY_TOKENS=$_r" + fi + _MAM_DIALOGS_AGENT="$agent" + return 0 +} + +# _mam_dialog_pick โ€” first actionable dialog on this frame. +# stdout: "send|| ..." or "skip||", nothing when there is +# none to act on. rc 1 means "nothing to do" (matched-but-cooling counts). +_mam_dialog_pick() { + MAM_DLG_PANE="$1" MAM_DLG_JSON="$2" MAM_DLG_TRUST="$3" \ + MAM_DLG_STATE="$4" MAM_DLG_NOW="$5" MAM_DLG_COOLDOWN="$6" \ + python3 -c ' +import os, re, sys, json +pane = os.environ.get("MAM_DLG_PANE", "") +try: + dialogs = json.loads(os.environ.get("MAM_DLG_JSON") or "[]") +except Exception: + dialogs = [] +auto_trust = os.environ.get("MAM_DLG_TRUST", "1") != "0" +try: + now = int(os.environ.get("MAM_DLG_NOW") or 0) + cooldown = int(os.environ.get("MAM_DLG_COOLDOWN") or 5) +except ValueError: + sys.exit(1) +state = {} +for tok in (os.environ.get("MAM_DLG_STATE") or "").split(): + k, _, v = tok.partition(":") + try: + state[int(k)] = int(v) + except ValueError: + pass +for idx, entry in enumerate(dialogs): + try: + pattern, keys, kind = entry[0], entry[1], entry[2] + if not re.search(pattern, pane): + continue + except Exception: + continue + last = state.get(idx) + if last is not None and 0 <= now - last < cooldown: + # rate-limited: look at the other dialogs on this screen instead of + # giving up (merge review, codex probe: a skipped trust prompt or a + # freshly answered one used to hide the notice below it) + continue + if kind == "trust" and not auto_trust: + print("skip|%d|" % idx) + sys.exit(0) + print("send|%d|%s" % (idx, " ".join(keys))) + sys.exit(0) +sys.exit(1) +' +} + +# handle_startup_dialogs [timeout_sec=20] [agent=claude] +# Answer the blocking modals an agent shows before its TUI is usable. The +# signatures and accept keys come from the agent adapter (MAM_STARTUP_DIALOGS), +# so this is agent-agnostic - issue #24: it used to be a claude-only ladder +# called only for claude, and agy/grok/codex sat on their trust prompt until +# the readiness wait timed out and onboarding was skipped. +# +# `--source visible` keeps showing an accepted dialog for a while (#24 +# comment), so a signature still on screen is NOT evidence that the keys were +# missed: re-sending for the same signature is gated by MAM_DIALOG_RESEND_S +# (default 5s) and acceptance is judged only by the ready tokens. +# +# MAM_AUTO_TRUST=0 leaves `trust` dialogs unanswered (warned once per +# cooldown); `notice` dialogs are always answered. handle_startup_dialogs() { - local sess="$1" timeout="${2:-20}" waited=0 pane + local sess="$1" timeout="${2:-20}" agent="${3:-claude}" + local waited=0 pane pick action rest idx keys key now + local cooldown="${MAM_DIALOG_RESEND_S:-5}" + local auto_trust="${MAM_AUTO_TRUST:-1}" + _mam_load_dialog_facts "$agent" + local dialogs="${MAM_STARTUP_DIALOGS:-[]}" + local ready_re="${MAM_STRONG_READY_TOKENS:-${MAM_READY_TOKENS:-}}" while [ "$waited" -lt "$timeout" ]; do pane=$(_pane_tail "$sess" 20) - if printf '%s\n' "$pane" | grep -Eq 'Do you trust the files|Yes, I trust this folder|Quick safety check'; then - _sks_herdr send-keys -t "$sess" Enter - elif printf '%s\n' "$pane" | grep -q 'Yes, try it'; then - # Fullscreen-renderer upsell modal (not the idle /tui tip). Enter would - # accept and restart the session without permission flags โ€” reject. - _sks_herdr send-keys -t "$sess" Escape - elif printf '%s\n' "$pane" | grep -q 'Yes, proceed'; then - _sks_herdr send-keys -t "$sess" Down - sleep 0.3 - _sks_herdr send-keys -t "$sess" Enter - elif printf '%s\n' "$pane" | grep -q 'Resuming the full session'; then - _sks_herdr send-keys -t "$sess" Enter - elif printf '%s\n' "$pane" | grep -q 'Press Enter to continue'; then - _sks_herdr send-keys -t "$sess" Enter - elif printf '%s\n' "$pane" | grep -Eq "${_MAM_READY_TOKENS_CLAUDE:-Anthropic|Assistant|Chat|Welcome}"; then + now=$(date +%s) + pick="" + pick=$(_mam_dialog_pick "$pane" "$dialogs" "$auto_trust" "${_MAM_DIALOG_SENT:-}" "$now" "$cooldown" 2>/dev/null || true) + if [ -n "$pick" ]; then + action="${pick%%|*}" + rest="${pick#*|}" + idx="${rest%%|*}" + keys="${rest#*|}" + if [ "$action" = "send" ]; then + for key in $keys; do + _sks_herdr send-keys -t "$sess" "$key" || true + sleep 0.3 + done + else + echo "handle_startup_dialogs: a trust dialog is open on '$sess' and --no-auto-trust was given; accept it manually" >&2 + fi + _MAM_DIALOG_SENT="${_MAM_DIALOG_SENT:-} $idx:$now" + elif [ -n "$ready_re" ] && printf '%s\n' "$pane" | grep -Eq "$ready_re" 2>/dev/null; then return 0 fi sleep 1 diff --git a/.agents/skills/lib_py/agents/__main__.py b/.agents/skills/lib_py/agents/__main__.py index d437b75..0796dfb 100644 --- a/.agents/skills/lib_py/agents/__main__.py +++ b/.agents/skills/lib_py/agents/__main__.py @@ -63,6 +63,7 @@ def main(): print(f"MAM_STRONG_READY_TOKENS={q(adapter.strong_ready_tokens)}") print(f"MAM_WEAK_READY_TOKENS={q(adapter.weak_ready_tokens)}") print(f"MAM_MODAL_TOKENS={q(adapter.modal_tokens or '')}") + print(f"MAM_STARTUP_DIALOGS={q(json.dumps(adapter.startup_dialogs))}") print(f"MAM_EXIT_KEY={q(adapter.exit_key)}") print(f"MAM_DELEGATE_AGENT_KEY={q(adapter.delegate_agent_key)}") diff --git a/.agents/skills/lib_py/agents/adapters/agy.py b/.agents/skills/lib_py/agents/adapters/agy.py index ba20214..948bc07 100644 --- a/.agents/skills/lib_py/agents/adapters/agy.py +++ b/.agents/skills/lib_py/agents/adapters/agy.py @@ -23,6 +23,15 @@ class AgyAgentAdapter(BaseAgentAdapter): def delegate_agent_key(self) -> str: return 'antigravity-cli' + @property + def startup_dialogs(self) -> list: + # #24: "Do you trust the contents of this project?" with + # "> Yes, I trust this folder" preselected - Enter accepts. + return [ + ('Do you trust the contents of this project|Yes, I trust this folder', + ['Enter'], 'trust'), + ] + @property def identity_cache_fields(self) -> tuple: return ('conversation_id', 'conversation_db', 'conversation_brain_dir') diff --git a/.agents/skills/lib_py/agents/adapters/claude.py b/.agents/skills/lib_py/agents/adapters/claude.py index 189334d..bf60fc7 100644 --- a/.agents/skills/lib_py/agents/adapters/claude.py +++ b/.agents/skills/lib_py/agents/adapters/claude.py @@ -44,6 +44,19 @@ class ClaudeAgentAdapter(BaseAgentAdapter): def modal_tokens(self) -> Optional[str]: return 'Try the new fullscreen renderer\\?' + @property + def startup_dialogs(self) -> list: + # The ladder handle_startup_dialogs used to hardcode, unchanged. + return [ + ('Do you trust the files|Yes, I trust this folder|Quick safety check', ['Enter'], 'trust'), + # Fullscreen-renderer upsell, not the idle /tui tip: Enter would + # accept and restart the session without the permission flags. + ('Yes, try it', ['Escape'], 'notice'), + ('Yes, proceed', ['Down', 'Enter'], 'trust'), + ('Resuming the full session', ['Enter'], 'notice'), + ('Press Enter to continue', ['Enter'], 'notice'), + ] + def artifact_path(self, uuid: str, ctx: DiscoveryContext) -> str: return f"{ctx.claude_dir}/{ctx.ws_key}/{uuid}.jsonl" diff --git a/.agents/skills/lib_py/agents/adapters/codex.py b/.agents/skills/lib_py/agents/adapters/codex.py index 1e7a5c7..8b688fc 100644 --- a/.agents/skills/lib_py/agents/adapters/codex.py +++ b/.agents/skills/lib_py/agents/adapters/codex.py @@ -24,6 +24,20 @@ class CodexAgentAdapter(BaseAgentAdapter): def delegate_agent_key(self) -> str: return 'codex-cli' + @property + def startup_dialogs(self) -> list: + # #24: the trust prompt lists "1. Yes, continue" preselected, so Enter + # accepts. Trust here also loads this project's local config, hooks and + # exec policy, which is why --no-auto-trust exists. + # + # The 0.155.1 update notice (observed 2026-09-22) blocks the session the + # same way; "2" is Skip, the choice that changes nothing. + return [ + ('Do you trust the contents of this directory|1\\. Yes, continue', + ['Enter'], 'trust'), + ('1\\. Update now|Skip until next version', ['2'], 'notice'), + ] + @property def identity_cache_fields(self) -> tuple: return ('session_id',) diff --git a/.agents/skills/lib_py/agents/adapters/grok.py b/.agents/skills/lib_py/agents/adapters/grok.py index 3f27860..055a4ab 100644 --- a/.agents/skills/lib_py/agents/adapters/grok.py +++ b/.agents/skills/lib_py/agents/adapters/grok.py @@ -26,6 +26,13 @@ class GrokAgentAdapter(BaseAgentAdapter): def delegate_agent_key(self) -> str: return 'grok-build' + @property + def startup_dialogs(self) -> list: + # #24: "Do you trust the contents of this directory?" answered with y. + return [ + ('Do you trust the contents of this directory', ['y'], 'trust'), + ] + @property def identity_cache_fields(self) -> tuple: return ('session_id', 'session_jsonl') diff --git a/.agents/skills/lib_py/agents/base.py b/.agents/skills/lib_py/agents/base.py index bd2221a..75d47ef 100644 --- a/.agents/skills/lib_py/agents/base.py +++ b/.agents/skills/lib_py/agents/base.py @@ -156,6 +156,21 @@ class BaseAgentAdapter: def modal_tokens(self) -> Optional[str]: return None + @property + def startup_dialogs(self) -> List[tuple]: + """Blocking modals this CLI can show before its TUI is usable, most + specific first: (pattern, keys, kind). + + `pattern` is an extended-regex signature matched against the pane tail, + `keys` are herdr send-keys arguments sent in order, and `kind` is + 'trust' (a directory-trust prompt, gated by --auto-trust) or 'notice' + (anything else that blocks the start, always answered). + + Issue #24: without this the start-up trust prompts of agy, grok and + codex burn the whole readiness timeout and onboarding is skipped. + """ + return [] + @property def strong_ready_tokens(self) -> str: return self.ready_tokens diff --git a/.agents/skills/lib_py/delegation/job_subscriber.py b/.agents/skills/lib_py/delegation/job_subscriber.py index 86f028a..fa5eea9 100755 --- a/.agents/skills/lib_py/delegation/job_subscriber.py +++ b/.agents/skills/lib_py/delegation/job_subscriber.py @@ -24,6 +24,7 @@ Exit codes: from __future__ import annotations import argparse +import hashlib import json import logging import queue @@ -279,6 +280,19 @@ class _Watcher: # that survives defensive parsing is recorded here, including ones a # different host published. This is the external-observer record that # backstops the publisher's own "published" line if it never wrote one. + # Which dispatch this record vouches for. `dispatch_verified` says the + # token check above ran (the record had a dispatch_token and the + # event's data.dispatch_token matched it) after HMAC verification; on + # its own it names no dispatch, and this file is appended to across a + # job's attempts, so a reader waiting for a later dispatch would take + # an earlier attempt's record as its acknowledgement (review round 4). + # `dispatch_fingerprint` is SHA-256 of the token that was verified - + # the same identity the local path compares in events.log, minus the + # token itself, which the audit redactor masks by key name and which a + # 128-bit uuid4 nonce is not recoverable from. `attempt_id` is kept for + # readers; it is not the identity, since a token can be re-minted + # within an attempt. A reader that demands a token must match the + # fingerprint; a record without one proves nothing about identity. mqtt_common.append_event(jid, { "event": "received", "source_event": payload.get("event"), @@ -286,6 +300,11 @@ class _Watcher: "topic": msg.topic, "timestamp": payload.get("timestamp"), "detail": payload.get("detail", ""), + "dispatch_verified": expected_disp_tok is not None, + "dispatch_fingerprint": (hashlib.sha256(expected_disp_tok.encode("utf-8")).hexdigest() + if expected_disp_tok else None), + "attempt_id": (data.get("attempt_id") if isinstance(data, dict) and data.get("attempt_id") is not None + else self.attempts.get(jid)), }) self.events.put((msg.topic, payload)) diff --git a/.agents/skills/lib_py/loop/eager.py b/.agents/skills/lib_py/loop/eager.py index ba73b19..4c852c8 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.7.1" +FRAMEWORK_VERSION = "4.7.2" CORE_MODULES: Sequence[str] = ( # Standard library modules diff --git a/.agents/skills/lib_py/loop/watcher.py b/.agents/skills/lib_py/loop/watcher.py index 7980d62..f318f0e 100644 --- a/.agents/skills/lib_py/loop/watcher.py +++ b/.agents/skills/lib_py/loop/watcher.py @@ -19,6 +19,7 @@ from __future__ import annotations import json import logging +import os import subprocess from typing import Any, Dict, Optional, Sequence @@ -38,6 +39,57 @@ TRANSPORT_ERROR = "transport_error" BACKOFF_START_S = 1.0 BACKOFF_MAX_S = 16.0 +# ISSUE-23: ids a herdr pane exports about ITS OWN server. Passing them to a +# call aimed at a different server makes herdr scope the lookup to a workspace +# that does not exist there, and the target is reported as missing. The shell +# side drops them in mam_herdr; this is the one python path that runs herdr +# without going through it. +CALLER_SCOPE_VARS = ("HERDR_WORKSPACE_ID", "HERDR_PANE_ID", "HERDR_TAB_ID") + + +def caller_server(environ=None): + """The server whose pane this process runs in, "" when not in a pane. + + HERDR_SESSION when present; otherwise the shape of HERDR_SOCKET_PATH, which + herdr exports to every pane (HERDR_SESSION is absent in a default-server + pane on herdr 0.8.2 - the orchestrator's usual home, and where the #23 leak + comes from). Same convention as lib.sh's _herdr_caller_server: + + .../.config/herdr/herdr.sock -> "default" + .../.config/herdr/sessions//herdr.sock -> + + Matched by shape, not by $HOME. Anything else is "" (not in a pane). + """ + src = os.environ if environ is None else environ + sess = (src.get("HERDR_SESSION") or "").strip() + if sess: + return sess + sock = (src.get("HERDR_SOCKET_PATH") or "").strip() + parts = sock.split("/") + if len(parts) >= 4 and parts[-1] == "herdr.sock": + if parts[-3:-1] == [".config", "herdr"]: + return "default" + if len(parts) >= 5 and parts[-4:-2] == ["herdr", "sessions"] and parts[-5] == ".config": + return parts[-2] + return "" + + +def scoped_env(session=None, environ=None): + """The environment for a wait aimed at `session`. + + Unchanged unless this process runs inside a pane of another server: only + then are the caller's own workspace/pane/tab ids meaningless for the call + and dropped. No pane and same-server keep every value. + """ + src = os.environ if environ is None else environ + caller = caller_server(src) + target = (session or "").strip() + # `default` is the same sentinel build_argv reads: no --session flag, so + # herdr answers on the ambient server - the caller's own. + if not caller or not target or target == "default" or caller == target: + return dict(src) + return dict((k, v) for k, v in src.items() if k not in CALLER_SCOPE_VARS) + class WaitOutcome(object): __slots__ = ("kind", "agent", "error_code", "detail", "raw") @@ -92,7 +144,7 @@ def wait_once(target: str, until: Sequence[str] = DEFAULT_UNTIL, """One barrier call. `run` is injectable so tests never touch a real herdr.""" argv = build_argv(target, until, timeout_ms, session=session) try: - proc = run(argv, capture_output=True, text=True) + proc = run(argv, capture_output=True, text=True, env=scoped_env(session)) except Exception as exc: return WaitOutcome(TRANSPORT_ERROR, detail="herdr could not be executed: {}".format(exc)) diff --git a/.agents/skills/multi-agent-mux-create/SKILL.md b/.agents/skills/multi-agent-mux-create/SKILL.md index e16d8f5..427a343 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.7.1 +version: 4.7.2 author: godopu license: MIT platforms: [linux, macos] @@ -60,7 +60,8 @@ If any check fails โ†’ abort with a non-zero exit and report the reason (automat - examples: `landing-page-refer-landing-page-creator-claude`, `paper-pdf2md-creator-agy` - never re-derive this by hand โ€” source lib.sh and call the function - **wrapper script** (claude only): `~/.local/bin/-creator-claude` - - contents: herdr new-session with `claude` inside, auto-handles trust/bypass dialogs + - contents: herdr new-session with `claude` inside; trust/bypass dialogs are + handled by the skill for every agent (see `--no-auto-trust` below) - see `/agent_sessions.md` for the canonical wrapper template ## Herdr Session Isolation (๊ฒฉ๋ฆฌ ์„ธ์…˜) @@ -97,6 +98,39 @@ The bootstrapped server receives a **sanitized copy of the caller's environment* bash scripts/create_session.sh --workspace /path/to/project --agent claude --role developer --onboard ``` + **Delivery is judged by the agent's own `started` event** (#23), within the + job's `ack_timeout_sec` (default 180 s) โ€” not by what the pane looked like two + seconds after the keys were sent. The script no longer publishes `started` on + the agent's behalf, so a job that shows `started` really was picked up. A + send_keys rc 4 ("Enter not seen accepted", where a still-starting TUI lands) is + a warning; rc 1/2/3 still abort at once. No `started` within the ack timeout + marks the job `error` and warns, but **keeps the session**: the pane is up and + the row is valid, only the handshake never landed, and killing a live agent + over a slow onboarding would destroy more than it protects (the same rule + this script applies to a TUI that never signalled ready). Re-send with + `multi-agent-mux-delegate-job submit` once the agent is up; the prompt is + never re-injected automatically. `MAM_ONBOARD_ACK_S` overrides how long that + wait is โ€” distinct from the wrapper's `MAM_INJECT_ACK_GRACE_S`. + + **Start-up dialogs** are answered for every agent, not only claude (#24). The + blocking-modal signatures and their accept keys live in each agent adapter + (`lib_py/agents/adapters/*.py`, published as `MAM_STARTUP_DIALOGS`), and they + are answered while waiting for the TUI rather than after it. `--no-auto-trust` + (or `MAM_AUTO_TRUST=0` in the environment; an explicit flag wins over the + environment) leaves directory-trust dialogs for a human to accept and still answers other + blocking notices (for example codex's update prompt, where Skip changes + nothing). Trust matters most for **codex**: trusting a directory makes it load + that project's local config, hooks and exec policy, so `--no-auto-trust` is the + switch to reach for when the workspace is not yours. Re-sending is rate-limited + by `MAM_DIALOG_RESEND_S` (default 5 s) because a `--source visible` snapshot + keeps showing a dialog for a while after it was accepted. + + **Exit codes**: 42 = the TUI never became ready and the process is gone; + 43 = the rollback could not kill the herdr session and it is still running. + On 43 the message names the session and the manual cleanup command; the YAML + row keeps `status: running` with `last_visible_status: rollback-failed` and no + `delegate_job_id`, and the onboarding job is closed as `cancelled`. + Why use `--herdr-session`? - By default, all skills target `default` herdr session socket โ€” fine for single-workspace use. diff --git a/.agents/skills/multi-agent-mux-create/scripts/create_session.sh b/.agents/skills/multi-agent-mux-create/scripts/create_session.sh index c8f8d81..1494ce1 100755 --- a/.agents/skills/multi-agent-mux-create/scripts/create_session.sh +++ b/.agents/skills/multi-agent-mux-create/scripts/create_session.sh @@ -17,6 +17,8 @@ # 2 = invalid args # 3 = herdr session already exists (use multi-agent-mux-resume or delete first) # 4 = agent-sessions.yaml append failure +# 42 = agent TUI never became ready and the process is gone +# 43 = rollback could not kill the herdr session; it is still running (#23) set -euo pipefail _script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -43,6 +45,7 @@ Options: use --herdr-session for that. --submit-job PROMPT submit a job to multi-agent-mux-delegate-job registry with the given prompt --onboard automatically submit a project alignment/orientation job to the new agent + --no-auto-trust do not answer a start-up directory-trust dialog (default: answer it) --no-onboard disable automatic onboarding job submission --no-isolate legacy flag (no-op; all sessions use global configuration) --isolate legacy flag (no-op; all sessions use global configuration) @@ -60,6 +63,13 @@ HERDR_SERVER_OPT="" HERDR_WORKSPACE_OPT="" SUBMIT_JOB_PROMPT="" ONBOARD=1 +# Issue #24: answer a start-up directory-trust dialog for the --workspace the +# operator named. --no-auto-trust leaves those unanswered (notices are always +# answered); codex in particular loads project-local config, hooks and exec +# policy once trusted. MAM_AUTO_TRUST=0 in the environment is the same switch +# (the skill docs say so); an explicit --auto-trust/--no-auto-trust wins over it +# (merge review, codex probe: the export below used to overwrite the caller's 0). +AUTO_TRUST="${MAM_AUTO_TRUST:-1}" while [ $# -gt 0 ]; do case "$1" in @@ -74,6 +84,8 @@ while [ $# -gt 0 ]; do --submit-job) SUBMIT_JOB_PROMPT="$2"; shift 2 ;; --onboard) ONBOARD=1; shift ;; --no-onboard) ONBOARD=0; shift ;; + --auto-trust) AUTO_TRUST=1; shift ;; + --no-auto-trust) AUTO_TRUST=0; shift ;; --isolate) echo "NOTE: --isolate/--no-isolate is a no-op โ€” config-home isolation was removed; sessions always use global config." >&2; shift ;; # legacy compatibility --no-isolate) echo "NOTE: --isolate/--no-isolate is a no-op โ€” config-home isolation was removed; sessions always use global config." >&2; shift ;; -h|--help) usage; exit 0 ;; @@ -81,6 +93,8 @@ while [ $# -gt 0 ]; do esac done +export MAM_AUTO_TRUST="$AUTO_TRUST" + if [ -n "$HERDR_SERVER_OPT" ]; then export HERDR_SESSION_NAME="$HERDR_SERVER_OPT" fi @@ -273,13 +287,67 @@ NOW_ISO=$(date -u +'%Y-%m-%dT%H:%M:%SZ') spawn +# _mark_row_rolled_back +# ISSUE-23: the YAML row and the job registration are both written before the +# prompt is injected, so a rollback that only kills the pane leaves a row that +# says `running` pointing at a failed job - which is what status.sh keeps +# reporting (symptom 5). Mark the row instead of deleting it: a deleted row +# takes the evidence with it. +# +# stays inside the schema's vocabulary (running/terminated/archived/ +# stopped - atomic_yaml.py rejects anything else): `terminated` when the +# session is really gone, and `running` when the kill failed, because it IS +# still running and saying otherwise would be the same lie in reverse. What +# makes the failed case legible is the note, the cleared job id and exit 43. +_mark_row_rolled_back() { + local status="$1" note="$2" + [ "${ROW_APPENDED:-0}" = "1" ] || return 0 + atomic_dump_yaml "$AGENT_SESSIONS_YAML" \ + SESSION_NAME="$SESSION_NAME" ROLLBACK_STATUS="$status" ROLLBACK_NOTE="$note" <<'PYEOF' || true +name = os.environ['SESSION_NAME'] +status = os.environ['ROLLBACK_STATUS'] +note = os.environ['ROLLBACK_NOTE'] +for s in d.get('herdr_sessions', []): + if s.get('name') == name: + s['status'] = status + s['last_visible_status'] = note + s['delegate_job_id'] = None + break +PYEOF +} + # Trap for rolling back/cleaning up herdr session if script exits due to error cleanup_herdr_on_error() { local exit_code=$? - if [ $exit_code -ne 0 ]; then - echo "โš ๏ธ Error occurred during initialization (exit code $exit_code). Rolling back and killing herdr session '$SESSION_NAME'..." >&2 - _herdr kill-session -t "$SESSION_NAME" 2>/dev/null || true + [ $exit_code -eq 0 ] && return 0 + echo "โš ๏ธ Error occurred during initialization (exit code $exit_code). Rolling back and killing herdr session '$SESSION_NAME'..." >&2 + + # ISSUE-23: the kill result used to be discarded (`2>/dev/null || true`), so + # a kill that closed nothing still printed "rolled back" while the session + # kept running. Check it, then confirm with has-session. + local kill_rc=0 + _herdr kill-session -t "$SESSION_NAME" || kill_rc=$? + local alive=0 + _herdr has-session -t "$SESSION_NAME" >/dev/null 2>&1 && alive=1 + + # Release the onboarding job: `cancelled` is a terminal transition the + # registry knows, and it says what happened - the job was never really + # dispatched. Best effort; a failure here must not stop the rollback. + # `completed`/`error`/`cancelled` are terminal and published exactly once, so + # a job this run already closed is left alone - a second one is refused by + # the registry anyway ("conflicting cancelled after terminal error"). + if [ -n "${DELEGATE_JOB_ID:-}" ] && [ "${JOB_TERMINALIZED:-0}" != "1" ]; then + delegate_publish_event "$DELEGATE_JOB_ID" cancelled "rolled back by create_session" "${DELEGATE_JOB_TOKEN:-}" "${DELEGATE_JOB_ATTEMPT:-}" || true + JOB_TERMINALIZED=1 fi + + if [ "$alive" = "1" ]; then + _mark_row_rolled_back "running" "rollback-failed" + echo "ERROR: rollback could not kill herdr session '$SESSION_NAME' (kill rc=$kill_rc) - it is still running." >&2 + echo " Clean it up with: HERDR_SESSION_NAME=${HERDR_SESSION_NAME:-default} herdr kill-session -t $SESSION_NAME" >&2 + exit 43 + fi + _mark_row_rolled_back "terminated" "rolled-back" } trap cleanup_herdr_on_error EXIT @@ -331,8 +399,11 @@ if [ "$TUI_READY_RC" -ne 0 ]; then fi fi -if [ "$AGENT" = "claude" ] && [ -z "$LAST_VISIBLE_STATUS_OVERRIDE" ]; then - handle_startup_dialogs "$SESSION_NAME" 15 +# Issue #24: every agent, not only claude. The signatures and accept keys come +# from the agent adapter, so an agy/grok/codex trust prompt is answered here +# too instead of sitting until the readiness wait gives up. +if [ -z "$LAST_VISIBLE_STATUS_OVERRIDE" ]; then + handle_startup_dialogs "$SESSION_NAME" 15 "$AGENT" fi # pane ๋ฉ”ํƒ€ ์บก์ฒ˜ @@ -377,6 +448,7 @@ if [ -n "$SUBMIT_JOB_PROMPT" ]; then echo "Submitted delegated job: $DELEGATE_JOB_ID" DELEGATE_JOB_TOKEN="" DELEGATE_JOB_ATTEMPT="" + DELEGATE_JOB_ACK_S="" if [ -n "$DELEGATE_JOB_ID" ]; then local_info=$(python3 -c " import sys, json, os @@ -389,13 +461,15 @@ if os.path.exists(p): d = json.load(f) tok = d.get('dispatch_token', '') att = d.get('attempt_id', d.get('iteration', 1)) - print(f'{tok} {att}') + ack = d.get('ack_timeout_sec', 180) + print(f'{tok} {att} {ack}') except Exception: pass " 2>/dev/null || true) if [ -n "$local_info" ]; then DELEGATE_JOB_TOKEN=$(echo "$local_info" | awk '{print $1}') DELEGATE_JOB_ATTEMPT=$(echo "$local_info" | awk '{print $2}') + DELEGATE_JOB_ACK_S=$(echo "$local_info" | awk '{print $3}') fi fi fi @@ -534,6 +608,7 @@ snap['taken_at'] = os.environ['NOW_ISO'] snap['cwd'] = os.environ['PANE_CWD'] print(f"appended: {name}", flush=True) PYEOF +ROW_APPENDED=1 echo echo "=== created ===" @@ -541,22 +616,12 @@ echo "herdr session: $SESSION_NAME (pane pid $PANE_PID, cmd $PANE_CMD, cwd $PANE if [ -n "$DELEGATE_JOB_ID" ]; then echo "delegate job: $DELEGATE_JOB_ID" - # Construct instructions matching multi-agent-mux-delegate-job format - py_bin="$(_delegate_py_bin)" - # Issue #21: absolute script, registry and audit-log paths (shell-quoted), so - # an agent whose cwd is a git worktree can run the command as written. - pub_script="$WORKSPACE_ROOT/.agents/skills/multi-agent-mux-delegate-job/scripts/publish_event.py" - [ -f "$pub_script" ] || pub_script="$(_delegate_script publish_event.py)" - pub_script="$(cd -P "$(dirname "$pub_script")" && pwd -P)/$(basename "$pub_script")" - pub_reg="${MAM_JOBS_DIR:-${WORKSPACE_ROOT:+$WORKSPACE_ROOT/.mam/jobs}}" - pub_reg="${pub_reg:-.mam/jobs}" - mkdir -p "$pub_reg" && pub_reg="$(cd -P "$pub_reg" && pwd -P)" - pub_logs="$(PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" "$py_bin" -c 'import os; from lib_py.delegation.mqtt_common import get_logs_dir; print(os.path.abspath(get_logs_dir()))' 2>/dev/null || true)" - pub="$( - if [ -n "$pub_logs" ]; then printf 'DELEGATE_JOB_LOGS_DIR=%q ' "$pub_logs"; fi - printf '%q ' "$py_bin" "$pub_script" --registry-dir "$pub_reg" --job "$DELEGATE_JOB_ID" - )" - pub="${pub% }" + # The publish command the agent will run: the same shape the delegate-job + # wrapper hands its agents, from the one builder in lib.sh. The dispatch + # token and attempt id are what make the agent's own `started` name THIS + # dispatch (trial-2 finding: without them the token-checking ack below could + # never be satisfied by a real agent, and every onboarding sat out 180 s). + pub="$(delegate_publish_cmd "$DELEGATE_JOB_ID" "$DELEGATE_JOB_TOKEN" "$DELEGATE_JOB_ATTEMPT")" instructions="Your job_id is \"$DELEGATE_JOB_ID\" (the one just registered for THIS delegation). On start run: $pub --event started. @@ -569,12 +634,56 @@ Task: $SUBMIT_JOB_PROMPT" # Inject instructions into the herdr pane rc=0 inject_instructions "$SESSION_NAME" "$instructions" "$DELEGATE_JOB_ID" || rc=$? - if [ "$rc" -ne 0 ]; then + # ISSUE-23: rc 4 is "Enter was not seen to be accepted within the send_keys + # window", which is where a TUI that is still finishing its start-up lands. + # It is a warning now; the agent's own `started` decides. rc 1/2/3 are still + # failures: they say the pane never quiesced, a dialog was blocking, or the + # text never appeared - none of which a later `started` can repair. + if [ "$rc" -ne 0 ] && [ "$rc" -ne 4 ]; then delegate_publish_event "$DELEGATE_JOB_ID" error "instruction injection failed (prompt-lock, rc=$rc)" "$DELEGATE_JOB_TOKEN" "$DELEGATE_JOB_ATTEMPT" + JOB_TERMINALIZED=1 exit 1 fi - - delegate_publish_event "$DELEGATE_JOB_ID" started "multi-agent-mux session created and instructions injected" "$DELEGATE_JOB_TOKEN" "$DELEGATE_JOB_ATTEMPT" + if [ "$rc" -eq 4 ]; then + echo "WARNING: Enter was not observed to be accepted for '$SESSION_NAME' (rc=4); waiting for the agent's own started event instead." >&2 + fi + + # ISSUE-23: this script no longer publishes `started` on the agent's behalf. + # It used to, which made the registry show a started job even when the agent + # never saw the prompt, and left any started-based verdict meaningless. + # + # How long to wait for the handshake: MAM_ONBOARD_ACK_S, else the wrapper's + # MAM_INJECT_ACK_GRACE_S, else the job's own ack timeout. The create-specific + # variable comes first so this wait can be shortened - in a sandbox with no + # real agent, for instance - without also retuning the delegate-job + # wrapper's injection ack, which is a different wait with its own tests. + ONBOARD_ACK_S="${MAM_ONBOARD_ACK_S:-${MAM_INJECT_ACK_GRACE_S:-${DELEGATE_JOB_ACK_S:-180}}}" + if ! delegate_wait_started "$DELEGATE_JOB_ID" "$ONBOARD_ACK_S" "$DELEGATE_JOB_TOKEN"; then + # The job failed, the session did not: the pane is up and the row is valid, + # only the handshake never landed. Killing a live agent over a slow or + # silent onboarding would destroy more than it protects, and it would + # contradict this script's own rule for the same situation one step + # earlier - a TUI that never signalled ready is preserved for diagnosis, + # not rolled back. So: mark the job, warn loudly, keep the session. + # MULTI_AGENT_RULES ยง6 has the GM gate on this job's `completed` anyway, + # so an unacknowledged onboarding already blocks real work. + # A job that is already terminal is not marked again: `error` over a + # `completed` is refused by the registry anyway (trial-2 log: "conflicting + # error after terminal completed"), and a job the agent has finished is + # not an undelivered one - only the evidence was missing. + ONBOARD_JOB_ST="$(delegate_job_status "$DELEGATE_JOB_ID")" + case "$ONBOARD_JOB_ST" in + completed|error|cancelled) + echo "WARNING: no started event naming this dispatch for '$SESSION_NAME' within ${ONBOARD_ACK_S}s, but job $DELEGATE_JOB_ID is already '$ONBOARD_JOB_ST'; leaving it as it is." >&2 + ;; + *) + delegate_publish_event "$DELEGATE_JOB_ID" error "onboarding delivery unconfirmed: no started within ${ONBOARD_ACK_S}s" "$DELEGATE_JOB_TOKEN" "$DELEGATE_JOB_ATTEMPT" + echo "WARNING: '$SESSION_NAME' did not acknowledge the onboarding prompt within ${ONBOARD_ACK_S}s." >&2 + echo " The session is preserved and job $DELEGATE_JOB_ID is marked error; re-send the onboarding with multi-agent-mux-delegate-job submit once the agent is up." >&2 + ;; + esac + JOB_TERMINALIZED=1 + fi # Trigger immediate priority reconcile cycle asynchronously # Issue #21 phase 2 (A8): the control root's reconcile/watchdog, never the # (unreviewed) copy in a worktree --workspace; identical paths by default. diff --git a/.agents/skills/multi-agent-mux-delegate-job/SKILL.md b/.agents/skills/multi-agent-mux-delegate-job/SKILL.md index 52ef4b3..fa32a9c 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.7.1 +version: 4.7.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 5347d02..529be41 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 @@ -633,41 +633,33 @@ _disk_status() { # ISSUE-18: delivery is confirmed by the protocol, not the screen. After the # prompt was handed over (herdr accepted the submission, or the dispatch was # published for a remote session) the job's own `started` must arrive within -# the ack timeout T: the registry leaves `pending` (the agent's publish_event -# syncs it on this host), a started line lands in events.log, or the -# subscriber prints a started line (a remote agent - no shared disk). No -# `started` within T is a delivery failure: fail closed, never re-inject -# (single-injection contract). 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. +# the ack timeout T. The evidence is lib.sh's delegate_started_recorded - the +# same predicate create_session's onboarding ack uses, so the two waits share +# one contract (ack-finding req. 2, review R1): the local publisher's line in +# events.log, or the subscriber's HMAC-verified, dispatch-attributed audit +# record of a `started` it received (a remote agent has no shared disk). The +# registry status is not evidence - a record leaves `pending` when another +# writer closes it (error, cancelled) or another dispatch claims it, none of +# which says this agent saw this prompt. No `started` within T is a delivery +# failure: fail closed, never re-inject (single-injection contract). A job +# that is already terminal is handled by the terminal logic after this, not +# turned into a success here. _wait_started() { - local job_id="$1" ack_s="${2:-$ACK_TIMEOUT}" sub_log="${3:-}" deadline wall st + local job_id="$1" ack_s="${2:-$ACK_TIMEOUT}" sub_log="${3:-}" deadline wall + # sub_log is retained for callers' signatures; the subscriber's evidence is + # read from its audit record, which is attributable, not from its console. deadline=$(( $(date +%s) + ack_s )) # The ack wait never outlives this dispatch's wall clock (START_TIME + TIMEOUT). wall=$(( ${START_TIME:-$(date +%s)} + ${TIMEOUT:-3600} )) [ "$deadline" -gt "$wall" ] && deadline=$wall - local ev_log="$REGISTRY_DIR/$job_id.events.log" - # A loop job keeps one events.log across iterations: only a `started` that - # carries THIS attempt's dispatch token counts (review R1). - local started_re='"event": *"started"' - [ -n "${_disp_tok:-}" ] && started_re='"event": *"started".*"dispatch_token": *"'"$_disp_tok"'"' local budget=$(( deadline - $(date +%s) )) while :; do - st="$(_disk_status "$job_id")" - if [ -n "$st" ] && [ "$st" != "pending" ]; then - echo "inject: job $job_id advanced to '$st' in the registry; treating the prompt as delivered and continuing." >&2 - return 0 - fi - # publish_event records `started` here before its wire publish and before - # the status sync, so a slow broker cannot delay the acknowledgement. - if [ -f "$ev_log" ] && grep -Eq "$started_re" "$ev_log" 2>/dev/null; then + # publish_event records `started` locally before its wire publish and + # before the status sync, so a slow broker cannot delay the acknowledgement. + if MAM_JOBS_DIR="$REGISTRY_DIR" delegate_started_recorded "$job_id" "${_disp_tok:-}" "${LOGS_DIR_ABS:-}"; then echo "inject: job $job_id recorded 'started' in events.log; treating the prompt as delivered and continuing." >&2 return 0 fi - if [ -n "$sub_log" ] && [ -f "$sub_log" ] && grep -Eq '^[^ ]+ job=[^ ]+ seq=[^ ]+ started( |$)' "$sub_log" 2>/dev/null; then - echo "inject: subscriber received started for job $job_id; treating the prompt as delivered and continuing." >&2 - return 0 - fi if [ "$(date +%s)" -ge "$deadline" ]; then ACK_WAITED_S="$budget" return 1 diff --git a/.agents/skills/multi-agent-mux-loop/SKILL.md b/.agents/skills/multi-agent-mux-loop/SKILL.md index 7d7ed24..1007730 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.7.1 +version: 4.7.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 1ee9a02..a22d594 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.7.1 +version: 4.7.2 author: godopu license: MIT platforms: [linux, macos] diff --git a/.agents/skills/multi-agent-mux-orc-onboard/SKILL.md b/.agents/skills/multi-agent-mux-orc-onboard/SKILL.md index 03f3290..a2388b5 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.7.1 +version: 4.7.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 8156922..385744c 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.7.1 +version: 4.7.2 author: godopu license: MIT platforms: [linux, macos] @@ -101,17 +101,11 @@ case "$AGENT" in esac # 4. Spawn new herdr session + run agent with the saved id -case "$AGENT" in - claude) - START_CMD="herdr new-session -d -s \"$SESSION_NAME\" -x 140 -y 40 -c \"$WORKSPACE\" \"$CMD_FULL\"" - eval "$START_CMD" - # auto-handle trust / bypass dialogs - handle_startup_dialogs "$SESSION_NAME" 20 - ;; - agy|hermes|grok|opencode|codex) - eval "herdr new-session -d -s \"$SESSION_NAME\" -x 140 -y 40 -c \"$WORKSPACE\" \"$CMD_FULL\"" - ;; -esac +eval "herdr new-session -d -s \"$SESSION_NAME\" -x 140 -y 40 -c \"$WORKSPACE\" \"$CMD_FULL\"" +# Issue #24: start-up dialogs (trust prompts, blocking notices) are answered for +# every agent from the adapter's signatures - the same call create makes. +# --no-auto-trust leaves a directory-trust dialog to a human (MAM_AUTO_TRUST=0). +handle_startup_dialogs "$SESSION_NAME" 20 "$AGENT" # 4. Update agent-sessions.yaml: status running, last_visible_status # (Also automatically publishes a `progress --detail "resumed"` event to the multi-agent-mux-delegate-job registry if a delegate_job_id exists) diff --git a/.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh b/.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh index c5a58ff..78a58d0 100755 --- a/.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh +++ b/.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh @@ -13,6 +13,8 @@ Usage: $0 --workspace --agent --s Options: --herdr-session NAME specify isolated herdr session name (alias: --herdr-server) + --no-auto-trust do not answer a start-up directory-trust dialog (default: answer it; + other blocking notices are always answered) - same switch as create --dry-run Simulates resume flow (resolves binary, environment) without writing any updates to YAML or DB. Safe to execute inside active write transactions. EOF @@ -25,6 +27,10 @@ HERDR_SERVER_OPT="" HERDR_WORKSPACE_OPT="" DRY_RUN=0 +# Issue #24: a resumed CLI shows the same directory-trust dialog as a fresh +# one, so resume takes the same switch as create (MAM_AUTO_TRUST is what +# handle_startup_dialogs reads). +AUTO_TRUST="${MAM_AUTO_TRUST:-1}" # the environment form of the same switch; a flag wins while [ $# -gt 0 ]; do case "$1" in @@ -33,6 +39,8 @@ while [ $# -gt 0 ]; do --session) SESSION_NAME="$2"; shift 2 ;; --herdr-session|--herdr-server) HERDR_SERVER_OPT="$2"; shift 2 ;; --herdr-workspace) HERDR_WORKSPACE_OPT="$2"; shift 2 ;; + --auto-trust) AUTO_TRUST=1; shift ;; + --no-auto-trust) AUTO_TRUST=0; shift ;; --dry-run) DRY_RUN=1; shift ;; -h|--help) usage; exit 0 ;; *) echo "ERROR: unknown arg: $1" >&2; exit 2 ;; @@ -46,6 +54,7 @@ case "$AGENT" in *) echo "ERROR: unsupported agent: $AGENT" >&2; exit 2 ;; esac [ -n "$SESSION_NAME" ] || { echo "ERROR: --session required" >&2; exit 2; } +export MAM_AUTO_TRUST="$AUTO_TRUST" # Issue #21 phase 2 (A11): a session resumes only in the directory it ran in # (its conversation is keyed by that cwd; a class A agent would otherwise start @@ -226,10 +235,10 @@ fi # 4. Spawn new agent session (delegates to herdr translation shim) _herdr new-session -d -s "$SESSION_NAME" -c "$WORKSPACE" "$CP_PREFIX$CMD_FULL" -if [ "$AGENT" = "claude" ]; then - # auto-handle trust / bypass dialogs - handle_startup_dialogs "$SESSION_NAME" 20 -fi +# Issue #24: every agent, the same call create makes. The signatures and accept +# keys come from the agent adapter; this used to run only for claude, so a +# resumed agy/grok/codex sat on its trust prompt. +handle_startup_dialogs "$SESSION_NAME" 20 "$AGENT" # Wait for TUI readiness or let it settle sleep 2 diff --git a/.agents/skills/multi-agent-mux-status/SKILL.md b/.agents/skills/multi-agent-mux-status/SKILL.md index 87e9d1d..2123559 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.7.1 +version: 4.7.2 author: godopu license: MIT platforms: [linux, macos] diff --git a/.agents/skills/multi-agent-mux-stop/SKILL.md b/.agents/skills/multi-agent-mux-stop/SKILL.md index b0184ac..0f3e1e9 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.7.1 +version: 4.7.2 author: godopu license: MIT platforms: [linux, macos] diff --git a/BOOTSTRAP.ko.md b/BOOTSTRAP.ko.md index b1215e7..1999289 100644 --- a/BOOTSTRAP.ko.md +++ b/BOOTSTRAP.ko.md @@ -2,7 +2,7 @@ ๋ณธ ๋ฌธ์„œ๋Š” `herdr_agent_orchestration` ์˜ค์ผ€์ŠคํŠธ๋ ˆ์ด์…˜ ๋ฐ ๋ฉ”์‹œ์ง• ๋ฐฑํ”Œ๋ ˆ์ธ ์›Œํฌํ”Œ๋กœ์šฐ๋ฅผ ์ƒˆ๋กœ์šด ํ”„๋กœ์ ํŠธ์— ๋„์ž…ํ•˜์—ฌ ์ด์‹ํ•˜๊ณ , ์ƒˆ๋กœ์šด ๊ฐœ๋ฐœ์ž/์—์ด์ „ํŠธ๊ฐ€ ์ดˆ๊ธฐ ๊ฐ€๋™์„ ์‹œ์ž‘ํ•  ๋•Œ ์ˆ˜ํ–‰ํ•ด์•ผ ํ•˜๋Š” ํ™˜๊ฒฝ ์„ค์ • ๋ฐ ๊ตฌ์ถ• ์ ˆ์ฐจ๋ฅผ ์•ˆ๋‚ดํ•ฉ๋‹ˆ๋‹ค. -[![Version: v4.7.1](https://img.shields.io/badge/version-v4.7.1-blue.svg)](VERSIONS.md) +[![Version: v4.7.2](https://img.shields.io/badge/version-v4.7.2-blue.svg)](VERSIONS.md) ์ƒˆ๋กœ์šด ์—์ด์ „ํŠธ๋Š” ์ด ์•ˆ๋‚ด์„œ์— ๊ธฐ์ˆ ๋œ ์ ˆ์ฐจ๋ฅผ ์ˆœ์ฐจ์ ์œผ๋กœ ์‹คํ–‰ํ•˜์—ฌ ์ดˆ๊ธฐ ํ™˜๊ฒฝ์„ ์•ˆ์ •์ ์œผ๋กœ ์„ค์ •ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. diff --git a/BOOTSTRAP.md b/BOOTSTRAP.md index 05f6908..5a4e99f 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.7.1](https://img.shields.io/badge/version-v4.7.1-blue.svg)](VERSIONS.md) +[![Version: v4.7.2](https://img.shields.io/badge/version-v4.7.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 1125620..bfe0947 100644 --- a/MESSAGING.md +++ b/MESSAGING.md @@ -1,6 +1,6 @@ # Messaging System Technical Analysis & Architecture Report -Applies to: MAM v4.7.1 +Applies to: MAM v4.7.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 25a75ea..c8573a6 100644 --- a/README.ko.md +++ b/README.ko.md @@ -2,7 +2,7 @@ Herdr์™€ MQTT ๋ธŒ๋กœ์ปค๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ ๊ตฌ์ถ•๋œ ๊ณ ์‹ ๋ขฐ์„ฑ **๋‹ค์ค‘ ์—์ด์ „ํŠธ ์˜ค์ผ€์ŠคํŠธ๋ ˆ์ด์…˜ ๋ฐ ๋ฉ”์‹œ์ง• ๋ฐฑํ”Œ๋ ˆ์ธ** ํ”„๋ ˆ์ž„์›Œํฌ์ž…๋‹ˆ๋‹ค. `claude`, `agy`, `hermes`, `grok`, `opencode`, `codex` ๋“ฑ ๋‹ค์ค‘ LLM ๋ฐฑ์—”๋“œ ์—์ด์ „ํŠธ ์ „๋ฐ˜์— ๊ฑธ์ณ ์žฅ์‹œ๊ฐ„ ์ˆ˜ํ–‰๋˜๋Š” ์ž‘์—…(์ฝ”๋“œ ์ƒ์„ฑ, ๋ฆฌํŒฉํ† ๋ง, ๋ณด์•ˆ ๊ฒ€ํ†  ๋“ฑ)์„ ์กฐ์ •, ๊ฒฉ๋ฆฌ ๋ฐ ๊ฐ์‚ฌํ•  ์ˆ˜ ์žˆ๋„๋ก ์„ค๊ณ„๋˜์—ˆ์Šต๋‹ˆ๋‹ค. -[![Version: v4.7.1](https://img.shields.io/badge/version-v4.7.1-blue.svg)](VERSIONS.md) +[![Version: v4.7.2](https://img.shields.io/badge/version-v4.7.2-blue.svg)](VERSIONS.md) --- diff --git a/README.md b/README.md index 8bbf741..2868936 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.7.1](https://img.shields.io/badge/version-v4.7.1-blue.svg)](VERSIONS.md) +[![Version: v4.7.2](https://img.shields.io/badge/version-v4.7.2-blue.svg)](VERSIONS.md) --- diff --git a/VERSIONS.md b/VERSIONS.md index 67100ff..8435155 100644 --- a/VERSIONS.md +++ b/VERSIONS.md @@ -6,8 +6,8 @@ ## ๐Ÿ“Œ ํ˜„์žฌ ๋ฒ„์ „ ๊ฐœ์š” (Current Release) -- **ํ”„๋ ˆ์ž„์›Œํฌ ๋ฒ„์ „**: `v4.7.1` -- **์ตœ์‹  ๋ฆด๋ฆฌ์Šค ์ผ์‹œ**: 2026-09-22 (KST) +- **ํ”„๋ ˆ์ž„์›Œํฌ ๋ฒ„์ „**: `v4.7.2` +- **์ตœ์‹  ๋ฆด๋ฆฌ์Šค ์ผ์‹œ**: 2026-09-23 (KST) - **๊ธฐ์ค€ ๋ธŒ๋žœ์น˜**: `main` - **ํ•ต์‹ฌ ์•„ํ‚คํ…์ฒ˜**: - **Terminal ์ƒํƒœ ๋‹จ๋ฐฉํ–ฅ ๋ถˆ๋ณ€์„ฑ (`engine.py`, Issue #6 Phase 1)**: `completed`, `error`, `cancelled` ํ„ฐ๋ฏธ๋„ ์ƒํƒœ ์ง„์ž… ํ›„ ํ›„์† ์ด๋ฒคํŠธ๋‚˜ ์—…๋ฐ์ดํŠธ์— ์˜ํ•œ ๋ฎ์–ด์“ฐ๊ธฐ๋ฅผ ์—„๊ฒฉํžˆ ์ฐจ๋‹จ(Terminal Immutability). `registry.py close` ์„œ๋ธŒ์ปค๋งจ๋“œ ๋ฐ fail-closed ์กฐ๊ธฐ ์ข…๋ฃŒ ๋„์ž…. @@ -22,23 +22,41 @@ ## ๐Ÿงญ ์Šคํ‚ฌ ํŒจํ‚ค์ง€ ๋ฒ„์ „ ๋งคํŠธ๋ฆญ์Šค (Skills Version Matrix) -๋ชจ๋“  8๊ฐœ ์Šคํ‚ฌ์€ YAML frontmatter ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ(`author`, `version`, `platforms`, `environments`) ํ‘œ์ค€ํ™”๋ฅผ ํ†ตํ•ด `v4.7.1`๋กœ ๋™๊ธฐํ™”๋˜์–ด ๋ฐฐํฌ๋ฉ๋‹ˆ๋‹ค. +๋ชจ๋“  8๊ฐœ ์Šคํ‚ฌ์€ YAML frontmatter ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ(`author`, `version`, `platforms`, `environments`) ํ‘œ์ค€ํ™”๋ฅผ ํ†ตํ•ด `v4.7.2`๋กœ ๋™๊ธฐํ™”๋˜์–ด ๋ฐฐํฌ๋ฉ๋‹ˆ๋‹ค. | ์Šคํ‚ฌ๋ช… | ๋ฒ„์ „ | ์—ญํ•  ๋ฐ ์ฃผ์š” ์ฑ…์ž„ | ์ƒํƒœ | | :--- | :---: | :--- | :---: | -| **`multi-agent-mux-create`** | `4.7.1` | ์—์ด์ „ํŠธ ์„ธ์…˜ ์‹ ๊ทœ ์ƒ์„ฑ ๋ฐ Herdr ์ปจํ…Œ์ด๋„ˆ ๊ฒฉ๋ฆฌ ์Šคํฐ | โœ… ๋ฐฐํฌ | -| **`multi-agent-mux-stop`** | `4.7.1` | ๋Œ€ํ™” UUID ์›์ž์  ์บก์ฒ˜ ๋ฐ ์„ธ์…˜ ์•ˆ์ „ ์ข…๋ฃŒ (Graceful Stop) | โœ… ๋ฐฐํฌ | -| **`multi-agent-mux-resume`** | `4.7.1` | ์˜จ๋””์Šคํฌ ๋Œ€ํ™” ์ปจํ…์ŠคํŠธ ๊ธฐ๋ฐ˜ Tier-1 ์ดˆ๊ณ ์† ์„ธ์…˜ ๋ณต์› | โœ… ๋ฐฐํฌ | -| **`multi-agent-mux-status`** | `4.7.1` | ์‹ค์‹œ๊ฐ„ Herdr ์„ธ์…˜ ๋ฐ ๋ ˆ์ง€์ŠคํŠธ๋ฆฌ ๋“œ๋ฆฌํ”„ํŠธ ์Šค๋ƒ…์ƒท ์กฐํšŒ | โœ… ๋ฐฐํฌ | -| **`multi-agent-mux-monitor`** | `4.7.1` | YAML โ†” ๋Ÿฐํƒ€์ž„ ์ƒํƒœ ๊ฐ„ ์ž์œจ ์กฐ์ •์ž (Reconciler Loop) | โœ… ๋ฐฐํฌ | -| **`multi-agent-mux-delegate-job`** | `4.7.1` | MQTT ์ด๋ฒคํŠธ ์ฑ„๋„ ๊ธฐ๋ฐ˜ ๋น„๋™๊ธฐ ๋‹จ์œ„ ์ž‘์—… ์œ„์ž„ | โœ… ๋ฐฐํฌ | -| **`multi-agent-mux-loop`** | `4.7.1` | Planner-Creator-Reviewer 3์ž ์ž์œจ ๊ณ„ํšยท์‹คํ–‰ยทํ”ผ์–ด๋ฆฌ๋ทฐ ๋ฃจํ”„ | โœ… ๋ฐฐํฌ | -| **`multi-agent-mux-orc-onboard`** | `4.7.1` | ์˜ค์ผ€์ŠคํŠธ๋ ˆ์ดํ„ฐ UUID ๊ฒฉ๋ฆฌ ๋“ฑ๋ก ๋ฐ ์„œ๋ธŒ ์„ธ์…˜ ์˜ค์—ผ ๋ฐฉ์ง€ | โœ… ๋ฐฐํฌ | +| **`multi-agent-mux-create`** | `4.7.2` | ์—์ด์ „ํŠธ ์„ธ์…˜ ์‹ ๊ทœ ์ƒ์„ฑ ๋ฐ Herdr ์ปจํ…Œ์ด๋„ˆ ๊ฒฉ๋ฆฌ ์Šคํฐ | โœ… ๋ฐฐํฌ | +| **`multi-agent-mux-stop`** | `4.7.2` | ๋Œ€ํ™” UUID ์›์ž์  ์บก์ฒ˜ ๋ฐ ์„ธ์…˜ ์•ˆ์ „ ์ข…๋ฃŒ (Graceful Stop) | โœ… ๋ฐฐํฌ | +| **`multi-agent-mux-resume`** | `4.7.2` | ์˜จ๋””์Šคํฌ ๋Œ€ํ™” ์ปจํ…์ŠคํŠธ ๊ธฐ๋ฐ˜ Tier-1 ์ดˆ๊ณ ์† ์„ธ์…˜ ๋ณต์› | โœ… ๋ฐฐํฌ | +| **`multi-agent-mux-status`** | `4.7.2` | ์‹ค์‹œ๊ฐ„ Herdr ์„ธ์…˜ ๋ฐ ๋ ˆ์ง€์ŠคํŠธ๋ฆฌ ๋“œ๋ฆฌํ”„ํŠธ ์Šค๋ƒ…์ƒท ์กฐํšŒ | โœ… ๋ฐฐํฌ | +| **`multi-agent-mux-monitor`** | `4.7.2` | YAML โ†” ๋Ÿฐํƒ€์ž„ ์ƒํƒœ ๊ฐ„ ์ž์œจ ์กฐ์ •์ž (Reconciler Loop) | โœ… ๋ฐฐํฌ | +| **`multi-agent-mux-delegate-job`** | `4.7.2` | MQTT ์ด๋ฒคํŠธ ์ฑ„๋„ ๊ธฐ๋ฐ˜ ๋น„๋™๊ธฐ ๋‹จ์œ„ ์ž‘์—… ์œ„์ž„ | โœ… ๋ฐฐํฌ | +| **`multi-agent-mux-loop`** | `4.7.2` | Planner-Creator-Reviewer 3์ž ์ž์œจ ๊ณ„ํšยท์‹คํ–‰ยทํ”ผ์–ด๋ฆฌ๋ทฐ ๋ฃจํ”„ | โœ… ๋ฐฐํฌ | +| **`multi-agent-mux-orc-onboard`** | `4.7.2` | ์˜ค์ผ€์ŠคํŠธ๋ ˆ์ดํ„ฐ UUID ๊ฒฉ๋ฆฌ ๋“ฑ๋ก ๋ฐ ์„œ๋ธŒ ์„ธ์…˜ ์˜ค์—ผ ๋ฐฉ์ง€ | โœ… ๋ฐฐํฌ | --- ## ๐Ÿ“‹ ๋ฒ„์ „๋ณ„ ์ƒ์„ธ ๋ณ€๊ฒฝ ๋‚ด์—ญ (Changelog) +### ๐Ÿš€ `v4.7.2` โ€” Issue #23ยท#24 ์„ธ์…˜ ์ƒ์„ฑ ๊ฒฝ๋กœ: ๊ต์ฐจ ์„œ๋ฒ„ herdr ์Šค์ฝ”ํ”„, ์—์ด์ „ํŠธ ๋ฌด๊ด€ ์‹œ์ž‘ ๋‹ค์ด์–ผ๋กœ๊ทธ, ์‹ค์ œ ์—์ด์ „ํŠธ๊ฐ€ ์ถฉ์กฑํ•˜๋Š” ์˜จ๋ณด๋”ฉ ์ „๋‹ฌ ํŒ์ • (2026-09-23, PR #35) + +> **ํŒจ์น˜ ๋งˆ์ผ์Šคํ†ค (PATCH Release)**: `create_session.sh` ๋กœ ์ƒˆ ์„ธ์…˜์„ ๋งŒ๋“œ๋Š” ์ฒซ 1๋ถ„ ์•ˆ์˜ ๊ฒฐํ•จ ๋‘˜์„ ๊ณ ์ณค๋‹ค. ์˜ค์ผ€์ŠคํŠธ๋ ˆ์ดํ„ฐ pane ์ด ๋ฌผ๋ ค์ฃผ๋Š” herdr ํ™˜๊ฒฝ(`HERDR_WORKSPACE_ID`ยท`HERDR_PANE_ID`ยท`HERDR_TAB_ID`)์ด ๊ฒฉ๋ฆฌ ์„œ๋ฒ„ ์กฐํšŒ๋ฅผ ์˜ค์—ผ์‹œ์ผœ ์˜จ๋ณด๋”ฉ ํ”„๋กฌํ”„ํŠธ๊ฐ€ ์ „๋‹ฌ๋˜์ง€ ์•Š๊ณ , ์ „๋‹ฌ ํŒ์ •์ด ํ™”๋ฉด ๊ธฐ์ค€์ด๋ผ ์‚ด์•„ ์žˆ๋Š” ์—์ด์ „ํŠธ๋ฅผ ์‹คํŒจ๋กœ ํŒ์ •ํ•œ ๋’ค ๋กค๋ฐฑ๊นŒ์ง€ ์‹คํŒจํ•˜๋˜ ๋ฌธ์ œ(#23)์™€, ์‹œ์ž‘ ์‹ ๋ขฐ(trust) ๋‹ค์ด์–ผ๋กœ๊ทธ๋ฅผ claude ์—๊ฒŒ๋งŒ ์ฒ˜๋ฆฌํ•ด agyยทgrokยทcodex ๊ฐ€ ์ค€๋น„ ๋Œ€๊ธฐ ์ดˆ๊ณผ๋กœ ์˜จ๋ณด๋”ฉ์„ ๊ฑด๋„ˆ๋›ฐ๋˜ ๋ฌธ์ œ(#24)๋‹ค. ์˜ต์…˜ ํ•˜๋‚˜(`--no-auto-trust`)์™€ ์ข…๋ฃŒ ์ฝ”๋“œ ๋‘˜(42ยท43)์ด ๋Š˜์ง€๋งŒ ์ž˜๋ชป๋œ ๊ธฐ๋ณธ ๋™์ž‘์„ ๊ณ ์น˜๋Š” ์ˆ˜์ •์ด๋ผ PATCH ๋กœ ์˜ฌ๋ฆฐ๋‹ค. + +#### ๐ŸŒŸ ์ฃผ์š” ๊ฐœ์„  ์‚ฌํ•ญ +1. **์‹œ์ž‘ ๋‹ค์ด์–ผ๋กœ๊ทธ๊ฐ€ ์—์ด์ „ํŠธ ๋ฌด๊ด€ (#24, `lib_py/agents/base.py: startup_dialogs`, ์–ด๋Œ‘ํ„ฐ 4์ข…, `lib.sh: handle_startup_dialogs`)**: ๋ชจ๋‹ฌ์˜ ์‹œ๊ทธ๋‹ˆ์ฒ˜ยท์ˆ˜๋ฝ ํ‚คยท์ข…๋ฅ˜(`trust`/`notice`)๋ฅผ ์–ด๋Œ‘ํ„ฐ๊ฐ€ ์ œ๊ณตํ•˜๊ณ (`MAM_STARTUP_DIALOGS`), TUI ์ค€๋น„ ๋Œ€๊ธฐ ์ค‘ ๊ฐ์ง€๋˜๋ฉด ์ˆ˜๋ฝํ•œ๋‹ค. agy(Enter)ยทgrok(`y`)ยทcodex(`1. Yes, continue` Enter)์˜ ์‹ ๋ขฐ ์ฐฝ๊ณผ codex 0.155.1 ์—…๋ฐ์ดํŠธ ์•ˆ๋‚ด(Skip)๋ฅผ ์ฒ˜๋ฆฌํ•œ๋‹ค. ์ˆ˜๋ฝ ํŒ์ •์€ ๋‹ค์ด์–ผ๋กœ๊ทธ ์‹œ๊ทธ๋‹ˆ์ฒ˜๊ฐ€ ์•„๋‹ˆ๋ผ ์ค€๋น„ ํ† ํฐ์œผ๋กœ ํ•œ๋‹ค(`--source visible` ์ด ์ˆ˜๋ฝ๋œ ์ฐฝ์„ ํ•œ๋™์•ˆ ๊ณ„์† ๋ณด์—ฌ์ค€๋‹ค). `--no-auto-trust` / `MAM_AUTO_TRUST=0` ์€ ์‹ ๋ขฐ ์ฐฝ๋งŒ ์‚ฌ๋žŒ์—๊ฒŒ ๋‚จ๊ธฐ๊ณ  ์•ˆ๋‚ด๋Š” ๊ณ„์† ์ฒ˜๋ฆฌํ•œ๋‹ค(codex ์˜ trust ๋Š” ํ”„๋กœ์ ํŠธ ๋กœ์ปฌ ์„ค์ •ยทํ›…ยทexec ์ •์ฑ…์„ ๋กœ๋“œํ•œ๋‹ค). resume ๋„ create ์™€ ๊ฐ™์€ ์Šค์œ„์น˜ยท๊ณ„์•ฝ์ด๋‹ค. +2. **๊ต์ฐจ ์„œ๋ฒ„ ํ˜ธ์ถœ์˜ ์Šค์ฝ”ํ”„ ์ œ๊ฑฐ (#23, `lib.sh`)**: herdr pane ์ด ๋‚ด๋ณด๋‚ด๋Š” ์„ธ id ๋Š” ๋ฐœ๊ธ‰ํ•œ ์„œ๋ฒ„์—์„œ๋งŒ ๋œป์ด ์žˆ์œผ๋ฏ€๋กœ **๋‹ค๋ฅธ ์„œ๋ฒ„๋ฅผ ๊ฒจ๋ƒฅํ•œ ํ˜ธ์ถœ์—์„œ๋งŒ** `env -u` ๋กœ ์ œ๊ฑฐํ•œ๋‹ค. ํ˜ธ์ถœ์ž ์„œ๋ฒ„๋Š” `HERDR_SESSION` ์ด ์žˆ์œผ๋ฉด ๊ทธ๊ฒƒ, ์—†์œผ๋ฉด `HERDR_SOCKET_PATH` ์˜ ๋ชจ์–‘์—์„œ ๊ตฌํ•œ๋‹ค(herdr 0.8.2 ๋Š” ๊ธฐ๋ณธ ์„œ๋ฒ„ pane ์— `HERDR_SESSION` ์„ ๋‚ด๋ณด๋‚ด์ง€ ์•Š๋Š”๋‹ค). +3. **์˜จ๋ณด๋”ฉ ์ „๋‹ฌ ํŒ์ • = ์—์ด์ „ํŠธ ์ž์‹ ์˜ `started` (#23, `lib.sh: delegate_publish_cmd / delegate_started_recorded / delegate_wait_started`, `create_session.sh`, delegate-job ๋ž˜ํผ)**: create ๋Š” ๋” ์ด์ƒ ์—์ด์ „ํŠธ ๋Œ€์‹  `started` ๋ฅผ ๋ฐœํ–‰ํ•˜์ง€ ์•Š๋Š”๋‹ค. ๋ฐœํ–‰ ๋ช…๋ น ๋นŒ๋” ํ•˜๋‚˜๊ฐ€ create ์˜จ๋ณด๋”ฉ๊ณผ ๋ž˜ํผ์— ๊ฐ™์€ ๋ชจ์–‘(dispatch_tokenยทattempt ํฌํ•จ)์„ ์ฃผ๊ณ , `started` ํŒ์ • ํ•˜๋‚˜๋ฅผ ๋‘ ๋Œ€๊ธฐ ์ง€์ ์ด ๊ณต์œ ํ•œ๋‹ค. ์ด๋ฒˆ dispatch ์˜ `started` ๋งŒ ์ฆ๊ฑฐ์ด๋ฉฐ(๋กœ์ปฌ: events.log ์˜ dispatch_token, ์›๊ฒฉ: ๊ตฌ๋…์ž๊ฐ€ HMACยทํ† ํฐ ๊ฒ€์ฆ ๋’ค ๊ฐ์‚ฌ ๊ธฐ๋ก์— ๋‚จ๊ธฐ๋Š” `dispatch_fingerprint`(๊ฒ€์ฆํ•œ ํ† ํฐ์˜ SHA-256)+attempt), ์‹ ์› ์—†๋Š” ๊ณผ๊ฑฐ ๊ธฐ๋ก์€ fail closed ๋‹ค. ๋ž˜ํผ์˜ "๋ ˆ์ฝ”๋“œ ์ƒํƒœ๋งŒ ๋ณด๊ณ  ๋จผ์ € ์„ฑ๊ณต" ์šฐํšŒ๋Š” ์‚ฌ๋ผ์กŒ๋‹ค. send_keys ์˜ rc 4(Enter ์ˆ˜๋ฝ ๋ฏธ๊ด€์ธก)๋Š” ๊ฒฝ๊ณ ๋กœ ๋‚ฎ์ถ”๊ณ  ์—์ด์ „ํŠธ์˜ `started` ๊ฐ€ ๊ฒฐ์ •ํ•œ๋‹ค. +4. **์‹คํŒจ ์ฒ˜๋ฆฌ (#23, `create_session.sh`)**: ๋กค๋ฐฑ์ด kill ๊ฒฐ๊ณผ๋ฅผ ํ™•์ธํ•˜๊ณ  `has-session` ์œผ๋กœ ์žฌํ™•์ธํ•œ๋‹ค. ๋ชป ์ฃฝ์˜€์œผ๋ฉด ํ–‰์— `rollback-failed` ๋ฅผ ์ ๊ณ  exit 43. ์˜จ๋ณด๋”ฉ์ด `MAM_ONBOARD_ACK_S`(๊ธฐ๋ณธ: ๋ž˜ํผ์˜ ์ฃผ์ž… ack ์œ ์˜ˆ โ†’ ์žก์˜ ack timeout 180) ์•ˆ์— ํ™•์ธ๋˜์ง€ ์•Š์•„๋„ ์„ธ์…˜์€ ๋ณด์กดํ•˜๊ณ  ์žก๋งŒ error ๋กœ ํ‘œ์‹œํ•œ๋‹ค(์‚ด์•„ ์žˆ๋Š” ์—์ด์ „ํŠธ๋ฅผ ๋А๋ฆฐ ์˜จ๋ณด๋”ฉ ๋•Œ๋ฌธ์— ์ฃฝ์ด์ง€ ์•Š๋Š”๋‹ค; ์ด๋ฏธ ์ข…๋ฃŒ๋œ ์žก์€ ๋ฎ์–ด์“ฐ์ง€ ์•Š๋Š”๋‹ค). ๋กค๋ฐฑ ์‹œ ์˜จ๋ณด๋”ฉ ์žก์€ `cancelled`, YAML ํ–‰์€ `terminated`/`rollback-failed` ๋กœ ํ‘œ์‹œํ•œ๋‹ค. TUI ๊ฐ€ ์ค€๋น„๋˜์ง€ ์•Š๊ณ  ํ”„๋กœ์„ธ์Šค๋„ ์—†์œผ๋ฉด exit 42. +5. **๋ฌธ์„œ**: create/resume SKILL.md(`--no-auto-trust`, `started` ๊ธฐ๋ฐ˜ ํŒ์ •, exit 42ยท43, `MAM_ONBOARD_ACK_S`, ๋ฏธํ™•์ธ ์˜จ๋ณด๋”ฉ ์‹œ ์„ธ์…˜ ๋ณด์กด ๊ทœ์น™). + +#### โœ… ํ’ˆ์งˆ ๊ฒ€์ฆ +- **์‹ ๊ทœ ํ…Œ์ŠคํŠธ** (`tests/test_issue23_24_startup_path.py` 1,268์ค„, `tests/test_issue24_resume_dialogs.py`): ์–ด๋Œ‘ํ„ฐ ๋‹ค์ด์–ผ๋กœ๊ทธ facts, ์ค€๋น„ ํ† ํฐ ๊ธฐ๋ฐ˜ ์ˆ˜๋ฝ, `--no-auto-trust`, ๊ต์ฐจ ์„œ๋ฒ„ ์Šค์ฝ”ํ”„ ์ œ๊ฑฐ(๊ฐ™์€ ์„œ๋ฒ„๋Š” ์œ ์ง€), ๊ณต์œ  `started` ํŒ์ •(์ด์ „ ์‹œ๋„ยท์‹ ์› ์—†๋Š” ๊ธฐ๋ก ๊ฑฐ๋ถ€, ์ข…๋ฃŒ๋œ ์žก ๋ณด์กด), ๋กค๋ฐฑ์˜ kill ํ™•์ธยทexit 43, ๋ฏธํ™•์ธ ์˜จ๋ณด๋”ฉ ์‹œ ์„ธ์…˜ ๋ณด์กด. ํ”ฝ์Šค์ฒ˜๋Š” ์‹ค์ œ `registry.py`ยท`publish_event.py` ๋ฅผ ์“ด๋‹ค(์ž‘์„ฑ ์›Œํฌ์ŠคํŽ˜์ด์Šค ๋ฆฌ๋ทฐ์—์„œ ์†์œผ๋กœ ๋งŒ๋“  ํ”ฝ์Šค์ฒ˜๊ฐ€ ๊ฒฐํ•จ 3๊ฑด์„ ๊ฐ€๋ ธ๋˜ ๊ตํ›ˆ). ๊ธฐ์กด ํ…Œ์ŠคํŠธ 6ํŒŒ์ผ ์กฐ์ •(G9 ๊ฐ€๋“œ๋Š” ์ฝ”๋“œ๋งŒ ์ฝ์Œ). +- **์‹ค์ œ herdr ์ˆ˜์šฉ ์‹œํ—˜ (์ž‘์„ฑ ์›Œํฌ์ŠคํŽ˜์ด์Šค)**: ์˜ค์ผ€์ŠคํŠธ๋ ˆ์ดํ„ฐ pane ์˜ ์ƒ์† ํ™˜๊ฒฝ์„ ๊ทธ๋Œ€๋กœ ๋‘” ์ฑ„ ๊ฒฉ๋ฆฌ ์„œ๋ฒ„์— agyยทgrokยทcodexยทopencode ๋ฅผ ์ˆœ์ฐจ ์ƒ์„ฑ โ€” 4๊ฑด ๋ชจ๋‘ ์‚ฌ๋žŒ ๊ฐœ์ž… ์—†์ด TUI ์ค€๋น„, ์˜จ๋ณด๋”ฉ ์ „๋‹ฌ, `started`(dispatch_token ํฌํ•จ) โ†’ `completed`. +- **๋ฆฌ๋ทฐ**: ์ž‘์„ฑ ์›Œํฌ์ŠคํŽ˜์ด์Šค 5์ธ 5๋ผ์šด๋“œ(์ฐจ๋‹จ ์ง€์  3๊ฑด โ€” `HERDR_SESSION` ์—๋งŒ ์˜์กดํ•œ ๊ต์ฐจ ์„œ๋ฒ„ ํŒ์ •, `data.dispatch_token` ์„ ์š”๊ตฌํ–ˆ์œผ๋‚˜ ์‹ค์ œ ๋ฐœํ–‰ ๋ช…๋ น์—” ์—†๋˜ ์˜จ๋ณด๋”ฉ ack, ๋ž˜ํผ์˜ ๋ ˆ์ฝ”๋“œ ์ƒํƒœ ์šฐํšŒ์™€ ์‹ ์› ์—†๋Š” ์›๊ฒฉ ๊ฐ์‚ฌ ๊ธฐ๋ก โ€” ๋ชจ๋‘ ํ•ด์†Œ) ์ „์› PASS. ๋ณ‘ํ•ฉ ์›Œํฌ์ŠคํŽ˜์ด์Šค์—์„œ PR head ๋ฅผ detached ์›ŒํฌํŠธ๋ฆฌ์— ๋ฐ›์•„ ์žฌ๊ฒ€ํ† : @f4ba205 claudeยทagyยทgrok PASS; codex ๋Š” ๋ฆฌ๋ทฐ ๋„์ค‘ ํ˜ธ์ŠคํŠธ๊ฐ€ ๋ฐ˜๋ณต ์ ˆ์ „(pmset ๋กœ๊ทธ)์— ๋“ค์–ด๊ฐ€ API ์ŠคํŠธ๋ฆผ์ด ๋Š๊ธฐ๋Š” ๋ฐ”๋žŒ์— ๋ฆฌํฌํŠธ๋ฅผ ๋‚จ๊ธฐ์ง€ ๋ชปํ–ˆ๊ณ , ๋Œ€ํ™” ๊ธฐ๋ก์—์„œ ๋ณต์›ํ•œ ํƒ์นจ 2๊ฑด(ํ™˜๊ฒฝ๋ณ€์ˆ˜ `MAM_AUTO_TRUST=0` ์ด ์Šคํฌ๋ฆฝํŠธ์˜ ๊ธฐ๋ณธ๊ฐ’ export ์— ๋ฎ์—ฌ ํ•ธ๋“ค๋Ÿฌ์— 1 ๋กœ ๋„๋‹ฌ, ์ฟจ๋‹ค์šด์— ๊ฑธ๋ฆฐ ๋‹ค์ด์–ผ๋กœ๊ทธ๊ฐ€ ๊ฐ™์€ ํ™”๋ฉด์˜ ๋‹ค์Œ ๋‹ค์ด์–ผ๋กœ๊ทธ๋ฅผ ๊ฐ€๋ฆผ)์„ 05b0ea4 ๋กœ ์ •์ •. ๋ธํƒ€ ๋ฆฌ๋ทฐ @05b0ea4 claudeยทagyยทgrokยทcodex 4์ธ ์ „์› PASS(identity 4๊ฑด ๋ชจ๋‘ base f4ba205 โ†’ head 05b0ea4, evidence review-tree; codex ๋Š” ๋‘ ์ •์ •์„ ๋˜๋Œ๋ฆฐ ์‚ฌ๋ณธ์—์„œ ์ƒˆ ํšŒ๊ท€๊ฐ€ ์‹คํŒจํ•จ์„ ์—ญ๊ฒ€์ฆ). ๋ฆฌํฌํŠธ์™€ identity ์‚ฌ์ด๋“œ์นด๋Š” `.agents/reports//`. +- **์ „์ฒด ์Šค์œ„ํŠธ (๋ณ‘ํ•ฉ ์›Œํฌ์ŠคํŽ˜์ด์Šค, ์ž์ฒด venv ๊ฐ€ ์žˆ๋Š” ์›ŒํฌํŠธ๋ฆฌ @f4ba205)**: 1111 ํ†ต๊ณผ / 1 skip / ์‹คํŒจ 0 (51๋ถ„ 21์ดˆ, ๋ฆฌ๋ทฐ ๋ฃจํ”„์™€ ๋ณ‘ํ–‰); 05b0ea4 ์—์„œ startup-pathยทresume-dialogsยทTUI readinessยทheadless reconcileยทversion ์Šค์œ„ํŠธ 123 ํ†ต๊ณผ. +- **๋ณ„๊ฑด**: ๋ฆฌ๋ทฐ ๋ฃจํ”„๊ฐ€ diff ๋ฅผ ๋‹จ์ผ argv ๋กœ ๋„˜๊ฒจ Linux ์˜ ๋‹จ์ผ ์ธ์ž ์ƒํ•œ(131,072 ๋ฐ”์ดํŠธ)์„ ๋„˜๋Š” ๋ธŒ๋žœ์น˜ diff ์—์„œ `OSError: [Errno 7]` ๋กœ ์ฃฝ๊ณ  rc 0 ์œผ๋กœ ๋๋‚œ๋‹ค(์ˆ˜์ง‘๊ธฐ ์ƒํ•œ `MAM_DIFF_MAX_BYTES` ๊ธฐ๋ณธ 200000 ์ด ๋” ๋†’๋‹ค). ์ž‘์„ฑ ์ธก์ด ๋ณ„๋„ ์ด์Šˆ๋กœ ๋“ฑ๋ก. ๋น„์ฐจ๋‹จ: ๊ฐ์‚ฌ ๊ธฐ๋ก์˜ ์ƒˆ ํ•„๋“œ(`dispatch_verified`ยท`dispatch_fingerprint`ยท`attempt_id`)๊ฐ€ MESSAGING.mdยทdelegate-job SKILL.md ์— ์—†์Œ, `MAM_INJECT_ACK_GRACE_S` ๊ฐ€ create ์˜ ๋Œ€๊ธฐ์—๋„ 2์ˆœ์œ„๋กœ ์ž‘์šฉํ•œ๋‹ค๋Š” ์ ์ด ๋ฌธ์„œ์— ์—†์Œ, `MAM_AUTO_TRUST` ๋Š” `0` ๋งŒ ๋ˆ๋‹ค๋Š” ์ ๊ณผ ํ”Œ๋ž˜๊ทธ ์šฐ์„  ๊ทœ์น™์ด resume SKILL.md ์— ์—†์Œ, codex ์—…๋ฐ์ดํŠธ ์•ˆ๋‚ด์— `2`(์ด๋ฒˆ๋งŒ ๊ฑด๋„ˆ๋›ฐ๊ธฐ)๋ฅผ ๊ณ ๋ฅด๋ฏ€๋กœ ๋‹ค์Œ ์‹œ์ž‘ ๋•Œ ๋‹ค์‹œ ๋œธ(`3` ์ด๋ฉด ๋‹ค์Œ ๋ฒ„์ „๊นŒ์ง€ ์กฐ์šฉํ•จ), ๋‹ค์ด์–ผ๋กœ๊ทธ ์‹œ๊ทธ๋‹ˆ์ฒ˜๋Š” ํ™”๋ฉด์˜ ๋””๋ ‰ํ„ฐ๋ฆฌ ๊ฒฝ๋กœ๋ฅผ ๊ฒ€์‚ฌํ•˜์ง€ ์•Š์Œ(pane ์ด `-c $WORKSPACE` ๋กœ ๋œจ๋ฏ€๋กœ ์‹ค๋ฌด ์œ„ํ—˜ ๋‚ฎ์Œ), `test_issue24_resume_dialogs.py` 4๊ฑด์ด ๊ฐ ~40 s. + ### ๐Ÿš€ `v4.7.1` โ€” Issue #25 ์Šน๊ฒฉ๋œ ๊ณ„ํš์„œ ์žฌ์‚ฌ์šฉ์€ `--plan-file` ๋กœ๋งŒ: ์•”๋ฌต์  ์ž๋™ ๋กœ๋“œ ์ œ๊ฑฐ, ๋นˆ ๊ณ„ํš ๋ฆฌํฌํŠธ๋Š” ์กฐ์šฉํžˆ ๋„˜์–ด๊ฐ€์ง€ ์•Š์Œ (2026-09-22, PR #32) > **ํŒจ์น˜ ๋งˆ์ผ์Šคํ†ค (PATCH Release)**: `run_loop.sh` ๋ฅผ `--plan` ์—†์ด ์‹คํ–‰ํ•˜๋ฉด ๋Ÿฌ๋„ˆ๊ฐ€ planner ๋ฅผ ์ž๋™์œผ๋กœ ๊ณ ๋ฅด๊ณ  `.agents/reports//plan-*.md` ์ค‘ mtime ์ด ๊ฐ€์žฅ ์ตœ๊ทผ์ธ ํŒŒ์ผ์„ "์ด๋ฏธ ์ˆ˜๋ฆฝ๋œ ๊ณ„ํš์„œ"๋กœ creator ์—๊ฒŒ ๋„˜๊ธฐ๋˜ ๋ฌธ์ œ(Issue #25)๋ฅผ ๊ณ ์ณค๋‹ค. ์ €์žฅ์†Œ์—๋Š” ๋‹ค๋ฅธ ์ด์Šˆ์˜ ๊ณ„ํš์„œ๊ฐ€ ์ˆ˜์‹ญ ๊ฐœ ์ปค๋ฐ‹๋ผ ์žˆ๊ณ  ์ฒดํฌ์•„์›ƒ ์งํ›„์˜ mtime ์€ ์˜๋ฏธ๊ฐ€ ์—†์–ด, ๋ฌด๊ด€ํ•œ ๊ณ„ํš์ด ๊ตฌํ˜„ ํ”„๋กฌํ”„ํŠธ์— ์‹ค๋ ธ๋‹ค(v4.7.0 ๋ณ‘ํ•ฉ ๋ฃจํ”„์—์„œ๋„ Issue #6 ๊ณ„ํš์„œ๊ฐ€ ์‹ค๋ฆฐ ๊ฒƒ์„ ๊ด€์ธก). ์˜ต์…˜์ด ํ•˜๋‚˜ ๋Š˜์ง€๋งŒ ์ž˜๋ชป๋œ ๊ธฐ๋ณธ ๋™์ž‘์„ ์—†์• ๋Š” ์ˆ˜์ •์ด๋ผ PATCH ๋กœ ์˜ฌ๋ฆฐ๋‹ค. diff --git a/tests/conftest.py b/tests/conftest.py index 14ee7ac..951dfb4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -227,6 +227,12 @@ def mam_sandbox(tmp_path, monkeypatch, request): # (`reconcile.sh --subscribe --idle-timeout 0`) from a sandbox. Tests that # exercise the daemon itself override this with "0" or delenv. monkeypatch.setenv("MAM_NO_SUBSCRIBER_DAEMON", "1") + # Issue #23: onboarding delivery now waits for the agent's own `started`. + # No sandbox has a real agent to publish one, so without a short grace + # every create_session test would sit out the full 180 s ack timeout. + # Create-specific on purpose: MAM_INJECT_ACK_GRACE_S is the delegate-job + # wrapper's knob and its own tests assert on the value they pass. + monkeypatch.setenv("MAM_ONBOARD_ACK_S", "3") monkeypatch.delenv("HERDR_SESSION_NAME", raising=False) monkeypatch.delenv("HERDR_SERVER_NAME", raising=False) monkeypatch.delenv("HERDR_WORKSPACE_ID", raising=False) diff --git a/tests/test_b19_headless_reconcile_fixes.py b/tests/test_b19_headless_reconcile_fixes.py index 7e618e4..4f925cd 100644 --- a/tests/test_b19_headless_reconcile_fixes.py +++ b/tests/test_b19_headless_reconcile_fixes.py @@ -282,13 +282,19 @@ if _pane_dialog_open dummy; then echo "DIALOG_OPEN"; else echo "DIALOG_CLOSED"; def test_mam_dialog_tokens_exclude_yes_try_it(): - """N-1 / F-1: token list must not contain Yes, try it; Escape branch stays.""" + """N-1 / F-1: token list must not contain Yes, try it; Escape branch stays. + + Issue #24 moved the per-agent ladder out of lib.sh into the agent adapter, + so the Escape branch is now claude's own `startup_dialogs` entry. The two + token-list assertions are unchanged; only where the branch lives moved. + """ content = open(_LIB_SH, encoding="utf-8").read() modal_line = next(l for l in content.splitlines() if l.startswith("_MAM_MODAL_TOKENS=")) hint_line = next(l for l in content.splitlines() if l.startswith("_MAM_HINT_TOKENS=")) assert "Yes, try it" not in modal_line assert "Yes, try it" not in hint_line - assert "grep -q 'Yes, try it'" in content + adapter = os.path.join(os.path.dirname(_LIB_SH), "lib_py", "agents", "adapters", "claude.py") + assert "('Yes, try it', ['Escape'], 'notice')" in open(adapter, encoding="utf-8").read() def test_wait_for_tui_ready_succeeds_on_fullscreen_tip(): diff --git a/tests/test_c1_tui_readiness.py b/tests/test_c1_tui_readiness.py index e91daba..96a14c7 100644 --- a/tests/test_c1_tui_readiness.py +++ b/tests/test_c1_tui_readiness.py @@ -55,7 +55,16 @@ if _pane_dialog_open dummy; then echo "DIALOG_OPEN"; else echo "DIALOG_CLOSED"; def test_c1b_hint_press_enter_sends_enter(): - """C-1b (B-3): 'Press Enter to continue' alone must return dialog CLOSED, and wait_for_tui_ready must send Enter exactly once per iteration (30 total across 30 iterations, not 60).""" + """C-1b (B-3): 'Press Enter to continue' alone must return dialog CLOSED, + and wait_for_tui_ready must never send Enter twice for one frame. + + Issue #24 replaced "once per iteration" with a time cooldown + (MAM_DIALOG_RESEND_S): a `--source visible` snapshot keeps showing an + accepted dialog for a while, so re-sending on every poll types into a TUI + that already moved on. This test stubs `sleep`, so the wall clock barely + advances and one cooldown window covers the whole run - hence 1, not 30. + The guard the case was written for (never 2 per iteration) is unchanged + and strictly stronger now.""" screen = """Some startup banner Press Enter to continue """ @@ -73,7 +82,7 @@ export MAM_STRONG_READY_TOKENS='NonExistentStrong' export MAM_WEAK_READY_TOKENS='' export MAM_INPUT_PLACEHOLDER='' rc=0 -wait_for_tui_ready dummy-sess custom || rc=$? +wait_for_tui_ready dummy-sess claude || rc=$? echo "RC=$rc" echo "KEYS_COUNT=$(grep -c "send-keys -t dummy-sess Enter" "$KEYS" || echo 0)" rm -f "$KEYS" @@ -81,7 +90,9 @@ rm -f "$KEYS" res = _run_lib_helpers(script) assert res.returncode == 0, res.stderr + res.stdout assert "DIALOG_CLOSED" in res.stdout - assert "KEYS_COUNT=30" in res.stdout + count = int(res.stdout.split("KEYS_COUNT=")[1].split()[0]) + assert count >= 1, "the dialog was never answered: " + res.stdout + assert count <= 30, "one frame must never be answered twice: " + res.stdout def test_c1c_transition_to_upsell_modal_sends_escape_not_enter(): diff --git a/tests/test_issue14_delivery_ack.py b/tests/test_issue14_delivery_ack.py index eec2e3e..15dc9da 100644 --- a/tests/test_issue14_delivery_ack.py +++ b/tests/test_issue14_delivery_ack.py @@ -261,13 +261,21 @@ def test_wrapper_honours_registry_ack_over_unconfirmed_inject(tmp_path, mock_her 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, + # The agent acknowledges the job out of band, as codex does via + # publish_event - the real producer, with the dispatch token the wrapper + # handed it. (Issue #23 review R1: a bare registry status flip is not an + # acknowledgement any more; only the agent's own `started` naming this + # dispatch is. publish_event writes that line locally before its wire + # publish, so an unreachable broker does not hide it.) + tok = json.loads((reg_dir / f"{jid}.json").read_text()).get("dispatch_token") or "" + subprocess.run( + [sys.executable, str(PUBLISH_PY), "--registry-dir", str(reg_dir), "--job", jid, + "--event", "started", "--expect-dispatch-token", tok, "--attempt-id", "1"], + capture_output=True, text=True, cwd=str(tmp_path), env=env, timeout=60, ) - assert ack.returncode == 0, ack.stderr + started_lines = [l for l in (reg_dir / f"{jid}.events.log").read_text().splitlines() + if '"started"' in l] + assert started_lines and tok in started_lines[-1], started_lines 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}" diff --git a/tests/test_issue18_liveness_contract.py b/tests/test_issue18_liveness_contract.py index d2839dd..22d1f45 100644 --- a/tests/test_issue18_liveness_contract.py +++ b/tests/test_issue18_liveness_contract.py @@ -1225,11 +1225,11 @@ def test_acceptance5_remote_runner_completes_a_job_over_the_broker_only(tmp_path pytest.skip("broker unreachable when publishing the dispatch (no CONNACK); the runner never saw the job") _skip_if_broker_did_not_deliver(reg, runner_log) assert proc.returncode == 0, f"rc={proc.returncode}\n{stdout}\n{stderr}\nrunner:\n{runner_log.read_text()}" - # delivery is confirmed by the protocol: the subscriber saw `started`, or - # the registry left pending - to running, or straight to completed when - # the broker delivered only the retained terminal event - assert ("subscriber received started" in stderr or "advanced to 'running'" in stderr - or "advanced to 'completed'" in stderr), stderr + # delivery is confirmed by the protocol: the subscriber's attributed + # audit record of the remote `started` (review R1: the registry status + # is no longer evidence - it can leave pending for reasons that say + # nothing about this dispatch) + assert "recorded 'started'" in stderr, stderr assert _job(reg, jid)["status"] == "completed", \ f"the subscriber mirrors the remote terminal into the registry\n{stdout}\n{stderr}\nrunner:\n{runner_log.read_text()}" assert _job(reg, jid)["transport"] == "remote" diff --git a/tests/test_issue23_24_startup_path.py b/tests/test_issue23_24_startup_path.py new file mode 100644 index 0000000..ddcdac6 --- /dev/null +++ b/tests/test_issue23_24_startup_path.py @@ -0,0 +1,1294 @@ +"""Issue #23 + #24 โ€” the create_session start path. + +Four regressions live here, in the order of the issues: + +1. a herdr call aimed at another server must not carry the caller pane's own + workspace/pane/tab ids (#23 comments: reproduced on Linux and macOS); +2. blocking start-up modals are agent-adapter knowledge and are answered while + waiting for the TUI (#24); +3. onboarding delivery is judged by the agent's own `started` event (#23); +4. a rollback verifies the kill and leaves no `running` row or dangling job (#23). + +What is mocked and what is real is stated per test. The dialog tests fix OUR +contract - which pane string gets which keys - not that the keys drive a real +agy/grok/codex/opencode TUI; no such TUI can be started here. +""" + +import json +import os +import subprocess +import sys +import textwrap + +import pytest + + +# -------------------------------------------------------------------------- +# helpers +# -------------------------------------------------------------------------- + +def _run_lib(tmp_path, body, env=None): + """Source the sandbox lib.sh and run `body`. Unlike the shim-contract + helper this keeps HERDR_* exactly as `env` sets it: these tests are about + what the caller's pane leaks in.""" + lib_path = tmp_path / ".agents" / "skills" / "lib.sh" + script = 'source "%s"\n_init_herdr_isolation\n%s\n' % (lib_path, body) + run_env = dict(os.environ) + for var in ("HERDR_SESSION", "HERDR_SESSION_NAME", "HERDR_WORKSPACE_ID", + "HERDR_PANE_ID", "HERDR_TAB_ID", "HERDR_SOCKET_PATH"): + run_env.pop(var, None) + run_env.update(env or {}) + return subprocess.run(["bash", "-c", script], capture_output=True, + text=True, cwd=str(tmp_path), env=run_env) + + +def _mock_state(mock_herdr): + with open(mock_herdr) as f: + return json.load(f) + + +def _write_mock_state(mock_herdr, state): + with open(mock_herdr, "w") as f: + json.dump(state, f, indent=2) + + +def _seed_agent(mock_herdr, tmp_path, name, workspace_id, pane_id): + state = _mock_state(mock_herdr) + state.setdefault("workspaces", []) + if not any(w.get("workspace_id") == workspace_id for w in state["workspaces"]): + state["workspaces"].append( + {"workspace_id": workspace_id, "label": workspace_id, "cwd": str(tmp_path)}) + state.setdefault("agents", {})[name] = { + "name": name, "agent": "agy", "status": "running", "cwd": str(tmp_path), + "workspace_id": workspace_id, "pane_id": pane_id, "pid": 9999, + "command": "agy", "buffer": "Ready", + } + state.setdefault("panes", []).append( + {"pane_id": pane_id, "workspace_id": workspace_id, "label": name, "buffer": "Ready"}) + _write_mock_state(mock_herdr, state) + + +def _install_env_recorder(tmp_path, monkeypatch): + """A herdr earlier in PATH than the mock that dumps the env it was called + with, then execs the mock. The shim resolves the first non-shim herdr on + PATH, so this observes exactly what reached the real binary.""" + rec_dir = tmp_path / "recorder" + rec_dir.mkdir(parents=True, exist_ok=True) + env_log = tmp_path / "herdr_env.ndjson" + real = tmp_path / "bin" / "herdr" + script = rec_dir / "herdr" + script.write_text(textwrap.dedent('''\ + #!/usr/bin/env python3 + import json, os, sys + with open(%r, "a") as f: + f.write(json.dumps({ + "argv": sys.argv[1:], + "HERDR_WORKSPACE_ID": os.environ.get("HERDR_WORKSPACE_ID"), + "HERDR_PANE_ID": os.environ.get("HERDR_PANE_ID"), + "HERDR_TAB_ID": os.environ.get("HERDR_TAB_ID"), + "HERDR_SESSION": os.environ.get("HERDR_SESSION"), + "HERDR_SESSION_NAME": os.environ.get("HERDR_SESSION_NAME"), + }) + "\\n") + os.execv(%r, [%r] + sys.argv[1:]) + ''') % (str(env_log), str(real), str(real))) + script.chmod(0o755) + monkeypatch.setenv("PATH", "%s:%s" % (rec_dir, os.environ["PATH"])) + return env_log + + +def _recorded(env_log): + if not os.path.exists(env_log): + return [] + return [json.loads(l) for l in open(env_log) if l.strip()] + + +# -------------------------------------------------------------------------- +# item 1 โ€” caller pane scope must not leak across servers +# -------------------------------------------------------------------------- + +CROSS = {"HERDR_SESSION": "default", "HERDR_SESSION_NAME": "mam-isolated", + "HERDR_WORKSPACE_ID": "w5", "HERDR_PANE_ID": "w5:p2", + "HERDR_TAB_ID": "w5:t1"} + +# The orchestrator pane as the live trial of this branch measured it (herdr +# 0.8.2): no HERDR_SESSION at all, the default server's socket, and that +# server's own ids. This is the env in which the HERDR_SESSION-based verdict +# was always "not crossing" and #23 reproduced unchanged. +DEFAULT_SOCK = "/home/someone/.config/herdr/herdr.sock" +NAMED_SOCK = "/home/someone/.config/herdr/sessions/%s/herdr.sock" +TRIAL_PANE = {"HERDR_SOCKET_PATH": DEFAULT_SOCK, "HERDR_WORKSPACE_ID": "w6", + "HERDR_PANE_ID": "w6:p1", "HERDR_TAB_ID": "w6:t1", + "HERDR_SESSION_NAME": "multi-agent-mux"} + + +def test_s1_cross_server_call_drops_the_caller_panes_scope_ids( + mam_sandbox, mock_herdr, mock_agents, monkeypatch): + """S1 (mock herdr): the caller sits in another server's pane. has-session, + kill-session and agent prompt must find the agent, and the three ids must + not reach herdr.""" + tmp_path = mam_sandbox + env_log = _install_env_recorder(tmp_path, monkeypatch) + _seed_agent(mock_herdr, tmp_path, "creator-agy-01", "w1", "w1:p5") + + res = _run_lib(tmp_path, 'herdr has-session -t creator-agy-01', env=CROSS) + assert res.returncode == 0, res.stdout + res.stderr + + res = _run_lib(tmp_path, 'herdr agent prompt creator-agy-01 "hello"', env=CROSS) + assert res.returncode == 0, res.stdout + res.stderr + + res = _run_lib(tmp_path, 'herdr kill-session -t creator-agy-01', env=CROSS) + assert res.returncode == 0, res.stdout + res.stderr + + calls = _recorded(env_log) + assert calls, "the recorder saw no herdr call" + for call in calls: + assert call["HERDR_WORKSPACE_ID"] is None, call + assert call["HERDR_PANE_ID"] is None, call + assert call["HERDR_TAB_ID"] is None, call + + # the kill really happened, i.e. this is not a silent no-op + assert "creator-agy-01" not in _mock_state(mock_herdr).get("agents", {}) + + +def test_s1b_the_delegate_job_wrappers_bare_herdr_is_the_lib_sh_function( + mam_sandbox, mock_herdr, mock_agents, monkeypatch): + """S1b (mock herdr): the wrapper sources lib.sh and then calls a bare + `herdr` (wrapper :918 preflight). A shell function beats PATH, so that call + is mam_herdr and is fixed by item 1 too - this is why #23's preflight + failure is cured by the same change.""" + tmp_path = mam_sandbox + _install_env_recorder(tmp_path, monkeypatch) + _seed_agent(mock_herdr, tmp_path, "creator-agy-01", "w1", "w1:p5") + + res = _run_lib(tmp_path, 'echo "kind=$(type -t herdr)"\nherdr has-session -t creator-agy-01', + env=CROSS) + assert "kind=function" in res.stdout, res.stdout + res.stderr + assert res.returncode == 0, res.stdout + res.stderr + + +def test_s2_same_server_keeps_the_explicit_workspace_scope( + mam_sandbox, mock_herdr, mock_agents, monkeypatch): + """S2 (mock herdr): on the target's own server the exported scope still + scopes - a same-named agent in another workspace is not addressed (H-22).""" + tmp_path = mam_sandbox + _seed_agent(mock_herdr, tmp_path, "creator-agy-01", "w1", "w1:p5") + same = {"HERDR_SESSION": "mam-isolated", "HERDR_SESSION_NAME": "mam-isolated", + "HERDR_WORKSPACE_ID": "w2"} + + before = len(_mock_state(mock_herdr).get("calls", [])) + res = _run_lib(tmp_path, 'herdr agent prompt creator-agy-01 "hello from w2"', env=same) + assert res.returncode != 0, res.stdout + res.stderr + new = _mock_state(mock_herdr).get("calls", [])[before:] + prompts = [c for c in new if len(c) >= 2 and c[0] == "agent" and c[1] == "prompt"] + assert prompts == [], new + + +def test_s2b_a_caller_outside_any_pane_keeps_the_explicit_scope( + mam_sandbox, mock_herdr, mock_agents): + """S2b (mock herdr): no HERDR_SESSION means the caller is not in a pane at + all, so an exported HERDR_WORKSPACE_ID is a deliberate scope, not a leak. + This is the case H-18/H-21/H-23 fix.""" + tmp_path = mam_sandbox + _seed_agent(mock_herdr, tmp_path, "creator-agy-01", "w1", "w1:p5") + + res = _run_lib(tmp_path, 'herdr has-session -t creator-agy-01', + env={"HERDR_SESSION_NAME": "mam-isolated", "HERDR_WORKSPACE_ID": "w2"}) + assert res.returncode == 1, "an explicit out-of-scope id must still hide the agent" + + res = _run_lib(tmp_path, 'herdr has-session -t creator-agy-01', + env={"HERDR_SESSION_NAME": "mam-isolated", "HERDR_WORKSPACE_ID": "w1"}) + assert res.returncode == 0, res.stdout + res.stderr + + +def test_s1c_the_real_default_server_pane_is_a_crossing( + mam_sandbox, mock_herdr, mock_agents, monkeypatch): + """S1c (trial finding, req. 4): the caller's server comes from the socket + path when HERDR_SESSION is absent. In the measured orchestrator pane the + three ids must be dropped and the agent on the named server found.""" + tmp_path = mam_sandbox + env_log = _install_env_recorder(tmp_path, monkeypatch) + _seed_agent(mock_herdr, tmp_path, "trial-agy", "w1", "w1:p5") + + res = _run_lib(tmp_path, 'herdr has-session -t trial-agy', env=TRIAL_PANE) + assert res.returncode == 0, "the trial's exact failure: " + res.stdout + res.stderr + + calls = _recorded(env_log) + assert calls, "the recorder saw no herdr call" + for call in calls: + assert call["HERDR_WORKSPACE_ID"] is None, call + assert call["HERDR_PANE_ID"] is None, call + assert call["HERDR_TAB_ID"] is None, call + + +def test_s1d_the_verdict_itself_on_the_trial_pane(mam_sandbox): + """S1d: the function the trial printed, in the trial's env: crossing.""" + res = _run_lib(mam_sandbox, + 'echo "caller=$(_herdr_caller_server)"\n' + 'if _herdr_call_is_cross_server; then echo VERDICT=crossing; ' + 'else echo VERDICT=not-crossing; fi', + env=TRIAL_PANE) + assert "caller=default" in res.stdout, res.stdout + res.stderr + assert "VERDICT=crossing" in res.stdout, res.stdout + res.stderr + + +def test_s1e_a_named_server_pane_calling_its_own_server_keeps_its_scope( + mam_sandbox, mock_herdr, mock_agents): + """S1e (req. 4): a caller in a named server's pane (socket under + sessions//) targeting that same server is not a crossing - the + exported workspace id still scopes, as in S2.""" + tmp_path = mam_sandbox + _seed_agent(mock_herdr, tmp_path, "creator-agy-01", "w1", "w1:p5") + same = {"HERDR_SOCKET_PATH": NAMED_SOCK % "mam-isolated", + "HERDR_SESSION_NAME": "mam-isolated", "HERDR_WORKSPACE_ID": "w2"} + res = _run_lib(tmp_path, 'echo "caller=$(_herdr_caller_server)"\nherdr has-session -t creator-agy-01', + env=same) + assert "caller=mam-isolated" in res.stdout, res.stdout + res.stderr + assert res.returncode == 1, "same server: the explicit w2 scope must still hide a w1 agent" + + +def test_s1f_a_named_server_pane_calling_another_named_server_is_a_crossing( + mam_sandbox, mock_herdr, mock_agents): + """S1f: named socket, different named target, no HERDR_SESSION - crossing.""" + tmp_path = mam_sandbox + _seed_agent(mock_herdr, tmp_path, "creator-agy-01", "w1", "w1:p5") + other = {"HERDR_SOCKET_PATH": NAMED_SOCK % "mam-other", + "HERDR_SESSION_NAME": "mam-isolated", "HERDR_WORKSPACE_ID": "w2"} + res = _run_lib(tmp_path, 'herdr has-session -t creator-agy-01', env=other) + assert res.returncode == 0, res.stdout + res.stderr + + +def test_s1g_herdr_session_wins_over_the_socket_when_both_are_present(mam_sandbox): + """S1g (req. 1): HERDR_SESSION, when a herdr exports it, is authoritative + over the socket-derived name.""" + res = _run_lib(mam_sandbox, 'echo "caller=$(_herdr_caller_server)"', + env={"HERDR_SESSION": "from-herdr", "HERDR_SOCKET_PATH": DEFAULT_SOCK}) + assert "caller=from-herdr" in res.stdout, res.stdout + res.stderr + + +def test_s1h_an_unrecognised_socket_layout_is_not_in_a_pane(mam_sandbox, mock_herdr, mock_agents): + """S1h: a socket path that is neither layout yields no caller server, so + an exported scope is respected (today's behaviour), never guessed away.""" + tmp_path = mam_sandbox + _seed_agent(mock_herdr, tmp_path, "creator-agy-01", "w1", "w1:p5") + res = _run_lib(tmp_path, 'echo "caller=[$(_herdr_caller_server)]"\nherdr has-session -t creator-agy-01', + env={"HERDR_SOCKET_PATH": "/tmp/odd/herdr.sock", + "HERDR_SESSION_NAME": "mam-isolated", "HERDR_WORKSPACE_ID": "w2"}) + assert "caller=[]" in res.stdout, res.stdout + res.stderr + assert res.returncode == 1, res.stdout + res.stderr + + +def test_s3_shim_contract_regressions_still_pass(mam_sandbox): + """S3: H-18 / H-21 / H-23 are the tests that fix today's scoping meaning. + Run them explicitly as this change's regression guard.""" + repo = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + res = subprocess.run( + [sys.executable, "-m", "pytest", "-q", + "-p", "no:cacheprovider", + "tests/test_herdr_shim_contract.py", "-k", + "h18 or h21 or h22 or h23"], + capture_output=True, text=True, cwd=repo, + env=dict(os.environ, PYTHONDONTWRITEBYTECODE="1")) + assert res.returncode == 0, res.stdout[-4000:] + res.stderr[-2000:] + + +def test_s11_this_branch_does_not_change_the_generated_shim(mam_sandbox): + """S11: the item-1 fix lives in mam_herdr, never in the heredoc that + lib.sh materialises into $WORKSPACE_ROOT/.mam/shim/herdr. That file is + shared by every checkout bound to one control plane, so keeping it + byte-identical to base is what makes "the control plane is not modified" + true at runtime.""" + repo = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + base = os.environ.get("MAM_BASE_SHA", "1e78dae") + show = subprocess.run(["git", "show", "%s:.agents/skills/lib.sh" % base], + capture_output=True, text=True, cwd=repo) + if show.returncode != 0: + pytest.skip("base %s not available in this checkout" % base) + with open(os.path.join(repo, ".agents", "skills", "lib.sh")) as f: + head = f.read() + assert _heredoc_body(head) == _heredoc_body(show.stdout), ( + "the shim heredoc changed: the generated .mam/shim/herdr of this branch " + "would differ from the control root's, and the two would overwrite " + "each other on every herdr call") + + +def _heredoc_body(text): + lines = text.splitlines(True) + start = None + for i, line in enumerate(lines): + if line.strip() == "cat <<'EOF' > \"$tmp_file\"": + start = i + 1 + break + assert start is not None, "shim heredoc not found in lib.sh" + for j in range(start, len(lines)): + if lines[j].rstrip("\n") == "EOF": + return "".join(lines[start:j]) + raise AssertionError("unterminated shim heredoc") + + +@pytest.mark.parametrize("env,expected_rc", [(CROSS, 1), ({"HERDR_SESSION_NAME": "mam-isolated"}, 1)]) +def test_s12_mam_herdr_never_aborts_its_caller_under_set_e( + mam_sandbox, mock_herdr, mock_agents, env, expected_rc): + """S12 (mock herdr): mam_herdr runs inside scripts using `set -euo + pipefail`. Neither branch of the cross-server test may abort the shell, + and the child's rc must be the function's rc.""" + tmp_path = mam_sandbox + body = ('set -euo pipefail\n' + 'rc=0\n' + 'mam_herdr has-session -t nobody-here || rc=$?\n' + 'echo "missing_rc=$rc"\n' + '_seen=0\n' + 'mam_herdr has-session -t creator-agy-01 || _seen=$?\n' + 'echo "present_rc=$_seen"\n' + 'echo REACHED_END\n') + _seed_agent(mock_herdr, tmp_path, "creator-agy-01", "w1", "w1:p5") + lib_path = tmp_path / ".agents" / "skills" / "lib.sh" + script = 'source "%s"\n%s' % (lib_path, body) + run_env = dict(os.environ) + for var in ("HERDR_SESSION", "HERDR_SESSION_NAME", "HERDR_WORKSPACE_ID", + "HERDR_PANE_ID", "HERDR_TAB_ID"): + run_env.pop(var, None) + run_env.update(env) + res = subprocess.run(["bash", "-c", script], capture_output=True, text=True, + cwd=str(tmp_path), env=run_env) + assert "REACHED_END" in res.stdout, res.stdout + res.stderr + assert "missing_rc=%d" % expected_rc in res.stdout, res.stdout + res.stderr + assert "present_rc=0" in res.stdout, res.stdout + res.stderr + + +def test_s13_the_loop_watcher_applies_the_same_rule(mam_sandbox): + """S13 (pure unit): watcher.py builds its own herdr argv and never goes + through mam_herdr, so it needs the same rule.""" + repo = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + sys.path.insert(0, os.path.join(repo, ".agents", "skills")) + from lib_py.loop import watcher + + caller_pane = {"HERDR_SESSION": "default", "HERDR_WORKSPACE_ID": "w5", + "HERDR_PANE_ID": "w5:p2", "HERDR_TAB_ID": "w5:t1", "PATH": "/usr/bin"} + + cross = watcher.scoped_env("mam-isolated", environ=caller_pane) + assert "HERDR_WORKSPACE_ID" not in cross and "HERDR_PANE_ID" not in cross + assert "HERDR_TAB_ID" not in cross and cross["PATH"] == "/usr/bin" + + same = watcher.scoped_env("default", environ=caller_pane) + assert same["HERDR_WORKSPACE_ID"] == "w5" + + outside = watcher.scoped_env("mam-isolated", environ={"HERDR_WORKSPACE_ID": "w5"}) + assert outside["HERDR_WORKSPACE_ID"] == "w5" + + none_target = watcher.scoped_env(None, environ=caller_pane) + assert none_target["HERDR_WORKSPACE_ID"] == "w5" + + # trial finding: no HERDR_SESSION, the caller's server comes from the socket + trial = {"HERDR_SOCKET_PATH": DEFAULT_SOCK, "HERDR_WORKSPACE_ID": "w6", + "HERDR_PANE_ID": "w6:p1", "HERDR_TAB_ID": "w6:t1"} + assert watcher.caller_server(trial) == "default" + assert "HERDR_WORKSPACE_ID" not in watcher.scoped_env("multi-agent-mux", environ=trial) + named_same = {"HERDR_SOCKET_PATH": NAMED_SOCK % "iso-x", "HERDR_WORKSPACE_ID": "w2"} + assert watcher.caller_server(named_same) == "iso-x" + assert watcher.scoped_env("iso-x", environ=named_same)["HERDR_WORKSPACE_ID"] == "w2" + assert "HERDR_WORKSPACE_ID" not in watcher.scoped_env("iso-y", environ=named_same) + assert watcher.caller_server({"HERDR_SOCKET_PATH": "/tmp/odd/herdr.sock"}) == "" + assert watcher.caller_server({"HERDR_SESSION": "s", "HERDR_SOCKET_PATH": DEFAULT_SOCK}) == "s" + + seen = {} + + def _run(argv, **kw): + seen["env"] = kw.get("env") + + class P: + returncode, stdout, stderr = 0, "{}", "" + return P() + + watcher.wait_once("agt", session="iso-x", run=_run) + assert seen["env"] is not None and "HERDR_SESSION" not in ("",) + + +# -------------------------------------------------------------------------- +# item 2 โ€” start-up dialogs come from the adapter (#24) +# +# MOCKED, and this is the limit of it: the pane text is a fixture string and +# send-keys is a shell stub, so these fix OUR contract - which screen signature +# gets which keys, once. They do NOT show that the keys drive a real agy, grok, +# codex or opencode TUI; no such TUI can be started in this environment. That +# check is a live herdr run after the loop. +# -------------------------------------------------------------------------- + +def _run_dialogs(tmp_path, agent, pane_text, timeout=1, env=None, prelude=""): + lib = tmp_path / ".agents" / "skills" / "lib.sh" + pane_file = tmp_path / "pane.txt" + pane_file.write_text(pane_text) + keys_log = tmp_path / "sent_keys.txt" + if keys_log.exists(): + keys_log.unlink() + script = ''' +source "%s" +_pane_tail() { cat "%s"; } +_sks_herdr() { printf '%%s\\n' "$*" >> "%s"; } +%s +handle_startup_dialogs test-sess %d %s +echo "rc=$?" +''' % (lib, pane_file, keys_log, prelude, timeout, agent) + run_env = dict(os.environ) + run_env.pop("MAM_AUTO_TRUST", None) + for k, v in (env or {}).items(): + if v == "": + run_env.pop(k, None) # "" means: not in the environment at all + else: + run_env[k] = v + res = subprocess.run(["bash", "-c", script], capture_output=True, text=True, + cwd=str(tmp_path), env=run_env) + sent = keys_log.read_text().splitlines() if keys_log.exists() else [] + return res, sent + + +AGENT_DIALOGS = [ + ("agy", "Do you trust the contents of this project?\n > Yes, I trust this folder", + ["send-keys -t test-sess Enter"]), + ("grok", "Do you trust the contents of this directory?", + ["send-keys -t test-sess y"]), + ("codex", "Do you trust the contents of this directory?\n > 1. Yes, continue", + ["send-keys -t test-sess Enter"]), + ("codex", "Update available\n 1. Update now\n 2. Skip\n 3. Skip until next version", + ["send-keys -t test-sess 2"]), + ("claude", "Do you trust the files in this folder?\n Yes, I trust this folder", + ["send-keys -t test-sess Enter"]), + ("claude", "Try the new fullscreen renderer?\n Yes, try it", + ["send-keys -t test-sess Escape"]), +] + + +@pytest.mark.parametrize("agent,pane,expected", AGENT_DIALOGS) +def test_s4_each_agents_dialog_signature_gets_its_own_keys( + mam_sandbox, agent, pane, expected): + """S4 (stubbed pane + stubbed send-keys): the adapter's signature is + matched and exactly its keys are sent.""" + res, sent = _run_dialogs(mam_sandbox, agent, pane) + assert sent == expected, (res.stdout, res.stderr, sent) + + +def test_s4b_every_adapter_publishes_the_fact(mam_sandbox): + """S4b (real `lib_py.agents facts`): MAM_STARTUP_DIALOGS is part of the + facts contract for every agent, so the shell never has to know an agent + name. An agent with no known blocking modal publishes an empty list.""" + repo = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + skills = os.path.join(repo, ".agents", "skills") + seen = {} + for agent in ("claude", "agy", "grok", "codex", "opencode", "hermes"): + out = subprocess.run( + [sys.executable, "-m", "lib_py.agents", + "facts", agent], + capture_output=True, text=True, cwd=repo, + env=dict(os.environ, PYTHONPATH=skills)).stdout + line = [l for l in out.splitlines() if l.startswith("MAM_STARTUP_DIALOGS=")] + assert line, "no MAM_STARTUP_DIALOGS for %s" % agent + seen[agent] = json.loads(line[0].split("=", 1)[1].strip("'")) + for agent in ("claude", "agy", "grok", "codex"): + assert seen[agent], "%s must publish its start-up dialogs" % agent + for pattern, keys, kind in seen[agent]: + assert kind in ("trust", "notice"), (agent, kind) + assert keys, (agent, pattern) + assert seen["opencode"] == [] and seen["hermes"] == [] + + +def test_s5_an_accepted_dialog_still_on_screen_is_not_resent(mam_sandbox): + """S5 (stubbed pane + stubbed send-keys): #24's comment reports that + `--source visible` keeps showing the dialog for a while after it was + accepted, and _pane_capture hardcodes that source. The pane here never + changes, so a frame-count rule would send the keys again every poll; the + time cooldown must keep it at one.""" + res, sent = _run_dialogs( + mam_sandbox, "grok", "Do you trust the contents of this directory?", + timeout=3, env={"MAM_DIALOG_RESEND_S": "60"}) + assert sent == ["send-keys -t test-sess y"], (res.stdout, res.stderr, sent) + + +def test_s5b_the_cooldown_expiring_allows_one_more_try(mam_sandbox): + """S5b: the cooldown is a rate limit, not a one-shot - a dialog that really + did not take the keys is retried once the window passes.""" + res, sent = _run_dialogs( + mam_sandbox, "grok", "Do you trust the contents of this directory?", + timeout=4, env={"MAM_DIALOG_RESEND_S": "1"}) + assert len(sent) >= 2, (res.stdout, res.stderr, sent) + assert set(sent) == {"send-keys -t test-sess y"}, sent + + +def test_s5c_readiness_is_judged_by_the_ready_tokens_not_by_the_dialog_leaving( + mam_sandbox): + """S5c: a ready screen returns 0 without sending anything.""" + res, sent = _run_dialogs(mam_sandbox, "grok", "Grok ready\n>>> ", timeout=3) + assert sent == [], sent + assert "rc=0" in res.stdout, res.stdout + res.stderr + + +READY_FRAMES = [ + ("claude", "Welcome to Claude Code\nโฏ "), + ("agy", "Antigravity Ready in /tmp\n> "), + ("grok", "Grok Ready\n>>> "), + ("codex", "Codex\nAsk Codex to do anything"), + ("opencode", "OpenCode\nAsk anything"), +] + + +@pytest.mark.parametrize("agent,frame", READY_FRAMES) +def test_s5d_a_ready_first_frame_returns_within_one_iteration_with_no_env( + mam_sandbox, agent, frame): + """S5d (review B-1): called on its own - as resume_session.sh calls it, + with no wait_for_tui_ready before it and no MAM_*READY* in the environment + - the handler must recognise the adapter's ready banner on the first frame + and return at once, not idle through its timeout. `sleep` is stubbed so + the iteration count is observable and the run is fast; base returned in + 0.03s, the version under review took the full timeout.""" + res, sent = _run_dialogs( + mam_sandbox, agent, frame, timeout=20, + env={"MAM_STRONG_READY_TOKENS": "", "MAM_READY_TOKENS": "", "MAM_WEAK_READY_TOKENS": ""}, + prelude='_iters=0\nsleep() { _iters=$((_iters + 1)); }\n' + 'trap \'echo "iterations=$_iters"\' EXIT') + assert "rc=0" in res.stdout, res.stdout + res.stderr + assert sent == [], sent + iters = int(res.stdout.split("iterations=")[1].split()[0]) + assert iters <= 1, "the handler idled %d iterations on a ready screen" % iters + + +def test_s5e_caller_set_ready_tokens_are_not_overwritten(mam_sandbox): + """S5e: the gap-fill must not clobber tokens a caller set on purpose - + wait_for_tui_ready's own evaluation, or a test steering readiness.""" + res, sent = _run_dialogs( + mam_sandbox, "grok", "Grok Ready\n>>> ", timeout=2, + env={"MAM_STRONG_READY_TOKENS": "NonExistentStrong", "MAM_READY_TOKENS": "NonExistentStrong"}, + prelude='_iters=0\nsleep() { _iters=$((_iters + 1)); }\n' + 'trap \'echo "iterations=$_iters"\' EXIT') + iters = int(res.stdout.split("iterations=")[1].split()[0]) + assert iters >= 2, "the caller's tokens were replaced by the adapter's: " + res.stdout + + +def test_s6_no_auto_trust_leaves_trust_dialogs_alone_but_answers_notices( + mam_sandbox): + """S6 (stubbed): --no-auto-trust. codex trust is left to a human with a + warning; the update notice still gets answered, since skipping it changes + nothing and it blocks the session just as hard.""" + res, sent = _run_dialogs( + mam_sandbox, "codex", "Do you trust the contents of this directory?\n > 1. Yes, continue", + timeout=1, env={"MAM_AUTO_TRUST": "0"}) + assert sent == [], sent + assert "no-auto-trust" in res.stderr, res.stderr + + res, sent = _run_dialogs( + mam_sandbox, "codex", "Update available\n 1. Update now\n 3. Skip until next version", + timeout=1, env={"MAM_AUTO_TRUST": "0"}) + assert sent == ["send-keys -t test-sess 2"], (res.stderr, sent) + + +def test_s6c_a_skipped_trust_dialog_does_not_hide_the_notice_below_it(mam_sandbox): + """S6c (merge review, codex probe): with --no-auto-trust and a codex screen + that shows the trust prompt and the update notice together, the trust + prompt is skipped and recorded, and the skip's cooldown must not stop the + picker from answering the notice on the next poll.""" + res, sent = _run_dialogs( + mam_sandbox, "codex", + "Do you trust the contents of this directory?\n > 1. Yes, continue\n" + "Update available\n 1. Update now\n 2. Skip\n 3. Skip until next version", + timeout=3, env={"MAM_AUTO_TRUST": "0", "MAM_DIALOG_RESEND_S": "30"}) + assert sent == ["send-keys -t test-sess 2"], (res.stderr, sent) + assert "no-auto-trust" in res.stderr, res.stderr + + +def test_s5f_a_dialog_inside_its_cooldown_does_not_block_the_next_one(mam_sandbox): + """S5f (merge review, codex probe): two dialogs on one screen, auto-trust on. + The trust prompt is answered first; while it is rate-limited the notice + must still be answered, once each.""" + res, sent = _run_dialogs( + mam_sandbox, "codex", + "Do you trust the contents of this directory?\n > 1. Yes, continue\n" + "Update available\n 1. Update now\n 2. Skip\n 3. Skip until next version", + timeout=3, env={"MAM_DIALOG_RESEND_S": "60"}) + assert sent == ["send-keys -t test-sess Enter", "send-keys -t test-sess 2"], (res.stderr, sent) + + +def test_s6b_create_session_no_longer_gates_dialogs_on_claude(mam_sandbox): + """S6b (source read): the #24 root cause was the `AGENT = claude` gate at + the create_session call site, so the gate's absence is the regression.""" + repo = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + src = open(os.path.join(repo, ".agents", "skills", "multi-agent-mux-create", + "scripts", "create_session.sh")).read() + call = [l for l in src.splitlines() if "handle_startup_dialogs" in l] + assert call, "create_session no longer answers start-up dialogs at all" + assert all('"$AGENT"' in l for l in call), call + assert 'if [ "$AGENT" = "claude" ] && [ -z "$LAST_VISIBLE_STATUS_OVERRIDE" ]' not in src + + +# -------------------------------------------------------------------------- +# item 3 โ€” onboarding delivery is judged by the agent's own `started` (#23) +# -------------------------------------------------------------------------- + +def _create_sh(tmp_path): + return tmp_path / ".agents" / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh" + + +def _extract(path, start_marker, end_marker, close=None): + """Lift a block out of create_session.sh so the test runs the shipped code + rather than a paraphrase of it. A moved marker fails loudly here. + + `close` closes the construct the end marker opens: the block runs to the + first line equal to it after the end marker (e.g. the `fi` of an `if`).""" + lines = open(path).read().splitlines(True) + start = next((i for i, l in enumerate(lines) if start_marker in l), None) + assert start is not None, "start marker %r is gone from %s" % (start_marker, path) + end = next((i for i, l in enumerate(lines[start:], start) if end_marker in l), None) + assert end is not None, "end marker %r is gone from %s" % (end_marker, path) + if close is not None: + end = next((i for i, l in enumerate(lines[end:], end) if l.strip() == close), None) + assert end is not None, "block opened at %r is never closed with %r" % (end_marker, close) + return "".join(lines[start:end + 1]) + + +SKILLS = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + ".agents", "skills") +REGISTRY_PY = os.path.join(SKILLS, "multi-agent-mux-delegate-job", "scripts", "registry.py") +PUBLISH_PY = os.path.join(SKILLS, "multi-agent-mux-delegate-job", "scripts", "publish_event.py") + + +def _producer_env(tmp_path): + env = dict(os.environ) + env.pop("MAM_ENV_FILE", None) # PoC mode, no broker: local-first is enough + env["HOME"] = str(tmp_path) + env["DELEGATE_JOB_LOGS_DIR"] = str(tmp_path / ".mam" / "delegate_job_logs") + return env + + +def _seed_job(tmp_path, job_id="job1", status="pending", started_token=None, + publish_args=None): + """A job record made by the real registry and, when asked, a `started` + published by the REAL publish_event.py - never a hand-written line. + + Trial 2 found the previous fixture writing `dispatch_token` into the + started line itself, a field the real producer only writes when its + command line carries --expect-dispatch-token. A fake producer that differs + from the real one let five reviewers pass a waiter no real agent could + satisfy. `started_token` here is what the *command line* passes; the line + is whatever publish_event makes of it. `publish_args` runs an arbitrary + publish instead (e.g. the old create_session command shape). + """ + jobs = tmp_path / ".mam" / "jobs" + jobs.mkdir(parents=True, exist_ok=True) + # a seed is a fresh record: the real registry refuses to re-register an + # existing id, and several cases seed the same id more than once + for stale in (jobs / ("%s.json" % job_id), jobs / ("%s.events.log" % job_id)): + if stale.exists(): + stale.unlink() + env = _producer_env(tmp_path) + reg = subprocess.run([sys.executable, REGISTRY_PY, "--registry-dir", str(jobs), "register", + "--prompt", "onboarding", "--agent", "claude-code", + "--agent-session", "herdr:x", "--dispatch-token", "tok-1", + "--job-id", job_id, "--ack-timeout", "2"], + capture_output=True, text=True, env=env) + assert reg.returncode == 0, reg.stdout + reg.stderr + if started_token is not None or publish_args is not None: + cmd = [sys.executable, PUBLISH_PY, "--registry-dir", str(jobs), "--job", job_id, + "--event", "started"] + if publish_args is not None: + cmd += list(publish_args) + else: + cmd += ["--expect-dispatch-token", started_token, "--attempt-id", "1"] + pub = subprocess.run(cmd, capture_output=True, text=True, env=env, timeout=60) + # a mismatched token is refused by the producer's own CAS check; that + # is fine here - the point is what (if anything) reached the log + if started_token in (None, "tok-1"): + assert pub.returncode == 0, pub.stdout + pub.stderr + if status != "pending": + rec = jobs / ("%s.json" % job_id) + d = json.loads(rec.read_text()) + d["status"] = status + rec.write_text(json.dumps(d)) + return jobs + + +def _run_delivery(tmp_path, inject_rc, job_id="job1", seed_events=True, **seed): + """Run create_session.sh's own delivery block with a stubbed injection.""" + lib = tmp_path / ".agents" / "skills" / "lib.sh" + block = _extract(_create_sh(tmp_path), "# Inject instructions into the herdr pane", + "delegate_wait_started", close="fi") + if seed_events: + _seed_job(tmp_path, job_id, **seed) + pub_log = tmp_path / "published.txt" + script = ''' +set -euo pipefail +source "%s" +SESSION_NAME=test-sess +DELEGATE_JOB_ID=%s +DELEGATE_JOB_TOKEN=tok-1 +DELEGATE_JOB_ATTEMPT=1 +DELEGATE_JOB_ACK_S=2 +instructions="hello" +inject_instructions() { return %d; } +delegate_publish_event() { printf '%%s\\n' "$2" >> "%s"; } +%s +echo DELIVERED +''' % (lib, job_id, inject_rc, pub_log, block) + env = dict(os.environ, MAM_JOBS_DIR=str(tmp_path / ".mam" / "jobs")) + res = subprocess.run(["bash", "-c", script], capture_output=True, text=True, + cwd=str(tmp_path), env=env) + published = pub_log.read_text().split() if pub_log.exists() else [] + return res, published + + +def test_s7_create_session_no_longer_publishes_started_for_the_agent(mam_sandbox): + """S7 (source): create_session used to publish `started` itself, so the + registry showed a started job even when the agent never saw the prompt. + With that line present no started-based verdict can mean anything.""" + src = open(_create_sh(mam_sandbox)).read() + assert 'delegate_publish_event "$DELEGATE_JOB_ID" started' not in src + assert "delegate_wait_started" in src + + +def test_s7b_delivery_succeeds_on_the_agents_own_started(mam_sandbox): + """S7b (real registry files, stubbed injection): a started line carrying + this dispatch token is the evidence.""" + res, published = _run_delivery(mam_sandbox, 0, started_token="tok-1") + assert "DELIVERED" in res.stdout, res.stdout + res.stderr + assert published == [], published + + +def test_s7c_a_started_that_was_followed_by_a_terminal_status_still_counts(mam_sandbox): + """S7c: a job that started and then finished (or failed) fast still shows + its started line - the evidence is the line, whatever the status became.""" + for status in ("running", "completed", "error"): + res, _ = _run_delivery(mam_sandbox, 0, status=status, started_token="tok-1") + assert "DELIVERED" in res.stdout, (status, res.stdout + res.stderr) + + +@pytest.mark.parametrize("status", ["error", "cancelled"]) +def test_s7f_a_job_closed_by_someone_else_is_not_an_acknowledgement(mam_sandbox, status): + """S7f (review R1 + req. 4): a record can leave `pending` because another + writer closed it. That is not evidence - the wait runs to its deadline + rather than returning early (the 'no started event naming this dispatch' + path, never the DELIVERED-without-warning path of S7c) - and, the job + being already terminal, nothing is published over it (req. 4).""" + res, published = _run_delivery(mam_sandbox, 0, status=status) + assert published == [], (status, published, res.stderr) + assert "no started event naming this dispatch" in res.stderr, res.stderr + assert "already '%s'" % status in res.stderr, res.stderr + + +def test_s7g_another_dispatch_running_is_not_this_dispatchs_started(mam_sandbox): + """S7g (review R1): `running` set by a different dispatch's claim says + nothing about this prompt.""" + res, published = _run_delivery(mam_sandbox, 0, status="running") + assert published == ["error"], (published, res.stderr) + + +def test_s7h_the_line_is_parsed_not_pattern_matched(mam_sandbox): + """S7h (review N-4), the one SYNTHETIC line in this file, on purpose: the + parser must not depend on key order. Every other started line here comes + from the real producer (S7b/S7c/S7e/S8, S7i-S7k).""" + tmp_path = mam_sandbox + jobs = _seed_job(tmp_path, "job1") + reordered = {"data": {"attempt_id": 1, "dispatch_token": "tok-1"}, "event": "started", + "job_id": "job1"} + (jobs / "job1.events.log").write_text(json.dumps(reordered) + "\n") + res, published = _run_delivery(mam_sandbox, 0, seed_events=False) + assert "DELIVERED" in res.stdout, res.stdout + res.stderr + assert published == [], published + + +# ---- trial-2 regression: the real agent must be able to satisfy the ack ---- + +def test_s7i_the_old_create_session_command_shape_could_never_be_acknowledged(mam_sandbox): + """S7i (trial 2): the command create_session used to hand the agent had no + --expect-dispatch-token, so the REAL producer wrote a started with no + token, and a token-checking waiter never accepted it: 180 s idle, then a + false 'unconfirmed' over a finished onboarding. Pinned as the negative it + is, so the fixture can never drift back to faking the token.""" + tmp_path = mam_sandbox + jobs = _seed_job(tmp_path, "job1", publish_args=[]) # old shape: no token args + line = json.loads((jobs / "job1.events.log").read_text().splitlines()[0]) + assert line["event"] == "started" and "dispatch_token" not in line.get("data", {}), line + res, published = _run_delivery(mam_sandbox, 0, seed_events=False) + assert published == ["error"], (published, res.stderr) + + +def test_s7j_the_shared_builders_command_is_acknowledged_by_the_real_producer(mam_sandbox): + """S7j (trial 2, the fix): run exactly the command delegate_publish_cmd + hands the agent, through the real publish_event.py, and the waiter is + satisfied on the first probe.""" + tmp_path = mam_sandbox + jobs = _seed_job(tmp_path, "job1") + lib = tmp_path / ".agents" / "skills" / "lib.sh" + env = _producer_env(tmp_path) + env["MAM_JOBS_DIR"] = str(jobs) + env["WORKSPACE_ROOT"] = str(tmp_path) + script = ('source "%s"\n' + 'cmd="$(delegate_publish_cmd job1 tok-1 1)"\n' + 'echo "CMD=$cmd"\n' + 'eval "$cmd --event started" >/dev/null\n' + 'delegate_started_recorded job1 tok-1 && echo RECORDED\n') % lib + res = subprocess.run(["bash", "-c", script], capture_output=True, text=True, + cwd=str(tmp_path), env=env, timeout=120) + assert "RECORDED" in res.stdout, res.stdout + res.stderr + cmd = res.stdout.split("CMD=")[1].splitlines()[0] + assert "--expect-dispatch-token tok-1" in cmd and "--attempt-id 1" in cmd, cmd + line = json.loads((jobs / "job1.events.log").read_text().splitlines()[-1]) + assert line["data"]["dispatch_token"] == "tok-1", line + + +def test_s7k_create_session_hands_the_agent_the_builders_command(mam_sandbox): + """S7k: the onboarding instruction is built by delegate_publish_cmd with + the job's token and attempt id - not by a hand-rolled copy that can drift.""" + src = open(_create_sh(mam_sandbox)).read() + assert 'pub="$(delegate_publish_cmd "$DELEGATE_JOB_ID" "$DELEGATE_JOB_TOKEN" "$DELEGATE_JOB_ATTEMPT")"' in src + assert 'printf \'%q \' "$py_bin" "$pub_script" --registry-dir' not in src + + +def test_s7l_the_builder_matches_the_wrappers_pub_cmd_argument_for_argument(mam_sandbox): + """S7l (req. 2, one contract): the wrapper's _pub_cmd and lib.sh's + delegate_publish_cmd produce the same argv for the same job.""" + tmp_path = mam_sandbox + jobs = _seed_job(tmp_path, "job1") + lib = tmp_path / ".agents" / "skills" / "lib.sh" + wrapper = tmp_path / ".agents" / "skills" / "multi-agent-mux-delegate-job" / "multi-agent-mux-delegate-job" + env = _producer_env(tmp_path) + env.update({"MAM_JOBS_DIR": str(jobs), "WORKSPACE_ROOT": str(tmp_path), + "MAM_DELEGATE_JOB_NO_MAIN": "1"}) + script = ('source "%s"\n' + 'source "%s"\n' + 'PY="$(_delegate_py_bin)"; REGISTRY_DIR="%s"; JOB_ID=job1; LOGS_DIR_ABS="$DELEGATE_JOB_LOGS_DIR"\n' + 'echo "W=$(_pub_cmd --expect-dispatch-token tok-1 --attempt-id 1)"\n' + 'echo "L=$(delegate_publish_cmd job1 tok-1 1)"\n') % (lib, wrapper, jobs) + res = subprocess.run(["bash", "-c", script], capture_output=True, text=True, + cwd=str(tmp_path), env=env, timeout=120) + w = res.stdout.split("W=")[1].splitlines()[0].split() + l = res.stdout.split("L=")[1].splitlines()[0].split() + # same script, registry, job and token/attempt flags; the interpreter path + # may differ in spelling (the wrapper resolves it relative), so compare from + # the script name on + def tail(argv): + i = next(i for i, a in enumerate(argv) if a.endswith("publish_event.py")) + return argv[i:] + assert tail(w) == tail(l), (w, l) + + +def test_s7m_a_job_the_agent_already_finished_is_not_marked_error(mam_sandbox): + """S7m (req. 4): with no started naming this dispatch but the job already + `completed`, create_session leaves it alone - no `error` over a terminal + state (the trial-2 'refused error ... after terminal completed').""" + res, published = _run_delivery(mam_sandbox, 0, status="completed") + assert published == [], (published, res.stderr) + assert "already 'completed'" in res.stderr, res.stderr + assert "DELIVERED" in res.stdout + + +def _wrapper_wait(tmp_path, jobs, disp_tok, ack_s=2, logs_dir=None): + """Run the wrapper's _wait_started exactly as the wrapper runs it.""" + lib = tmp_path / ".agents" / "skills" / "lib.sh" + wrapper = tmp_path / ".agents" / "skills" / "multi-agent-mux-delegate-job" / "multi-agent-mux-delegate-job" + env = _producer_env(tmp_path) + env.update({"MAM_JOBS_DIR": str(jobs), "WORKSPACE_ROOT": str(tmp_path), + "MAM_DELEGATE_JOB_NO_MAIN": "1"}) + script = ('source "%s"\nsource "%s"\nset +e\n' + 'PY="$(_delegate_py_bin)"; REGISTRY_DIR="%s"; ACK_TIMEOUT=%d; START_TIME=$(date +%%s); TIMEOUT=60\n' + 'LOGS_DIR_ABS="%s"\n' + '_disp_tok=%s; _wait_started job1 %d; echo "rc=$?"\n' + ) % (lib, wrapper, jobs, ack_s, logs_dir or env["DELEGATE_JOB_LOGS_DIR"], disp_tok, ack_s) + return subprocess.run(["bash", "-c", script], capture_output=True, text=True, + cwd=str(tmp_path), env=env, timeout=120) + + +@pytest.mark.parametrize("terminal", ["error", "cancelled"]) +def test_w1_wrapper_a_job_closed_without_a_started_is_not_acknowledged(mam_sandbox, terminal): + """W1 (review R1, real producer, record NOT reset): the agent never + published started; someone published `%s`. The record left `pending`. + The wrapper's wait must still say no - as create's does.""" + tmp_path = mam_sandbox + jobs = _seed_job(tmp_path, "job1", publish_args=None) + env = _producer_env(tmp_path) + subprocess.run([sys.executable, PUBLISH_PY, "--registry-dir", str(jobs), "--job", "job1", + "--event", terminal, "--detail", "closed by another writer", + "--expect-dispatch-token", "tok-1", "--attempt-id", "1"], + check=True, capture_output=True, text=True, env=env, timeout=60) + assert json.loads((jobs / "job1.json").read_text())["status"] == terminal + res = _wrapper_wait(tmp_path, jobs, "tok-1") + assert "rc=1" in res.stdout, res.stdout + res.stderr + assert "treating the prompt as delivered" not in res.stderr, res.stderr + + +def test_w2_wrapper_another_dispatchs_started_and_running_is_not_this_ones(mam_sandbox): + """W2 (review R1, real producer, record NOT reset): started published + under token A (record -> running), then the job was re-dispatched with + token B. Waiting for B must not accept A's started, nor the `running`.""" + tmp_path = mam_sandbox + jobs = tmp_path / ".mam" / "jobs"; jobs.mkdir(parents=True, exist_ok=True) + env = _producer_env(tmp_path) + subprocess.run([sys.executable, REGISTRY_PY, "--registry-dir", str(jobs), "register", + "--prompt", "x", "--agent", "claude-code", "--agent-session", "herdr:x", + "--dispatch-token", "tok-A", "--job-id", "job1", "--ack-timeout", "2"], + check=True, capture_output=True, text=True, env=env) + subprocess.run([sys.executable, PUBLISH_PY, "--registry-dir", str(jobs), "--job", "job1", + "--event", "started", "--expect-dispatch-token", "tok-A", "--attempt-id", "1"], + check=True, capture_output=True, text=True, env=env, timeout=60) + rec = jobs / "job1.json"; d = json.loads(rec.read_text()) + assert d["status"] == "running" + d["dispatch_token"] = "tok-B"; rec.write_text(json.dumps(d)) # the re-dispatch; status untouched + res = _wrapper_wait(tmp_path, jobs, "tok-B") + assert "rc=1" in res.stdout, res.stdout + res.stderr + + +def test_w3_wrapper_this_dispatchs_started_and_running_is_acknowledged(mam_sandbox): + """W3 (real producer, record NOT reset): the positive twin of W2.""" + tmp_path = mam_sandbox + jobs = _seed_job(tmp_path, "job1", started_token="tok-1") + assert json.loads((jobs / "job1.json").read_text())["status"] == "running" + res = _wrapper_wait(tmp_path, jobs, "tok-1") + assert "rc=0" in res.stdout, res.stdout + res.stderr + + +def test_w4_both_waits_agree_on_every_case(mam_sandbox): + """W4 (ack-finding req. 2): create's delegate_wait_started and the + wrapper's _wait_started answer the same on the four reviewer cases.""" + tmp_path = mam_sandbox + lib = tmp_path / ".agents" / "skills" / "lib.sh" + cases = [] + for label, status, started_tok, wait_tok in ( + ("error-no-started", "error", None, "tok-1"), + ("cancelled-no-started", "cancelled", None, "tok-1"), + ("other-dispatch-started", "running", "tok-1", "tok-B"), + ("own-started", "running", "tok-1", "tok-1")): + jobs = _seed_job(tmp_path, "job1", status=status, started_token=started_tok) + env = _producer_env(tmp_path) + env.update({"MAM_JOBS_DIR": str(jobs), "WORKSPACE_ROOT": str(tmp_path)}) + c = subprocess.run(["bash", "-c", 'source "%s"; delegate_wait_started job1 1 %s; echo "rc=$?"' % (lib, wait_tok)], + capture_output=True, text=True, cwd=str(tmp_path), env=env, timeout=60) + w = _wrapper_wait(tmp_path, jobs, wait_tok, ack_s=1) + cases.append((label, c.stdout.strip().splitlines()[-1], w.stdout.strip().splitlines()[-1])) + for label, c_rc, w_rc in cases: + assert c_rc == w_rc, (label, cases) + assert [c for _, c, _ in cases] == ["rc=1", "rc=1", "rc=1", "rc=0"], cases + + +def test_w5_a_remote_agents_started_counts_only_through_the_subscribers_verified_record(mam_sandbox): + """W5 (remote route): a remote agent leaves no line in events.log; the + subscriber writes an audit record after HMAC verification and after + dropping any dispatch_token mismatch, and marks it dispatch_verified. + SYNTHETIC on purpose: the record is written in exactly the shape + mqtt_common.append_event produces (a live subscriber needs a broker). + With a token demanded, only a verified record counts.""" + tmp_path = mam_sandbox + jobs = _seed_job(tmp_path, "job1") + logs = tmp_path / ".mam" / "delegate_job_logs" + (logs / "job1").mkdir(parents=True, exist_ok=True) + audit = logs / "job1" / "events.ndjson" + + def rec(**kw): + base = {"event": "received", "source_event": "started", "seq": 1, "topic": "t", + "timestamp": "2026-09-22T00:00:00Z", "detail": "", "logged_at": "x"} + base.update(kw) + return json.dumps(base) + "\n" + + import hashlib + fp = lambda t: hashlib.sha256(t.encode()).hexdigest() + audit.write_text(rec(dispatch_verified=False, dispatch_fingerprint=None)) # untokened: not attributed + assert "rc=1" in _wrapper_wait(tmp_path, jobs, "tok-1", ack_s=1, logs_dir=str(logs)).stdout + audit.write_text(rec(source_event="progress", dispatch_verified=True, dispatch_fingerprint=fp("tok-1"))) + assert "rc=1" in _wrapper_wait(tmp_path, jobs, "tok-1", ack_s=1, logs_dir=str(logs)).stdout # not a started + audit.write_text(rec(dispatch_verified=True)) # verified, no identity: fail closed + assert "rc=1" in _wrapper_wait(tmp_path, jobs, "tok-1", ack_s=1, logs_dir=str(logs)).stdout + audit.write_text(rec(dispatch_verified=True, dispatch_fingerprint=fp("tok-OLD"))) # another dispatch's + assert "rc=1" in _wrapper_wait(tmp_path, jobs, "tok-1", ack_s=1, logs_dir=str(logs)).stdout + audit.write_text(rec(dispatch_verified=True, dispatch_fingerprint=fp("tok-1"))) + assert "rc=0" in _wrapper_wait(tmp_path, jobs, "tok-1", ack_s=1, logs_dir=str(logs)).stdout + + +def test_w6_the_subscriber_stamps_dispatch_verified_on_what_it_records(mam_sandbox): + """W6: the real subscriber code path - a _Watcher with an expected + dispatch token records a matching started as dispatch_verified, and drops + a mismatching one before recording anything.""" + import types + sys.path.insert(0, SKILLS) + from lib_py.delegation import job_subscriber, mqtt_common + logs = mam_sandbox / ".mam" / "delegate_job_logs" + w = job_subscriber._Watcher({"job1"}, {"job1": None}, {"job1": 0}, {"job1": "tok-1"}, {"job1": 1}) + msg = types.SimpleNamespace(topic="python/mqtt/jobs/job1/events") + good = {"schema_version": 1, "seq": 1, "job_id": "job1", "event": "started", "timestamp": "t", + "detail": "", "data": {"dispatch_token": "tok-1", "attempt_id": 1}} + bad = dict(good, seq=2, data={"dispatch_token": "tok-OLD", "attempt_id": 1}) + for payload in (good, bad): + w._on_event(msg, payload, "job1") if hasattr(w, "_on_event") else w.on_message(None, None, types.SimpleNamespace(topic=msg.topic, payload=json.dumps(payload).encode())) + recs = list(mqtt_common.iter_logged_events("job1", str(logs))) + started = [r for r in recs if r.get("source_event") == "started"] + assert len(started) == 1 and started[0]["dispatch_verified"] is True, recs + import hashlib + assert started[0]["dispatch_fingerprint"] == hashlib.sha256(b"tok-1").hexdigest(), started[0] + assert started[0]["attempt_id"] == 1, started[0] + assert "tok-1" not in json.dumps(started[0]), "the token itself must never reach the audit log" + + +# -------------------------------------------------------------------------- +# review round 4 (planner-reviewer-claude-01 B-1, planner-reviewer-codex-01): +# the remote audit record must name the dispatch it vouches for. Real signed +# producer, real _Watcher.on_message, real audit writer, real re-dispatch +# (registry operator write minting a new token) - no record is edited by hand. +# -------------------------------------------------------------------------- + +def _remote_fixture(tmp_path, monkeypatch): + import test_issue18_liveness_contract as live + jobs = tmp_path / "jobs" + env = live._env(tmp_path, dict(live.UNREACHABLE, MAM_DELEGATE_JOB_NO_MAIN="1", + MAM_JOBS_DIR=str(jobs))) + for key in ("MAM_ENV_FILE", "DELEGATE_JOB_LOGS_DIR", "WORKSPACE_ROOT"): + monkeypatch.setenv(key, env[key]) + sys.path.insert(0, str(live.SKILLS)) + from lib_py.delegation import job_subscriber, mqtt_common, publish_event, registry + return live, env, jobs, job_subscriber, mqtt_common, publish_event, registry + + +def _receive(publish_event, watcher, jid, token, attempt, seq, secret): + """A signed `started` from the real producer, handed to the real subscriber.""" + import types + payload = publish_event.build_payload(jid, seq, "started", "", + {"dispatch_token": token, "attempt_id": attempt}, secret) + watcher.on_message(None, None, types.SimpleNamespace( + topic="python/mqtt/jobs/%s/events" % jid, payload=json.dumps(payload).encode())) + + +def _both_waits(live, env, tmp_path, jobs, jid, token): + """create's delegate_wait_started and the wrapper's _wait_started, same inputs.""" + import shlex + script = ( + 'source %s\nset +e\nPY=%s\nREGISTRY_DIR=%s\nACK_TIMEOUT=0\nTIMEOUT=60\nSTART_TIME=$(date +%%s)\n' + 'LOGS_DIR_ABS=%s\n_disp_tok=%s\n' + 'delegate_wait_started %s 0 "$_disp_tok"; echo CREATE_RC=$?\n' + '_wait_started %s 0; echo WRAPPER_RC=$?\n' + ) % (shlex.quote(str(live.WRAPPER)), shlex.quote(sys.executable), shlex.quote(str(jobs)), + shlex.quote(env["DELEGATE_JOB_LOGS_DIR"]), shlex.quote(token), jid, jid) + res = subprocess.run(["bash", "-c", script], env=env, cwd=str(tmp_path), + capture_output=True, text=True, timeout=60) + out = res.stdout + res.stderr + c = int(out.split("CREATE_RC=")[1].split()[0]); w = int(out.split("WRAPPER_RC=")[1].split()[0]) + return c, w, out + + +@pytest.mark.parametrize("scenario,expected", [ + ("current", 0), ("old_audit", 1), ("old_audit_and_stale_wire", 1), + ("new_dispatch_received", 0), ("invalid_hmac", 1), +]) +def test_w7_remote_audit_is_bound_to_the_dispatch_being_waited_for(tmp_path, monkeypatch, scenario, expected): + """W7 (codex's five cases, real paths throughout). Attempt 1 under token A + leaves a verified audit record; the operator re-dispatches (attempt 2, + new token B, nothing edited by hand); waiting for B must not be answered + by A's record, even when B's subscriber correctly dropped A's replayed + packet - and B's own verified started must still be accepted. Both waits + must agree.""" + live, env, jobs, job_subscriber, mqtt_common, publish_event, registry = _remote_fixture(tmp_path, monkeypatch) + jid, secret = "auditjob", "test-only-hmac-secret" + registry.register_job("remote audit", job_id=jid, registry_dir=str(jobs), auth_token=secret, + dispatch_token="dispatch-A", transport="remote", + broker={"host": "127.0.0.1", "port": 9, "tls": False}) + w1 = job_subscriber._Watcher({jid}, {jid: secret}, {jid: 0}, {jid: "dispatch-A"}, {jid: 1}) + _receive(publish_event, w1, jid, "dispatch-A", 1, 1, "wrong-secret" if scenario == "invalid_hmac" else secret) + recs = list(mqtt_common.iter_logged_events(jid, env["DELEGATE_JOB_LOGS_DIR"])) + assert sum(r.get("source_event") == "started" for r in recs) == (0 if scenario == "invalid_hmac" else 1), recs + token = "dispatch-A" + if scenario not in ("current", "invalid_hmac"): + job_subscriber._sync_remote_status(jid, "completed", str(jobs), token) + assert registry._operator_write(jid, str(jobs), status="pending", attempt_id=2, iteration=2) == 0 + record = mqtt_common.load_job(jid, str(jobs)) + token = record["dispatch_token"] + assert token != "dispatch-A" and record["attempt_id"] == 2, record + w2 = job_subscriber._Watcher({jid}, {jid: secret}, {jid: 0}, {jid: token}, {jid: 2}) + if scenario == "old_audit_and_stale_wire": + _receive(publish_event, w2, jid, "dispatch-A", 1, 2, secret) # A's packet replayed to B's subscriber + assert w2.events.empty(), "the subscriber must drop the stale dispatch" + elif scenario == "new_dispatch_received": + _receive(publish_event, w2, jid, token, 2, 2, secret) + assert not w2.events.empty() + assert not (jobs / ("%s.events.log" % jid)).exists(), "remote evidence comes only from the subscriber's audit" + c, w, out = _both_waits(live, env, tmp_path, jobs, jid, token) + assert (c, w) == (expected, expected), (scenario, c, w, out) + + +def test_w8_a_record_without_identity_fails_closed_when_a_token_is_demanded(tmp_path, monkeypatch): + """W8: an audit record from before dispatch_fingerprint existed (verified, + no fingerprint) names no dispatch: rejected when a token is demanded, + accepted when none is (an untokened legacy job), exactly as before.""" + live, env, jobs, job_subscriber, mqtt_common, publish_event, registry = _remote_fixture(tmp_path, monkeypatch) + jid = "legacyjob" + registry.register_job("legacy", job_id=jid, registry_dir=str(jobs), auth_token=None, + dispatch_token="dispatch-L", transport="remote", + broker={"host": "127.0.0.1", "port": 9, "tls": False}) + mqtt_common.append_event(jid, {"event": "received", "source_event": "started", "seq": 1, + "topic": "t", "timestamp": "t", "detail": "", + "dispatch_verified": True}, env["DELEGATE_JOB_LOGS_DIR"]) + c, w, out = _both_waits(live, env, tmp_path, jobs, jid, "dispatch-L") + assert (c, w) == (1, 1), ("token demanded: a record with no identity must not count", out) + c, w, out = _both_waits(live, env, tmp_path, jobs, jid, "") + assert (c, w) == (0, 0), ("no token demanded: compatibility path", out) + + +def test_s7n_the_wrappers_wait_started_accepts_the_real_producers_line(mam_sandbox): + """S7n (req. 2): the delegate-job wrapper's _wait_started, on the same + real-producer started (token in data), accepts it; on a stale token it + does not - via the shared predicate.""" + tmp_path = mam_sandbox + jobs = _seed_job(tmp_path, "job1", started_token="tok-1") + lib = tmp_path / ".agents" / "skills" / "lib.sh" + wrapper = tmp_path / ".agents" / "skills" / "multi-agent-mux-delegate-job" / "multi-agent-mux-delegate-job" + env = _producer_env(tmp_path) + env.update({"MAM_JOBS_DIR": str(jobs), "WORKSPACE_ROOT": str(tmp_path), + "MAM_DELEGATE_JOB_NO_MAIN": "1"}) + # the record stays exactly as the real producer left it (`running`): + # review R1 found the previous version resetting it to `pending`, which + # hid the wrapper's status bypass behind the predicate + script = ('source "%s"\nsource "%s"\n' + 'PY="$(_delegate_py_bin)"; REGISTRY_DIR="%s"; ACK_TIMEOUT=2; START_TIME=$(date +%%s); TIMEOUT=60\n' + '_disp_tok=tok-1; _wait_started job1 2 && echo ACCEPT_OWN\n' + '_disp_tok=tok-X; _wait_started job1 2 || echo REJECT_STALE\n') % (lib, wrapper, jobs) + res = subprocess.run(["bash", "-c", script], capture_output=True, text=True, + cwd=str(tmp_path), env=env, timeout=120) + assert "ACCEPT_OWN" in res.stdout, res.stdout + res.stderr + assert "REJECT_STALE" in res.stdout, res.stdout + res.stderr + + +def test_s7d_no_started_marks_the_job_but_keeps_the_session(mam_sandbox): + """S7d: the job failed, the session did not. The handshake is marked + `error` and warned about loudly, but a live agent is not killed over a + slow onboarding - the same rule this script already applies one step + earlier to a TUI that never signalled ready.""" + res, published = _run_delivery(mam_sandbox, 0) + assert res.returncode == 0, res.stdout + res.stderr + assert published == ["error"], published + assert "did not acknowledge the onboarding prompt" in res.stderr + assert "preserved" in res.stderr + + +def test_s7e_a_started_from_another_dispatch_is_not_evidence(mam_sandbox): + """S7e: a loop job keeps one events.log across attempts, so only a started + carrying THIS dispatch token counts. The stale line is made by the real + producer under the record's *previous* token, then the record is + re-dispatched (token rotated) before the waiter looks.""" + tmp_path = mam_sandbox + jobs = tmp_path / ".mam" / "jobs" + jobs.mkdir(parents=True, exist_ok=True) + env = _producer_env(tmp_path) + subprocess.run([sys.executable, REGISTRY_PY, "--registry-dir", str(jobs), "register", + "--prompt", "x", "--agent", "claude-code", "--agent-session", "herdr:x", + "--dispatch-token", "tok-OLD", "--job-id", "job1", "--ack-timeout", "2"], + check=True, capture_output=True, text=True, env=env) + subprocess.run([sys.executable, PUBLISH_PY, "--registry-dir", str(jobs), "--job", "job1", + "--event", "started", "--expect-dispatch-token", "tok-OLD", "--attempt-id", "1"], + check=True, capture_output=True, text=True, env=env, timeout=60) + rec = jobs / "job1.json" + d = json.loads(rec.read_text()); d["dispatch_token"] = "tok-1" # the re-dispatch; status untouched + rec.write_text(json.dumps(d)) + res, published = _run_delivery(mam_sandbox, 0, seed_events=False) + assert published == ["error"], (published, res.stderr) + + +def test_s8_rc4_is_a_warning_and_the_started_wait_decides(mam_sandbox): + """S8 (stubbed injection): rc 4 is "Enter not seen accepted", which is + exactly where a still-starting TUI lands - #23's false failure. It must no + longer abort; the started wait decides both ways.""" + res, published = _run_delivery(mam_sandbox, 4, started_token="tok-1") + assert "DELIVERED" in res.stdout, res.stdout + res.stderr + assert "rc=4" in res.stderr, res.stderr + assert published == [], published + + res, published = _run_delivery(mam_sandbox, 4) + assert published == ["error"], published + assert "did not acknowledge the onboarding prompt" in res.stderr + + +@pytest.mark.parametrize("rc", [1, 2, 3]) +def test_s8b_the_other_injection_failures_still_abort_at_once(mam_sandbox, rc): + """S8b: rc 1/2/3 say the pane never quiesced, a dialog was blocking, or the + text never appeared. No later `started` repairs those, so they stay hard + failures - only rc 4 was relaxed.""" + res, published = _run_delivery(mam_sandbox, rc, started_token="tok-1") + assert res.returncode == 1, res.stdout + res.stderr + assert "DELIVERED" not in res.stdout + assert published == ["error"], published + + +# -------------------------------------------------------------------------- +# item 4 โ€” a rollback verifies the kill (#23) +# -------------------------------------------------------------------------- + +def _run_rollback(tmp_path, kill_rc, still_alive, row=True, job_id="job1"): + lib = tmp_path / ".agents" / "skills" / "lib.sh" + yaml_path = tmp_path / ".mam" / "agent-sessions.yaml" + if row: + yaml_path.write_text( + "herdr_sessions:\n" + "- name: test-sess\n" + " status: running\n" + " role: creator\n" + " delegate_job_id: %s\n" + " pane:\n" + " index: 0\n" + " pid: 111\n" + " cmd: claude\n" + " cwd: %s\n" % (job_id, tmp_path)) + else: + yaml_path.write_text("herdr_sessions: []\n") + _seed_job(tmp_path, job_id) + block = _extract(_create_sh(tmp_path), "# _mark_row_rolled_back ", + "trap cleanup_herdr_on_error EXIT") + pub_log = tmp_path / "published.txt" + # The trap is installed and fired the way create_session fires it: an + # errexit shell exiting non-zero. rc 9 through means "no exit 43". + script = ''' +set -euo pipefail +source "%s" +SESSION_NAME=test-sess +DELEGATE_JOB_ID=%s +DELEGATE_JOB_TOKEN=tok-1 +DELEGATE_JOB_ATTEMPT=1 +ROW_APPENDED=%d +AGENT_SESSIONS_YAML="%s" +delegate_publish_event() { printf '%%s\\n' "$2" >> "%s"; } +_herdr() { + case "${1:-}" in + kill-session) return %d ;; + has-session) return %d ;; + esac + return 0 +} +%s +exit 9 +''' % (lib, job_id, 1 if row else 0, yaml_path, pub_log, + kill_rc, 0 if still_alive else 1, block) + env = dict(os.environ, MAM_JOBS_DIR=str(tmp_path / ".mam" / "jobs"), + AGENT_SESSIONS_YAML=str(yaml_path)) + res = subprocess.run(["bash", "-c", script], capture_output=True, text=True, + cwd=str(tmp_path), env=env) + published = pub_log.read_text().split() if pub_log.exists() else [] + return res, published, yaml_path.read_text() + + +def test_s9_a_kill_that_left_the_session_alive_is_reported(mam_sandbox): + """S9 (stubbed herdr, real YAML + registry): the kill failed and the + session is still up. #23's rollback swallowed exactly this and printed + "rolled back" anyway, leaving a `running` row against a failed job.""" + res, published, yaml_text = _run_rollback(mam_sandbox, kill_rc=1, still_alive=True) + assert res.returncode == 43, res.stdout + res.stderr + assert "test-sess" in res.stderr and "kill-session" in res.stderr + assert "rollback-failed" in yaml_text, yaml_text + assert "status: running" in yaml_text, "a session that is still up must not be called gone" + assert "delegate_job_id: null" in yaml_text, yaml_text + assert published == ["cancelled"], published + + +def test_s10_a_successful_kill_keeps_the_previous_behaviour(mam_sandbox): + """S10: nothing is still running, so no exit 43 and no warning - but the + row and the job are still cleaned up, which is the other half of #23.""" + res, published, yaml_text = _run_rollback(mam_sandbox, kill_rc=0, still_alive=False) + assert res.returncode == 9, res.stdout + res.stderr + assert "rolled-back" in yaml_text, yaml_text + "\nSTDERR:" + res.stderr + assert "status: terminated" in yaml_text, yaml_text + assert "delegate_job_id: null" in yaml_text, yaml_text + assert published == ["cancelled"], published + + +def test_s10b_a_rollback_before_the_row_existed_touches_no_yaml(mam_sandbox): + """S10b: failing before the append must not invent a row.""" + res, published, yaml_text = _run_rollback(mam_sandbox, kill_rc=0, still_alive=False, + row=False) + assert res.returncode == 9, res.stdout + res.stderr + assert "herdr_sessions: []" in yaml_text, yaml_text diff --git a/tests/test_issue24_resume_dialogs.py b/tests/test_issue24_resume_dialogs.py new file mode 100644 index 0000000..f3a12ff --- /dev/null +++ b/tests/test_issue24_resume_dialogs.py @@ -0,0 +1,166 @@ +"""Issue #24, resume path: start-up dialogs are answered for every agent. + +resume_session.sh called handle_startup_dialogs only for claude and passed no +agent, so a resumed agy/grok/codex sat on its directory-trust prompt exactly as +the create path did before this branch. These fix the call contract resume +now shares with create. + +What is real: the shipped create/stop/resume scripts run end to end against +the mock herdr in a sandbox. What is not: the mock's pane always shows a +ready banner, so no test here drives a real trust dialog - the create-side +tests (test_issue23_24_startup_path.py, S4-S6) fix which keys each dialog +gets, and R4 shows the same shipped function is now reached from resume. +""" + +import json +import os +import subprocess +import time + +import pytest + +SCRIPTS = ("multi-agent-mux-create/scripts/create_session.sh", + "multi-agent-mux-stop/scripts/stop_session.sh", + "multi-agent-mux-resume/scripts/resume_session.sh") + + +def _script(tmp_path, rel): + return str(tmp_path / ".agents" / "skills" / rel) + + +def _create_and_stop(tmp_path, agent, name): + ws = str(tmp_path / ("ws_" + name)) + os.makedirs(ws, exist_ok=True) + create, stop, _ = (_script(tmp_path, s) for s in SCRIPTS) + subprocess.run(["bash", create, "--workspace", ws, "--agent", agent, + "--role", "creator", "--session", name, "--no-onboard"], + check=True, cwd=str(tmp_path), capture_output=True, text=True) + subprocess.run(["bash", stop, "--session", name, "--agent", agent], + check=True, cwd=str(tmp_path), capture_output=True, text=True) + return ws + + +def _resume(tmp_path, agent, name, ws, extra=()): + _, _, resume = (_script(tmp_path, s) for s in SCRIPTS) + return subprocess.run(["bash", resume, "--workspace", ws, "--agent", agent, + "--session", name, *extra], + cwd=str(tmp_path), capture_output=True, text=True) + + +def _record_dialog_calls(tmp_path): + """Override handle_startup_dialogs in the SANDBOX copy of lib.sh (never the + repo's) so the call resume makes - session, timeout, agent, MAM_AUTO_TRUST + - is written down. Bash keeps the last definition, so appending wins.""" + log = tmp_path / "dialog_calls.log" + lib = tmp_path / ".agents" / "skills" / "lib.sh" + with open(lib, "a") as f: + f.write('\nhandle_startup_dialogs() {\n' + ' printf \'%%s\\n\' "sess=$1 timeout=${2:-} agent=${3:-} ' + 'auto_trust=${MAM_AUTO_TRUST:-}" >> "%s"\n' + '}\n' % log) + return log + + +def _calls(log): + return log.read_text().splitlines() if log.exists() else [] + + +@pytest.mark.parametrize("agent", ["agy", "codex"]) +def test_r1_resume_answers_dialogs_for_a_non_claude_agent_and_names_it( + mam_sandbox, mock_herdr, mock_agents, agent): + """R1: the gate is gone and the agent is passed - the two halves of the + create contract resume was missing.""" + tmp_path = mam_sandbox + name = "test-r1-" + agent + ws = _create_and_stop(tmp_path, agent, name) + log = _record_dialog_calls(tmp_path) # armed after create, so only resume is recorded + res = _resume(tmp_path, agent, name, ws) + assert res.returncode == 0, res.stdout + res.stderr + assert _calls(log) == ["sess=%s timeout=20 agent=%s auto_trust=1" % (name, agent)] + + +def test_r2_claude_keeps_its_dialog_handling(mam_sandbox, mock_herdr, mock_agents): + """R2: claude is still handled, now by the same call as everyone else.""" + tmp_path = mam_sandbox + ws = _create_and_stop(tmp_path, "claude", "test-r2-claude") + log = _record_dialog_calls(tmp_path) + res = _resume(tmp_path, "claude", "test-r2-claude", ws) + assert res.returncode == 0, res.stdout + res.stderr + assert _calls(log) == ["sess=test-r2-claude timeout=20 agent=claude auto_trust=1"] + + +def test_r3_no_auto_trust_reaches_the_dialog_handler(mam_sandbox, mock_herdr, mock_agents): + """R3: --no-auto-trust on resume sets MAM_AUTO_TRUST=0 for the handler, + the same switch with the same meaning as create's.""" + tmp_path = mam_sandbox + ws = _create_and_stop(tmp_path, "codex", "test-r3-codex") + log = _record_dialog_calls(tmp_path) + res = _resume(tmp_path, "codex", "test-r3-codex", ws, extra=("--no-auto-trust",)) + assert res.returncode == 0, res.stdout + res.stderr + assert _calls(log) == ["sess=test-r3-codex timeout=20 agent=codex auto_trust=0"] + + +@pytest.mark.parametrize("env_value,flag,expected", [ + ("0", (), "0"), # the documented environment form alone + ("0", ("--auto-trust",), "1"), # an explicit flag wins over the environment + ("1", ("--no-auto-trust",), "0"), +]) +def test_r3b_the_environment_form_of_the_switch_reaches_the_handler_on_resume( + mam_sandbox, mock_herdr, mock_agents, monkeypatch, env_value, flag, expected): + """R3b (merge review, codex probe): MAM_AUTO_TRUST=0 without a flag used to + be overwritten by the script's own export of its default.""" + tmp_path = mam_sandbox + ws = _create_and_stop(tmp_path, "codex", "test-r3b-codex") + log = _record_dialog_calls(tmp_path) + monkeypatch.setenv("MAM_AUTO_TRUST", env_value) + res = _resume(tmp_path, "codex", "test-r3b-codex", ws, extra=flag) + assert res.returncode == 0, res.stdout + res.stderr + assert _calls(log) == ["sess=test-r3b-codex timeout=20 agent=codex auto_trust=%s" % expected] + + +@pytest.mark.parametrize("env_value,flag,expected", [ + ("0", (), "0"), + ("0", ("--auto-trust",), "1"), + ("1", ("--no-auto-trust",), "0"), +]) +def test_c3_the_environment_form_of_the_switch_reaches_the_handler_on_create( + mam_sandbox, mock_herdr, mock_agents, monkeypatch, env_value, flag, expected): + """C3 (merge review, codex probe): the same contract on create_session.""" + tmp_path = mam_sandbox + log = _record_dialog_calls(tmp_path) + ws = str(tmp_path / "ws_c3") + os.makedirs(ws, exist_ok=True) + create = _script(tmp_path, SCRIPTS[0]) + monkeypatch.setenv("MAM_AUTO_TRUST", env_value) + res = subprocess.run(["bash", create, "--workspace", ws, "--agent", "codex", + "--role", "creator", "--session", "test-c3-codex", "--no-onboard", *flag], + cwd=str(tmp_path), capture_output=True, text=True) + assert res.returncode == 0, res.stdout + res.stderr + assert _calls(log) == ["sess=test-c3-codex timeout=15 agent=codex auto_trust=%s" % expected] + + +def test_r4_the_shipped_handler_really_runs_for_a_non_claude_resume( + mam_sandbox, mock_herdr, mock_agents): + """R4 (no override): with the real handle_startup_dialogs, a codex resume + completes and the pane is read after the spawn - the handler was reached + and returned on the ready banner. Before this change no pane read happened + between new-session and the YAML update for a non-claude agent.""" + tmp_path = mam_sandbox + ws = _create_and_stop(tmp_path, "codex", "test-r4-codex") + before = len(json.load(open(mock_herdr)).get("calls", [])) + t0 = time.monotonic() + res = _resume(tmp_path, "codex", "test-r4-codex", ws) + elapsed = time.monotonic() - t0 + assert res.returncode == 0, res.stdout + res.stderr + # Review B-1: the handler used to idle its whole 20 s timeout here because + # it had no ready tokens on the resume path. rc 0 alone cannot tell early + # return from timeout; the clock can. + assert elapsed < 12, "resume took %.1fs: the dialog handler idled to its timeout" % elapsed + calls = json.load(open(mock_herdr)).get("calls", [])[before:] + + def _has(c, *verbs): # the mock records argv with a leading `--session ` + return any(tuple(c[i:i + len(verbs)]) == verbs for i in range(len(c))) + + spawn = next(i for i, c in enumerate(calls) if _has(c, "agent", "start")) + reads = [c for c in calls[spawn + 1:] if _has(c, "agent", "read") or _has(c, "pane", "read")] + assert reads, "no pane read after the spawn: handle_startup_dialogs never ran\n%s" % calls diff --git a/tests/test_issue5_phase2a_loop.py b/tests/test_issue5_phase2a_loop.py index 1837849..aa9f083 100644 --- a/tests/test_issue5_phase2a_loop.py +++ b/tests/test_issue5_phase2a_loop.py @@ -692,22 +692,90 @@ def test_g9_loop_package_imports_only_delegation_among_siblings(): assert not offenders, offenders +# The G9 guard reads CODE, not prose. Its claim is that no frozen lib.sh +# helper is *invoked* from lib_py/loop; a comment or docstring that names +# lib.sh (say, to point at the shell twin of a function so the two stay in +# sync) invokes nothing. Every way Python could actually reach a lib.sh helper +# - a path or `source` inside a subprocess argv, an identifier - lives in code +# text, which is still scanned in full, so the guard loses none of its reach. +# History: it first fired on `hashlib.sha256` (a word boundary fixed that), +# then on a docstring cross-reference in watcher.py; both were mentions. +_G9_PATTERNS = { + "ensure_subscriber_daemon": r"ensure_subscriber_daemon", + "preexec_fn": r"preexec_fn", + "lib.sh": r"\blib\.sh\b", # word boundary: `hashlib.sha256` is not lib.sh +} + + +def _code_only(src): + """`src` as code only: the docstring expression statements are removed from + the AST and the rest is unparsed, which also drops comments (they are not + in the AST). Only the docstring *node* goes, so a statement that shares + its line - a docstring followed by `; call()` - is kept, as are string literals in code + (a path or `source` in a subprocess argv is exactly what must be caught). + Review R2: blanking the docstring's whole line range hid same-line code.""" + import ast + + tree = ast.parse(src) + for node in ast.walk(tree): + if isinstance(node, (ast.Module, ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)): + body = getattr(node, "body", None) or [] + first = body[0] if body else None + if (isinstance(first, ast.Expr) and isinstance(first.value, ast.Constant) + and isinstance(first.value.value, str)): + del body[0] + if not body: + body.append(ast.Pass()) + return ast.unparse(tree) + + +def _g9_offenders(src): + import re + code = _code_only(src) + return [name for name, pat in _G9_PATTERNS.items() if re.search(pat, code)] + + def test_g9_phase2a_spawns_no_daemon_and_calls_no_frozen_helper(): """The two phase 1 handoffs stay dormant: preexec_fn is unreachable because nothing here spawns, and no frozen lib.sh helper is invoked.""" - import re - - # `lib.sh` needs a word boundary: a bare substring search also matches - # hashlib.sha256, which is how this guard first fired on innocent code. - patterns = { - "ensure_subscriber_daemon": re.compile(r"ensure_subscriber_daemon"), - "preexec_fn": re.compile(r"preexec_fn"), - "lib.sh": re.compile(r"\blib\.sh\b"), - } for path in sorted((SKILLS / "lib_py" / "loop").glob("*.py")): src = path.read_text(encoding="utf-8") - for name, pattern in patterns.items(): - assert not pattern.search(src), "{} references {}".format(path.name, name) + hits = _g9_offenders(src) + assert not hits, "{} references {}".format(path.name, ", ".join(hits)) + + +def test_g9b_the_guard_still_catches_an_invocation_and_ignores_prose(): + """The narrowing must not cost the guard its purpose: an invocation-shaped + reference in code text still trips it; the same words in a docstring or a + comment do not.""" + invoked = ( + 'import subprocess\n' + 'def go():\n' + ' return subprocess.run(["bash", "-c", "source .agents/skills/lib.sh; ensure_subscriber_daemon x"])\n' + ) + assert set(_g9_offenders(invoked)) == {"lib.sh", "ensure_subscriber_daemon"} + path_only = 'CMD = ["bash", "/x/.agents/skills/lib.sh"]\n' + assert _g9_offenders(path_only) == ["lib.sh"] + prose = ( + '"""Module doc: same convention as lib.sh\'s _herdr_caller_server."""\n' + 'import hashlib\n' + 'def f():\n' + ' """See lib.sh; never call ensure_subscriber_daemon from here."""\n' + ' # preexec_fn is documented in lib.sh\n' + ' return hashlib.sha256(b"x").hexdigest()\n' + ) + assert _g9_offenders(prose) == [] + # Review R2: code sharing a line with a docstring must still be seen - + # module, one-line body, one-line def, and the last line of a multi-line + # docstring. Only the docstring node is removed, never its line. + same_line = [ + '"""module doc"""; ensure_subscriber_daemon()\n', + 'def f():\n """function doc"""; ensure_subscriber_daemon()\n', + 'def f(): """function doc"""; ensure_subscriber_daemon()\n', + 'def f():\n """multi\n line"""; ensure_subscriber_daemon()\n', + ] + for snippet in same_line: + assert _g9_offenders(snippet) == ["ensure_subscriber_daemon"], snippet # ==============================================================================