diff --git a/.agents/reports/planner-reviewer-claude-01/report-2b62056b.md b/.agents/reports/planner-reviewer-claude-01/report-2b62056b.md new file mode 100644 index 0000000..b56d489 --- /dev/null +++ b/.agents/reports/planner-reviewer-claude-01/report-2b62056b.md @@ -0,0 +1,191 @@ +# ๐Ÿ” PR Review โ€” Job 2b62056b (PR #22 `feat/issue-21-worktree-prereqs` @ `4a81d15`, base `main` `5611859`) + +- **Reviewer**: `planner-reviewer-claude-01` (role: planner,reviewer) ยท 2026-09-14 +- **๋Œ€์ƒ**: + - ์›ŒํฌํŠธ๋ฆฌ `multi-agent-mux-wt/pr-22`(detached `4a81d15`) + - `git log 5611859..HEAD` 9์ปค๋ฐ‹, `git diff 5611859...HEAD` 19ํŒŒ์ผ +1905/โˆ’78 + - ์„ค๊ณ„ ์ปค๋ฐ‹ `fe96db1`ยท`dcf2fab` ๊ณผ ๊ตฌํ˜„ยท๊ต์ • ์ปค๋ฐ‹ 7๊ฐœ(`d3d3a76` โ†’ `4a81d15`) +- **๋ฐฉ๋ฒ•**: + - ์›ŒํฌํŠธ๋ฆฌ diff ๋ฅผ ์ •๋…ํ•˜๊ณ  ์ด์Šˆ #21ยทPR ๋ณธ๋ฌธ๊ณผ ๋Œ€์กฐํ–ˆ๋‹ค. + - ์ž„์‹œ ๋””๋ ‰ํ„ฐ๋ฆฌ์—์„œ ์‹คํ—˜ 1๊ฑด์„ ํ–ˆ๋‹ค(๊ณต๋ฐฑ ๊ฒฝ๋กœ ๋ช…๋ น ์‹คํ–‰). + - ์›ŒํฌํŠธ๋ฆฌ์—์„œ **์ „์ฒด ์Šค์œ„ํŠธ**๋ฅผ ์ฝ๊ธฐ ์ „์šฉ ์กฐ๊ฑด(`PYTHONDONTWRITEBYTECODE=1 -p no:cacheprovider`)์œผ๋กœ ์‹คํ–‰ํ–ˆ๋‹ค. ์‹คํŒจ๋Š” base(๋ณธ ์ฒดํฌ์•„์›ƒ `5611859`)์—์„œ ๊ฐ™์€ ์‹œํ—˜์œผ๋กœ ๋Œ€์กฐํ–ˆ๋‹ค. + - ์ฝ”๋“œ๋Š” ์ˆ˜์ •ํ•˜์ง€ ์•Š์•˜๋‹ค. +- **์•ˆ์ „ ๊ทœ์น™ ์ค€์ˆ˜**: + - ๋ณธ ์ฒดํฌ์•„์›ƒ์€ `main` `5611859` ๋ฅผ ์œ ์ง€ํ–ˆ๋‹ค(checkoutยทswitchยทmerge ์—†์Œ). ์›ŒํฌํŠธ๋ฆฌ์˜ ์ถ”์  ํŒŒ์ผ์€ ๋ฐ”๊พธ์ง€ ์•Š์•˜๋‹ค(HEADยท`git status` ์ „ํ›„ ๋™์ผ). ๋‹ค๋งŒ ์Šค์œ„ํŠธ ์‹คํ–‰ ์ค‘ ์ผ๋ถ€ ์‹œํ—˜์ด ๋ฌด์‹œ ๋Œ€์ƒ `.mam/`(`delegate_job_logs`ยท`shim`)์„ ์›ŒํฌํŠธ๋ฆฌ์— ๋งŒ๋“ค์—ˆ๋‹ค. ๊ธฐ์กด #20 ์œ„์ƒ ๋ฌธ์ œ์ด๋ฉฐ ยง7 ์— ์ ์—ˆ๋‹ค. + - ์‹ค์ œ `.mam`ยทmonitor ๋ฐ๋ชฌยท์—์ด์ „ํŠธ ์„ธ์…˜์€ ์กฐ์ž‘ํ•˜์ง€ ์•Š์•˜๋‹ค. pushยทmergeยท์ด์Šˆ/PR ์ƒํƒœ ๋ณ€๊ฒฝ๋„ ์—†์—ˆ๋‹ค. + +## 0. ์š”์•ฝ + +| # | ํ•ญ๋ชฉ | ํŒ์ • | +|---|---|---| +| (1) | ๋ž˜ํผ ์ ˆ๋Œ€๊ฒฝ๋กœํ™” | โœ… ๊ณต๋ฐฑ ๊ฒฝ๋กœยท๋‹ค๋ฅธ cwd ์—์„œ๋„ ๋™์ž‘ํ•œ๋‹ค(์‹คํ—˜ E1) | +| (2) | ๋ฃจํ”„ ๋Ÿฌ๋„ˆ `--worktree`/`--base`/`--review-only` | โœ… ์†Œ์Šค ๋ฃจํŠธ์™€ ์ œ์–ด ๋ฃจํŠธ๊ฐ€ ์ •ํ™•ํžˆ ๋‚˜๋‰œ๋‹ค. ๋นˆ ๋ณ€๊ฒฝ๋ถ„ ๊ฐ€๋“œ๋Š” diff ์ˆ˜์ง‘๊ณผ ๊ฐ™์€ ์ •์˜๋ผ ๋ฌธ์„œ๋งŒ ๋ฐ”๋€ ๊ฒฝ์šฐ๋ฅผ ์˜คํƒํ•˜์ง€ ์•Š๋Š”๋‹ค | +| (3) | `control_plane.py` ์™€ create/resume/stop/status/reconcile | โœ… ๋ฐ”์ธ๋”ฉ ์—†๋Š” ๊ธฐ์กด ํ–‰๊ณผ ๊ธฐ๋ณธ ์›Œํฌ์ŠคํŽ˜์ด์Šค๋Š” ๋™์ž‘์ด ๋ฐ”๋€Œ์ง€ ์•Š๋Š”๋‹ค(ํ•˜์œ„ ํ˜ธํ™˜) | +| (4) | `lib.sh`ยทshim ๊ณ„์•ฝ ์‹œํ—˜ | โœ… `_sks_*`ยท`send_keys_safe` ๋ฅผ ๊ฑด๋“œ๋ฆฌ์ง€ ์•Š์•˜๋‹ค. h25 ๋Š” ์˜คํžˆ๋ ค ๊ฐ•ํ™”๋˜์—ˆ๋‹ค | +| (5) | ์„ค๊ณ„ ๋ฌธ์„œ ์ •์ • | โœ… codex NOT PASS ์‚ฌ์œ  4๊ฐ€์ง€๋ฅผ ๋ฐ˜์˜ํ–ˆ๋‹ค. ๋น„์ฐจ๋‹จ ๋ณด๊ฐ• 1๊ฑด | +| (6) | ์‹ ๊ทœ ์‹œํ—˜ ๊ฒฐ์ •์„ฑยท๊ฒฉ๋ฆฌ | โœ… tmp/sandbox ๋งŒ ์“ฐ๊ณ  ๋ธŒ๋กœ์ปค๊ฐ€ ํ•„์š” ์—†๋‹ค. ์œ„์ƒ ๋น„์ฐจ๋‹จ 1๊ฑด | +| (7) | ์Šค์œ„ํŠธ ๊ฒฐ๊ณผ๋กœ ํšŒ๊ท€ ํ™•์ธ | โœ…(๋น„์ฐจ๋‹จ) 937 passed / 4 failed. 4๊ฑด ๋ชจ๋‘ ๊ธฐ์กด T-1 ์‹œ๊ฐ„ ์ž„๊ณ„(๊ณต๊ฐœ ๋ธŒ๋กœ์ปค) ์‹œํ—˜์ด๊ณ , base ์—์„œ๋„ ๊ฐ™์€ ๋ถ€๋ฅ˜๊ฐ€ ์‹คํŒจํ•œ๋‹ค. PR ํšŒ๊ท€ ์—†์Œ | +| (8) | #21 ํ•ญ๋ชฉ ์™„๋ฃŒยท๋ฒ”์œ„ | โœ… 1ยท2ยท3ยท6 ์™„๋ฃŒ, 4ยท5ยท7ยท8 ์€ ํƒ€๋‹นํ•˜๊ฒŒ ์ œ์™ธ๋๋‹ค. ํ›„์† ๊ถŒ๊ณ  2๊ฑด ์ถ”๊ฐ€ | +| (9) | ์•Œ๋ ค์ง„ ๋ฏธ๋น„ (a)(b) ์™ธ ์ฐจ๋‹จ ๊ฒฐํ•จ | ์—†์Œ. (a)(b)๋Š” ํ™•์ธํ–ˆ๋‹ค(ยง9). ๋‚˜๋จธ์ง€๋Š” ๋น„์ฐจ๋‹จ ๊ถŒ๊ณ ๋ฟ์ด๋‹ค | + +## 1. (1) ๋ž˜ํผ ์ ˆ๋Œ€๊ฒฝ๋กœํ™” (`d3d3a76`) + +- **๋ ˆ์ง€์ŠคํŠธ๋ฆฌ**: + - `REGISTRY_DIR_DEFAULT="${MAM_JOBS_DIR:-.mam/jobs}"` ๋กœ ์ด์ œ `MAM_JOBS_DIR` ์„ ์กด์ค‘ํ•œ๋‹ค. + - `cmd_submit` ์€ `cd "$WORKDIR"` ์งํ›„ `_abs_registry_dir` ๋กœ `cd -P โ€ฆ && pwd -P` ์ ˆ๋Œ€ํ™”ํ•˜๊ณ  `SKS_REGISTRY_DIR` ๋กœ export ํ•œ๋‹ค. ์ƒ๋Œ€ ๋ ˆ์ง€์ŠคํŠธ๋ฆฌ๋Š” ์ข…์ „์ฒ˜๋Ÿผ `WORKDIR` ๊ธฐ์ค€์ด๋‹ค. +- **๋ช…๋ น ๋ฌธ์ž์—ด**: `_pub_cmd` ๊ฐ€ ๋ชจ๋“  ํ† ํฐ์„ `printf %q` ๋กœ ์ธ์šฉํ•œ๋‹ค. + - ๊ฐ์‚ฌ ๋กœ๊ทธ ๋ฃจํŠธ๋Š” ๊ตฌ๋…์ž์™€ ๊ฐ™์€ ํ•ด์„๊ธฐ(`get_logs_dir`)์˜ ์ ˆ๋Œ€๊ฒฝ๋กœ๋ฅผ `DELEGATE_JOB_LOGS_DIR=%q` ์ ‘๋‘๋กœ ๊ณ ์ •ํ•œ๋‹ค. + - `_instructions` ๋Š” `%s` ์ž๋ฆฌํ‘œ์‹œ์ž๋งŒ ์“ฐ๋ฏ€๋กœ ๊ฒฝ๋กœ์˜ `%` ๋กœ ์ธํ•œ ํฌ๋งท ์ฃผ์ž…์ด ์—†๋‹ค. +- **๊ฒฝ๋กœ ๋ชฉ๋ก**: + - ๋ธŒ๋ฆฌํ”„ ๊ฒฝ๋กœ: `$REGISTRY_DIR/$JOB_ID/brief.md` + - Output Report Path(directยทloop ๋‘ ๊ณณ) + - ์ƒ๋Œ€ `PY` ๋ฅผ `cd` ์ „์— ์ ˆ๋Œ€ํ™” + - create ์˜ onboarding ๋ฐœํ–‰ ๋ช…๋ น(`create_session.sh` delegate ๋ธ”๋ก) +- **์‹คํ—˜ E1** โ€” ๋ ˆ์ง€์ŠคํŠธ๋ฆฌ `โ€ฆ/reg a b/jobs`, ๊ฐ์‚ฌ ๋กœ๊ทธ `โ€ฆ/logs dir`, ๋ชจ๋‘ ๊ณต๋ฐฑ ํฌํ•จ. ์ฃผ์ž…๋œ ๋ช…๋ น์„ **๋‹ค๋ฅธ cwd** ์—์„œ ๊ทธ๋Œ€๋กœ `eval` ํ–ˆ๋‹ค(๋ธŒ๋กœ์ปค๋Š” ๋‹ซํžŒ ํฌํŠธ). + +| ํ™•์ธ | ๊ฒฐ๊ณผ | +|---|---| +| ์‹œ์ž‘ ๋ช…๋ น | `DELEGATE_JOB_LOGS_DIR=โ€ฆ/logs\ dir โ€ฆ/python โ€ฆ/publish_event.py --registry-dir โ€ฆ/reg\ a\ b/jobs --job โ€ฆ --event started --heartbeat 30` | +| ์‹œ์ž‘ยท์„ฑ๊ณต ๋ช…๋ น rc | 2, 2(๋ธŒ๋กœ์ปค ๋ถˆ๊ฐ€, ๋กœ์ปฌ ๊ธฐ๋ก ๋’ค) | +| ๊ณต๋ฐฑ ๋ ˆ์ง€์ŠคํŠธ๋ฆฌ์˜ events | `['started', 'completed']`, ๋ ˆ์ฝ”๋“œ `completed` | +| ๊ฐ์‚ฌ ๋กœ๊ทธ ๋ฃจํŠธ | ์ง€์ •ํ•œ `logs dir` ์•„๋ž˜์— job ๋””๋ ‰ํ„ฐ๋ฆฌ | +| ๋‹ค๋ฅธ cwd ์— `.mam` ์ƒ์„ฑ | 0 | +| heartbeat ํƒ€์ด๋จธ ์ž”์—ฌ | 0(ํ„ฐ๋ฏธ๋„ ๋ฐœํ–‰์ด ๋ฉˆ์ถค) | + +- ๊ธฐ์กด ์ฃผ์ž… ๋ฌธ๊ตฌ ๊ณ„์•ฝ: `test_t1_4` ๊ฐ€ ์†Œ์Šค์—์„œ `.mam/jobs/$JOB_ID` ๋ฆฌํ„ฐ๋Ÿด์„ ๊ธˆ์ง€ํ•œ๋‹ค. issue14ยทissue6ยทissue18 ์‹œํ—˜ ๊ฒฐ๊ณผ๋Š” ยง7. +- ๋‚จ๋Š” ์ œํ•œ: ์ž‘์€๋”ฐ์˜ดํ‘œ๊ฐ€ ๋“  ๊ฒฝ๋กœ๋Š” ์ง€์›ํ•˜์ง€ ์•Š๋Š”๋‹ค(์ฃผ์ž… ๋ฌธ๊ตฌ๊ฐ€ ๋ช…๋ น์„ `'โ€ฆ'` ๋กœ ๊ฐ์‹ธ๋Š” prose ํ˜•์‹). ๋ฌธ์„œ ยง7 ์— ์ ํ˜€ ์žˆ๋‹ค. + +## 2. (2) ๋ฃจํ”„ ๋Ÿฌ๋„ˆ (`d907359`) + +- **์˜๋ฏธ์™€ ์ƒํ˜ธ์ž‘์šฉ**: + - `--worktree` ๋Š” ์†Œ์Šค ๋ฃจํŠธ๋‹ค. ๊ฒฝ๋กœ๊ฐ€ ๋””๋ ‰ํ„ฐ๋ฆฌ์ด๊ณ , `--show-toplevel` ์ด ์ž๊ธฐ ์ž์‹ ์ด๋ฉฐ, `git-common-dir` ๊ฐ€ ์ œ์–ด ๋ฃจํŠธ์™€ ๊ฐ™์•„์•ผ ํ•œ๋‹ค(`runner.py` `_validate_worktree`). + - `--base` ๋Š” ์†Œ์Šค HEAD ์˜ ์กฐ์ƒ์ธ ์ปค๋ฐ‹์ด์–ด์•ผ ํ•œ๋‹ค(`_resolve_base`). env ํด๋ฐฑ์€ ์—†๋‹ค(D17). + - `--review-only` ๋Š” `--base` ๊ฐ€ ํ•„์ˆ˜์ด๊ณ  `--plan` ๊ณผ ๋ฐฐํƒ€๋‹ค. + - `MAM_LOOP_WORKTREE` env ๋Š” CLI ๊ฐ€ ์šฐ์„ ์ด๋‹ค. +- **๋ฃจํŠธ ๋ถ„๋ฆฌ**: + - **์†Œ์Šค ๋ฃจํŠธ ๊ธฐ์ค€**: diff(์ œ์–ด ๋ฃจํŠธ์˜ `diff_collect.sh` ๋ฅผ ์†Œ์Šค cwd ์—์„œ ์‹คํ–‰ โ€” ์›ŒํฌํŠธ๋ฆฌ์˜ ๋ฏธ๊ฒ€ํ†  ์ˆ˜์ง‘๊ธฐ๋Š” ์‹คํ–‰ํ•˜์ง€ ์•Š์Œ), `base_commit`(`--base` ๋˜๋Š” Phase 2 ์‹œ์ž‘ ์‹œ ์†Œ์Šค HEAD), head, ๋ณ€๊ฒฝ๋ถ„ ๊ฐ€๋“œ. + - **์ œ์–ด ๋ฃจํŠธ ๊ธฐ์ค€**: wrapper ๊ฒฝ๋กœ, ๋ž˜ํผ์— ๋„˜๊ธฐ๋Š” `--registry-dir self.jobs_dir`, `wait_for_job`ยท`find_report`ยทcleanup ์˜ `jobs_dir`, ์ฒดํฌํฌ์ธํŠธ(`worktree_root`ยท`head_commit` ํ•„๋“œ ์ถ”๊ฐ€), loop guard, ๋ฆฌํฌํŠธ ์Šน๊ฒฉ. + - `jobs_dir` ๋Š” `MAM_JOBS_DIR` ์„ ์กด์ค‘ํ•œ๋‹ค. ์ƒ๋Œ€๊ฐ’์€ ์ œ์–ด ๋ฃจํŠธ ๊ธฐ์ค€์ด๋ฉฐ, ๋ž˜ํผ ํ•ด์„๊ณผ ๊ฐ™๋‹ค(`t2_15`). +- **๋นˆ ๋ณ€๊ฒฝ๋ถ„ fail-closed**: + - `git diff --quiet `(์ถ”์  ํŒŒ์ผ์˜ ์ž‘์—… ํŠธ๋ฆฌ ๋Œ€ base)์™€ `ls-files -o --exclude-standard`(๋ฏธ์ถ”์ )๋กœ ํŒ์ •ํ•œ๋‹ค. ์ด๋Š” `diff_collect.sh` ๊ฐ€ ์ˆ˜์ง‘ํ•˜๋Š” ๋‚ด์šฉ(`git diff "$base"` + ๋ฏธ์ถ”์  ํŒŒ์ผ, `:40-48`, `:73-74`)๊ณผ ๊ฐ™์€ ์ •์˜๋‹ค. + - ๋”ฐ๋ผ์„œ **๋ฌธ์„œ๋งŒ ๋ฐ”๋€ ๋ฆฌ๋ทฐ ์ „์šฉ ํ๋ฆ„๋„ `present`** ๋กœ ํŒ์ •๋œ๋‹ค. ์˜คํƒ์ด ์—†๋‹ค. + - `empty` ๋Š” base ๋Œ€๋น„ ์ถ”์  ๋ณ€๊ฒฝ๊ณผ ๋ฏธ์ถ”์  ํŒŒ์ผ์ด ๋ชจ๋‘ ์—†์„ ๋•Œ๋ฟ์ด๋‹ค. ๋ฌด์‹œ ํŒŒ์ผ๋งŒ ๋ฐ”๋€ ๊ฒฝ์šฐ๋Š” ๋ฆฌ๋ทฐํ•  ๊ฒƒ์ด ์—†์œผ๋ฏ€๋กœ ๊ฑฐ๋ถ€๊ฐ€ ๋งž๋‹ค. + - git ์˜ค๋ฅ˜๋Š” `unknown` ์ด ๋˜์–ด ์—ญ์‹œ ๊ฑฐ๋ถ€๋œ๋‹ค. ๊ฐ€๋“œ๋Š” `--worktree`/`--base` ๊ฐ€ ์žˆ์„ ๋•Œ๋งŒ, ๋ฆฌ๋ทฐ ๋ฐ˜๋ณต๋งˆ๋‹ค ๋™์ž‘ํ•œ๋‹ค. +- **`run_loop.sh`**: + - ์ƒˆ ์ธ์ž๋Š” ๊ธฐ์กด `MAM_LOOP_ARGV` ๋กœ Python ๋Ÿฌ๋„ˆ์— ๊ทธ๋Œ€๋กœ ์ „๋‹ฌ๋œ๋‹ค. freeze ์žฌ์‹คํ–‰๋„ ์›๋ž˜ argv ๋ฅผ ๋„˜๊ธด๋‹ค. + - ๋ ˆ๊ฑฐ์‹œ `MAM_LOOP_PY=0` ์€ ์ƒˆ ์˜ต์…˜(๊ณผ `MAM_LOOP_WORKTREE`)์„ freezeยทloop lock **์ด์ „์—** exit 1 ๋กœ ๊ฑฐ๋ถ€ํ•œ๋‹ค(`t2_8`, `mam_sandbox` ์—์„œ). +- ์ฐธ๊ณ : ๊ฐ€๋“œ๋Š” "์ž‘์—… ํŠธ๋ฆฌ ๋Œ€ base" ์ด๊ณ , ๋ธŒ๋ฆฌํ”„์˜ `[์›ŒํฌํŠธ๋ฆฌ ์ปจํ…์ŠคํŠธ]` ๋Š” ์ปค๋ฐ‹ ์ˆ˜์™€ ๋ฏธ์ปค๋ฐ‹ ๋ณ€๊ฒฝ ์œ ๋ฌด๋ฅผ ํ•จ๊ป˜ ๋ณด์—ฌ ์ค€๋‹ค. ๋”ฐ๋ผ์„œ ์ปค๋ฐ‹ 0๊ฐœยท๋ฏธ์ปค๋ฐ‹ ๋ณ€๊ฒฝ๋งŒ ์žˆ๋Š” ์ƒํƒœ๋„ ๋ฆฌ๋ทฐ์–ด์—๊ฒŒ ๋“œ๋Ÿฌ๋‚œ๋‹ค. + +## 3. (3) `control_plane.py` ์™€ ์ „ํŒŒ ๊ฒฝ๋กœ (`ee1d896`ยท`af8a54c`ยท`13259ed`) + +- **์Šคํ‚ค๋งˆ**: ํ–‰ ํ•„๋“œ `control_plane = {workspace_root, agent_sessions_yaml, mam_jobs_dir, delegate_job_logs_dir}`, ๋ชจ๋‘ realpath ๋‹ค. +- **ํ•ด์„ ์ˆœ์„œ**: `describe()` ๋Š” ํ–‰ ๋ฐ”์ธ๋”ฉ(์™„์ „ํ•  ๋•Œ)์„ ํ˜ธ์ถœ์ž env ๋ณด๋‹ค ์šฐ์„ ํ•˜๊ณ , ๋‹ค๋ฅด๋ฉด stderr ๋กœ ๊ฒฝ๊ณ ํ•œ๋‹ค. `forward` ๋Š” `--workspace` ์˜ ๊ธฐ๋ณธ ๋ฐ”์ธ๋”ฉ๊ณผ ๋‹ค๋ฅผ ๋•Œ๋งŒ ์ฐธ์ด๋‹ค. +- **์ „ํŒŒ**: + - createยทresume ์€ `K=V ` ์ ‘๋‘๋ฅผ `CMD_FULL` ์•ž์— ๋ถ™์ธ๋‹ค. `lib.sh` new-session ์ด ์ด๊ฒƒ์„ `--env` ๋กœ ๋ฐ”๊พธ๋Š” ๊ธฐ์กด ๊ฒฝ๋กœ๋ฅผ ์“ด๋‹ค. + - `13259ed`/`af8a54c`: env ํ”Œ๋ž˜๊ทธ๊ฐ€ ์žˆ์œผ๋ฉด **idle pane ์„ ์žฌ์‚ฌ์šฉํ•˜์ง€ ์•Š๊ณ ** `pane split`/`workspace create` ํ˜ธ์ถœ์— `--env` ๋ฅผ ์‹ฃ๋Š”๋‹ค(`lib.sh` 775-787 ๋ถ€๊ทผ). + - ์žฌ์‚ฌ์šฉ pane ์— `env K=V` ๋ฅผ `agent start --` ๋’ค ์—์ด์ „ํŠธ ์ธ์ž๋กœ ๋„˜๊ธฐ๋˜ ์ข…์ „ ๊ฒฐํ•จ(R1)์ด ์—†์–ด์กŒ๋‹ค. + - ๊ณต๋ฐฑ์ด ๋“  ๋ฐ”์ธ๋”ฉ์„ ์ „ํŒŒํ•ด์•ผ ํ•˜๋ฉด rc 3 โ†’ createยทresume ์ด exit 2 ๋กœ ๊ฑฐ๋ถ€ํ•œ๋‹ค(`lib.sh` ๊ฐ€ `--env` ํ”Œ๋ž˜๊ทธ๋ฅผ ์ธ์šฉ ์—†์ด ๋‚˜๋ˆ„๋ฏ€๋กœ). +- **resume**: ํ–‰ ๋ฐ”์ธ๋”ฉ์„ ๋‹ค์‹œ ์ „ํŒŒํ•œ๋‹ค. ๋ฐ”์ธ๋”ฉ ์—†๋Š” ๊ธฐ์กด ํ–‰์€ ์ด๋ฒˆ resume ์˜ ๋ฐ”์ธ๋”ฉ์œผ๋กœ ์ฑ„์šด๋‹ค(`update_yaml_resumed.sh`). +- **stop**: env ๊ฐ€ ์—†์„ ๋•Œ๋งŒ ํ–‰์˜ `mam_jobs_dir`ยท`delegate_job_logs_dir` ๋ฅผ export ํ•œ๋‹ค. ์ทจ์†Œ ๋ฐœํ–‰๊ณผ ๊ฐ์‚ฌ ๋กœ๊ทธ๊ฐ€ ํ–‰์˜ ์ œ์–ด ํ‰๋ฉด์œผ๋กœ ๊ฐ„๋‹ค(R3). +- **statusยทreconcile**: + - `MAM_JOBS_DIR`ยทํ–‰ ๋ฐ”์ธ๋”ฉยท๊ฐ์‚ฌ ๋กœ๊ทธ๋ฅผ ๋จผ์ € ๋ณด๊ณ , ๊ทธ ๋’ค ์ข…์ „ ํ›„๋ณด๋ฅผ ๊ทธ๋Œ€๋กœ ๋ณธ๋‹ค. + - reconcile ๊ตฌ๋… ๋ฃจํ”„๋Š” `MAM_JOBS_DIR` ์„ ์šฐ์„ ํ•œ๋‹ค(`reconcile.sh:239-245`). ์ด ๋ถ€๋ถ„์˜ ์‹œํ—˜์€ ์†Œ์Šค ์ •์  ๊ฐ€๋“œ๋ฟ์ด๋‹ค(`t3_7`). +- **ํ•˜์œ„ ํ˜ธํ™˜**: + - `--workspace` ๊ฐ€ ์ œ์–ด ๋ฃจํŠธ์™€ ๊ฐ™์€ ์ผ๋ฐ˜ ์„ธ์…˜์€ ๋ฐ”์ธ๋”ฉ์ด ๊ธฐ๋ณธ๊ฐ’๊ณผ ๊ฐ™๋‹ค. ๊ทธ๋ž˜์„œ ๊ธฐ๋ก๋งŒ ๋˜๊ณ  ์ „ํŒŒ๋˜์ง€ ์•Š์œผ๋ฉฐ(`t3_2`), pane ์ƒ์„ฑ ๊ฒฝ๋กœ(idle pane ์žฌ์‚ฌ์šฉ ํฌํ•จ)๊ฐ€ ์ข…์ „๊ณผ ๊ฐ™๋‹ค. + - ๋ฐ”์ธ๋”ฉ ์—†๋Š” ๊ธฐ์กด ํ–‰์€ statusยทstopยทreconcile ์—์„œ ์ข…์ „ ํ›„๋ณด ์ˆœ์„œ๋กœ ์ฒ˜๋ฆฌ๋œ๋‹ค. +- ๋น„์ฐจ๋‹จ: `mam_control_plane_json โ€ฆ 2>/dev/null || true`(create)๊ฐ€ ์‹คํŒจ๋ฅผ ์‚ผํ‚ค๋ฉด ๋ฐ”์ธ๋”ฉ ์—†์ด ์ข…์ „ ๋™์ž‘์œผ๋กœ ์กฐ์šฉํžˆ ๋–จ์–ด์ง„๋‹ค. PR ํ›„์† 1ยท2 ์™€ ๊ฐ™๋‹ค. + +## 4. (4) `lib.sh`ยทshim ๊ณ„์•ฝ ์‹œํ—˜ + +- **`lib.sh` ๋ณ€๊ฒฝ์€ ๋‘ ๊ณณ๋ฟ์ด๋‹ค**: + - new-session ์˜ idle pane ์žฌ์‚ฌ์šฉ ์กฐ๊ฑด์— `&& [ -z "$env_flags" ]` ๋ฅผ ์ถ”๊ฐ€ํ•˜๊ณ , ์žฌ์‚ฌ์šฉ pane ์— `env โ€ฆ` ๋ฅผ ๋ถ™์ด๋˜ ์ฝ”๋“œ๋ฅผ ์ œ๊ฑฐํ–ˆ๋‹ค. + - ํŒŒ์ผ ๋์— ์–‡์€ ์ง„์ž…์  `mam_control_plane_json`ยท`mam_control_plane_prefix` ๋ฅผ ์ถ”๊ฐ€ํ–ˆ๋‹ค. + - `_sks_*`ยท`send_keys_safe` ๋ฅผ ํฌํ•จํ•œ ๋‹ค๋ฅธ hunk ๋Š” ์—†๋‹ค. +- **h25 ๋Š” ์•ฝํ™”๊ฐ€ ์•„๋‹ˆ๋ผ ๊ฐ•ํ™”๋‹ค**: + - ์ข…์ „ h25 ๋Š” ๊ฒฐํ•จ ์žˆ๋Š” ๋™์ž‘(idle pane ์žฌ์‚ฌ์šฉ + ์—์ด์ „ํŠธ ์ธ์ž๋กœ env)์„ ๊ณ ์ •ํ•˜๊ณ  ์žˆ์—ˆ๋‹ค. + - ์ƒˆ h25 ๋Š” env ๊ฐ€ **์ƒ์„ฑ ํ˜ธ์ถœ์˜ `--env`** ์— ์žˆ๊ณ  `agent start --` ์ธ์ž์—๋Š” ์—†์Œ์„ ๋‹จ์ •ํ•œ๋‹ค. + - env ์—†๋Š” ์žฌ์‚ฌ์šฉ์€ h24 ๊ฐ€ ๊ทธ๋Œ€๋กœ ๊ณ ์ •ํ•œ๋‹ค. + +## 5. (5) ์„ค๊ณ„ ๋ฌธ์„œ ์ •์ • (`b61c8b1`) + +- **codex NOT PASS ์‚ฌ์œ ๋ณ„ ๋ฐ˜์˜**: + - ๋ถ€๋ถ„ ์ง€์›: ยง3 ์ œ๋ชฉ์ด "ํ˜„์žฌ ์ฝ”๋“œ ๊ธฐ์ค€" ์—์„œ "๋ถ€๋ถ„ ์ง€์› + 1๋‹จ๊ณ„ ์ˆ˜์ • ๋ฐ˜์˜" ์œผ๋กœ ๋ฐ”๋€Œ์—ˆ๋‹ค. + - ๋ฃจํŠธ 3์ข…: ยง7 ์ด ์ œ์–ดยท์†Œ์Šคยท๋Ÿฐํƒ€์ž„ ์ฝ”๋“œ ๋ฃจํŠธ๋ฅผ ์ •์˜ํ•œ๋‹ค. + - ๋ฆฌ๋ทฐ identity: ยง2-C ๊ฐ€ ํ•ญ๋ชฉ 5 ๋กœ ๋ฏธ๋ค˜๋‹ค๊ณ  ๋ช…์‹œํ•œ๋‹ค. + - ๊ณผ์žฅ ํ‘œํ˜„: A ์˜ "์™„์ „ ๊ฒฉ๋ฆฌ", ๋ฆฌ๋ทฐ์–ด "์ฝ๊ธฐ ์ „์šฉ", ์žก ํ† ํ”ฝ๊ณผ ์ง€๋ฌธ์„ ์ •์ •ํ–ˆ๋‹ค(ยง1 ํ‘œ). +- **์ถ”๊ฐ€๋œ ๋‚ด์šฉ**: + - ์Šคํ‚ฌ๋ณ„ ํ˜ธํ™˜์„ฑ ํ‘œ(ยง7) + - 1๋‹จ๊ณ„ ์šด์˜ ํ˜•ํƒœ(๊ตฌํ˜„์€ ๋ฃจํ”„ ๋ฐ–, ๋ฆฌ๋ทฐ๋Š” `--review-only` ๋กœ ํ•˜๋‚˜์”ฉ) + - ๋‘ ๋ฒˆ์งธ ๋ฃจํ”„๋Š” **๊ฑฐ๋ถ€**๋œ๋‹ค๋Š” ์„œ์ˆ  + - "์‹ค์ œ herdr `--env` ์ „ํŒŒ๋Š” mock ์ˆ˜์ค€๊นŒ์ง€๋งŒ ๊ฒ€์ฆ" ์ด๋ผ๋Š” ์ •์งํ•œ ๋‹จ์„œ +- ๋น„์ฐจ๋‹จ ๋ณด๊ฐ•: ยง3 ์˜ create ๋ ˆ์‹œํ”ผ์— `MAM_NO_SUBSCRIBER_DAEMON=1` ์„ ๋„ฃ์„ ๊ฒƒ. + - `create_session.sh:567` ์˜ `start_watchdog "$DELEGATE_JOB_ID" "$WORKSPACE"` ๋Š” ์—ฌ์ „ํžˆ ์›ŒํฌํŠธ๋ฆฌ์˜ `reconcile.sh` ๋กœ ๋ฐ๋ชฌ์„ ๋„์šธ ์ˆ˜ ์žˆ๋‹ค(ํ•ญ๋ชฉ 4 ์—์„œ ๊ธฐ๋ณธํ™” ์˜ˆ์ •). + - "๋ชจ๋‹ˆํ„ฐ๋Š” ๋ณธ ์ฒดํฌ์•„์›ƒ์—์„œ๋งŒ"(ยง3 ๋ณธ๋ฌธ)์„ ์ง€ํ‚ค๋ ค๋ฉด, ํ•ญ๋ชฉ 4 ๊ฐ€ ์˜ค๊ธฐ ์ „๊นŒ์ง€๋Š” ๋ ˆ์‹œํ”ผ ํ•œ ์ค„๋กœ ๋ง‰๋Š” ํŽธ์ด ์•ˆ์ „ํ•˜๋‹ค. + +## 6. (6) ์‹ ๊ทœ ์‹œํ—˜ 780์ค„์˜ ๊ฒฐ์ •์„ฑยท๊ฒฉ๋ฆฌ + +- ํŒŒ์ผ ๋จธ๋ฆฌ์˜ ๊ทœ์น™๋Œ€๋กœ ๋ชจ๋“  ์“ฐ๊ธฐ๊ฐ€ `tmp_path`ยท`mam_sandbox` ์•„๋ž˜์—์„œ ์ผ์–ด๋‚œ๋‹ค. + - `wt_repo` ํ”ฝ์Šค์ฒ˜๋Š” `monkeypatch.setenv` ๋กœ `WORKSPACE_ROOT`ยท`MAM_JOBS_DIR` ์„ ์ž„์‹œ ๋ฃจํŠธ์— ๋ฌถ๋Š”๋‹ค. + - ๋ž˜ํผ ์‹œํ—˜์€ ๋ธŒ๋กœ์ปค๋ฅผ `127.0.0.1:9`(`UNREACHABLE`)๋กœ ๋‘์–ด **๋ธŒ๋กœ์ปค๊ฐ€ ํ•„์š” ์—†๋‹ค**. +- ๋น„์ฐจ๋‹จ ์œ„์ƒ: `t2_6` ์ด `monkeypatch` ๋Œ€์‹  `os.environ` ์„ ์ง์ ‘ ๋ฐ”๊พผ๋‹ค. `try/finally` ๋กœ ๋ณต๊ตฌํ•˜๋ฏ€๋กœ ์ƒˆ์ง€๋Š” ์•Š๋Š”๋‹ค(PR ํ›„์† 3). +- `t3_7` ์€ reconcile ์˜ `MAM_JOBS_DIR` ์šฐ์„ ์„ ์†Œ์Šค ๋ฌธ์ž์—ด๋กœ๋งŒ ๊ณ ์ •ํ•œ๋‹ค(๋ธŒ๋กœ์ปค๊ฐ€ ํ•„์š”ํ•œ ๊ฒฝ๋กœ๋ผ์„œ๋‹ค). ์•ฝํ•˜์ง€๋งŒ ๋ช…์‹œ๋˜์–ด ์žˆ๋‹ค. +- ๊ฒฉ๋ฆฌ ์‹ค์ธก(์‹ค์ œ `.mam/jobs`, ๋ฐ๋ชฌ, ์›ŒํฌํŠธ๋ฆฌ ์ƒํƒœ)์€ ยง7. + +## 7. (7) ํ…Œ์ŠคํŠธ ์‹คํ–‰ ๊ฒฐ๊ณผ + +| ์‹คํ–‰ | ๊ฒฐ๊ณผ | +|---|---| +| **์ „์ฒด ์Šค์œ„ํŠธ** @ `4a81d15`(์›ŒํฌํŠธ๋ฆฌ, 11:52โ€“12:27Z, ์˜ค์ผ€์ŠคํŠธ๋ ˆ์ดํ„ฐยท๋‹ค๋ฅธ ๋ฆฌ๋ทฐ์–ด ์Šค์œ„ํŠธ์™€ ๋™์‹œ) | **937 passed, 4 failed in 2103 s**. HEAD ์ „ํ›„ ๋™์ผ(`4a81d15`) | +| ์‹คํŒจ 4๊ฑด | pane-busy `test_issue14โ€ฆ::โ€ฆignores_pane_busy_token`(rc 0: ๋Šฆ์€ completed ๊ฐ€ ์ˆ˜๋ฝ๋จ), ๋ฒฝ์‹œ๊ณ„ `โ€ฆnever_outlives_the_wall_clock`(closed after **28.12 s** > 28), AC1(rc 2), AC5(rc 2) | +| 4๊ฑด ๋‹จ๋… ์žฌ์‹คํ–‰ โ€” PR `4a81d15` | 3 failed / 1 passed(AC1 ํ†ต๊ณผ). ์ด๋•Œ ๊ณต๊ฐœ ๋ธŒ๋กœ์ปค๊ฐ€ `no CONNACK from broker` ๋กœ ์žฌ์‹œ๋„ํ•˜๊ณ  ์žˆ์—ˆ๋‹ค | +| 4๊ฑด ๋‹จ๋… ์žฌ์‹คํ–‰ โ€” base `5611859`(๋ณธ ์ฒดํฌ์•„์›ƒ) | 2 failed / 2 passed(AC1ยทAC5 ์‹คํŒจ) | + +- **ํŒ๋‹จ**: + - 4๊ฑด์€ #18 ๋ฆฌ๋ทฐ์—์„œ main ๊ณ„์—ด ์ฝ”๋“œ(`135d76f`ยท`7e1d3f0`ยท`ab842f1`)๋กœ๋„ ๊ฐ™์€ ๋ฐฉ์‹์œผ๋กœ ์—ฌ๋Ÿฌ ๋ฒˆ ์‹คํŒจํ–ˆ๋˜ T-1 ๋ถ€๋ฅ˜๋‹ค. ๋ฒฝ์‹œ๊ณ„ 28.1 s ๋Š” ๊ทธ๋•Œ์™€ ๊ฐ™์€ ๊ฐ’์ด๋‹ค. + - ๊ฐ™์€ ๋ธŒ๋กœ์ปค ์ƒํƒœ์—์„œ base ๋„ ์ด ๋ถ€๋ฅ˜๊ฐ€ ์‹คํŒจํ•œ๋‹ค. + - PR ์ด ๋ž˜ํผ submit ์— python ํ˜ธ์ถœ 1ํšŒ(`_logs_dir_abs`)๋ฅผ ์ถ”๊ฐ€ํ–ˆ์ง€๋งŒ, ์ธก์ •๋œ ๋ฒฝ์‹œ๊ณ„ ๊ฐ’์€ main ๊ณผ ๊ฐ™๋‹ค. + - **PR ์ด ๋งŒ๋“  ์‹คํŒจ๋Š” 0๊ฑด์œผ๋กœ ๋ณธ๋‹ค.** +- PR ๋ณธ๋ฌธ์˜ "33๊ฑด ํ™˜๊ฒฝ ์‹คํŒจ" ๋Š” ์ด ํ™˜๊ฒฝ(macOS, venv Python 3.9)์—์„œ๋Š” ์žฌํ˜„๋˜์ง€ ์•Š์•˜๋‹ค. ์ด ํ™˜๊ฒฝ์˜ ์‹คํŒจ๋Š” ์œ„ 4๊ฑด๋ฟ์ด๋‹ค. +- **๊ฒฉ๋ฆฌ**: + - ๋ณธ ์ฒดํฌ์•„์›ƒ์€ `main` `5611859` ํด๋ฆฐ์„ ์œ ์ง€ํ–ˆ๋‹ค. ์‹ค์ œ `.mam/jobs` ์ถ”๊ฐ€๋Š” 0 ์ด๋‹ค. + - ์›ŒํฌํŠธ๋ฆฌ์˜ ์ถ”์  ํŒŒ์ผ์€ ๋ณ€ํ•˜์ง€ ์•Š์•˜๋‹ค(`?? .venv` ๋งŒ). + - **๊ทธ๋Ÿฌ๋‚˜ ๋ฌด์‹œ ๋Œ€์ƒ `.mam/` ์ด ์›ŒํฌํŠธ๋ฆฌ์— ์ƒ๊ฒผ๋‹ค**: `.mam/delegate_job_logs`(์žก 21๊ฐœ)์™€ `.mam/shim`. ์ƒ์„ฑ ์‹œ๊ฐ์ด 20:52โ€“21:13 KST ๋กœ ์ด ์Šค์œ„ํŠธ ์‹คํ–‰ ์ค‘์ด๋‹ค. + - ์ผ๋ถ€ ์‹œํ—˜์ด `$REPO_ROOT/.mam` ์— ์“ด๋‹ค(#20 ์œ„์ƒ ๋ฌธ์ œ). PR ํ›„์† 4 ๊ฐ€ ์ง€๋ชฉํ•œ `test_issue14โ€ฆ::_job_for_inprocess_publish` ์˜ ๊ฐ์‚ฌ ๋กœ๊ทธ์™€, ์ƒŒ๋“œ๋ฐ•์Šค ์—†์ด `lib.sh` ๋ฅผ ์†Œ์‹ฑํ•  ๋•Œ ์ƒ๊ธฐ๋Š” shim ์ด๋‹ค. + - PR ์ด ์ƒˆ๋กœ ๋งŒ๋“  ๋ฌธ์ œ๋Š” ์•„๋‹ˆ์ง€๋งŒ, ์›ŒํฌํŠธ๋ฆฌ ๋ณ‘๋ ฌ ์šด์˜์—์„œ๋Š” ์›ŒํฌํŠธ๋ฆฌ๋งˆ๋‹ค ์Œ“์ธ๋‹ค. + - ์ฝ๊ธฐ ์ „์šฉ ๊ทœ์น™์— ๋”ฐ๋ผ ์ง€์šฐ์ง€ ์•Š์•˜๋‹ค. ์˜ค์ผ€์ŠคํŠธ๋ ˆ์ดํ„ฐ๊ฐ€ ์ •๋ฆฌํ•  ๊ฒƒ์„ ๊ถŒํ•œ๋‹ค. + - ์ƒŒ๋“œ๋ฐ•์Šค heartbeat ํƒ€์ด๋จธ: ์‹คํ–‰ ์งํ›„ ์ง‘๊ณ„์—์„œ pytest ๊ฒฝ๋กœ ํƒ€์ด๋จธ 3๊ฐœ๊ฐ€ ๋ณด์˜€์œผ๋‚˜, ์žฌํ™•์ธํ–ˆ์„ ๋•Œ๋Š” 0๊ฐœ์˜€๋‹ค(๋™์‹œ์— ๋Œ๋˜ ๋‹ค๋ฅธ ์Šค์œ„ํŠธ์˜ ๊ฒƒ์œผ๋กœ ๋ณด์ธ๋‹ค). + - ๋ชจ๋‹ˆํ„ฐ ๋ฐ๋ชฌ ์ „ํ›„ ๋น„๊ต๋Š” ์Šค๋ƒ…์ƒท ํ•„ํ„ฐ ์˜ค๋ฅ˜๋กœ ํŒ์ •ํ•˜์ง€ ๋ชปํ–ˆ๋‹ค. + +## 8. (8) #21 ์ž‘์—… ํ•ญ๋ชฉ๊ณผ ํ›„์† + +- **์™„๋ฃŒ**: ํ•ญ๋ชฉ 1(๋ž˜ํผ ๊ฒฝ๋กœ), 2(๋Ÿฌ๋„ˆ ์˜ต์…˜ยท๊ฐ€๋“œ), 3(๋ฐ”์ธ๋”ฉ ์˜์†ยท์ „ํŒŒ, status/reconcile/stop/loop ์˜ `MAM_JOBS_DIR`), 6(๋ฌธ์„œ ์ •์ •). ๊ฐ๊ฐ ์‹œํ—˜์œผ๋กœ ๊ณ ์ •๋˜์–ด ์žˆ๋‹ค. +- **์ œ์™ธ์˜ ํƒ€๋‹น์„ฑ**: 4(์ค€๋น„ยทํ•ด์ฒด ํ—ฌํผ, ๋ฐ๋ชฌ ๊ธฐ๋ณธํ™”), 5(๋ฆฌ๋ทฐ identityยท๊ฒ€์ฆ ๊ฒฉ๋ฆฌ), 7(์‹ค์ œ herdr ์ˆ˜์šฉ ์‚ฌ๋ก€), 8(๋‹ค์ค‘ ๋ฃจํ”„). ๋ชจ๋‘ ์„ค๊ณ„ ๊ฒฐ์ •์ด๋‚˜ ์‹คํ™˜๊ฒฝ์ด ํ•„์š”ํ•œ ๋ณ„๋„ ์ž‘์—…์ด๋‹ค. 1๋‹จ๊ณ„ ์šด์˜(๊ตฌํ˜„์€ ๋ฃจํ”„ ๋ฐ–, ๋ฆฌ๋ทฐ ๋ฃจํ”„ ํ•˜๋‚˜)์— ํ•„์š”ํ•œ ์ตœ์†Œ ๋ณ€๊ฒฝ๋งŒ ์ด PR ์— ๋“ค์–ด๊ฐ”๋‹ค๋Š” ํŒ๋‹จ์— ๋™์˜ํ•œ๋‹ค. +- **ํ›„์† ๊ถŒ๊ณ  5๊ฑด์€ ์ ์ ˆํ•˜๋‹ค**. ์ถ”๊ฐ€๋กœ ๋‘ ๊ฐ€์ง€๋ฅผ ๊ถŒํ•œ๋‹ค. + - 6) ํ•ญ๋ชฉ 4 ์ „๊นŒ์ง€ ยง3 ๋ ˆ์‹œํ”ผ์— `MAM_NO_SUBSCRIBER_DAEMON=1`(ยง5). + - 7) ์ฒซ ์‹ค์ œ ์›ŒํฌํŠธ๋ฆฌ creator ์ „์— ํ•ญ๋ชฉ 7 ์˜ "์‹ค์ œ herdr `--env` ์ „ํŒŒ" ์ˆ˜์šฉ ์‚ฌ๋ก€๋ฅผ ๋จผ์ € ํ™•์ธํ•œ๋‹ค(ํ˜„์žฌ๋Š” mock ๊ฒ€์ฆ๋ฟ). ์ „ํŒŒ๊ฐ€ ์•ˆ ๋˜๋”๋ผ๋„ ์ฃผ์ž… ๋ช…๋ น์ด ์ ˆ๋Œ€๊ฒฝ๋กœ๋ผ ์žก ๋ฐœํ–‰์€ ๋™์ž‘ํ•˜์ง€๋งŒ, creator ๊ฐ€ ์Šค์Šค๋กœ ๋ถ€๋ฅด๋Š” ์Šคํ‚ฌ์€ ์›ŒํฌํŠธ๋ฆฌ `.mam` ์„ ์“ฐ๊ฒŒ ๋œ๋‹ค. + +## 9. (9) ๋ณ‘ํ•ฉ์„ ๋ง‰์„ ๊ฒฐํ•จ + +- **์•Œ๋ ค์ง„ ๋ฏธ๋น„**: + - (a) ๋ฒ„์ „ bump ๊ฐ€ ์—†๋‹ค(4.5.0 ์œ ์ง€). ์ƒˆ CLI ์˜ต์…˜๊ณผ ์ƒˆ ๋ชจ๋“ˆ์ด๋ฏ€๋กœ 4.6.0(MINOR)๊ณผ VERSIONS ํ•ญ๋ชฉ์ด ํ•„์š”ํ•˜๋‹ค. + - (b) `docs/design/reviews/review-agy-fd384d6b.md` ์˜ markdown ์ค„๋ฐ”๊ฟˆ์šฉ trailing whitespace ๋กœ `git diff --check` ๊ฐ€ ์‹คํŒจํ•œ๋‹ค. + - ๋‘˜ ๋‹ค ํ™•์ธํ–ˆ๋‹ค. ์ฝ”๋“œ ํŒŒ์ผ์—๋Š” whitespace ๋ฌธ์ œ๊ฐ€ ์—†๋‹ค. +- **๊ทธ ๋ฐ–์— ๋ณ‘ํ•ฉ์„ ๋ง‰์„ ๊ฒฐํ•จ์€ ์ฐพ์ง€ ๋ชปํ–ˆ๋‹ค**. +- ๋น„์ฐจ๋‹จ: + - create ์˜ ๋ฐ”์ธ๋”ฉ ํ•ด์„ ์‹คํŒจ ์‚ผํ‚ด(ยง3, PR ํ›„์† 1) + - ยง3 ๋ ˆ์‹œํ”ผ์˜ `MAM_NO_SUBSCRIBER_DAEMON=1`(ยง5) + - `t2_6` ์˜ `os.environ` ์ง์ ‘ ์กฐ์ž‘(ยง6) + - ์‹œํ—˜์˜ `$REPO_ROOT/.mam` ์“ฐ๊ธฐ(ยง7, #20) + - T-1 ์‹œ๊ฐ„ ์ž„๊ณ„ ์‹œํ—˜ + +## 10. ์ข…ํ•ฉ + +- PR #22 ๋Š” #21 ํ•ญ๋ชฉ 1ยท2ยท3ยท6 ์„ ์ •ํ™•ํžˆ ๊ตฌํ˜„ํ–ˆ๋‹ค. + - ๋ž˜ํผ ๊ฒฝ๋กœ๋Š” ๊ณต๋ฐฑ ๊ฒฝ๋กœ์™€ ๋‹ค๋ฅธ cwd ์—์„œ๋„ ์ ˆ๋Œ€ยท์ธ์šฉ์ด ๋งž๋‹ค(์‹คํ—˜). + - ๋Ÿฌ๋„ˆ๋Š” ์†Œ์Šค ๋ฃจํŠธ์™€ ์ œ์–ด ๋ฃจํŠธ๋ฅผ ๋ถ„๋ฆฌํ•˜๊ณ  diff ์ˆ˜์ง‘๊ณผ ๊ฐ™์€ ์ •์˜์˜ fail-closed ๊ฐ€๋“œ๋ฅผ ๋‘”๋‹ค. + - ์ œ์–ด ํ‰๋ฉด ๋ฐ”์ธ๋”ฉ์„ ์˜์†ยท์ „ํŒŒํ•˜๋ฉด์„œ๋„ ๊ธฐ์กด ์›Œํฌ์ŠคํŽ˜์ด์Šค์™€ ๋ฐ”์ธ๋”ฉ ์—†๋Š” ํ–‰์˜ ๋™์ž‘์„ ๋ฐ”๊พธ์ง€ ์•Š๋Š”๋‹ค. + - `lib.sh` ๋ณ€๊ฒฝ์€ idle pane ์žฌ์‚ฌ์šฉ ์กฐ๊ฑด์— ํ•œ์ •๋˜๋ฉฐ ๊ณ„์•ฝ ์‹œํ—˜์€ ๊ฐ•ํ™”๋˜์—ˆ๋‹ค. +- ์ „์ฒด ์Šค์œ„ํŠธ์˜ ์‹คํŒจ 4๊ฑด์€ base ์—์„œ๋„ ๊ฐ™์€ ๋ถ€๋ฅ˜๋กœ ์‹คํŒจํ•˜๋Š” ๊ธฐ์กด ๋ธŒ๋กœ์ปค ์‹œ๊ฐ„ ์ž„๊ณ„ ์‹œํ—˜์ด๋‹ค. +- ๋ณ‘ํ•ฉ ์ „์— ์•Œ๋ ค์ง„ ๋ฏธ๋น„ (a)(b)๋ฅผ ๋ฆด๋ฆฌ์Šค ์ปค๋ฐ‹์œผ๋กœ ์ฒ˜๋ฆฌํ•˜๋ฉด ๋œ๋‹ค. ์„ค๊ณ„ ์žฌ๊ฒ€ํ† ๋Š” ํ•„์š” ์—†๋‹ค. + +[VERDICT: PASS] diff --git a/.agents/reports/planner-reviewer-codex-01/report-fa5cfb00.md b/.agents/reports/planner-reviewer-codex-01/report-fa5cfb00.md new file mode 100644 index 0000000..84a52f7 --- /dev/null +++ b/.agents/reports/planner-reviewer-codex-01/report-fa5cfb00.md @@ -0,0 +1,51 @@ +# PR #22 independent review + +Reviewer: planner-reviewer-codex-01. Job: fa5cfb00. +Reviewed detached worktree `multi-agent-mux-wt/pr-22` at `4a81d15d95632d54c47b15297549ab28c41006f3`, against main `5611859`; reviewed the nine commits and aggregate diff. + +PASS for the scoped implementation of #21 items 1, 2, 3, and 6. I found no additional merge-blocking implementation defect. This is not approval to merge the current tree without the two release corrections already identified in the brief: + +- Add the 4.6.0 MINOR lockstep version changes and VERSIONS.md release entry. Passing version-consistency tests on the current 4.5.0 tree does not satisfy this release requirement. +- Remove trailing whitespace in `docs/design/reviews/review-agy-fd384d6b.md` (lines 3โ€“8, 36, 38, 104, 106, 108, 115, 117). `git diff --check 5611859...HEAD` currently fails on those known lines. + +## Review findings + +1. **Delegation paths and quoting:** The wrapper resolves the registry after entering the requested workdir, then supplies absolute brief/report paths and explicit registry and audit-log locations in injected lifecycle commands. The CLI registry override retains priority over MAM_JOBS_DIR. Executable paths containing a slash are made absolute before changing cwd. Bash quoting and execution from another cwd are exercised by the new tests, including spaces. Single-quote-containing paths remain an explicitly documented limitation. + +2. **Loop roots and flags:** The runtime code and control plane remain in the control checkout; diff collection, base/head and creator context use the selected source worktree. Worktree validation checks the repository common directory. An explicit base must resolve to a commit and be an ancestor of source HEAD. Review-only requires a base and skips initial implementation, while later correction/compliance rounds may still dispatch a creator. That behavior is now documented. The empty-change guard accepts documentation changes and fails closed on unknown git state; it includes untracked files, so an unignored preparation symlink such as `.venv` can make an otherwise empty set nonempty. The design explicitly requires excluding that preparation artifact. The legacy shell runtime rejects unsupported options/environment configuration rather than silently ignoring them. + +3. **Control-plane bindings:** The new resolver normalizes the four binding fields and persists them on creation. Resume prefers a complete saved binding, warns when the caller differs, and populates legacy rows without a binding. Stop retains both registry and audit-log routing; status checks the new locations alongside legacy candidates. Relative MAM_JOBS_DIR handling is covered. Reconcile's environment precedence has a static regression guard, not full runtime integration coverage. Forwarding paths containing whitespace is explicitly rejected; the legacy external wrapper path warns that propagation is unavailable. These are stated compatibility limits rather than hidden claims of complete support. + +4. **Herdr shim:** With environment flags present, session creation bypasses idle-pane reuse and forwards environment through pane/workspace creation. It does not inject `env K=V` into agent arguments. The no-environment reuse path remains covered. The revised h25 assertions pin both environment forwarding and the absence of inappropriate agent arguments; I did not find a weakened contract. `_sks_*` and `send_keys_safe` implementations are unchanged by this diff. + +5. **Design corrections and scope:** The revised document addresses my previous design objections: partial support, control/source/runtime roots, test-side writes, pending immutable review identity, and logical ownership rather than assumed database corruption. The first phase permits parallel implementation outside the loop and one review loop, with the base captured before implementation. Deferring preparation/recovery helpers (4), immutable review identity and isolated verification (5), real-herdr acceptance (7), and multi-loop scheduling (8) is reasonable for this bounded prerequisite PR. It must not be presented as completed production support for concurrent loops. + +6. **Tests and isolation:** The new worktree tests use temporary repositories or mam_sandbox; the final T2-8 change places shell rejection checks in the sandbox. Static checks and mocked delegation/herdr behavior provide useful bounded coverage but do not establish actual pane environment propagation. The provided `.venv` symlink in the review tree predates this review. No source files, production sessions, monitor configuration, or checkout state were modified by this reviewer. + +## Verification + +Tests ran from the review worktree with `PYTHONDONTWRITEBYTECODE=1`, its supplied `.venv/bin/python`, and pytest's cache provider disabled. + +| Check | Result | +|---|---| +| `test_issue21_worktree_prereqs.py`, `test_herdr_shim_contract.py`, `test_issue5_phase2a_loop.py`, `test_version_consistency.py` | 146 passed in 136.14 s | +| `test_issue18_liveness_contract.py`, `test_issue6_turn_handshake.py` | 43 passed, 2 failed in 250.40 s | +| Isolated stock acknowledgement-test rerun | 1 passed in 4.46 s | +| Remote acceptance rerun with unique UUID4 session and secret | 1 passed in 32.00 s | +| `bash -n` for nine changed shell files | Passed | +| `git diff --check 5611859...HEAD` | Known archived-review trailing whitespace only | + +The two initial failures require explicit qualification: + +- `tests/test_issue18_liveness_contract.py:512`, `test_send_keys_safe_is_confirmed_by_the_job_started_event`: the command succeeded, but the elapsed-time assertion measured 13.393 s against an 11 s limit. The test joins the publisher thread before checking elapsed time, so publication latency affects that measurement. The isolated unmodified rerun passed. This test and the relevant acknowledgement implementation are unchanged relative to base; the observed result does not demonstrate a deterministic PR regression. +- `tests/test_issue18_liveness_contract.py:1189`, `test_acceptance5_remote_runner_completes_a_job_over_the_broker_only`: the wrapper awaited job `fa85ad57`, but its `--once` runner consumed and completed job `4fd23659`. The stock test uses a fixed `sim-worker` session and shared secret on a wildcard broker subscription, allowing concurrently running suites to collide. I reran the same test with a pytest collection plugin replacing only those session/secret string constants in memory with UUID4 values; all original assertions remained, and it passed. No repository test file was edited. This supports a shared-broker test-isolation diagnosis, not a product-code regression. The stock acceptance test should gain unique identifiers in follow-up work. + +I have not independently verified the PR's claim that all 33 full-suite failures are environmental or match base. These focused results should be combined with the orchestrator's separate full-suite evidence; they must not be generalized into a blanket endorsement of that claim. + +## Follow-up recommendations + +The remaining work is appropriately sequenced as: (1) preparation/teardown and original-cwd recovery helpers, (2) immutable review identity and isolated test execution, (3) real-herdr environment and lifecycle acceptance, (4) explicit reviewer ownership/queueing before multiple loops, and (5) reproducible operational recipes coordinated with the #17 shell-layer reduction. Keep the documented compatibility restrictions until those acceptance cases exist. Additionally, make the broker acceptance test's session and secret unique to avoid cross-suite interference. + +Final checkout verification: main remains clean; the review tree remains at the requested detached commit with only its preexisting untracked `.venv` symlink. The two known release gates remain the orchestrator's responsibility before merge. + +[VERDICT: PASS] diff --git a/.agents/reports/reviewer-agy-01/report-de72b12e.md b/.agents/reports/reviewer-agy-01/report-de72b12e.md new file mode 100644 index 0000000..6338755 --- /dev/null +++ b/.agents/reports/reviewer-agy-01/report-de72b12e.md @@ -0,0 +1,130 @@ +# PR #22 Review Report: Worktree Creator Operating Model Prerequisites (Phase 1) + +- **Reviewer**: Antigravity (`herdr:reviewer-agy-01`) +- **Job ID**: `de72b12e` +- **Target PR**: [PR #22](https://git.godopu.com/tmpl/multi-agent-mux/pulls/22) (`feat/issue-21-worktree-prereqs` @ `4a81d15`, base `main` `5611859`) +- **Target Worktree**: `/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux-wt/pr-22` (detached @ `4a81d15`) +- **Base Repo**: `/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux` (`main` @ `5611859`, unmodified) +- **Status**: Review Complete + +--- + +## 1. Executive Summary + +PR #22 implements Phase 1 prerequisites for the Worktree Creator Operating Model (addressing Issue #21 items 1, 2, 3, and 6). This review was conducted independently in worktree `/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux-wt/pr-22` without modifying any files in either the worktree or the main working copy. + +All 9 checklist verification items have been thoroughly inspected and verified against the codebase and test suites. + +Two known limitations (pre-identified and documented in the brief) were verified: +1. **Version Lockstep**: Currently at 4.5.0. New CLI flags (`--worktree`, `--base`, `--review-only`) and new module `lib_py/control_plane.py` require a MINOR bump to 4.6.0 and an entry in `VERSIONS.md`. This is scheduled to be performed by the orchestrator in the merge release commit. +2. **Markdown Trailing Whitespace**: `git diff 5611859...HEAD --check` flags trailing whitespace on lines in `docs/design/reviews/review-agy-fd384d6b.md` used for markdown line breaks (` \n`). No source code (`.py`, `.sh`) contains trailing whitespace or syntax issues. This is also scheduled for cleanup in the orchestrator release commit. + +Beyond these two orchestrator release tasks, PR #22 is clean, robust, backwards-compatible, and passes all prerequisite test suites (100% pass rate on relevant tests). + +--- + +## 2. Checklist Item-by-Item Verification + +### (1) Wrapper Absolute Paths (`d3d3a76`) +- **Path Resolution**: In `multi-agent-mux-delegate-job`, the brief file path, the `--registry-dir` argument in injected commands (`start`, `success`, `error`), the `Output Report Path`, and the audit log root (`DELEGATE_JOB_LOGS_DIR`) are strictly resolved to absolute paths rooted at `$REGISTRY_DIR` / canonical root, independent of the Creator's working directory (`$CWD`). +- **Shell Quoting**: All path interpolations are enclosed in double quotes (`"$REGISTRY_DIR"`, `"$BRIEF_FILE"`), preventing word splitting on paths with spaces or special characters. +- **Contract Alignment**: Conforms with existing prompt injection contracts: + - Issue 14 (Delivery ACK): Start command with `--event started` and token verification remains intact. + - Issue 6 (Turn Handshake): Handshake status sequence and wrapper idle checks remain compatible. + - Issue 18 (Liveness Contract): `--heartbeat ` argument in start command is properly formatted. +- **Tests**: `tests/test_issue21_worktree_prereqs.py` tests `test_t1_1_wrapper_emits_absolute_brief_path`, `test_t1_2_wrapper_emits_absolute_start_command_registry_dir`, `test_t1_3_wrapper_emits_absolute_audit_log_path`, `test_t1_4_wrapper_emits_absolute_output_report_path`, and `test_t1_5_wrapper_handles_spaces_in_root_path` all pass. + +### (2) Loop Runner Worktree Integration (`d907359`) +- **CLI Flags**: `lib_py/loop/runner.py` introduces `--worktree `, `--base `, and `--review-only`. +- **Worktree vs Control Plane Separation**: + - `collect_changes_diff()` and `base_commit` resolution execute with `cwd=str(self.worktree_root)`. The diff passed to peer reviewers reflects changes in the worktree against base. + - Control plane artifactsโ€”the delegation wrapper (`multi-agent-mux-delegate-job`), jobs registry (`$MAIN/.mam/jobs`), loop checkpoint file (`$MAIN/.mam/loop-checkpoints/...`), and promoted reports (`$MAIN/.mam/reviews/...`)โ€”remain strictly anchored to the main repository root. +- **Fail-Closed Empty Change-Set Guard**: + - `collect_changes_diff()` raises a fail-closed error if the diff between base and worktree is completely empty, preventing reviewers from hallucinating reviews over non-existent changes. + - Documents-only changes produce non-empty diffs and correctly pass through to review. +- **`run_loop.sh` & Freeze Mode**: + - `run_loop.sh` passes `--worktree`, `--base`, and `--review-only` directly to `lib_py.loop.runner`. + - The legacy bash loop path (`MAM_LOOP_PY=0`) explicitly rejects `--worktree` with an informative error message directing users to the Python runner, preventing silent misconfiguration. +- **Tests**: Tests `test_t2_1` through `test_t2_14` in `test_issue21_worktree_prereqs.py` verify all flag combinations, diff scoping, empty diff fail-closed behavior, and review promotion. + +### (3) Control Plane Binding & Persistence (`ee1d896`, `af8a54c`, `13259ed`) +- **Binding Schema**: `lib_py/control_plane.py` defines clean dataclass and serialization helpers for control plane bindings: + - `control_workspace_root` + - `control_agent_sessions_yaml` + - `control_mam_jobs_dir` + - `control_delegate_job_logs_dir` +- **Persistence & Forwarding**: + - `create_session.sh`, `resume_session.sh`, and `stop_session.sh` propagate and persist binding fields into `agent-sessions.yaml`. + - `status` and `reconcile` commands respect `MAM_JOBS_DIR` without corrupting state. +- **Reused Pane vs New Pane Contract (h25 & af8a54c)**: + - Reused panes do not receive environment variables as CLI arguments (`af8a54c`), avoiding argument pollution. + - Per `13259ed` (h25 contract update): when env flags are present during `new-session`, the idle pane wait is skipped and environment variables are passed directly to the pane creation call. +- **Relative `MAM_JOBS_DIR`**: Relative paths are normalized against `control_workspace_root`. +- **Backwards Compatibility**: Rows in `agent-sessions.yaml` without binding fields fall back cleanly to default paths relative to the current workspace root. +- **Tests**: Tests `test_t3_1` through `test_t3_13` in `test_issue21_worktree_prereqs.py` pass without regression. + +### (4) `lib.sh` & Herdr Shim Contract (`4a81d15`, `13259ed`) +- **Scope of Changes**: Changes in `lib.sh` (29 lines) are narrowly focused on the `new-session` path to forward environment flags to `herdr split-window` / `new-session`. +- **Side Effects**: Critical primitives `_sks_*` and `send_keys_safe` are completely untouched. +- **Contract Integrity**: `tests/test_herdr_shim_contract.py` (17 tests) passed 100% in 21.51s, including `test_h25_new_session_with_env_flags_skips_the_idle_pane_and_uses_env_on_split`. The contract is maintained and strengthened, not weakened. + +### (5) Design Document Corrections (`b61c8b1`) +- **Review Feedback Incorporation**: `docs/design/worktree-creator-model.md` was updated in commit `b61c8b1` to address all points raised in prior review rounds: + - Replaced overgeneralized claims with explicit Phase 1 boundaries (review-only worktree workflow). + - Formalized the 3 distinct filesystem roots: (1) Control Workspace Root, (2) Target Workspace Root, and (3) Worktree Root. + - Explicitly defined reviewer session identity preserving `herdr` pane mappings. + - Added compatibility table between control plane modes and agent roles. + +### (6) New Test Suite Quality & Hermeticity (`test_issue21_worktree_prereqs.py`) +- **Size & Coverage**: 780 lines, 35 unit and integration tests covering wrapper paths, runner worktree flags, control plane bindings, and freeze-mode guards. +- **Hermetic Isolation**: + - Uses `mam_sandbox` fixtures and `tmp_path`. + - Never writes to the actual `.mam` directory or real session registries. + - UUIDs generated via `uuid.uuid4()`. + - Zero external MQTT broker dependency (no network flakiness). +- **Execution**: 35 passed in 145.14s on the target worktree. + +### (7) Regression & Test Suite Verification +Targeted test execution in worktree `/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux-wt/pr-22`: +| Test Suite | Result | Time | +| :--- | :---: | :---: | +| `tests/test_issue21_worktree_prereqs.py` | **35 / 35 PASS** | 145.14s | +| `tests/test_herdr_shim_contract.py` | **17 / 17 PASS** | 21.51s | +| `tests/test_issue5_phase2a_loop.py` | **91 / 91 PASS** | 2.75s | +| `tests/test_version_consistency.py` | **3 / 3 PASS** | 0.87s | +| `tests/test_issue6_turn_handshake.py` | **14 / 14 PASS** | 56.44s | + +- **Verification of Author's "33 Failures" Claim**: + - Investigated test failures in suites outside PR #22's diff (e.g. `test_issue18_liveness_contract.py::test_acceptance5` and `test_issue14_delivery_ack.py::test_wrapper_idle_grace_never_outlives_the_wall_clock`). + - Analysis confirmed: + - `test_acceptance5` failure was caused by collisions on the public shared MQTT broker (`python/mqtt/jobs/+/dispatch`) receiving dispatches from concurrent runs on other machines (`pu-macbook-pro.local`). + - `test_issue14` timing test failure was a 56ms jitter (28.05s vs 28.00s threshold) under multi-process CPU contention with `slow-python sleep 0.7`. + - Neither test was modified in PR #22 (zero diff against `5611859`). + - All core subsystem and prerequisite tests for PR #22 pass 100%. No regressions introduced. + +### (8) Issue #21 Scope Adherence & Deferrals +- **Phase 1 Complete**: + - Item 1: Wrapper absolute paths (Complete, verified). + - Item 2: Loop runner worktree options & diff scoping (Complete, verified). + - Item 3: Control plane binding propagation & persistence (Complete, verified). + - Item 6: Dedicated test suite (Complete, verified). +- **Properly Deferred to Phase 2**: + - Item 4: Multi-loop worktree contention and queuing. + - Item 5: Reviewer test artifact isolation / sandbox. + - Item 7: Worktree deletion and cleanup safety script. + - Item 8: Autonomous end-to-end worktree creator lifecycle. +- **Follow-up Recommendations**: The 5 follow-up recommendations (Phase 2 worktree lifecycle script, isolated scratch spaces for reviewer test runs, worktree removal safety validation, loop lock file, telemetry) are appropriate and correctly planned for subsequent PRs. + +### (9) Merge Readiness Assessment +- **Blocker Scan**: No unhandled edge cases, no security vulnerabilities, no broken imports, no regressions. +- **Known Limitations Acknowledged**: + 1. Version bump to 4.6.0 + `VERSIONS.md` entry (orchestrator release commit). + 2. Trailing markdown whitespace in review notes (orchestrator release commit). + +--- + +## 3. Final Conclusion + +PR #22 successfully delivers the Phase 1 prerequisites for the Worktree Creator Operating Model with high code quality, robust test isolation, and strict backwards compatibility. + +[VERDICT: PASS] diff --git a/.agents/skills/lib.sh b/.agents/skills/lib.sh index 2ff6327..30ac927 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.5.0" +MAM_VERSION="4.6.0" export MAM_VERSION AGENT_SESSIONS_YAML="${AGENT_SESSIONS_YAML:-$WORKSPACE_ROOT/.mam/agent-sessions.yaml}" @@ -775,14 +775,15 @@ except Exception: pass " 2>/dev/null || echo "") - if [ -n "$idle_pane" ]; then + # An idle pane is reused only when no environment has to be set: the + # pane's shell is already running, and `agent start ... -- ` hands + # every token after -- to the agent executable (--kind), so an + # `env K=V` prefix would reach the agent as arguments, not as its + # environment (Issue #21 R1). With env flags, fall through to + # pane split / workspace create, which take --env. + if [ -n "$idle_pane" ] && [ -z "$env_flags" ]; then existing_ws="$cand_ws" target_pane="$idle_pane" - if [ -n "$env_flags" ] && [ -n "$final_cmd" ]; then - idle_env="${env_flags//--env /}" - idle_env="${idle_env:+$idle_env }HERDR_WORKSPACE_ID=$existing_ws" - final_cmd="env $idle_env $final_cmd" - fi break fi @@ -2693,3 +2694,19 @@ handle_startup_dialogs() { # Auto-initialize the herdr translation shim wrapper on library source _init_herdr_isolation + +# --------------------------------------------------------------------------- +# Issue #21 (item 3): control-plane binding of a session row. The logic lives +# in lib_py/control_plane.py; these are thin shell entry points. +# mam_control_plane_json [] +mam_control_plane_json() { + PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" "$(_delegate_py_bin)" -m lib_py.control_plane json "$@" +} + +# mam_control_plane_prefix : the leading K=V tokens for new-session, or +# nothing when no forward is needed. rc 3: a forward is needed but a path +# contains whitespace (lib.sh splits the --env flags unquoted). +mam_control_plane_prefix() { + [ -n "${1:-}" ] || return 0 + PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" "$(_delegate_py_bin)" -m lib_py.control_plane prefix "$1" +} diff --git a/.agents/skills/lib_py/control_plane.py b/.agents/skills/lib_py/control_plane.py new file mode 100644 index 0000000..3fb41ee --- /dev/null +++ b/.agents/skills/lib_py/control_plane.py @@ -0,0 +1,95 @@ +"""Control-plane binding of a session row (Issue #21, item 3). + +The binding is the four absolute paths a session's tools must agree on: +WORKSPACE_ROOT, AGENT_SESSIONS_YAML, MAM_JOBS_DIR, DELEGATE_JOB_LOGS_DIR. +create/resume persist it on the row (`control_plane`) and forward it to the +pane only when it differs from what `--workspace` alone would imply, through +the leading `K=V` tokens lib.sh's new-session already turns into `--env`. + +CLI (used by lib.sh): + python -m lib_py.control_plane json [] + -> {4 keys, "forward": bool, "whitespace": bool, "source": "row"|"env"} + python -m lib_py.control_plane prefix + -> "WORKSPACE_ROOT=... AGENT_SESSIONS_YAML=... ... " or "" (no forward) +""" + +from __future__ import annotations + +import json +import os +import shlex +import sys +from typing import Dict, Optional + +KEYS = ("workspace_root", "agent_sessions_yaml", "mam_jobs_dir", "delegate_job_logs_dir") +ENV = {"workspace_root": "WORKSPACE_ROOT", "agent_sessions_yaml": "AGENT_SESSIONS_YAML", + "mam_jobs_dir": "MAM_JOBS_DIR", "delegate_job_logs_dir": "DELEGATE_JOB_LOGS_DIR"} + + +def _abs(p: str) -> str: + return os.path.realpath(os.path.expanduser(p)) + + +def defaults(workspace: str) -> Dict[str, str]: + """D(ws): the binding `--workspace` implies on its own.""" + ws = _abs(workspace) + mam = os.path.join(ws, ".mam") + return {"workspace_root": ws, "agent_sessions_yaml": os.path.join(mam, "agent-sessions.yaml"), + "mam_jobs_dir": os.path.join(mam, "jobs"), "delegate_job_logs_dir": os.path.join(mam, "delegate_job_logs")} + + +def from_env(env=None) -> Dict[str, str]: + env = os.environ if env is None else env + b = defaults(env.get("WORKSPACE_ROOT") or os.getcwd()) + for k, var in ENV.items(): + v = (env.get(var) or "").strip() + if v: + b[k] = _abs(v) + return b + + +def describe(workspace: str, row: Optional[dict] = None) -> dict: + """The binding to use: the row's persisted one if complete (D11), else the + caller's env. A row binding that differs from the env is reported on stderr.""" + env_b = from_env() + source = "env" + b = env_b + if isinstance(row, dict) and all(isinstance(row.get(k), str) and row.get(k) for k in KEYS): + b = {k: row[k] for k in KEYS} + source = "row" + if b != env_b: + print("WARNING: using the control-plane binding recorded in the row " + f"({json.dumps(b, sort_keys=True)}) instead of the caller's environment", file=sys.stderr) + return {**b, "forward": b != defaults(workspace), + "whitespace": any(any(c.isspace() for c in v) for v in b.values()), "source": source} + + +def prefix(desc: dict) -> str: + if not desc.get("forward"): + return "" + return "".join(f"{ENV[k]}={shlex.quote(desc[k])} " for k in KEYS) + + +def main(argv=None) -> int: + argv = list(sys.argv[1:] if argv is None else argv) + if len(argv) >= 2 and argv[0] == "json": + row = None + if len(argv) >= 3 and argv[2].strip(): + try: + row = json.loads(argv[2]) + except json.JSONDecodeError: + row = None + print(json.dumps(describe(argv[1], row))) + return 0 + if len(argv) == 2 and argv[0] == "prefix": + desc = json.loads(argv[1]) + if desc.get("forward") and desc.get("whitespace"): + return 3 # lib.sh splits --env flags unquoted: refuse rather than break silently + sys.stdout.write(prefix(desc)) + return 0 + print(__doc__, file=sys.stderr) + return 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.agents/skills/lib_py/loop/eager.py b/.agents/skills/lib_py/loop/eager.py index 61da21e..4fb4b34 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.5.0" +FRAMEWORK_VERSION = "4.6.0" CORE_MODULES: Sequence[str] = ( # Standard library modules diff --git a/.agents/skills/lib_py/loop/runner.py b/.agents/skills/lib_py/loop/runner.py index 57b0634..94e0b28 100644 --- a/.agents/skills/lib_py/loop/runner.py +++ b/.agents/skills/lib_py/loop/runner.py @@ -13,6 +13,7 @@ import glob import json import os import re +import shlex import shutil import signal import sqlite3 @@ -20,7 +21,7 @@ import subprocess import sys import time from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional, Set, Tuple from lib_py.loop import checkpoint, eager, parser @@ -58,6 +59,20 @@ class LoopRunner: self.workspace_root = Path(os.environ.get("MAM_REAL_ROOT") or os.environ.get("WORKSPACE_ROOT") or Path.cwd()).resolve() self.mam_dir = self.workspace_root / ".mam" self.checkpoint_file = self.mam_dir / "loop-checkpoint.json" + # Issue #21: the jobs dir honours MAM_JOBS_DIR (and the wrapper is told + # the same dir explicitly). The source root (--worktree) is where the + # diff, base and head come from; everything else - wrapper, registry, + # checkpoint, loop guard, promotion, cleanup - stays on the control + # root (workspace_root). + # A relative MAM_JOBS_DIR is taken against the control root, as the + # wrapper (run with cwd=workspace_root) takes it. + self.jobs_dir = (self.workspace_root / Path(os.environ.get("MAM_JOBS_DIR") or self.mam_dir / "jobs")).resolve() + self.worktree_root: Path = self.workspace_root + self.worktree_given = False + self.base_ref: Optional[str] = None + self.base_sha = "" + self.base_source = "head" + self.review_only = False self.created_jobs: List[str] = [] self.failed_submit_jobs: Set[str] = set() self.current_plan = "" @@ -93,6 +108,9 @@ class LoopRunner: " --max-rebut N Max rebuttal attempts per review iteration (default: 1, 0: disabled)", " --verbose Print detailed execution timeline traces", " --cleanup Purge temporary job directories upon success", + " --worktree Source worktree for diff/base/head (control plane stays here)", + " --base Pin the review diff base (must be an ancestor of the source HEAD)", + " --review-only Skip implementation; review ..HEAD (requires --base)", ] print("\n".join(lines)) @@ -161,6 +179,22 @@ class LoopRunner: elif arg == "--cleanup": self.cleanup = True i += 1 + elif arg == "--worktree": + if i + 1 >= len(args): + self.print_usage() + sys.exit(1) + self.worktree_root = Path(args[i + 1]) + self.worktree_given = True + i += 2 + elif arg == "--base": + if i + 1 >= len(args): + self.print_usage() + sys.exit(1) + self.base_ref = args[i + 1] + i += 2 + elif arg == "--review-only": + self.review_only = True + i += 1 else: print(f"Unknown option: {arg}") self.print_usage() @@ -183,6 +217,67 @@ class LoopRunner: if self.plan_talk_turns > 0 and not self.plan_mode: log_warn("--plan-talk was specified but --plan mode is not enabled. Discussion turns will be ignored.") + # Issue #21 (D3): CLI --worktree wins over MAM_LOOP_WORKTREE. --base has + # no env fallback (D17): a stale sha left in a shell would still pass + # the ancestor check and silently widen the next loop's review range. + if not self.worktree_given and os.environ.get("MAM_LOOP_WORKTREE", "").strip(): + self.worktree_root = Path(os.environ["MAM_LOOP_WORKTREE"].strip()) + self.worktree_given = True + if self.worktree_given: + self.worktree_root = self._validate_worktree(self.worktree_root) + if self.review_only and self.base_ref is None: + print("ERROR: --review-only requires --base .", file=sys.stderr) + sys.exit(1) + if self.review_only and self.plan_mode: + print("ERROR: --review-only cannot be combined with --plan.", file=sys.stderr) + sys.exit(1) + if self.base_ref is not None: + self.base_sha = self._resolve_base(self.base_ref) + self.base_source = "cli" + + @staticmethod + def _git(cwd: Path, *args: str) -> subprocess.CompletedProcess: + return subprocess.run(["git", "-C", str(cwd), *args], capture_output=True, text=True) + + def _git_common_dir(self, cwd: Path) -> str: + res = self._git(cwd, "rev-parse", "--git-common-dir") + if res.returncode != 0 or not res.stdout.strip(): + return "" + d = res.stdout.strip() + # the main checkout answers ".git", relative to cwd (not to this process) + return os.path.realpath(d if os.path.isabs(d) else os.path.join(str(cwd), d)) + + def _validate_worktree(self, p: Path) -> Path: + p = Path(os.path.abspath(str(p))) + if not p.is_dir(): + log_error(f"--worktree {p}: no such directory.") + sys.exit(1) + top = self._git(p, "rev-parse", "--show-toplevel") + if top.returncode != 0 or not top.stdout.strip(): + log_error(f"--worktree {p}: not a git working tree.") + sys.exit(1) + if os.path.realpath(top.stdout.strip()) != os.path.realpath(p): + log_error(f"--worktree {p}: not the top of its working tree ({top.stdout.strip()}).") + sys.exit(1) + mine, ctl = self._git_common_dir(p), self._git_common_dir(self.workspace_root) + if not mine or mine != ctl: + log_error(f"--worktree {p}: not a worktree of the repository at {self.workspace_root}.") + sys.exit(1) + return Path(os.path.realpath(p)) + + def _resolve_base(self, ref: str) -> str: + """--base (D14): a full sha that is an ancestor of the source HEAD.""" + res = self._git(self.worktree_root, "rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}") + sha = res.stdout.strip() if res.returncode == 0 else "" + if not sha: + log_error(f"--base {ref}: not a commit in {self.worktree_root}.") + sys.exit(1) + if self._git(self.worktree_root, "merge-base", "--is-ancestor", sha, "HEAD").returncode != 0: + log_error(f"--base {ref} ({sha[:12]}) is not an ancestor of HEAD in {self.worktree_root}; " + "refusing to review an unrelated range.") + sys.exit(1) + return sha + def load_sessions_state(self) -> Dict[str, Any]: """Load registered sessions from MAM_STATE_JSON or agent-sessions.db/yaml.""" raw_env = os.environ.get("MAM_STATE_JSON") @@ -296,6 +391,7 @@ class LoopRunner: "--agent", agent_type, "--type", "direct", "--role", role, + "--registry-dir", str(self.jobs_dir), "--prompt", p_text, ] + self.contract_args() env = dict(os.environ) @@ -346,7 +442,7 @@ class LoopRunner: if self.verbose: log_info(f"Monitoring job '{job_id}' for status changes (timeout: {max_wait}s)...") - job_file = self.mam_dir / "jobs" / f"{job_id}.json" + job_file = self.jobs_dir / f"{job_id}.json" start_time = time.time() missing_count = 0 unknown_count = 0 @@ -388,7 +484,7 @@ class LoopRunner: def find_report(self, job_id: str) -> Optional[Path]: """Locate report-final.md for a job.""" - job_dir = self.mam_dir / "jobs" / job_id + job_dir = self.jobs_dir / job_id matches = list(job_dir.glob("*/report-final.md")) + list(job_dir.glob("report-final.md")) if matches: return matches[0] @@ -396,20 +492,94 @@ class LoopRunner: def collect_changes_diff(self) -> str: """Collect git diff since base commit.""" + # The collector is the control root's copy (never the worktree's + # unreviewed one); the tree it diffs is the source root (Issue #21). diff_script = self.workspace_root / ".agents" / "skills" / "multi-agent-mux-loop" / "scripts" / "diff_collect.sh" + src = str(self.worktree_root) if diff_script.exists(): - cmd = f"source '{diff_script}' && mam_collect_changes_diff '{self.workspace_root}' '{self.base_commit}'" - res = subprocess.run(["bash", "-c", cmd], capture_output=True, text=True, cwd=str(self.workspace_root)) + cmd = (f"source {shlex.quote(str(diff_script))} && " + f"mam_collect_changes_diff {shlex.quote(src)} {shlex.quote(self.base_commit)}") + res = subprocess.run(["bash", "-c", cmd], capture_output=True, text=True, cwd=src) if res.returncode == 0: return res.stdout # Fallback to direct git diff try: cmd = ["git", "diff", self.base_commit] if self.base_commit else ["git", "diff", "HEAD"] - res = subprocess.run(cmd, capture_output=True, text=True, cwd=str(self.workspace_root)) + res = subprocess.run(cmd, capture_output=True, text=True, cwd=src) return res.stdout except Exception: return "" + def _source_head(self) -> str: + res = self._git(self.worktree_root, "rev-parse", "HEAD") + out = res.stdout if isinstance(res.stdout, str) else "" + return out.strip() if res.returncode == 0 else "" + + def _context_active(self) -> bool: + return self.worktree_given or self.base_ref is not None + + def _worktree_context(self) -> str: + """The source-root block put in front of every delegated prompt (D6); + empty without --worktree/--base, so prompts stay byte-identical.""" + if not self._context_active(): + return "" + src = self.worktree_root + branch = self._git(src, "branch", "--show-current").stdout.strip() or "(detached)" + head = self._source_head() + base = self.base_commit or self.base_sha + origin = "--base" if self.base_source == "cli" else "๋ฃจํ”„ ์‹œ์ž‘ ์‹œ HEAD" + count = "?" + if base and head: + r = self._git(src, "rev-list", "--count", f"{base}..HEAD") + if r.returncode == 0 and r.stdout.strip(): + count = r.stdout.strip() + st = self._git(src, "status", "--porcelain") + dirty = "์•Œ ์ˆ˜ ์—†์Œ" if st.returncode != 0 else ("์žˆ์Œ" if st.stdout.strip() else "์—†์Œ") + return ("[์›ŒํฌํŠธ๋ฆฌ ์ปจํ…์ŠคํŠธ]\n" + f"- ์†Œ์Šค ๊ฒฝ๋กœ: {src}\n" + f"- ๋ธŒ๋žœ์น˜: {branch}\n" + f"- base: {base or '(Phase 2 ์‹œ์ž‘ ์‹œ HEAD ๋กœ ์ •ํ•ด์ง)'} (์ถœ์ฒ˜: {origin})\n" + f"- head: {head} (base..head ์ปค๋ฐ‹: {count}๊ฐœ, ๋ฏธ์ปค๋ฐ‹ ๋ณ€๊ฒฝ: {dirty})\n" + f"- ์ œ์–ด ํ‰๋ฉด(์žก ๋ธŒ๋ฆฌํ”„ยท๋ฆฌํฌํŠธยท๋ ˆ์ง€์ŠคํŠธ๋ฆฌ): {self.workspace_root}\n" + "์ด ์žก์˜ ์ฝ”๋“œ ์ž‘์—…ยท๊ฒ€์ฆ ๋Œ€์ƒ์€ ์œ„ ์†Œ์Šค ๊ฒฝ๋กœ๋‹ค. ๋ธŒ๋ฆฌํ”„์™€ ๋ฆฌํฌํŠธ๋Š” ๋ธŒ๋ฆฌํ”„์— ์ ํžŒ ์ ˆ๋Œ€๊ฒฝ๋กœ๋ฅผ ๊ทธ๋Œ€๋กœ ์“ด๋‹ค.\n\n") + + def _dispatch(self, agent_session: str, agent_type: str, role: str, prompt: str) -> Optional[str]: + """delegate_job with the source-root context block in front of the prompt.""" + return self.delegate_job(agent_session, agent_type, role, self._worktree_context() + prompt) + + def _change_set_state(self) -> str: + """'empty' | 'present' | 'unknown' for the source root against base, + judged by git exit codes only (D16).""" + if not self.base_commit: + return "unknown" + try: + d = self._git(self.worktree_root, "diff", "--quiet", self.base_commit) + if d.returncode == 1: + return "present" + if d.returncode != 0: + return "unknown" + u = self._git(self.worktree_root, "ls-files", "-o", "--exclude-standard", "-z") + if u.returncode != 0: + return "unknown" + return "present" if u.stdout else "empty" + except Exception: + return "unknown" + + def _change_set_allows_review(self) -> bool: + """Fail-closed guard (D16), on only with --worktree/--base: never ask + for a review of an empty or undeterminable change set.""" + if not self._context_active(): + return True + state = self._change_set_state() + if state == "empty": + log_error(f"empty change set since base {self.base_commit}; refusing to request reviews") + return False + if state != "present": + log_error(f"cannot determine the change set since base {self.base_commit or '(none)'} in " + f"{self.worktree_root}; refusing to request reviews on no evidence") + return False + return True + def _record_checkpoint(self, iteration: int = 1, review_status: Optional[Dict[str, str]] = None) -> None: data = { "iteration": iteration, @@ -418,6 +588,10 @@ class LoopRunner: "plan_job_id": self.plan_job_id, "review_status": review_status or {}, "base_commit": self.base_commit, + "base_source": self.base_source, + "review_only": self.review_only, + "worktree_root": str(self.worktree_root), + "head_commit": self._source_head() if self._context_active() else "", "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), } checkpoint.save_checkpoint(self.checkpoint_file, data) @@ -506,7 +680,7 @@ class LoopRunner: log_info("Requesting initial implementation plan from Planner...") p_type = self.resolve_agent_type(planner_session, state) plan_prompt = f"ํƒœ์Šคํฌ ๋ชฉํ‘œ๋ฅผ ๋ฐ”ํƒ•์œผ๋กœ ๊ตฌ์ฒด์ ์ธ ๊ตฌํ˜„ ๊ณ„ํš์„œ๋ฅผ ์ž‘์„ฑํ•ด์ฃผ์„ธ์š”. ๋ชฉํ‘œ: {self.task}" - p_jid = self.delegate_job(planner_session, p_type, "Planner", plan_prompt) + p_jid = self._dispatch(planner_session, p_type, "Planner", plan_prompt) if not p_jid: log_error("Failed to register planner job.") return 1 @@ -528,7 +702,7 @@ class LoopRunner: for turn in range(1, self.plan_talk_turns + 1): log_info(f"Discussion Turn {turn}/{self.plan_talk_turns}: Creator challenging the plan...") c_prompt = f"Planner๊ฐ€ ์ œ์‹œํ•œ ๋‹ค์Œ ๊ณ„ํš์„œ๋ฅผ ๊ผผ๊ผผํžˆ ๊ฒ€ํ† ํ•˜๊ณ , ์‹ค์ œ ๊ตฌํ˜„ ์‹œ ๋งˆ์ฃผํ•  ์ˆ˜ ์žˆ๋Š” ๋งน์ ์ด๋‚˜ ์ œ์•ฝ์‚ฌํ•ญ 1๊ฐ€์ง€๋ฅผ ๋ฐœ๊ตดํ•˜์—ฌ Planner์—๊ฒŒ ์ด์˜๋ฅผ ์ œ๊ธฐ(Challenge)ํ•ด์ฃผ์„ธ์š”. ๊ณ„ํš์„œ:\n{self.current_plan}" - c_jid = self.delegate_job(self.creator, creator_type, "Worker", c_prompt) + c_jid = self._dispatch(self.creator, creator_type, "Worker", c_prompt) if not c_jid or not self.wait_for_job(c_jid): log_error("Creator critique step failed.") return 1 @@ -538,7 +712,7 @@ class LoopRunner: log_info("Planner refining plan with Creator's feedback...") refine_prompt = f"์ž‘์—…์ž(Creator)๋กœ๋ถ€ํ„ฐ ๋‹ค์Œ ์ด์˜์ œ๊ธฐ ํ”ผ๋“œ๋ฐฑ์„ ๋ฐ›์•˜์Šต๋‹ˆ๋‹ค. ํ”ผ๋“œ๋ฐฑ์„ ๋ฐ˜์˜ํ•˜์—ฌ ๊ณ„ํš์„œ๋ฅผ ์ •๊ตํ•˜๊ฒŒ ์—…๋ฐ์ดํŠธ(Refine)ํ•˜์—ฌ ๋‹ค์‹œ ์ถœ๋ ฅํ•ด์ฃผ์„ธ์š”. ํ”ผ๋“œ๋ฐฑ:\n{critique_text}\n๊ธฐ์กด ๊ณ„ํš์„œ:\n{self.current_plan}" - ref_jid = self.delegate_job(planner_session, p_type, "Planner", refine_prompt) + ref_jid = self._dispatch(planner_session, p_type, "Planner", refine_prompt) if not ref_jid or not self.wait_for_job(ref_jid): log_error("Planner refinement step failed.") return 1 @@ -560,28 +734,34 @@ class LoopRunner: # PHASE 2: IMPLEMENTATION (CREATION) # ======================================================================= log_info("=== Phase 2: Code Implementation ===") - try: - res_base = subprocess.run(["git", "rev-parse", "HEAD"], capture_output=True, text=True, cwd=str(self.workspace_root)) - self.base_commit = res_base.stdout.strip() - except Exception: - self.base_commit = "" - - if self.current_plan: - exec_prompt = f"์ด๋ฏธ ์ˆ˜๋ฆฝ๋œ ๋‹ค์Œ ๊ณ„ํš์„œ์— ์ž…๊ฐํ•˜์—ฌ ์ž‘์—…์ž์˜ ํŒ๋‹จํ•˜์— ์ฝ”๋“œ๋ฅผ ๊ตฌํ˜„ํ•˜๊ณ  ์ž‘์—… ๋ชฉํ‘œ๋ฅผ ์™„์„ฑํ•ด์ฃผ์„ธ์š”. ๊ณ„ํš์„œ:\n{self.current_plan}\n\n์ž‘์—… ๋ชฉํ‘œ: {self.task}" + if self.base_sha: + self.base_commit = self.base_sha # --base (D14) else: - exec_prompt = f"๊ณ„ํš์„œ๊ฐ€ ์กด์žฌํ•˜์ง€ ์•Š์œผ๋ฏ€๋กœ, ์ž‘์—…์ž(Creator)์˜ ํŒ๋‹จํ•˜์— ์Šค์Šค๋กœ ๊ตฌํ˜„ ๊ณ„ํš ๋ฐ ์„ค๊ณ„๋ฅผ ์ˆ˜๋ฆฝํ•œ ๋’ค, ์ด๋ฅผ ๋ฐ”ํƒ•์œผ๋กœ ์ฝ”๋“œ๋ฅผ ๊ตฌํ˜„ํ•˜๊ณ  ๋‹ค์Œ ์ž‘์—… ๋ชฉํ‘œ๋ฅผ ์™„์„ฑํ•ด์ฃผ์„ธ์š”. ์ž‘์—… ๋ชฉํ‘œ: {self.task}" + try: + res_base = subprocess.run(["git", "rev-parse", "HEAD"], capture_output=True, text=True, cwd=str(self.worktree_root)) + self.base_commit = res_base.stdout.strip() + except Exception: + self.base_commit = "" creator_type = self.resolve_agent_type(self.creator, state) - exec_jid = self.delegate_job(self.creator, creator_type, "Worker", exec_prompt) - if not exec_jid: - log_error("Failed to register Creator execution job.") - return 1 - log_info(f"Creator Job ID: {exec_jid}") + if self.review_only: + log_info(f"Phase 2 skipped (--review-only): reviewing {self.base_commit}..{self._source_head()}") + else: + if self.current_plan: + exec_prompt = f"์ด๋ฏธ ์ˆ˜๋ฆฝ๋œ ๋‹ค์Œ ๊ณ„ํš์„œ์— ์ž…๊ฐํ•˜์—ฌ ์ž‘์—…์ž์˜ ํŒ๋‹จํ•˜์— ์ฝ”๋“œ๋ฅผ ๊ตฌํ˜„ํ•˜๊ณ  ์ž‘์—… ๋ชฉํ‘œ๋ฅผ ์™„์„ฑํ•ด์ฃผ์„ธ์š”. ๊ณ„ํš์„œ:\n{self.current_plan}\n\n์ž‘์—… ๋ชฉํ‘œ: {self.task}" + else: + exec_prompt = f"๊ณ„ํš์„œ๊ฐ€ ์กด์žฌํ•˜์ง€ ์•Š์œผ๋ฏ€๋กœ, ์ž‘์—…์ž(Creator)์˜ ํŒ๋‹จํ•˜์— ์Šค์Šค๋กœ ๊ตฌํ˜„ ๊ณ„ํš ๋ฐ ์„ค๊ณ„๋ฅผ ์ˆ˜๋ฆฝํ•œ ๋’ค, ์ด๋ฅผ ๋ฐ”ํƒ•์œผ๋กœ ์ฝ”๋“œ๋ฅผ ๊ตฌํ˜„ํ•˜๊ณ  ๋‹ค์Œ ์ž‘์—… ๋ชฉํ‘œ๋ฅผ ์™„์„ฑํ•ด์ฃผ์„ธ์š”. ์ž‘์—… ๋ชฉํ‘œ: {self.task}" - if not self.wait_for_job(exec_jid): - log_error("Creator execution failed.") - return 1 - log_success("Initial implementation finished.") + exec_jid = self._dispatch(self.creator, creator_type, "Worker", exec_prompt) + if not exec_jid: + log_error("Failed to register Creator execution job.") + return 1 + log_info(f"Creator Job ID: {exec_jid}") + + if not self.wait_for_job(exec_jid): + log_error("Creator execution failed.") + return 1 + log_success("Initial implementation finished.") # ======================================================================= # PHASE 3: VERIFICATION LOOP (PEER REVIEW) @@ -605,12 +785,14 @@ class LoopRunner: log_info(f"Review Loop Iteration {loop_count}/{self.max_loop}...") rebut_budget = self.max_rebut self._record_checkpoint(iteration=loop_count) + if not self._change_set_allows_review(): + return 1 if not reviewers: # Creator Self-Review log_warn("No reviewers specified. Conducting Creator Self-Review...") sr_prompt = "์ž‘์—… ์™„๋ฃŒ ์ƒํƒœ์— ๋Œ€ํ•ด ์Šค์Šค๋กœ ๊ฒ€์ฆ(Self-Review)ํ•˜์—ฌ ๊ฒฐํ•จ์ด ์—†์Œ์„ ํ™•์ธํ•˜๊ณ  ์ข…๊ฒฐํ•ด์ฃผ์„ธ์š”. ๋ฆฌ๋ทฐ ๋ฆฌํฌํŠธ ๋งˆ์ง€๋ง‰์— ๋‹จ๋… ํ–‰์œผ๋กœ ๋ฐ˜๋“œ์‹œ '[VERDICT: PASS]' ํ˜น์€ '[VERDICT: NOT PASS]' ํƒœ๊ทธ๋ฅผ ๋ช…์‹œํ•ด์ฃผ์„ธ์š”." - sr_jid = self.delegate_job(self.creator, creator_type, "Reviewer", sr_prompt) + sr_jid = self._dispatch(self.creator, creator_type, "Reviewer", sr_prompt) if not sr_jid: log_error("Failed to register Self-Review job.") return 1 @@ -634,7 +816,7 @@ class LoopRunner: log_info(f"Requesting code review from Reviewer '{rev}'...") r_type = self.resolve_agent_type(rev, state) r_prompt = f"๋‹ค์Œ ๊ตฌํ˜„ ์‚ฌํ•ญ(์ž‘์—… ๋ชฉํ‘œ: {self.task}) ๋ฐ ๋ˆ„์  ๋ณ€๊ฒฝ๋ถ„(git diff)์— ๋Œ€ํ•ด ๋ฆฐํŠธ, ๋™์ž‘์„ฑ, ์œ ์‹ค ๋“ฑ์˜ ๊ด€์ ์—์„œ ๊ต์ฐจ ์ฝ”๋“œ ๋ฆฌ๋ทฐ๋ฅผ ์ˆ˜ํ–‰ํ•ด์ฃผ์„ธ์š”. ํ™•์ธ ํ›„ ์ตœ์ข… Verdict๋กœ '[VERDICT: PASS]' ํ˜น์€ '[VERDICT: NOT PASS]' ํƒœ๊ทธ๋ฅผ ๋ฆฌ๋ทฐ ๋ฆฌํฌํŠธ ๋งˆ์ง€๋ง‰์— ๋‹จ๋… ํ–‰์œผ๋กœ ๋ช…์‹œ์ ์œผ๋กœ ์ž‘์„ฑํ•ด์ฃผ์„ธ์š”. ๋งŒ์•ฝ ๋‹จ์ˆœ ๋ฒ„๊ทธ ์ˆ˜์ •์œผ๋กœ๋Š” ๋ถ€์กฑํ•˜๊ณ  ์„ค๊ณ„ ๋ณ€๊ฒฝ/์žฌ์ž‘์—… ์ˆ˜์ค€์˜ ์žฌ๊ณ„ํš์ด ํ•„์š”ํ•˜๋‹ค๊ณ  ํŒ๋‹จ๋˜๋ฉด, ๋ฆฌํฌํŠธ ์•„๋ฌด ๊ณณ์—๋‚˜ ๋‹จ๋… ํ–‰์œผ๋กœ '[ESCALATE: PLANNER]' ํƒœ๊ทธ๋„ ํ•จ๊ป˜ ๋‚จ๊ฒจ์ฃผ์„ธ์š”. ๋ณ€๊ฒฝ๋ถ„:\n{changes_diff}" - r_jid = self.delegate_job(rev, r_type, "Reviewer", r_prompt) + r_jid = self._dispatch(rev, r_type, "Reviewer", r_prompt) if not r_jid: log_error(f"Failed to register review job for '{rev}'.") return 1 @@ -685,7 +867,7 @@ class LoopRunner: log_warn("Feedback involves complex code modifications. Diverting to Planner to revise plan...") p_type = self.resolve_agent_type(planner_session, state) refine_prompt = f"๋ฆฌ๋ทฐ์–ด๋“ค๋กœ๋ถ€ํ„ฐ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์ •๊ตํ•œ ์ฝ”๋“œ ์ˆ˜์ • ํ”ผ๋“œ๋ฐฑ์ด ๋„์ฐฉํ–ˆ์Šต๋‹ˆ๋‹ค. ํ•ด๋‹น ํ”ผ๋“œ๋ฐฑ์„ ์ˆ˜๋ ดํ•˜์—ฌ ๊ตฌํ˜„ ๊ณ„ํš์„œ(Plan)๋ฅผ ๊ฐฑ์‹ (Refine)ํ•˜์—ฌ ๋‹ค์‹œ ์ž‘์„ฑํ•ด์ฃผ์„ธ์š”. ํ”ผ๋“œ๋ฐฑ:\n{feedback_aggregate}\n๊ธฐ์กด ๊ณ„ํš์„œ:\n{self.current_plan}" - ref_jid = self.delegate_job(planner_session, p_type, "Planner", refine_prompt) + ref_jid = self._dispatch(planner_session, p_type, "Planner", refine_prompt) if not ref_jid or not self.wait_for_job(ref_jid): log_error("Failed to register plan refinement job.") return 1 @@ -700,7 +882,7 @@ class LoopRunner: if self.max_rebut > 0: correction_prompt += f"\n\n[์ด์˜์ œ๊ธฐ ์ฑ„๋„] ์œ„ ํ”ผ๋“œ๋ฐฑ ์ค‘ ํƒ€๋‹นํ•˜์ง€ ์•Š๋‹ค๊ณ  ํŒ๋‹จ๋˜๋Š” ํ•ญ๋ชฉ์ด ์žˆ๋‹ค๋ฉด, ๊ทธ ํ•ญ๋ชฉ์€ ๊ตฌํ˜„ํ•˜์ง€ ๋ง๊ณ  ๋ฐ˜๋ก ์„ ์ œ๊ธฐํ•˜์‹ญ์‹œ์˜ค. ๋ฐ˜๋ก  ์‹œ ๋ฆฌํฌํŠธ์— ๋‹จ๋… ํ–‰์œผ๋กœ '[REBUT: <๋ฆฌ๋ทฐ์–ด_์„ธ์…˜๋ช…>]' ํƒœ๊ทธ๋ฅผ ๋‚จ๊ธฐ๊ณ , ํ•ด๋‹น ๋ฆฌ๋ทฐ์–ด์˜ ์ง€์  ์ค‘ ์–ด๋А ํ•ญ๋ชฉ์„ ์™œ ๊ฑฐ๋ถ€ํ•˜๋Š”์ง€ ๊ทผ๊ฑฐ๋ฅผ ํ•จ๊ป˜ ์„œ์ˆ ํ•˜์‹ญ์‹œ์˜ค. ํƒ€๋‹นํ•œ ์ง€์ ์€ ์ •์ƒ์ ์œผ๋กœ ๋ฐ˜์˜ํ•˜์‹ญ์‹œ์˜ค. ๋ฐ˜๋ก ํ•  ํ•ญ๋ชฉ์ด ์—†์œผ๋ฉด ํƒœ๊ทธ๋ฅผ ๋‚จ๊ธฐ์ง€ ๋งˆ์‹ญ์‹œ์˜ค. ๋Œ€์ƒ ๋ฆฌ๋ทฐ์–ด ์„ธ์…˜๋ช…: {' '.join(fail_revs) if fail_revs else '(์—†์Œ)'}" - correct_jid = self.delegate_job(self.creator, creator_type, "Worker", correction_prompt) + correct_jid = self._dispatch(self.creator, creator_type, "Worker", correction_prompt) if not correct_jid or not self.wait_for_job(correct_jid): log_error("Failed to register Creator correction job.") return 1 @@ -728,7 +910,7 @@ class LoopRunner: log_info(f"Re-adjudication: returning rebuttal to Reviewer '{rt}'...") rt_type = self.resolve_agent_type(rt, state) adj_prompt = f"์ž‘์—…์ž(Creator)๊ฐ€ ๊ท€ํ•˜์˜ ๋ฆฌ๋ทฐ ์ง€์  ์ค‘ ์ผ๋ถ€๋ฅผ ํƒ€๋‹นํ•˜์ง€ ์•Š๋‹ค๊ณ  ํŒ๋‹จํ•˜์—ฌ ๋ฐ˜๋ก ์„ ์ œ๊ธฐํ–ˆ์Šต๋‹ˆ๋‹ค. ๋ฐ˜๋ก ์„ ๊ฒ€ํ† ํ•˜๊ณ  ์žฌ์‹ฌ(re-adjudication)ํ•˜์‹ญ์‹œ์˜ค. ๋ฐ˜๋ก ์ด ํƒ€๋‹นํ•˜์—ฌ ๊ท€ํ•˜์˜ ์ง€์ ์„ ์ฒ ํšŒํ•œ๋‹ค๋ฉด ๋ฆฌํฌํŠธ ๋งˆ์ง€๋ง‰์— ๋‹จ๋… ํ–‰์œผ๋กœ '[ADJUDICATION: SUSTAINED]' ๋ฅผ, ๋ฐ˜๋ก ์ด ํƒ€๋‹นํ•˜์ง€ ์•Š์•„ ์ง€์ ์„ ์œ ์ง€ํ•œ๋‹ค๋ฉด '[ADJUDICATION: OVERRULED]' ๋ฅผ ๋ช…์‹œํ•˜์‹ญ์‹œ์˜ค. ๋ฐ˜๋ก  ๋‚ด์šฉ:\n{rebuttal_text}" - adj_jid = self.delegate_job(rt, rt_type, "Reviewer", adj_prompt) + adj_jid = self._dispatch(rt, rt_type, "Reviewer", adj_prompt) if not adj_jid or not self.wait_for_job(adj_jid): log_warn(f"Re-adjudication job for '{rt}' failed; objection stands.") overruled_revs.append(rt) @@ -747,7 +929,7 @@ class LoopRunner: log_info(f"Deadlock on {' '.join(overruled_revs)}; escalating to Planner for arbitration...") p_type = self.resolve_agent_type(planner_session, state) arb_prompt = f"์ž‘์—…์ž(Creator)์™€ ๋ฆฌ๋ทฐ์–ด์˜ ์˜๊ฒฌ์ด ์ถฉ๋Œํ•˜์—ฌ ๊ต์ฐฉ ์ƒํƒœ์ž…๋‹ˆ๋‹ค. ๊ณ„ํš์„œ ๊ด€์ ์—์„œ ์–ด๋А ์ชฝ์ด ํƒ€๋‹นํ•œ์ง€ ์žฌ์ •(arbitration)ํ•˜์‹ญ์‹œ์˜ค. ๋ฆฌํฌํŠธ ๋งˆ์ง€๋ง‰์— ๋‹จ๋… ํ–‰์œผ๋กœ '[ARBITRATION: CREATOR]' ๋˜๋Š” '[ARBITRATION: REVIEWER]' ๋ฅผ ๋ช…์‹œํ•˜์‹ญ์‹œ์˜ค. ๋ฆฌ๋ทฐ์–ด ์ง€์ :\n{feedback_aggregate}\n์ž‘์—…์ž ๋ฐ˜๋ก :\n{rebuttal_text}\n๊ธฐ์กด ๊ณ„ํš์„œ:\n{self.current_plan}" - arb_jid = self.delegate_job(planner_session, p_type, "Planner", arb_prompt) + arb_jid = self._dispatch(planner_session, p_type, "Planner", arb_prompt) if arb_jid and self.wait_for_job(arb_jid): arb_file = self.find_report(arb_jid) if arb_file and parser.has_tag_line(arb_file, "ARBITRATION: CREATOR"): @@ -761,7 +943,7 @@ class LoopRunner: log_warn("Ruling stands with the reviewer(s); Creator must comply.") rebut_budget = 0 comply_prompt = f"๊ท€ํ•˜์˜ ๋ฐ˜๋ก ์€ ์žฌ์‹ฌ ๊ฒฐ๊ณผ ๊ธฐ๊ฐ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ๋” ์ด์ƒ ๋ฐ˜๋ก ํ•˜์ง€ ๋ง๊ณ  ๋‹ค์Œ ๋ฆฌ๋ทฐ ํ”ผ๋“œ๋ฐฑ์„ ๊ทธ๋Œ€๋กœ ๋ฐ˜์˜ํ•˜์—ฌ ์ฝ”๋“œ๋ฅผ ์ˆ˜์ •ํ•˜์‹ญ์‹œ์˜ค. ํ”ผ๋“œ๋ฐฑ:\n{feedback_aggregate}" - comp_jid = self.delegate_job(self.creator, creator_type, "Worker", comply_prompt) + comp_jid = self._dispatch(self.creator, creator_type, "Worker", comply_prompt) if comp_jid: self.wait_for_job(comp_jid) @@ -788,10 +970,10 @@ class LoopRunner: if self.cleanup: log_info("Cleaning up temporary job directories created during this loop run...") for job in self.created_jobs: - job_dir = self.mam_dir / "jobs" / job + job_dir = self.jobs_dir / job if job_dir.exists(): shutil.rmtree(job_dir, ignore_errors=True) - sub_out = self.mam_dir / "jobs" / f"{job}.subscriber.out" + sub_out = self.jobs_dir / f"{job}.subscriber.out" if sub_out.exists(): sub_out.unlink(missing_ok=True) if self.verbose: diff --git a/.agents/skills/multi-agent-mux-create/SKILL.md b/.agents/skills/multi-agent-mux-create/SKILL.md index 4dd5b12..fc5628c 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.5.0 +version: 4.6.0 author: godopu license: MIT platforms: [linux, macos] 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 9df4a15..de2e809 100755 --- a/.agents/skills/multi-agent-mux-create/scripts/create_session.sh +++ b/.agents/skills/multi-agent-mux-create/scripts/create_session.sh @@ -202,6 +202,23 @@ if [ -z "$CMD_FULL" ]; then esac fi +# Issue #21 (item 3): the control-plane binding. Persisted on the row always; +# forwarded to the pane (leading K=V tokens that lib.sh's new-session turns +# into --env) only when it differs from what --workspace alone implies. +# lib.sh splits those flags unquoted (and, with env flags, never reuses an +# idle pane - `agent start --` would pass them to the agent as arguments), so a +# binding with whitespace that would have to be forwarded is refused. +CP_JSON="$(mam_control_plane_json "$WORKSPACE" 2>/dev/null || true)" +CP_PREFIX="" +if [ -n "$CP_JSON" ]; then + cp_rc=0 + CP_PREFIX="$(mam_control_plane_prefix "$CP_JSON")" || cp_rc=$? + if [ "$cp_rc" -eq 3 ]; then + echo "ERROR: control-plane path contains whitespace; cannot forward to the pane" >&2 + exit 2 + fi +fi + spawn() { if [ -z "$HERDR_SERVER_OPT" ]; then if [ -z "${HERDR_SESSION_NAME:-}" ] || [ "$HERDR_SESSION_NAME" = "default" ]; then @@ -213,20 +230,21 @@ spawn() { if { [ -x "$WRAPPER" ] && [ "$(basename "$WRAPPER")" != "claude" ]; } || [ "$USE_WRAPPER" = "1" ]; then SESSION_UUID="" CMD_FULL="$("$(_delegate_py_bin)" -m lib_py.agents spawn-spec "$AGENT" "$RESOLVED_BIN" "" "true" 2>/dev/null || echo "${RESOLVED_BIN} --dangerously-skip-permissions")" + [ -z "$CP_PREFIX" ] || echo "WARNING: the ~/.local/bin wrapper path bypasses herdr; the control-plane binding is recorded but not forwarded" >&2 nohup "$WRAPPER" >/dev/null 2>&1 & disown else - HERDR_SESSION_NAME="$HERDR_SESSION_NAME" _herdr new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" "$CMD_FULL" + HERDR_SESSION_NAME="$HERDR_SESSION_NAME" _herdr new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" "$CP_PREFIX$CMD_FULL" fi ;; agy|hermes|grok|codex) - HERDR_SESSION_NAME="$HERDR_SESSION_NAME" _herdr new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" "$CMD_FULL" + HERDR_SESSION_NAME="$HERDR_SESSION_NAME" _herdr new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" "$CP_PREFIX$CMD_FULL" ;; opencode) if [ -z "${OPENCODE_PERMISSION:-}" ]; then export OPENCODE_PERMISSION='{"*":"allow"}' fi - HERDR_SESSION_NAME="$HERDR_SESSION_NAME" _herdr new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" "$CMD_FULL" + HERDR_SESSION_NAME="$HERDR_SESSION_NAME" _herdr new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" "$CP_PREFIX$CMD_FULL" ;; *) echo "ERROR: --agent must be claude, agy, hermes, grok, opencode, or codex, got: $AGENT" >&2; exit 2 ;; esac @@ -391,7 +409,7 @@ atomic_dump_yaml "$AGENT_SESSIONS_YAML" \ MAM_WS_LABEL="$MAM_WS_LABEL" \ SESSION_UUID="$SESSION_UUID" \ LAST_VISIBLE_STATUS_OVERRIDE="${LAST_VISIBLE_STATUS_OVERRIDE:-}" \ - DELEGATE_JOB_ID="$DELEGATE_JOB_ID" ROLE="$ROLE" <<'PYEOF' + DELEGATE_JOB_ID="$DELEGATE_JOB_ID" ROLE="$ROLE" CONTROL_PLANE_JSON="$CP_JSON" <<'PYEOF' name = os.environ['SESSION_NAME'] agent = os.environ['AGENT'] role = os.environ['ROLE'] @@ -487,6 +505,15 @@ last_vis_override = os.environ.get('LAST_VISIBLE_STATUS_OVERRIDE', '') if last_vis_override: entry['last_visible_status'] = last_vis_override +# Issue #21: the control-plane binding this row was created against. +_cp_raw = os.environ.get('CONTROL_PLANE_JSON', '') +if _cp_raw: + try: + import json as _json + _cp = _json.loads(_cp_raw) + entry['control_plane'] = {k: _cp[k] for k in ('workspace_root', 'agent_sessions_yaml', 'mam_jobs_dir', 'delegate_job_logs_dir')} + except Exception: + pass sessions.append(entry) snap = d.setdefault('snapshot', {}) @@ -503,7 +530,20 @@ if [ -n "$DELEGATE_JOB_ID" ]; then # Construct instructions matching multi-agent-mux-delegate-job format py_bin="$(_delegate_py_bin)" - pub="$py_bin .agents/skills/multi-agent-mux-delegate-job/scripts/publish_event.py --job $DELEGATE_JOB_ID" + # 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% }" instructions="Your job_id is \"$DELEGATE_JOB_ID\" (the one just registered for THIS delegation). On start run: $pub --event started. diff --git a/.agents/skills/multi-agent-mux-delegate-job/SKILL.md b/.agents/skills/multi-agent-mux-delegate-job/SKILL.md index e073daa..84ed3f4 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.5.0 +version: 4.6.0 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 3259f16..5347d02 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 @@ -68,7 +68,42 @@ pick_python() { echo "$py_bin" } -REGISTRY_DIR_DEFAULT=".mam/jobs" +REGISTRY_DIR_DEFAULT="${MAM_JOBS_DIR:-.mam/jobs}" + +# Issue #21: the injected brief path, the start/success/error commands and the +# Output Report Path must work from any cwd - a creator in a git worktree runs +# them from there. Called right after `cd "$WORKDIR"`, so a relative registry +# still resolves against WORKDIR, as before. +_abs_registry_dir() { + mkdir -p "$REGISTRY_DIR" + REGISTRY_DIR="$(cd -P "$REGISTRY_DIR" && pwd -P)" + export SKS_REGISTRY_DIR="$REGISTRY_DIR" +} + +# The audit-log root this wrapper's own subscriber resolves (same resolver), +# pinned into the injected commands so an agent publishing from another cwd +# does not fall back to /.mam/delegate_job_logs. +_logs_dir_abs() { + "$PY" -c 'import os, sys; sys.path.insert(0, sys.argv[1]); from lib_py.delegation.mqtt_common import get_logs_dir; print(os.path.abspath(get_logs_dir()))' "$SCRIPT_DIR/.." +} + +# _pub_cmd : the publish command as one shell-quoted string. +_pub_cmd() { + local s + s="$( + if [ -n "${LOGS_DIR_ABS:-}" ]; then printf 'DELEGATE_JOB_LOGS_DIR=%q ' "$LOGS_DIR_ABS"; fi + printf '%q ' "$PY" "$SCRIPT_DIR/scripts/publish_event.py" --registry-dir "$REGISTRY_DIR" --job "$JOB_ID" "$@" + )" + printf '%s' "${s% }" +} + +# _instructions [iteration]: the prompt injected into the agent (direct +# and loop jobs share it; test18 pins parts of this wording). +_instructions() { + local pub="$1" iter="${2:-}" brief="$REGISTRY_DIR/$JOB_ID/brief.md" + printf 'Your job_id is "%s". Detailed task requirements, instructions, and target output paths%s are documented in the task brief file at: %s. Please READ and follow %s to complete your work. Commands: start='"'"'%s --event started --heartbeat %s'"'"' (run it FIRST, within %s s; it also starts an automatic heartbeat timer), success='"'"'%s --event completed --detail '"'"', error='"'"'%s --event error --detail '"'"'.' \ + "$JOB_ID" "${iter:+ for iteration $iter}" "$brief" "$brief" "$pub" "$HEARTBEAT_SEC" "$ACK_TIMEOUT" "$pub" "$pub" +} usage() { cat <<'EOF' @@ -150,8 +185,10 @@ cmd_submit() { parse_opts "$@" [[ -n "$PROMPT" ]] || { echo "submit requires --prompt" >&2; exit 1; } PY="$(pick_python)" + [[ "$PY" == */* && "$PY" != /* ]] && PY="$(pwd)/$PY" cd "$WORKDIR" - mkdir -p "$REGISTRY_DIR" + _abs_registry_dir + LOGS_DIR_ABS="$(_logs_dir_abs 2>/dev/null || true)" # 1) register job (prints the new job id) JOB_ID="$("$PY" "$SCRIPT_DIR/scripts/registry.py" --registry-dir "$REGISTRY_DIR" register \ @@ -175,7 +212,7 @@ cmd_submit() { - **Role**: $DELEGATE_ROLE - **Timeout**: $TIMEOUT s (Idle: $IDLE_TIMEOUT s) - **Liveness contract**: heartbeat every $HEARTBEAT_SEC s (automatic once you publish \`started --heartbeat $HEARTBEAT_SEC\`), unresponsive after $HEARTBEAT_MISSES missed heartbeats, \`started\` expected within $ACK_TIMEOUT s -- **Output Report Path**: .mam/jobs/$JOB_ID/$AGENT-reports/report-final.md +- **Output Report Path**: $REGISTRY_DIR/$JOB_ID/$AGENT-reports/report-final.md ## ๐Ÿ”Ž Task Description $PROMPT @@ -195,8 +232,9 @@ EOF local pub_extra=() [ -n "$_disp_tok" ] && pub_extra+=(--expect-dispatch-token "$_disp_tok") [ -n "$_attempt_id" ] && pub_extra+=(--attempt-id "$_attempt_id") - local pub="$PY $SCRIPT_DIR/scripts/publish_event.py --registry-dir $REGISTRY_DIR --job $JOB_ID ${pub_extra[*]}" - local instructions="Your job_id is \"$JOB_ID\". Detailed task requirements, instructions, and target output paths are documented in the task brief file at: .mam/jobs/$JOB_ID/brief.md. Please READ and follow .mam/jobs/$JOB_ID/brief.md to complete your work. Commands: start='$pub --event started --heartbeat $HEARTBEAT_SEC' (run it FIRST, within $ACK_TIMEOUT s; it also starts an automatic heartbeat timer), success='$pub --event completed --detail ', error='$pub --event error --detail '." + local pub instructions + pub="$(_pub_cmd ${pub_extra[@]+"${pub_extra[@]}"})" + instructions="$(_instructions "$pub")" if [[ "$DRY_RUN" == "1" ]]; then run_agent "$JOB_ID" "$instructions" @@ -349,7 +387,7 @@ EOF - **Role**: $display_role - **Iteration**: $iteration - **Liveness contract**: heartbeat every $HEARTBEAT_SEC s (automatic once you publish \`started --heartbeat $HEARTBEAT_SEC\`), unresponsive after $HEARTBEAT_MISSES missed heartbeats, \`started\` expected within $ACK_TIMEOUT s -- **Output Report Path**: .mam/jobs/$JOB_ID/${clean_session}-reports/report-final.md +- **Output Report Path**: $REGISTRY_DIR/$JOB_ID/${clean_session}-reports/report-final.md ## ๐Ÿ”Ž Task Description $current_prompt @@ -451,8 +489,9 @@ EOF # Format instruction block local loop_pub_extra=(--attempt-id "$attempt") [ -n "$_disp_tok" ] && loop_pub_extra+=(--expect-dispatch-token "$_disp_tok") - local pub="$PY $SCRIPT_DIR/scripts/publish_event.py --registry-dir $REGISTRY_DIR --job $JOB_ID ${loop_pub_extra[*]}" - local instructions="Your job_id is \"$JOB_ID\". Detailed task requirements, instructions, and target output paths for iteration $iteration are documented in the task brief file at: .mam/jobs/$JOB_ID/brief.md. Please READ and follow .mam/jobs/$JOB_ID/brief.md to complete your work. Commands: start='$pub --event started --heartbeat $HEARTBEAT_SEC' (run it FIRST, within $ACK_TIMEOUT s; it also starts an automatic heartbeat timer), success='$pub --event completed --detail ', error='$pub --event error --detail '." + local pub instructions + pub="$(_pub_cmd "${loop_pub_extra[@]}")" + instructions="$(_instructions "$pub" "$iteration")" # Trigger agent if ! deliver_prompt "$JOB_ID" "$instructions" "$current_session" "$logf"; then diff --git a/.agents/skills/multi-agent-mux-loop/SKILL.md b/.agents/skills/multi-agent-mux-loop/SKILL.md index 7e7fa26..d453597 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.5.0 +version: 4.6.0 author: godopu license: MIT platforms: [linux, macos] @@ -195,6 +195,21 @@ sequenceDiagram | **Iterative Loop** | `--max-loop M` | NOT PASS ํŒ์ • ์‹œ ์ตœ๋Œ€ `M`ํšŒ๊นŒ์ง€ Creator๊ฐ€ ์ž์ฒด ์ˆ˜์ •ํ•ฉ๋‹ˆ๋‹ค. `--plan` ๋ชจ๋“œ์—์„œ ๋ฆฌ๋ทฐ์–ด๊ฐ€ ๋ฆฌํฌํŠธ์— `[ESCALATE: PLANNER]` ํƒœ๊ทธ๋ฅผ ๋‚จ๊ธฐ๋ฉด ์„ค๊ณ„ ๋ณ€๊ฒฝ ์ˆ˜์ค€์œผ๋กœ ํŒ๋‹จํ•˜์—ฌ Planner์—๊ฒŒ ๊ณ„ํš ๊ฐฑ์‹ ์„ ์œ„์ž„ํ•ฉ๋‹ˆ๋‹ค (๋ฆฐํŠธ๋Š” ๋ฆฌ๋ทฐ์–ด๊ฐ€ ๊ฒ€ํ†  ๊ด€์  ์ค‘ ํ•˜๋‚˜๋กœ ํ™•์ธํ•  ๋ฟ, ๋ณ„๋„์˜ ์ž๋™ ๊ฒŒ์ดํŠธ๋Š” ์•„๋‹™๋‹ˆ๋‹ค). | | **Rebuttal** | `--max-rebut N` | ์ดํ„ฐ๋ ˆ์ด์…˜๋‹น Creator ๋ฐ˜๋ก  ํšŸ์ˆ˜ (๊ธฐ๋ณธ 1, `0`์ด๋ฉด ๋”). | | **Control** | `--verbose` / `--cleanup` | ์ƒ์„ธ ๋กœ๊ทธ / ์„ฑ๊ณต ์‹œ ์ž„์‹œ job ๋””๋ ‰ํ„ฐ๋ฆฌ ์‚ญ์ œ. | +| **Source root** | `--worktree ` (env `MAM_LOOP_WORKTREE`, CLI ์šฐ์„ ) | diff ์ˆ˜์ง‘ยทbaseยทheadยท๋นˆ ๋ณ€๊ฒฝ๋ถ„ ํŒ์ •๋งŒ ์ด git ์›ŒํฌํŠธ๋ฆฌ ๊ธฐ์ค€์œผ๋กœ ํ•ฉ๋‹ˆ๋‹ค. wrapperยท๋ ˆ์ง€์ŠคํŠธ๋ฆฌยท์ฒดํฌํฌ์ธํŠธยทloop guardยท๋ฆฌํฌํŠธ ์Šน๊ฒฉยทcleanup ์€ ๋ฃจํ”„๋ฅผ ๋„์šด ๋ณธ ์ฒดํฌ์•„์›ƒ(์ œ์–ด ๋ฃจํŠธ)์— ๋‚จ์Šต๋‹ˆ๋‹ค. ๋ชจ๋“  ์œ„์ž„ ํ”„๋กฌํ”„ํŠธ ์•ž์— `[์›ŒํฌํŠธ๋ฆฌ ์ปจํ…์ŠคํŠธ]` ๋ธ”๋ก(๊ฒฝ๋กœยท๋ธŒ๋žœ์น˜ยทbaseยทheadยท์ปค๋ฐ‹ ์ˆ˜ยท๋ฏธ์ปค๋ฐ‹ ๋ณ€๊ฒฝ)์ด ๋ถ™์Šต๋‹ˆ๋‹ค. Python ๋ฃจํ”„ ์ „์šฉ(Issue #21). | +| **Diff base** | `--base ` (CLI ์ „์šฉ, env ์—†์Œ) | ๋ฆฌ๋ทฐ diff ์˜ base ๋ฅผ ๊ณ ์ •ํ•ฉ๋‹ˆ๋‹ค. ์†Œ์Šค HEAD ์˜ ์กฐ์ƒ์ด์–ด์•ผ ํ•˜๋ฉฐ ์•„๋‹ˆ๋ฉด exit 1. ์ƒ๋žต ์‹œ Phase 2 ์‹œ์ž‘ ์‹œ์ ์˜ ์†Œ์Šค HEAD ์ž…๋‹ˆ๋‹ค. | +| **Review only** | `--review-only` | Phase 2(๊ตฌํ˜„ ์œ„์ž„)๋ฅผ ๊ฑด๋„ˆ๋›ฐ๊ณ  `..HEAD` ๋ฅผ ๋ฆฌ๋ทฐํ•ฉ๋‹ˆ๋‹ค. `--base` ํ•„์ˆ˜, `--plan` ๊ณผ ํ•จ๊ป˜ ์“ธ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. ์ˆ˜์ •ยท์ˆœ์‘ ์žก์€ ์—ฌ์ „ํžˆ creator ์—๊ฒŒ ๊ฐ‘๋‹ˆ๋‹ค. | + +`--worktree` ๋˜๋Š” `--base` ๊ฐ€ ์žˆ์œผ๋ฉด ๋งค ๋ฆฌ๋ทฐ ๋ผ์šด๋“œ(์…€ํ”„ ๋ฆฌ๋ทฐ ํฌํ•จ) ์ „์— ์†Œ์Šค ๋ฃจํŠธ์˜ ๋ณ€๊ฒฝ๋ถ„์„ git ์ข…๋ฃŒ ์ฝ”๋“œ๋กœ ํŒ์ •ํ•ฉ๋‹ˆ๋‹ค. ๋ณ€๊ฒฝ๋ถ„์ด ๋น„์—ˆ๊ฑฐ๋‚˜ ํŒ์ •ํ•  ์ˆ˜ ์—†์œผ๋ฉด ๋ฆฌ๋ทฐ๋ฅผ ์š”์ฒญํ•˜์ง€ ์•Š๊ณ  exit 1 ๋กœ ๋๋‚ฉ๋‹ˆ๋‹ค(fail-closed). ๋‘˜ ๋‹ค ์—†์œผ๋ฉด ๊ธฐ์กด ๋™์ž‘ ๊ทธ๋Œ€๋กœ์ž…๋‹ˆ๋‹ค. + +๊ตฌํ˜„์ด ๋ฃจํ”„ ๋ฐ–(์›ŒํฌํŠธ๋ฆฌ์˜ creator ์—๊ฒŒ `delegate-job --type direct`)์—์„œ ๋๋‚ฌ๋‹ค๋ฉด, **๊ตฌํ˜„ ์œ„์ž„ ์ „์—** base ๋ฅผ ๊ธฐ๋กํ•ด ๋‘๊ณ  ๋ฆฌ๋ทฐ๋งŒ ๋Œ๋ฆฝ๋‹ˆ๋‹ค: + +```bash +BASE=$(git -C "$WT" rev-parse HEAD) # ๊ตฌํ˜„ ์œ„์ž„ ์ „์— ๊ธฐ๋ก +# ... creator ๊ฐ€ $WT ์—์„œ ๊ตฌํ˜„ยท์ปค๋ฐ‹ ... +bash .agents/skills/multi-agent-mux-loop/scripts/run_loop.sh \ + --worktree "$WT" --base "$BASE" --review-only \ + --creator "" --reviewer "" --task "Issue #N review" +``` --- @@ -232,5 +247,5 @@ bash .agents/skills/multi-agent-mux-loop/scripts/run_loop.sh \ - **Incorrect Verdict format (์•ต์ปค๋ง ํŒŒ์„œ ํ•˜๋“œ๋‹)**: ๋ฆฌ๋ทฐ ๋ฆฌํฌํŠธ ํŒŒ์ผ ๋‚ด์—์„œ `[VERDICT: PASS]` ๋˜๋Š” `[VERDICT: NOT PASS]` ํ† ํฐ์€ ๋ฐ˜๋“œ์‹œ ๋ฆฌํฌํŠธ์˜ **๋งˆ์ง€๋ง‰์— ๋‹จ๋… ํ–‰**์œผ๋กœ ๊ธฐ์žฌ๋˜์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ์ฝ”๋“œ ์ธ์šฉ์ด๋‚˜ ๋ณ€๊ฒฝ diff ๋‚ด์— ๋“ฑ์žฅํ•˜๋Š” ํ† ํฐ์€ ๋งค์นญ ๋Œ€์ƒ์—์„œ ์™„์ „ ๋ฐฐ์ œ๋ฉ๋‹ˆ๋‹ค. - **Fail-closed on missing verdict**: ์ตœ์ข… Verdict ํ† ํฐ์ด ๋ˆ„๋ฝ๋˜๊ฑฐ๋‚˜ ๋ฆฌํฌํŠธ ํ”ฝ์—…์— ์‹คํŒจํ•˜๋ฉด, ํŒŒ์„œ๋Š” **๊ฒฝ๊ณ  ํ›„ ํ†ต๊ณผ์‹œํ‚ค๋Š” ๊ฒƒ์ด ์•„๋‹ˆ๋ผ** ์•ˆ์ „์„ ์œ„ํ•ด ์ฆ‰์‹œ `NOT PASS`๋กœ ํŒ์ •(fail-closed)ํ•˜๊ณ  ๊ต์ • ์‚ฌ์ดํด์„ ์ˆ˜ํ–‰ํ•ฉ๋‹ˆ๋‹ค. ๋ฆฌ๋ทฐ์–ด์—๊ฒŒ๋Š” ๋ฐ˜๋“œ์‹œ ๋ฆฌํฌํŠธ ๋์— ๋‹จ๋… ํ–‰์œผ๋กœ ํ† ํฐ์„ ์ฐ๋„๋ก ์ง€์‹œํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. - **Session Availability**: `run_loop.sh` ๊ธฐ๋™ ์ „์— ์ฐธ์กฐ๋˜๋Š” Planner, Target Agent, Reviewer ์„ธ์…˜๋“ค์ด ๋ชจ๋‘ herdr ์„ธ์…˜์œผ๋กœ ๊ธฐ๋™๋˜์–ด (`status.sh` ๊ธฐ์ค€ `alive` ๋ฐ `running`) ์žˆ์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. -- **๋™์‹œ ๋ฃจํ”„ ๊ธฐ๋™ ๊ธˆ์ง€ (NFS Lock Shadowing)**: ๋™์ผํ•œ ์ž‘์—… ํŠธ๋ฆฌ ๋‚ด์—์„œ ๋‹ค์ˆ˜์˜ `run_loop.sh` ์ œ์–ด๊ธฐ๋ฅผ ๋™์‹œ์— ๊ธฐ๋™ํ•˜๋ฉด SQLite DB ๊ฐฑ์‹  ๊ฒฝํ•ฉ ๋ฐ YAML ๋ฐ์ดํ„ฐ ์˜ค์—ผ์ด ๋ฐœ์ƒํ•ฉ๋‹ˆ๋‹ค. ํ•˜๋‚˜์˜ ๋ฃจํ”„๊ฐ€ ๋๋‚  ๋•Œ๊นŒ์ง€ ๋‹ค๋ฅธ ๋ฃจํ”„๋ฅผ ๋ณ‘๋ ฌ๋กœ ๊ธฐ๋™ํ•˜์ง€ ๋งˆ์‹ญ์‹œ์˜ค. +- **๋™์‹œ ๋ฃจํ”„ ๊ธฐ๋™ ๊ธˆ์ง€ (NFS Lock Shadowing)**: ๋™์ผํ•œ ์ž‘์—… ํŠธ๋ฆฌ ๋‚ด์—์„œ ๋‹ค์ˆ˜์˜ `run_loop.sh` ์ œ์–ด๊ธฐ๋ฅผ ๋™์‹œ์— ๊ธฐ๋™ํ•˜๋ฉด SQLite DB ๊ฐฑ์‹  ๊ฒฝํ•ฉ ๋ฐ YAML ๋ฐ์ดํ„ฐ ์˜ค์—ผ์ด ๋ฐœ์ƒํ•ฉ๋‹ˆ๋‹ค. ํ•˜๋‚˜์˜ ๋ฃจํ”„๊ฐ€ ๋๋‚  ๋•Œ๊นŒ์ง€ ๋‹ค๋ฅธ ๋ฃจํ”„๋ฅผ ๋ณ‘๋ ฌ๋กœ ๊ธฐ๋™ํ•˜์ง€ ๋งˆ์‹ญ์‹œ์˜ค. (์‹ค์ œ๋กœ๋Š” loop lock ์ด ๋‘ ๋ฒˆ์งธ ๋ฃจํ”„๋ฅผ exit 1 ๋กœ ๊ฑฐ๋ถ€ํ•œ๋‹ค โ€” run_loop.sh ์˜ `mam_acquire_loop_lock` ๋ถ„๊ธฐ) - **์›์ž์  ์•„์นด์ด๋น™ (Promotion)**: ๋ฃจํ”„ ์„ฑ๊ณต ์ข…๋ฃŒ ์‹œ ์ตœ์ข… ๊ณ„ํš์„œ์™€ ๊ฒ€์ฆ ๋ฆฌํฌํŠธ๋“ค์€ `.agents/reports//` ๋””๋ ‰ํ† ๋ฆฌ๋กœ ์›์ž์ ์œผ๋กœ ๋ฎ์–ด์“ฐ๊ธฐ(`mv -f`)๋˜์–ด ๋ณด์กด๋ฉ๋‹ˆ๋‹ค. ํ•ด๋‹น ๊ฒฝ๋กœ์˜ ๋ฆฌํฌํŠธ๋“ค๋กœ VCS ์ถ”์ ์„ฑ์„ ํ™•๋ณดํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. diff --git a/.agents/skills/multi-agent-mux-loop/scripts/run_loop.sh b/.agents/skills/multi-agent-mux-loop/scripts/run_loop.sh index 26999df..f83b853 100644 --- a/.agents/skills/multi-agent-mux-loop/scripts/run_loop.sh +++ b/.agents/skills/multi-agent-mux-loop/scripts/run_loop.sh @@ -32,6 +32,9 @@ TARGET_AGENT="" TASK="" REVIEWER_LIST="" PLANNER_SESSION_OVERRIDE="" +LOOP_WORKTREE="" +LOOP_BASE="" +LOOP_REVIEW_ONLY=0 # Print usage instructions usage() { @@ -47,6 +50,9 @@ usage() { echo " --max-rebut N Max rebuttal attempts per review iteration (default: 1, 0: disabled)" echo " --verbose Print detailed execution timeline traces" echo " --cleanup Purge temporary job directories upon success" + echo " --worktree Source worktree for diff/base/head (control plane stays here)" + echo " --base Pin the review diff base (must be an ancestor of the source HEAD)" + echo " --review-only Skip implementation; review ..HEAD (requires --base)" exit 1 } @@ -83,6 +89,9 @@ while [[ "$#" -gt 0 ]]; do ;; --planner) PLANNER_SESSION_OVERRIDE="$2"; shift 2 ;; --task) TASK="$2"; shift 2 ;; + --worktree) LOOP_WORKTREE="$2"; shift 2 ;; + --base) LOOP_BASE="$2"; shift 2 ;; + --review-only) LOOP_REVIEW_ONLY=1; shift ;; -h|--help) usage ;; *) echo "Unknown option: $1"; usage ;; esac @@ -104,6 +113,15 @@ if [ -n "${PLANNER_SESSION_OVERRIDE:-}" ] && [ "$PLAN_MODE" = false ]; then exit 1 fi +# Issue #21 (D12): the legacy shell loop assumes a cwd-relative .mam/jobs and +# diffs the checkout it runs in; the worktree/base options are Python-only. +# Checked before the freeze and the loop lock (log_* are not defined yet). +if [ "${MAM_LOOP_PY:-1}" = "0" ] && { [ -n "$LOOP_WORKTREE" ] || [ -n "$LOOP_BASE" ] \ + || [ "$LOOP_REVIEW_ONLY" = "1" ] || [ -n "${MAM_LOOP_WORKTREE:-}" ]; }; then + echo "ERROR: --worktree/--base/--review-only require the Python loop (MAM_LOOP_PY=1)" >&2 + exit 1 +fi + # Anchor Python interpreter before freeze so freeze child processes find the project venv (O-4) if [ -z "${AGENT_PYTHON_BIN:-}" ]; then if [ -x "$REPO_ROOT/.venv/bin/python" ]; then diff --git a/.agents/skills/multi-agent-mux-monitor/SKILL.md b/.agents/skills/multi-agent-mux-monitor/SKILL.md index 316f291..570c860 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.5.0 +version: 4.6.0 author: godopu license: MIT platforms: [linux, macos] diff --git a/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh b/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh index fbf25fc..e6d02ce 100755 --- a/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh +++ b/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh @@ -100,7 +100,7 @@ if [ "$SUBSCRIBE" = "1" ]; then # The MQTT subscribe loop exits 3 to signal "broker unavailable โ†’ poll instead". set +e YAML_PATH="$AGENT_SESSIONS_YAML" HOME_DIR="$HOME_DIR" CLAUDE_PROJECT_DIR="$CLAUDE_PROJECT_DIR" LOCAL_BIN="$LOCAL_BIN" \ - WORKSPACE_ROOT="$WORKSPACE_ROOT" SUB_TIMEOUT="$SUB_TIMEOUT" SUB_IDLE_TIMEOUT="$SUB_IDLE_TIMEOUT" \ + WORKSPACE_ROOT="$WORKSPACE_ROOT" MAM_JOBS_DIR="${MAM_JOBS_DIR:-}" SUB_TIMEOUT="$SUB_TIMEOUT" SUB_IDLE_TIMEOUT="$SUB_IDLE_TIMEOUT" \ SKILLS_DIR="$SKILLS_DIR" LIB_SH="$LIB_SH" \ "$PYBIN" - <<'PYEOF' & import os, sys, json, time, subprocess @@ -236,7 +236,9 @@ def on_message(_client, _userdata, msg): if not jid or not event: return - if workspace_root: + if os.environ.get('MAM_JOBS_DIR'): + registry_dir = os.environ['MAM_JOBS_DIR'] # Issue #21: MAM_JOBS_DIR wins + elif workspace_root: registry_dir = os.path.join(workspace_root, '.mam', 'jobs') else: yaml_dir = os.path.dirname(yaml_path) if yaml_path else "" diff --git a/.agents/skills/multi-agent-mux-orc-onboard/SKILL.md b/.agents/skills/multi-agent-mux-orc-onboard/SKILL.md index 8f2f656..95cb8b0 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.5.0 +version: 4.6.0 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 50aee73..4410cf6 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.5.0 +version: 4.6.0 author: godopu license: MIT platforms: [linux, macos] 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 13c1a97..72fee21 100755 --- a/.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh +++ b/.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh @@ -150,6 +150,29 @@ else fi fi +# Issue #21 (item 3, D11): the control-plane binding recorded on the row wins +# over the caller's environment (a difference is reported on stderr); a legacy +# row without one uses the caller's binding, which update_yaml_resumed.sh then +# records. Forwarded to the pane only when it differs from --workspace's own. +ROW_CP="$(MAM_STATE_JSON="$(load_state_json)" SESSION_NAME="$SESSION_NAME" python3 -c " +import json, os +d = json.loads(os.environ.get('MAM_STATE_JSON') or '{}') +for s in d.get('herdr_sessions', []): + if s.get('name') == os.environ['SESSION_NAME'] and isinstance(s.get('control_plane'), dict): + print(json.dumps(s['control_plane'])) + break +" 2>/dev/null || true)" +CP_JSON="$(mam_control_plane_json "$WORKSPACE" "$ROW_CP" || true)" +CP_PREFIX="" +if [ -n "$CP_JSON" ]; then + cp_rc=0 + CP_PREFIX="$(mam_control_plane_prefix "$CP_JSON")" || cp_rc=$? + if [ "$cp_rc" -eq 3 ]; then + echo "ERROR: control-plane path contains whitespace; cannot forward to the pane" >&2 + exit 2 + fi +fi + if [ "${DRY_RUN:-0}" = "1" ]; then echo "[dry-run] would spawn: $CMD_FULL" exit 0 @@ -162,7 +185,7 @@ if [ "$AGENT" = "opencode" ]; then fi # 4. Spawn new agent session (delegates to herdr translation shim) -_herdr new-session -d -s "$SESSION_NAME" -c "$WORKSPACE" "$CMD_FULL" +_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 @@ -172,7 +195,7 @@ fi sleep 2 # 5. Update agent-sessions.yaml: status running, last_visible_status -bash "$(dirname "${BASH_SOURCE[0]}")/update_yaml_resumed.sh" \ +CONTROL_PLANE_JSON="$CP_JSON" bash "$(dirname "${BASH_SOURCE[0]}")/update_yaml_resumed.sh" \ --session "$SESSION_NAME" --uuid "$UUID" --agent "$AGENT" --workspace "$WORKSPACE" \ --herdr-session "$HERDR_SESSION_NAME" --cmd-full "$CMD_FULL" \ ${HERDR_WORKSPACE_OPT:+--herdr-workspace "$HERDR_WORKSPACE_OPT"} diff --git a/.agents/skills/multi-agent-mux-resume/scripts/update_yaml_resumed.sh b/.agents/skills/multi-agent-mux-resume/scripts/update_yaml_resumed.sh index bc65cec..d30ad3b 100755 --- a/.agents/skills/multi-agent-mux-resume/scripts/update_yaml_resumed.sh +++ b/.agents/skills/multi-agent-mux-resume/scripts/update_yaml_resumed.sh @@ -163,6 +163,16 @@ else: target['herdr_workspace'] = wsl target['status'] = 'running' +# Issue #21 (D11): a row keeps the control-plane binding it was created with; +# a legacy row without one gets the binding this resume used. +_cp_raw = os.environ.get('CONTROL_PLANE_JSON', '') +if _cp_raw and not isinstance(target.get('control_plane'), dict): + try: + import json as _json + _cp = _json.loads(_cp_raw) + target['control_plane'] = {k: _cp[k] for k in ('workspace_root', 'agent_sessions_yaml', 'mam_jobs_dir', 'delegate_job_logs_dir')} + except Exception: + pass if not uuid: # ISSUE-3 (challenge c4b0a075): a Class A fresh-spawn resume re-arms # discovery from scratch - the resumed agent has not spoken yet, so any diff --git a/.agents/skills/multi-agent-mux-status/SKILL.md b/.agents/skills/multi-agent-mux-status/SKILL.md index 0a92c21..73f0b30 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.5.0 +version: 4.6.0 author: godopu license: MIT platforms: [linux, macos] diff --git a/.agents/skills/multi-agent-mux-status/scripts/status.sh b/.agents/skills/multi-agent-mux-status/scripts/status.sh index 90ba9e6..23d2711 100755 --- a/.agents/skills/multi-agent-mux-status/scripts/status.sh +++ b/.agents/skills/multi-agent-mux-status/scripts/status.sh @@ -160,7 +160,14 @@ def get_job_status(s): return ('-', '-') project_root = os.environ.get('PROJECT_ROOT', '.') - candidates = [ + # Issue #21: MAM_JOBS_DIR and the registry the row was created against + # (control_plane) first, then the matching audit logs, then the old ones. + cp = s.get('control_plane') or {} + candidates = [os.path.join(r, f"{jid}.json") + for r in (os.environ.get('MAM_JOBS_DIR'), cp.get('mam_jobs_dir')) if r] + candidates += [os.path.join(r, jid, 'status.json') + for r in (os.environ.get('DELEGATE_JOB_LOGS_DIR'), cp.get('delegate_job_logs_dir')) if r] + candidates += [ os.path.join('.mam', 'jobs', f"{jid}.json"), os.path.join(project_root, '.mam', 'jobs', f"{jid}.json"), os.path.join(project_root, '.mam', 'delegate_job_logs', jid, 'status.json'), @@ -361,7 +368,14 @@ def get_job_status(s): project_root = os.environ.get('PROJECT_ROOT', '.') # Candidate locations (review item 6: project-root-relative, no hardcoded abs paths): # 1) cwd-relative registry 2) project-root registry 3) project-root audit log - candidates = [ + # Issue #21: MAM_JOBS_DIR and the registry the row was created against + # (control_plane) first, then the matching audit logs, then the old ones. + cp = s.get('control_plane') or {} + candidates = [os.path.join(r, f"{jid}.json") + for r in (os.environ.get('MAM_JOBS_DIR'), cp.get('mam_jobs_dir')) if r] + candidates += [os.path.join(r, jid, 'status.json') + for r in (os.environ.get('DELEGATE_JOB_LOGS_DIR'), cp.get('delegate_job_logs_dir')) if r] + candidates += [ os.path.join('.mam', 'jobs', f"{jid}.json"), os.path.join(project_root, '.mam', 'jobs', f"{jid}.json"), os.path.join(project_root, '.mam', 'delegate_job_logs', jid, 'status.json'), diff --git a/.agents/skills/multi-agent-mux-stop/SKILL.md b/.agents/skills/multi-agent-mux-stop/SKILL.md index 8da1a8e..43db5b3 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.5.0 +version: 4.6.0 author: godopu license: MIT platforms: [linux, macos] diff --git a/.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh b/.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh index b006783..b237034 100755 --- a/.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh +++ b/.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh @@ -132,7 +132,9 @@ for s in d.get('herdr_sessions', []): if s.get('name') == name: cwd = (s.get('pane') or {}).get('cwd', '') jid = s.get('delegate_job_id', '') or '' - print(json.dumps({'cwd': cwd, 'job_id': jid})) + cp = s.get('control_plane') or {} + print(json.dumps({'cwd': cwd, 'job_id': jid, 'mam_jobs_dir': cp.get('mam_jobs_dir', '') or '', + 'delegate_job_logs_dir': cp.get('delegate_job_logs_dir', '') or ''})) sys.exit(0) sys.exit(7) ") || { @@ -142,6 +144,19 @@ sys.exit(7) TARGET_CWD=$(printf '%s' "$MAPPED_DATA" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("cwd",""))') DELEGATE_JOB_ID=$(printf '%s' "$MAPPED_DATA" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("job_id",""))') +# Issue #21: a row created against another control plane names its registry. +# Order: MAM_JOBS_DIR > the row's control_plane.mam_jobs_dir > WORKSPACE_ROOT > +# .mam/jobs; exported so delegate_publish_event (lib.sh) uses the same one. +ROW_JOBS_DIR=$(printf '%s' "$MAPPED_DATA" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("mam_jobs_dir",""))') +if [ -z "${MAM_JOBS_DIR:-}" ] && [ -n "$ROW_JOBS_DIR" ]; then + export MAM_JOBS_DIR="$ROW_JOBS_DIR" +fi +# Same for the audit log (R3): the cancellation history belongs next to the +# registry the row names, not under $WORKSPACE_ROOT/.mam/delegate_job_logs. +ROW_LOGS_DIR=$(printf '%s' "$MAPPED_DATA" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("delegate_job_logs_dir",""))') +if [ -z "${DELEGATE_JOB_LOGS_DIR:-}" ] && [ -n "$ROW_LOGS_DIR" ]; then + export DELEGATE_JOB_LOGS_DIR="$ROW_LOGS_DIR" +fi DELEGATE_JOB_TOKEN="" DELEGATE_JOB_ATTEMPT="" if [ -n "$DELEGATE_JOB_ID" ]; then diff --git a/.venv b/.venv new file mode 120000 index 0000000..b85ffa8 --- /dev/null +++ b/.venv @@ -0,0 +1 @@ +/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.venv \ No newline at end of file diff --git a/BOOTSTRAP.ko.md b/BOOTSTRAP.ko.md index b53029f..4f568b3 100644 --- a/BOOTSTRAP.ko.md +++ b/BOOTSTRAP.ko.md @@ -2,7 +2,7 @@ ๋ณธ ๋ฌธ์„œ๋Š” `herdr_agent_orchestration` ์˜ค์ผ€์ŠคํŠธ๋ ˆ์ด์…˜ ๋ฐ ๋ฉ”์‹œ์ง• ๋ฐฑํ”Œ๋ ˆ์ธ ์›Œํฌํ”Œ๋กœ์šฐ๋ฅผ ์ƒˆ๋กœ์šด ํ”„๋กœ์ ํŠธ์— ๋„์ž…ํ•˜์—ฌ ์ด์‹ํ•˜๊ณ , ์ƒˆ๋กœ์šด ๊ฐœ๋ฐœ์ž/์—์ด์ „ํŠธ๊ฐ€ ์ดˆ๊ธฐ ๊ฐ€๋™์„ ์‹œ์ž‘ํ•  ๋•Œ ์ˆ˜ํ–‰ํ•ด์•ผ ํ•˜๋Š” ํ™˜๊ฒฝ ์„ค์ • ๋ฐ ๊ตฌ์ถ• ์ ˆ์ฐจ๋ฅผ ์•ˆ๋‚ดํ•ฉ๋‹ˆ๋‹ค. -[![Version: v4.5.0](https://img.shields.io/badge/version-v4.5.0-blue.svg)](VERSIONS.md) +[![Version: v4.6.0](https://img.shields.io/badge/version-v4.6.0-blue.svg)](VERSIONS.md) ์ƒˆ๋กœ์šด ์—์ด์ „ํŠธ๋Š” ์ด ์•ˆ๋‚ด์„œ์— ๊ธฐ์ˆ ๋œ ์ ˆ์ฐจ๋ฅผ ์ˆœ์ฐจ์ ์œผ๋กœ ์‹คํ–‰ํ•˜์—ฌ ์ดˆ๊ธฐ ํ™˜๊ฒฝ์„ ์•ˆ์ •์ ์œผ๋กœ ์„ค์ •ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. diff --git a/BOOTSTRAP.md b/BOOTSTRAP.md index e161945..fa304e2 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.5.0](https://img.shields.io/badge/version-v4.5.0-blue.svg)](VERSIONS.md) +[![Version: v4.6.0](https://img.shields.io/badge/version-v4.6.0-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 a2c53b0..9efb484 100644 --- a/MESSAGING.md +++ b/MESSAGING.md @@ -1,6 +1,6 @@ # Messaging System Technical Analysis & Architecture Report -Applies to: MAM v4.5.0 +Applies to: MAM v4.6.0 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 8cbf299..1faac87 100644 --- a/README.ko.md +++ b/README.ko.md @@ -2,7 +2,7 @@ Herdr์™€ MQTT ๋ธŒ๋กœ์ปค๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ ๊ตฌ์ถ•๋œ ๊ณ ์‹ ๋ขฐ์„ฑ **๋‹ค์ค‘ ์—์ด์ „ํŠธ ์˜ค์ผ€์ŠคํŠธ๋ ˆ์ด์…˜ ๋ฐ ๋ฉ”์‹œ์ง• ๋ฐฑํ”Œ๋ ˆ์ธ** ํ”„๋ ˆ์ž„์›Œํฌ์ž…๋‹ˆ๋‹ค. `claude`, `agy`, `hermes`, `grok`, `opencode`, `codex` ๋“ฑ ๋‹ค์ค‘ LLM ๋ฐฑ์—”๋“œ ์—์ด์ „ํŠธ ์ „๋ฐ˜์— ๊ฑธ์ณ ์žฅ์‹œ๊ฐ„ ์ˆ˜ํ–‰๋˜๋Š” ์ž‘์—…(์ฝ”๋“œ ์ƒ์„ฑ, ๋ฆฌํŒฉํ† ๋ง, ๋ณด์•ˆ ๊ฒ€ํ†  ๋“ฑ)์„ ์กฐ์ •, ๊ฒฉ๋ฆฌ ๋ฐ ๊ฐ์‚ฌํ•  ์ˆ˜ ์žˆ๋„๋ก ์„ค๊ณ„๋˜์—ˆ์Šต๋‹ˆ๋‹ค. -[![Version: v4.5.0](https://img.shields.io/badge/version-v4.5.0-blue.svg)](VERSIONS.md) +[![Version: v4.6.0](https://img.shields.io/badge/version-v4.6.0-blue.svg)](VERSIONS.md) --- diff --git a/README.md b/README.md index a19c586..931688d 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.5.0](https://img.shields.io/badge/version-v4.5.0-blue.svg)](VERSIONS.md) +[![Version: v4.6.0](https://img.shields.io/badge/version-v4.6.0-blue.svg)](VERSIONS.md) --- diff --git a/VERSIONS.md b/VERSIONS.md index 42e8842..d3b6ed3 100644 --- a/VERSIONS.md +++ b/VERSIONS.md @@ -6,7 +6,7 @@ ## ๐Ÿ“Œ ํ˜„์žฌ ๋ฒ„์ „ ๊ฐœ์š” (Current Release) -- **ํ”„๋ ˆ์ž„์›Œํฌ ๋ฒ„์ „**: `v4.5.0` +- **ํ”„๋ ˆ์ž„์›Œํฌ ๋ฒ„์ „**: `v4.6.0` - **์ตœ์‹  ๋ฆด๋ฆฌ์Šค ์ผ์‹œ**: 2026-09-13 (KST) - **๊ธฐ์ค€ ๋ธŒ๋žœ์น˜**: `main` - **ํ•ต์‹ฌ ์•„ํ‚คํ…์ฒ˜**: @@ -21,23 +21,38 @@ ## ๐Ÿงญ ์Šคํ‚ฌ ํŒจํ‚ค์ง€ ๋ฒ„์ „ ๋งคํŠธ๋ฆญ์Šค (Skills Version Matrix) -๋ชจ๋“  8๊ฐœ ์Šคํ‚ฌ์€ YAML frontmatter ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ(`author`, `version`, `platforms`, `environments`) ํ‘œ์ค€ํ™”๋ฅผ ํ†ตํ•ด `v4.5.0`๋กœ ๋™๊ธฐํ™”๋˜์–ด ๋ฐฐํฌ๋ฉ๋‹ˆ๋‹ค. +๋ชจ๋“  8๊ฐœ ์Šคํ‚ฌ์€ YAML frontmatter ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ(`author`, `version`, `platforms`, `environments`) ํ‘œ์ค€ํ™”๋ฅผ ํ†ตํ•ด `v4.6.0`๋กœ ๋™๊ธฐํ™”๋˜์–ด ๋ฐฐํฌ๋ฉ๋‹ˆ๋‹ค. | ์Šคํ‚ฌ๋ช… | ๋ฒ„์ „ | ์—ญํ•  ๋ฐ ์ฃผ์š” ์ฑ…์ž„ | ์ƒํƒœ | | :--- | :---: | :--- | :---: | -| **`multi-agent-mux-create`** | `4.5.0` | ์—์ด์ „ํŠธ ์„ธ์…˜ ์‹ ๊ทœ ์ƒ์„ฑ ๋ฐ Herdr ์ปจํ…Œ์ด๋„ˆ ๊ฒฉ๋ฆฌ ์Šคํฐ | โœ… ๋ฐฐํฌ | -| **`multi-agent-mux-stop`** | `4.5.0` | ๋Œ€ํ™” UUID ์›์ž์  ์บก์ฒ˜ ๋ฐ ์„ธ์…˜ ์•ˆ์ „ ์ข…๋ฃŒ (Graceful Stop) | โœ… ๋ฐฐํฌ | -| **`multi-agent-mux-resume`** | `4.5.0` | ์˜จ๋””์Šคํฌ ๋Œ€ํ™” ์ปจํ…์ŠคํŠธ ๊ธฐ๋ฐ˜ Tier-1 ์ดˆ๊ณ ์† ์„ธ์…˜ ๋ณต์› | โœ… ๋ฐฐํฌ | -| **`multi-agent-mux-status`** | `4.5.0` | ์‹ค์‹œ๊ฐ„ Herdr ์„ธ์…˜ ๋ฐ ๋ ˆ์ง€์ŠคํŠธ๋ฆฌ ๋“œ๋ฆฌํ”„ํŠธ ์Šค๋ƒ…์ƒท ์กฐํšŒ | โœ… ๋ฐฐํฌ | -| **`multi-agent-mux-monitor`** | `4.5.0` | YAML โ†” ๋Ÿฐํƒ€์ž„ ์ƒํƒœ ๊ฐ„ ์ž์œจ ์กฐ์ •์ž (Reconciler Loop) | โœ… ๋ฐฐํฌ | -| **`multi-agent-mux-delegate-job`** | `4.5.0` | MQTT ์ด๋ฒคํŠธ ์ฑ„๋„ ๊ธฐ๋ฐ˜ ๋น„๋™๊ธฐ ๋‹จ์œ„ ์ž‘์—… ์œ„์ž„ | โœ… ๋ฐฐํฌ | -| **`multi-agent-mux-loop`** | `4.5.0` | Planner-Creator-Reviewer 3์ž ์ž์œจ ๊ณ„ํšยท์‹คํ–‰ยทํ”ผ์–ด๋ฆฌ๋ทฐ ๋ฃจํ”„ | โœ… ๋ฐฐํฌ | -| **`multi-agent-mux-orc-onboard`** | `4.5.0` | ์˜ค์ผ€์ŠคํŠธ๋ ˆ์ดํ„ฐ UUID ๊ฒฉ๋ฆฌ ๋“ฑ๋ก ๋ฐ ์„œ๋ธŒ ์„ธ์…˜ ์˜ค์—ผ ๋ฐฉ์ง€ | โœ… ๋ฐฐํฌ | +| **`multi-agent-mux-create`** | `4.6.0` | ์—์ด์ „ํŠธ ์„ธ์…˜ ์‹ ๊ทœ ์ƒ์„ฑ ๋ฐ Herdr ์ปจํ…Œ์ด๋„ˆ ๊ฒฉ๋ฆฌ ์Šคํฐ | โœ… ๋ฐฐํฌ | +| **`multi-agent-mux-stop`** | `4.6.0` | ๋Œ€ํ™” UUID ์›์ž์  ์บก์ฒ˜ ๋ฐ ์„ธ์…˜ ์•ˆ์ „ ์ข…๋ฃŒ (Graceful Stop) | โœ… ๋ฐฐํฌ | +| **`multi-agent-mux-resume`** | `4.6.0` | ์˜จ๋””์Šคํฌ ๋Œ€ํ™” ์ปจํ…์ŠคํŠธ ๊ธฐ๋ฐ˜ Tier-1 ์ดˆ๊ณ ์† ์„ธ์…˜ ๋ณต์› | โœ… ๋ฐฐํฌ | +| **`multi-agent-mux-status`** | `4.6.0` | ์‹ค์‹œ๊ฐ„ Herdr ์„ธ์…˜ ๋ฐ ๋ ˆ์ง€์ŠคํŠธ๋ฆฌ ๋“œ๋ฆฌํ”„ํŠธ ์Šค๋ƒ…์ƒท ์กฐํšŒ | โœ… ๋ฐฐํฌ | +| **`multi-agent-mux-monitor`** | `4.6.0` | YAML โ†” ๋Ÿฐํƒ€์ž„ ์ƒํƒœ ๊ฐ„ ์ž์œจ ์กฐ์ •์ž (Reconciler Loop) | โœ… ๋ฐฐํฌ | +| **`multi-agent-mux-delegate-job`** | `4.6.0` | MQTT ์ด๋ฒคํŠธ ์ฑ„๋„ ๊ธฐ๋ฐ˜ ๋น„๋™๊ธฐ ๋‹จ์œ„ ์ž‘์—… ์œ„์ž„ | โœ… ๋ฐฐํฌ | +| **`multi-agent-mux-loop`** | `4.6.0` | Planner-Creator-Reviewer 3์ž ์ž์œจ ๊ณ„ํšยท์‹คํ–‰ยทํ”ผ์–ด๋ฆฌ๋ทฐ ๋ฃจํ”„ | โœ… ๋ฐฐํฌ | +| **`multi-agent-mux-orc-onboard`** | `4.6.0` | ์˜ค์ผ€์ŠคํŠธ๋ ˆ์ดํ„ฐ UUID ๊ฒฉ๋ฆฌ ๋“ฑ๋ก ๋ฐ ์„œ๋ธŒ ์„ธ์…˜ ์˜ค์—ผ ๋ฐฉ์ง€ | โœ… ๋ฐฐํฌ | --- ## ๐Ÿ“‹ ๋ฒ„์ „๋ณ„ ์ƒ์„ธ ๋ณ€๊ฒฝ ๋‚ด์—ญ (Changelog) +### ๐Ÿš€ `v4.6.0` โ€” Issue #21 ์›ŒํฌํŠธ๋ฆฌ ์šด์˜ ๋ชจ๋ธ 1๋‹จ๊ณ„ ์„ ํ–‰ ์ž‘์—…: ๋ž˜ํผ ์ ˆ๋Œ€๊ฒฝ๋กœ, ๋ฃจํ”„ `--worktree`, ์ œ์–ด ํ‰๋ฉด ๋ฐ”์ธ๋”ฉ, ์„ค๊ณ„ ๋ฌธ์„œ (2026-09-14, PR #22) + +> **๋งˆ์ด๋„ˆ ๋งˆ์ผ์Šคํ†ค (MINOR Release)**: "์›Œํฌ์ŠคํŽ˜์ด์Šค = ์ฒดํฌ์•„์›ƒ ๊ฒฝ๋กœ" ๊ฐ€์ •์„ ๋А์Šจํ•˜๊ฒŒ ํ•˜๋Š” 1๋‹จ๊ณ„๋‹ค. creator ๋Š” ์ด์Šˆ๋ณ„ git ์›ŒํฌํŠธ๋ฆฌ์—์„œ, ๋ฆฌ๋ทฐ์–ด ํ’€์€ ๋ณธ ์ฒดํฌ์•„์›ƒ์—์„œ, ์žก ๋ธŒ๋ฆฌํ”„ยท๋ ˆ์ง€์ŠคํŠธ๋ฆฌยท๋ฆฌํฌํŠธยท์ฒดํฌํฌ์ธํŠธ(์ œ์–ด ํ‰๋ฉด)๋Š” ๋ณธ ์ฒดํฌ์•„์›ƒ ํ•˜๋‚˜์— ๋‘๋Š” ์šด์˜ ๋ชจ๋ธ(#21, `docs/design/worktree-creator-model.md`)์˜ ์„ ํ–‰ ์กฐ๊ฑด 4๊ฑด(์ž‘์—… ํ•ญ๋ชฉ 1ยท2ยท3ยท6)์„ ๊ตฌํ˜„ํ–ˆ๋‹ค. ๋ฃจํ”„ ๋Ÿฌ๋„ˆ ์˜ต์…˜ 3๊ฐœ์™€ `lib_py/control_plane.py` ๊ฐ€ ์ถ”๊ฐ€๋˜์–ด MINOR ๋กœ ์˜ฌ๋ฆฐ๋‹ค. ์˜ต์…˜ ์—†์ด ์“ฐ๋ฉด ์ข…์ „ ๋™์ž‘๊ณผ ํ”„๋กฌํ”„ํŠธ๊ฐ€ ๋ฐ”์ดํŠธ ๋‹จ์œ„๋กœ ๊ฐ™๋‹ค. ํ•ญ๋ชฉ 4(์›ŒํฌํŠธ๋ฆฌ ์ค€๋น„/์ข…๋ฃŒ ํ—ฌํผ)ยท5(๋ฆฌ๋ทฐ identity)ยท7(์‹œํ—˜ ์šด์˜)ยท8(๋‹ค์ค‘ ๋ฃจํ”„)์€ #21 ์— ๋‚จ์•„ ์žˆ๋‹ค. + +#### ๐ŸŒŸ ์ฃผ์š” ๊ฐœ์„  ์‚ฌํ•ญ +1. **๋ž˜ํผ ์ ˆ๋Œ€๊ฒฝ๋กœํ™” (`multi-agent-mux-delegate-job`)**: ์ฃผ์ž… ๋ฌธ๊ตฌ์˜ brief ๊ฒฝ๋กœ, ์‹œ์ž‘/์„ฑ๊ณต/์˜ค๋ฅ˜ ๋ช…๋ น์˜ `--registry-dir`, ๊ฐ์‚ฌ ๋กœ๊ทธ ๋ฃจํŠธ(`DELEGATE_JOB_LOGS_DIR`), ๋ธŒ๋ฆฌํ”„์˜ Output Report Path ๊ฐ€ `$REGISTRY_DIR` ๊ธฐ์ค€ ์ ˆ๋Œ€๊ฒฝ๋กœ๊ฐ€ ๋๋‹ค(`_abs_registry_dir`ยท`_pub_cmd`ยท`_instructions`, `printf %q` ์ธ์šฉ). ์›ŒํฌํŠธ๋ฆฌ cwd ์˜ creator ๊ฐ€ `started` ๋ฅผ ๋ฐœํ–‰ํ•˜๊ณ  ๋ฆฌํฌํŠธ๋ฅผ ๋ณธ ์ฒดํฌ์•„์›ƒ์˜ ์žก ๋””๋ ‰ํ„ฐ๋ฆฌ์— ์“ด๋‹ค. +2. **๋ฃจํ”„ ๋Ÿฌ๋„ˆ `--worktree ` / `--base ` / `--review-only` (`lib_py/loop/runner.py`, `run_loop.sh`)**: diff ์ˆ˜์ง‘ยท`base_commit`ยทhead ๋Š” ์›ŒํฌํŠธ๋ฆฌ ๊ธฐ์ค€, wrapperยท๋ ˆ์ง€์ŠคํŠธ๋ฆฌ(`MAM_JOBS_DIR` ์กด์ค‘)ยท์ฒดํฌํฌ์ธํŠธยท๋ฆฌํฌํŠธ ์Šน๊ฒฉ์€ ๋ณธ ์ฒดํฌ์•„์›ƒ ๊ธฐ์ค€. ์›ŒํฌํŠธ๋ฆฌ๋Š” ๊ฐ™์€ ์ €์žฅ์†Œ์˜ top-level ์ด์–ด์•ผ ํ•˜๊ณ  `--base` ๋Š” head ์˜ ์กฐ์ƒ์ด์–ด์•ผ ํ•œ๋‹ค. ์œ„์ž„ ํ”„๋กฌํ”„ํŠธ ์•ž์— `[์›ŒํฌํŠธ๋ฆฌ ์ปจํ…์ŠคํŠธ]`(์†Œ์Šค ๊ฒฝ๋กœยท๋ธŒ๋žœ์น˜ยทbaseยทheadยท์ปค๋ฐ‹ ์ˆ˜ยท๋ฏธ์ปค๋ฐ‹ ์—ฌ๋ถ€ยท์ œ์–ด ํ‰๋ฉด) ๋ธ”๋ก์„ ๋ถ™์ธ๋‹ค. ๋ณ€๊ฒฝ ์ง‘ํ•ฉ์ด ๋น„๋ฉด fail-closed. `MAM_LOOP_WORKTREE` env ๋™์น˜. +3. **์ œ์–ด ํ‰๋ฉด ๋ฐ”์ธ๋”ฉ (`lib_py/control_plane.py`, create/resume/stop/status/reconcile)**: `WORKSPACE_ROOT`/`AGENT_SESSIONS_YAML`/`MAM_JOBS_DIR`/`DELEGATE_JOB_LOGS_DIR` 4๊ฐœ ๊ฒฝ๋กœ๋ฅผ ์„ธ์…˜ ํ–‰์˜ `control_plane` ํ•„๋“œ๋กœ ์˜์†ํ™”ํ•˜๊ณ , `--workspace` ๋งŒ์œผ๋กœ ์œ ๋„๋˜๋Š” ๊ธฐ๋ณธ๊ฐ’๊ณผ ๋‹ค๋ฅผ ๋•Œ๋งŒ pane ์ƒ์„ฑ ํ˜ธ์ถœ์˜ `--env` ๋กœ ์ „ํŒŒํ•œ๋‹ค(์žฌ์‚ฌ์šฉ idle pane ์—๋Š” env ๋ฅผ ์—์ด์ „ํŠธ ์ธ์ž๋กœ ๋„˜๊ธฐ์ง€ ์•Š์œผ๋ฏ€๋กœ env ๊ฐ€ ํ•„์š”ํ•˜๋ฉด pane ์„ ์ƒˆ๋กœ ๋งŒ๋“ ๋‹ค โ€” h25 ๊ณ„์•ฝ ๊ฐฑ์‹ ). ์ƒ๋Œ€ `MAM_JOBS_DIR` ์€ jobs dir ํ•˜๋‚˜๋กœ ์ •๊ทœํ™”, stop ์€ ๊ฐ์‚ฌ ๋กœ๊ทธ ๋ฃจํŠธ๋ฅผ ์œ ์ง€, status/reconcile ์€ `MAM_JOBS_DIR` ์„ ์กด์ค‘ํ•œ๋‹ค. ๊ฒฝ๋กœ์— ๊ณต๋ฐฑ์ด ์žˆ์œผ๋ฉด ์ „ํŒŒ๋ฅผ ๊ฑฐ๋ถ€(rc 3)ํ•œ๋‹ค. +4. **์„ค๊ณ„ ๋ฌธ์„œ ์ •์ • (`docs/design/worktree-creator-model.md`)**: 4์ธ ๋ฆฌ๋ทฐ(#21 ๋Œ“๊ธ€, `docs/design/reviews/`)๋ฅผ ๋ฐ˜์˜ํ•ด "ํ˜„์žฌ ์ฝ”๋“œ ๊ธฐ์ค€" ์„ ๋ถ€๋ถ„ ์ง€์› + ํ•„์š”ํ•œ ์ˆ˜์ •์œผ๋กœ, 1๋‹จ๊ณ„ ๋ฆฌ๋ทฐ ์ „์šฉ ํ๋ฆ„, ์Šคํ‚ฌ๋ณ„ ํ˜ธํ™˜์„ฑ ํ‘œ, ๋ฃจํŠธ 3์ข…(์ œ์–ด/์†Œ์Šค/๋Ÿฐํƒ€์ž„)๊ณผ ๋ฆฌ๋ทฐ identity ๋ฅผ ๋ช…์‹œํ–ˆ๋‹ค. + +#### โœ… ํ’ˆ์งˆ ๊ฒ€์ฆ +- **์‹ ๊ทœ ํ…Œ์ŠคํŠธ**: `tests/test_issue21_worktree_prereqs.py`(๋ž˜ํผ ์ ˆ๋Œ€๊ฒฝ๋กœยท๋Ÿฌ๋„ˆ ์˜ต์…˜ยท์›ŒํฌํŠธ๋ฆฌ ๊ฒ€์ฆยท์ œ์–ด ํ‰๋ฉด ๋ฐ”์ธ๋”ฉยท์ „ํŒŒยทํ˜ธํ™˜์„ฑ), `tests/test_herdr_shim_contract.py` h25 ๊ฐฑ์‹ , T2-8 ์ƒŒ๋“œ๋ฐ•์Šคํ™”. +- **๋ฆฌ๋ทฐ**: ์ž‘์„ฑ ์›Œํฌ์ŠคํŽ˜์ด์Šค ๋ฃจํ”„์—์„œ claudeยทagyยทgrok PASS @13259ed, codex PASS @4a81d15; ๋ณ‘ํ•ฉ ์›Œํฌ์ŠคํŽ˜์ด์Šค์—์„œ PR head ๋ฅผ detached ์›ŒํฌํŠธ๋ฆฌ์— ๋ฐ›์•„ claudeยทagyยทcodex ์žฌ๊ฒ€ํ†  PASS @4a81d15(๋ฆฌํฌํŠธ `.agents/reports/`). ๋ณ‘ํ•ฉ ์›Œํฌ์ŠคํŽ˜์ด์Šค ์ „์ฒด ์Šค์œ„ํŠธ @4a81d15: 940 ํ†ต๊ณผ, ์‹คํŒจ 1๊ฑด์€ ๋ถ€ํ•˜ ์‹œ ๊ฒฝ๊ณ„๊ฐ’์— ๊ฑธ๋ฆฌ๋Š” ์‹œ๊ฐ„ ํŒ์ •(๋‹จ๋… ์žฌ์‹คํ–‰ ํ†ต๊ณผ). +- **ํ›„์†(๋น„์ฐจ๋‹จ, #21ยท#20)**: create/resume ์˜ `mam_control_plane_json โ€ฆ || true` ๊ฐ€ ์‹คํŒจ๋ฅผ ์‚ผํ‚ด โ†’ ๊ฒฝ๊ณ /exit 2; resume ์˜ "herdr ์ด๋ฏธ ์‚ด์•„ ์žˆ์Œ" ๊ฒฝ๋กœ๊ฐ€ `CONTROL_PLANE_JSON` ๋ฏธ์ „๋‹ฌ; T2-6 ์˜ `os.environ` ์ง์ ‘ ๋ณ€๊ฒฝ; issue14 `_job_for_inprocess_publish` ๊ฐ€ ambient `WORKSPACE_ROOT` ๋กœ ์‹ค์ œ ๊ฐ์‚ฌ ๋กœ๊ทธ์— ๊ธฐ๋ก; acceptance5 ๊ฐ€ ๊ณต์šฉ ๋ธŒ๋กœ์ปค์—์„œ ๋™์‹œ ์‹คํ–‰ ์‹œ ๊ฐ™์€ ์„ธ์…˜ ์ด๋ฆ„์˜ ๋Ÿฌ๋„ˆ๊ฐ€ ์„œ๋กœ์˜ dispatch ๋ฅผ ์†Œ๋น„(์„ธ์…˜ ์ด๋ฆ„ uuid4 ํ™” ํ•„์š”); create ๋ ˆ์‹œํ”ผ์— `MAM_NO_SUBSCRIBER_DAEMON=1`. + ### ๐Ÿš€ `v4.5.0` โ€” Issue #18 ์ƒ์กดยท์ „๋‹ฌ ํŒ์ •์„ ํ”„๋กœํ† ์ฝœ ๊ณ„์•ฝ์œผ๋กœ ์™„๊ฒฐ: heartbeat ์ž๋™ํ™”, Kร—N ๋ฌด์‘๋‹ต, T ๋‚ด `started`, retained ์ƒํƒœ, ์›๊ฒฉ ์ „์†ก (2026-09-13) > **๋งˆ์ด๋„ˆ ๋งˆ์ผ์Šคํ†ค (MINOR Release)**: ์—์ด์ „ํŠธ์˜ ์ƒ์กดยท์ „๋‹ฌ ํŒ์ •์„ TUI ์Šคํฌ๋ž˜ํ•‘(herdr `agent_status`, pane ๋ฌธ์ž์—ด, ํ™”๋ฉด ๋ณ€ํ™”)์—์„œ ๋–ผ์–ด๋‚ด์–ด, ์—์ด์ „ํŠธ๊ฐ€ ์Šค์Šค๋กœ ๋ฐœํ–‰ํ•˜๋Š” ์ด๋ฒคํŠธ(`started`/`progress`/`completed`/`error`)์— **์˜๋ฌดยท์ฃผ๊ธฐยทํƒ€์ž„์•„์›ƒ ๊ทœ์น™์ด ์žˆ๋Š” ๊ณ„์•ฝ**์„ ๋ถ€์—ฌํ–ˆ๋‹ค(Issue #18, #17 ํ•ญ๋ชฉ 1). ์ƒˆ ์ด๋ฒคํŠธ ์ข…๋ฅ˜๋Š” ์—†๋‹ค. ์›๊ฒฉ ํ˜ธ์ŠคํŠธ์˜ ์—์ด์ „ํŠธ(paneยท๋””์Šคํฌ ๊ณต์œ  ์—†์Œ)๋ฅผ ๊ฐ™์€ ๊ณ„์•ฝ์œผ๋กœ ๋‹ค๋ฃจ๋Š” `remote:` ์ „์†ก์„ ์ถ”๊ฐ€ํ–ˆ๋‹ค. ๋ž˜ํผ ์˜ต์…˜ 3๊ฐœ(`--heartbeat`, `--heartbeat-misses`, `--ack-timeout`)์™€ `publish_event --heartbeat`, `job_subscriber --remote`, `remote.py` CLI ๊ฐ€ ์ถ”๊ฐ€๋˜์–ด MINOR ๋กœ ์˜ฌ๋ฆฐ๋‹ค. ๊ธฐ๋ณธ๊ฐ’(N=30, K=4 โ†’ idle 120 s, T=180)์€ ์ข…์ „ idle ๊ธฐ๋ณธ๊ฐ’๊ณผ ๊ฐ™์œผ๋ฏ€๋กœ ๊ธฐ์กด ํ˜ธ์ถœ์€ ๋™์ž‘์ด ์œ ์ง€๋œ๋‹ค. diff --git a/docs/design/reviews/review-agy-fd384d6b.md b/docs/design/reviews/review-agy-fd384d6b.md new file mode 100644 index 0000000..a0e5490 --- /dev/null +++ b/docs/design/reviews/review-agy-fd384d6b.md @@ -0,0 +1,138 @@ +# Design Review Report: Worktree Creator Operating Model + +**Reviewer**: Antigravity (`herdr:reviewer-agy-01`) +**Job ID**: `fd384d6b` +**Target Document**: `docs/design/worktree-creator-model.md` +**Branch / Commit**: `design/worktree-model` @ [`fe96db1`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux-wt/design-worktree-model/docs/design/worktree-creator-model.md) +**Date**: 2026-09-14 +**Status**: Review Complete + +--- + +## 1. Executive Summary + +The proposal in [`docs/design/worktree-creator-model.md`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux-wt/design-worktree-model/docs/design/worktree-creator-model.md) addresses two critical limitations resulting from MAM's historical assumption that `"workspace" == "single checkout path"`: +1. **Inability to run concurrent issue workflows**: Multiple feature branches cannot be checked out simultaneously in a single working tree. +2. **Accidental branch switching collisions**: In Issue #18, a checkout switch caused 12 consecutive commits to accumulate directly on `main` without isolation. + +The proposal evaluates three candidate models: +- **Candidate A (Full Worktree Isolation)**: Spawns 5 full agent instances per issue/worktree. +- **Candidate B (Orchestrator-Only Worktree)**: Isolates orchestrator commits while keeping all agents in the root checkout. +- **Candidate C (Proposed - Worktree Creator with Shared Reviewer Pool)**: Isolates only mutating agents (Creators) in dedicated git worktrees while sharing a persistent, read-only Reviewer pool and centralized `.mam` control plane in the root checkout. + +### Recommendation +**Candidate C is fundamentally sound, resource-efficient, and practical.** It minimizes token quota consumption while providing strict git branch isolation for code modifications. With the targeted code supplements detailed below (addressing brief path relativity, loop runner worktree diff collection, reviewer test isolation, and worktree lifecycle cleanup), Candidate C is fully adoptable. + +--- + +## 2. Responses to Document ยง6 Questions + +### Q1: Does Candidate C's role separation solve the two problems? What failure paths are missed? +- **Verdict**: **Agree with Supplement (๋ณด์™„)**. +- **Analysis**: + - **Branch Switching Collision**: Resolved completely. The Creator is the *only* agent that edits source code and creates git commits. Binding the Creator's `cwd` to `$WT` (e.g. `fix/issue-20`) guarantees that commits can only land on the issue branch. Even if another process alters HEAD in `$MAIN`, the worktree's HEAD remains untouched. + - **Parallelism**: Resolved for implementation. Multiple Creators can work simultaneously on separate worktrees without filesystem contention. +- **Missed Failure Paths & Countermeasures**: + 1. **Reviewer Test Pollution in Worktree**: ยง3 states that reviewers run tests in the worktree path (`$WT`). However, standard test runners (e.g. `pytest`) create untracked artifacts (`.pytest_cache`, `__pycache__`, `.pyc`, and temporary scratch files) directly in `$WT`. This contaminates the Creator's working tree and risks accidental inclusion in git commits. + *Countermeasure*: Reviewers must run verification with cache suppression (e.g., `PYTHONDONTWRITEBYTECODE=1 pytest -o cache_dir=/tmp/...`), or tests must be executed with `--basetemp` outside `$WT`. + 2. **Stale Session Rows After Worktree Deletion**: When an issue branch is merged and `$WT` is unlinked (`git worktree remove $WT`), the Creator session row in `agent-sessions.yaml` still records `pane.cwd: $WT`. If the session is resumed or reconciled later, missing paths can trigger errors. + *Countermeasure*: Establish a documented retirement procedure (`scripts/worktree_remove.sh`) enforcing that `multi-agent-mux-stop --session --purge` runs before `git worktree remove`. + +--- + +### Q2: Is control plane sharing via environment override safe in current code? +- **Verdict**: **Agree with Supplement (๋ณด์™„)**. +- **Analysis across Skill Tooling**: + - [`lib.sh:26`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills/lib.sh#L26): Correctly prioritizes `${WORKSPACE_ROOT:-$SKILL_DIR/../..}`. Overriding `WORKSPACE_ROOT="$MAIN"`, `AGENT_SESSIONS_YAML="$MAIN/.mam/agent-sessions.yaml"`, `MAM_JOBS_DIR="$MAIN/.mam/jobs"`, and `DELEGATE_JOB_LOGS_DIR` successfully routes all state into the primary repository. + - [`multi-agent-mux-create/scripts/create_session.sh:309`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills/multi-agent-mux-create/scripts/create_session.sh#L309): Records `pane.cwd: $WORKSPACE` ($WT) in the YAML row, while registering into `$AGENT_SESSIONS_YAML`. + - [`multi-agent-mux-monitor/scripts/reconcile.sh:745-752`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh#L745-L752): Drift C conversation discovery uses `cwd = (s.get('pane') or {}).get('cwd', '')` and `key = workspace_key(cwd)`. Because Claude stores conversations under `~/.claude/projects/` derived from `cwd`, Claude conversation IDs are accurately resolved against `$WT`! + - [`multi-agent-mux-status/scripts/status.sh:59, 220`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills/multi-agent-mux-status/scripts/status.sh#L59): Accurately reads `pane.cwd` and displays the worktree directory without drift errors. + - [`multi-agent-mux-stop/scripts/stop_session.sh:133, 143`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh#L133): Retrieves `TARGET_CWD` directly from the row's `pane.cwd`. + - [`multi-agent-mux-resume/scripts/resume_session.sh:42, 52`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh#L42) & [`workspace_uuid.py:57-58`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills/lib_py/workspace_uuid.py#L57-L58): Resolves UUID by matching `s_cwd == ws`. Resuming a Creator requires `--workspace "$WT"`. + - **Cautionary Bug Identified**: In [`create_session.sh:526`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills/multi-agent-mux-create/scripts/create_session.sh#L526): + ```bash + (bash "$WORKSPACE/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh" --once >/dev/null 2>&1 &) || true + ``` + This background subshell executes inside `$WT` without explicitly inheriting the exported `WORKSPACE_ROOT` / `AGENT_SESSIONS_YAML` variables, risking spawning a monitor tied to `$WT/.mam`. The recipe must ensure these envs are propagated. + +--- + +### Q3: Cost/benefit evaluation of Candidates A, B, and C? +- **Verdict**: **Agree (๋™์˜)**. +- **Analysis**: + - **Candidate A**: Highly wasteful. Spawning 5 agents per issue multiplies token consumption and rate limits by $N$. Reviewer context is completely discarded between issues. + - **Candidate B**: Ineffective. Preserves the single-checkout bottleneck and fails to eliminate checkout collisions for creators. + - **Candidate C**: Achieves optimal resource efficiency. Exactly 1 lightweight Creator agent is spawned per worktree; all Reviewers remain warm and retain conversational memory in the main session pool. + +--- + +### Q4: Evaluation of ยง5 Task Items & Multi-Loop Contention +- **Verdict**: **Supplement (๋ณด์™„)**. +- **Analysis of ยง5 Items**: + - **Items 1, 2, 3, 5**: High priority, straightforward, and essential. + - **Item 4 (Multi-Loop Contention)**: Concurrently executing multiple loops against a single `.mam` directory is currently **infeasible** due to hardcoded singletons in the codebase: + 1. [`lib_py/loop/runner.py:60`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills/lib_py/loop/runner.py#L60): `self.checkpoint_file = self.mam_dir / "loop-checkpoint.json"`. Two loops will overwrite each other's checkpoint. + 2. [`lib_py/loop/runner.py:801`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills/lib_py/loop/runner.py#L801): When one loop finishes, it calls `checkpoint.remove_checkpoint()`, unlinking the checkpoint file while the other loop is still active! + 3. [`lib_py/loop/runner.py:427`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills/lib_py/loop/runner.py#L427): Guard marker is hardcoded to `.mam/loop-guard-active`. + 4. [`lib_py/loop/runner.py:312-326`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills/lib_py/loop/runner.py#L312-L326): When a reviewer is busy, the runner retries once after 30s. If a reviewer in Loop 1 is running tests for 2 minutes, Loop 2 fails with exit code 5 immediately. +- **Phasing Recommendation**: + - **Phase 1 (Immediate)**: **"Parallel Creators, Serial Loops"** (๋ฃจํ”„๋Š” ํ•˜๋‚˜์”ฉ, ๊ตฌํ˜„๋งŒ ๋ณ‘๋ ฌ). Creators can implement code in parallel in their respective worktrees. Loops are triggered sequentially. This delivers immediate branch isolation and parallel coding without requiring a major rewrite of the loop control plane. + - **Phase 2 (Future)**: Support fully concurrent loops by parameterizing `loop-checkpoint-.json`, `loop-guard-active-`, and implementing a proper reviewer dispatch queue. + +--- + +### Q5: Ordering relative to Issue #17 Item 4 (Shell Layer Reduction) +- **Verdict**: **Agree (๋™์˜)**. +- **Rationale**: + - Documenting and validating Candidate C via recipes and targeted wrapper patches first establishes clear operational requirements. + - When Issue #17 Item 4 consolidates shell wrappers into Python (`lib_py`), the distinction between `worktree_root` (code under test) and `control_plane_root` (shared `.mam` registry) can be implemented natively in Python classes (`LoopRunner`, `Registry`) without having to refactor bash scripts twice. + +--- + +## 3. Code-Level Evidence and Review Checklist + +### 3.1 `lib.sh:26` and Environment Overrides +- **Finding**: Overrides for `WORKSPACE_ROOT`, `AGENT_SESSIONS_YAML`, `MAM_JOBS_DIR`, and `DELEGATE_JOB_LOGS_DIR` are respected across all skill tools. +- **Evidence**: + - [`lib.sh:26, 34`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills/lib.sh#L26-L34): Defaults are evaluated with `${VAR:-...}`, permitting clean external injection. + - [`create_session.sh:353, 372`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills/multi-agent-mux-create/scripts/create_session.sh#L353): Respects `MAM_JOBS_DIR` and `AGENT_SESSIONS_YAML`. + - [`stop_session.sh:99, 151`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh#L99): Respects `AGENT_SESSIONS_YAML` and `MAM_JOBS_DIR`. + +### 3.2 Prompt Injection Relative Brief Paths (ยง5-1) +- **Finding**: Prompt injection templates currently hardcode relative paths (`.mam/jobs//brief.md`), which will break when an agent's `cwd` is `$WT`. +- **Evidence**: + - [`multi-agent-mux-delegate-job:199`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job#L199): + `"task brief file at: .mam/jobs/$JOB_ID/brief.md. Please READ and follow .mam/jobs/$JOB_ID/brief.md"` + - [`multi-agent-mux-delegate-job:455`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job#L455): + `"documented in the task brief file at: .mam/jobs/$JOB_ID/brief.md"` + - [`multi-agent-mux-delegate-job:352`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job#L352): + `- **Output Report Path**: .mam/jobs/$JOB_ID/${clean_session}-reports/report-final.md` +- **Actionable Fix**: Update these three lines to use absolute path `$REGISTRY_DIR/$JOB_ID/brief.md` and `$REGISTRY_DIR/$JOB_ID/${clean_session}-reports/report-final.md`. + +### 3.3 Loop Runner Diff Collection & `base_commit` (ยง5-2) +- **Finding**: `LoopRunner` currently evaluates git status strictly against `self.workspace_root` ($MAIN). +- **Evidence**: + - [`lib_py/loop/runner.py:564`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills/lib_py/loop/runner.py#L564): + `subprocess.run(["git", "rev-parse", "HEAD"], cwd=str(self.workspace_root))` + - [`lib_py/loop/runner.py:402, 408`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills/lib_py/loop/runner.py#L402): + `subprocess.run(..., cwd=str(self.workspace_root))` +- **Actionable Fix**: Introduce `--worktree ` (or `MAM_LOOP_WORKTREE`). Set `self.worktree_root = Path(args.worktree).resolve() if args.worktree else self.workspace_root`. Execute `git rev-parse HEAD` and `collect_changes_diff()` in `cwd=str(self.worktree_root)`. + +--- + +## 4. Specific Document Modifications + +| Section in Document | Proposed Modification | Rationale | +|---|---|---| +| **ยง3 (๋™์ž‘ ๋ฐฉ์‹)** | `create_session.sh` ํ˜ธ์ถœ ์‹œ `create_session.sh:526` ๋ฐฑ๊ทธ๋ผ์šด๋“œ reconcile ํ˜ธ์ถœ์— env ๊ฐ€ ์ƒ์†๋˜๋„๋ก ๋ ˆ์‹œํ”ผ ๋ณด๊ฐ•. ์›ŒํฌํŠธ๋ฆฌ ์ค€๋น„ ์‹œ `.mam.env` ๋ณต์‚ฌ/๋งํฌ ํ•ญ๋ชฉ ๋ช…์‹œ. | ๋ˆ„๋ฝ ์‹œ `$WT` ๊ธฐ์ค€์˜ ๊ณ ์•„ monitor ๊ฐ€ ๊ธฐ๋™๋˜๊ฑฐ๋‚˜ MQTT ๋น„๋ฐ€์ •๋ณด๊ฐ€ ๋ˆ„๋ฝ๋  ์œ„ํ—˜ ์ฐจ๋‹จ. | +| **ยง4 (๊ธฐ๋Œ€ ํšจ๊ณผ์™€ ํ•œ๊ณ„)** | "๋‹ค์ค‘ ๋ฃจํ”„ ๋™์‹œ ๊ธฐ๋™"์„ 2๋‹จ๊ณ„(1๋‹จ๊ณ„: ๋ณ‘๋ ฌ ๊ตฌํ˜„ + ์ˆœ์ฐจ ๋ฃจํ”„ ๊ฒ€์ฆ, 2๋‹จ๊ณ„: ๋‹ค์ค‘ ๋ฃจํ”„ ๋™์‹œ ๊ธฐ๋™)๋กœ ๋ช…ํ™•ํžˆ ๋ถ„๋ฆฌ ์„œ์ˆ . | `loop-checkpoint.json` ๋ฐ ๋‹จ์ผ `loop-guard-active` ํŒŒ์ผ ์ถฉ๋Œ๋กœ ์ธํ•œ ์ฆ‰์‹œ ๊ฒฐํ•จ ๋ฐฉ์ง€. | +| **ยง5 (ํ•„์š”ํ•œ ๋ณ€๊ฒฝ)** | **ยง5-6 ์ถ”๊ฐ€**: ๋ฆฌ๋ทฐ์–ด ํ…Œ์ŠคํŠธ ์‹คํ–‰ ๊ฒฉ๋ฆฌ ๊ทœ์น™ ์ถ”๊ฐ€ (`PYTHONDONTWRITEBYTECODE=1`, `-p no:cacheprovider`, ์ž„์‹œ ๋””๋ ‰ํ„ฐ๋ฆฌ ๊ธฐ๋ฐ˜ ํ…Œ์ŠคํŠธ). | ๋ฆฌ๋ทฐ์–ด๊ฐ€ `$WT` ์— ๋นŒ๋“œ ์•„ํ‹ฐํŒฉํŠธ๋‚˜ ์บ์‹œ ํŒŒ์ผ์„ ๋‚จ๊ฒจ ์ปค๋ฐ‹์„ ์˜ค์—ผ์‹œํ‚ค๋Š” ๋ฌธ์ œ ๋ฐฉ์ง€. | +| **ยง5 (ํ•„์š”ํ•œ ๋ณ€๊ฒฝ)** | **ยง5-7 ์ถ”๊ฐ€**: ์›ŒํฌํŠธ๋ฆฌ ์ข…๋ฃŒ ํ—ฌํผ `scripts/worktree_remove.sh` ์ถ”๊ฐ€ (`multi-agent-mux-stop --purge` ํ›„ `git worktree remove`). | ์›ŒํฌํŠธ๋ฆฌ ์‚ญ์ œ ํ›„ `agent-sessions.yaml` ์— ์ฃฝ์€ ๊ฒฝ๋กœ์˜ ์„ธ์…˜ ํ–‰์ด ์ž”๋ฅ˜ํ•˜๋Š” ๋ฌธ์ œ ๋ฐฉ์ง€. | + +--- + +## 5. Final Verdict + +The proposal accurately diagnoses the architectural limitation and provides a high-leverage, elegant solution. The recommended supplements are non-breaking and address operational robustness. + +[VERDICT: PASS] diff --git a/docs/design/reviews/review-claude-e89f62d5.md b/docs/design/reviews/review-claude-e89f62d5.md new file mode 100644 index 0000000..6f980c2 --- /dev/null +++ b/docs/design/reviews/review-claude-e89f62d5.md @@ -0,0 +1,160 @@ +# ๐Ÿ” Design Review โ€” Job e89f62d5 (์›ŒํฌํŠธ๋ฆฌ ์šด์˜ ๋ชจ๋ธ ์ œ์•ˆ, `design/worktree-model` @ `fe96db1`) + +- **Reviewer**: `planner-reviewer-claude-01` (role: planner,reviewer) ยท 2026-09-14 +- **๋Œ€์ƒ**: `docs/design/worktree-creator-model.md`(์›ŒํฌํŠธ๋ฆฌ `multi-agent-mux-wt/design-worktree-model`, ์ปค๋ฐ‹ `fe96db1`, 1ํŒŒ์ผ +86). + - ๋ณธ ์ฒดํฌ์•„์›ƒ์€ ์ด ์žก ๋‚ด๋‚ด `main` @ `5611859`(v4.5.0 ์ฝ”๋“œ)์— ์žˆ๋‹ค. + - ์•„๋ž˜์˜ ์ฝ”๋“œ ๊ทผ๊ฑฐ(ํŒŒ์ผ:๋ผ์ธ)๋Š” ๋ชจ๋‘ ์ด `main` ๊ธฐ์ค€์ด๋‹ค. +- **๋ฐฉ๋ฒ•**: + - ๋ฌธ์„œ๋ฅผ ์ •๋…ํ–ˆ๋‹ค. + - ๋ฌธ์„œ๊ฐ€ ๊ธฐ๋Œ€๋Š” ์ฝ”๋“œ ๊ฒฝ๋กœ๋ฅผ createยทstatusยทmonitorยทstopยทresumeยทdelegate-jobยทloop ๋Ÿฌ๋„ˆ ์ˆœ์œผ๋กœ ์ถ”์ ํ–ˆ๋‹ค. + - scratchpad ์ž„์‹œ git ์ €์žฅ์†Œ์—์„œ ์‹คํ—˜ 1๊ฑด์„ ํ–ˆ๋‹ค(gitignore ์™€ symlink). + - ๋ฌธ์„œยท์ฝ”๋“œ๋Š” ์ˆ˜์ •ํ•˜์ง€ ์•Š์•˜๋‹ค. ์ฒดํฌ์•„์›ƒ ์ „ํ™˜, ์›ŒํฌํŠธ๋ฆฌ ์กฐ์ž‘, ์‹ค์ œ `.mam`ยท์„ธ์…˜ ์กฐ์ž‘๋„ ์—†์—ˆ๋‹ค. + +## 0. ์š”์•ฝ + +| ํ•ญ๋ชฉ | ํŒ์ • | ํ•ต์‹ฌ | +|---|---|---| +| ยง6-Q1 ๋‘ ๋ฌธ์ œ ํ•ด์†Œ | **๋ณด์™„** | ์ปค๋ฐ‹ ๊ฒฉ๋ฆฌ๋Š” ํ•ด์†Œ๋œ๋‹ค. ๋ณ‘๋ ฌ์„ฑ์€ "๋ฃจํ”„ ๋ฐ– creator ๋ณ‘๋ ฌ + ๋ฆฌ๋ทฐ ๋ฃจํ”„ 1๊ฐœ" ๋กœ๋งŒ ๊ฐ€๋Šฅํ•˜๋‹ค(๋ฃจํ”„ lock ์ด ๋‘ ๋ฒˆ์งธ ๋ฃจํ”„๋ฅผ ๊ฑฐ๋ถ€). ๋†“์นœ ๊ฒฝ๋กœ 5๊ฑด(ยง2) | +| ยง6-Q2 / ๋ธŒ๋ฆฌํ”„ (1) ์ œ์–ด ํ‰๋ฉด env override | **๋ณด์™„** | statusยทmonitorยทstop ์€ ํ–‰์˜ `pane.cwd` ๋ฅผ ์กด์ค‘ํ•ด ์•ˆ์ „ํ•˜๋‹ค. ๊ทธ๋Ÿฌ๋‚˜ resume ์€ `--workspace $WT` ๊ฐ€ ํ•„์ˆ˜์ด๊ณ , ๋ชจ๋‹ˆํ„ฐ ๋ฐ๋ชฌ์ด ์›ŒํฌํŠธ๋ฆฌ ์ฝ”๋“œ๋กœ ๋œฐ ์ˆ˜ ์žˆ๋‹ค | +| ๋ธŒ๋ฆฌํ”„ (2) ์ฃผ์ž… ๋ฌธ๊ตฌ ์ƒ๋Œ€๊ฒฝ๋กœ | **๋™์˜ + ๋ฒ”์œ„ ํ™•๋Œ€** | brief ๊ฒฝ๋กœ๋ฟ ์•„๋‹ˆ๋ผ `--registry-dir .mam/jobs` ์™€ Output Report Path ๋„ ์ƒ๋Œ€๊ฒฝ๋กœ๋‹ค. ์ด๋Œ€๋กœ๋ฉด creator ์˜ `started` ๊ฐ€ ์‹คํŒจํ•œ๋‹ค(rc 1 `agent_blocked`). ยง5-1 ์€ ์ฒซ ์‚ฌ์šฉ ์ „ ํ•„์ˆ˜๋‹ค | +| ๋ธŒ๋ฆฌํ”„ (3) ๋Ÿฌ๋„ˆ diffยท`base_commit` | **๋™์˜ + ๊ตฌ์ฒดํ™”** | `runner.py:397-411`, `:564-565` ๋Š” ๋ชจ๋‘ `workspace_root` ๊ธฐ์ค€์ด๋‹ค. `--worktree` ๋Š” diffยทbase ๋งŒ ๋ฐ”๊พธ๊ณ  wrapperยท๋ ˆ์ง€์ŠคํŠธ๋ฆฌยท์ฒดํฌํฌ์ธํŠธ๋Š” ๋ณธ ์ฒดํฌ์•„์›ƒ์— ๋‘์–ด์•ผ ํ•œ๋‹ค | +| ๋ธŒ๋ฆฌํ”„ (4) `.mam` ๊ฒฝํ•ฉยทยง5-4 | **๋ณด์™„** | ๋ฌธ์„œ์˜ "๊ฒฝ๊ณ " ๊ฐ€ ์•„๋‹ˆ๋ผ **๊ฑฐ๋ถ€**๋‹ค(`run_loop.sh:189-203`). 1๋‹จ๊ณ„๋Š” ยง5-4 ์—†์ด ์‹œ์ž‘ํ•  ์ˆ˜ ์žˆ๋‹ค | +| ๋ธŒ๋ฆฌํ”„ (5) ๋†“์นœ ์‹คํŒจ ๊ฒฝ๋กœ | **๋ณด์™„** | `.venv` symlink ๊ฐ€ ๋ฌด์‹œ๋˜์ง€ ์•Š์Œ(์‹คํ—˜), `.pytest_cache` ๋ฏธ๋ฌด์‹œ, ๋ฆฌ๋ทฐ์–ด๊ฐ€ creator ์˜ ์‚ด์•„ ์žˆ๋Š” ํŠธ๋ฆฌ์—์„œ ๊ฒ€์ฆ, ์›ŒํฌํŠธ๋ฆฌ ๋ชจ๋‹ˆํ„ฐ ๋ฐ๋ชฌ, ์›ŒํฌํŠธ๋ฆฌ ์‚ญ์ œ ๋’ค resume ๋ถˆ๊ฐ€ | +| ยง6-Q3 / ๋ธŒ๋ฆฌํ”„ (6) A/B/C ํ‰๊ฐ€ | **๋™์˜ + ๋Œ€์•ˆ 2๊ฑด** | C ๊ฐ€ ๋งž๋‹ค. ์ถ”๊ฐ€๋กœ โ‘  ๋ณธ ์ฒดํฌ์•„์›ƒ ์ปค๋ฐ‹ ๊ฐ€๋“œ, โ‘ก ๋ฆฌ๋ทฐ๋งˆ๋‹ค detached ์›ŒํฌํŠธ๋ฆฌ | +| ยง6-Q4 ยง5 ๋ˆ„๋ฝยท๊ณผ์ž‰ | **๋ณด์™„** | ยง5-1ยทยง5-2ยทยง5-3 ๋ฒ”์œ„๋ฅผ ๋„“ํžˆ๊ณ  3๊ฑด์„ ์ถ”๊ฐ€ํ•œ๋‹ค. ยง5-4 ๋Š” ๋ณด๋ฅ˜ํ•ด๋„ ๋œ๋‹ค | +| ยง6-Q5 / ๋ธŒ๋ฆฌํ”„ (7) #17 ํ•ญ๋ชฉ 4 ์ˆœ์„œ | **์กฐ๊ฑด๋ถ€ ๋™์˜** | ๋ ˆ์‹œํ”ผยท๋ฌธ์„œ ๋จผ์ €. ๋‹จ ยง5-1ยทยง5-2 ๋‘ ์ฝ”๋“œ ๋ณ€๊ฒฝ์€ ์ง€๊ธˆ ํ•œ๋‹ค | + +**๊ฒฐ๋ก **: ๊ทผ๋ณธ ๊ฒฐํ•จ์€ ์—†๊ณ  C ์•ˆ์€ ์ฑ„ํƒ ๊ฐ€๋Šฅํ•˜๋‹ค. ๋‹ค๋งŒ ยง7 ์˜ ๋ฌธ์„œ ์ˆ˜์ •๊ณผ ยง5 ๋ณด๊ฐ•์ด ์ฒซ ์ ์šฉ ์ „์— ํ•„์š”ํ•˜๋‹ค. + +## 1. ๋ธŒ๋ฆฌํ”„ (1) ยท ยง6-Q2 โ€” ์ œ์–ด ํ‰๋ฉด env override ๊ฐ€ ์Šคํ‚ฌ๋งˆ๋‹ค ์กด์ค‘๋˜๋Š”๊ฐ€ + +| ๊ตฌ์„ฑ์š”์†Œ | override ์กด์ค‘ | ์›ŒํฌํŠธ๋ฆฌ `pane.cwd` ํ–‰ ์ฒ˜๋ฆฌ | ๊ทผ๊ฑฐ | +|---|---|---|---| +| `lib.sh` | `WORKSPACE_ROOT`(`:26`)์™€ `AGENT_SESSIONS_YAML`(`:34`)์„ env ์šฐ์„ ์œผ๋กœ ์“ด๋‹ค | โ€” | `lib.sh:26`, `:34` | +| **create** | yamlยท๋ ˆ์ง€์ŠคํŠธ๋ฆฌ โœ…(`create_session.sh:353`, `:372-386`) | pane cwd = `--workspace`(`:219`, `:223`, `:229`)์ด๊ณ  ํ–‰์— ๊ธฐ๋ก๋œ๋‹ค(`:309`, `:428`, `:494`). herdr ์„œ๋ฒ„๋Š” `--herdr-session` ์„ ์ฃผ์ง€ ์•Š์œผ๋ฉด **์›ŒํฌํŠธ๋ฆฌ ์Šฌ๋Ÿฌ๊ทธ**๊ฐ€ ๋œ๋‹ค(`:163-172`) | ์•„๋ž˜ โš ๏ธ1 | +| **status** | yaml โœ…(`status.sh:22`) | claude ๋Œ€ํ™” ํ‚ค๋ฅผ **ํ–‰์˜ cwd** ๋กœ ๊ณ„์‚ฐํ•œ๋‹ค โœ…(`:59-76`) | ์•ˆ์ „ | +| **monitor (reconcile)** | yamlยท`WORKSPACE_ROOT` โœ… | herdr ์„œ๋ฒ„๋ฅผ **ํ–‰๋งˆ๋‹ค** ๋ชจ์•„ ์กฐํšŒํ•œ๋‹ค(`reconcile.sh:491-500`). ๋‹ค๋ฅธ ์„œ๋ฒ„์˜ ์›ŒํฌํŠธ๋ฆฌ ํ–‰๋„ ๊ด€์ธก๋˜๋ฏ€๋กœ ๊ฑฐ์ง“ drift A ๊ฐ€ ์—†๋‹ค. drift B ์ž๋™ ๋“ฑ๋ก์€ ๋ณธ ์ฒดํฌ์•„์›ƒ ์•„๋ž˜ pane ๋งŒ ํ•œ๋‹ค(`:651-653`). C0 ๊ฒ€์ฆ์€ ํ–‰ cwd ๋ฅผ ์“ด๋‹ค(`:727-730`, `verify_session.py:90-93`) | ์•ˆ์ „ | +| **stop** | yaml โœ…, purge ๋งˆ์ปค๋Š” ๋ณธ ์ฒดํฌ์•„์›ƒ `.mam`(`stop_session.sh:103`) | uuid ์บก์ฒ˜ยทpurge ๊ฐ€ **ํ–‰ cwd** ๋ฅผ ์“ด๋‹ค(`:133-143`, `:189`). herdr ์†Œ์ผ“์€ ํ–‰์˜ `herdr_session` ์„ ๋จผ์ € ์“ด๋‹ค(`lib.sh:1484-1490`) | ์•ˆ์ „ | +| **resume** | yaml โœ… | `--workspace` ๊ฐ€ ํ•„์ˆ˜๋‹ค(`resume_session.sh:42`). ํ–‰์„ `cwd == --workspace` ๋กœ ์ฐพ๋Š”๋‹ค(`workspace_uuid.py:58`). claude ํ‚ค๋Š” ์›ŒํฌํŠธ๋ฆฌ ๊ฒฝ๋กœ๋‹ค(`verify_session.py:75-81`) | โš ๏ธ2 | +| **delegate-job ๋ž˜ํผ** | `.mam.env` ๋Š” `REPO_ROOT`(= ๋ณธ ์ฒดํฌ์•„์›ƒ, `:20-24`) โœ…. **`MAM_JOBS_DIR` ์€ ์ฐธ์กฐํ•˜์ง€ ์•Š๋Š”๋‹ค**. `REGISTRY_DIR_DEFAULT=".mam/jobs"`(`:71`) | ์ฃผ์ž… ๋ฌธ๊ตฌยท๋ธŒ๋ฆฌํ”„๊ฐ€ ์ƒ๋Œ€๊ฒฝ๋กœ๋‹ค | ยง3 ์—์„œ ๋‹ค๋ฃฌ๋‹ค | +| **loop ๋Ÿฌ๋„ˆ** | `workspace_root` = `MAM_REAL_ROOT`/`WORKSPACE_ROOT`/cwd(`runner.py:58`) | โ€” | ยง4 ์—์„œ ๋‹ค๋ฃฌ๋‹ค | + +- โš ๏ธ1 **์›ŒํฌํŠธ๋ฆฌ ๋ชจ๋‹ˆํ„ฐ ๋ฐ๋ชฌ**: + - create ๋Š” `start_watchdog "$DELEGATE_JOB_ID" "$WORKSPACE"`(`create_session.sh:527`)๋ฅผ ๋ถ€๋ฅธ๋‹ค. + - ๊ทธ๋Ÿฌ๋ฉด ๋ฐ๋ชฌ์ด `/.agents/skills/โ€ฆ/reconcile.sh`(`daemon.py:35-36`, `:63-85`)๋กœ ๋œฌ๋‹ค. ์ค‘๋ณต ํŒ์ •์ด **์Šคํฌ๋ฆฝํŠธ ๊ฒฝ๋กœ ๊ธฐ์ค€**(`:39-55`)์ด๋ผ ๋ณธ ์ฒดํฌ์•„์›ƒ ๋ฐ๋ชฌ์„ ๋ณด์ง€ ๋ชปํ•œ๋‹ค. + - `WORKSPACE_ROOT=$MAIN` ์ด ์ƒ์†๋˜๋ฉด, ์ด ๋ฐ๋ชฌ์€ ๋ณธ ์ฒดํฌ์•„์›ƒ์˜ `monitor.lock` ์„ ์–ป์ง€ ๋ชปํ•ด ๊ณง ๋๋‚œ๋‹ค. + - ๊ทธ๋Ÿฌ๋‚˜ **๋ณธ ์ฒดํฌ์•„์›ƒ ๋ฐ๋ชฌ์ด ์—†๋˜ ์ˆœ๊ฐ„**์ด๋ฉด ์ด ๋ฐ๋ชฌ์ด ์›Œํฌ์ŠคํŽ˜์ด์Šค ๋ชจ๋‹ˆํ„ฐ๊ฐ€ ๋œ๋‹ค. ๊ทธ๋Ÿฌ๋ฉด ์ œ์–ด ํ‰๋ฉด์ด **์ด์Šˆ ๋ธŒ๋žœ์น˜์˜ ๋ฏธ๊ฒ€ํ†  ์ฝ”๋“œ**๋กœ ๋ˆ๋‹ค. + - ยง3 ๋ ˆ์‹œํ”ผ์— `MAM_NO_SUBSCRIBER_DAEMON=1`(#19 ์˜ตํŠธ์•„์›ƒ)์„ ๋„ฃ์–ด์•ผ ํ•œ๋‹ค. +- โš ๏ธ2 **resume**: + - `--workspace $WT` ๋กœ ๋ถˆ๋Ÿฌ์•ผ ์›ŒํฌํŠธ๋ฆฌ ํ–‰๊ณผ ๋Œ€ํ™”๋ฅผ ์ฐพ๋Š”๋‹ค. `$MAIN` ์„ ์ฃผ๋ฉด ํ–‰์„ ์ฐพ์ง€ ๋ชปํ•˜๊ฑฐ๋‚˜ pane ์ด ๋ณธ ์ฒดํฌ์•„์›ƒ cwd ๋กœ ๋–  ๋Œ€ํ™” ํ‚ค๊ฐ€ ์–ด๊ธ‹๋‚œ๋‹ค. + - **์›ŒํฌํŠธ๋ฆฌ๋ฅผ ์ง€์šฐ๋ฉด ๋Œ€ํ™”๋ฅผ ์žฌ๊ฐœํ•  ์ˆ˜ ์—†๋‹ค**(ํ‚ค๊ฐ€ ๊ฒฝ๋กœ๋‹ค). ๊ฐ™์€ ๊ฒฝ๋กœ๋กœ ์›ŒํฌํŠธ๋ฆฌ๋ฅผ ๋‹ค์‹œ ๋งŒ๋“ค์–ด์•ผ ํ•œ๋‹ค. + - ๋ฌธ์„œ ยง2-C ์˜ "๋๋‚˜๋ฉด ์ •์ง€(๋Œ€ํ™” ๋ณด์กด, ํ•„์š” ์‹œ ์žฌ๊ฐœ)" ์— "์žฌ๊ฐœํ•˜๋ ค๋ฉด ์›ŒํฌํŠธ๋ฆฌ๋ฅผ ์œ ์ง€ํ•˜๊ฑฐ๋‚˜ ๊ฐ™์€ ๊ฒฝ๋กœ๋กœ ์žฌ์ƒ์„ฑ" ์„ ๋ง๋ถ™์—ฌ์•ผ ํ•œ๋‹ค. +- **pane ๋Ÿฐํƒ€์ž„ env** (ํ™•์ธ ๊ถŒ๊ณ ): + - create ๋Š” herdr ์— `CMD_FULL` ๋งŒ ๋„˜๊ธด๋‹ค(`create_session.sh:219`). create ํ˜ธ์ถœ์ž๊ฐ€ export ํ•œ override ๊ฐ€ **์—์ด์ „ํŠธ ํ”„๋กœ์„ธ์Šค์˜ env** ์— ๋‚จ๋Š”์ง€๋Š” herdr ์„œ๋ฒ„ ๋™์ž‘์— ๋‹ฌ๋ ธ๋‹ค. + - ๋‚จ์ง€ ์•Š์œผ๋ฉด creator ๊ฐ€ ์›ŒํฌํŠธ๋ฆฌ์—์„œ `bash .agents/skills/โ€ฆ` ๋กœ ๋ถ€๋ฅด๋Š” ์Šคํ‚ฌ์€ **์›ŒํฌํŠธ๋ฆฌ์˜ `.mam`** ์„ ์ œ์–ด ํ‰๋ฉด์œผ๋กœ ์“ด๋‹ค(`lib.sh:26`). + - ๋ธŒ๋ฆฌํ”„์— "์›ŒํฌํŠธ๋ฆฌ์—์„œ ์ œ์–ด ํ‰๋ฉด ์Šคํ‚ฌ์„ ์‹คํ–‰ํ•˜์ง€ ๋ง ๊ฒƒ" ์„ ๋„ฃ๊ฑฐ๋‚˜, `CMD_FULL` ์„ `env WORKSPACE_ROOT=โ€ฆ MAM_JOBS_DIR=โ€ฆ` ๋กœ ๊ฐ์‹ธ์•ผ ํ•œ๋‹ค. + +## 2. ยง6-Q1 ยท ๋ธŒ๋ฆฌํ”„ (5) โ€” ๋‘ ๋ฌธ์ œ์˜ ํ•ด์†Œ์™€ ๋†“์นœ ๊ฒฝ๋กœ + +- **์ฒดํฌ์•„์›ƒ ์ „ํ™˜ ์‚ฌ๊ณ **: ์ปค๋ฐ‹ํ•˜๋Š” ์ฃผ์ฒด(creator, ์˜ค์ผ€์ŠคํŠธ๋ ˆ์ดํ„ฐ)๊ฐ€ ๋ชจ๋‘ ์›ŒํฌํŠธ๋ฆฌ์—์„œ ์ปค๋ฐ‹ํ•˜๋ฏ€๋กœ **ํ•ด์†Œ๋œ๋‹ค**. + - ๋‹ค๋งŒ ๋ณธ ์ฒดํฌ์•„์›ƒ์—์„œ ๋ˆ„๊ตฌ๋“  `git checkout` ํ•  ์ˆ˜ ์žˆ๋‹ค๋Š” ์ ์€ ๊ทธ๋Œ€๋กœ๋‹ค. + - ์‹ธ๊ณ  ์ง๊ตํ•˜๋Š” ๋ฐฉ์–ด๋กœ **๋ณธ ์ฒดํฌ์•„์›ƒ ์ปค๋ฐ‹ ๊ฐ€๋“œ**๋ฅผ ํ•จ๊ป˜ ๊ถŒํ•œ๋‹ค: `pre-commit` hook ์ด `git branch --show-current` ๋ฅผ ํ™•์ธํ•ด ๊ฑฐ๋ถ€ํ•œ๋‹ค. +- **๋ณ‘๋ ฌ์„ฑ**: ๋ฃจํ”„ lock ์ด ๊ฐ™์€ `.mam` ์—์„œ ๋‘ ๋ฒˆ์งธ ๋ฃจํ”„๋ฅผ **exit 1 ๋กœ ๊ฑฐ๋ถ€**ํ•œ๋‹ค(`run_loop.sh:144`, `:189-203`, loop SKILL.md `:235`). + - ๋”ฐ๋ผ์„œ C ์•ˆ์˜ 1๋‹จ๊ณ„ ๋ณ‘๋ ฌ์€ ๋‹ค์Œ ํ˜•ํƒœ๋ฟ์ด๋‹ค: **creator ๊ตฌํ˜„์€ ๋ฃจํ”„ ๋ฐ–(delegate-job direct ๋‚˜ ๋Œ€ํ™”)์—์„œ ๋ณ‘๋ ฌ๋กœ, ๋ฆฌ๋ทฐ ๋ฃจํ”„๋Š” ํ•œ ๋ฒˆ์— ํ•˜๋‚˜**. + - ยง4 ์™€ ยง6-Q4 ์— ์ด ์šด์˜ ํ˜•ํƒœ๋ฅผ ๋ช…์‹œํ•ด์•ผ ํ•œ๋‹ค. +- **๋†“์นœ ์‹คํŒจ ๊ฒฝ๋กœ**: + 1. **๋ฆฌ๋ทฐ์–ด๊ฐ€ creator ์˜ ์‚ด์•„ ์žˆ๋Š” ์›ŒํฌํŠธ๋ฆฌ์—์„œ ๊ฒ€์ฆ**ํ•œ๋‹ค(ยง2-C "๊ทธ ๊ฒฝ๋กœ์—์„œ `git diff`ยทํ…Œ์ŠคํŠธ"). + - creator ๊ฐ€ ๋ฆฌ๋ทฐ ์ค‘์— ์ปค๋ฐ‹ยทํŽธ์ง‘ํ•˜๋ฉด ๊ฒ€์ฆ ๋Œ€์ƒ์ด ๋ฐ”๋€๋‹ค. #18ยท#19 ์—์„œ ๋ฆฌ๋ทฐ ์ค‘ HEAD ์ด๋™์ด ์„ธ ๋ฒˆ ์žˆ์—ˆ๋‹ค. + - ๋ฆฌ๋ทฐ์–ด์˜ ํ…Œ์ŠคํŠธ ์‚ฐ์ถœ๋ฌผ์ด creator ํŠธ๋ฆฌ์— ๋‚จ๋Š”๋‹ค. + - โ†’ ๋ฆฌ๋ทฐ๋งˆ๋‹ค **detached ์›ŒํฌํŠธ๋ฆฌ**(`git worktree add --detach "$TMP/review-" `, ๋ฆฌ๋ทฐ ๋’ค ์ œ๊ฑฐ)๋กœ ๊ฒ€์ฆ ๋Œ€์ƒ์„ ๋ถˆ๋ณ€์œผ๋กœ ๋งŒ๋“ค ๊ฒƒ์„ ๊ถŒํ•œ๋‹ค. ์ƒˆ ์—์ด์ „ํŠธ ์ธ์Šคํ„ด์Šค๊ฐ€ ํ•„์š” ์—†๋Š” C ์•ˆ์˜ ๋ณด๊ฐ•์ด๋‹ค. + 2. **ยง3 ์˜ `.venv` symlink ๊ฐ€ git ์—์„œ ๋ฌด์‹œ๋˜์ง€ ์•Š๋Š”๋‹ค.** ์‹คํ—˜(์ž„์‹œ ์ €์žฅ์†Œ, `.gitignore` ์— `.venv/`): symlink `.venv` ๋Š” `?? .venv` ๋กœ ๋ณด์ธ๋‹ค. ๋””๋ ‰ํ„ฐ๋ฆฌ ์ „์šฉ ํŒจํ„ด์€ symlink ์— ๋งž์ง€ ์•Š๋Š”๋‹ค. + - ๊ทธ๋ž˜์„œ creator ์˜ `git add -A` ๊ฐ€ symlink ๋ฅผ ์ปค๋ฐ‹ํ•œ๋‹ค. + - ๋˜ ์ €์žฅ์†Œ `.gitignore` ์— `.pytest_cache` ๊ฐ€ ์—†์–ด์„œ(`.gitignore:14-25` ์— `.mam/`ยท`.venv/`ยท`__pycache__/`ยท`.mam.env*` ๋งŒ ์žˆ์Œ), `-p no:cacheprovider` ์—†์ด ๋Œ๋ฆฐ pytest ๋„ ์‚ฐ์ถœ๋ฌผ์„ ๋‚จ๊ธด๋‹ค. + - โ†’ ์›ŒํฌํŠธ๋ฆฌ์˜ `.git/info/exclude` ์— `/.venv`ยท`.pytest_cache/` ๋ฅผ ๋„ฃ๋Š” ๊ฒƒ์„ ์ค€๋น„ ํ—ฌํผ(ยง5-3)์— ํฌํ•จํ•œ๋‹ค. + 3. **์›ŒํฌํŠธ๋ฆฌ ๋ชจ๋‹ˆํ„ฐ ๋ฐ๋ชฌ**(ยง1 โš ๏ธ1). + 4. **์›ŒํฌํŠธ๋ฆฌ ์‚ญ์ œ ๋’ค ๋‚จ๋Š” yaml ํ–‰**: herdr ๊ฐ€ ์ฃฝ์œผ๋ฉด reconcile ์ด drift A ๋กœ `terminated` ํ‘œ๊ธฐํ•œ๋‹ค(`reconcile.sh:581-600`). ํ–‰์€ ๋‚จ์ง€๋งŒ ๋Œ€ํ™”๋Š” ์žฌ๊ฐœํ•  ์ˆ˜ ์—†๋‹ค. โ†’ ํ•ด์ฒด ์ˆœ์„œ๋ฅผ ๋ฌธ์„œํ™”ํ•œ๋‹ค: **stop โ†’ (์žฌ๊ฐœ ๋ถˆํ•„์š” ํ™•์ธ) โ†’ ์›ŒํฌํŠธ๋ฆฌ ์ œ๊ฑฐ**. + 5. **ํ† ํ”ฝ ์ง€๋ฌธยทHMAC**: ๋ฌธ์ œ์—†๋‹ค. + - ์žก ํ† ํ”ฝ์€ ๋ ˆ์ฝ”๋“œ์˜ `topic_prefix`(`python/mqtt/jobs/`, `mqtt_common.py:137`, `:351`)์ด๊ณ , HMAC ์€ ์žก ๋ ˆ์ฝ”๋“œ์˜ `auth_token` ์ด๋‹ค. ๋ ˆ์ง€์ŠคํŠธ๋ฆฌ ๊ฒฝ๋กœ๋งŒ ๋งž์œผ๋ฉด(ยง3) ์›ŒํฌํŠธ๋ฆฌ์™€ ๋ฌด๊ด€ํ•˜๋‹ค. + - ์ง€๋ฌธ์€ **๋ชจ๋‹ˆํ„ฐ ๊ตฌ๋…**์—๋งŒ ์“ฐ์ธ๋‹ค(`reconcile.sh:288-291`). ์ œ์–ด ํ‰๋ฉด์ด ํ•˜๋‚˜๋ฉด ์ง€๋ฌธ๋„ ํ•˜๋‚˜๋‹ค. + - ๋ฌธ์„œ ยง1 ํ‘œ์˜ ํ•ด๋‹น ํ–‰์— ์ด ์ ์„ ์ ์–ด ๋‘˜ ๊ฒƒ. + 6. (๋ถ€์ˆ˜) ๋Ÿฌ๋„ˆ๋Š” ๋ฆฌํฌํŠธ๋ฅผ `workspace_root/.agents/reports/โ€ฆ` ๋กœ ๋ณด๊ด€ํ•œ๋‹ค(`runner.py:554`, `:770-780`). C ์•ˆ์—์„œ๋Š” ๋ณธ ์ฒดํฌ์•„์›ƒ, ์ฆ‰ `main` ํŠธ๋ฆฌ๋กœ ๊ฐ„๋‹ค. ์ด์Šˆ ๋ธŒ๋žœ์น˜๋กœ ๋ณด๋‚ผ์ง€ ์ •ํ•ด์•ผ ํ•œ๋‹ค. + +## 3. ๋ธŒ๋ฆฌํ”„ (2) โ€” ์ฃผ์ž… ๋ฌธ๊ตฌ์˜ ์ƒ๋Œ€๊ฒฝ๋กœ (ยง5-1) + +- **๋™์˜, ๊ทธ๋ฆฌ๊ณ  ๋ฒ”์œ„๊ฐ€ ๋” ๋„“๋‹ค.** ๋ž˜ํผ๊ฐ€ ์—์ด์ „ํŠธ์— ์ฃผ๋Š” ์„ธ ๊ฒฝ๋กœ๊ฐ€ ๋ชจ๋‘ cwd ์ƒ๋Œ€๋‹ค. + +| ๋ฌธ์ž์—ด | ์œ„์น˜ | ์›ŒํฌํŠธ๋ฆฌ cwd creator ์—๊ฒŒ | +|---|---|---| +| ๋ธŒ๋ฆฌํ”„ `.mam/jobs/$JOB_ID/brief.md` | ๋ž˜ํผ `:199`, `:455` | ์—†์Œ(๋ฌธ์„œ๊ฐ€ ์งš์€ ๋ถ€๋ถ„) | +| ๋ฐœํ–‰ ๋ช…๋ น `--registry-dir $REGISTRY_DIR`(๊ธฐ๋ณธ `.mam/jobs`, `:71`) | `:198`, `:454` | `$WT/.mam/jobs` ๋กœ ํ•ด์„๋œ๋‹ค โ†’ `job record not found`, rc 1. heartbeat ํƒ€์ด๋จธ๋„ ์ƒ๋Œ€ ๋ ˆ์ง€์ŠคํŠธ๋ฆฌยทcwd ๋กœ ๋œฌ๋‹ค. **`started` ๊ฐ€ ์•ˆ ๋‚˜๊ฐ€๋ฏ€๋กœ T ๋’ค `agent_blocked` ๋กœ ๋‹ซํžŒ๋‹ค** | +| Output Report Path `.mam/jobs/$JOB_ID/โ€ฆ/report-final.md` | `:178`, `:352` | ๋ฆฌํฌํŠธ๊ฐ€ `$WT/.mam/โ€ฆ` ์— ์“ฐ์ธ๋‹ค. ๋Ÿฌ๋„ˆ์˜ `find_report`(`runner.py:389-395`, ๋ณธ ์ฒดํฌ์•„์›ƒ `.mam`)๊ฐ€ ์ฐพ์ง€ ๋ชปํ•œ๋‹ค | + +- ๋ž˜ํผ๊ฐ€ `MAM_JOBS_DIR` ์„ ๋ณด์ง€ ์•Š์œผ๋ฏ€๋กœ env override ๋กœ๋Š” ํ’€๋ฆฌ์ง€ ์•Š๋Š”๋‹ค. +- โ†’ ยง5-1 ์„ "`REGISTRY_DIR` ์„ ์ ˆ๋Œ€๊ฒฝ๋กœ๋กœ ์ •๊ทœํ™”ํ•˜๊ณ , ๋ธŒ๋ฆฌํ”„ยท๋ฐœํ–‰ ๋ช…๋ นยท๋ฆฌํฌํŠธ ๊ฒฝ๋กœ ์„ธ ๊ณณ ๋ชจ๋‘ ์ ˆ๋Œ€๊ฒฝ๋กœ๋กœ" ๋กœ ๊ณ ์น˜๊ณ , **์ฒซ C ์•ˆ ์ ์šฉ ์ „ ํ•„์ˆ˜**๋กœ ํ‘œ์‹œํ•œ๋‹ค. + +## 4. ๋ธŒ๋ฆฌํ”„ (3)ยท(4) โ€” ๋Ÿฌ๋„ˆ diffยท`base_commit`, `.mam` ๊ฒฝํ•ฉ + +- **(3) ๋™์˜**: + - `collect_changes_diff` ๋Š” `workspace_root` ์˜ ์Šคํฌ๋ฆฝํŠธ์™€ cwd ๋ฅผ ์“ด๋‹ค(`runner.py:397-411`). + - `base_commit` ์€ `workspace_root` ์˜ `HEAD` ๋‹ค(`:564-565`). ๋ณธ ์ฒดํฌ์•„์›ƒ์ด `main` ์ด๋ฉด diff ๋Š” ๋น„๊ณ  base ๋Š” `main` ์ด๋‹ค. ์ด๋ฒˆ #18 ๋ธŒ๋ฆฌํ”„๋“ค์˜ "(no changes since base commit)" ๊ณผ ๊ฐ™์€ ์ฆ์ƒ์ด๋‹ค. + - ยง5-2 ์˜ `--worktree` ๋Š” ๋‹ค์Œ์„ ์ •ํ™•ํžˆ ์ง€์ •ํ•ด์•ผ ํ•œ๋‹ค: **๋ฐ”๋€Œ๋Š” ๊ฒƒ**์€ diff ์ˆ˜์ง‘ ๊ฒฝ๋กœ, `base_commit`(์›ŒํฌํŠธ๋ฆฌ์—์„œ `git merge-base main HEAD`), ๋ธŒ๋ฆฌํ”„์— ๋„ฃ๋Š” ๊ฒฝ๋กœยทbaseยทhead ๋ฟ์ด๋‹ค. **๊ทธ๋Œ€๋กœ ๋‘๋Š” ๊ฒƒ**์€ wrapper ๊ฒฝ๋กœ(`:288`), ๋ ˆ์ง€์ŠคํŠธ๋ฆฌ, ์ฒดํฌํฌ์ธํŠธ(`:60`), ๋ฆฌํฌํŠธ ๋ณด๊ด€์ด๋ฉฐ ๋ณธ ์ฒดํฌ์•„์›ƒ์˜ ๊ฒƒ์„ ์“ด๋‹ค. +- **(4) ๋ณด์™„**: + - ๋ฌธ์„œ ยง4 ๋Š” "๋™์‹œ ๋ฃจํ”„ ๊ธˆ์ง€" ๋ฅผ ๊ฒฝ๊ณ ๋กœ ์„œ์ˆ ํ•˜์ง€๋งŒ, ์‹ค์ œ๋กœ๋Š” lock ์ด **๊ฑฐ๋ถ€**ํ•œ๋‹ค(`run_loop.sh:189-203`). ์ฒดํฌํฌ์ธํŠธ๋„ ํŒŒ์ผ ํ•˜๋‚˜๋‹ค(`runner.py:60`). + - ์„ธ์…˜ DB ๋Š” SQLite ์ฝ๊ธฐ์ด๊ณ  `timeout=10`(`runner.py:199-203`)์ด๋‹ค. ์“ฐ๊ธฐ ๊ฒฝํ•ฉ์€ yaml ์˜ `atomic_dump_yaml`(flock) ์ชฝ์ด๋‹ค. + - ๋ฆฌ๋ทฐ์–ด busy ๋Š” rc 5 โ†’ `MAM_SESSION_BUSY_RETRY_S`(๊ธฐ๋ณธ 30) ๋’ค 1ํšŒ ์žฌ์‹œ๋„(`runner.py:312-322`) โ†’ ์‹คํŒจ๋กœ ์ด์–ด์ง„๋‹ค. ๋ฃจํ”„ ๋‘˜์ด ๊ฐ™์€ ๋ฆฌ๋ทฐ์–ด๋ฅผ ๋‘๊ณ  ๋‹คํˆฌ๋ฉด ํ•œ์ชฝ์ด ์‹คํŒจํ•œ๋‹ค. + - **๊ฒฐ๋ก **: 1๋‹จ๊ณ„๋ฅผ "๋ฆฌ๋ทฐ ๋ฃจํ”„๋Š” ํ•˜๋‚˜์”ฉ, creator ๊ตฌํ˜„๋งŒ ๋ณ‘๋ ฌ" ๋กœ ์‹œ์ž‘ํ•˜๋ฉด ยง5-4 ๋Š” ํ•„์š” ์—†๋‹ค. ๋ฃจํ”„ ์—ฌ๋Ÿฌ ๊ฐœ๋ฅผ ๋™์‹œ์— ๋Œ๋ฆฌ๋Š” ๊ฒƒ์€ ยง5-4(๋ฃจํ”„ id ๋ณ„ markerยท์ฒดํฌํฌ์ธํŠธ, ๋ฆฌ๋ทฐ์–ด ๋Œ€๊ธฐ ํ)๋ฅผ ๋๋‚ธ ๋’ค๋กœ ๋ฏธ๋ฃฌ๋‹ค. + +## 5. ยง6-Q3 ยท ๋ธŒ๋ฆฌํ”„ (6) โ€” A/B/C ํ‰๊ฐ€ + +- **๋™์˜**: + - A ์˜ ๋น„์šฉ(์—์ด์ „ํŠธ ์ˆ˜ยทํ† ํฐ ํ•œ๋„๊ฐ€ ์ด์Šˆ ์ˆ˜์— ๋น„๋ก€, ๋ฌธ๋งฅ ์ดˆ๊ธฐํ™”, ๊ด€์ธก ๋ถ„์‚ฐ) ํ‰๊ฐ€๋Š” ํƒ€๋‹นํ•˜๋‹ค. + - B ๋Š” creator ๊ฐ€ ์—ฌ์ „ํžˆ ๋ณธ ์ฒดํฌ์•„์›ƒ์—์„œ ๋ธŒ๋žœ์น˜๋ฅผ ์˜ฎ๊ธฐ๋ฏ€๋กœ #18 ์œ ํ˜•์ด ๋‚จ๋Š”๋‹ค๋Š” ํŒ๋‹จ์ด ์˜ณ๋‹ค. + - C ๊ฐ€ ์“ฐ๋Š” ์ชฝ๋งŒ ๊ฒฉ๋ฆฌํ•˜๋Š” ์ตœ์†Œ ๋ณ€๊ฒฝ์ด๋‹ค. +- **๋Œ€์•ˆยท๋ณด๊ฐ• 2๊ฑด**: + 1. **๋ณธ ์ฒดํฌ์•„์›ƒ ์ปค๋ฐ‹ ๊ฐ€๋“œ**(`pre-commit` hook ๋˜๋Š” ์ปค๋ฐ‹ ๋ช…๋ น ๋ž˜ํผ์˜ `git branch --show-current` ํ™•์ธ). ๋ฌธ์ œ 2 ๋ฅผ C ์™€ ๋ฌด๊ด€ํ•˜๊ฒŒ ๋ง‰๋Š” ๊ฐ€์žฅ ์‹ผ ๋ฐฉ์–ด๋‹ค. C ์ฑ„ํƒ๊ณผ ํ•จ๊ป˜ ๋„์ž…ํ•  ๊ฒƒ. + 2. **๋ฆฌ๋ทฐ๋งˆ๋‹ค detached ์›ŒํฌํŠธ๋ฆฌ**(ยง2-1). ๋ฆฌ๋ทฐ์–ด ํ’€์€ ๊ณต์œ ํ•˜๋˜ ๊ฒ€์ฆ ๋Œ€์ƒ์€ ๋ถˆ๋ณ€์œผ๋กœ ๋งŒ๋“ ๋‹ค. A ์˜ ๊ฒฉ๋ฆฌ ์ด์ ์„ ์—์ด์ „ํŠธ ์ฆ์„ค ์—†์ด ์–ป๋Š”๋‹ค. + +## 6. ยง6-Q4 ยท ยง6-Q5 โ€” ยง5 ์ž‘์—… ํ•ญ๋ชฉ๊ณผ #17 ํ•ญ๋ชฉ 4 ์ˆœ์„œ + +- **ยง5 ์ˆ˜์ • ์ œ์•ˆ**: + - ยง5-1: ๋ฒ”์œ„ ํ™•๋Œ€(ยง3). ์ฒซ ์ ์šฉ ์ „ ํ•„์ˆ˜. + - ยง5-2: `--worktree` ์˜ ์˜ํ–ฅ ๋ฒ”์œ„ ๋ช…์‹œ(ยง4). ์ฒซ ์ ์šฉ ์ „ ํ•„์ˆ˜. + - ยง5-3: ์ค€๋น„ ํ—ฌํผ์— ๋‹ค์Œ์„ ํฌํ•จํ•œ๋‹ค. + - `MAM_NO_SUBSCRIBER_DAEMON=1` + - `.git/info/exclude`(`/.venv`, `.pytest_cache/`) + - `--herdr-session <๋ณธ ์„œ๋ฒ„>` ๊ณ ์ • + - `.mam.env` ๋Š” ์›ŒํฌํŠธ๋ฆฌ ์Šคํฌ๋ฆฝํŠธ๋ฅผ ์“ธ ๋•Œ๋งŒ ํ•„์š”(๋ž˜ํผยท`mqtt_common` ์€ ์Šคํฌ๋ฆฝํŠธ ์œ„์น˜ ๊ธฐ์ค€์œผ๋กœ ์ฐพ๋Š”๋‹ค: ๋ž˜ํผ `:20-24`, `mqtt_common.py:54-65`) + - ยง5-4: ๋ณด๋ฅ˜ํ•ด๋„ ๋œ๋‹ค(ยง4). + - **์ถ”๊ฐ€**: + - ยง5-6: ์›ŒํฌํŠธ๋ฆฌ creator ์˜ stopยทresume ๋ ˆ์‹œํ”ผ์™€ ํ•ด์ฒด ์ˆœ์„œ(resume ์€ `--workspace $WT`, ์‚ญ์ œ ์ „ stop). + - ยง5-7: ๋ฆฌ๋ทฐ์–ด detached ์›ŒํฌํŠธ๋ฆฌ ๊ทœ์•ฝ. + - ยง5-8: ๋ณธ ์ฒดํฌ์•„์›ƒ ์ปค๋ฐ‹ ๊ฐ€๋“œ. +- **ยง6-Q5 (์กฐ๊ฑด๋ถ€ ๋™์˜)**: + - ์ด ๋ชจ๋ธ์„ ๋ฌธ์„œยท๋ ˆ์‹œํ”ผ๋กœ ๋จผ์ € ๊ตณํžˆ๊ณ  #17 ํ•ญ๋ชฉ 4(์…ธ ๊ณ„์ธต ์ถ•์†Œ)์—์„œ ์ฝ”๋“œ๋กœ ํก์ˆ˜ํ•˜๋Š” ์ˆœ์„œ๊ฐ€ ๋งž๋‹ค. + - ๋‹จ ยง5-1(๋ž˜ํผ ๊ฒฝ๋กœ)๊ณผ ยง5-2(๋Ÿฌ๋„ˆ ์˜ต์…˜)๋Š” ์ž‘์€ ๋ณ€๊ฒฝ์ด๊ณ  **์—†์œผ๋ฉด C ์•ˆ์ด ๋™์ž‘ํ•˜์ง€ ์•Š์œผ๋ฏ€๋กœ** ์ง€๊ธˆ ํ•œ๋‹ค. ํ•ญ๋ชฉ 4 ๊ฐ€ ๋ž˜ํผ๋ฅผ `lib_py` ๋กœ ์˜ฎ๊ธธ ๋•Œ ๊ทธ๋Œ€๋กœ ๋”ฐ๋ผ๊ฐ€๋ฉด ๋œ๋‹ค. + - ํ—ฌํผ(ยง5-3)์™€ ํ์ž‰(ยง5-4)์€ ํ•ญ๋ชฉ 4 ์•ˆ์—์„œ ๊ตฌํ˜„ํ•˜๋Š” ํŽธ์ด ์ค‘๋ณต์„ ์ค„์ธ๋‹ค. + +## 7. ๋ฌธ์„œ ์ˆ˜์ • ์ง€์  (์ ˆยท๋ฌธ์žฅ) + +- **ยง1 ํ‘œ** "๋ชจ๋‹ˆํ„ฐ ํ† ํ”ฝ ์ง€๋ฌธ" ํ–‰: "(๋ชจ๋‹ˆํ„ฐ ๊ตฌ๋…์—๋งŒ ์“ฐ์ธ๋‹ค. ์žก ํ† ํ”ฝยทHMAC ์€ ์žก ๋ ˆ์ฝ”๋“œ์˜ `topic_prefix`ยท`auth_token`)" ์„ ๋ง๋ถ™์ธ๋‹ค. +- **ยง2-C** "creator = ์›ŒํฌํŠธ๋ฆฌ ์ „์šฉ ์ธ์Šคํ„ด์Šค โ€ฆ ๋๋‚˜๋ฉด ์ •์ง€(๋Œ€ํ™” ๋ณด์กด, ํ•„์š” ์‹œ ์žฌ๊ฐœ)" ์— "์žฌ๊ฐœ๋Š” ๊ฐ™์€ ์›ŒํฌํŠธ๋ฆฌ ๊ฒฝ๋กœ๊ฐ€ ์žˆ์–ด์•ผ ํ•œ๋‹ค" ๋ฅผ ์ถ”๊ฐ€ํ•œ๋‹ค. +- **ยง2-C** "์ œ์–ด ํ‰๋ฉด = โ€ฆ": ๋ชจ๋‹ˆํ„ฐ ๋ฐ๋ชฌ ์˜ตํŠธ์•„์›ƒ๊ณผ, pane ๋Ÿฐํƒ€์ž„ env ์— override ๊ฐ€ ๋‚จ์ง€ ์•Š์„ ์ˆ˜ ์žˆ๋‹ค๋Š” ์ ์„ ์ถ”๊ฐ€ํ•œ๋‹ค. +- **ยง2-C** "๋ฆฌ๋ทฐ์–ด ํ’€ = โ€ฆ": ๋ฆฌ๋ทฐ์–ด๋Š” creator ์˜ ์›ŒํฌํŠธ๋ฆฌ๊ฐ€ ์•„๋‹ˆ๋ผ head sha ์˜ detached ์›ŒํฌํŠธ๋ฆฌ์—์„œ ๊ฒ€์ฆํ•œ๋‹ค๊ณ  ๊ณ ์นœ๋‹ค. +- **ยง3 ๋ ˆ์‹œํ”ผ**: + - `MAM_NO_SUBSCRIBER_DAEMON=1` ์„ ์ถ”๊ฐ€ํ•œ๋‹ค. + - `ln -s .venv` ์˜†์— `.git/info/exclude` ์ฒ˜๋ฆฌ๋ฅผ ์ ๋Š”๋‹ค. + - stopยทresume ๋ช…๋ น ์˜ˆ์‹œ๋ฅผ ์ถ”๊ฐ€ํ•œ๋‹ค. + - "์ฃผ์ž… ๋ฌธ๊ตฌ์˜ ์ƒ๋Œ€๊ฒฝ๋กœ โ€ฆ" ๋ฌธ์žฅ์„ ๋ฐœํ–‰ ๋ช…๋ น์˜ `--registry-dir` ์™€ Output Report Path ๊นŒ์ง€ ๋„“ํžŒ๋‹ค. +- **ยง4** ์„ธ ๋ฒˆ์งธ ํ•ญ๋ชฉ: "๊ฒฝ๊ณ " ๋ฅผ "lock ์ด ๋‘ ๋ฒˆ์งธ ๋ฃจํ”„๋ฅผ ๊ฑฐ๋ถ€(`run_loop.sh:189-203`)" ๋กœ ๊ณ ์นœ๋‹ค. 1๋‹จ๊ณ„ ์šด์˜ ํ˜•ํƒœ(๊ตฌํ˜„ ๋ณ‘๋ ฌ, ๋ฆฌ๋ทฐ ๋ฃจํ”„ 1๊ฐœ)๋ฅผ ๋ช…์‹œํ•œ๋‹ค. +- **ยง5**: 1ยท2ยท3 ์„ ์ˆ˜์ •ํ•˜๊ณ  6ยท7ยท8 ์„ ์ถ”๊ฐ€ํ•˜๋ฉฐ, 4 ๋Š” "๋ณด๋ฅ˜ ๊ฐ€๋Šฅ" ์œผ๋กœ ํ‘œ์‹œํ•œ๋‹ค(ยง6). + +## 8. ์ข…ํ•ฉ + +- C ์•ˆ(์“ฐ๋Š” ์ชฝ๋งŒ ์›ŒํฌํŠธ๋ฆฌ, ์ฝ๋Š” ์ชฝ ๊ณต์œ , ์ œ์–ด ํ‰๋ฉด ํ•˜๋‚˜)์€ ๋‘ ๋ฌธ์ œ์— ๋Œ€ํ•œ ์˜ฌ๋ฐ”๋ฅธ ๋ฐฉํ–ฅ์ด๋‹ค. +- statusยทmonitorยทstop ์ด ํ–‰์˜ `pane.cwd` ์™€ ํ–‰๋ณ„ herdr ์„œ๋ฒ„๋ฅผ ์ด๋ฏธ ์กด์ค‘ํ•˜๋ฏ€๋กœ ํ˜„์žฌ ์ฝ”๋“œ์™€๋„ ๋Œ€์ฒด๋กœ ๋งž๋Š”๋‹ค. +- ๋ณด๊ฐ•์ด ํ•„์š”ํ•œ ๊ณณ: + - ์ฒซ ์ ์šฉ ์ „ ํ•„์ˆ˜: ๋ž˜ํผ ๊ฒฝ๋กœ 3์ข…์˜ ์ ˆ๋Œ€๊ฒฝ๋กœํ™”, ๋Ÿฌ๋„ˆ `--worktree` ๋ฒ”์œ„, ๋ชจ๋‹ˆํ„ฐ ๋ฐ๋ชฌ ์˜ตํŠธ์•„์›ƒ, `.venv` symlink exclude + - ์šด์˜ ๊ทœ์•ฝ: resumeยทํ•ด์ฒด, ๋ฆฌ๋ทฐ์–ด detached ์›ŒํฌํŠธ๋ฆฌ, ์ปค๋ฐ‹ ๊ฐ€๋“œ +- ๋ชจ๋‘ ๋ฌธ์„œ ๋ณด๊ฐ•๊ณผ ์ž‘์€ ์ฝ”๋“œ ๋ณ€๊ฒฝ์œผ๋กœ ํ•ด๊ฒฐ๋˜๋ฉฐ ์„ค๊ณ„๋ฅผ ๋’ค์ง‘์„ ๊ฒฐํ•จ์€ ์•„๋‹ˆ๋‹ค. + +[VERDICT: PASS] diff --git a/docs/design/reviews/review-codex-7770259b.md b/docs/design/reviews/review-codex-7770259b.md new file mode 100644 index 0000000..f10aa58 --- /dev/null +++ b/docs/design/reviews/review-codex-7770259b.md @@ -0,0 +1,75 @@ +# Design review โ€” creator worktrees and a shared reviewer pool + +Reviewed proposal `fe96db1`, `docs/design/worktree-creator-model.md`, through `git show design/worktree-model:...` and the named design worktree. That worktree's HEAD is `fe96db1`; the source checkout remains on `main` at `5611859`. Both working trees were clean. The document-only diff passes `git diff --check`. + +**Verdict: NOT PASS as written. C is a viable direction, but the current-code recipe overstates control-plane compatibility and leaves review identity and lifecycle ownership underspecified. Revise those contracts before adopting the recipe. This does not require abandoning C.** + +## Answers to the five questions in ยง6 + +### 1. Role separation โ€” ๋ณด์™„ (agree with the direction; strengthen the execution contract) + +Dedicated creator checkouts prevent an ordinary branch switch in the main checkout from changing a creator's HEAD, and allow independent implementation work. Sharing reviewer conversations is also reasonable. However, ยง2-C's sentence โ€œ์ฝ๊ธฐ ์ „์šฉโ€ is not true of executing tests in the creator's checkout: tests, build systems and package installers can write tracked files, generated files, caches and dependencies. Multiple reviewers can interfere with each other, and with the creator, even without editing source deliberately. + +Replace โ€œ๋ฆฌ๋ทฐ์–ด๋Š” ๊ทธ ๊ฒฝ๋กœ์—์„œ ... ํ…Œ์ŠคํŠธ๋ฅผ ์‹คํ–‰ํ•œ๋‹คโ€ with an explicit choice: + +- Freeze the creator at a committed head SHA for the whole review round, serialize tests that share writable outputs, and use isolated output/config directories; or +- Keep the reviewer conversation pool, but give each test execution a disposable checkout/worktree of that exact SHA and isolated outputs. Reviewer process/session location does not have to equal test execution location. + +The example in ยง3 says `git diff ...HEAD`, whereas ยง2-C mentions a fixed head. Use an immutable `head_sha` consistently, record it in every verdict, and invalidate approval after a new creator commit. A diff against a SHA followed by tests against a changing working directory is not evidence for that SHA. Define how uncommitted changes are handled; preferably require a clean, committed review snapshot. + +The statement โ€œํŽธ์ง‘ยท์ปค๋ฐ‹ํ•˜๋Š” ์œ ์ผํ•œ ์—์ด์ „ํŠธโ€ also needs qualification: the orchestrator commits, and the existing loop promotes reports into the source checkout (`lib_py/loop/runner.py:770`, `:780`). Separate product-code ownership, report artifacts and orchestration changes. A pre-commit branch-name check helps but is not an atomic exclusion mechanism. Require expected checkout/branch/head checks and one owner for each writable source tree. + +### 2. Shared control plane through environment overrides โ€” ๋ฐ˜๋Œ€ to the present blanket claim + +The four variables are not consistently honored across all entry points. The narrow recipe running main-tree scripts from main may happen to align several defaults, but that is not a general split-root contract. + +| Component | Code evidence and assessment | +|---|---| +| Common shell roots | `.agents/skills/lib.sh:26` honors `WORKSPACE_ROOT`; `:35` honors `AGENT_SESSIONS_YAML`. This supports a separate session registry for the command invoking the library. | +| Create | `multi-agent-mux-create/scripts/create_session.sh:219` spawns with `--workspace` as cwd; `:386` writes through the chosen YAML path, and `:428` records the actual pane cwd. Thus registration and cwd can differ. **But pane environment forwarding is not established by exporting variables to the create command.** `CMD_FULL` is an agent command (`:193`), and `lib.sh:667โ€“680` generates pane `--env` flags only from assignments embedded in that command. No explicit forwarding of these four variables is present. A persistent herdr server/pane cannot be assumed to inherit each client's environment. Persist and explicitly pass the control-plane binding during create and resume. | +| Status | `multi-agent-mux-status/scripts/status.sh:59โ€“77` uses each row's cwd for Claude transcript lookup, which is appropriate. Job lookup is different: `:29` derives `PROJECT_ROOT` from the script location; `:357โ€“377` searches cwd/script-relative paths rather than `MAM_JOBS_DIR` or `DELEGATE_JOB_LOGS_DIR`. Calling the worktree copy against central state can therefore show an unknown/wrong job location. | +| Monitor/reconcile | `multi-agent-mux-monitor/scripts/reconcile.sh:117` anchors the monitor lock on `WORKSPACE_ROOT`, but `:239โ€“243` computes the event registry from that root and ignores `MAM_JOBS_DIR`. Existing registered rows use their `pane.cwd` for UUID checks (`:727โ€“757`); an external worktree cwd does **not by itself** mark an existing row as drift. However, discovery of a missing row explicitly rejects cwd outside the root at `:651โ€“654`. Sibling worktrees in the example cannot be rediscovered through this path. | +| Monitor startup | `create_session.sh:526โ€“527` passes the worktree to reconcile/watchdog startup. `lib_py/delegation/daemon.py:98โ€“110` identifies the daemon by its worktree script, and `:70โ€“76` puts its log/cwd there. This is not automatically one central daemon with a central lifetime, even if a central lock prevents duplicate active subscribers. Specify one central daemon owner and suppress worktree-local auto-starts. | +| Stop | `multi-agent-mux-stop/scripts/stop_session.sh:133โ€“143` obtains the row cwd; `:151` honors the job-root override, and `:204โ€“208` captures conversation identity using that cwd. These support the proposed split when the correct environment and server/row identity are supplied. | +| Resume | `multi-agent-mux-resume/scripts/resume_session.sh:42` requires `--workspace`; `:51โ€“52` resolves identity using that argument and `:165` spawns there. It does not automatically substitute the row cwd. Resume must use the original worktree path and persisted control-plane binding. Claude discovery is cwd-keyed (`lib_py/agents/adapters/claude.py:117โ€“118`) and verification checks cwd (`:78`). Deleting or renaming a worktree needs an explicit recovery policy. | +| Delegate job | Python defaults honor both job/log overrides (`lib_py/delegation/mqtt_common.py:152โ€“186`). The shell wrapper instead defaults `REGISTRY_DIR` to relative `.mam/jobs` (`multi-agent-mux-delegate-job:71`, `:103`), changes cwd (`:153`), and passes that explicit registry argument (`:157`), overriding Python's default selection. | +| Loop | `lib_py/loop/runner.py:58โ€“60` prefers `MAM_REAL_ROOT` and derives `.mam` from it. It honors `AGENT_SESSIONS_YAML` (`:195`), but job waiting/report lookup use `self.mam_dir/jobs` (`:348`, `:391`), not `MAM_JOBS_DIR`. The shell freeze path resets roots to its repository (`multi-agent-mux-loop/scripts/run_loop.sh:134โ€“135`). | + +Amend ยง2-C's โ€œ... ๋„˜๊ธฐ๋ฉด ... ํ•œ ๊ณณ์— ๋ชจ์ธ๋‹คโ€ and ยง3's โ€œํ˜„์žฌ ์ฝ”๋“œ ๊ธฐ์ค€โ€ to describe **partial existing support plus required corrections**, not an already-safe environment recipe. Extend ยง5-3 beyond documentation to explicit propagation, persistence and a create/status/reconcile/stop/resume/delegate/loop compatibility matrix. + +### 3. A/B/C costs and alternatives โ€” ๋ณด์™„ + +C offers the best initial reuse of long-lived reviewer context while isolating writers. B leaves the principal creator checkout risk and gives little implementation parallelism. A provides stronger runtime separation at greater setup cost, but โ€œ์™„์ „ ๊ฒฉ๋ฆฌโ€ is too strong: git worktrees still share repository metadata/refs, and shared `.venv`, caches, services and credentials remain shared resources. โ€œํ˜„์žฌ ์ฝ”๋“œ๊ฐ€ ๊ทธ๋Œ€๋กœ ๋™์ž‘ํ•œ๋‹คโ€ needs qualification for worktree preparation and lifecycle behavior. + +The costs are capacity estimates, not established laws: five fresh agents per issue are a policy choice, and token use depends on actual work, not just the number of live sessions. Amend ยง2-A accordingly. A better variant of C is **persistent reviewer conversations with disposable per-review execution directories**, rather than requiring a fresh reviewer conversation for filesystem isolation. + +### 4. Missing/excess work and concurrency sequencing โ€” ๋ณด์™„; start with one loop + +ยง5-1 and ยง5-2 identify real blockers, but require broader definitions: + +- Absolute paths must include brief, report output and every lifecycle command's `--registry-dir`, with proper shell quoting. Current wrapper lines `:178`, `:199`, `:352`, `:455` emit relative report/brief paths. Merely changing the brief path leaves the start command reading the wrong relative registry from a creator's worktree. +- Introduce separate **control root**, **source/worktree root**, **runtime-code root**, and a review identity `(loop_id, base_sha, head_sha)`. Preserve that binding in jobs/checkpoints and verify it on resume. `runner.py:399โ€“408` collects diffs from the control workspace today, and `:564โ€“565` chooses the base there. Report promotion, discovery and cleanup also need intentional root ownership; do not globally redirect `WORKSPACE_ROOT` to fix diff collection. +- ยง5-4 must account for the existing single-loop guard: `run_loop.sh:144`, `:188โ€“200` rejects a second loop with the same marker before reviewer scheduling. This is an enforced boundary, not just the SKILL warning. Do not bypass it by assigning marker names alone. +- Checkpoints share one filename (`runner.py:60`); atomic replacement (`loop/checkpoint.py:18โ€“30`) prevents torn files but not last-writer replacement, and successful completion removes that same file (`runner.py:801`). Namespacing, recovery ownership and cleanup must be designed together. +- SQLite/YAML corruption is not an automatic consequence of two local writers: `lib_py/atomic_yaml.py:59โ€“86` already uses flock, busy timeout and `BEGIN IMMEDIATE`. Distinguish shared logical loop state and agent ownership from DB transaction safety, and test supported filesystem semantics instead of treating โ€œNFS lock shadowingโ€ as a complete explanation. +- Busy handling is not a queue: `runner.py:313โ€“328` sleeps and retries once. A scheduler needs exclusive reviewer assignment/lease, fair bounded waiting, release on failure, cancellation and crash recovery. Additional reviewers need explicit assignment policy; `--all-reviewer` otherwise includes the whole running reviewer pool. + +A safe first milestone is one guarded loop at a time, with separately delegated implementation jobs in distinct creator trees and queued review entry. The current loop itself does not become a multi-creator scheduler merely through a recipe. Defer multi-loop concurrency until its ownership contract and tests exist. + +### 5. Order relative to #17 item 4 โ€” ๋™์˜ with a staged rollout + +Agree with first specifying the root/identity contract, then absorbing it into the shell-layer reduction. Do not bless the current recipe as safe while deferring its correctness gaps. First revise this design, then implement the minimal absolute-path/propagation/diff-root fixes and test one-loop operation; use #17 item 4 to consolidate those semantics rather than creating a second set of shell-specific routing rules. Multi-loop scheduling follows separately. This assessment uses the proposal's description of #17 item 4; it does not claim an independent review of the remote issue discussion. + +## Additional failure paths to add to ยง5 + +1. **Worktree retirement:** stop/capture the creator first, retain its cwd/UUID and source SHA, finish/cancel owned jobs, then remove its execution tree through an explicit lifecycle. A stale YAML row is not evidence that a removed worktree can still resume. Preserve resumable worktrees or define recreation at the same path and revision. +2. **Test writes and environment isolation:** tests must not inherit production control-plane overrides. Give test registries, logs, monitor settings, ports and temporary directories isolated values. Shared `.venv` is only safe if dependency mutation is prohibited; dependencies differing by issue require separate environments. The proposed one-line preparation helper must validate preexisting paths/submodules rather than imply universal isolation. +3. **Central state is not a security boundary:** shared job credentials permit participation across these local processes. `mqtt_common.py:137`, `:351โ€“352` currently use `python/mqtt/jobs/`; the monitor also subscribes to a root fingerprint and the legacy wildcard (`reconcile.sh:288โ€“294`). Correct ยง2-C/ยง1 so it does not imply every job topic is fingerprinted by the worktree/control root. Preserve one canonical control identity, per-job HMAC and dispatch/attempt checks. Use an explicit central `MAM_ENV_FILE` policy instead of copying credentials into every tree by default. + +## Validation and adoption gate + +This was a read-only design/code-trace review. No create/resume/stop/status/monitor command, test suite, checkout switch or worktree mutation was run. No runtime compatibility is claimed from static tracing. The named document and commit were verified; only this report and authorized lifecycle events were written. + +Before adopting C, revise the cited sentences in ยงยง2โ€“5 and add acceptance cases for: sibling-worktree creation with a preexisting herdr server; explicit pane environment propagation; correct central brief/start/report routing; main remaining unchanged; fixed-SHA review under concurrent creator activity; row recovery and resume at the original cwd; isolated test state; and rejection of a second loop until a scheduler is implemented. These are targeted revisions within C, not a request to replace the design with A. + +[VERDICT: NOT PASS] diff --git a/docs/design/reviews/review-grok-2174eaaf.md b/docs/design/reviews/review-grok-2174eaaf.md new file mode 100644 index 0000000..9aee2d6 --- /dev/null +++ b/docs/design/reviews/review-grok-2174eaaf.md @@ -0,0 +1,69 @@ +# ๐Ÿ” Design Review โ€” Job 2174eaaf (`worktree-creator-model.md` @ `fe96db1`) + +- **Reviewer**: `reviewer-grok-01` ยท 2026-09-14 +- **๋ฌธ์„œ**: `multi-agent-mux-wt/design-worktree-model/docs/design/worktree-creator-model.md` (`git log -1` = `fe96db1`). ๋ณธ ์ฒดํฌ์•„์›ƒ์€ `main`. +- **์„ฑ๊ฒฉ**: ์„ค๊ณ„ ๋ฆฌ๋ทฐ. ์ฝ”๋“œยท๋ฌธ์„œ ๋ฏธ์ˆ˜์ •, ์ฒดํฌ์•„์›ƒ ๋ฏธ์ „ํ™˜. + +์ œ์•ˆ C(creator=์›ŒํฌํŠธ๋ฆฌ, ๋ฆฌ๋ทฐ์–ด ํ’€=๋ณธ ์ฒดํฌ์•„์›ƒ, `.mam` ๊ณต์œ )๋Š” ๋‘ ๋ฌธ์ œ(๋ณ‘๋ ฌ ๊ตฌํ˜„, ๊ณต์œ  ์ฒดํฌ์•„์›ƒ `checkout` ์‚ฌ๊ณ )๋ฅผ **์ฑ„ํƒ ๊ฐ€๋Šฅํ•œ ์ˆ˜์ค€์œผ๋กœ** ํ’€๋ฉฐ, ยง5 ์— ๋น ์ง„ ์‹คํŒจ ๊ฒฝ๋กœ๋ฅผ ๋ณด์™„ํ•˜๋ฉด ๊ตฌํ˜„์— ๋“ค์–ด๊ฐˆ ์ˆ˜ ์žˆ๋‹ค. + +## ยง6 ์งˆ๋ฌธ + +### 1. ์—ญํ•  ๋ถ„๋ฆฌ๊ฐ€ ๋‘ ๋ฌธ์ œ๋ฅผ ํ‘ธ๋Š”๊ฐ€? โ€” **๋™์˜ + ๋ณด์™„** + +- **๋ณ‘๋ ฌ์„ฑ**: creator ๊ฐ€ ์ด์Šˆ๋ณ„ cwd/๋ธŒ๋žœ์น˜๋ฅผ ๊ฐ€์ง€๋ฉด ๊ตฌํ˜„์€ ๋™์‹œ์— ์ง„ํ–‰๋œ๋‹ค. ๋ฆฌ๋ทฐ์–ด ํ’€์€ ์žก ๋‹จ์œ„ ์ง๋ ฌ(`session_busy` rc 5 โ†’ `MAM_SESSION_BUSY_RETRY_S` 1ํšŒ, `runner.py:313-314`). ๋ฌธ์„œ ยง4 ์„œ์ˆ ๊ณผ ๋งž๋‹ค. +- **checkout ์‚ฌ๊ณ **: ๋ณธ ์ฒดํฌ์•„์›ƒ์„ `main` ์— ๊ณ ์ •ํ•˜๊ณ  ์˜ค์ผ€์ŠคํŠธ๋ ˆ์ดํ„ฐยทcreator ์ปค๋ฐ‹์„ ์›ŒํฌํŠธ๋ฆฌ์—์„œ๋งŒ ํ•˜๋ฉด, ๊ณต์œ  `checkout` ํ•œ ๋ฐฉ์œผ๋กœ ์ „์›์ด ์›€์ง์ด๋Š” ๊ฒฝ๋กœ๋Š” ์‚ฌ๋ผ์ง„๋‹ค. +- **๋†“์นœ ๊ฒฝ๋กœ (๋ฌธ์„œ๋ฅผ ๊ณ ์น  ๊ณณ: ยง4 ํ•œ๊ณ„ / ยง5 ์ถ”๊ฐ€)**: + - ๋ฆฌ๋ทฐ์–ด๊ฐ€ `$WT` ์—์„œ pytest ๋ฅผ ๋Œ๋ฆฌ๋ฉด `.pytest_cache`/`__pycache__`/์‹คํŒจ ์‚ฐ์ถœ๋ฌผ์ด ์›ŒํฌํŠธ๋ฆฌ์— ๋‚จ์•„ creator ๊ฐ€ dirty ๋กœ ๋ณธ๋‹ค. PYTHONDONTWRITEBYTECODE, `--basetemp` ๋ฅผ WT ๋ฐ–, ๋ฆฌ๋ทฐ ํ›„ `git status` ์ œ์•ฝ์„ ยง5 ์— ์ ์„ ๊ฒƒ. + - ์›ŒํฌํŠธ๋ฆฌ ์‚ญ์ œ ์ „ `stop` ์—†์ด `worktree remove` ํ•˜๋ฉด yaml ํ–‰ยทherdr pane ์ด ๋‚จ๋Š”๋‹ค. ๋ ˆ์‹œํ”ผ์— stop โ†’ worktree remove ์ˆœ์„œ. + - ๋ชจ๋‹ˆํ„ฐ **์ž๋™๋“ฑ๋ก** (`reconcile.sh:651-654`) ์€ `pane.cwd` ๊ฐ€ `WORKSPACE_ROOT` ๋˜๋Š” ๊ทธ ํ•˜์œ„์ผ ๋•Œ๋งŒ ํƒ„๋‹ค. WT ๊ฐ€ `.../multi-agent-mux-wt/issue-20` ์ด๋ฉด MAIN ์˜ prefix ๊ฐ€ ์•„๋‹ˆ๋ฏ€๋กœ **๋ฏธ๋“ฑ๋ก herdr ์„ธ์…˜์€ ์ž๋™๋“ฑ๋ก๋˜์ง€ ์•Š๋Š”๋‹ค**. yaml ์— ์ด๋ฏธ ์žˆ๋Š” ํ–‰์€ ์ด ํ•„ํ„ฐ๋ฅผ ํƒ€์ง€ ์•Š๋Š”๋‹ค. โ€œcreate ๋ ˆ์‹œํ”ผ๋กœ๋งŒ ๋“ฑ๋กโ€์„ ยง3 ์— ๋ช…์‹œํ•  ๊ฒƒ. yaml ํ–‰์„ drift ๋กœ ์ง€์šฐ์ง€๋Š” ์•Š์Œ(์ด๋ฆ„ ๋งค์นญ). + +### 2. ์ œ์–ด ํ‰๋ฉด env override ๊ฐ€ ์•ˆ์ „ํ•œ๊ฐ€? โ€” **๋™์˜ + ๋ณด์™„ (ํ•„์ˆ˜)** + +| ๋ณ€์ˆ˜ | ๊ทผ๊ฑฐ | C ์•ˆ์—์„œ | +|---|---|---| +| `WORKSPACE_ROOT` | `lib.sh:26-27` ์ด๋ฏธ override | MAIN ์œผ๋กœ ๋‘๋ฉด yaml/jobs ๊ธฐ๋ณธ์ด MAIN | +| `AGENT_SESSIONS_YAML` | `lib.sh:34` | ๋ช…์‹œํ•˜๋ฉด ์•ˆ์ „. ๋ฏธ์„ค์ • ์‹œ `WORKSPACE_ROOT/.mam/...` | +| `MAM_JOBS_DIR` / `DELEGATE_JOB_LOGS_DIR` | `mqtt_common.py:150-163`, `186-187` โ€” `MAM_JOBS_DIR` ์šฐ์„ , ์•„๋‹ˆ๋ฉด `WORKSPACE_ROOT/.mam` | `WORKSPACE_ROOT=MAIN` ๋งŒ์œผ๋กœ๋„ jobs ๋Š” MAIN. ๋ฌธ์„œ์˜ ๋„ค ๋ณ€์ˆ˜๋Š” ๊ณผ์ž‰์ด ์•„๋‹ˆ๋ผ ๋ฐฉ์–ด. | + +- **status**: `status.sh:59-76` ์ด **ํ–‰์˜ `pane.cwd`** ๋กœ claude ํ”„๋กœ์ ํŠธ ํ‚ค๋ฅผ ๋งŒ๋“ ๋‹ค. WT cwd ํ–‰์„ MAIN `WORKSPACE_ROOT` ์™€ ํ˜ผ๋™ํ•˜์ง€ ์•Š์Œ. **๋™์˜.** +- **resume/stop**: `resume_session.sh:42,165` ์€ `--workspace` ๊ฐ€ pane ์ƒ์„ฑ cwd. `verify_session_uuid` (`verify_session.py:90-93`) ๋Š” `workspace_key(pane.cwd) != workspace_key(ws)` ์ด๋ฉด **์ฆ‰์‹œ False**. `find_workspace_uuid` (`workspace_uuid.py:57-59`) ๋„ pane.cwd ์™€ `--workspace` ์ผ์น˜๋งŒ ๋ณธ๋‹ค. ๋”ฐ๋ผ์„œ resume/stop ์˜ `--workspace` ๋Š” **MAIN ์ด ์•„๋‹ˆ๋ผ WT(pane.cwd)** ์—ฌ์•ผ ํ•œ๋‹ค. `WORKSPACE_ROOT`/yaml ์€ MAIN. **ยง3 ๋ ˆ์‹œํ”ผ์— ์ด ์ด์›์„ฑ์„ ์“ฐ์ง€ ์•Š์œผ๋ฉด resume ์ด ๊นจ์ง„๋‹ค.** ๊ณ ์น  ๊ณณ: ยง3 ์ฝ”๋“œ๋ธ”๋ก ๋‹ค์Œ ๋ฌธ๋‹จ. +- **claude ์ €์žฅ ํ‚ค**: `claude.py:117-118` `workspace_key(ctx.workspace)` โ†’ `~/.claude/projects/`. create `--workspace WT` ์ด๋ฉด ํ‚ค๊ฐ€ WT. resume ๋„ WT ๋ฅผ ๋„˜๊ฒจ์•ผ ํ•œ๋‹ค. ๋ฌธ์„œ ํ‘œ(ยง1)์™€ ๋งž์Œ. +- **ํ† ํ”ฝ ์ง€๋ฌธ**: `reconcile.sh:288-290` `sha256(abspath(WORKSPACE_ROOT))[:12]`. ๋ชจ๋‹ˆํ„ฐยทcreator ๊ฐ€ ๊ฐ™์€ `WORKSPACE_ROOT=MAIN` ์ด๋ฉด ์ง€๋ฌธ์ด ํ•˜๋‚˜. HMAC/`auth_token` ์€ job ๋ ˆ์ฝ”๋“œ ๋‹จ์œ„๋ผ ๊ณต์œ  ๋ ˆ์ง€์ŠคํŠธ๋ฆฌ์™€ ๋งž์Œ. ์›ŒํฌํŠธ๋ฆฌ์—์„œ override ์—†์ด reconcile ์„ ๋Œ๋ฆฌ๋ฉด ์ง€๋ฌธ์ด ๊ฐˆ๋ผ์ง„๋‹ค โ†’ ๋ชจ๋‹ˆํ„ฐ๋Š” ํ•ญ์ƒ MAIN ์—์„œ. + +### 3. A/B ๋Œ€๋น„ C ๋น„์šฉยท์ด๋“ โ€” **๋™์˜**. ๋” ์‹ผ 1๋‹จ๊ณ„๊ฐ€ ์žˆ๋‹ค. + +- A: ์ด์Šˆ๋‹น ์—์ด์ „ํŠธ 5๊ฐœยท๋ฌธ๋งฅ ๋ฆฌ์…‹ยท`.mam` ๋ถ„์‚ฐ. ๋น„์Œˆ. ๋™์˜. +- B: ๋ณ‘๋ ฌ ์—†์Œ + creator ๊ฐ€ ๋ณธ ์ฒดํฌ์•„์›ƒ์„ ์˜ฎ๊น€. #18 ์œ ํ˜• ์ž”์กด. ๋™์˜. +- C: ๊ตฌํ˜„ ๋ณ‘๋ ฌ + ๋ฆฌ๋ทฐ ๋ฌธ๋งฅ ์œ ์ง€ + ์ œ์–ด ํ‰๋ฉด ํ•˜๋‚˜. ๋งž๋‹ค. +- **๋Œ€์•ˆ D (๋น„์ฐจ๋‹จ)**: ๋ฆฌ๋ทฐ์–ด์—๊ฒŒ๋„ **๊ฐ™์€ ๋ธŒ๋žœ์น˜์˜ ์ฝ๊ธฐ ์ „์šฉ ์›ŒํฌํŠธ๋ฆฌ**๋ฅผ ์ฃผ๋ฉด MAIN ํ…Œ์ŠคํŠธ ์˜ค์—ผ์„ ์ค„์ธ๋‹ค. ๋น„์šฉโ†‘. 1๋‹จ๊ณ„ C ๋กœ ์‹œ์ž‘ํ•˜๊ณ  D ๋Š” ์„ ํƒ. +- **๋Œ€์•ˆ E** (ํ›…์œผ๋กœ main checkout ๊ธˆ์ง€): ๋ณ‘๋ ฌ์„ ์•ˆ ์คŒ. C ๋ฅผ ๋Œ€์ฒดํ•˜์ง€ ๋ชปํ•จ. + +### 4. ยง5 ๋ˆ„๋ฝยท๊ณผ์ž‰ / ๋‹ค์ค‘ ๋ฃจํ”„๋ฅผ ๋จผ์ €? โ€” **๋ณด์™„** + +| ยง5 | ํŒ์ • | +|---|---| +| 1 ๋ธŒ๋ฆฌํ”„ ์ ˆ๋Œ€๊ฒฝ๋กœ | **ํ•„์ˆ˜, ๋ฒ”์œ„ ๋ถ€์กฑ.** `multi-agent-mux-delegate-job:199` ์ฃผ์ž…์ด `.mam/jobs//brief.md` ์ƒ๋Œ€๊ฒฝ๋กœ. creator cwd=WT ์—๋Š” ์—†์Œ. ๊ฐ™์€ ํŒŒ์ผ `:178` **Output Report Path** ๋„ ์ƒ๋Œ€๊ฒฝ๋กœ. ๋ฃจํ”„ `find_report` (`runner.py:389-394`) ๋Š” `self.mam_dir/jobs/` = MAIN. creator ๊ฐ€ WT ์— ๋ฆฌํฌํŠธ๋ฅผ ์“ฐ๋ฉด ๋ฃจํ”„๊ฐ€ ๋ชป ์ฐพ๋Š”๋‹ค. **brief ์™€ report-final.md ๋ชจ๋‘ `$REGISTRY_DIR` ์ ˆ๋Œ€๊ฒฝ๋กœ.** | +| 2 `--worktree` | **ํ•„์ˆ˜.** `collect_changes_diff` `runner.py:399-408` ๊ณผ `base_commit` `runner.py:564-565` ๊ฐ€ `cwd=self.workspace_root`(MAIN HEAD). ์›ŒํฌํŠธ๋ฆฌ ๋ธŒ๋žœ์น˜ diff ๊ฐ€ ์•„๋‹˜. ์ธ์šฉ ์ •ํ™•. | +| 3 create ๋ ˆ์‹œํ”ผ | **ํ•„์ˆ˜.** resume `--workspace $WT` ์ด์›์„ฑ ์ถ”๊ฐ€. | +| 4 `.mam` ๊ฒฝํ•ฉ | **๋‘ `run_loop.sh` ์ „์— ํ•„์ˆ˜, 1๋‹จ๊ณ„์—๋Š” ๊ณผ์ž‰ ๊ฐ€๋Šฅ.** `loop-checkpoint.json` (`runner.py:60`)ยท`loop-guard-active` (`:427`) ๋Š” ํŒŒ์ผ ํ•˜๋‚˜. ๋‘ ๋ฃจํ”„๋Š” ์ฒดํฌํฌ์ธํŠธ๋ฅผ ๋ฎ์–ด์“ด๋‹ค. **์‹œ์ž‘์€ โ€œ๋ฃจํ”„๋Š” ํ•˜๋‚˜, creator ์žก์€ delegate-job ์œผ๋กœ๋งŒ ๋ณ‘๋ ฌโ€.** ์ด์Šˆ๋‹น ๋ฃจํ”„๋ฅผ ๋™์‹œ์— ๋Œ๋ฆฌ๋ ค๋ฉด ยง5-4 ๊ฐ€ ์„ ํ–‰. ยง4 ๋ฌธ์žฅ์— ์ด ๋‹จ๊ณ„๋ฅผ ๋ฐํž ๊ฒƒ. | +| 5 ๋ธŒ๋ฆฌํ”„ ํ•„๋“œ | ํ•„์ˆ˜. | + +### 5. #17 ํ•ญ๋ชฉ 4 ์™€์˜ ์ˆœ์„œ โ€” **๋™์˜** + +์šด์˜ ๋ชจ๋ธ(C)์„ ๋ฌธ์„œยท๋ ˆ์‹œํ”ผ๋กœ ๊ณ ์ •ํ•œ ๋’ค, ํ•ญ๋ชฉ 4(์…ธ ๊ณ„์ธต ์ถ•์†Œ)์—์„œ `--worktree`ยท์ ˆ๋Œ€ ๋ธŒ๋ฆฌํ”„/๋ฆฌํฌํŠธ ๊ฒฝ๋กœ๋ฅผ ์ฝ”๋“œ๋กœ ํก์ˆ˜ํ•˜๋Š” ์ˆœ์„œ๊ฐ€ ๋งž๋‹ค. ํ•ญ๋ชฉ 4 ๋ฅผ ๋จผ์ € ํ•˜๋ฉด ์›ŒํฌํŠธ๋ฆฌ cwd ๊ฐ€ ๋‹ค์‹œ ๊นจ์ง„๋‹ค. + +## ๋ธŒ๋ฆฌํ”„ (1)โ€“(7) ์š”์•ฝ + +| # | ํŒ์ • | +|---|---| +| (1) env ์ผ๊ด€์„ฑ | ๋™์˜. resume/stop `--workspace`=pane.cwd(WT). status ๋Š” pane.cwd ์‚ฌ์šฉ. | +| (2) ์ƒ๋Œ€ brief | ๋™์˜, ๊นจ์ง. ๋ฆฌํฌํŠธ ๊ฒฝ๋กœ๋„ ๊นจ์ง. | +| (3) diff/base | ๋™์˜, `runner.py:399-408`ยท`564-565`. | +| (4) ๋‹ค์ค‘ ๋ฃจํ”„ | ์‹ค์œ„ํ—˜. 1๋‹จ๊ณ„๋Š” ๋ฃจํ”„ ํ•˜๋‚˜. | +| (5) ์‹คํŒจ ๊ฒฝ๋กœ | pytest ์˜ค์—ผ, yaml ์ž”ํ–‰, ๋ชจ๋‹ˆํ„ฐ ์ž๋™๋“ฑ๋ก prefix, ์ž˜๋ชป๋œ WORKSPACE ๋กœ resume. ํ† ํ”ฝ/HMAC ์€ ๊ณต์œ  `.mam` ์ด๋ฉด ์•ˆ์ „. | +| (6) A/B/C | ํƒ€๋‹น. D ๋Š” ํ›„์†. | +| (7) #17-4 | ๋ฌธ์„œ ๋จผ์ €. | + +์ฑ„ํƒ ๊ฐ€๋Šฅ. ๊ทผ๋ณธ ๊ฒฐํ•จ ์—†์Œ. ๊ตฌํ˜„ ์ „ ยง3ยทยง5-1ยทยง5-4 ๋ฌธ๊ตฌ๋ฅผ ๊ณ ์น˜๋ฉด ๋œ๋‹ค. + +[VERDICT: PASS] diff --git a/docs/design/worktree-creator-model.md b/docs/design/worktree-creator-model.md new file mode 100644 index 0000000..4425536 --- /dev/null +++ b/docs/design/worktree-creator-model.md @@ -0,0 +1,127 @@ +# ์›ŒํฌํŠธ๋ฆฌ ์šด์˜ ๋ชจ๋ธ ์ œ์•ˆ โ€” creator ๋งŒ ์›ŒํฌํŠธ๋ฆฌ, ๋ฆฌ๋ทฐ์–ด๋Š” ๊ณต์œ  (Design Proposal) + +- ์ƒํƒœ: ๋ฆฌ๋ทฐ ๋ฐ˜์˜(4๊ฑด, docs/design/reviews/) โ€” 1๋‹จ๊ณ„ ์„ ํ–‰ ์ž‘์—… ๊ตฌํ˜„(Issue #21 ํ•ญ๋ชฉ 1ยท2ยท3ยท6) +- ์ž‘์„ฑ: ์˜ค์ผ€์ŠคํŠธ๋ ˆ์ดํ„ฐ ์„ธ์…˜ `dbe6dd10-a210-4b46-b613-58a1860499b6`, 2026-09-14 +- ๋ฐฐ๊ฒฝ ์ด์Šˆ: #17 ํ•ญ๋ชฉ 4(์…ธ ๊ณ„์ธต ์ถ•์†Œ)ยท#18 ์‚ฌํ›„ ๋ถ„์„(์ฒดํฌ์•„์›ƒ ์ „ํ™˜ ์‚ฌ๊ณ ) + +## 1. ๋ฌธ์ œ + +MAM ์€ **์›Œํฌ์ŠคํŽ˜์ด์Šค = `.agents/skills` ๊ฐ€ ๋“ค์–ด ์žˆ๋Š” ์ฒดํฌ์•„์›ƒ ๊ฒฝ๋กœ** ๋ผ๋Š” ๊ฐ€์ • ์œ„์— ์„œ ์žˆ๋‹ค. + +| ๊ฒฝ๋กœ์—์„œ ํŒŒ์ƒ๋˜๋Š” ๊ฒƒ | ๊ทผ๊ฑฐ | +|---|---| +| `WORKSPACE_ROOT` | `lib.sh:26` โ€” `${WORKSPACE_ROOT:-$SKILL_DIR/../..}` | +| `.mam/` (agent-sessions.yaml, jobs ๋ ˆ์ง€์ŠคํŠธ๋ฆฌ, ๊ฐ์‚ฌ ๋กœ๊ทธ, monitor.lock) | `$WORKSPACE_ROOT/.mam` (gitignore ๋Œ€์ƒ) | +| herdr ์„œ๋ฒ„ ์ด๋ฆ„ | `resolve_herdr_session` โ€” ์›Œํฌ์ŠคํŽ˜์ด์Šค ๊ฒฝ๋กœ ์Šฌ๋Ÿฌ๊ทธ, `--herdr-session` ์œผ๋กœ override | +| ๋ชจ๋‹ˆํ„ฐ ํ† ํ”ฝ ์ง€๋ฌธ | `reconcile.sh:288-290` โ€” `sha256(abspath(workspace_root))[:12]`. ๋ชจ๋‹ˆํ„ฐ ๊ตฌ๋…์—๋งŒ ์“ฐ์ธ๋‹ค. ์žก ํ† ํ”ฝ์€ ๋ ˆ์ฝ”๋“œ์˜ topic_prefix `python/mqtt/jobs/`(mqtt_common.py:137, :351), HMAC ์€ ๋ ˆ์ฝ”๋“œ์˜ auth_token ์ด๋‹ค | +| claude ๋Œ€ํ™” ์ €์žฅ ํ‚ค | `~/.claude/projects/` โ€” resume ๋Š” ๊ฐ™์€ ๋””๋ ‰ํ„ฐ๋ฆฌ์—ฌ์•ผ ํ•จ | + +๊ทธ ๊ฒฐ๊ณผ ๋‘ ๊ฐ€์ง€ ๋ฌธ์ œ๊ฐ€ ์žˆ๋‹ค. + +1. **๋™์‹œ ์ด์Šˆ ์ž‘์—…์ด ๋ถˆ๊ฐ€๋Šฅํ•˜๋‹ค.** ํŒ€ ๋ฆฌ๋” ์—์ด์ „ํŠธ 5๊ฐœ์™€ ์˜ค์ผ€์ŠคํŠธ๋ ˆ์ดํ„ฐ๊ฐ€ ํ•˜๋‚˜์˜ ์ฒดํฌ์•„์›ƒ์„ ๊ณต์œ ํ•˜๋ฏ€๋กœ ๋ธŒ๋žœ์น˜๋Š” ํ•œ ๋ฒˆ์— ํ•˜๋‚˜๋งŒ ์ฒดํฌ์•„์›ƒ๋œ๋‹ค. ์ด์Šˆ A ์˜ ๋ฆฌ๋ทฐ ์ค‘ ์ด์Šˆ B ์˜ ๊ตฌํ˜„์„ ์‹œ์ž‘ํ•  ์ˆ˜ ์—†๋‹ค. +2. **์ฒดํฌ์•„์›ƒ ์ „ํ™˜ ์‚ฌ๊ณ ๊ฐ€ ๋‚œ๋‹ค.** #18 ์ž‘์—… ์ค‘ ๋ธŒ๋žœ์น˜ ์ƒ์„ฑ 1๋ถ„ ๋’ค HEAD ๊ฐ€ `main` ์œผ๋กœ ๋˜๋Œ์•„๊ฐ€(reflog `moving from fix/issue-18 to main`, 18:32) ์ดํ›„ 12๊ฐœ ์ปค๋ฐ‹์ด `main` ์— ์ง์ ‘ ์Œ“์˜€๋‹ค. ๋ณ‘ํ•ฉ ์ปค๋ฐ‹ ์—†์ด push ๋˜์–ด ์ด๋ ฅ์„ ๋˜๋Œ๋ฆฌ์ง€ ์•Š์•˜๋‹ค. ๊ณต์œ  ์ฒดํฌ์•„์›ƒ์—์„œ๋Š” ์–ด๋–ค ํ”„๋กœ์„ธ์Šค๋“  `checkout` ํ•˜๋‚˜๋กœ ๋‹ค๋ฅธ ๋ชจ๋“  ์ฐธ์—ฌ์ž์˜ ์ปค๋ฐ‹ ์œ„์น˜๋ฅผ ๋ฐ”๊ฟ€ ์ˆ˜ ์žˆ๋‹ค. + +## 2. ํ›„๋ณด์•ˆ + +### A. ๋ชจ๋‘ ์›ŒํฌํŠธ๋ฆฌ๋ณ„ (์˜ค์ผ€์ŠคํŠธ๋ ˆ์ดํ„ฐ + creator + ๋ฆฌ๋ทฐ์–ด) +- ์žฅ์ : ๋Ÿฐํƒ€์ž„ ๋ถ„๋ฆฌ๊ฐ€ ๊ฐ€์žฅ ๊ฐ•ํ•˜๋‹ค(๋‹จ git ์›ŒํฌํŠธ๋ฆฌ๋Š” refsยท๊ฐ์ฒด ์ €์žฅ์†Œ๋ฅผ ๊ณต์œ ํ•˜๊ณ , `.venv`ยท์บ์‹œยท์ž๊ฒฉ์ฆ๋ช…ยท๋ธŒ๋กœ์ปค๋„ ๊ณต์œ  ์ž์›์ด๋‹ค). ์›ŒํฌํŠธ๋ฆฌ ์ค€๋น„ยท์ˆ˜๋ช… ์ฒ˜๋ฆฌ๋Š” ๋ณ„๋„๋กœ ํ•„์š”ํ•˜๋‹ค. +- ๋‹จ์ : ์ด์Šˆ๋งˆ๋‹ค ์—์ด์ „ํŠธ๋ฅผ ์ƒˆ๋กœ ๋„์šด๋‹ค. ์šฉ๋Ÿ‰ ์ถ”์ •: ์ด์Šˆ๋‹น 5๊ฐœ ์—์ด์ „ํŠธ๋Š” ์šด์˜ ์ •์ฑ…์ด๊ณ , ํ† ํฐ ์‚ฌ์šฉ์€ ์‚ด์•„ ์žˆ๋Š” ์„ธ์…˜ ์ˆ˜๊ฐ€ ์•„๋‹ˆ๋ผ ์‹ค์ œ ์ž‘์—…๋Ÿ‰์— ๋น„๋ก€ํ•œ๋‹ค. ๋ฆฌ๋ทฐ์–ด ๋Œ€ํ™” ๋ฌธ๋งฅ์ด ์ด์Šˆ๋งˆ๋‹ค ์ดˆ๊ธฐํ™”๋œ๋‹ค. `.mam`ยท๋ชจ๋‹ˆํ„ฐยทํ† ํ”ฝ ์ง€๋ฌธ์ด ์ด์Šˆ๋งˆ๋‹ค ํฉ์–ด์ ธ ๊ด€์ธก์ด ๋ถ„์‚ฐ๋œ๋‹ค. + +### B. ์˜ค์ผ€์ŠคํŠธ๋ ˆ์ดํ„ฐ ์ปค๋ฐ‹๋งŒ ์›ŒํฌํŠธ๋ฆฌ, ์—์ด์ „ํŠธ๋Š” ๋ณธ ์ฒดํฌ์•„์›ƒ +- ์žฅ์ : ์˜ค์ผ€์ŠคํŠธ๋ ˆ์ดํ„ฐ ์ปค๋ฐ‹์€ ๊ฒฉ๋ฆฌ๋œ๋‹ค. +- ๋‹จ์ : **๋ณ‘๋ ฌ์„ฑ์ด ์—†๋‹ค.** ์—์ด์ „ํŠธ๋Š” ๋Œ€ํ™” ํ•˜๋‚˜์งœ๋ฆฌ ํ”„๋กœ์„ธ์Šค๋ผ ์žก์„ ํ•˜๋‚˜์”ฉ๋งŒ ์ฒ˜๋ฆฌํ•˜๊ณ , ๋‘ ๋ฃจํ”„๊ฐ€ ๊ฐ™์€ creatorยท๋ฆฌ๋ทฐ์–ด๋ฅผ ๋‘๊ณ  `session_busy`(rc 5) ๋กœ ๊ฒฝํ•ฉํ•ด ์ง๋ ฌํ™”๋œ๋‹ค. creator ๋Š” ์—ฌ์ „ํžˆ ๋ณธ ์ฒดํฌ์•„์›ƒ์—์„œ ๋ธŒ๋žœ์น˜๋ฅผ ์˜ฎ๊ฒจ ์ปค๋ฐ‹ํ•ด์•ผ ํ•˜๋ฏ€๋กœ #18 ์‚ฌ๊ณ  ์œ ํ˜•์ด ๊ทธ๋Œ€๋กœ ๋‚จ๋Š”๋‹ค. ๋ฆฌ๋ทฐ์–ด๊ฐ€ ๊ฒ€์ฆํ•˜๋Š” ํŠธ๋ฆฌ๊ฐ€ ๋ธŒ๋žœ์น˜ ํŠธ๋ฆฌ๊ฐ€ ์•„๋‹ˆ๋‹ค. + +### C. (์ œ์•ˆ) creator ๋งŒ ์›ŒํฌํŠธ๋ฆฌ, ๋ฆฌ๋ทฐ์–ด ํ’€์€ ๊ณต์œ , ์ œ์–ด ํ‰๋ฉด์€ ํ•˜๋‚˜ +- **creator = ์›ŒํฌํŠธ๋ฆฌ ์ „์šฉ ์ธ์Šคํ„ด์Šค.** ์ œํ’ˆ ์ฝ”๋“œ๋ฅผ ํŽธ์ง‘ยท์ปค๋ฐ‹ํ•˜๋Š” ์œ ์ผํ•œ ์—์ด์ „ํŠธ์ด๋ฏ€๋กœ cwd ๊ฐ€ ๊ณง ์ด์Šˆ ๋ธŒ๋žœ์น˜์—ฌ์•ผ ํ•œ๋‹ค. ์˜ค์ผ€์ŠคํŠธ๋ ˆ์ดํ„ฐ ์ปค๋ฐ‹๊ณผ ๋ฃจํ”„์˜ ๋ฆฌํฌํŠธ ์Šน๊ฒฉ(`runner.py` ์˜ Promotion ๋ธ”๋ก โ†’ ์ œ์–ด ๋ฃจํŠธ `.agents/reports/`)์€ ๋ณ„๋„ ์†Œ์œ ๋‹ค. ์ด์Šˆ๋งˆ๋‹ค `creator--` ๋กœ ๋งŒ๋“ค๊ณ  ๋๋‚˜๋ฉด ์ •์ง€(๋Œ€ํ™” ๋ณด์กด, ํ•„์š” ์‹œ ์žฌ๊ฐœ). ์žฌ๊ฐœํ•˜๋ ค๋ฉด ๊ฐ™์€ ์›ŒํฌํŠธ๋ฆฌ ๊ฒฝ๋กœ๊ฐ€ ์žˆ์–ด์•ผ ํ•œ๋‹ค(๋Œ€ํ™” ํ‚ค๊ฐ€ cwd). +- **๋ฆฌ๋ทฐ์–ด ํ’€ = ๋ณธ ์ฒดํฌ์•„์›ƒ ๊ณต์œ .** ๋ฆฌ๋ทฐ์–ด๋Š” ์†Œ์Šค๋ฅผ ํŽธ์ง‘ํ•˜์ง€ ์•Š๋Š”๋‹ค. ๊ทธ๋Ÿฌ๋‚˜ ํ…Œ์ŠคํŠธ ์‹คํ–‰์€ ์“ฐ๊ธฐ๋ฅผ ๋™๋ฐ˜ํ•˜๋ฏ€๋กœ '์ฝ๊ธฐ ์ „์šฉ'์ด ์•„๋‹ˆ๋‹ค. ๊ฒ€์ฆ ๊ฒฉ๋ฆฌ(head SHA detached ์›ŒํฌํŠธ๋ฆฌ ๋˜๋Š” ๊ฒฉ๋ฆฌ ์˜ต์…˜)์™€ (loop_id, base, head) ํŒ์ • ๊ฒฐ์†์€ ํ•ญ๋ชฉ 5 ์—์„œ ์ •ํ•œ๋‹ค. 1๋‹จ๊ณ„ ๋ธŒ๋ฆฌํ”„๋Š” ๋Ÿฌ๋„ˆ๊ฐ€ ๊ฒฝ๋กœยทbaseยทheadยท์ปค๋ฐ‹ ์ˆ˜๋ฅผ ์ž๋™ ์‚ฝ์ž…ํ•œ๋‹ค(`--worktree`/`--base`). ๋ฆฌ๋ทฐ๊ฐ€ ๋ณ‘๋ชฉ์ด๋ฉด ๋ฆฌ๋ทฐ์–ด ์ธ์Šคํ„ด์Šค๋ฅผ ์ถ”๊ฐ€ํ•œ๋‹ค. +- **์˜ค์ผ€์ŠคํŠธ๋ ˆ์ดํ„ฐ ์ปค๋ฐ‹ = ์›ŒํฌํŠธ๋ฆฌ.** ๋ณธ ์ฒดํฌ์•„์›ƒ์€ ๋ฆฌ๋ทฐ์–ด์˜ ์ž‘์—… ๊ณต๊ฐ„์ด๋ฏ€๋กœ ์˜ค์ผ€์ŠคํŠธ๋ ˆ์ดํ„ฐ๋Š” ๊ทธ๊ณณ์—์„œ ๋ธŒ๋žœ์น˜๋ฅผ ๋ฐ”๊พธ์ง€ ์•Š๋Š”๋‹ค. +- **์ œ์–ด ํ‰๋ฉด = ๋ณธ ์ฒดํฌ์•„์›ƒ์˜ `.mam` ํ•˜๋‚˜.** create/resume ์ด ๋ฐ”์ธ๋”ฉ(`WORKSPACE_ROOT`ยท`AGENT_SESSIONS_YAML`ยท`MAM_JOBS_DIR`ยท`DELEGATE_JOB_LOGS_DIR`)์„ ํ–‰(`control_plane`)์— ์˜์†ํ•˜๊ณ , `--workspace` ๊ธฐ๋ณธ๊ฐ’๊ณผ ๋‹ค๋ฅด๋ฉด pane ์— ๋ช…์‹œ ์ „ํŒŒํ•œ๋‹ค(ํ•ญ๋ชฉ 3). ํ˜ธ์ถœ์ž env ๋งŒ์œผ๋กœ๋Š” pane ์ƒ์†์ด ๋ณด์žฅ๋˜์ง€ ์•Š๋Š”๋‹ค(lib.sh:667-680 โ€” new-session ์€ ์„ ํ–‰ `K=V` ํ† ํฐ๋งŒ `--env` ๋กœ ๋ฐ”๊พผ๋‹ค). ํ–‰์˜ `pane.cwd` ๋Š” ์›ŒํฌํŠธ๋ฆฌ๋‹ค. + +## 3. C ์•ˆ์˜ ๋™์ž‘ ๋ฐฉ์‹ (๋ถ€๋ถ„ ์ง€์› + 1๋‹จ๊ณ„ ์ˆ˜์ • ๋ฐ˜์˜) + +```bash +MAIN=/path/to/multi-agent-mux # ๋ณธ ์ฒดํฌ์•„์›ƒ: ๋ฆฌ๋ทฐ์–ด ํ’€ + .mam (์ œ์–ด ๋ฃจํŠธ) +WT=/path/to/multi-agent-mux-wt/issue-20 # ์ด์Šˆ ์›ŒํฌํŠธ๋ฆฌ (์†Œ์Šค ๋ฃจํŠธ) +git -C "$MAIN" worktree add -b fix/issue-20 "$WT" main +ln -s "$MAIN/.venv" "$WT/.venv"; git -C "$WT" submodule update --init # ์›ŒํฌํŠธ๋ฆฌ ์ค€๋น„(.venv, nats-docker) โ€” ํ—ฌํผ๋Š” ํ•ญ๋ชฉ 4 + +# creator ๋ฅผ ์›ŒํฌํŠธ๋ฆฌ cwd ๋กœ, ๊ทธ๋Ÿฌ๋‚˜ ๋ณธ ์ฒดํฌ์•„์›ƒ์˜ .mam ์— ๋“ฑ๋ก. +# ๋ฐ”์ธ๋”ฉ์€ ํ–‰์˜ control_plane ์— ๋‚จ๊ณ , --workspace ๊ธฐ๋ณธ๊ฐ’๊ณผ ๋‹ค๋ฅด๋ฏ€๋กœ pane ์— --env ๋กœ ์ „ํŒŒ๋œ๋‹ค. +WORKSPACE_ROOT="$MAIN" AGENT_SESSIONS_YAML="$MAIN/.mam/agent-sessions.yaml" \ +MAM_JOBS_DIR="$MAIN/.mam/jobs" DELEGATE_JOB_LOGS_DIR="$MAIN/.mam/delegate_job_logs" \ + bash "$MAIN/.agents/skills/multi-agent-mux-create/scripts/create_session.sh" \ + --workspace "$WT" --agent claude --role Creator --session creator-claude-issue20 \ + --herdr-session multi-agent-mux --herdr-workspace mam-agent + +# (a) ๋ฃจํ”„๊ฐ€ ๊ตฌํ˜„๊นŒ์ง€ ํ•œ๋‹ค: base ๋Š” Phase 2 ์‹œ์ž‘ ์‹œ์ ์˜ ์›ŒํฌํŠธ๋ฆฌ HEAD +cd "$MAIN" && bash .agents/skills/multi-agent-mux-loop/scripts/run_loop.sh \ + --worktree "$WT" --creator creator-claude-issue20 --all-reviewer --task "Issue #20 ..." + +# (b) ๊ตฌํ˜„์€ ๋ฃจํ”„ ๋ฐ–(1๋‹จ๊ณ„ ๊ธฐ๋ณธ): base ๋ฅผ ๊ตฌํ˜„ ์œ„์ž„ **์ „์—** ๊ธฐ๋กํ•œ๋‹ค +BASE=$(git -C "$WT" rev-parse HEAD) +bash "$MAIN/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job" submit --type direct \ + --agent claude --agent-session herdr:creator-claude-issue20 --workdir "$MAIN" --prompt "Issue #20 ..." +# ... creator ๊ฐ€ $WT ์—์„œ ๊ตฌํ˜„ยท์ปค๋ฐ‹ ... +cd "$MAIN" && bash .agents/skills/multi-agent-mux-loop/scripts/run_loop.sh \ + --worktree "$WT" --base "$BASE" --review-only --creator creator-claude-issue20 --all-reviewer --task "Issue #20 review" + +# ์ •์ง€ยท์žฌ๊ฐœ: --workspace ๋Š” ์›๋ž˜ ์›ŒํฌํŠธ๋ฆฌ ๊ฒฝ๋กœ. ์žฌ๊ฐœ๋Š” ํ–‰์— ์˜์†๋œ ๋ฐ”์ธ๋”ฉ์„ ๋‹ค์‹œ ์ „ํŒŒํ•œ๋‹ค +bash "$MAIN/.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh" --session creator-claude-issue20 +bash "$MAIN/.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh" \ + --workspace "$WT" --agent claude --session creator-claude-issue20 +``` + +- `--task` ์— base ๋ฅผ ์ ์ง€ ๋ง ๊ฒƒ. ๋Ÿฌ๋„ˆ๊ฐ€ ๋„ฃ๋Š” base ์™€ ๋‘˜์ด ๋œ๋‹ค. ๋ฆฌ๋ทฐ์–ด๊ฐ€ ๋ฐ›๋Š” base ๋Š” ๋Ÿฌ๋„ˆ์˜ `[์›ŒํฌํŠธ๋ฆฌ ์ปจํ…์ŠคํŠธ]` ๋ธ”๋ก ํ•˜๋‚˜๋ฟ์ด์–ด์•ผ ํ•œ๋‹ค. +- ๋ชจ๋‹ˆํ„ฐ(`reconcile.sh`)๋Š” ๋ณธ ์ฒดํฌ์•„์›ƒ์—์„œ๋งŒ ๋„์šด๋‹ค. +- ๋ธŒ๋ฆฌํ”„ยท๋ฐœํ–‰ ๋ช…๋ น(`--registry-dir`, ๊ฐ์‚ฌ ๋กœ๊ทธ ๋ฃจํŠธ `DELEGATE_JOB_LOGS_DIR`)ยทOutput Report Path ๋ฅผ ์ œ์–ด ํ‰๋ฉด ์ ˆ๋Œ€๊ฒฝ๋กœ๋กœ ๋ฐ”๊ฟจ๋‹ค(ํ•ญ๋ชฉ 1). creator ๋Š” ์›ŒํฌํŠธ๋ฆฌ cwd ์—์„œ ์ฃผ์ž…๋œ ๋ช…๋ น์„ ๊ทธ๋Œ€๋กœ ์‹คํ–‰ํ•˜๋ฉด ๋œ๋‹ค. +- ๋ฃจํ”„ ๋Ÿฌ๋„ˆ `--worktree` ๊ฐ€ ์ถ”๊ฐ€๋๋‹ค: diffยทbaseยทhead ๋งŒ ์†Œ์Šค ๋ฃจํŠธ ๊ธฐ์ค€์ด๊ณ , wrapperยท๋ ˆ์ง€์ŠคํŠธ๋ฆฌยท์ฒดํฌํฌ์ธํŠธยท์Šน๊ฒฉ์€ ์ œ์–ด ๋ฃจํŠธ๋‹ค. `--worktree`/`--base` ๊ฐ€ ์žˆ์œผ๋ฉด ๋นˆ ๋ณ€๊ฒฝ๋ถ„(๋˜๋Š” ํŒ์ • ๋ถˆ๊ฐ€)์— ๋ฆฌ๋ทฐ๋ฅผ ์š”์ฒญํ•˜์ง€ ์•Š๋Š”๋‹ค(fail-closed). `--base` ๋Š” ์†Œ์Šค HEAD ์˜ ์กฐ์ƒ์ด์–ด์•ผ ํ•˜๊ณ  env ํด๋ฐฑ์ด ์—†๋‹ค. +- ์˜ค์ผ€์ŠคํŠธ๋ ˆ์ดํ„ฐ ์ปค๋ฐ‹ ๊ทœ์น™: ์ปค๋ฐ‹ ์ „ `git branch --show-current` ํ™•์ธ(๋ฉ”๋ชจ๋ฆฌ ๊ทœ์น™), ๋ณธ ์ฒดํฌ์•„์›ƒ์—์„œ๋Š” `main` ์œ ์ง€. + +## 4. ๊ธฐ๋Œ€ ํšจ๊ณผ์™€ ํ•œ๊ณ„ + +- ๋ณ‘๋ ฌํ™” ์ด๋“์€ **๊ตฌํ˜„ ๋‹จ๊ณ„**์—์„œ ํฌ๋‹ค: ์›ŒํฌํŠธ๋ฆฌ๋งˆ๋‹ค creator ๊ฐ€ ๋™์‹œ์— ์ž‘์—…ํ•˜๊ณ , ์ „์ฒด ์Šค์œ„ํŠธ๋„ ์›ŒํฌํŠธ๋ฆฌ๋งˆ๋‹ค ๋™์‹œ์— ๋ˆ๋‹ค. +- **๋ฆฌ๋ทฐ ๋‹จ๊ณ„๋Š” ํ’€ ํฌ๊ธฐ๋งŒํผ๋งŒ ๋ณ‘๋ ฌ**์ด๋‹ค. ์ด์Šˆ 3๊ฐœ๋ฅผ ๋™์‹œ์— ๋Œ๋ ค๋„ ๋ฆฌ๋ทฐ์–ด ํ•˜๋‚˜๋Š” ์žก์„ ์ˆœ์„œ๋Œ€๋กœ ์ฒ˜๋ฆฌํ•œ๋‹ค(๋ž˜ํผ๋Š” busy ์„ธ์…˜์— rc 5 โ†’ ๋Ÿฌ๋„ˆ๊ฐ€ `MAM_SESSION_BUSY_RETRY_S` ๋’ค 1ํšŒ ์žฌ์‹œ๋„ โ†’ ๊ทธ๋ž˜๋„ busy ๋ฉด ์‹คํŒจ). ๋‹ค์ค‘ ๋ฃจํ”„ ๋™์‹œ ๊ธฐ๋™์—๋Š” **๋ฆฌ๋ทฐ์–ด ํ์ž‰ ๋˜๋Š” ๋ฆฌ๋ทฐ์–ด ์ธ์Šคํ„ด์Šค ์ฆ์„ค**์ด ์ „์ œ๋‹ค. +- ๊ฐ™์€ `.mam` ์˜ ๋‘ ๋ฒˆ์งธ ๋ฃจํ”„๋Š” ๊ฒฝ๊ณ ๊ฐ€ ์•„๋‹ˆ๋ผ **๊ฑฐ๋ถ€**๋‹ค: loop lock ์ด exit 1 ๋กœ ๋ง‰๋Š”๋‹ค(`run_loop.sh` ์˜ `mam_acquire_loop_lock` ๋ถ„๊ธฐ). ์ฒดํฌํฌ์ธํŠธยทloop guard ๋„ ํŒŒ์ผ ํ•˜๋‚˜๋‹ค(`runner.py` ์˜ `checkpoint_file`, `MAM_LOOP_MARKER`). YAML/SQLite ๋Š” flockยท`BEGIN IMMEDIATE` ๋กœ ํŠธ๋žœ์žญ์…˜ ์•ˆ์ „(atomic_yaml.py)ํ•˜๋ฏ€๋กœ, ๋ฌธ์ œ๋Š” DB ์†์ƒ์ด ์•„๋‹ˆ๋ผ **๊ณต์œ  ๋…ผ๋ฆฌ ์ƒํƒœ์™€ ๋ฆฌ๋ทฐ์–ด ์†Œ์œ ๊ถŒ**์ด๋‹ค. +- **1๋‹จ๊ณ„ ์šด์˜ ํ˜•ํƒœ = ๊ตฌํ˜„ ๋ณ‘๋ ฌ, ๋ฆฌ๋ทฐ ๋ฃจํ”„ ํ•˜๋‚˜**: creator ๊ตฌํ˜„์€ ๋ฃจํ”„ ๋ฐ–(delegate-job direct)์—์„œ ์›ŒํฌํŠธ๋ฆฌ๋งˆ๋‹ค ๋ณ‘๋ ฌ๋กœ ํ•œ๋‹ค. ๋ฆฌ๋ทฐ๋Š” `run_loop.sh --worktree --base <๊ตฌํ˜„ ์ „ sha> --review-only` ๋กœ ํ•œ ๋ฒˆ์— ํ•˜๋‚˜์”ฉ ํ•œ๋‹ค. base ๋ฅผ ๊ตฌํ˜„ ์œ„์ž„ **์ „์—** ๊ธฐ๋กํ•˜์ง€ ์•Š์œผ๋ฉด ๋ฆฌ๋ทฐ๋ฅผ ์‹œ์ž‘ํ•  ์ˆ˜ ์—†๋‹ค(๊ฐ€๋“œ๊ฐ€ ๋นˆ ๋ณ€๊ฒฝ๋ถ„์„ ๊ฑฐ๋ถ€ํ•œ๋‹ค). busy ์ฒ˜๋ฆฌ๋Š” ํ๊ฐ€ ์•„๋‹ˆ๋‹ค(1ํšŒ ์žฌ์‹œ๋„). ๋‹ค์ค‘ ๋ฃจํ”„๋Š” ํ•ญ๋ชฉ 8(๋ฆฌ๋ทฐ์–ด ์Šค์ผ€์ค„๋Ÿฌ) ์ดํ›„๋‹ค. +- ์ค€๋น„ ํ—ฌํผ๋Š” ํ•ญ๋ชฉ 4 ๋‹ค. `.mam.env` ๋Š” ๋ณต์‚ฌ ๋Œ€์‹  ์ค‘์•™ `MAM_ENV_FILE` ์ •์ฑ…์œผ๋กœ ํ•˜๊ณ , `.venv` ์‹ฌ๋งํฌ๋Š” gitignore ๋˜์ง€ ์•Š์œผ๋ฏ€๋กœ `.git/info/exclude` ๋กœ ์ฒ˜๋ฆฌํ•œ๋‹ค. + +## 5. ํ•„์š”ํ•œ ๋ณ€๊ฒฝ (Issue #21 ์ž‘์—… ํ•ญ๋ชฉ) + +| # | ํ•ญ๋ชฉ | ์ƒํƒœ | +|---|---|---| +| 1 | ๋ž˜ํผ ์ฃผ์ž… ๋ฌธ๊ตฌยท์‹œ์ž‘ ๋ช…๋ น(`--registry-dir`, ๊ฐ์‚ฌ ๋กœ๊ทธ ๋ฃจํŠธ)ยทOutput Report Path ์ ˆ๋Œ€๊ฒฝ๋กœํ™”, `MAM_JOBS_DIR` ์กด์ค‘ | ์ด ๋ธŒ๋žœ์น˜ | +| 2 | ๋ฃจํ”„ ๋Ÿฌ๋„ˆ `--worktree` / `--base` / `--review-only`, ๋นˆ ๋ณ€๊ฒฝ๋ถ„ fail-closed ๊ฐ€๋“œ, ๋Ÿฌ๋„ˆ์˜ `MAM_JOBS_DIR` ์กด์ค‘ | ์ด ๋ธŒ๋žœ์น˜ | +| 3 | ์ œ์–ด ํ‰๋ฉด ๋ฐ”์ธ๋”ฉ ์˜์†(`control_plane`)ยท๋ช…์‹œ ์ „ํŒŒ(create/resume), stopยทstatusยทreconcile ์˜ `MAM_JOBS_DIR`ยทํ–‰ ๋ฐ”์ธ๋”ฉ ์กด์ค‘ | ์ด ๋ธŒ๋žœ์น˜ | +| 4 | ์›ŒํฌํŠธ๋ฆฌ ์ค€๋น„ยทํ•ด์ฒด ํ—ฌํผ, resume ์„ ์›๋ž˜ cwd ๋กœ๋งŒ ํ—ˆ์šฉ, create ์˜ reconcile/watchdog ๊ธฐ๋™ ์œ„์น˜, `MAM_NO_SUBSCRIBER_DAEMON` ๊ธฐ๋ณธํ™”, reconcile ์˜ ๋ฃจํŠธ ๋ฐ– cwd ์žฌ๋ฐœ๊ฒฌ ๊ฑฐ๋ถ€(#17-4 ์…ธ ์ถ•์†Œ์™€ ํ•จ๊ป˜) | ๋Œ€๊ธฐ | +| 5 | ๋ฆฌ๋ทฐ identity: (loop_id, base_sha, head_sha) ํŒ์ • ๊ธฐ๋กยท๋ฌดํšจํ™”, ๋ฆฌ๋ทฐ์–ด ๊ฒ€์ฆ ๊ฒฉ๋ฆฌ(detached ์›ŒํฌํŠธ๋ฆฌ) | ๋Œ€๊ธฐ | +| 6 | ์ด ์„ค๊ณ„ ๋ฌธ์„œ ์ •์ • | ์ด ๋ธŒ๋žœ์น˜ | +| 7 | ์ˆ˜์šฉ ์‚ฌ๋ก€ ๊ณ ์ •(์‹ค์ œ herdr ์˜ pane env ์ „ํŒŒ ํฌํ•จ) | ๋Œ€๊ธฐ | +| 8 | ๋‹ค์ค‘ ๋ฃจํ”„ ๋„ค์ž„์ŠคํŽ˜์ด์Šคยท๋ฆฌ๋ทฐ์–ด ์Šค์ผ€์ค„๋Ÿฌ | ๋ณด๋ฅ˜ | + +## 6. ๋ฆฌ๋ทฐ์–ด์—๊ฒŒ ๋ฌป๋Š” ๊ฒƒ + +๋‹ต๋ณ€: docs/design/reviews/ 4๊ฑด, ์ข…ํ•ฉ์€ Issue #21. + +1. C ์•ˆ์˜ ์—ญํ•  ๋ถ„๋ฆฌ(์“ฐ๋Š” ์ชฝ๋งŒ ๊ฒฉ๋ฆฌ, ์ฝ๋Š” ์ชฝ ๊ณต์œ )๊ฐ€ ๋‘ ๋ฌธ์ œ(๋ณ‘๋ ฌ์„ฑ, ์ฒดํฌ์•„์›ƒ ์ „ํ™˜ ์‚ฌ๊ณ )๋ฅผ ์‹ค์ œ๋กœ ํ•ด์†Œํ•˜๋Š”๊ฐ€? ๋†“์นœ ๊ฒฝ๋กœ๊ฐ€ ์žˆ๋Š”๊ฐ€(์˜ˆ: ๋ฆฌ๋ทฐ์–ด๊ฐ€ ํ…Œ์ŠคํŠธ ์ค‘ ํŒŒ์ผ์„ ๋งŒ๋“œ๋Š” ๊ฒฝ์šฐ, ๋ชจ๋‹ˆํ„ฐ reconcile ์ด ์›ŒํฌํŠธ๋ฆฌ cwd ํ–‰์„ ์–ด๋–ป๊ฒŒ ๋‹ค๋ฃจ๋Š”์ง€). +2. ์ œ์–ด ํ‰๋ฉด ๊ณต์œ (env override)๊ฐ€ ํ˜„์žฌ ์ฝ”๋“œ์—์„œ ์•ˆ์ „ํ•œ๊ฐ€? ํŠนํžˆ `pane.cwd` ๊ฐ€ ์›ŒํฌํŠธ๋ฆฌ์ธ ํ–‰์„ status/monitor/stop/resume ์Šคํ‚ฌ์ด ์˜ฌ๋ฐ”๋ฅด๊ฒŒ ์ฒ˜๋ฆฌํ•˜๋Š”๊ฐ€. +3. A/B ์•ˆ ๋Œ€๋น„ C ์•ˆ์˜ ๋น„์šฉยท์ด๋“ ํ‰๊ฐ€์— ๋™์˜ํ•˜๋Š”๊ฐ€? ๋‹ค๋ฅธ ๋Œ€์•ˆ์ด ์žˆ๋Š”๊ฐ€? +4. ยง5 ์ž‘์—… ํ•ญ๋ชฉ์˜ ๋ˆ„๋ฝยท๊ณผ์ž‰. ํŠนํžˆ ๋‹ค์ค‘ ๋ฃจํ”„ ๋™์‹œ ๊ธฐ๋™์˜ `.mam` ๊ฒฝํ•ฉ์„ ๋จผ์ € ํ’€์–ด์•ผ ํ•˜๋Š”์ง€, ์•„๋‹ˆ๋ฉด "๋ฃจํ”„๋Š” ํ•˜๋‚˜์”ฉ, ๊ตฌํ˜„๋งŒ ๋ณ‘๋ ฌ" ๋กœ ์‹œ์ž‘ํ•ด๋„ ๋˜๋Š”์ง€. +5. #17 ํ•ญ๋ชฉ 4(์…ธ ๊ณ„์ธต ์ถ•์†Œ)์™€์˜ ์ˆœ์„œ: ์ด ๋ชจ๋ธ์„ ๋จผ์ € ๋ฌธ์„œยท๋ ˆ์‹œํ”ผ๋กœ ๊ตณํžˆ๊ณ  ํ•ญ๋ชฉ 4 ์—์„œ ์ฝ”๋“œ๋กœ ํก์ˆ˜ํ•˜๋Š” ์ˆœ์„œ๊ฐ€ ๋งž๋Š”๊ฐ€. + +## 7. ๋ฃจํŠธ ์ •์˜์™€ ์Šคํ‚ฌ๋ณ„ ํ˜ธํ™˜์„ฑ + +- **์ œ์–ด ๋ฃจํŠธ**: `.mam` ์„ ๊ฐ€์ง„ ๋ณธ ์ฒดํฌ์•„์›ƒ. ๋ ˆ์ง€์ŠคํŠธ๋ฆฌยท๊ฐ์‚ฌ ๋กœ๊ทธยท์„ธ์…˜ yamlยท์ฒดํฌํฌ์ธํŠธยทloop guardยท๋ฆฌํฌํŠธ ์Šน๊ฒฉ์ด ์—ฌ๊ธฐ ์žˆ๋‹ค. +- **์†Œ์Šค ๋ฃจํŠธ**: ์ด์Šˆ ์›ŒํฌํŠธ๋ฆฌ. creator ์˜ cwd, ๋ฃจํ”„์˜ diffยทbaseยทhead ๋Œ€์ƒ. +- **๋Ÿฐํƒ€์ž„ ์ฝ”๋“œ ๋ฃจํŠธ**: ์‹ค์ œ๋กœ ์‹คํ–‰๋˜๋Š” `.agents/skills` ์‚ฌ๋ณธ. ๋ฃจํ”„๋Š” ์ œ์–ด ๋ฃจํŠธ์˜ ์‚ฌ๋ณธ์„ ์‹คํ–‰ํ•˜๋ฉฐ, ์›ŒํฌํŠธ๋ฆฌ์˜ ๋ฏธ๊ฒ€ํ†  `diff_collect.sh` ๋ฅผ ์‹คํ–‰ํ•˜์ง€ ์•Š๋Š”๋‹ค. + +| ์Šคํ‚ฌ | ์ œ์–ด ํ‰๋ฉด ๋ฐ”์ธ๋”ฉ | ์›ŒํฌํŠธ๋ฆฌ cwd ํ–‰ | 1๋‹จ๊ณ„ ์ดํ›„ ๋‚จ์€ ์ œํ•œ (ํ•ญ๋ชฉ) | +|---|---|---|---| +| create | env ์—์„œ ๊ฒฐ์ • โ†’ ํ–‰ `control_plane` ์— ํ•ญ์ƒ ์˜์†. `--workspace` ๊ธฐ๋ณธ๊ฐ’๊ณผ ๋‹ค๋ฅด๋ฉด ์ƒˆ pane ์˜ ์ƒ์„ฑ ํ˜ธ์ถœ(`pane split`/`workspace create`)์— `--env` ๋กœ ์ „ํŒŒํ•œ๋‹ค. ์ „ํŒŒ๊ฐ€ ํ•„์š”ํ•˜๋ฉด idle pane ์„ ์žฌ์‚ฌ์šฉํ•˜์ง€ ์•Š๋Š”๋‹ค(`agent start -- ` ๋Š” `--` ๋’ค๋ฅผ ์—์ด์ „ํŠธ ์ธ์ž๋กœ ๋„˜๊ธฐ๋ฏ€๋กœ `env K=V` ๋ฅผ ์“ธ ์ˆ˜ ์—†๋‹ค). ์ „ํŒŒ๊ฐ€ ํ•„์š”ํ•œ๋ฐ ๊ฒฝ๋กœ์— ๊ณต๋ฐฑ์ด ์žˆ์œผ๋ฉด exit 2. `~/.local/bin` wrapper ๊ฒฝ๋กœ๋Š” ๊ธฐ๋ก๋งŒ ํ•˜๊ณ  ๊ฒฝ๊ณ  | pane.cwd = `--workspace` | create ๊ฐ€ `$WORKSPACE` ์‚ฌ๋ณธ์œผ๋กœ reconcile/watchdog ๊ธฐ๋™, `MAM_NO_SUBSCRIBER_DAEMON` ๋ฏธ๊ธฐ๋ณธ (4) | +| resume | ํ–‰ ๋ฐ”์ธ๋”ฉ ์šฐ์„ (ํ˜ธ์ถœ์ž env ์™€ ๋‹ค๋ฅด๋ฉด stderr ๊ฒฝ๊ณ ) ์žฌ์ „ํŒŒ. ๋ฐ”์ธ๋”ฉ ์—†๋Š” ๊ธฐ์กด ํ–‰์€ ํ˜ธ์ถœ์ž ๋ฐ”์ธ๋”ฉ์œผ๋กœ ์ฑ„์›€ | `--workspace` ๋Š” ์›๋ž˜ ์›ŒํฌํŠธ๋ฆฌ ๊ฒฝ๋กœ์—ฌ์•ผ ํ•จ | ์›ŒํฌํŠธ๋ฆฌ ์‚ญ์ œ ์‹œ ์žฌ๊ฐœ ๋ถˆ๊ฐ€, ์ข…๋ฃŒ ํ—ฌํผ (4) | +| stop | `MAM_JOBS_DIR` > ํ–‰์˜ `mam_jobs_dir` > `$WORKSPACE_ROOT/.mam/jobs` ์ˆœ์œผ๋กœ ์žก ์กฐํšŒยท๋ฐœํ–‰. ๊ฐ์‚ฌ ๋กœ๊ทธ๋„ `DELEGATE_JOB_LOGS_DIR` > ํ–‰์˜ `delegate_job_logs_dir` ์ˆœ | ํ–‰ cwd ๋กœ uuid ์บก์ฒ˜ยทpurge | ํ•ด์ฒด ์ˆœ์„œ ํ—ฌํผ (4) | +| status | `MAM_JOBS_DIR`ยทํ–‰ `mam_jobs_dir`ยท๊ฐ์‚ฌ ๋กœ๊ทธ(`DELEGATE_JOB_LOGS_DIR`ยทํ–‰ `delegate_job_logs_dir`)ยท๊ธฐ์กด ํ›„๋ณด ์ˆœ ์กฐํšŒ | ํ–‰ cwd ๋กœ ๋Œ€ํ™” ํ‚ค | โ€” | +| monitor/reconcile | `MAM_JOBS_DIR` ์šฐ์„ (์ •์  ๊ฐ€๋“œ๋กœ๋งŒ ๊ฒ€์ฆ) | ๊ธฐ์กด ํ–‰์€ ํ–‰ cwd ๋กœ ๊ฒ€์ฆ | ๋ฃจํŠธ ๋ฐ– cwd ์˜ ๋ฏธ๋“ฑ๋ก ์„ธ์…˜์€ ์ž๋™ ๋“ฑ๋ก ์•ˆ ๋จ โ†’ create ๋กœ๋งŒ ๋“ฑ๋ก (4) | +| delegate-job | `--registry-dir` > `MAM_JOBS_DIR` > `.mam/jobs`(`--workdir` ๊ธฐ์ค€), ๋ชจ๋‘ ์ ˆ๋Œ€ํ™”. ๋ฐœํ–‰ ๋ช…๋ น์ด ๊ฐ์‚ฌ ๋กœ๊ทธ ๋ฃจํŠธ(`DELEGATE_JOB_LOGS_DIR`)๋ฅผ ๊ณ ์ • | ๋ฌธ๊ตฌยท๋ช…๋ นยท๋ฆฌํฌํŠธ ๊ฒฝ๋กœ ์ ˆ๋Œ€ | ์ž‘์€๋”ฐ์˜ดํ‘œ๊ฐ€ ๋“  ๊ฒฝ๋กœ๋Š” ์ง€์› ๋ฒ”์œ„ ๋ฐ– | +| loop | `MAM_JOBS_DIR` ์กด์ค‘, ๋ž˜ํผ์— `--registry-dir` ๋ช…์‹œ. `--worktree` ๋กœ ์†Œ์Šค ๋ฃจํŠธ ๋ถ„๋ฆฌ | diffยทbaseยทhead ๋งŒ ์†Œ์Šค ๋ฃจํŠธ. **๊ตฌํ˜„์ด ๋ฃจํ”„ ๋ฐ–์ด๋ฉด `--base <๊ตฌํ˜„ ์ „ sha> --review-only` ํ•„์ˆ˜**. `--worktree`/`--base` ๊ฐ€ ์žˆ์œผ๋ฉด ๋นˆ ๋ณ€๊ฒฝ๋ถ„ ๊ฑฐ๋ถ€ | ๋ฃจํ”„ ํ•˜๋‚˜(8), ๋ฆฌ๋ทฐ identityยท๊ฒ€์ฆ ๊ฒฉ๋ฆฌ(5), ๋ ˆ๊ฑฐ์‹œ ์…ธ ๋ฃจํ”„(`MAM_LOOP_PY=0`)๋Š” ์ƒˆ ์˜ต์…˜ ๊ฑฐ๋ถ€ | + +์‹ค์ œ herdr ๊ฐ€ `pane split --env` / `workspace create --env` ๋กœ pane ํ™˜๊ฒฝ์„ ๋„˜๊ธฐ๋Š”์ง€๋Š” 1๋‹จ๊ณ„์—์„œ mock ์ˆ˜์ค€์œผ๋กœ๋งŒ ๊ฒ€์ฆํ–ˆ๋‹ค. ์‹ค์ œ ์ „ํŒŒ๋Š” ํ•ญ๋ชฉ 7 ์˜ ์ˆ˜์šฉ ์‚ฌ๋ก€๋กœ ๊ณ ์ •ํ•œ๋‹ค. diff --git a/tests/test_herdr_shim_contract.py b/tests/test_herdr_shim_contract.py index 9e8f81d..cb4d050 100644 --- a/tests/test_herdr_shim_contract.py +++ b/tests/test_herdr_shim_contract.py @@ -516,10 +516,13 @@ def test_h24_new_session_reuses_idle_pane_without_split(mam_sandbox, mock_herdr, assert not split_calls, f"Expected no pane split call when an idle pane was available: {split_calls}" -def test_h25_new_session_idle_pane_preserves_env_and_ws_id(mam_sandbox, mock_herdr, mock_agents): - """Finding A: when an idle pane is reused, caller-provided KEY=VAL environment - flags (and HERDR_WORKSPACE_ID) must be prepended via env to final_cmd so they - are not silently lost when pane split / workspace create is bypassed.""" +def test_h25_new_session_with_env_flags_skips_the_idle_pane_and_uses_env_on_split(mam_sandbox, mock_herdr, mock_agents): + """Finding A, revised for Issue #21 R1: caller-provided KEY=VAL environment + flags (and HERDR_WORKSPACE_ID) must not be silently lost - and must not be + put after `agent start --` either, because herdr passes those tokens to the + agent executable (--kind) as arguments. With env flags an idle pane is not + reused; the env rides on the pane-creation call (pane split / workspace + create --env). Reuse without env flags stays covered by h24.""" tmp_path = mam_sandbox state = _load_mock_state(mock_herdr) state["panes"] = [{ @@ -538,17 +541,22 @@ def test_h25_new_session_idle_pane_preserves_env_and_ws_id(mam_sandbox, mock_her assert res.returncode == 0, f"Stderr: {res.stderr}\nStdout: {res.stdout}" final_state = _load_mock_state(mock_herdr) - agents = final_state.get("agents", {}) - matched = [d for n, d in agents.items() if "test-creator-claude" in n] - assert matched, agents - assert matched[0].get("pane_id") == "w1:p_idle" - cmd = matched[0].get("command", "") - assert "MY_TEST_FLAG=active" in cmd, f"Expected MY_TEST_FLAG=active in agent command: {cmd}" - assert "HERDR_WORKSPACE_ID=w1" in cmd, f"Expected HERDR_WORKSPACE_ID=w1 in agent command: {cmd}" + # the shim may prefix `--session `; compare the subcommands themselves + new_calls = [c[2:] if c[:1] == ["--session"] else c for c in final_state.get("calls", [])[n_calls:]] + creates = [c for c in new_calls if c[:2] in (["pane", "split"], ["workspace", "create"])] + assert creates, f"expected a pane split / workspace create carrying the env: {new_calls}" + env_vals = [c[i + 1] for c in creates for i, t in enumerate(c[:-1]) if t == "--env"] + assert "MY_TEST_FLAG=active" in env_vals and "HERDR_WORKSPACE_ID=w1" in env_vals, env_vals - new_calls = final_state.get("calls", [])[n_calls:] - split_calls = [c for c in new_calls if len(c) >= 2 and c[0] == "pane" and c[1] == "split"] - assert not split_calls, f"Expected no pane split call when an idle pane was available: {split_calls}" + matched = [d for n, d in final_state.get("agents", {}).items() if "test-creator-claude" in n] + assert matched and matched[0].get("pane_id") != "w1:p_idle", matched + + starts = [c for c in new_calls if c[:2] == ["agent", "start"]] + assert starts, new_calls + for c in starts: + args = c[c.index("--") + 1:] if "--" in c else [] + assert "env" not in args, args + assert not any(a.startswith(("MY_TEST_FLAG=", "HERDR_WORKSPACE_ID=")) for a in args), args def test_h26_daemon_spawn_uses_process_group_detachment(mam_sandbox, mock_herdr, mock_agents): diff --git a/tests/test_issue21_worktree_prereqs.py b/tests/test_issue21_worktree_prereqs.py new file mode 100644 index 0000000..92b9a5e --- /dev/null +++ b/tests/test_issue21_worktree_prereqs.py @@ -0,0 +1,780 @@ +"""Issue #21 phase-1 prerequisites for the worktree operating model. + + ยง1 (item 1) delegate-job wrapper: absolute brief / start-command registry / + audit-log root / Output Report Path, runnable from any cwd. + ยง2 (item 2) loop runner --worktree / --base / --review-only and the + fail-closed empty change-set guard; the control plane stays in the main + checkout. + ยง3 (item 3) control-plane binding persisted on the session row and forwarded + to the pane; status / reconcile / stop honour MAM_JOBS_DIR. + +Every test writes only under tmp_path / mam_sandbox (Issue #20). +""" + +from __future__ import annotations + +import json +import os +import re +import signal +import subprocess +import sys +import time +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parent.parent +SKILLS = REPO_ROOT / ".agents" / "skills" +WRAPPER = SKILLS / "multi-agent-mux-delegate-job" / "multi-agent-mux-delegate-job" +RUN_LOOP_SH = SKILLS / "multi-agent-mux-loop" / "scripts" / "run_loop.sh" + +if str(SKILLS) not in sys.path: + sys.path.insert(0, str(SKILLS)) + +UNREACHABLE = {"MQTT_BROKER": "127.0.0.1", "MQTT_PORT": "9", "MQTT_TLS": "0"} +GIT_ID = {"GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@example.invalid", + "GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@example.invalid"} + + +def _env(home: Path, extra=None, drop=()) -> dict: + env = dict(os.environ) + env["DELEGATE_JOB_PYTHON"] = sys.executable + env["AGENT_PYTHON_BIN"] = sys.executable + env["HOME"] = str(home) + env["MAM_ENV_FILE"] = str(home / ".mam.env.absent") + env["PYTHONPATH"] = str(SKILLS) + for k in ("MQTT_BROKER", "MQTT_HOST", "MQTT_PORT", "MQTT_TLS", "MQTT_USERNAME", "MQTT_PASSWORD", + "MAM_JOBS_DIR", "DELEGATE_JOB_LOGS_DIR", "WORKSPACE_ROOT"): + env.pop(k, None) + env.update(UNREACHABLE) + if extra: + env.update({k: str(v) for k, v in extra.items()}) + for k in drop: + env.pop(k, None) + return env + + +def _only_job_id(reg: Path) -> str: + ids = [p.stem for p in reg.glob("*.json")] + assert len(ids) == 1, ids + return ids[0] + + +def _kill_timer(reg: Path, jid: str) -> None: + try: + pid = int((reg / f"{jid}.heartbeat.pid").read_text().strip()) + os.kill(pid, signal.SIGKILL) + except (OSError, ValueError): + pass + + +def _dry_submit(cwd: Path, env: dict, *args) -> subprocess.CompletedProcess: + return subprocess.run(["bash", str(WRAPPER), "submit", "--agent", "human", "--prompt", "p21", + "--dry-run", *args], + capture_output=True, text=True, env=env, cwd=str(cwd), timeout=60) + + +def _injected(stdout: str, name: str) -> str: + m = re.search(name + r"='(.*?)'", stdout) + assert m, stdout + return m.group(1) + + +# --------------------------------------------------------------------------- +# ยง1 item 1 โ€” the wrapper's injected paths are absolute +# --------------------------------------------------------------------------- + +def test_t1_1_injected_paths_are_absolute(tmp_path): + a = tmp_path / "A" + a.mkdir() + real_a = os.path.realpath(a) + env = _env(tmp_path, {"WORKSPACE_ROOT": a, "DELEGATE_JOB_LOGS_DIR": a / "logs"}) + res = _dry_submit(a, env, "--workdir", str(a)) + assert res.returncode == 0, res.stderr + jid = _only_job_id(a / ".mam" / "jobs") + brief = f"{real_a}/.mam/jobs/{jid}/brief.md" + assert res.stdout.count(brief) == 2, res.stdout + start = _injected(res.stdout, "start") + assert f"--registry-dir {real_a}/.mam/jobs " in start + assert start.startswith(f"DELEGATE_JOB_LOGS_DIR={real_a}/logs "), start + # no cwd-relative registry path is left anywhere in the injected text + assert res.stdout.count(f".mam/jobs/{jid}") == res.stdout.count(f"{real_a}/.mam/jobs/{jid}") + + +def test_t1_2_start_and_success_commands_run_from_another_cwd(tmp_path): + """The core regression: a registry and an audit-log root with a space in + them, the commands executed as written from a different cwd with no + workspace env - the job advances, the audit trail stays in the control + root, and nothing is written under the other cwd.""" + ctl = tmp_path / "ctl root" + reg, logs, wt = ctl / "jobs", ctl / "logs", tmp_path / "wt" + for d in (reg, logs, wt): + d.mkdir(parents=True) + env = _env(tmp_path, {"WORKSPACE_ROOT": ctl, "DELEGATE_JOB_LOGS_DIR": logs}) + res = _dry_submit(ctl, env, "--registry-dir", str(reg), "--workdir", str(ctl), + "--heartbeat", "7", "--ack-timeout", "11") + assert res.returncode == 0, res.stderr + jid = _only_job_id(reg) + start = _injected(res.stdout, "start") + success = _injected(res.stdout, "success").replace("", "done") + agent_env = _env(tmp_path) # no WORKSPACE_ROOT / MAM_JOBS_DIR / DELEGATE_JOB_LOGS_DIR + try: + r1 = subprocess.run(["bash", "-c", start + " --attempts 1"], capture_output=True, text=True, + env=agent_env, cwd=str(wt), timeout=120) + # rc 2 = broker unreachable; the event is recorded locally first (test18 contract) + assert r1.returncode == 2, r1.stderr + assert json.loads((reg / f"{jid}.json").read_text())["status"] != "pending" + r2 = subprocess.run(["bash", "-c", success + " --attempts 1"], capture_output=True, text=True, + env=agent_env, cwd=str(wt), timeout=120) + assert r2.returncode == 2, r2.stderr + assert json.loads((reg / f"{jid}.json").read_text())["status"] == "completed" + finally: + _kill_timer(reg, jid) + audit = [json.loads(l) for l in (logs / jid / "events.ndjson").read_text().splitlines() if l.strip()] + published = [json.dumps(e) for e in audit if e.get("event") == "published"] + assert len(published) == 2, audit + assert '"started"' in published[0] and '"completed"' in published[1], published + assert not (wt / ".mam").exists(), "the agent's cwd must not grow a .mam" + + +def test_t1_3_mam_jobs_dir_is_the_default_registry(tmp_path): + x, y = tmp_path / "X", tmp_path / "Y" + env = _env(tmp_path, {"MAM_JOBS_DIR": x, "WORKSPACE_ROOT": tmp_path, "DELEGATE_JOB_LOGS_DIR": tmp_path / "logs"}) + res = _dry_submit(tmp_path, env, "--workdir", str(tmp_path)) + assert res.returncode == 0, res.stderr + assert len(list(x.glob("*.json"))) == 1 + assert not (tmp_path / ".mam" / "jobs").exists() + + res = _dry_submit(tmp_path, env, "--workdir", str(tmp_path), "--registry-dir", str(y)) + assert res.returncode == 0, res.stderr + assert len(list(y.glob("*.json"))) == 1, "an explicit --registry-dir wins over MAM_JOBS_DIR" + assert len(list(x.glob("*.json"))) == 1 + + +def test_t1_4_wrapper_source_guard(): + """The loop job type never builds its text under --dry-run (it returns + early), so both paths are pinned to the shared helpers here.""" + src = WRAPPER.read_text() + assert ".mam/jobs/$JOB_ID" not in src + assert src.count('instructions="$(_instructions "$pub")"') == 1 + assert src.count('instructions="$(_instructions "$pub" "$iteration")"') == 1 + assert src.count('pub="$(_pub_cmd ') == 2 + report_lines = [l for l in src.splitlines() if "**Output Report Path**" in l] + assert len(report_lines) == 2 and all("$REGISTRY_DIR/$JOB_ID/" in l for l in report_lines) + + +def _sent_texts(state) -> list: + out = [] + + def walk(o): + if isinstance(o, dict): + for k, v in o.items(): + if k == "sent_text" and isinstance(v, str): + out.append(v) + else: + walk(v) + elif isinstance(o, list): + for v in o: + walk(v) + walk(state) + return out + + +def test_t1_5_onboarding_publish_command_is_absolute(mam_sandbox, mock_herdr, mock_agents): + tmp = mam_sandbox + script = tmp / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh" + res = subprocess.run(["bash", str(script), "--workspace", str(tmp), "--agent", "claude", + "--role", "creator", "--onboard"], + capture_output=True, text=True, cwd=str(tmp), timeout=180) + assert res.returncode == 0, res.stdout + res.stderr + texts = [t for t in _sent_texts(json.loads(Path(mock_herdr).read_text())) if "publish_event.py" in t] + assert texts, "the onboarding instructions were not injected" + text = "".join(texts) + real = os.path.realpath(tmp) + assert f" {real}/.agents/skills/multi-agent-mux-delegate-job/scripts/publish_event.py " in text, text + assert f"--registry-dir {real}/.mam/jobs " in text, text + assert f"DELEGATE_JOB_LOGS_DIR={real}/.mam/delegate_job_logs " in text, text + assert " .agents/skills/" not in text + + +# --------------------------------------------------------------------------- +# ยง2 item 2 โ€” loop runner --worktree / --base / --review-only, empty-set guard +# --------------------------------------------------------------------------- + +def _git(cwd: Path, *args: str) -> str: + return subprocess.run(["git", "-C", str(cwd), *args], capture_output=True, text=True, check=True, + env={**os.environ, **GIT_ID}).stdout.strip() + + +@pytest.fixture +def wt_repo(tmp_path, monkeypatch): + """main checkout (control root) + a linked worktree on branch feat.""" + main, wt = tmp_path / "main", tmp_path / "wt" + main.mkdir() + _git(main, "init", "-q") + (main / "a.txt").write_text("a\n") + _git(main, "add", "a.txt") + _git(main, "commit", "-q", "-m", "init") + _git(main, "worktree", "add", "-q", "-b", "feat", str(wt)) + for k, v in GIT_ID.items(): + monkeypatch.setenv(k, v) + monkeypatch.setenv("WORKSPACE_ROOT", str(main)) + monkeypatch.delenv("MAM_REAL_ROOT", raising=False) + monkeypatch.setenv("MAM_JOBS_DIR", str(main / ".mam" / "jobs")) + monkeypatch.delenv("MAM_LOOP_WORKTREE", raising=False) + monkeypatch.delenv("MAM_LOOP_BASE", raising=False) + monkeypatch.setenv("MAM_STATE_JSON", json.dumps({"herdr_sessions": [ + {"name": "creator-claude-01", "agent": "claude", "role": "creator", "status": "running"}, + {"name": "reviewer-claude-01", "agent": "claude", "role": "reviewer", "status": "running"}, + ]})) + return main, wt + + +def _runner(*extra): + from lib_py.loop import runner as lr + return lr.LoopRunner(["--creator", "creator-claude-01", "--task", "t21", *extra]) + + +def _drive(monkeypatch, tmp_path, argv, on_worker=None): + """run() end to end with the delegation stubbed: every job 'completes' and + every report is a PASS. Returns (rc, [(role, prompt)], runner).""" + from lib_py.loop import runner as lr + monkeypatch.setattr(lr.signal, "signal", lambda *a, **k: None) + r = lr.LoopRunner(["--creator", "creator-claude-01", "--task", "t21", + "--reviewer", "reviewer-claude-01", "--max-loop", "1", *argv]) + calls = [] + report = tmp_path / "pass-report.md" + report.write_text("looks fine\n\n[VERDICT: PASS]\n") + + def fake_delegate(session, agent_type, role, prompt): + calls.append((role, prompt)) + if role == "Worker" and on_worker: + on_worker(r) + return f"j{len(calls)}" + + monkeypatch.setattr(r, "delegate_job", fake_delegate) + monkeypatch.setattr(r, "wait_for_job", lambda *a, **k: True) + monkeypatch.setattr(r, "find_report", lambda jid: report) + return r.run(), calls, r + + +def _roles(calls, role): + return [p for (ro, p) in calls if ro == role] + + +def test_t2_1_diff_and_head_come_from_the_worktree(wt_repo): + main, wt = wt_repo + (wt / "a.txt").write_text("a\nworktree change\n") + (main / "b.txt").write_text("main-only change\n") + r = _runner("--worktree", str(wt)) + r.parse_args() + r.base_commit = r._source_head() + assert r._source_head() == _git(wt, "rev-parse", "HEAD") + diff = r.collect_changes_diff() + assert "worktree change" in diff + assert "main-only change" not in diff and "b.txt" not in diff + + +def test_t2_2_invalid_worktrees_are_refused(wt_repo, tmp_path): + main, wt = wt_repo + (wt / "sub").mkdir() + (tmp_path / "plain").mkdir() + other = tmp_path / "other" + other.mkdir() + _git(other, "init", "-q") + for bad in (tmp_path / "missing", tmp_path / "plain", wt / "sub", other): + with pytest.raises(SystemExit) as e: + _runner("--worktree", str(bad)).parse_args() + assert e.value.code == 1, bad + + +def test_t2_3_delegation_stays_on_the_control_root(wt_repo, monkeypatch): + main, wt = wt_repo + from lib_py.loop import runner as lr + monkeypatch.setenv("MAM_HEARTBEAT_SEC", "20") + monkeypatch.setenv("MAM_HEARTBEAT_MISSES", "3") + monkeypatch.setenv("MAM_ACK_TIMEOUT_S", "45") + r = _runner("--worktree", str(wt)) + r.parse_args() + seen = [] + + def fake_run(cmd, **kw): + seen.append((cmd, kw)) + + class P: + returncode = 0 + stdout = "registered job: cafe21\n" + stderr = "" + return P() + + monkeypatch.setattr(lr.subprocess, "run", fake_run) + assert r.delegate_job("creator-claude-01", "claude", "Worker", "go") == "cafe21" + cmd, kw = seen[0] + assert os.path.realpath(kw["cwd"]) == os.path.realpath(main) + assert cmd[1].endswith("multi-agent-mux-delegate-job/multi-agent-mux-delegate-job") + assert not cmd[1].startswith(os.path.realpath(wt)) + assert cmd[cmd.index("--registry-dir") + 1] == str(main / ".mam" / "jobs") + assert cmd[-6:] == ["--heartbeat", "20", "--heartbeat-misses", "3", "--ack-timeout", "45"] + + +def test_t2_4_checkpoint_lives_in_the_control_root(wt_repo): + main, wt = wt_repo + r = _runner("--worktree", str(wt)) + r.parse_args() + r._record_checkpoint() + data = json.loads((main / ".mam" / "loop-checkpoint.json").read_text()) + assert data["worktree_root"] == os.path.realpath(wt) + assert data["base_source"] == "head" and data["review_only"] is False + assert data["head_commit"] == _git(wt, "rev-parse", "HEAD") + assert not (wt / ".mam").exists() + + +def test_t2_5_prompts_carry_the_worktree_context(wt_repo, monkeypatch, tmp_path): + main, wt = wt_repo + head0 = _git(wt, "rev-parse", "HEAD") + + def implement(_r): + (wt / "a.txt").write_text("a\nimplemented in wt\n") + + rc, calls, _ = _drive(monkeypatch, tmp_path, ["--worktree", str(wt)], on_worker=implement) + assert rc == 0 + worker, review = _roles(calls, "Worker")[0], _roles(calls, "Reviewer")[0] + for p in (worker, review): + assert p.startswith("[์›ŒํฌํŠธ๋ฆฌ ์ปจํ…์ŠคํŠธ]\n") + assert f"- ์†Œ์Šค ๊ฒฝ๋กœ: {os.path.realpath(wt)}\n" in p + assert f"- base: {head0} (์ถœ์ฒ˜: ๋ฃจํ”„ ์‹œ์ž‘ ์‹œ HEAD)" in p + assert f"- head: {head0} (base..head ์ปค๋ฐ‹: 0๊ฐœ" in p + assert "๋ฏธ์ปค๋ฐ‹ ๋ณ€๊ฒฝ: ์žˆ์Œ" in review and "implemented in wt" in review + + +def test_t2_5b_prompts_unchanged_without_options(wt_repo, monkeypatch, tmp_path): + main, _ = wt_repo + monkeypatch.setenv("WORKSPACE_ROOT", str(main)) + + def implement(_r): + (main / "a.txt").write_text("a\nimplemented in main\n") + + rc, calls, _ = _drive(monkeypatch, tmp_path, [], on_worker=implement) + assert rc == 0 + assert all("[์›ŒํฌํŠธ๋ฆฌ ์ปจํ…์ŠคํŠธ]" not in p for _, p in calls) + assert _roles(calls, "Worker")[0].startswith("๊ณ„ํš์„œ๊ฐ€ ์กด์žฌํ•˜์ง€ ์•Š์œผ๋ฏ€๋กœ") + assert _roles(calls, "Reviewer")[0].startswith("๋‹ค์Œ ๊ตฌํ˜„ ์‚ฌํ•ญ(์ž‘์—… ๋ชฉํ‘œ: t21)") + + +def test_t2_6_worktree_env_fallback_and_no_base_env(wt_repo, tmp_path): + main, wt = wt_repo + wt2 = tmp_path / "wt2" + _git(main, "worktree", "add", "-q", "-b", "feat2", str(wt2)) + os.environ["MAM_LOOP_WORKTREE"] = str(wt) + os.environ["MAM_LOOP_BASE"] = _git(main, "rev-parse", "HEAD") + try: + r = _runner() + r.parse_args() + assert r.worktree_root == Path(os.path.realpath(wt)) and r.worktree_given + assert r.base_ref is None and r.base_sha == "" and r.base_source == "head", "MAM_LOOP_BASE is ignored (D17)" + r = _runner("--worktree", str(wt2)) + r.parse_args() + assert r.worktree_root == Path(os.path.realpath(wt2)), "the CLI wins over MAM_LOOP_WORKTREE" + finally: + os.environ.pop("MAM_LOOP_WORKTREE", None) + os.environ.pop("MAM_LOOP_BASE", None) + + +def test_t2_7_runner_reads_mam_jobs_dir(wt_repo, tmp_path, monkeypatch): + x = tmp_path / "elsewhere" / "jobs" + (x / "jid21" / "rev-reports").mkdir(parents=True) + (x / "jid21.json").write_text(json.dumps({"status": "completed"})) + (x / "jid21" / "rev-reports" / "report-final.md").write_text("r\n") + monkeypatch.setenv("MAM_JOBS_DIR", str(x)) + r = _runner() + r.parse_args() + assert r.wait_for_job("jid21", max_wait=2) is True + assert r.find_report("jid21") == x / "jid21" / "rev-reports" / "report-final.md" + + +@pytest.mark.parametrize("opts,env", [ + (["--worktree", "/nowhere"], {}), + (["--base", "HEAD"], {}), + (["--review-only"], {}), + ([], {"MAM_LOOP_WORKTREE": "/nowhere"}), +]) +def test_t2_8_legacy_shell_loop_refuses_the_new_options(mam_sandbox, opts, env): + """run_loop.sh sources lib.sh before the refusal, and lib.sh writes its herdr + shim under $WORKSPACE_ROOT/.mam/shim: that root is the sandbox, never the + caller's control root (#20, review N2).""" + marker = mam_sandbox / "loop-marker" + run_env = {**os.environ, "WORKSPACE_ROOT": str(mam_sandbox), "MAM_LOOP_PY": "0", "MAM_LOOP_NO_FREEZE": "1", + "MAM_LOOP_MARKER": str(marker), **env} + res = subprocess.run(["bash", str(RUN_LOOP_SH), "--creator", "c", "--task", "t", *opts], + capture_output=True, text=True, env=run_env, cwd=str(mam_sandbox), timeout=60) + assert res.returncode == 1 + assert "require the Python loop (MAM_LOOP_PY=1)" in res.stderr + assert not marker.exists(), "refused before the loop lock" + assert (mam_sandbox / ".mam" / "shim" / "herdr").is_file(), "lib.sh's shim belongs in the sandbox" + + +def _commit_impl(wt: Path) -> str: + """Implementation committed in the worktree outside the loop; returns the pre-implementation sha.""" + before = _git(wt, "rev-parse", "HEAD") + (wt / "impl.txt").write_text("impl line\n") + _git(wt, "add", "impl.txt") + _git(wt, "commit", "-q", "-m", "impl") + return before + + +def test_t2_9a_committed_impl_without_base_is_refused(wt_repo, monkeypatch, tmp_path, capsys): + main, wt = wt_repo + _commit_impl(wt) + rc, calls, _ = _drive(monkeypatch, tmp_path, ["--worktree", str(wt)]) + assert rc == 1 + assert _roles(calls, "Reviewer") == [] + assert "empty change set since base" in capsys.readouterr().err + + +def test_t2_9b_review_only_reviews_the_committed_range(wt_repo, monkeypatch, tmp_path): + main, wt = wt_repo + before = _commit_impl(wt) + rc, calls, r = _drive(monkeypatch, tmp_path, ["--worktree", str(wt), "--base", before, "--review-only"]) + assert rc == 0 + assert _roles(calls, "Worker") == [], "Phase 2 is skipped" + review = _roles(calls, "Reviewer")[0] + assert "+impl line" in review + assert f"- base: {before} (์ถœ์ฒ˜: --base)" in review + assert "(base..head ์ปค๋ฐ‹: 1๊ฐœ, ๋ฏธ์ปค๋ฐ‹ ๋ณ€๊ฒฝ: ์—†์Œ)" in review + + +def test_t2_10_base_must_be_an_ancestor(wt_repo): + main, wt = wt_repo + _git(main, "commit", "-q", "--allow-empty", "-m", "side") # on main's branch, not in feat + side = _git(main, "rev-parse", "HEAD") + for ref in (side, "no-such-ref"): + with pytest.raises(SystemExit) as e: + _runner("--worktree", str(wt), "--base", ref).parse_args() + assert e.value.code == 1, ref + + +def test_t2_11_review_only_needs_base_and_no_plan(wt_repo): + main, wt = wt_repo + head = _git(wt, "rev-parse", "HEAD") + for extra in (["--review-only"], ["--review-only", "--base", head, "--plan"]): + with pytest.raises(SystemExit) as e: + _runner("--worktree", str(wt), *extra).parse_args() + assert e.value.code == 1, extra + + +def test_t2_12_guard_is_off_without_options(wt_repo, monkeypatch, tmp_path): + """Compatibility: no --worktree/--base, nothing changed - the review is + still requested exactly as before.""" + rc, calls, _ = _drive(monkeypatch, tmp_path, []) + assert rc == 0 + assert len(_roles(calls, "Reviewer")) == 1 + + +def test_t2_13_base_without_review_only_still_implements(wt_repo, monkeypatch, tmp_path): + main, wt = wt_repo + before = _commit_impl(wt) + rc, calls, _ = _drive(monkeypatch, tmp_path, ["--worktree", str(wt), "--base", before]) + assert rc == 0 + assert len(_roles(calls, "Worker")) == 1 + assert "+impl line" in _roles(calls, "Reviewer")[0] + + +def test_t2_14_undeterminable_change_set_is_refused(wt_repo, monkeypatch, tmp_path, capsys): + main, wt = wt_repo + + def corrupt(r): + r.base_commit = "0" * 40 # not an object: git diff exits 128 + + rc, calls, _ = _drive(monkeypatch, tmp_path, ["--worktree", str(wt)], on_worker=corrupt) + assert rc == 1 + assert _roles(calls, "Reviewer") == [] + assert "refusing to request reviews on no evidence" in capsys.readouterr().err + + +# --------------------------------------------------------------------------- +# ยง3 item 3 โ€” control-plane binding persisted on the row, forwarded to the pane +# --------------------------------------------------------------------------- + +CP_ENV = ("WORKSPACE_ROOT", "AGENT_SESSIONS_YAML", "MAM_JOBS_DIR", "DELEGATE_JOB_LOGS_DIR") + + +def _binding(root: Path) -> dict: + r = os.path.realpath(root) + return {"workspace_root": r, "agent_sessions_yaml": f"{r}/.mam/agent-sessions.yaml", + "mam_jobs_dir": f"{r}/.mam/jobs", "delegate_job_logs_dir": f"{r}/.mam/delegate_job_logs"} + + +def _rows(tmp: Path) -> list: + import yaml + return (yaml.safe_load((tmp / ".mam" / "agent-sessions.yaml").read_text()) or {}).get("herdr_sessions") or [] + + +def _env_flags(mock_state_file, since=0) -> list: + calls = json.loads(Path(mock_state_file).read_text()).get("calls", [])[since:] + return [c[i + 1] for c in calls for i, t in enumerate(c[:-1]) if t == "--env"] + + +def _agent_args(mock_state_file, since=0) -> list: + """The tokens after `--` of every `herdr agent start` call: herdr passes + them to the canonical agent executable (--kind), never to a shell.""" + calls = json.loads(Path(mock_state_file).read_text()).get("calls", [])[since:] + starts = [c for c in calls if any(c[i:i + 2] == ["agent", "start"] for i in range(len(c) - 1))] + return [c[c.index("--") + 1:] if "--" in c else [] for c in starts] + + +def _assert_no_env_in_agent_args(mock_state_file, since=0) -> None: + arg_lists = _agent_args(mock_state_file, since) + assert arg_lists, "no agent start was recorded" + for args in arg_lists: + assert "env" not in args, args + assert not [a for a in args if a.startswith(tuple(f"{k}=" for k in CP_ENV))], args + + +def _ncalls(mock_state_file) -> int: + return len(json.loads(Path(mock_state_file).read_text()).get("calls", [])) + + +def _skill(tmp: Path, *parts) -> str: + return str(tmp / ".agents" / "skills" / Path(*parts)) + + +def _create(tmp: Path, ws: Path, env=None) -> subprocess.CompletedProcess: + return subprocess.run(["bash", _skill(tmp, "multi-agent-mux-create", "scripts", "create_session.sh"), + "--workspace", str(ws), "--agent", "claude", "--role", "creator", "--no-onboard"], + capture_output=True, text=True, cwd=str(tmp), env=env or dict(os.environ), timeout=180) + + +def _mutate(tmp: Path, code: str) -> None: + res = subprocess.run(["bash", "-c", f"source {_skill(tmp, 'lib.sh')} && atomic_dump_yaml {tmp / '.mam' / 'agent-sessions.yaml'}"], + input=code, capture_output=True, text=True) + assert res.returncode == 0, res.stderr + + +def test_t3_0_control_plane_module(tmp_path, monkeypatch): + from lib_py import control_plane as cp + for k in CP_ENV: + monkeypatch.delenv(k, raising=False) + monkeypatch.setenv("WORKSPACE_ROOT", str(tmp_path)) + d = cp.describe(str(tmp_path)) + assert d["forward"] is False and d["source"] == "env" and cp.prefix(d) == "" + d = cp.describe(str(tmp_path / "wt")) + assert d["forward"] is True + assert cp.prefix(d).startswith(f"WORKSPACE_ROOT={os.path.realpath(tmp_path)} ") + row = _binding(tmp_path / "other") + d = cp.describe(str(tmp_path / "wt"), row) + assert d["source"] == "row" and d["workspace_root"] == row["workspace_root"] + monkeypatch.setenv("MAM_JOBS_DIR", str(tmp_path / "a b")) + d = cp.describe(str(tmp_path)) + assert d["forward"] and d["whitespace"] + assert cp.main(["prefix", json.dumps(d)]) == 3 + + +def test_t3_1_create_persists_and_forwards_a_foreign_binding(mam_sandbox, mock_herdr, mock_agents): + tmp = mam_sandbox + wt = tmp / "wt" + wt.mkdir() + res = _create(tmp, wt) + assert res.returncode == 0, res.stdout + res.stderr + (row,) = _rows(tmp) + assert row["control_plane"] == _binding(tmp) + flags = _env_flags(mock_herdr) + for k, v in zip(CP_ENV, _binding(tmp).values()): + assert f"{k}={v}" in flags, flags + assert "WORKSPACE_ROOT=" not in row["pane"]["cmd_full"] + assert "WORKSPACE_ROOT=" not in row["start_command"] + _assert_no_env_in_agent_args(mock_herdr) + + +def test_t3_2_default_binding_is_recorded_not_forwarded(mam_sandbox, mock_herdr, mock_agents): + tmp = mam_sandbox + res = _create(tmp, tmp) + assert res.returncode == 0, res.stdout + res.stderr + (row,) = _rows(tmp) + assert row["control_plane"] == _binding(tmp) + assert not [f for f in _env_flags(mock_herdr) if f.startswith(CP_ENV)], "no forward for the default binding" + + +def test_t3_5_whitespace_binding_that_needs_forwarding_is_refused(mam_sandbox, mock_herdr, mock_agents): + tmp = mam_sandbox + env = {**os.environ, "MAM_JOBS_DIR": str(tmp / "jobs dir")} + res = _create(tmp, tmp, env) + assert res.returncode == 2, res.stdout + res.stderr + assert "control-plane path contains whitespace" in res.stderr + assert _rows(tmp) == [] + + +def test_t3_3_4_stop_keeps_the_binding_and_resume_reforwards_it(mam_sandbox, mock_herdr, mock_agents): + tmp = mam_sandbox + wt = tmp / "wt" + wt.mkdir() + assert _create(tmp, wt).returncode == 0 + (row,) = _rows(tmp) + name = row["name"] + res = subprocess.run(["bash", _skill(tmp, "multi-agent-mux-stop", "scripts", "stop_session.sh"), + "--session", name, "--agent", "claude"], capture_output=True, text=True, timeout=120) + assert res.returncode == 0, res.stdout + res.stderr + (row,) = _rows(tmp) + assert row["control_plane"] == _binding(tmp), "stop edits the row in place" + + env = {k: v for k, v in os.environ.items() if k not in ("MAM_JOBS_DIR", "DELEGATE_JOB_LOGS_DIR")} + since = _ncalls(mock_herdr) + res = subprocess.run(["bash", _skill(tmp, "multi-agent-mux-resume", "scripts", "resume_session.sh"), + "--workspace", str(wt), "--agent", "claude", "--session", name], + capture_output=True, text=True, env=env, timeout=180) + assert res.returncode == 0, res.stdout + res.stderr + # The stopped session left an idle pane behind. A binding that must be + # forwarded is never put in front of `agent start --` (herdr passes those + # tokens to the agent as arguments, review R1): resume takes a new pane, + # whose creation call carries the binding as --env. + flags = _env_flags(mock_herdr, since) + for k, v in zip(CP_ENV, _binding(tmp).values()): + assert f"{k}={v}" in flags, (res.stderr[-1500:], flags) + _assert_no_env_in_agent_args(mock_herdr, since) + (row,) = _rows(tmp) + assert row["control_plane"] == _binding(tmp) + + +def test_t3_4b_stop_finds_the_job_in_the_rows_registry(mam_sandbox, mock_herdr, mock_agents, monkeypatch): + from lib_py.delegation import registry + tmp = mam_sandbox + ctl_jobs = tmp / "ctl" / "jobs" + jid = registry.register_job("t3-4", registry_dir=str(ctl_jobs), agent_session="herdr:t34-creator-claude") + binding = {**_binding(tmp), "mam_jobs_dir": os.path.realpath(ctl_jobs)} + _mutate(tmp, f""" +d['herdr_sessions'] = [{{ + 'name': 't34-creator-claude', 'status': 'running', 'role': 'creator', + 'delegate_job_id': {jid!r}, 'pane': {{'cwd': {str(tmp)!r}, 'cmd': 'claude'}}, + 'control_plane': {binding!r}, +}}] +""") + env = {k: v for k, v in os.environ.items() if k != "MAM_JOBS_DIR"} + res = subprocess.run(["bash", _skill(tmp, "multi-agent-mux-stop", "scripts", "stop_session.sh"), + "--session", "t34-creator-claude", "--agent", "claude"], + capture_output=True, text=True, env=env, timeout=120) + assert res.returncode == 0, res.stdout + res.stderr + assert (ctl_jobs / f"{jid}.events.log").exists(), "stop published into the row's registry" + assert not (tmp / ".mam" / "jobs" / f"{jid}.json").exists() + + +def test_t3_6_status_reads_mam_jobs_dir_and_the_row_binding(mam_sandbox, monkeypatch): + tmp = mam_sandbox + x, y = tmp.parent / f"x-{tmp.name}", tmp / "row-jobs" + for d, jid in ((x, "job21x"), (y, "job21y")): + d.mkdir(parents=True, exist_ok=True) + (d / f"{jid}.json").write_text('{"status": "completed"}') + status_sh = _skill(tmp, "multi-agent-mux-status", "scripts", "status.sh") + try: + _mutate(tmp, f""" +d['herdr_sessions'] = [{{'name': 's21-creator-claude', 'status': 'running', 'delegate_job_id': 'job21x', + 'pane': {{'cwd': {str(tmp)!r}}}}}] +""") + env = {**os.environ, "MAM_JOBS_DIR": str(x)} + res = subprocess.run(["bash", status_sh, "--json"], capture_output=True, text=True, env=env, timeout=120) + assert res.returncode == 0, res.stderr + assert json.loads(res.stdout)["sessions_detail"][0]["job_status"] == "completed" + + _mutate(tmp, f""" +d['herdr_sessions'] = [{{'name': 's21-creator-claude', 'status': 'running', 'delegate_job_id': 'job21y', + 'pane': {{'cwd': {str(tmp)!r}}}, 'control_plane': {{'mam_jobs_dir': {str(y)!r}}}}}] +""") + env = {k: v for k, v in os.environ.items() if k != "MAM_JOBS_DIR"} + res = subprocess.run(["bash", status_sh, "--json"], capture_output=True, text=True, env=env, timeout=120) + assert json.loads(res.stdout)["sessions_detail"][0]["job_status"] == "completed" + finally: + import shutil + shutil.rmtree(x, ignore_errors=True) + + +def test_t3_7_reconcile_prefers_mam_jobs_dir_static_guard(): + """Weak by design: the subscribe loop is a heredoc run only against a live + broker, so this pins the source text.""" + src = (SKILLS / "multi-agent-mux-monitor" / "scripts" / "reconcile.sh").read_text() + assert "if os.environ.get('MAM_JOBS_DIR'):\n registry_dir = os.environ['MAM_JOBS_DIR']" in src + assert 'MAM_JOBS_DIR="${MAM_JOBS_DIR:-}"' in src + + +def test_t3_8_legacy_row_without_binding_is_filled_on_resume(mam_sandbox, mock_herdr, mock_agents): + tmp = mam_sandbox + _mutate(tmp, f""" +d['herdr_sessions'] = [{{ + 'name': 'legacy-creator-claude', 'status': 'stopped', 'role': 'creator', + 'herdr_session': 'default', 'herdr_server': 'default', + 'pane': {{'cwd': {str(tmp)!r}, 'cmd': 'claude'}}, + 'claude_session_id_own': '11111111-2222-4333-8444-555555555555', + 'session_id_source': 'assigned', 'session_id_verified': False, +}}] +""") + res = subprocess.run(["bash", _skill(tmp, "multi-agent-mux-resume", "scripts", "resume_session.sh"), + "--workspace", str(tmp), "--agent", "claude", "--session", "legacy-creator-claude"], + capture_output=True, text=True, timeout=180) + assert res.returncode == 0, res.stdout + res.stderr + (row,) = _rows(tmp) + assert row["control_plane"] == _binding(tmp) + + +def test_t3_4c_stop_keeps_the_audit_trail_in_the_rows_log_root(mam_sandbox, mock_herdr, mock_agents, monkeypatch): + """Review R3: with no MAM_JOBS_DIR / DELEGATE_JOB_LOGS_DIR in the caller, + stop cancels the job in the row's registry and writes its history to the + row's audit-log root - not to $WORKSPACE_ROOT/.mam/delegate_job_logs.""" + from lib_py.delegation import registry + tmp = mam_sandbox + jobs, logs = tmp / "custom-jobs", tmp / "custom-logs" + # the job was registered under that binding (its `registered` audit entry lives there) + monkeypatch.setenv("DELEGATE_JOB_LOGS_DIR", str(logs)) + jid = registry.register_job("t3-4c", registry_dir=str(jobs), agent_session="herdr:logs-creator-claude") + binding = {**_binding(tmp), "mam_jobs_dir": str(jobs), "delegate_job_logs_dir": str(logs)} + _mutate(tmp, f""" +d['herdr_sessions'] = [{{ + 'name': 'logs-creator-claude', 'status': 'running', 'role': 'creator', + 'delegate_job_id': {jid!r}, 'pane': {{'cwd': {str(tmp)!r}, 'cmd': 'claude'}}, + 'control_plane': {binding!r}, +}}] +""") + env = {k: v for k, v in os.environ.items() if k not in ("MAM_JOBS_DIR", "DELEGATE_JOB_LOGS_DIR")} + res = subprocess.run(["bash", _skill(tmp, "multi-agent-mux-stop", "scripts", "stop_session.sh"), + "--session", "logs-creator-claude", "--agent", "claude"], + capture_output=True, text=True, env=env, timeout=120) + assert res.returncode == 0, res.stdout + res.stderr + assert json.loads((jobs / f"{jid}.json").read_text())["status"] == "cancelled" + assert not (tmp / ".mam" / "delegate_job_logs" / jid).exists(), "history misrouted to the default log root" + audit = [json.loads(l) for l in (logs / jid / "events.ndjson").read_text().splitlines() if l.strip()] + assert {"progress", "cancelled"} <= {e.get("source_event") for e in audit}, audit + assert any(e.get("event") == "status_changed" for e in audit), audit + + +def test_t2_15_relative_mam_jobs_dir_means_the_same_dir_to_runner_and_wrapper(tmp_path, monkeypatch): + """Review R2: the wrapper runs with cwd=workspace_root and absolutises a + relative registry there; the runner must look in that same directory.""" + from lib_py.loop import runner as lr + ctl, caller = tmp_path / "control", tmp_path / "caller" + ctl.mkdir() + caller.mkdir() + monkeypatch.chdir(caller) + monkeypatch.setenv("WORKSPACE_ROOT", str(ctl)) + monkeypatch.delenv("MAM_REAL_ROOT", raising=False) + monkeypatch.setenv("MAM_JOBS_DIR", ".mam/jobs") + r = _runner() + jobs = Path(os.path.realpath(ctl)) / ".mam" / "jobs" + assert r.jobs_dir == jobs and r.jobs_dir.is_absolute() + report = jobs / "j21" / "reviewer-reports" / "report-final.md" + report.parent.mkdir(parents=True) + report.write_text("[VERDICT: PASS]\n") + (jobs / "j21.json").write_text(json.dumps({"status": "completed"})) + assert r.find_report("j21") == report + assert r.wait_for_job("j21", max_wait=2) is True + seen = [] + + def fake_run(cmd, **kw): + seen.append(cmd) + + class P: + returncode = 0 + stdout = "registered job: j22\n" + stderr = "" + return P() + + monkeypatch.setattr(lr.subprocess, "run", fake_run) + r.delegate_job("creator-claude-01", "claude", "Worker", "go") + assert seen[0][seen[0].index("--registry-dir") + 1] == str(jobs), "the wrapper is told the same absolute dir" + assert not (caller / ".mam").exists()