diff --git a/.agents/reports/planner-reviewer-claude-01/report-291e38b5.md b/.agents/reports/planner-reviewer-claude-01/report-291e38b5.md new file mode 100644 index 0000000..77cc861 --- /dev/null +++ b/.agents/reports/planner-reviewer-claude-01/report-291e38b5.md @@ -0,0 +1,170 @@ +# ๐Ÿ” Review Report โ€” PR #29 ๋ณ‘ํ•ฉ ์ „ ๋…๋ฆฝ ์žฌ๊ฒ€ํ†  (job `291e38b5`) + +- **Reviewer**: `planner-reviewer-claude-01` (role: planner,reviewer) ยท 2026-09-20 +- **๋Œ€์ƒ**: PR #29 `feat/issue-21-items-4-5-7` **@ `46ea4cc`**, base `main @ 1134d74` (v4.6.0). 20์ปค๋ฐ‹, `git diff --stat 1134d74...HEAD` = **25 files, +3229/โˆ’108**. +- **๊ฒ€ํ†  ํŠธ๋ฆฌ**: ์›ŒํฌํŠธ๋ฆฌ `/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux-wt/pr-29` (detached @ `46ea4cc`, ์ž์ฒด `.venv`). +- **์•ˆ์ „ ๊ทœ์น™ ์ค€์ˆ˜**: ์›ŒํฌํŠธ๋ฆฌ์— ํŒŒ์ผ ์ˆ˜์ •ยท์ปค๋ฐ‹ยทcheckout ์—†์Œ. ๋ณธ ์ฒดํฌ์•„์›ƒ์€ `main @ 1134d74` ๊ทธ๋Œ€๋กœ(checkout/switch/merge ์—†์Œ). `worktree_remove.sh` ๋ฅผ ์‹ค์ œ ์›ŒํฌํŠธ๋ฆฌ์— ์‹คํ–‰ํ•˜์ง€ ์•Š์•˜๋‹ค. ์‹ค์ œ `.mam`ยทmonitor ๋ฐ๋ชฌยท์—์ด์ „ํŠธ ์„ธ์…˜ ์กฐ์ž‘ ์—†์Œ, pushยทmergeยท์ด์Šˆ/PR ์ƒํƒœ ๋ณ€๊ฒฝ ์—†์Œ. ์žฌํ˜„ ์‹คํ—˜์€ scratchpad ์˜ ์ž„์‹œ ์ €์žฅ์†Œ(uuid4 ์ด๋ฆ„)์—์„œ๋งŒ ํ–ˆ๋‹ค. +- **heartbeat**: ๋ธŒ๋ฆฌํ”„ ์งํ›„ `started --heartbeat 30` ์„ ๊ฐ€์žฅ ๋จผ์ € ๋ฐœํ–‰ํ–ˆ๋‹ค. + +## ๊ฒฐ๋ก  ์š”์•ฝ + +๋ธŒ๋ฆฌํ”„์˜ 8๊ฐœ ํ•ญ๋ชฉ์„ ๋ชจ๋‘ ํ™•์ธํ–ˆ๋‹ค. **๋ณ‘ํ•ฉ์„ ๋ง‰์„ ๊ฒฐํ•จ์€ ์ฐพ์ง€ ๋ชปํ–ˆ๋‹ค.** ํ•ญ๋ชฉ 5(๋ฆฌ๋ทฐ identity)์˜ ์„ค๊ณ„ยท๊ตฌํ˜„ ํ’ˆ์งˆ์€ ๋†’๊ณ , ๊ธฐ๋ณธ ๊ฒฝ๋กœ(์˜ต์…˜ ์—†๋Š” ๋ฃจํ”„)์˜ ํ˜ธํ™˜์„ฑ์€ ์ฝ”๋“œ์ƒ ๋ณด์žฅ๋œ๋‹ค. ์•„๋ž˜ **๋น„์ฐจ๋‹จ ์ง€์  9๊ฑด**(N1โ€“N9)์„ ๋‚จ๊ธด๋‹ค. ๊ทธ์ค‘ N1 ์€ ์žฌํ˜„ ์‹คํ—˜์œผ๋กœ ํ™•์ธํ•œ ์‹ค์ œ ์†Œ์‹ค ๊ฐ€๋Šฅ์„ฑ(ํŒ์ • ์ฆ๊ฑฐ์˜ gc ์†Œ๋ฉธ)์ด๋ฏ€๋กœ ํ›„์† ์ด์Šˆ ๋“ฑ๋ก์„ ๊ถŒํ•œ๋‹ค. + +## 1. ํ•ญ๋ชฉ 4 โ€” ์›ŒํฌํŠธ๋ฆฌ ์ค€๋น„ยทํ•ด์ฒด ํ—ฌํผ + +### 1-1. `.venv` โ€” ๋ธŒ๋ฆฌํ”„๊ฐ€ ๋ฌผ์€ "์‹ค์ œ venv" ๋Š” ์•„๋‹ˆ๋‹ค (N2, ๋น„์ฐจ๋‹จ) + +`worktree_prepare.sh:111-118` ์€ ์—ฌ์ „ํžˆ **์‹ฌ๋งํฌ**๋ฅผ ๋งŒ๋“ ๋‹ค(`ln -s "$MAIN/.venv" "$WT/.venv"`). ์‹ค์ œ venv ์ƒ์„ฑ๋„, ์ธํ„ฐํ”„๋ฆฌํ„ฐ ๊ฒฝ๋กœ ์ „๋‹ฌ๋„ ํ•˜์ง€ ์•Š๋Š”๋‹ค. ๋‹ค๋งŒ ์ด๋ฒˆ PRยทbase ์—์„œ **์ปค๋ฐ‹ ์‚ฌ๊ณ  ๊ฒฝ๋กœ๋Š” 3์ค‘์œผ๋กœ ๋‹ซํ˜€ ์žˆ๋‹ค**: + +| ์ธต | ๊ทผ๊ฑฐ | +|---|---| +| ์ €์žฅ์†Œ `.gitignore` | base `1134d74` ์˜ `.gitignore:15` ๊ฐ€ `.venv`(์Šฌ๋ž˜์‹œ ์—†์Œ) โ€” ํŒŒ์ผยท๋งํฌยท๋””๋ ‰ํ„ฐ๋ฆฌ ๋ชจ๋‘ ๋ฌด์‹œ | +| ๊ณต์œ  `info/exclude` | `worktree_prepare.sh:120-129` ๊ฐ€ `/.venv`ยท`.pytest_cache/` ๋ฅผ `$GIT_COMMON_DIR/info/exclude` ์— ์ถ”๊ฐ€ | +| ๋ฐฐํฌ ๊ด€๋ฆฌ ๋ธ”๋ก | `deploy/install.sh:549`ยท`deploy/install_mam.sh:249` ๊ฐ€ `/.venv/` โ†’ `/.venv` (#27 `fadd38c`) | + +๋‚จ๋Š” ์œ„ํ—˜์€ ๋‘ ๊ฐ€์ง€์ด๋ฉฐ, ๋‘˜ ๋‹ค ํ—ฌํผ๊ฐ€ ์œ ๋ฐœํ•˜์ง€๋Š” ์•Š๋Š”๋‹ค. +- `rm -rf "$WT/.venv/"` ์ฒ˜๋Ÿผ **๋์— ์Šฌ๋ž˜์‹œ๋ฅผ ๋ถ™์ด๋ฉด ๋งํฌ๋ฅผ ๋”ฐ๋ผ๊ฐ€** ์ œ์–ด ๋ฃจํŠธ์˜ venv ๋‚ด์šฉ์„ ์ง€์šด๋‹ค. ํ—ฌํผ๋Š” ๊ทธ๋Ÿฐ ํ˜ธ์ถœ์„ ํ•˜์ง€ ์•Š์ง€๋งŒ(์Šคํฌ๋ฆฝํŠธ 2๊ฐœ์˜ `rm -rf` ๋Š” `$gd/modules*` ๋ฟ), ์‚ฌ๋žŒยท์—์ด์ „ํŠธ์˜ ์ˆ˜์ž‘์—…์—์„œ๋Š” ๊ทธ๋Œ€๋กœ ๋‚จ๋Š” ํ•จ์ •์ด๋‹ค. +- ์›ŒํฌํŠธ๋ฆฌ์™€ ๋ณธ ์ฒดํฌ์•„์›ƒ์ด venv ๋ฅผ ๊ณต์œ ํ•˜๋ฏ€๋กœ ๋ธŒ๋žœ์น˜๋ณ„ ์˜์กด์„ฑ ๋ถ„๊ธฐ๊ฐ€ ๋ถˆ๊ฐ€๋Šฅํ•˜๋‹ค. + +โ†’ ๊ถŒ๊ณ : ํ—ฌํผ์— `--venv link|create` ๊ฐ™์€ ์„ ํƒ์ง€๋ฅผ ๋‘๊ฑฐ๋‚˜, ์ตœ์†Œํ•œ SKILL.md ์— "`.venv` ๋Š” ๋งํฌ์ด๋ฏ€๋กœ ์›ŒํฌํŠธ๋ฆฌ์—์„œ `pip install`ยท`rm -rf .venv/` ๊ธˆ์ง€"๋ฅผ ๋ช…์‹œ. + +### 1-2. `info/exclude` ๋Š” ๊ณต์œ ๋ฌผ์ด๋‹ค (N3, ๋น„์ฐจ๋‹จ) + +linked worktree ์˜ `info/exclude` ๋Š” `$GIT_COMMON_DIR` ์˜ ๊ฒƒ์ด๋ฏ€๋กœ prepare ๊ฐ€ **๋ณธ ์ฒดํฌ์•„์›ƒ์˜ ๋ฌด์‹œ ๊ทœ์น™๋„ ๋ฐ”๊พผ๋‹ค**. ์ฝ”๋“œ ์ฃผ์„(`:120-122`)๊ณผ ์„ค๊ณ„ ๋ฌธ์„œ๊ฐ€ ์ด๋ฅผ ๋ช…์‹œํ•˜๊ณ , ์ถ”๊ฐ€๋˜๋Š” ๋‘ ํŒจํ„ด์€ ๋ณธ ์ฒดํฌ์•„์›ƒ์—์„œ๋„ ์‚ฐ์ถœ๋ฌผ์ด๋‹ค. ์ˆ˜์šฉ ๊ฐ€๋Šฅํ•˜๋‚˜, ๋˜๋Œ๋ฆฌ๋Š” ๊ฒฝ๋กœ(remove ์‹œ ์ •๋ฆฌ)๋Š” ์—†๋‹ค. + +### 1-3. ์„œ๋ธŒ๋ชจ๋“ˆ opt-inยท๋กค๋ฐฑ + +`worktree_prepare.sh:131-204`: `--submodules` ์ผ ๋•Œ๋งŒ ์ดˆ๊ธฐํ™”ํ•˜๊ณ , ์ดˆ๊ธฐํ™” ์ „ ์ƒํƒœ(๊ฐ ์„œ๋ธŒ๋ชจ๋“ˆ์˜ git dir ์กด์žฌ ์—ฌ๋ถ€, ๊ณต์šฉ `.git/config` ์˜ `submodule..url|active`)๋ฅผ **์ธ๋ฑ์Šค ๋ฐฐ์—ด**(bash 3.2 ํ˜ธํ™˜)๋กœ ๊ธฐ๋กํ•œ๋‹ค. ์ดˆ๊ธฐํ™” ์•ˆ ๋œ ๊ฒฝ๋กœ์— ํŒŒ์ผ์ด ์ด๋ฏธ ์žˆ์œผ๋ฉด **์•„๋ฌด๊ฒƒ๋„ ๋ฐ”๊พธ๊ธฐ ์ „์—** exit 3 ์œผ๋กœ ๊ฑฐ๋ถ€ํ•œ๋‹ค(R4) โ€” ๋กค๋ฐฑ์ด "์ด๋ฒˆ ์‹œ๋„๊ฐ€ ๋งŒ๋“  ๊ฒƒ"๋งŒ ์ง€์šธ ์ˆ˜ ์žˆ๋‹ค๋Š” ์ „์ œ๋ฅผ ์‹ค์ œ๋กœ ์„ฑ๋ฆฝ์‹œํ‚ค๋Š” ์˜ฌ๋ฐ”๋ฅธ ์ˆœ์„œ๋‹ค. ์‹คํŒจ ์‹œ deinit โ†’ `.git` ํŒŒ์ผ ์ œ๊ฑฐ โ†’ `modules/` ์ œ๊ฑฐ โ†’ ์—†๋˜ `modules/` ์ œ๊ฑฐ โ†’ config ๋ณต์› ์ˆœ์œผ๋กœ ๋˜๋Œ๋ฆฌ๊ณ , ๋งˆ์ง€๋ง‰์— `git status` ๋กœ ์›ŒํฌํŠธ๋ฆฌ๊ฐ€ ์—ฌ์ „ํžˆ ์“ธ ์ˆ˜ ์žˆ๋Š” ์ฒดํฌ์•„์›ƒ์ธ์ง€ ํ™•์ธํ•ด ๋ฉ”์‹œ์ง€๋ฅผ ๊ฐˆ๋ผ ์ค€๋‹ค. ์‚ฌ๋žŒ์ด ๊ฐ•์ œ ์ œ๊ฑฐ๋กœ ๋” ๋ง๊ฐ€๋œจ๋ฆฌ์ง€ ๋ง๋ผ๋Š” ์•ˆ๋‚ด๊นŒ์ง€ ์žˆ๋‹ค. + +### 1-4. `--force` ๊ฐ€ ์ž‘์—…์„ ์žƒ์„ ์ˆ˜ ์žˆ๋Š”๊ฐ€ โ€” ์žƒ์ง€ ์•Š๋Š”๋‹ค + +`worktree_remove.sh` ์˜ ํŒ์ •์€ **๋ณ€๊ฒฝ์„ ๊ฐ€ํ•˜๊ธฐ ์ „์—** ๋ชจ๋‘ ๋๋‚œ๋‹ค(`:57-121`): ์ž ๊ธˆ, ์‹คํ–‰ ์ค‘ ์„ธ์…˜(`--stop` ์—†์œผ๋ฉด ๊ฑฐ๋ถ€), ์—ด๋ฆฐ ์žก, ํŠธ๋ฆฌ ์ฒญ๊ฒฐ(์„œ๋ธŒ๋ชจ๋“ˆ ํฌํ•จ), ์„œ๋ธŒ๋ชจ๋“ˆ ์†Œ๊ฒฌ. `--force` ๋Š” ์›ŒํฌํŠธ๋ฆฌ git dir ์— `modules/` ๊ฐ€ ์žˆ์–ด git ์ด plain remove ๋ฅผ ๊ฑฐ๋ถ€ํ•˜๋Š” ๊ฒฝ์šฐ์—๋งŒ, ๊ทธ๊ฒƒ๋„ **๋‹จ ํ•œ ๋ฒˆ** ์“ด๋‹ค(`METHOD="single --force"`). + +- **R1(์ •์ง€ ์ค‘ ์“ฐ๊ธฐ)**: `judge_tree()` ๋ฅผ `--stop` **์ดํ›„ ๋‹ค์‹œ** ์‹คํ–‰ํ•˜๊ณ , ๋‹ฌ๋ผ์กŒ์œผ๋ฉด `:154` ์—์„œ ๊ฑฐ๋ถ€ํ•œ๋‹ค. +- **R6(tag/notes ๋กœ๋งŒ ๋ณด์กด๋œ ์ปค๋ฐ‹)**: `lib_py/worktree.py:UNPUSHED_REVS = ("HEAD","--exclude=refs/stash","--all","--not","--remotes")` ๋กœ ๋ชจ๋“  ๋กœ์ปฌ ref ๊ธฐ์ค€ ๋ฏธํ‘ธ์‹œ ์ปค๋ฐ‹์„ ๋ณธ๋‹ค. stash ๋Š” ๋ณ„๋„ ํ™•์ธ. +- **R5(deinit ์ €์žฅ์†Œ)**: `submodule foreach` ๋กœ ๋‹ฟ์ง€ ์•Š๋Š” `modules/` ์•„๋ž˜ ์ €์žฅ์†Œ๊นŒ์ง€ `--git-dir` ๋กœ ์ง์ ‘ ๊ฒ€์‚ฌํ•˜๊ณ , ํŒ๋‹จ ๋ถˆ๊ฐ€ํ•œ "๋ถ€๋ถ„ ์ €์žฅ์†Œ"๋Š” ์˜ค๋ฅ˜๋กœ ๋‚จ๊ฒจ ๊ฑฐ๋ถ€ํ•œ๋‹ค. + +์ฆ‰ `--force` ์‹œ์ ์—๋Š” "๋ฏธ์ปค๋ฐ‹ยท๋ฏธํ‘ธ์‹œยทstash ์—†์Œ"์ด ์ฆ๋ช…๋ผ ์žˆ๋‹ค. ํ•œ ๊ฐ€์ง€๋งŒ ์ ์–ด ๋‘”๋‹ค(N8): plain/force ์–ด๋А ์ชฝ์ด๋“  git ์€ **๋ฌด์‹œ๋œ ํŒŒ์ผ**์„ ๋ง์—†์ด ์ง€์šด๋‹ค. ์›ŒํฌํŠธ๋ฆฌ์— ํ…Œ์ŠคํŠธ๊ฐ€ ๋‚จ๊ธด `.mam/delegate_job_logs` ๊ฐ™์€ ์‚ฐ์ถœ๋ฌผ์€ ์ œ๊ฑฐ์™€ ํ•จ๊ป˜ ์‚ฌ๋ผ์ง„๋‹ค(์ œ์–ด ํ‰๋ฉด์ด ๋ณธ ์ฒดํฌ์•„์›ƒ์ด๋ฏ€๋กœ ์„ค๊ณ„์ƒ ๋ฌดํ•ด). + +๋˜ ํ•˜๋‚˜(N7): ์—ด๋ฆฐ ์žก ๊ฒ€์‚ฌ๋Š” `registry_dirs`(`MAM_JOBS_DIR` โ†’ `WORKSPACE_ROOT/.mam/jobs` โ†’ ํ–‰๋“ค์˜ `control_plane.mam_jobs_dir`)๋งŒ ๋ณธ๋‹ค. ํ–‰์ด ์—†๋Š” ์›ŒํฌํŠธ๋ฆฌ ๋กœ์ปฌ `.mam/jobs` ์˜ ์žก์€ ๋ณด์ด์ง€ ์•Š๋Š”๋‹ค. prepare ๊ฐ€ ๋งŒ๋“  creator ๋Š” ์ œ์–ด ๋ฃจํŠธ์— ๋ฌถ์ด๋ฏ€๋กœ ๋ชจ๋ธ์ƒ ๋ฐœ์ƒํ•˜์ง€ ์•Š๋Š” ๊ฒฝ๋กœ์ง€๋งŒ, ์ˆ˜์ž‘์—…์œผ๋กœ ๋งŒ๋“  ์›ŒํฌํŠธ๋ฆฌ์—๋Š” ํ•ด๋‹นํ•œ๋‹ค. + +## 2. ํ•ญ๋ชฉ 4 โ€” resume ๊ณ„์•ฝ ๋ณ€๊ฒฝ(`2679ddf`)์€ ๊ธฐ์กด ์‚ฌ์šฉ์ž๋ฅผ ๊นจ์ง€ ์•Š๋Š”๋‹ค + +`resume_session.sh:50-70` ์ด ํ–‰์˜ `pane.cwd` ์™€ `--workspace` ๋ฅผ ๋น„๊ตํ•ด ๋‹ค๋ฅด๋ฉด exit 2, ๊ฒฝ๋กœ๊ฐ€ ์‚ฌ๋ผ์กŒ์œผ๋ฉด exit 2 ๋กœ ์•ˆ๋‚ดํ•œ๋‹ค. ๊ธฐ์กด ๋‹จ์ผ ์›Œํฌ์ŠคํŽ˜์ด์Šค ์‚ฌ์šฉ์ž์—๊ฒŒ ์˜ํ–ฅ์ด ์—†๋Š” ์ด์œ : + +1. `--workspace` ๋Š” ์›๋ž˜๋ถ€ํ„ฐ **ํ•„์ˆ˜ ์ธ์ž**์ด๊ณ (`:42`), ๊ธฐ์กด ํ–‰์˜ `pane.cwd` ๋Š” ๊ทธ ์›Œํฌ์ŠคํŽ˜์ด์Šค ๊ฒฝ๋กœ๋‹ค. ๋ณธ ์ฒดํฌ์•„์›ƒ์˜ ์‹ค์ œ `.mam/agent-sessions.yaml` 5๊ฐœ ํ–‰ ๋ชจ๋‘ `cwd: /Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux` ๋กœ, ํ‰์†Œ ํ˜ธ์ถœ๊ณผ ์ผ์น˜ํ•œ๋‹ค. +2. ๋น„๊ต๋Š” `mam_abs_workspace`(`lib.sh:1606-1609`, `cd -P โ€ฆ pwd -P`)๋กœ ์ •๊ทœํ™”ํ•œ ๋’ค ํ•˜๋ฏ€๋กœ ์‹ฌ๋งํฌยท์ƒ๋Œ€๊ฒฝ๋กœ ํ‘œ๊ธฐ ์ฐจ์ด๋Š” ํ†ต๊ณผํ•œ๋‹ค. +3. `pane.cwd` ๊ฐ€ ์—†๋Š” ๊ตฌ(่ˆŠ) ํ–‰์€ `ROW_CWD` ๊ฐ€ ๋น„์–ด ๊ฒ€์‚ฌ ์ž์ฒด๋ฅผ ๊ฑด๋„ˆ๋›ด๋‹ค. + +๋ฐ”์ธ๋”ฉ ์˜์†์€ **์ด๋ฏธ ์‹คํ–‰ ์ค‘์ธ ๊ฒฝ๋กœ์—๋„** ์ ์šฉ๋œ๋‹ค: `:145` ๊ฐ€ `CONTROL_PLANE_JSON="$CP_JSON"` ๋ฅผ `update_yaml_resumed.sh` ์— ๋„˜๊ธฐ๊ณ , ๊ทธ์ชฝ `:168` ์ด ์ด๋ฅผ ์ฝ์–ด ํ–‰์— ๊ธฐ๋กํ•œ๋‹ค(A13). ๊ณ„์‚ฐ ์‹คํŒจ ์‹œ ์›ŒํฌํŠธ๋ฆฌ๋ฉด exit 2, ์ œ์–ด ๋ฃจํŠธ๋ฉด ๊ฒฝ๊ณ  ํ›„ ๋ฌด๊ธฐ๋ก โ€” create ์ชฝ(`create_session.sh:211-221`)๊ณผ ๊ฐ™์€ ๊ทœ์น™์ด๋‹ค. + +## 3. ํ•ญ๋ชฉ 5 โ€” ๋ฆฌ๋ทฐ identity + +- **๊ธฐ๋ก**: `_record_round`(`runner.py`)๊ฐ€ ๋ผ์šด๋“œ๋งˆ๋‹ค `iteration/base_sha/head_sha/tree_sha/review_commit/isolation/review_path/jobs/verdicts/reviewers(evidence)/stale` ๋ฅผ ๋‚จ๊ธฐ๊ณ  ์ฒดํฌํฌ์ธํŠธ์— `loop_id` ์™€ `review_rounds` ๋ฅผ ์‹ฃ๋Š”๋‹ค. ์Šน๊ฒฉ ์‹œ์—๋Š” ๋ฆฌํฌํŠธ ์˜†์— `report-.identity.json` ์„ ์›์ž์ ์œผ๋กœ(`.tmp` โ†’ `replace`) ์“ด๋‹ค. +- **์Šค๋ƒ…์ƒท**: `_worktree_tree()` ๊ฐ€ ์ž„์‹œ `GIT_INDEX_FILE` ๋กœ `read-tree HEAD` โ†’ `add -A` โ†’ `write-tree` ํ•˜์—ฌ **์‹ค์ œ ์ธ๋ฑ์Šคยทstatusยทref ๋ฅผ ๊ฑด๋“œ๋ฆฌ์ง€ ์•Š๋Š”๋‹ค**. ๋ฏธ์ปค๋ฐ‹์ด ์žˆ์œผ๋ฉด `commit-tree` ๋กœ ref ์—†๋Š” ์Šค๋ƒ…์ƒท ์ปค๋ฐ‹์„ ๋งŒ๋“ ๋‹ค. untracked ์ค‘์ฒฉ ์ €์žฅ์†Œ๋Š” gitlink ๋งŒ ๋‚จ์•„ ๊ณ ์ • ๋ถˆ๊ฐ€์ด๋ฏ€๋กœ ๊ฑฐ๋ถ€ํ•˜๊ณ (`_snapshot`), dirty ์„œ๋ธŒ๋ชจ๋“ˆ๋„ ๊ฑฐ๋ถ€ํ•œ๋‹ค(`_dirty_submodules` ๋Š” `--ignore-submodules=none` ์œผ๋กœ ๋ฌผ์–ด `submodule.*.ignore` ์„ค์ •์ด ์ˆจ๊ธฐ์ง€ ๋ชปํ•˜๊ฒŒ ํ•œ๋‹ค). ํŒ์ • ๋ถˆ๊ฐ€(`None`)๋Š” ์ „๋ถ€ ์‹คํŒจ๋กœ ๋–จ์–ด์ง€๋Š” fail-closed ๋‹ค. +- **๊ฒฉ๋ฆฌ**: `_open_review_tree` ๋Š” `TMPDIR/mam-review//` ์— detached ์›ŒํฌํŠธ๋ฆฌ๋ฅผ ๋งŒ๋“ค๊ณ , **์‹คํŒจํ•˜๋ฉด ๊ฒฉ๋ฆฌ๋ฅผ ๋‚ฎ์ถ”์ง€ ์•Š๊ณ  exit 1**(๋กœ๊ทธ ๋ฌธ๊ตฌ๊นŒ์ง€ ๊ทธ๋ ‡๊ฒŒ ์ ํ˜€ ์žˆ๋‹ค). `_close_review_tree` ๋Š” remove ์‹คํŒจ ์‹œ์—๋งŒ `prune` ์„ ๋ถ€๋ฅด๊ณ , ๋ถ€๋ชจ ๋””๋ ‰ํ„ฐ๋ฆฌ๋Š” ๋น„์—ˆ์„ ๋•Œ๋งŒ ์ง€์šด๋‹ค. `run()` ์ด `_run_body()` ๋ฅผ `finally: _close_all_review_trees()` ๋กœ ๊ฐ์‹ธ๊ณ  `release_guard` ๋„ ๊ฐ™์€ ์ •๋ฆฌ๋ฅผ ํ•œ๋‹ค. +- **stale ์žฌ๋ฆฌ๋ทฐ**: ๋ผ์šด๋“œ ํŒ์ • ๋’ค `_round_is_stale`(ํ˜„์žฌ headยทtree ๋น„๊ต)๋กœ ์†Œ์Šค ๋ณ€๊ฒฝ์„ ์žก์•„ ์Šน์ธ ์ „๋Ÿ‰ ํ๊ธฐ ํ›„ ์žฌ๋ฆฌ๋ทฐ, ๋งˆ์ง€๋ง‰ ๋ผ์šด๋“œ๋ฉด exit 1. ์Šน๊ฒฉ ์ง์ „์—๋„ `_identity_now() != (accepted head, tree)` ๋ฉด "source changed after approval; not promoting" ์œผ๋กœ exit 1(B10). +- **์›๊ฒฉ ๋ฆฌ๋ทฐ์–ด**: `_check_remote_recipients` ๊ฐ€ **์–ด๋–ค ์žก๋„ ์œ„์ž„ํ•˜๊ธฐ ์ „์—** ํŒ์ • ์ˆ˜์‹ ์ž ์ „์›์„ ๊ฒ€์‚ฌํ•ด ๊ธฐ๋ณธ ๊ฑฐ๋ถ€, `--remote-review diff-only` ๋งŒ ํ—ˆ์šฉํ•˜๋ฉฐ ๊ทธ๋•Œ diff ๊ฐ€ truncated/unavailable ์ด๋ฉด ์ฆ๊ฑฐ๊ฐ€ ์—†๋‹ค๊ณ  exit 1. +- **๊ธฐ๋ณธ ๋™์ž‘ ๋™์ผ์„ฑ**: `_context_active()` ๊ฐ€ ๊ฑฐ์ง“์ด๋ฉด `_worktree_context()` ๋Š” ๋นˆ ๋ฌธ์ž์—ด์„ ๋ฐ˜ํ™˜ํ•˜๊ณ  `collect_changes_diff(None)` ์€ ๊ธฐ์กด ํ˜ธ์ถœ๊ณผ ๋™์ผํ•˜๋‹ค. `_dispatch` ์˜ ์ƒˆ ์ธ์ž๋Š” ๊ธฐ๋ณธ๊ฐ’(`remote=False, purpose="work"`)์ด๋ผ ํ”„๋กฌํ”„ํŠธ๋Š” ๋ฐ”์ดํŠธ ๋™์ผํ•˜๋‹ค. ๋‹ค๋งŒ ์˜ต์…˜๊ณผ ๋ฌด๊ด€ํ•˜๊ฒŒ `Loop id:` ๋กœ๊ทธ 1์ค„๊ณผ ์ฒดํฌํฌ์ธํŠธ์˜ `loop_id` ํ•„๋“œ๊ฐ€ ๋Š˜์—ˆ๋‹ค(N9, ๋ฌดํ•ด). + +### N1 (๋น„์ฐจ๋‹จ, ํ›„์† ๊ถŒ๊ณ ) โ€” ์Šค๋ƒ…์ƒท ์ปค๋ฐ‹์ด gc ๋กœ ์‚ฌ๋ผ์ง„๋‹ค + +`_snapshot` ์˜ ์Šค๋ƒ…์ƒท ์ปค๋ฐ‹์€ ref ๊ฐ€ ์—†๊ณ , ์œ ์ผํ•œ ๋„๋‹ฌ ๊ฒฝ๋กœ์˜€๋˜ ๋ฆฌ๋ทฐ ์›ŒํฌํŠธ๋ฆฌ HEAD ๋Š” `_close_review_tree` ๊ฐ€ ๋ผ์šด๋“œ ํŒ์ • ์งํ›„ ์—†์•ค๋‹ค. ๊ทธ๋ž˜์„œ `identity.json` ์˜ `review_commit`ยท`tree_sha` ๋Š” **๋‚˜์ค‘์— ๊ฒ€์ฆํ•˜๋ ค ํ•  ๋•Œ ์ด๋ฏธ ์—†์„ ์ˆ˜ ์žˆ๋‹ค**. scratchpad ์ž„์‹œ ์ €์žฅ์†Œ์—์„œ ์žฌํ˜„ํ–ˆ๋‹ค(์›ŒํฌํŠธ๋ฆฌ ์ œ๊ฑฐ โ†’ `reflog expire --expire=now --all` โ†’ `gc --prune=now` โ†’ `git cat-file -e ` ์‹คํŒจ). ์ผ์ƒ์ ์œผ๋กœ๋„ unreachable ๊ฐ์ฒด๋Š” ๊ธฐ๋ณธ 2์ฃผ ๋’ค ๋Œ€์ƒ์ด๋‹ค. ๋ฏธ์ปค๋ฐ‹์ด ์žˆ๋˜ ๋ผ์šด๋“œ์—๋งŒ ํ•ด๋‹นํ•˜๋ฉฐ(ํด๋ฆฐ ํŠธ๋ฆฌ๋ฉด `review_commit == head_sha`), ํŒ์ • ์ž์ฒด์˜ ์ •ํ•ฉ์„ฑ์—๋Š” ์˜ํ–ฅ์ด ์—†๋‹ค. + +โ†’ ๊ถŒ๊ณ : `refs/mam/review//` ๊ฐ™์€ ref ๋กœ ์Šค๋ƒ…์ƒท์„ ๊ณ ์ •ํ•˜๊ณ , ๋ฃจํ”„ ์ข…๋ฃŒ ์‹œ ์ •์ฑ…์ ์œผ๋กœ ์ •๋ฆฌ(๋˜๋Š” ๋ณด์กด). + +### N6 (๋น„์ฐจ๋‹จ) โ€” ๊ฐ•์ œ ์ข…๋ฃŒ ์‹œ ๋ฆฌ๋ทฐ ์›ŒํฌํŠธ๋ฆฌ ์ž”์กด + +`finally`/`release_guard`/์‹œ๊ทธ๋„ ํ•ธ๋“ค๋Ÿฌ๋กœ ์ •์ƒยทTERMยทINT ๊ฒฝ๋กœ๋Š” ๋ฎ์ด์ง€๋งŒ, SIGKILL ์ด๋ฉด `TMPDIR/mam-review/...` ๋””๋ ‰ํ„ฐ๋ฆฌ์™€ ์†Œ์Šค ์ €์žฅ์†Œ์˜ worktree ํ•ญ๋ชฉ์ด ๋‚จ๋Š”๋‹ค. `prune` ์€ remove ์‹คํŒจ ์‹œ์—๋งŒ ๋ถ€๋ฅธ๋‹ค(์˜๋„๋œ ์„ ํƒ, ์ฃผ์„์— ๊ทผ๊ฑฐ ์žˆ์Œ). + +## 4. #20 ๊ฒฉ๋ฆฌ โ€” ์„ค๊ณ„์™€ ์‹ค์ธก + +- `tests/conftest.py` ์˜ `pytest_configure` ๊ฐ€ **์ˆ˜์ง‘ ์ด์ „์—** ambient ๋ฐ”์ธ๋”ฉ 4๊ฐœ๋ฅผ ๊ธฐ๋กํ•˜๊ณ  `AGENT_SESSIONS_YAML`ยท`MAM_JOBS_DIR`ยท`DELEGATE_JOB_LOGS_DIR` ๋ฅผ ์ œ๊ฑฐํ•œ๋‹ค. `WORKSPACE_ROOT` ๋Š” **๋‹ค๋ฅธ ์ฒดํฌ์•„์›ƒ์„ ๊ฐ€๋ฆฌํ‚ฌ ๋•Œ๋งŒ** ์ œ๊ฑฐํ•œ๋‹ค(์ด ์ฒดํฌ์•„์›ƒ์„ ๊ฐ€๋ฆฌํ‚ค๋ฉด lib.sh ๊ฐ€ ์–ด์ฐจํ”ผ ์œ ๋„ํ•˜๋Š” ๊ฐ’). import ์‹œ์ ์— `os.environ` ์„ ๋ณต์‚ฌํ•˜๋Š” ๋ชจ๋“ˆ(`test_orc_onboard.SCRUBBED_ENV`) ๋•Œ๋ฌธ์— ์„ธ์…˜ fixture ๋กœ๋Š” ๋Šฆ๋‹ค๋Š” ์„ค๋ช…์ด ์ •ํ™•ํ•˜๋‹ค. `pytest_unconfigure` ๊ฐ€ ๋˜๋Œ๋ ค in-process `pytest.main()` ํ˜ธ์ถœ์ž๋ฅผ ๋ฐฐ๋ คํ•œ๋‹ค. +- autouse `_isolate_mam_state_dirs` ๋Š” **env ๋งŒ** ์„ค์ •ํ•˜๊ณ  ๋””๋ ‰ํ„ฐ๋ฆฌ๋ฅผ ๋งŒ๋“ค์ง€ ์•Š์œผ๋ฉฐ, ์ˆจ๊น€ ์ด๋ฆ„(`.mam-test-state`)์ด๋ผ `tmp_path` ๋ฅผ ๋‚˜์—ดํ•˜๋Š” ๊ธฐ์กด ํ…Œ์ŠคํŠธ๋ฅผ ๊นจ์ง€ ์•Š๋Š”๋‹ค. `mam_sandbox` ์™€ ํ…Œ์ŠคํŠธ ์ž์‹ ์˜ `setenv/delenv` ๊ฐ€ ๋’ค์— ์‹คํ–‰๋ผ ์ด๊ธด๋‹ค. +- ์„ธ์…˜ ๊ฐ€๋“œ `mam_state_leak_guard` + `_mam_state_guard.py` ๋Š” **๋ณด๊ณ ๋งŒ ํ•˜๊ณ  ์ ˆ๋Œ€ ์ง€์šฐ์ง€ ์•Š๋Š”๋‹ค**(์‹ค์ œ ์ œ์–ด ํ‰๋ฉด์ด๋ฏ€๋กœ ์˜ณ๋‹ค). ๊ฐ์‹œ ๋Œ€์ƒ์€ ์ด ์ฒดํฌ์•„์›ƒ์˜ `.mam` ๊ณผ ๊ธฐ๋ก๋œ ๋ฐ”์ธ๋”ฉ์ด ๊ฐ€๋ฆฌํ‚จ ์ œ์–ด ํ‰๋ฉด์ด๋ฉฐ, ๋™์‹œ์— ๋„๋Š” ์‹ค์ œ ์œ„์ž„๊ณผ๋Š” "๋ ˆ์ฝ”๋“œ ๋จผ์ € โ†’ ๋“ฑ๋ก๋œ ์„ธ์…˜" ๊ทœ์น™์œผ๋กœ ๊ตฌ๋ถ„ํ•œ๋‹ค. +- #15 ์˜ตํŠธ์•„์›ƒ(N4, ๋น„์ฐจ๋‹จ): `test_issue15_registry_isolation.py` ์˜ ๋ˆ„์ˆ˜ ๋‹จ์–ธ์ด "์ „์ฒด ๋ชฉ๋ก ๋™์ผ"์—์„œ "์ž๊ธฐ ์žก id ๊ฐ€ ์‹ค์ œ ๋ฃจํŠธ์— ์—†์Œ + ๊ทธ ์‚ฌ์ด ๋Š˜์–ด๋‚œ ํ•ญ๋ชฉ ์ค‘ ์ž๊ธฐ ์„ธ์…˜ ์ด๋ฆ„์ด ์—†์Œ"์œผ๋กœ **์•ฝํ•ด์กŒ๋‹ค**. ๋™์‹œ ์‹คํ–‰๋˜๋Š” ์‹ค์ œ ์œ„์ž„ ๋•Œ๋ฌธ์— ์ „์ฒด ๋น„๊ต๊ฐ€ ์˜คํƒ์„ ๋ƒˆ๋‹ค๋Š” ๊ทผ๊ฑฐ๊ฐ€ ํƒ€๋‹นํ•˜๊ณ , ์„ธ์…˜ ์ด๋ฆ„ ์Šค์บ”์ด ํ•ต์‹ฌ ๋ณด์žฅ์„ ์œ ์ง€ํ•˜๋ฏ€๋กœ ์ˆ˜์šฉํ•œ๋‹ค. + +## 5. acceptance5 ์•ˆ์ •ํ™”(`7bc3e1a`ยท`371099a`ยท`e6d2168`) โ€” ๊ณ„์•ฝ์„ ์•ฝํ™”ํ•˜์ง€ ์•Š๋Š”๊ฐ€ + +| ๋ณ€๊ฒฝ | ํŒ๋‹จ | +|---|---| +| ์„ธ์…˜ ์ด๋ฆ„ยทHMAC secret ์„ ์‹คํ–‰๋งˆ๋‹ค ์œ ๋‹ˆํฌํ•˜๊ฒŒ(`tag = os.urandom(4).hex()`) | **๊ฐœ์„ **. ๊ณต๊ฐœ ๋ธŒ๋กœ์ปค์— retain ๋œ ๊ณผ๊ฑฐ dispatch ๊ฐ€ ์ด๋ฒˆ ์‹คํ–‰์˜ ์žก ๋Œ€์‹  ๋ฐฐ๋‹ฌ๋˜๋˜ ๊ต์ฐจ ์˜ค์—ผ์„ ์—†์•ค๋‹ค | +| `--heartbeat-misses 3 โ†’ 10` | ํ—ˆ์šฉ. ์ด ์‹œํ—˜์˜ ๋ชฉ์ ์€ "์›๊ฒฉ ๋Ÿฌ๋„ˆ๊ฐ€ ๋ธŒ๋กœ์ปค๋งŒ์œผ๋กœ ์žก์„ ๋๋‚ธ๋‹ค"์ด๊ณ , Kร—N ๋ฌด์‘๋‹ต ์ž„๊ณ„ ์ž์ฒด๋Š” AC1ยทAC3 ๊ฐ€ ๊ณ ์ •ํ•œ๋‹ค. ์›๊ฒฉ ๋Ÿฌ๋„ˆ์˜ `started --heartbeat` ๊ณ„์•ฝ์€ ๊ทธ๋Œ€๋กœ๋‹ค | +| ๋‹จ์–ธ์— `advanced to 'completed'` ์ถ”๊ฐ€ | ํ—ˆ์šฉ. retain ๋œ ์ข…๋‹จ ์ด๋ฒคํŠธ๋งŒ ๋ฐฐ๋‹ฌ๋œ ๊ฒฝ์šฐ๋„ ํ”„๋กœํ† ์ฝœ์ƒ ์ •๋‹นํ•œ ๋ฐฐ๋‹ฌ ํ™•์ธ์ด๋‹ค | +| `_skip_if_broker_did_not_deliver` | ์กฐ๊ฑด๋ถ€ ํ—ˆ์šฉ(N5). skip ์€ "๋Ÿฌ๋„ˆ๊ฐ€ started ๋ฅผ ๋ฐœํ–‰ํ–ˆ๊ณ , ๊ตฌ๋…์ž๊ฐ€ **๋งž๋Š” ํ† ํ”ฝ**์— SUBSCRIBED ํ–ˆ๊ณ , ๊ทธ ์žก์— ๋Œ€ํ•ด **์•„๋ฌด๊ฒƒ๋„**(๋“œ๋กญ ๋กœ๊ทธ์กฐ์ฐจ) ๋ฐ›์ง€ ๋ชปํ–ˆ๋‹ค"๋Š” ์„ธ ์กฐ๊ฑด์ด ๋™์‹œ์— ์„ฑ๋ฆฝํ•  ๋•Œ๋งŒ์ด๋‹ค. HMACยทidentity ๋“œ๋กญ, ์ž˜๋ชป๋œ ํ† ํ”ฝ, ๋ฐœํ–‰ ์‹คํŒจ๋Š” ์—ฌ์ „ํžˆ ์‹คํŒจํ•œ๋‹ค. ๋‹ค๋งŒ ๊ตฌ๋…์ž ๋ฐฐ๋‹ฌ ๊ฒฝ๋กœ์˜ ์‹ค์ œ ํšŒ๊ท€๊ฐ€ ๊ฐ™์€ ์ฆ์ƒ์„ ๋‚ด๋ฉด skip ์œผ๋กœ ๊ฐ€๋ ค์งˆ ์ˆ˜ ์žˆ๋‹ค | + +## 6. ์ˆ˜์šฉ ์‚ฌ๋ก€ยท์‹ ๊ทœ ํ…Œ์ŠคํŠธ์˜ ๊ฒฐ์ •์„ฑยท๊ฒฉ๋ฆฌ + +- `tests/test_issue21_acceptance.py`(A1โ€“A8, 278์ค„): ์‹ค์ œ herdrยท์‹ค์ œ ๋ธŒ๋กœ์ปค๋ฅผ ์“ฐ์ง€ ์•Š๋Š”๋‹ค. mock herdr / stub ์œ„์ž„ + `mam_sandbox`ยท`tmp_path` ๊ธฐ๋ฐ˜์ด๋ฉฐ, ๋ธŒ๋กœ์ปค ๊ด€๋ จ ์–ธ๊ธ‰์€ ์ฃผ์„๊ณผ "๋ธŒ๋กœ์ปค ๋„๋‹ฌ ๋ถˆ๊ฐ€ ์กฐ๊ฑด์—์„œ ๋ž˜ํผ๋ฅผ ๋Œ๋ฆฐ๋‹ค"๋Š” A3 ํ•œ ๊ณณ(`:102`)๋ฟ์ด๋‹ค. +- `tests/test_issue21_loop_b.py`(526์ค„, L1โ€“L21): ๋Ÿฌ๋„ˆ๋ฅผ ์ง์ ‘ ๊ตฌ๋™ํ•˜๋˜ ์œ„์ž„ยท๋Œ€๊ธฐ๋ฅผ ์Šคํ…์œผ๋กœ ๋ฐ”๊พธ๊ณ  ์ž„์‹œ git ์ €์žฅ์†Œ์—์„œ ๋ˆ๋‹ค. ๋ธŒ๋กœ์ปค ์˜์กด 0, `time.sleep` ์—†์Œ. L1/L12/L14b/L18b ๊ฐ€ **์˜ต์…˜ ์—†๋Š” ๊ธฐ์กด ๋™์ž‘ ๋ถˆ๋ณ€**์„ ๋ณ„๋„๋กœ ๊ณ ์ •ํ•˜๋Š” ์ ์ด ์ข‹๋‹ค. +- `tests/test_issue21_phase2_loop_a.py`(649์ค„): `time.sleep(0.2)` 1ํšŒ(`:312`) ์™ธ์— ์‹œ๊ฐ„ ์˜์กด ์—†์Œ. `deploy/install.sh` ๋ฅผ ์ž„์‹œ ํƒ€๊นƒ์— ์‹คํ–‰ํ•˜๋Š” ๊ฒฝ๋กœ๊ฐ€ ์žˆ์œผ๋‚˜ ๋ชจ๋‘ `tmp_path` ์•ˆ์ด๋‹ค. +- `tests/test_issue20_state_isolation.py`(202์ค„): ์ˆœ์ˆ˜ ํ•จ์ˆ˜ ๋‹จ์œ„ + ์ค‘์ฒฉ pytest 1๊ฑด(์„ค๊ณ„๋œ skip). +- uuid ์‚ฌ์šฉ์€ `uuid.uuid4()`(issue15 ์˜ `_unique`)๋กœ ๋ธŒ๋ฆฌํ”„ ๊ทœ์น™๊ณผ ๋งž๋Š”๋‹ค. + +## 7. ๋ฌธ์„œ ์ •ํ•ฉ์„ฑ๊ณผ ๊ถŒ๊ณ ์˜ ํƒ€๋‹น์„ฑ + +- `docs/design/worktree-creator-model.md`: ํ•ญ๋ชฉ ํ‘œ๊ฐ€ 1ยท2ยท3ยท6 = ์™„๋ฃŒ(v4.6.0), 4ยท5ยท7 = ์ด ๋ธŒ๋žœ์น˜, 8 = ๋ณด๋ฅ˜๋กœ ๊ฐฑ์‹ ๋๊ณ , ์‹คํ–‰ ์˜ˆ์‹œ๊ฐ€ `worktree_prepare.sh`/`worktree_remove.sh` ๊ธฐ์ค€์œผ๋กœ ๋‹ค์‹œ ์“ฐ์˜€๋‹ค. ํ˜ธํ™˜์„ฑ ํ‘œ์— resume ์˜ exit 2 ์กฐ๊ฑด, remote ๋ฆฌ๋ทฐ์–ด ๊ฑฐ๋ถ€/opt-in, "์‹ค์ œ herdr pane ๊ฐ’ ํ™•์ธ์€ ๋‹จ๊ณ„ C" ๋ผ๋Š” ํ•œ๊ณ„๊ฐ€ ๋ช…์‹œ๋ผ ๊ณผ์žฅ ์—†์ด ์ •ํ™•ํ•˜๋‹ค. +- `multi-agent-mux-loop/SKILL.md` ๊ฐ€ `--review-isolation`ยท`--remote-review` ๋ฅผ ํ‘œ์— ์ถ”๊ฐ€ํ•˜๊ณ  ๊ธฐ๋ณธ๊ฐ’ยท๊ฑฐ๋ถ€ ์‹œ์ (์œ„์ž„ ์ด์ „)ยทevidence ๊ธฐ๋ก๊นŒ์ง€ ์ ๋Š”๋‹ค. create/resume/stop SKILL.md ๋„ ํ—ฌํผ๋ฅผ ๋ฐ˜์˜ํ•œ๋‹ค. +- PR ๋ณธ๋ฌธ์˜ **๋น„์ฐจ๋‹จ ๊ถŒ๊ณ  4๊ฑด**์„ ์ฝ”๋“œ์—์„œ ํ™•์ธํ–ˆ๊ณ  ๋ชจ๋‘ ์‚ฌ์‹ค์ด๋‹ค: โ‘  A4 ์˜ `_main_state` ๋น„๊ต๊ฐ€ untracked `.agents/`ยท`.mam/` ๋ณ€ํ™”๋ฅผ ๋ณด์ง€ ๋ชปํ•จ, โ‘ก A4 ๊ฐ€ `info/exclude` ์ถ”๊ฐ€๋ฅผ **์„ ๋ฐ˜์˜**ํ•œ ๋’ค ๋น„๊ตํ•จ(`test_issue21_acceptance.py:121-122`), โ‘ข `_close_review_tree` ์˜ remove ์‹คํŒจ ์‹œ `prune` ์ด ์ „์—ญ, โ‘ฃ `worktree_remove.sh` ์˜ ์„œ๋ธŒ๋ชจ๋“ˆ ๊ฑฐ๋ถ€ ์ถœ๋ ฅ ์ค‘๋ณต. ๋„ท ๋‹ค ๋ณ‘ํ•ฉ์„ ๋ง‰์„ ์„ฑ์งˆ์ด ์•„๋‹ˆ๋ฉฐ ํ›„์†์œผ๋กœ ์ ์ ˆํ•˜๋‹ค. +- #30(๋ฉˆ์ถ˜ ์—์ด์ „ํŠธ ๊ฐ์ง€) ๋“ฑ๋ก์€ ํƒ€๋‹นํ•˜๋‹ค. #18 ์ด "ํ”„๋กœ์„ธ์Šค ์ƒ์กด"์— ๊ฒฐ์†๋œ ์„ค๊ณ„๋ผ "์‚ด์•„ ์žˆ์ง€๋งŒ ๋ฉˆ์ถ˜" ์ƒํƒœ๋Š” ๊ทธ ๊ณ„์•ฝ ๋ฐ–์ด๋ฉฐ, ํ•ด๊ฒฐ์€ ์•Œ๋ฆผยท์šด์˜ ์ˆ˜์ค€์ด๋ฏ€๋กœ ๋ณ„๋„ ์ด์Šˆ๊ฐ€ ๋งž๋‹ค. + +## 8. ์ „์ฒด ์Šค์œ„ํŠธ ์‹คํ–‰ (์ด ๋ฆฌ๋ทฐ์–ด๊ฐ€ ์ง์ ‘, ์›ŒํฌํŠธ๋ฆฌ์—์„œ) + +``` +cd && PYTHONDONTWRITEBYTECODE=1 .venv/bin/python -m pytest -p no:cacheprovider -q -rfE tests/ +โ†’ 2 failed, 1011 passed, 2 skipped in 2266.28s (0:37:46) (HEAD 46ea4cc, ์‹คํ–‰ ์ „ํ›„ ๋™์ผ) +``` + +| ์‹คํŒจ | ํŒ์ • | +|---|---| +| `test_issue14_delivery_ack.py::test_wrapper_idle_grace_never_outlives_the_wall_clock` | **PR ํšŒ๊ท€ ์•„๋‹˜**. ์ด ์‹œํ—˜์€ PR ์ด ๊ฑด๋“œ๋ฆฌ์ง€ ์•Š์•˜๋‹ค(`git diff -U0` ์˜ issue14 hunk ๋Š” ์ „๋ถ€ `_job_for_inprocess_publish` ๋’ค์ชฝ `:520` ์ดํ›„). ๋‹จ๋… ์žฌ์‹คํ–‰์—์„œ๋„ close_delay 28.5 s(์ž„๊ณ„ 20+8)๋กœ ์žฌํ˜„๋˜๋ฉฐ, **๋‹ค๋ฅธ ๋ธŒ๋žœ์น˜์— ์ด๋ฏธ `90a289d test(issue-14): widen the wall-clock close margin under suite load` ๊ฐ€ ์žˆ๋‹ค** โ€” ์ž„๊ณ„ ์ž์ฒด๊ฐ€ ์ข๋‹ค๋Š” ๊ธฐ์ง€ ๋ฌธ์ œ๋‹ค | +| `test_issue19_subscriber_daemon_lifecycle.py::test_mqtt_loop_sigterm_exits_cleanly` | **๋ธŒ๋กœ์ปค flake**. ํŒŒ์ผ ์ž์ฒด๊ฐ€ PR ๋ณ€๊ฒฝ 0์ค„์ด๊ณ , ๋กœ๊ทธ๊ฐ€ `broker did not accept connection; falling back to polling` ์ด๋‹ค. ๋‹จ๋… ์žฌ์‹คํ–‰ **ํ†ต๊ณผ** | + +- PR ๋ณธ๋ฌธ์˜ "์ด ํ˜ธ์ŠคํŠธ ๊ธฐ์ค€ ์‹คํŒจ 33๊ฑด(#28)" ์ฃผ์žฅ์€ **๋‚ด ์‹คํ–‰์œผ๋กœ๋Š” ํ™•์ธ๋˜์ง€ ์•Š๋Š”๋‹ค**(๋‚ด ์กฐ๊ฑด์—์„œ๋Š” 2๊ฑด). ์ œ์–ด ํ‰๋ฉด ๋ฐ”์ธ๋”ฉ์ด ์‹ค๋ฆฐ creator pane ์กฐ๊ฑด์—์„œ ์žฌํ˜„๋œ ์ˆ˜์น˜๋กœ ๋ณด์ด๋ฉฐ, ๋‚ด๊ฐ€ ์žฌํ˜„ํ•  ์ˆ˜ ์žˆ๋Š” ์กฐ๊ฑด์ด ์•„๋‹ˆ๋ฏ€๋กœ ๊ทธ ์ˆ˜์น˜์˜ ์ฐธยท๊ฑฐ์ง“์„ ์ฃผ์žฅํ•˜์ง€ ์•Š๋Š”๋‹ค. ๋‚ด๊ฐ€ ๋งํ•  ์ˆ˜ ์žˆ๋Š” ๊ฒƒ์€ **๋‚ด ์กฐ๊ฑด์—์„œ ์ƒˆ ํšŒ๊ท€๊ฐ€ 0 ์ด๋ผ๋Š” ๊ฒƒ**์ด๋‹ค. + +### ๊ฒฉ๋ฆฌยท์œ„์ƒ ์‹ค์ธก (#20, ๋ธŒ๋ฆฌํ”„ ํ•ญ๋ชฉ 4) + +| ํ•ญ๋ชฉ | ๊ฒฐ๊ณผ | +|---|---| +| ์‹ค์ œ `.mam/jobs`(๋ณธ ์ฒดํฌ์•„์›ƒ) ์ „ํ›„ | **์ฐจ์ด 0** (๋ ˆ์ฝ”๋“œ ์ถ”๊ฐ€ 0๊ฑด) | +| ์‹ค์ œ `.mam/delegate_job_logs` ์ „ํ›„ | **์ฐจ์ด 0** | +| ์›ŒํฌํŠธ๋ฆฌ `git status --short --ignored` ์ „ํ›„ | **์™„์ „ ๋™์ผ**(๋ฌด์‹œ ํŒŒ์ผ ํฌํ•จ) โ€” ์Šค์œ„ํŠธ๊ฐ€ ์›ŒํฌํŠธ๋ฆฌ์— ์•„๋ฌด๊ฒƒ๋„ ๋‚จ๊ธฐ์ง€ ์•Š์•˜๋‹ค | +| ์›ŒํฌํŠธ๋ฆฌ HEAD | ์‹คํ–‰ ์ „ํ›„ `46ea4cc` | +| ๋ณธ ์ฒดํฌ์•„์›ƒ | `main @ 1134d74`, ํด๋ฆฐ | + +์ด์ „ ํšŒ์ฐจ(PR #22)์—์„œ ๋‚ด๊ฐ€ ์ง€์ ํ–ˆ๋˜ "๋ฆฌ๋ทฐ์–ด pytest ๊ฐ€ ์›ŒํฌํŠธ๋ฆฌ์— `.mam/delegate_job_logs`ยท`.mam/shim` ์„ ๋‚จ๊ธด๋‹ค" ๊ฐ€ ์ด๋ฒˆ ์‹คํ–‰์—์„œ๋Š” **์žฌํ˜„๋˜์ง€ ์•Š์•˜๋‹ค**. #20 ์ˆ˜์ •์ด ์˜๋„๋Œ€๋กœ ๋™์ž‘ํ•œ๋‹ค. + +## 9. ๋ณ‘ํ•ฉ ์ฐจ๋‹จ ์—ฌ๋ถ€ (๋ธŒ๋ฆฌํ”„ ํ•ญ๋ชฉ 8) + +์•Œ๋ ค์ง„ ๋ฒ„์ „ bump ๋ฏธํฌํ•จ(์˜ค์ผ€์ŠคํŠธ๋ ˆ์ดํ„ฐ๊ฐ€ v4.7.0 ์œผ๋กœ ์ฒ˜๋ฆฌ) ์™ธ์— **๋ณ‘ํ•ฉ์„ ๋ง‰์„ ๊ฒฐํ•จ์€ ์ฐพ์ง€ ๋ชปํ–ˆ๋‹ค**. N1โ€“N9 ๋Š” ๋ชจ๋‘ ๋น„์ฐจ๋‹จ์ด๋ฉฐ, ํ›„์† ์ˆœ์œ„๋Š” N1 > N2 > N5 ์ˆœ์œผ๋กœ ๋ณธ๋‹ค. + +| # | ์ง€์  | ์„ฑ๊ฒฉ | +|---|---|---| +| N1 | ์Šค๋ƒ…์ƒท ์ปค๋ฐ‹ยทtree ๊ฐ€ ref ์—†์ด ๋‚จ์•„ gc ๋กœ ์†Œ๋ฉธ โ†’ `identity.json` ์‚ฌํ›„ ๊ฒ€์ฆ ๋ถˆ๊ฐ€(์žฌํ˜„ ํ™•์ธ) | ํ›„์† ์ด์Šˆ ๊ถŒ๊ณ  | +| N2 | prepare ์˜ `.venv` ๋Š” ์—ฌ์ „ํžˆ ์‹ฌ๋งํฌ(์‹ค์ œ venv/์ธํ„ฐํ”„๋ฆฌํ„ฐ ๊ฒฝ๋กœ ์•„๋‹˜); ์ปค๋ฐ‹ ๊ฒฝ๋กœ๋Š” 3์ค‘์œผ๋กœ ๋‹ซํ˜”์œผ๋‚˜ `rm -rf .venv/`ยท์˜์กด์„ฑ ๊ณต์œ  ์œ„ํ—˜ ์ž”์กด | ๋ฌธ์„œํ™” ๋˜๋Š” ์˜ต์…˜ํ™” ๊ถŒ๊ณ  | +| N3 | prepare ๊ฐ€ ๊ณต์œ  `info/exclude` ๋ฅผ ๋ฐ”๊ฟ” ๋ณธ ์ฒดํฌ์•„์›ƒ์—๋„ ์ ์šฉ(๋˜๋Œ๋ฆผ ๊ฒฝ๋กœ ์—†์Œ) | ๊ธฐ์ง€ยท๋ฌธ์„œํ™”๋จ | +| N4 | issue15 ๋ˆ„์ˆ˜ ๋‹จ์–ธ์ด ์ „์ฒด ๋ชฉ๋ก ๋น„๊ต์—์„œ ์ž๊ธฐ ์žก id + ์„ธ์…˜ ์Šค์บ”์œผ๋กœ ์•ฝํ™”(๊ทผ๊ฑฐ ํƒ€๋‹น) | ์ˆ˜์šฉ | +| N5 | acceptance5 ์˜ skip ๊ฒฝ๋กœ๊ฐ€ ๊ตฌ๋…์ž ๋ฐฐ๋‹ฌ ํšŒ๊ท€๋ฅผ ๊ฐ€๋ฆด ์—ฌ์ง€(์กฐ๊ฑด์€ ๋งค์šฐ ์ข์Œ), `--heartbeat-misses 3โ†’10` | ์ˆ˜์šฉ | +| N6 | SIGKILL ์‹œ ๋ฆฌ๋ทฐ ์›ŒํฌํŠธ๋ฆฌยทworktree ํ•ญ๋ชฉ ์ž”์กด(prune ์€ ์‹คํŒจ ์‹œ์—๋งŒ) | ์‚ฌ์†Œ | +| N7 | remove ์˜ ์—ด๋ฆฐ ์žก ๊ฒ€์‚ฌ๊ฐ€ ํ–‰ ์—†๋Š” ์›ŒํฌํŠธ๋ฆฌ ๋กœ์ปฌ `.mam/jobs` ๋ฅผ ๋ชป ๋ด„ | ๋ชจ๋ธ ๋ฐ– ๊ฒฝ๋กœ | +| N8 | remove ๋Š” plain/force ๋ชจ๋‘ ๋ฌด์‹œ ํŒŒ์ผ์„ ๋ง์—†์ด ์‚ญ์ œ(์ถ”์  ์ž‘์—… ์†์‹ค์€ ์—†์Œ) | ์ •๋ณด | +| N9 | ์˜ต์…˜ ์—†๋Š” ๋ฃจํ”„์—๋„ `Loop id:` ๋กœ๊ทธ 1์ค„๊ณผ ์ฒดํฌํฌ์ธํŠธ `loop_id` ํ•„๋“œ๊ฐ€ ์ถ”๊ฐ€(ํ”„๋กฌํ”„ํŠธ๋Š” ๋ฐ”์ดํŠธ ๋™์ผ) | ์ •๋ณด | + +## 10. ๊ต์ฐจ๊ฒ€์ฆ ๊ฐ€์ด๋“œ + +```bash +WT=/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux-wt/pr-29 +(cd "$WT" && PYTHONDONTWRITEBYTECODE=1 .venv/bin/python -m pytest -p no:cacheprovider -q -rfE tests/) # 2 failed / 1011 passed +(cd "$WT" && PYTHONDONTWRITEBYTECODE=1 .venv/bin/python -m pytest -p no:cacheprovider -q \ + tests/test_issue14_delivery_ack.py::test_wrapper_idle_grace_never_outlives_the_wall_clock \ + tests/test_issue19_subscriber_daemon_lifecycle.py::test_mqtt_loop_sigterm_exits_cleanly) # issue19 ํ†ต๊ณผ, issue14 ์žฌํ˜„ +git -C "$WT" diff 1134d74...HEAD -U0 -- tests/test_issue14_delivery_ack.py | grep '^@@' # ์‹คํŒจ ์‹œํ—˜์€ ๋ณ€๊ฒฝ ๋ฐ– +# N1 ์žฌํ˜„ (์ž„์‹œ ์ €์žฅ์†Œ์—์„œ๋งŒ) +R=$(mktemp -d); cd "$R"; git init -q .; git config user.email t@t; git config user.name t +echo a > a; git add a; git commit -qm base; H=$(git rev-parse HEAD); echo dirty > b +GIT_INDEX_FILE=$R/idx git read-tree HEAD; GIT_INDEX_FILE=$R/idx git add -A; T=$(GIT_INDEX_FILE=$R/idx git write-tree) +C=$(git commit-tree "$T" -p "$H" -m snap); git worktree add -q --detach "$R/wt" "$C"; git worktree remove --force "$R/wt" +git reflog expire --expire=now --all; git gc --prune=now -q; git cat-file -e "$C^{commit}" || echo "snapshot gone" +``` + +[VERDICT: PASS] diff --git a/.agents/reports/planner-reviewer-claude-01/report-f1174fc0.md b/.agents/reports/planner-reviewer-claude-01/report-f1174fc0.md new file mode 100644 index 0000000..670657d --- /dev/null +++ b/.agents/reports/planner-reviewer-claude-01/report-f1174fc0.md @@ -0,0 +1,189 @@ +# ๐Ÿ” Review Report โ€” PR #29 ๋ธํƒ€ ๋ฆฌ๋ทฐ 2ํšŒ์ฐจ (job `f1174fc0`) + +- **Reviewer**: `planner-reviewer-claude-01` (role: planner,reviewer) ยท 2026-09-20 +- **๋Œ€์ƒ ๋ธํƒ€**: `46ea4cc..08db1da` (3์ปค๋ฐ‹, `git diff --stat` = **16 files, +159/โˆ’34**), base `main 1134d74` (v4.6.0). +- **๊ฒ€ํ†  ํŠธ๋ฆฌ**: ์›ŒํฌํŠธ๋ฆฌ `/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux-wt/pr-29` (detached @ `08db1da`). +- **1ํšŒ์ฐจ ๋‚ด ํŒ์ •**: PASS (job `291e38b5`). ์ด๋ฒˆ์—๋Š” **๋ธํƒ€๊ฐ€ ๊ทธ ํŒ์ •์„ ๋’ค์ง‘๋Š”์ง€**๋ฅผ ๋ณธ๋‹ค. +- **์•ˆ์ „ ๊ทœ์น™ ์ค€์ˆ˜**: ์›ŒํฌํŠธ๋ฆฌ ์ฝ๊ธฐ ์ „์šฉ(์ˆ˜์ •ยท์ปค๋ฐ‹ยทcheckout ์—†์Œ, `git status --short` ํด๋ฆฐ ์œ ์ง€). ๋ณธ ์ฒดํฌ์•„์›ƒ `main @ 1134d74` ๊ทธ๋Œ€๋กœ. `worktree_prepare.sh`ยท`worktree_remove.sh` ๋ฅผ ์‹ค์ œ ์›ŒํฌํŠธ๋ฆฌยท๋ณธ ์ฒดํฌ์•„์›ƒ์— ์‹คํ–‰ํ•˜์ง€ ์•Š์•˜๋‹ค โ€” ๋ชจ๋“  ์‹คํ–‰์€ scratchpad ์˜ ์ž„์‹œ ๋ณต์‚ฌ๋ณธ/์ž„์‹œ ์ €์žฅ์†Œ(uuid4 ์ด๋ฆ„)์—์„œ๋งŒ ํ–ˆ๋‹ค. ์‹ค์ œ `.mam`ยท๋ฐ๋ชฌยท์„ธ์…˜ ์กฐ์ž‘ ์—†์Œ, pushยทmergeยทPR ์ƒํƒœ ๋ณ€๊ฒฝ ์—†์Œ, purgeยทscrub ์‹คํ–‰ ์—†์Œ. + +## ๊ฒฐ๋ก  ์š”์•ฝ + +**R1ยทR2ยทR3 ์€ ์‹ค์ œ๋กœ ๋‹ซํ˜”๋‹ค.** ํŠนํžˆ R1/R2 ๋Š” (a) ๊ฒฐํ•จ์„ ์ž„์‹œ ์ €์žฅ์†Œ์—์„œ ์ง์ ‘ ์žฌํ˜„ํ–ˆ๊ณ , (b) ์›ŒํฌํŠธ๋ฆฌ ๋ณต์‚ฌ๋ณธ์—์„œ ์ˆ˜์ •๋งŒ ๋˜๋Œ๋ ธ์„ ๋•Œ ์ƒˆ ํšŒ๊ท€ 4๊ฑด์ด ์ •ํ™•ํžˆ ์‹คํŒจํ•˜๋Š” ๊ฒƒ๊นŒ์ง€ ํ™•์ธํ–ˆ๋‹ค. 1ํšŒ์ฐจ PASS ๋ฅผ ๋’ค์ง‘์„ ์‚ฌ์œ ๋Š” ์—†๋‹ค. ๋น„์ฐจ๋‹จ 4๊ฑด(D1โ€“D4)๋งŒ ๋‚จ๊ธด๋‹ค. ๊ทธ์ค‘ D1 ์€ ์ด๋ฒˆ ๋ธํƒ€๊ฐ€ ์ƒˆ๋กœ ๋งŒ๋“  ์ž‘์€ ๊ตฌ๋ฉ์ด๋‹ค. + +## 1. R1/R2 โ€” `--untracked-files=all` (ํ•ญ๋ชฉ 1) + +### 1-1. ๊ฒฐํ•จ ์žฌํ˜„ (์ž„์‹œ ์ €์žฅ์†Œ) + +``` +git config status.showUntrackedFiles no ; echo secret > untracked.txt +git status --porcelain --ignore-submodules=none โ†’ (๋นˆ ์ถœ๋ ฅ) โ† ์˜› ๊ฒ€์‚ฌ: "ํด๋ฆฐ" +git status --porcelain --untracked-files=all --ignore-submodules=none โ†’ ?? untracked.txt โ† ์ˆ˜์ •๋ณธ +``` + +ํ‘œ์‹œ ์„ค์ • ํ•˜๋‚˜๋กœ "๊ฐ•์ œ ์ œ๊ฑฐํ•ด๋„ ๋˜๋Š”๊ฐ€"๋ผ๋Š” ํŒ์ •์ด ๋’ค์ง‘ํ˜”๋‹ค๋Š” codex ์˜ ์ง€์ ์€ ์‚ฌ์‹ค์ด๋‹ค. + +### 1-2. ์„ธ ๊ฒ€์‚ฌ ๋ชจ๋‘์— ๋“ค์–ด๊ฐ”๋Š”๊ฐ€ โ€” ๊ทธ๋ ‡๋‹ค + +| ํŒŒ์ผ:๋ผ์ธ | ๊ฒ€์‚ฌ | +|---|---| +| `.agents/skills/multi-agent-mux-stop/scripts/worktree_remove.sh:93` | `judge_tree()` ์˜ ์›ŒํฌํŠธ๋ฆฌ ์ฒญ๊ฒฐ ๊ฒ€์‚ฌ | +| `.agents/skills/lib_py/worktree.py:170` | `submodule_findings()` ์˜ `dirty` ๊ฒ€์‚ฌ(`modules/` ์•„๋ž˜ ๋ชจ๋“  ์ €์žฅ์†Œ) | +| `.agents/skills/lib_py/loop/runner.py:718` | `_dirty_submodules()` (๋ฆฌ๋ทฐ ์Šค๋ƒ…์ƒท ์Šน์ธ ์ „ ์„œ๋ธŒ๋ชจ๋“ˆ ๊ฒ€์‚ฌ) | + +์Šคํ‚ฌ ํŠธ๋ฆฌ ์ „์ฒด์—์„œ `status --porcelain` ์„ ๋ถ€๋ฅด๋Š” ๋‚˜๋จธ์ง€ ๋‘ ๊ณณ๋„ ํ™•์ธํ–ˆ๋‹ค. +- `worktree_prepare.sh:209`: ๋กค๋ฐฑ ํ›„ "์ด ์›ŒํฌํŠธ๋ฆฌ๊ฐ€ ์•„์ง ์“ธ ์ˆ˜ ์žˆ๋Š” ์ฒดํฌ์•„์›ƒ์ธ๊ฐ€"๋งŒ ๋ณด๋Š” ์šฉ๋„(stdout ์€ ๋ฒ„๋ฆฌ๊ณ  stderr ๋งŒ ๋ณธ๋‹ค). ํŒŒ๊ดด ํŒ์ •์ด ์•„๋‹ˆ๋‹ค โ€” ํ”Œ๋ž˜๊ทธ ๋ถˆํ•„์š”. +- `runner.py:581`: ํ”„๋กฌํ”„ํŠธ์˜ `๋ฏธ์ปค๋ฐ‹ ๋ณ€๊ฒฝ: ์žˆ์Œ/์—†์Œ` **ํ‘œ์‹œ์šฉ**. ํŒ์ •์— ์“ฐ์ด์ง€ ์•Š๋Š”๋‹ค(โ†’ D3 ์ฐธ์กฐ). + +### 1-3. ์†Œ์Šค ํŠธ๋ฆฌ ์Šค๋ƒ…์ƒท์€ ๋ณ„๋„ ์กฐ์น˜๊ฐ€ ํ•„์š” ์—†๋Š”๊ฐ€ โ€” ํ•„์š” ์—†๋‹ค + +`_worktree_tree()` ์˜ `git add -A` ๋Š” `status.showUntrackedFiles` ๋ฅผ **๋ณด์ง€ ์•Š๋Š”๋‹ค**. ์ž„์‹œ ์ €์žฅ์†Œ์—์„œ `showUntrackedFiles=no` ๋กœ ์„ค์ •ํ•œ ๋’ค `read-tree HEAD` โ†’ `add -A` โ†’ `write-tree` ํ•œ ํŠธ๋ฆฌ์— `untracked.txt` ๊ฐ€ ๊ทธ๋Œ€๋กœ ๋“ค์–ด๊ฐ”๋‹ค. ์ฆ‰ ์Šค๋ƒ…์ƒท์€ ์ด๋ฏธ untracked ๋ฅผ ๊ณ ์ •ํ•˜๊ณ  ์žˆ์—ˆ๊ณ , ๊ตฌ๋ฉ์€ "์„œ๋ธŒ๋ชจ๋“ˆ ๊ฒ€์‚ฌ"์™€ "์ œ๊ฑฐ ํŒ์ •"์—๋งŒ ์žˆ์—ˆ๋‹ค. ์ˆ˜์ • ๋ฒ”์œ„๊ฐ€ ์ •ํ™•ํ•˜๋‹ค. + +`_change_set_state()`(`runner.py`)์™€ `diff_collect.sh` ์˜ untracked ์ˆ˜์ง‘์€ `git ls-files -o --exclude-standard` ๋ผ ์ด ์„ค์ •์˜ ์˜ํ–ฅ์„ ๋ฐ›์ง€ ์•Š๋Š”๋‹ค. ๋นˆ ๋ณ€๊ฒฝ๋ถ„ fail-closed ๊ฐ€๋“œ๋„ ์•ˆ์ „ํ•˜๋‹ค. + +### 1-4. ์ƒˆ ํšŒ๊ท€๊ฐ€ ์ˆ˜์ • ์—†์ด๋Š” ์‹คํŒจํ•˜๋Š”๊ฐ€ โ€” ํ™•์ธํ•จ + +์›ŒํฌํŠธ๋ฆฌ๋ฅผ `.git`ยท`.venv` ์ œ์™ธํ•˜๊ณ  scratchpad ์— ๋ณต์‚ฌํ•œ ๋’ค, **์„ธ ๊ณณ์˜ `--untracked-files=all` ๋งŒ ์ œ๊ฑฐ**ํ•˜๊ณ  ๋Œ๋ ธ๋‹ค(์›ŒํฌํŠธ๋ฆฌ ์ž์ฒด๋Š” ๊ฑด๋“œ๋ฆฌ์ง€ ์•Š์•˜๋‹ค). + +``` +4 failed, 5 passed in 14.69s +FAILED test_issue21_phase2_loop_a.py::test_w6e2_untracked_submodule_file_hidden_by_config_blocks_the_forced_removal +FAILED test_issue21_phase2_loop_a.py::test_w6e_work_written_while_stopping_is_not_force_removed[True] +FAILED test_issue21_loop_b.py::test_l17_dirty_submodule_is_refused_or_makes_the_round_stale[untracked-hidden] +FAILED test_issue21_loop_b.py::test_l17_dirty_submodule_is_refused_or_makes_the_round_stale[during-hidden] +``` + +๊ธฐ์กด ๋ณ€ํ˜•(`[False]`, `before`, `before-ignored`, `untracked`, `during`)์€ ๊ทธ๋Œ€๋กœ ํ†ต๊ณผํ–ˆ๋‹ค. ์ฆ‰ ์ƒˆ ํšŒ๊ท€๋Š” **์ •ํ™•ํžˆ ์ด ์ˆ˜์ •๋งŒ** ๊ฒ€์ฆํ•˜๋ฉฐ, ํšŒ๊ท€ ๋ฐฉ์ง€๋ ฅ์ด ์‹ค์žฌํ•œ๋‹ค. `test_w6e2` ๊ฐ€ ๋‹จ์–ธ ์•ˆ์—์„œ `assert _git(wt,"status","--porcelain","--ignore-submodules=none").stdout == "", "git hides it by default"` ๋กœ ์ „์ œ ์ž์ฒด๋ฅผ ๊ณ ์ •ํ•œ ๊ฒƒ๋„ ์ข‹๋‹ค. + +## 2. R3 โ€” ์›ŒํฌํŠธ๋ฆฌ ์ž์ฒด venv (ํ•ญ๋ชฉ 2) + +`worktree_prepare.sh:111-134` ์˜ ๋ถ„๊ธฐ๋ฅผ ์ฝ๊ณ  ๋ฌธ์„œยทํ…Œ์ŠคํŠธ์™€ ๋Œ€์กฐํ–ˆ๋‹ค(์Šคํฌ๋ฆฝํŠธ๋Š” ์‹ค์ œ ํŠธ๋ฆฌ์— ์‹คํ–‰ํ•˜์ง€ ์•Š์•˜๋‹ค). + +| ์š”๊ตฌ | ๊ตฌํ˜„ | ํŒ์ • | +|---|---|---| +| ์‹ฌ๋งํฌ๊ฐ€ ์•„๋‹Œ ์‹ค์ œ venv | `"$MAIN/.venv/bin/python" -m venv "$WT/.venv"` + `requirements.txt`ยท`requirements-dev.txt` ์„ค์น˜ | **์ถฉ์กฑ**. 1ํšŒ์ฐจ ๋‚ด ์ง€์  N2 ๊ฐ€ ๋‹ซํ˜”๋‹ค | +| ๋ฉฑ๋“ฑ์„ฑ | `[ -x "$WT/.venv/bin/python" ]` โ†’ `VENV_STATE="kept"` | ์ถฉ์กฑ(`test_w1` ์ด `VENV=created` โ†’ `VENV=kept` ์ˆœ์„œ๋ฅผ ๊ณ ์ •) | +| ๋‚จ์€ ๋งํฌ ๊ฑฐ๋ถ€ | `[ -L "$WT/.venv" ]` โ†’ exit 1, **์•„๋ฌด๊ฒƒ๋„ ๋ฐ”๊พธ์ง€ ์•Š์Œ** | ์ถฉ์กฑ(`test_w1b` ๊ฐ€ ๋งํฌ ๋ณด์กด๊นŒ์ง€ ๋‹จ์–ธ) | +| ์ธํ„ฐํ”„๋ฆฌํ„ฐ ๋ถ€์žฌ | `WARNING: ... not found; no venv created` + `VENV=skipped` | ์ถฉ์กฑ | +| ์„ค์น˜ ์‹คํŒจ ์‹œ exit 1, ์›ŒํฌํŠธ๋ฆฌ ์œ ์ง€ | `pip install` ์‹คํŒจ โ†’ ๋ฉ”์‹œ์ง€ `(the worktree is kept)` + exit 1. `git worktree add` ๋Š” ์ด๋ฏธ ๋๋‚œ ๋’ค๋ผ ์‹ค์ œ๋กœ ๋‚จ๋Š”๋‹ค | ์ถฉ์กฑ | +| ๋ฌธ์„œ ์ •ํ•ฉ | `create/SKILL.md:246`, `stop/SKILL.md:123`, ์„ค๊ณ„ ๋ฌธ์„œ 4์žฅ์ด ๋ชจ๋‘ "์ž์ฒด venvยท๋งํฌ ๊ฑฐ๋ถ€ยท`VENV=` ์š”์•ฝ"์œผ๋กœ ๊ฐฑ์‹ ๋จ | ์ถฉ์กฑ | +| ignore ๊ทœ์น™ ์ •ํ•ฉ | `.gitignore` ๊ด€๋ฆฌ ๋ธ”๋ก์˜ `/.venv`(#27) ์™€ ๊ณต์œ  `info/exclude` ์˜ `/.venv` ๋Š” **๋””๋ ‰ํ„ฐ๋ฆฌ์—๋„ ๊ทธ๋Œ€๋กœ ์ ์šฉ**๋œ๋‹ค. `test_w1` ์ด ์ค€๋น„ ํ›„ `status --porcelain` ์ด ๋น„๋Š”์ง€ ๋‹จ์–ธ | ์ถฉ์กฑ | + +`bash -n` ์€ ๋ณ€๊ฒฝ๋œ ๋‘ ์Šคํฌ๋ฆฝํŠธ ๋ชจ๋‘ ํ†ต๊ณผํ•œ๋‹ค. `req=()` ๋ฐฐ์—ด + `[ -f ... ] && req+=(...)` ๊ฐ€ `set -euo pipefail` ์•„๋ž˜์„œ ์กฐ๊ธฐ ์ข…๋ฃŒํ•˜์ง€ ์•Š๋Š”์ง€ macOS `/bin/bash` 3.2.57 ๋กœ ์ง์ ‘ ํ™•์ธํ–ˆ๋‹ค(๋‘ ํŒŒ์ผ ๋ชจ๋‘ ์—†์„ ๋•Œ๋„ ๋ฃจํ”„ ๋๊นŒ์ง€ ๋„๋‹ฌ, rc 0). + +### D1 (๋น„์ฐจ๋‹จ, ์ด๋ฒˆ ๋ธํƒ€๊ฐ€ ์ƒˆ๋กœ ๋งŒ๋“  ๊ตฌ๋ฉ) โ€” ์„ค์น˜ ์‹คํŒจ ๋’ค ์žฌ์‹คํ–‰์ด ์กฐ์šฉํžˆ `VENV=kept` + +`pip install` ์ด ์‹คํŒจํ•˜๋ฉด exit 1 ์ด์ง€๋งŒ `$WT/.venv/bin/python` ์€ **์ด๋ฏธ ๋งŒ๋“ค์–ด์ ธ ์žˆ๋‹ค**(`:121` ์—์„œ venv ์ƒ์„ฑ์ด ๋จผ์ € ๋๋‚œ๋‹ค). ๊ทธ๋ž˜์„œ ์‚ฌ์šฉ์ž๊ฐ€ ๊ทธ๋Œ€๋กœ ์žฌ์‹คํ–‰ํ•˜๋ฉด `:117-118` ์˜ `-x` ๊ฒ€์‚ฌ์— ๊ฑธ๋ ค `VENV=kept` ๋กœ ๋„˜์–ด๊ฐ€๊ณ , **์š”๊ตฌ์‚ฌํ•ญ์ด ์„ค์น˜๋˜์ง€ ์•Š์€ ํ™˜๊ฒฝ**์„ ์ •์ƒ์œผ๋กœ ๋ณด๊ณ ํ•œ๋‹ค. ๋ฉ”์‹œ์ง€๋Š” "fix it and re-run" ์ด๋ผ ์žฌ์‹คํ–‰์„ ์œ ๋„ํ•œ๋‹ค. + +โ†’ ๊ถŒ๊ณ : ์„ค์น˜ ์„ฑ๊ณต์„ ํ‘œ์‹œํ•˜๋Š” ๋งˆ์ปค(์˜ˆ: `$WT/.venv/.mam-requirements-ok`)๋ฅผ ๋‘๊ณ  `kept` ํŒ์ •์— ํ•จ๊ป˜ ์“ฐ๊ฑฐ๋‚˜, `kept` ๊ฒฝ๋กœ์—์„œ๋„ `requirements*.txt` ์„ค์น˜๋ฅผ ํ•œ ๋ฒˆ ๋” ์‹œ๋„. + +### D2 (๋น„์ฐจ๋‹จ, ์ •๋ณด) โ€” ์ค€๋น„ ๋น„์šฉยท๋„คํŠธ์›Œํฌ ์˜์กด + +์ด์ œ ์›ŒํฌํŠธ๋ฆฌ๋งˆ๋‹ค venv ์ƒ์„ฑ + PyPI ์„ค์น˜๊ฐ€ ํ•„์š”ํ•˜๋‹ค. ์˜คํ”„๋ผ์ธ์ด๊ฑฐ๋‚˜ PyPI ๊ฐ€ ๋ง‰ํžŒ ํ˜ธ์ŠคํŠธ์—์„œ๋Š” prepare ๊ฐ€ exit 1 ๋กœ ๋๋‚œ๋‹ค(์ด์ „์—๋Š” ๋งํฌ ํ•œ ๋ฒˆ์ด๋ผ ์ฆ‰์‹œ ๋๋‚ฌ๋‹ค). SKILL.md ์— ์„ค์น˜ ๋™์ž‘์€ ์ ํ˜€ ์žˆ์œผ๋‚˜ "๋„คํŠธ์›Œํฌ๊ฐ€ ํ•„์š”ํ•˜๋‹ค"๋Š” ๋ช…์‹œ๋Š” ์—†๋‹ค. + +### D3 (๋น„์ฐจ๋‹จ, ์‚ฌ์†Œ) โ€” ํ”„๋กฌํ”„ํŠธ์˜ `๋ฏธ์ปค๋ฐ‹ ๋ณ€๊ฒฝ` ํ‘œ์‹œ๋Š” ์—ฌ์ „ํžˆ ์„ค์ •์— ์ทจ์•ฝ + +`runner.py:581` ์˜ `git status --porcelain` ์—๋Š” ํ”Œ๋ž˜๊ทธ๊ฐ€ ์—†์–ด, `status.showUntrackedFiles=no` ์ธ ์†Œ์Šค์—์„œ๋Š” untracked ๋งŒ ์žˆ์„ ๋•Œ `๋ฏธ์ปค๋ฐ‹ ๋ณ€๊ฒฝ: ์—†์Œ` ์œผ๋กœ ํ‘œ์‹œ๋œ๋‹ค. ํŒ์ •ยท์Šค๋ƒ…์ƒทยท์„œ๋ธŒ๋ชจ๋“ˆ ๊ฒ€์‚ฌ๋Š” ๋ชจ๋‘ ์•ˆ์ „ํ•˜๋ฏ€๋กœ **ํ‘œ์‹œ ์˜ค์ฐจ**์ผ ๋ฟ์ด์ง€๋งŒ, R1 ์˜ ์ทจ์ง€("ํ‘œ์‹œ ์„ค์ •์ด ํŒ๋‹จ ์žฌ๋ฃŒ๋ฅผ ๋ฐ”๊พธ๋ฉด ์•ˆ ๋œ๋‹ค")๋ฅผ ์ƒ๊ฐํ•˜๋ฉด ๊ฐ™์€ ํ”Œ๋ž˜๊ทธ๋ฅผ ์ฃผ๋Š” ํŽธ์ด ์ผ๊ด€์ ์ด๋‹ค. + +## 3. #28 ์ด์‹์„ฑ ์ˆ˜์ •(`7a94ba2`)์ด ์˜๋„๋ฅผ ์•ฝํ™”์‹œํ‚ค๋Š”๊ฐ€ (ํ•ญ๋ชฉ 3) + +### 3-1. `ps -eo pid=,ppid=,args=` โ€” ์–‘์ชฝ์—์„œ ๊ฐ™์€ ์ถœ๋ ฅ์ธ๊ฐ€ + +์ด ํ˜ธ์ŠคํŠธ(macOS 15, arm64)์—์„œ **๋‘ ํ˜•ํƒœ๋ฅผ ๊ฐ™์€ ํ”„๋กœ์„ธ์Šค์— ๋Œ€ํ•ด ์ง์ ‘ ๋น„๊ต**ํ–ˆ๋‹ค(ํŒŒ์ดํ”„๋กœ ์บก์ฒ˜, ์ฝ”๋“œ์™€ ๋™์ผ ์กฐ๊ฑด). + +``` +['-axo','pid=,ppid=,command='] โ†’ ๊ธธ์ด 575, ์ธ์ž X 400๊ฐœ ๊ทธ๋Œ€๋กœ +['-eo','pid=,ppid=,args='] โ†’ ๊ธธ์ด 575, ์ธ์ž X 400๊ฐœ ๊ทธ๋Œ€๋กœ +``` + +- **๊ตฌ๋ถ„์žยทํ•„๋“œ**: ๋™์ผ(`pid ppid args`), ์ž˜๋ฆผ ์—†์Œ. 400์ž argv ๊ฐ€ ์˜จ์ „ํžˆ ๋‚˜์™”๋‹ค โ€” ํŒŒ์ดํ”„ ์ถœ๋ ฅ์ผ ๋•Œ๋Š” ํญ ์ œํ•œ์ด ๊ฑธ๋ฆฌ์ง€ ์•Š๋Š”๋‹ค. +- **์ด์‹์„ฑ**: `-e`(๋ชจ๋“  ํ”„๋กœ์„ธ์Šค), `-o`(ํฌ๋งท), `args`(์ „์ฒด ๋ช…๋ นํ–‰)๋Š” POSIX ๋‹ค. ๋ฐ˜๋Œ€๋กœ `-a`+`-x` ์กฐํ•ฉ๊ณผ `command=` ๋Š” BSD ๊ด€์šฉ์ด๊ณ  procps ์—์„œ๋Š” ๊ฒฝ๊ณ ยท๋‹ค๋ฅธ ํ•ด์„์ด ๋‚˜์˜ฌ ์ˆ˜ ์žˆ๋‹ค. purge 12๊ฑด์˜ ์›์ธ ๊ฐ€์„ค๋กœ ํƒ€๋‹นํ•˜๋ฉฐ, **macOS ๋™์ž‘์€ ๊ทธ๋Œ€๋กœ**๋‹ค(์œ„ ์‹ค์ธก). +- ๋‚จ๋Š” ์ฐจ์ด: Linux ์˜ `args` ๋„ ์ปค๋„ `/proc//cmdline` ๊ธฐ์ค€์ด๋ผ ๋งค์šฐ ๊ธด argv ๋Š” ํ™˜๊ฒฝ์— ๋”ฐ๋ผ ์ž˜๋ฆด ์ˆ˜ ์žˆ๋‹ค. ๋‹ค๋งŒ ์ด ์ฝ”๋“œ์˜ ํŒ์ •(์—์ด์ „ํŠธ CLI ์—ฌ๋ถ€, UUID ํฌํ•จ ์—ฌ๋ถ€)์€ ์•ž์ชฝ ํ† ํฐ์— ์˜์กดํ•˜๋ฏ€๋กœ ์‹ค์งˆ ์˜ํ–ฅ์€ ์—†๋‹ค. + +### 3-2. `_normalize_help` + +`optional arguments:` โ†’ `options:` ์น˜ํ™˜ + ๊ณต๋ฐฑ ๋Ÿฐ ์ถ•์•ฝ์ด๋‹ค. ์ฆ‰ **๋ ˆ์ด์•„์›ƒ(์ค„๋ฐ”๊ฟˆยท๋“ค์—ฌ์“ฐ๊ธฐ) ๋™๋“ฑ์„ฑ์€ ํฌ๊ธฐํ•˜๊ณ  ๋‚ด์šฉ ๋™๋“ฑ์„ฑ๋งŒ** ๋ณธ๋‹ค. ๊ณจ๋“  ์บก์ฒ˜๊ฐ€ 3.9 ์—์„œ ๋– ์กŒ๊ณ  ๋Œ€์ƒ ํ˜ธ์ŠคํŠธ๊ฐ€ 3.14 ๋ผ๋Š” ์‚ฌ์ •์ƒ ํƒ€๋‹นํ•˜๋ฉฐ, ํ”Œ๋ž˜๊ทธยท์ธ์žยท์„ค๋ช… ๋ฌธ๊ตฌ๊ฐ€ ๋ฐ”๋€Œ๋ฉด ์—ฌ์ „ํžˆ ์‹คํŒจํ•œ๋‹ค(์‹œํ—˜์˜ ๋ชฉ์ ์€ "shim ์ด ๊ตฌํ˜„๊ณผ CLI ์ƒ ๊ตฌ๋ถ„ ๋ถˆ๊ฐ€"์ด๋ฏ€๋กœ ์œ ์ง€). ์•ฝํ™”๋Š” ์ œํ•œ์ ์ด๊ณ  ๋Œ€์•ˆ(๋ฒ„์ „๋ณ„ ๊ณจ๋“  ์บก์ฒ˜)์€ ์œ ์ง€๋น„๊ฐ€ ํฌ๋‹ค โ€” ์ˆ˜์šฉ. + +### 3-3. `test_workspace_scope.py` ์˜ `bash -c` + +`shell=True` ๋Š” `/bin/sh` ๋ฅผ ์“ฐ๋ฏ€๋กœ dash ์—์„œ `source` ๊ฐ€ ์—†๋‹ค. ๋ฌธ์ž์—ด์„ ๊ทธ๋Œ€๋กœ `["bash","-c", cmd]` ๋กœ ์˜ฎ๊ธด ๊ฒƒ์€ ์ตœ์†Œ ๋ณ€๊ฒฝ์ด๋ฉฐ ๊ฒ€์ฆ ๋‚ด์šฉ(=`resolve_herdr_session` ์˜ ์ถœ๋ ฅ)์€ ๊ทธ๋Œ€๋กœ๋‹ค. ์ˆ˜์šฉ. + +### 3-4. conftest ์˜ `MAM_ENV_FILE` ๊ณ ์ • + +`tests/conftest.py:173-177` ์ด autouse ํ”ฝ์Šค์ฒ˜์—์„œ `MAM_ENV_FILE` ์„ `tmp_path/.mam-test-state/.mam.env.absent` ๋กœ ๊ณ ์ •ํ•œ๋‹ค. ๋ฐฉํ–ฅ์€ #20ยท#15 ์™€ ๊ฐ™์€ **๊ฒฉ๋ฆฌ ๊ฐ•ํ™”**์ด๊ณ , ์‹ค์ œ `.mam.env` ๋ฅผ ์ฝ์–ด์•ผ ํ•˜๋Š” ์‹œํ—˜์€ ์Šค์Šค๋กœ `MAM_ENV_FILE` ์„ ์„ค์ •ํ•œ๋‹ค(`test_issue14/18/5_phase1/21_worktree_prereqs/20_state_isolation` ๋ชจ๋‘ ์ž์ฒด ์„ค์ •์„ ๊ฐ–๊ณ  ์žˆ๋‹ค). "๋ธŒ๋กœ์ปค ์—†์Œ" ๊ณ„์—ด ์‹œํ—˜์ด ํ˜ธ์ŠคํŠธ์˜ ์‹ค ์„ค์ •์„ ์ฃผ์›Œ ์ฝ๋˜ ๊ฒƒ์ด ์˜คํžˆ๋ ค ๊ฒฐํ•จ์ด์—ˆ์œผ๋ฏ€๋กœ ๊ณ„์•ฝ์„ ์•ฝํ™”์‹œํ‚ค์ง€ ์•Š๋Š”๋‹ค. + +## 4. `90a289d` ๋ฒฝ์‹œ๊ณ„ ์—ฌ์œ  8 โ†’ 15 s (ํ•ญ๋ชฉ 4) + +์ด ์‹œํ—˜์ด ๊ณ ์ •ํ•˜๋Š” ๊ฒฐํ•จ์€ "idle grace ๊ฐ€ wall clock ์„ **๋ฌด์‹œ**ํ•ด `2 ร— TIMEOUT`(โ‰ˆ40 s) ์— ๋‹ซํžˆ๋Š” ๊ฒƒ"์ด๋‹ค. ์ž„๊ณ„๋Š” `20 + 15 = 35 s` ์ด๋ฏ€๋กœ ๊ฒฐํ•จ ์‹œ๋‚˜๋ฆฌ์˜ค(40 s)๋Š” ์—ฌ์ „ํžˆ ์‹คํŒจํ•œ๋‹ค. ์ •์ƒ ์ข…๋ฃŒ๋Š” ๋‚ด๊ฐ€ 1ํšŒ์ฐจ ์ „์ฒด ์Šค์œ„ํŠธ์—์„œ ์‹ค์ธกํ•œ ๊ฐ’์ด 28.49 sยท28.50 s(๋‹จ๋… ์žฌ์‹คํ–‰ ํฌํ•จ 2ํšŒ ์ผ์น˜)์˜€๋‹ค. + +``` +์ •์ƒ 28~29 s < ์ƒˆ ์ž„๊ณ„ 35 s < ๊ฒฐํ•จ 40 s +``` + +์—ฌ์œ ๋Š” 5 s ๋กœ ์ข์•„์กŒ์ง€๋งŒ ๊ณ„์•ฝ(โ€œwall clock ์„ ๋„˜๊ธฐ์ง€ ์•Š๋Š”๋‹คโ€)์˜ ํŒ๋ณ„๋ ฅ์€ ๋‚จ๋Š”๋‹ค. 1ํšŒ์ฐจ์—์„œ ๋‚ด๊ฐ€ ์œ ์ผํ•œ ๋น„-flake ์‹คํŒจ๋กœ ๋ณด๊ณ ํ–ˆ๋˜ ํ•ญ๋ชฉ์ด ์ด ์ปค๋ฐ‹์œผ๋กœ ํ•ด์†Œ๋œ๋‹ค. ๋‹ค๋งŒ `timeout_s` ๊ฐ€ ๋” ์ปค์ง€๋ฉด `2 ร— TIMEOUT` ๊ณผ `TIMEOUT + 15` ์˜ ๊ฐ„๊ฒฉ์€ ๋ฒŒ์–ด์ง€๊ณ  ์ž‘์•„์ง€๋ฉด ์ข์•„์ง€๋ฏ€๋กœ, **๊ณ ์ • ์—ฌ์œ (+15)๋ณด๋‹ค ๋น„์œจ(์˜ˆ: `timeout_s * 1.5`)** ์ด ๊ณ„์•ฝ์„ ๋” ์ •ํ™•ํžˆ ํ‘œํ˜„ํ•œ๋‹ค(D4, ๋น„์ฐจ๋‹จ ๊ถŒ๊ณ ). + +## 5. ํ…Œ์ŠคํŠธ ์‹คํ–‰ (์›ŒํฌํŠธ๋ฆฌ, ์ด ๋ฆฌ๋ทฐ์–ด๊ฐ€ ์ง์ ‘) + +``` +cd && PYTHONDONTWRITEBYTECODE=1 .venv/bin/python -m pytest -p no:cacheprovider -q -rfEs \ + tests/test_issue21_phase2_loop_a.py tests/test_issue21_loop_b.py tests/test_issue21_worktree_prereqs.py \ + tests/test_issue21_acceptance.py tests/test_workspace_scope.py tests/test_issue5_phase1_delegation.py \ + tests/test_deploy_freshness.py tests/test_version_consistency.py +โ†’ 208 passed in 322.94s (failed 0 ยท errors 0 ยท skipped 0) +``` + +- ๋ธŒ๋ฆฌํ”„๊ฐ€ ์ง€์ ํ•œ **`tests/test_issue28_*.py` ๋Š” ์ด ํŠธ๋ฆฌ์— ์—†๋‹ค**(#28 ์ˆ˜์ •์€ ๊ธฐ์กด ํŒŒ์ผ์— ๋“ค์–ด๊ฐ”๋‹ค) โ€” ๊ทœ์น™๋Œ€๋กœ ๊ฑด๋„ˆ๋›ฐ์—ˆ๋‹ค. +- **skip 0 ๊ฑด**: `test_deploy_freshness.py` ์˜ ์ƒˆ `skipif`(D-11~D-19, D-22~D-30)๋Š” ์ด ํ˜ธ์ŠคํŠธ์—์„œ **์ „๋ถ€ ์‹คํ–‰**๋๋‹ค(์„œ๋ธŒ๋ชจ๋“ˆ ์ž์‚ฐ์ด ์žˆ์œผ๋ฏ€๋กœ ์˜๋„๋Œ€๋กœ๋‹ค). ๋ธŒ๋ฆฌํ”„ ํ•ญ๋ชฉ 3 ์˜ ์šฐ๋ ค("๋กœ์ปฌ ์ž์‚ฐ์ด ์žˆ๋Š” ํ˜ธ์ŠคํŠธ์—์„œ๋Š” ์‹คํ–‰๋˜์–ด์•ผ ํ•œ๋‹ค")๋Š” ์‹ค์ธก์œผ๋กœ ํ•ด์†Œ. +- ๊ฐ€์žฅ ๋А๋ฆฐ ์‹œํ—˜ 10๊ฑด์€ ๋ชจ๋‘ herdr mockยท์ •์ง€/์žฌ๊ฐœ ๊ฒฝ๋กœ์ด๋ฉฐ ๋ธŒ๋กœ์ปค ํƒ€์ž„์•„์›ƒ ๊ณ„์—ด ์‹คํŒจ๋Š” ์—†์—ˆ๋‹ค. + +### ๊ฒฉ๋ฆฌยท์œ„์ƒ ์‹ค์ธก + +| ํ•ญ๋ชฉ | ๊ฒฐ๊ณผ | +|---|---| +| ์‹ค์ œ `.mam/jobs` ์ „ํ›„ | **์ฐจ์ด 0** | +| ์›ŒํฌํŠธ๋ฆฌ `git status --short --ignored` ์ „ํ›„ | **์™„์ „ ๋™์ผ** | +| ์›ŒํฌํŠธ๋ฆฌ HEAD / ๋ณธ ์ฒดํฌ์•„์›ƒ | `08db1da` / `main 1134d74` (๋‘˜ ๋‹ค ๋ณ€ํ™” ์—†์Œ) | +| ์ƒŒ๋“œ๋ฐ•์Šค ์‹คํ—˜ | scratchpad ์ž„์‹œ ๋””๋ ‰ํ„ฐ๋ฆฌ์—์„œ๋งŒ(๋ณต์‚ฌ๋ณธ ๋ฆฌ๋ฒ„ํŠธ ์‹คํ–‰ ํฌํ•จ). ์‹ค์ œ ์Šคํฌ๋ฆฝํŠธยท์›ŒํฌํŠธ๋ฆฌ์— prepare/remove ์‹คํ–‰ ์—†์Œ | + +## 6. ๋ณ‘ํ•ฉ ์ฐจ๋‹จ ์—ฌ๋ถ€ (ํ•ญ๋ชฉ 5) + +๋ฒ„์ „ ๋ฝ์Šคํ… bump ๋ถ€์žฌ(`lib.sh:32`ยท`eager.py:16` ๋ชจ๋‘ `4.6.0`, ์˜ค์ผ€์ŠคํŠธ๋ ˆ์ดํ„ฐ๊ฐ€ 4.7.0 ์œผ๋กœ ์ฒ˜๋ฆฌ ์˜ˆ์ •) ์™ธ์— **๋ณ‘ํ•ฉ์„ ๋ง‰์„ ๊ฒฐํ•จ์€ ์—†๋‹ค**. 1ํšŒ์ฐจ PASS ํŒ์ •์€ ์ด๋ฒˆ ๋ธํƒ€๋กœ ๋’ค์ง‘ํžˆ์ง€ ์•Š์œผ๋ฉฐ, 1ํšŒ์ฐจ์— ๋‚ด๊ฐ€ ๋‚จ๊ธด ๋น„์ฐจ๋‹จ ์ง€์  ์ค‘ **N2(.venv ์‹ฌ๋งํฌ)๋Š” ์ด ๋ธํƒ€๋กœ ํ•ด์†Œ**๋๋‹ค. 1ํšŒ์ฐจ์˜ N1(์Šค๋ƒ…์ƒท ์ปค๋ฐ‹์ด ref ์—†์ด ๋‚จ์•„ gc ๋กœ ์†Œ๋ฉธ)์€ ๊ทธ๋Œ€๋กœ ๋‚จ์•„ ์žˆ์œผ๋‹ˆ ํ›„์† ์ด์Šˆ๋กœ ์œ ์ง€ํ•˜๊ธฐ๋ฅผ ๊ถŒํ•œ๋‹ค. + +| # | ์ด๋ฒˆ ํšŒ์ฐจ ์ง€์  | ์„ฑ๊ฒฉ | +|---|---|---| +| D1 | pip ์„ค์น˜ ์‹คํŒจ ํ›„ ์žฌ์‹คํ–‰์ด `VENV=kept` ๋กœ ์กฐ์šฉํžˆ ํ†ต๊ณผ | ํ›„์† ๊ถŒ๊ณ (์ด๋ฒˆ ๋ธํƒ€๊ฐ€ ๋งŒ๋“  ๊ฒƒ) | +| D2 | prepare ๊ฐ€ ์›ŒํฌํŠธ๋ฆฌ๋งˆ๋‹ค venv ์ƒ์„ฑ + PyPI ์„ค์น˜๋ฅผ ์š”๊ตฌ(์˜คํ”„๋ผ์ธ exit 1) | ์ •๋ณดยท๋ฌธ์„œํ™” | +| D3 | `runner.py:581` ์˜ ํ‘œ์‹œ์šฉ status ์—๋Š” `--untracked-files=all` ์ด ์—†์Œ | ์‚ฌ์†Œ(์ผ๊ด€์„ฑ) | +| D4 | ๋ฒฝ์‹œ๊ณ„ ์—ฌ์œ ๋ฅผ ๊ณ ์ •๊ฐ’(+15 s)์ด ์•„๋‹ˆ๋ผ ๋น„์œจ๋กœ ๋‘๋Š” ํŽธ์ด ๊ณ„์•ฝ์— ์ถฉ์‹ค | ์‚ฌ์†Œ | + +## 7. ๊ต์ฐจ๊ฒ€์ฆ ๊ฐ€์ด๋“œ + +```bash +WT=/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux-wt/pr-29 +# R1 ๊ฒฐํ•จ ์žฌํ˜„ (์ž„์‹œ ์ €์žฅ์†Œ์—์„œ๋งŒ) +R=$(mktemp -d); cd "$R"; git init -q .; git config user.email t@t; git config user.name t +echo a>a; git add a; git commit -qm base; git config status.showUntrackedFiles no; echo secret > untracked.txt +git status --porcelain --ignore-submodules=none # (๋นˆ ์ถœ๋ ฅ) +git status --porcelain --untracked-files=all --ignore-submodules=none # ?? untracked.txt +GIT_INDEX_FILE=$R/idx git read-tree HEAD; GIT_INDEX_FILE=$R/idx git add -A +git ls-tree -r --name-only "$(GIT_INDEX_FILE=$R/idx git write-tree)" # untracked.txt ๊ฐ€ ๋“ค์–ด ์žˆ๋‹ค(= add -A ๋Š” ์„ค์ • ๋ฌด์‹œ) +# ์ˆ˜์ • ์—†์ด๋Š” ํšŒ๊ท€๊ฐ€ ์‹คํŒจํ•˜๋Š”์ง€ (์›ŒํฌํŠธ๋ฆฌ ๋ณต์‚ฌ๋ณธ์—์„œ๋งŒ) +S=$(mktemp -d); rsync -a --exclude .venv --exclude .git --exclude .mam "$WT/" "$S/" +sed -i '' 's/ --untracked-files=all --ignore-submodules=none/ --ignore-submodules=none/' \ + "$S/.agents/skills/multi-agent-mux-stop/scripts/worktree_remove.sh" "$S/.agents/skills/lib_py/loop/runner.py" +sed -i '' 's/"git status --porcelain --untracked-files=all"/"git status --porcelain"/' "$S/.agents/skills/lib_py/worktree.py" +(cd "$S" && PYTHONDONTWRITEBYTECODE=1 "$WT/.venv/bin/python" -m pytest -p no:cacheprovider -q \ + tests/test_issue21_phase2_loop_a.py::test_w6e2_untracked_submodule_file_hidden_by_config_blocks_the_forced_removal \ + tests/test_issue21_phase2_loop_a.py::test_w6e_work_written_while_stopping_is_not_force_removed \ + tests/test_issue21_loop_b.py::test_l17_dirty_submodule_is_refused_or_makes_the_round_stale) # 4 failed, 5 passed +# ps ํ˜•ํƒœ ๋น„๊ต(์ž˜๋ฆผยท๊ตฌ๋ถ„์ž) +python3 - <<'PY' +import subprocess,sys,time +p=subprocess.Popen([sys.executable,"-c","import time;time.sleep(6)","X"*400]); time.sleep(1) +for f in (["-axo","pid=,ppid=,command="],["-eo","pid=,ppid=,args="]): + l=[x for x in subprocess.run(["ps",*f],capture_output=True,text=True).stdout.splitlines() if x.split()[:1]==[str(p.pid)]] + print(f, len(l[0]), l[0].count("X")) +p.wait() +PY +``` + +[VERDICT: PASS] diff --git a/.agents/reports/planner-reviewer-codex-01/report-b880c1f2.md b/.agents/reports/planner-reviewer-codex-01/report-b880c1f2.md new file mode 100644 index 0000000..36d4125 --- /dev/null +++ b/.agents/reports/planner-reviewer-codex-01/report-b880c1f2.md @@ -0,0 +1,71 @@ +# PR #29 independent review + +Job: b880c1f2. Reviewer: planner-reviewer-codex-01. +Review target: detached `multi-agent-mux-wt/pr-29` at `46ea4cc1e4f664ca35711c05c3fa71258e169697`, compared with main `1134d74b3884d5818a4f738f5c9441b80f3f8802`. + +**NOT PASS.** Two reproduced correctness defects remain in the handling of hidden untracked submodule files. Preparation also fails the brief's explicit environment requirement. These are separate from the known pending 4.7.0 release bump. No broad redesign is necessary: the guard corrections are narrow and were verified in disposable fixtures. + +## Blocking findings + +### R1 โ€” P1: removal can silently delete untracked submodule data + +Locations: `.agents/skills/lib_py/worktree.py:167` and `.agents/skills/multi-agent-mux-stop/scripts/worktree_remove.sh:91` (the resulting force-removal path is at line 161). + +Both cleanliness checks inherit Git's `status.showUntrackedFiles` configuration. `--porcelain` does not override that setting. In an initialized submodule with `git config status.showUntrackedFiles no`, a normal, non-ignored untracked file is hidden from both the parent status and the submodule `foreach` check. The helper consequently reports that all checks passed and executes `git worktree remove --force`, deleting the file. + +I reproduced this using the existing `ctl_sub` sandbox fixture and the actual preparation/removal scripts, with no real worktree removal: + +1. Prepare a temporary worktree with its local test submodule initialized. +2. Set `status.showUntrackedFiles=no` in that submodule and create `valuable-untracked.txt` there. +3. Parent `status --porcelain --ignore-submodules=none` returns an empty string; explicit submodule `status --porcelain --untracked-files=all` reports the file. +4. Removal returns **0**, prints `checks: locked/sessions/jobs/clean/submodules -> passed; removal method: single --force`, and the file no longer exists. + +**Correction:** explicitly use `--untracked-files=all` in the parent and recursive submodule safety checks, retaining `--ignore-submodules=none`; do not let display preferences govern deletion eligibility. Add regressions with this configuration both before removal and for the post-stop recheck. I changed only the disposable fixture's copy of the submodule check to `git status --porcelain --untracked-files=all --ignore-submodules=none`: removal then returned **3** and preserved the file. The reviewed source remains unchanged. + +### R2 โ€” P1: the review snapshot can approve a dirty submodule it did not capture + +Location: `.agents/skills/lib_py/loop/runner.py:717` (`_dirty_submodules`). + +The review-identity guard has the same configuration dependency. With `status.showUntrackedFiles=no` inside a submodule, untracked content is not rejected. The snapshot records only the submodule gitlink, so that content is absent from the review tree and from the identity comparison. A stubbed PASS is then accepted and promoted, although the documented dirty-submodule refusal should have prevented any review. + +Using the existing temporary `wt_repo` and `_drive` fixtures, I added a clean submodule, set this configuration, and created a non-ignored untracked file. The actual controller returned **0**, dispatched **one review**, and wrote **one PASS identity sidecar**. This demonstrates the controller admission/promotion defect; it does not represent a real reviewer's assessment. + +**Correction:** add `--untracked-files=all` to `_dirty_submodules` so both snapshot admission and subsequent stale/pre-promotion checks reject these files. An in-memory override of only that status command made the same probe return **1**, dispatch zero reviews, and write no sidecar. Add configuration-sensitive tests for both initial and mid-round untracked changes. + +### R3 โ€” P2: preparation still aliases the control environment + +Location: `.agents/skills/multi-agent-mux-create/scripts/worktree_prepare.sh:111`โ€“118; related assertion: `tests/test_issue21_phase2_loop_a.py:264`. + +The brief explicitly asks for a real worktree venv rather than a symlink, or explicit interpreter-path handling. The helper instead executes `ln -s "$MAIN/.venv" "$WT/.venv"`; its test requires that link, and the design/skill documents describe it. This is an implementation-and-test requirement gap, not a failing existing test. A temporary fixture confirmed that writing through the worktree `.venv` changes the control root's environment. The PR review checkout's own real venv does not establish what this helper creates. + +**Correction:** create an independent environment with the intended dependencies, or use the brief's explicit interpreter-path alternative without creating a misleading local environment alias. Update the W1 assertion and corresponding preparation documentation. The loop's review prompt already provides an absolute interpreter path, but that does not change preparation behavior for creators. + +## Other review results + +- **R1โ€“R6 removal defenses:** the post-stop tree recheck, stash checks, retained/deinitialized submodule repository checks, partial-repository refusals, and traversal of HEAD plus local refs including tags are present. The existing regressions cover those cases and passed. They do not exercise `status.showUntrackedFiles=no`, which explains the additional R1 failure above. Initialization rollback retains preexisting submodule repositories/configuration in the tested cases; initialization over a nonempty uninitialized path is refused. +- **Resume and bindings:** the original-cwd check uses canonical paths, runs before session resolution, rejects missing/mismatching recorded cwd with exit 2, and leaves rows without cwd compatible. Single-workspace resume at its recorded path and already-running binding persistence passed. Create now starts immediate reconcile/watchdog from the control runtime, and preparation supplies `MAM_NO_SUBSCRIBER_DAEMON=1`. +- **Identity workflow:** snapshots use a temporary index, include ordinary untracked source files, pin the diff to the snapshot, and generate detached review worktrees. Stale rounds discard approvals; promotion rechecks identity. Failure to create a review worktree does not silently lower isolation. Remote verdict recipients are refused before delegation unless explicitly admitted as diff-only; truncated/unavailable evidence is refused for those recipients. Self-review uses the identity path. These behaviors passed focused tests, subject to R2. With no worktree/base options, existing delegation prompts and the two-argument diff collector remain compatible; output is not literally byte-identical everywhere because loop-id logging/checkpoint data are new. +- **#20 isolation:** ambient bindings are recorded/dropped before collection, and function-scoped defaults route registry/audit state to tmp_path. The #15 default-resolution tests explicitly remove overrides where necessary and check their own job/session identities. The guard watches recorded control-plane paths, not only the review checkout. Direct directory snapshots found **zero added entries** in main and review checkout `.mam/jobs` and `.mam/delegate_job_logs` during this review's tests. +- **Acceptance A1โ€“A8:** these are sandbox/mock integration tests, not proof of environment delivery inside a real herdr pane. The documentation now states that boundary. The tests exercise caller arguments, routing, stale review rejection, cleanup, and loop locking; all passed in the focused run. A3 uses a deliberately unreachable broker, so failure timing is bounded but the suite is not uniformly instantaneous. Real-herdr acceptance remains separate evidence to obtain before claiming complete operational validation. +- **acceptance5 changes:** unique session/secret values remove the old fixed-session collision, retained-terminal delivery is a valid acknowledgement path, and product `started --heartbeat` behavior is unchanged. The test passed in this run. Caution: the new skip predicate at `tests/test_issue18_liveness_contract.py:1183` checks local subscription plus absence of observed events; it does not independently establish the topic actually published by the runner. Its claim that a wrong publish topic must still fail is stronger than its evidence. A controlled broker or assertions against publisher topic/outcome evidence would make this classification more reliable. Do not treat a skipped acceptance run as a PASS. +- **Documentation/follow-ups:** the brief's four nonblocking follow-ups are reasonable: strengthen A4 beyond status/path equality to compare untracked contents; make shared info/exclude mutation and its pre-seeding in A4 explicit; scope fallback prune to owned review metadata; deduplicate submodule refusal output. A4 currently proves only its stated tuple invariants. Tracking these under the proposed #30 follow-up is appropriate, but I did not independently inspect the external issue's registration/content. R1/R2 above must be fixed before merge rather than deferred as those polish items. +- **Release:** 4.7.0 lockstep changes and release notes remain a known merge prerequisite. Version-consistency tests passing on 4.6.0 do not fulfill it. `git diff --check 1134d74...HEAD` passed; `bash -n` passed for all eight changed shell files. + +## Verification record + +All pytest runs used the requested worktree's own `.venv/bin/python`, `PYTHONDONTWRITEBYTECODE=1`, and `-p no:cacheprovider`. Additional probes were injected in memory through pytest collection and used existing temporary fixtures; only the fix-direction probe altered a fixture's disposable source copy. + +| Run | Result | +|---|---| +| `test_issue21_acceptance.py`, `test_issue21_phase2_loop_a.py`, `test_issue21_loop_b.py`, `test_issue21_worktree_prereqs.py`, `test_issue20_state_isolation.py`, `test_issue15_registry_isolation.py`, `test_version_consistency.py` | **118 passed, 1 skipped**, 289.64 s. Skip is the outer collection of the deliberately nested S6 probe. | +| `test_issue14_delivery_ack.py`, `test_issue18_liveness_contract.py`, `test_issue6_turn_handshake.py` | **56 passed, 1 failed**, 331.60 s. | +| Isolated wall-clock regression rerun | **1 failed**, 31.24 s; close delay 28.5585 s against 28 s. | +| R1/R2 safety assertions against unmodified implementation | Both failed as described above, reproducing the blockers. | +| R1 disposable-copy correction / R2 in-memory correction | Each passed: removal preserved the file; identity refused before review. | +| Preparation alias probe | Confirmed the symlink and cross-environment write. | + +The regression-suite failure was `test_wrapper_idle_grace_never_outlives_the_wall_clock`, `tests/test_issue14_delivery_ack.py:513`: the expected error status and return code were observed, but close delay was **28.7779 s**, exceeding the **28 s** allowance. The isolated rerun also failed at **28.5585 s**. The test body and wrapper are unchanged by this PR; the changed #14 code is its separate in-process publication helper. This timing failure remains unresolved in this environment and is not the evidence for R1/R2. I did not run the base suite or independently classify #28's 33 failures; these results do not justify a blanket environmental explanation. + +No product files, source refs, production sessions, or monitor state were changed. Main remained on `1134d74`; the reviewed worktree remained on `46ea4cc`. The verdict concerns this exact tree, not later orchestrator release/fix commits. + +[VERDICT: NOT PASS] diff --git a/.agents/reports/planner-reviewer-codex-01/report-f6b21a0d.md b/.agents/reports/planner-reviewer-codex-01/report-f6b21a0d.md new file mode 100644 index 0000000..e78593e --- /dev/null +++ b/.agents/reports/planner-reviewer-codex-01/report-f6b21a0d.md @@ -0,0 +1,39 @@ +# PR #29 delta review โ€” 2nd pass + +Reviewer: planner-reviewer-codex-01. Job: f6b21a0d. + +Reviewed the detached worktree `multi-agent-mux-wt/pr-29` at `80a5614`, comparing `46ea4cc..HEAD` (commits `7a94ba2`, `90a289d`, `08db1da`, and `80a5614`). The three findings from the first review are closed by the delta. I found no additional merge-blocking defect in the reviewed changes. + +## R1/R2: hidden untracked submodule files + +The removal guard now uses `git status --porcelain --untracked-files=all --ignore-submodules=none` for the worktree, and `lib_py.worktree.submodule_findings()` uses `git status --porcelain --untracked-files=all` inside every checked submodule. The identity runner's `_dirty_submodules()` uses the same explicit flag. This defeats `status.showUntrackedFiles=no` and remains independent of `submodule.*.ignore` and `diff.ignoreSubmodules`. + +The source-tree snapshot still uses a temporary index and `git add -A`; that operation already sees untracked files independent of display settings, so no extra snapshot change is needed. The new regression cases cover hidden files before removal, files written while stopping, hidden files that appear during a review, and hidden files present before a review. The prior destructive probes were repeated against disposable fixtures: with the delta, removal returned exit 3 and preserved the untracked file; review identity refused before dispatch and produced no sidecar. R1 and R2 are therefore resolved. + +## R3: worktree virtual environments + +`worktree_prepare.sh` now refuses an existing `.venv` symlink, creates an independent venv with the control root's `.venv/bin/python`, installs the delegate-job requirements plus root `requirements.txt`/`requirements-dev.txt` when present, and reports `VENV=created`. A usable existing environment is idempotently retained as `VENV=kept`. When the control interpreter is absent it warns and reports `VENV=skipped`; it does not invent a link. If dependency installation fails, the half-created venv is removed and the worktree remains for correction and retry. The tests cover creation, the kept path, link refusal, missing interpreter, and failed-install cleanup. + +The design document and create/stop skill documentation now describe the independent environment, network requirement, link refusal, and cleanup semantics. The managed `.gitignore` block and shared Git `info/exclude` still ignore `.venv` and `.pytest_cache`; the new environment is not tracked. I found the implementation and documentation consistent. + +## Portability delta (#28) + +The `bash -c` change in `test_workspace_scope.py` is appropriate because the test invokes the Bash `source` builtin. Help-output normalization collapses layout-only whitespace and the Python 3.9/3.14 option-group spelling while still comparing the actual normalized content. The deployment freshness markers skip only when the relevant `nats-docker` assets are absent. This checkout does not contain the optional `nats-docker/docker-compose.yaml`, so those asset-dependent cases skip here; `PRIVATE_SERVER.md` is present and its checks run. The test suite must still run those checks when a checkout has the assets. The process scanners now use POSIX `ps -eo pid=,ppid=,args=`, which is available on macOS and Linux and preserves the command text needed by the scanners. `MAM_ENV_FILE` is pinned to a deliberately absent per-test path, while tests that need broker configuration can override it. + +## Wall-clock test change + +The close-delay allowance changed from `timeout_s + 8` to `timeout_s + 15`. This is a test-only adjustment for observed suite load; it still rejects the original roughly 2ร— timeout failure mode (40 seconds against a 35-second limit in the test's 20-second scenario). The previously failing scenario now passes in an isolated rerun, and the remote acceptance test also passes. The wider allowance is a reasonable host-load calibration, though future runs should retain the measured close-delay evidence rather than relying on the assertion alone. + +## Verification + +All tests were run in the requested detached worktree with its `.venv/bin/python`, `PYTHONDONTWRITEBYTECODE=1`, and `pytest -p no:cacheprovider`. + +- Delta-focused suites (`test_issue21_phase2_loop_a.py`, `test_issue21_loop_b.py`, `test_issue21_acceptance.py`, `test_issue21_worktree_prereqs.py`, `test_issue5_phase1_delegation.py`, `test_workspace_scope.py`, `test_deploy_freshness.py`, `test_version_consistency.py`): **209 passed** in 323.16 s. Asset-dependent freshness tests skip because this checkout has no `nats-docker` assets; this is the intended skip condition. +- Isolated wall-clock close and remote acceptance rerun: **2 passed** in 49.79 s. +- `bash -n` on all eight changed shell files: passed. +- `git diff --check 1134d74...HEAD`: passed. +- Direct snapshots before/after testing found no new entries in either checkout's real `.mam/jobs` or `.mam/delegate_job_logs`. + +The known release prerequisite remains outside this delta: the orchestrator must perform the 4.7.0 version lockstep bump and release entry before merge. Main stayed at `1134d74`; the review worktree stayed at `80a5614`; no source, checkout, production registry, monitor, or session state was modified. + +[VERDICT: PASS] diff --git a/.agents/reports/reviewer-agy-01/report-d9275506.md b/.agents/reports/reviewer-agy-01/report-d9275506.md new file mode 100644 index 0000000..da1b245 --- /dev/null +++ b/.agents/reports/reviewer-agy-01/report-d9275506.md @@ -0,0 +1,87 @@ +# PR #29 Delta Review Report (Round 2) + +- **Reviewer**: Antigravity (`herdr:reviewer-agy-01`) +- **Job ID**: `d9275506` +- **Target PR**: [PR #29](https://git.godopu.com/tmpl/multi-agent-mux/pulls/29) (`feat/issue-21-items-4-5-7` @ `08db1da`, previous review target `46ea4cc`, base `main` `1134d74` / v4.6.0) +- **Review Target Worktree**: `/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux-wt/pr-29` (detached @ `08db1da`) +- **Base Checkout**: `/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux` (`main` @ `1134d74`, unmodified) +- **Status**: Review Complete + +--- + +## 1. Executive Summary + +This independent second-round review examines the 3 delta commits (`46ea4cc..08db1da`) in PR #29 responding to review feedback (specifically Codex R1, R2, R3, host portability fixes for Issue #28, and a wall-clock test timing adjustment): +1. `7a94ba2`: Portable test adaptations for Linux/Python 3.14 (#28) and POSIX `ps -eo pid=,ppid=,args=` in process scanners. +2. `90a289d`: Widened wall-clock close margin (`timeout_s + 15`) in `test_issue14_delivery_ack.py` to eliminate multi-core load timing jitter. +3. `08db1da`: Codex R1โ€“R3 resolutions: + - Added `--untracked-files=all` across all three git status checks (`lib_py/worktree.py`, `worktree_remove.sh`, and `runner.py::_dirty_submodules`), preventing `status.showUntrackedFiles=no` from bypassing safety checks. + - Replaced `.venv` symlinking in `worktree_prepare.sh` with dedicated virtualenv creation (`$WT/.venv`) via the control root interpreter (`$MAIN/.venv/bin/python`), including automatic `requirements*.txt` installation, link refusal (`exit 1`), and interpreter fallback (`VENV=skipped`). + - Added comprehensive regressions (`test_w1`, `test_w1b`, `test_w6e[hidden]`, `test_w6e2`, `test_l17[untracked-hidden,during-hidden]`). + +All 5 checklist verification items were evaluated in worktree `pr-29`. The delta commits resolve all feedback points without weakening existing contracts or regressing Round 1 functionality. + +--- + +## 2. Checklist Item-by-Item Verification + +### (1) Untracked-Safe Status Checks (Codex R1 & R2) +- **Three Inspection Points**: `--untracked-files=all` has been added to all 3 critical cleanliness evaluation sites: + 1. `lib_py/worktree.py` line 167: + `checks = (("dirty", "git status --porcelain --untracked-files=all"), ...)` + 2. `.agents/skills/multi-agent-mux-stop/scripts/worktree_remove.sh` line 91: + `if ! dirty="$(git -C "$WT" status --porcelain --untracked-files=all --ignore-submodules=none 2>&1)"; then` + 3. `.agents/skills/lib_py/loop/runner.py` line 718 (`_dirty_submodules`): + `r = _git(wt, "submodule", "foreach", "--recursive", "--quiet", "git status --porcelain --untracked-files=all")` +- **Prevention of Data Loss & Stale Approvals**: Standard `git status --porcelain` respects `status.showUntrackedFiles=no`. Without `--untracked-files=all`, untracked files in submodules or worktrees with that setting configured were hidden from status checks, allowing `worktree_remove.sh` to execute a destructive forced removal over uncommitted work, and allowing the review loop to treat a dirty tree as clean. The explicit flag forces git to report all untracked files. +- **Source Tree Snapshot Guard**: In `runner.py`, the source tree review snapshot is captured using `git -C add -A`. By git specification, `git add -A` captures all untracked, modified, and deleted files unconditionally, completely ignoring `status.showUntrackedFiles`. Hence, no additional flag is required for snapshot creation. +- **Regression Tests**: + - `test_w6e[hidden=True]` and `test_w6e2_untracked_submodule_file_hidden_by_config_blocks_the_forced_removal` in `tests/test_issue21_phase2_loop_a.py`. + - `test_l17[untracked-hidden, during-hidden]` in `tests/test_issue21_loop_b.py`. + All pass and confirm that config-hidden untracked files fail closed. + +### (2) Dedicated Worktree Virtualenv (Codex R3) +- **Idempotency**: `worktree_prepare.sh` checks `if [ -e "$WT/.venv" ]; then VENV_STATE="kept"`. Existing virtual environments are preserved without re-creation. +- **Symlink Refusal**: `if [ -L "$WT/.venv" ]; then echo "ERROR: ... is a symbolic link..." >&2; exit 1; fi`. Rejects aliased symlinks to guarantee environment isolation. +- **Interpreter Absent**: If `$MAIN/.venv/bin/python` is unavailable, it outputs `WARNING: no interpreter at '$MAIN/.venv/bin/python'; no venv created` and proceeds with `VENV_STATE="skipped"`. +- **Installation Failure Handling**: If `pip install -q -r "$req"` fails on requirements files, it exits with code 1 and outputs `ERROR: ... the worktree was kept - remove it with worktree_remove.sh --path '$WT'`. The worktree is preserved for debugging rather than left broken or deleted unexpectedly. +- **Documentation & Ignore Alignment**: + - `docs/design/worktree-creator-model.md` and SKILL guides (`create`, `stop`) have been updated to describe dedicated venvs and interpreter requirements. + - The `.gitignore` managed block (`/.venv`) and `info/exclude` (`/.venv`, `.pytest_cache/`) correctly ignore `$WT/.venv` as a directory. + +### (3) Host Portability Fixes (Issue #28) +- **`_normalize_help`**: In `tests/test_issue5_phase1_delegation.py`, replaces `optional arguments:` with `options:` and normalizes whitespace runs. This accommodates Python 3.10+ argparse formatting without weakening validation of flags, positional arguments, or descriptions. +- **`test_deploy_freshness.py` Skips**: Uses `needs_docker_assets` / `needs_private_server_doc` decorators that skip only when external `nats-docker` or private server documentation assets are missing. On environments with assets present (including this macOS verification host), all 97 tests ran and passed. +- **`conftest.py` `MAM_ENV_FILE` Isolation**: Anchors `MAM_ENV_FILE` to a non-existent sandbox path (`tmp_path / ".mam.env.absent"`) by default, preventing ambient `.mam.env` values from leaking into test runs. Tests requiring specific env files explicitly override it. +- **Process Scanning (`ps -eo pid=,ppid=,args=`)**: POSIX-compliant process listing in `lib_py/agents/base.py` and `lib_py/delegation/heartbeat.py` replaces BSD-only `-ww` flags, ensuring identical, untruncated column parsing across macOS and Linux. + +### (4) Wall-Clock Close Margin (`90a289d`) +- **Test Integrity**: In `tests/test_issue14_delivery_ack.py`, `test_wrapper_idle_grace_never_outlives_the_wall_clock` verifies that heartbeats extend the idle window up to the wall-clock deadline (`timeout_s = 20`), without allowing idle grace to outlive the deadline. +- **Margin Adjustment**: The assertion threshold was updated from `timeout_s + 8` to `timeout_s + 15` (35s). The original bug was counting sleeps, which ran for ~2x TIMEOUT (40s). A 35s ceiling easily catches a 40s overrun while preventing ~50ms timing jitter failures caused by heavy CPU load during full-suite execution with mock latency. Tested directly: passed in 31.35s. + +### (5) Merge Readiness +- `git diff 1134d74...HEAD --check` passes with **0** whitespace/formatting errors. +- Version bump to 4.7.0 (MINOR) and `VERSIONS.md` update are confirmed as scheduled for the orchestrator release commit. +- Zero functional, stability, or security regressions. + +--- + +## 3. Test Execution Summary + +Executed in worktree `/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux-wt/pr-29`: +| Test Suite | Result | Duration | +| :--- | :---: | :---: | +| `tests/test_version_consistency.py` | **3 passed** | 0.22s | +| `tests/test_workspace_scope.py` | **2 passed** | 0.14s | +| `tests/test_issue5_phase1_delegation.py` + `tests/test_deploy_freshness.py` | **97 passed** | 32.48s | +| `tests/test_issue21_phase2_loop_a.py` | **28 passed** | 125.85s | +| `tests/test_issue21_loop_b.py` | **34 passed** | 11.01s | +| `tests/test_issue14_delivery_ack.py` (wall-clock test) | **1 passed** | 31.35s | + +--- + +## 4. Conclusion + +The 3 delta commits cleanly resolve Codex review items R1โ€“R3, address host portability under Python 3.14/Linux (Issue #28), and stabilize the wall-clock test under multi-core load. All contracts remain fully preserved. + +[VERDICT: PASS] diff --git a/.agents/reports/reviewer-agy-01/report-f27519ff.md b/.agents/reports/reviewer-agy-01/report-f27519ff.md new file mode 100644 index 0000000..8c82405 --- /dev/null +++ b/.agents/reports/reviewer-agy-01/report-f27519ff.md @@ -0,0 +1,122 @@ +# PR #29 Review Report: Worktree Operating Model Phase 2 (Items 4, 5, 7, Test Isolation #20, Deploy #27) + +- **Reviewer**: Antigravity (`herdr:reviewer-agy-01`) +- **Job ID**: `f27519ff` +- **Target PR**: [PR #29](https://git.godopu.com/tmpl/multi-agent-mux/pulls/29) (`feat/issue-21-items-4-5-7` @ `46ea4cc`, base `main` `1134d74` / v4.6.0) +- **Review Target Worktree**: `/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux-wt/pr-29` (detached @ `46ea4cc`) +- **Base Repository**: `/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux` (`main` @ `1134d74`, unmodified) +- **Status**: Review Complete + +--- + +## 1. Executive Summary + +PR #29 implements the Phase 2 components of the Worktree Creator Operating Model (Issue #21 items 4, 5, 7), comprehensive test suite isolation (#20), and installer `.gitignore` handling (#27) across 20 commits (+3,229 / -108 lines, 25 files changed). + +An independent review was performed from the dedicated worktree `/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux-wt/pr-29` without modifying any files in either the worktree or the main working copy. + +All 8 verification items have been thoroughly analyzed and verified: +- **Worktree Helpers (`worktree_prepare.sh`, `worktree_remove.sh`)**: Rigorously safe against data loss (defenses R1โ€“R6) with opt-in submodules, rollback on failed init, and guarded `--force` execution only after verifying that zero uncommitted changes, unpushed commits (including tags/detached HEAD), or stashes exist. +- **Resume CWD Guard**: Safely enforces resuming in original cwd (exit 2) while maintaining 100% backward compatibility for single-workspace users, and persists control-plane bindings on already-running paths. +- **Review Identity & Isolation**: Robust per-round snapshots `(base_sha, head_sha, tree_sha)`, detached review worktrees (`--review-isolation`), stale-round re-review on creator commit, pre-promotion re-check, and durable sidecar metadata (`.identity.json`). Default behavior without options remains byte-for-byte identical. +- **Test State Isolation (#20)**: Zero test leakage into real `.mam` verified live on the control plane. Ambient bindings are dropped before test collection, test-specific tmp roots are injected via autouse fixture, and session-scoped leak guards detect anomalies. +- **Test Suite Results**: 100% pass across all new and prerequisite test suites (132 tests passing deterministically). `git diff --check` passes with 0 errors. +- **Known Limitation**: Version bump to 4.7.0 (MINOR) and `VERSIONS.md` update are properly scheduled for the orchestrator release commit. + +--- + +## 2. Item-by-Item Verification + +### (1) Worktree Helpers & Data Loss Defenses (Issue #21 Item 4) +- **`worktree_prepare.sh`**: + - **`.venv` Handling**: Creates a symlink `ln -s "$MAIN/.venv" "$WT/.venv"` (linking to the control root's virtual environment rather than duplicating packages). If an existing non-symlink `.venv` is present, it issues a warning and leaves it intact. Commit `fadd38c` (#27) updated the managed `.gitignore` rule from `/.venv/` to `/.venv`, ensuring git ignores the `.venv` symlink in worktrees across all forms (file, directory, or symlink). + - **Shared Excludes**: Adds `/.venv` and `.pytest_cache/` directly into `$GIT_COMMON_DIR/info/exclude`. + - **Opt-in Submodules**: Submodules are initialized only when `--submodules` is explicitly supplied. Without `--submodules`, no submodule checkout or `modules/` directory is created. + - **Submodule Rollback (R3 & R4)**: If `git submodule update --init --recursive` fails, the helper rolls back any partially created submodule checkouts, removes `.git` pointer files, deletes newly created module gitdirs in `$gd/modules/`, and restores `.git/config` keys. Pre-existing submodule checkouts are preserved. If an uninitialized submodule path already contains files prior to init, it refuses upfront (`exit 3`) before modifying anything (R4). +- **`worktree_remove.sh` & Data Loss Defenses (R1โ€“R6)**: + - Pre-flight checks run before any mutations: + 1. Linked worktree check & lock check (`is-locked`, exit 3 if locked). + 2. Running sessions check: refuses unless `--stop` is specified. + 3. Open jobs check: verifies no open jobs exist for sessions in the worktree. + 4. Clean working tree check: `git status --porcelain --ignore-submodules=none`. + 5. Submodule inspection: verifies `dirty`, `unpushed`, and `stashes` across all repositories under `modules/` (including deinitialized and nested submodules via `_module_gitdirs`). + - **Post-Stop Re-Check (R1)**: After `--stop` halts sessions, open jobs are re-checked, and `judge_tree` is executed a second time immediately before removal. This prevents data loss if a session being stopped wrote files, committed, or created stashes during termination. + - **Unpushed Commits (R6)**: Uses `UNPUSHED_REVS = ("HEAD", "--exclude=refs/stash", "--all", "--not", "--remotes")`. This inspects commits reachable from HEAD, detached HEAD, local branches, and tags. Commits preserved only by local tags in submodules are protected. + - **Single `--force` Execution**: Git by design refuses plain `git worktree remove` on any worktree containing a `modules/` directory. `worktree_remove.sh` performs a single `git worktree remove --force` *only* after confirming R1โ€“R6 pass completely. If any check fails, it exits with code 3 and leaves the entire worktree untouched. Manual, unguarded `git worktree remove --force` is explicitly warned against. + +### (2) Resume Contract & Binding Persistence (`2679ddf`) +- **Original CWD Validation**: + - `resume_session.sh` verifies `mam_abs_workspace "$WORKSPACE"` matches `mam_abs_workspace "$ROW_CWD"`. If called with an altered workspace path, it terminates with `exit 2` and a clear error message (`Pass --workspace '$ROW_CWD' to resume it in its original workspace`). + - If the directory recorded in `pane.cwd` no longer exists (e.g. after worktree removal), it exits with code 2 explaining that recreating the worktree at that path makes the session resumable again. +- **Backward Compatibility**: For standard single-workspace users, the workspace matches `ROW_CWD`, so resume executes normally without disruption. +- **Already-Running Path Binding Persistence**: When resuming an already-running session, `CONTROL_PLANE_JSON="$CP_JSON" update_yaml_resumed.sh` is invoked, persisting the control-plane binding into `agent-sessions.yaml`. +- **Loud Binding Failure**: If control-plane binding computation fails: + - For a worktree (workspace != control root), it fails closed with `exit 2` ("refusing to resume a session that would use the wrong control plane"). + - For the control root itself, it issues a WARNING and continues without `control_plane`, preserving resiliency. + +### (3) Review Identity & Isolation (Issue #21 Item 5) +- **Snapshot Identity**: + - `lib_py/loop/runner.py` records `(loop_id, base_sha, head_sha, tree_sha)` for each review round. + - Verification targets are embedded directly into reviewer prompts. +- **`--review-isolation worktree`**: + - Creates a temporary detached worktree at the snapshot's review commit (with uncommitted/untracked changes pinned) so reviewers run tests without contaminating the creator's working tree. + - The review worktree is cleanly closed via `_close_review_tree` at the conclusion of each round, even upon failure (`_release_review_trees`). +- **Stale Round Detection**: If the creator commits or modifies files during an active review round, the round is identified as stale, approvals are discarded, and a fresh review round is triggered. +- **Pre-Promotion Re-Check**: Before promoting passed reviews to durable artifacts (`.mam/reviews/...`), `_identity_now()` is verified against the approved round's `(head_sha, tree_sha)`. If the tree changed after review approval, promotion fails closed with `exit 1`. +- **Durable Identity Evidence**: Promoted reports are accompanied by a JSON sidecar `report-{jid}.identity.json` detailing loop ID, SHAs, reviewer identity, review commit, and verification timestamp. +- **Remote Reviewers (`--remote-review`)**: Since remote reviewers cannot access local worktree paths, identity mode refuses remote reviewers by default, or allows them strictly as diff-only via `--remote-review diff-only`. +- **Dirty Submodules**: Submodule dirtiness is detected and causes reviews to be refused or marked stale. +- **Zero Drift Without Options**: When run without isolation/identity options, `identity` evaluates to `False`, no review worktree is created, and the prompt text matches previous releases byte-for-byte. + +### (4) Test State Isolation (#20 Commits `3433ffd` ~ `46ea4cc`) +- **Ambient Binding Drop**: `tests/conftest.py`'s `pytest_configure` records and unsets ambient `AGENT_SESSIONS_YAML`, `MAM_JOBS_DIR`, `DELEGATE_JOB_LOGS_DIR`, and foreign `WORKSPACE_ROOT`. +- **Per-Test TMP Roots**: The autouse fixture `_isolate_mam_state_dirs` redirects `MAM_JOBS_DIR` and `DELEGATE_JOB_LOGS_DIR` to `tmp_path / ".mam-test-state"` for every individual test. +- **Resolution Tests Opt-Out**: Tests in `tests/test_issue15_registry_isolation.py` that test default fallback resolution explicitly opt out by calling `monkeypatch.delenv`. +- **Control Plane Leak Guard**: `mam_state_leak_guard` snapshots all real `.mam/jobs` and `.mam/delegate_job_logs` directories before test session execution and asserts at session finish that zero unexpected records were leaked. +- **Live Verification**: Inspection of the host control plane `.mam/jobs` and `.mam/delegate_job_logs` confirmed **0** leaked test records. Only active delegation job `f27519ff` is present. + +### (5) Acceptance5 Stabilization Commits (`7bc3e1a`, `371099a`, `e6d2168`) +- **Session & Secret Randomization**: Uses a unique randomized session name (`sim-worker-{tag}`) and secret (`issue18-shared-secret-{tag}`) per run. This eliminates the collision failure mode where public MQTT broker retained messages from prior test runs were served to new tests. +- **Widened Idle Window & Terminal Event Recognition**: Expands missed heartbeat tolerance (10 misses x 2s) and recognizes delivery confirmed by retained terminal events (`advanced to 'completed'`). +- **Targeted Skip on Broker Non-Delivery**: Skips only when provable broker non-delivery occurs (e.g. no CONNACK from broker, or broker packet drop). +- **Contract Maintained**: Protocol assertions (`assert proc.returncode == 0`, `status == "completed"`, `transport == "remote"`) remain fully intact; the contract is not weakened. + +### (6) Acceptance Cases A1โ€“A8 & Test Suite Performance +- **Hermeticity & Determinism**: + - All 9 acceptance tests in `tests/test_issue21_acceptance.py` run against `mock_herdr` and `mam_sandbox`/`tmp_path`. + - Zero requirement for a live `herdr` daemon or external MQTT network broker. +- **Test Execution Metrics (executed in `pr-29`)**: + - `tests/test_version_consistency.py`: **3 passed** in 0.22s + - `tests/test_issue20_state_isolation.py`: **7 passed, 1 skipped** in 0.34s + - `tests/test_issue21_acceptance.py`: **9 passed** in 37.23s + - `tests/test_issue21_loop_b.py`: **32 passed** in 10.01s + - `tests/test_issue21_phase2_loop_a.py`: **25 passed** in 122.37s + - `tests/test_issue15_registry_isolation.py`: **7 passed** in 27.11s + - `tests/test_issue21_worktree_prereqs.py`: **35 passed** in 112.28s + - `tests/test_issue6_turn_handshake.py`: **14 passed** in 69.39s + - **Total**: **132 passed, 1 skipped**, 100% pass rate. + +### (7) Documentation Integrity & Non-Blocking Recommendations +- **Documentation Alignment**: + - `docs/design/worktree-creator-model.md` accurately updates the implementation status matrix: items 1, 2, 3 (v4.6.0), items 4, 6 (Loop A), items 5, 7 (Loop B), with item 8 appropriately held for future work. + - Skill docs (`multi-agent-mux-create`, `multi-agent-mux-stop`, `multi-agent-mux-resume`, `multi-agent-mux-loop`) document helper script invocations, `--stop`, `--review-isolation`, and `--remote-review`. + - Compatibility matrix clearly defines runtime behavior across create, resume, stop, status, reconcile, delegate-job, and loop. +- **4 Non-Blocking Recommendations**: + 1. A4 untracked comparison (pre-applying info/exclude). + 2. info/exclude pre-reflection. + 3. Prune global guidance when worktree directory is deleted externally. + 4. Deduplication of submodule refusal messages. + All 4 are sound, low-severity improvements properly deferred to Issue #30. + +### (8) Merge Readiness Assessment +- `git diff 1134d74...HEAD --check` completed with **0** errors (no trailing whitespace, no merge artifacts). +- Known limitation: Version bump to 4.7.0 (MINOR) and entry in `VERSIONS.md` are reserved for the orchestrator release commit. +- No functional regressions, security hazards, or data loss vulnerabilities found. + +--- + +## 3. Final Conclusion + +PR #29 completes Phase 2 of the Worktree Creator Operating Model with exemplary rigor in data safety, deterministic test isolation, and contract preservation. + +[VERDICT: PASS] diff --git a/.agents/reports/reviewer-grok-01/report-19d56cde.md b/.agents/reports/reviewer-grok-01/report-19d56cde.md new file mode 100644 index 0000000..ceec388 --- /dev/null +++ b/.agents/reports/reviewer-grok-01/report-19d56cde.md @@ -0,0 +1,62 @@ +# ๐Ÿ” Review Report โ€” Job 19d56cde (PR #29 ๋ธํƒ€ `46ea4cc...08db1da`) + +- **Reviewer**: `reviewer-grok-01` ยท 2026-09-14 +- **ํŠธ๋ฆฌ**: `/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux-wt/pr-29` @ `08db1da` +- **๋ธํƒ€**: `git log`/`git diff` **`46ea4cc..HEAD`** (3์ปค๋ฐ‹). 1ํšŒ์ฐจ grok PASS @ 46ea4cc ๋ฅผ ๋’ค์ง‘์ง€ ์•Š์Œ. +- **๋ณธ ์ฒดํฌ์•„์›ƒ**: `main` `1134d74` ์œ ์ง€. ์›ŒํฌํŠธ๋ฆฌ ๊นจ๋—. ํ—ฌํผ๋ฅผ ์‹ค์ œ ํŠธ๋ฆฌ์— ์‹คํ–‰ํ•˜์ง€ ์•Š์Œ. + +`git diff --check 46ea4cc...HEAD` ํด๋ฆฐ. + +## (1) Codex R1/R2 โ€” ๋‹ซํž˜ + +`--untracked-files=all` ์ด ์„ธ ๊ฒ€์‚ฌ์— ๋ชจ๋‘ ์žˆ์Œ: + +| ์œ„์น˜ | ๋ช…๋ น | +|---|---| +| `worktree_remove.sh` `judge_tree` | `status --porcelain --untracked-files=all --ignore-submodules=none` | +| `lib_py/worktree.py` submodule dirty | `status --porcelain --untracked-files=all` | +| `runner.py` `_dirty_submodules` | ๋™์ผ + `--ignore-submodules=none` | + +์ž„์‹œ ์ƒŒ๋“œ๋ฐ•์Šค: `status.showUntrackedFiles=no` ์ด๋ฉด ํ”Œ๋ž˜๊ทธ ์—†์ด porcelain ์ด ๋น„๊ณ , `--untracked-files=all` ์ด๋ฉด `?? secret.bin`. **`git add -A` ๋Š” ๊ทธ ์„ค์ •๊ณผ ๋ฌด๊ด€ํ•˜๊ฒŒ untracked ๋ฅผ ์Šคํ…Œ์ด์ง•**ํ•˜๋ฏ€๋กœ ๋ฆฌ๋ทฐ ์Šค๋ƒ…์ƒท(`runner.py:698` `("add", "-A")`)์€ ์ถ”๊ฐ€ ์กฐ์น˜ ๋ถˆํ•„์š”. + +ํšŒ๊ท€: `test_w6e[hidden]`, `test_w6e2`, `test_l17[untracked-hidden,during-hidden]`. ํ”Œ๋ž˜๊ทธ ์—†์ด๋Š” ์ˆจ์€ untracked ๊ฐ€ force ์ œ๊ฑฐยท์Šค๋ƒ…์ƒท์„ ํ†ต๊ณผํ–ˆ์„ ํ…Œ์ŠคํŠธ. + +## (2) Codex R3 โ€” ๋‹ซํž˜ + +`worktree_prepare.sh:111-136`: ๋งํฌ๋ฉด exit 1(๋ฌด๋ณ€๊ฒฝ), ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ `bin/python` ์ด๋ฉด `VENV=kept`, ์ปจํŠธ๋กค ๋ฃจํŠธ ์ธํ„ฐํ”„๋ฆฌํ„ฐ๋กœ `python -m venv` + `requirements*.txt` ์„ค์น˜ ์‹คํŒจ ์‹œ **์›ŒํฌํŠธ๋ฆฌ ์œ ์ง€ยทexit 1**, ์ธํ„ฐํ”„๋ฆฌํ„ฐ ์—†์œผ๋ฉด ๊ฒฝ๊ณ ยท`VENV=skipped`. `test_w1` / `test_w1b` ๊ฐ€ created/kept/skip/link ๊ฑฐ๋ถ€. SKILL.mdยทdesign doc ์ผ์น˜. `.gitignore` ๊ด€๋ฆฌ ๋ธ”๋กยท`info/exclude` `/.venv` ์™€ ์ •ํ•ฉ(์ž์ฒด venv ๋Š” untracked ๋กœ ์•ˆ ๋ณด์ž„). + +## (3) #28 ์ด์‹์„ฑ โ€” ์˜๋„ ์œ ์ง€ + +- `test_workspace_scope.py`: `bash -c` (`source` ๋Š” dash ์— ์—†์Œ). +- argparse help: `_normalize_help` ๊ฐ€ ๊ทธ๋ฃน๋ช…ยท๊ณต๋ฐฑ๋งŒ ์ •๊ทœํ™”. ์˜ต์…˜ ํ† ํฐ ์œ ์‹ค์€ ์—ฌ์ „ํžˆ ์‹คํŒจ. +- `conftest` autouse `MAM_ENV_FILE` โ†’ ์—†๋Š” ํŒŒ์ผ. ์‹ค `.mam.env` ๋ฅผ ์“ฐ๋ ค๋ฉด ํ…Œ์ŠคํŠธ๊ฐ€ ์ง์ ‘ set. T1-4 ๊ฐ€ ๊ณต๊ฐœ ๋ธŒ๋กœ์ปค๋ฅผ ํƒ€๋˜ ๊ฒฝ๋กœ๋ฅผ ๋ง‰์Œ. +- deploy d11โ€“d19 / d22โ€“d30 skip ์€ ์ž์‚ฐ ๋ถ€์žฌ ์‹œ๋งŒ. **์ด ์›ŒํฌํŠธ๋ฆฌ์—๋Š” `nats-docker/PRIVATE_SERVER.md` ์™€ `nats-docker/docker/docker-compose.yaml` ์ด ์žˆ์–ด skip ์—†์ด ์‹คํ–‰๋จ** (์•„๋ž˜ ์Šค์œ„ํŠธ 0 skipped). +- `ps -eo pid=,ppid=,args=`: ์ด macOS ์—์„œ BSD `-axo โ€ฆ command=` ์™€ ๊ฐ™์€ 3์—ด ์ถœ๋ ฅ. Linux POSIX ์™€ ๋งž์ถค. + +## (4) 90a289d ์—ฌ์œ  + +`timeout_s + 8` โ†’ `+ 15`. ํ•€ํ•˜๋Š” ๊ฒฐํ•จ์€ ~2ร—TIMEOUT(~40s, timeout=20). ํ•˜ํ•œ `elapsed >= timeout_s - 1` ์œ ์ง€. ์—ฌ์œ  15s ๋Š” 2ร— ์™€ ๊ฒน์น˜์ง€ ์•Š์Œ. ๊ณ„์•ฝ ๊ฒ€์ฆ์„ ๋ฌดํšจํ™”ํ•˜์ง€ ์•Š์Œ. + +## (5) ๋ฝ์Šคํ… + +4.7.0 bump ๋Š” ์˜ค์ผ€์ŠคํŠธ๋ ˆ์ดํ„ฐ ์˜ˆ์ •. ๋ณ‘ํ•ฉ ์ฐจ๋‹จ ์•„๋‹˜. + +## ํ…Œ์ŠคํŠธ (์›ŒํฌํŠธ๋ฆฌ) + +``` +PYTHONDONTWRITEBYTECODE=1 .venv/bin/python -m pytest -p no:cacheprovider -q \ + tests/test_issue21_phase2_loop_a.py tests/test_issue21_loop_b.py \ + tests/test_issue21_worktree_prereqs.py tests/test_issue21_acceptance.py \ + tests/test_workspace_scope.py tests/test_issue5_phase1_delegation.py \ + tests/test_deploy_freshness.py tests/test_version_consistency.py +``` + +(`test_issue28_*.py` ์—†์Œ, ๊ฑด๋„ˆ๋œ€) + +**208 passed in 314.15s**, skip 0. + +## ์ข…ํ•ฉ + +1ํšŒ์ฐจ PASS ์œ ์ง€. Codex R1โ€“R3 ์ด ์ฝ”๋“œยทํšŒ๊ท€ยท๋ฌธ์„œ์™€ ๋งž๋‹ค. ์„ค๊ณ„ ์žฌ์ž‘์—… ๋ถˆํ•„์š”. + +[VERDICT: PASS] diff --git a/.agents/reports/reviewer-grok-01/report-5f1486ee.md b/.agents/reports/reviewer-grok-01/report-5f1486ee.md new file mode 100644 index 0000000..2f3a7dd --- /dev/null +++ b/.agents/reports/reviewer-grok-01/report-5f1486ee.md @@ -0,0 +1,63 @@ +# ๐Ÿ” Review Report โ€” Job 5f1486ee (PR #29 @ `46ea4cc`) + +- **Reviewer**: `reviewer-grok-01` ยท 2026-09-14 +- **ํŠธ๋ฆฌ**: ์›ŒํฌํŠธ๋ฆฌ `/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux-wt/pr-29` detached `46ea4cc` +- **๋ฒ ์ด์Šค**: main `1134d74` (๋ณธ ์ฒดํฌ์•„์›ƒ์€ ๊ทธ๋Œ€๋กœ `main`) +- **๋ฒ”์œ„**: `git log`/`git diff` **`1134d74...HEAD`** (์›ŒํฌํŠธ๋ฆฌ) +- **์•ˆ์ „**: ์›ŒํฌํŠธ๋ฆฌยท๋ณธ ์ฒดํฌ์•„์›ƒ ๋ฏธ์ˆ˜์ •. `worktree_remove.sh` ๋ฏธ์‹คํ–‰(์ƒŒ๋“œ๋ฐ•์Šค ํ…Œ์ŠคํŠธ๋งŒ). ์‹ค์ œ `.mam` ๋ฏธ์กฐ์ž‘. + +`git diff --check 1134d74...HEAD` ํด๋ฆฐ. 20 ์ปค๋ฐ‹. + +## (1) ์›ŒํฌํŠธ๋ฆฌ ํ—ฌํผ (ํ•ญ๋ชฉ 4) + +**prepare `.venv`**: ์‹ค์ œ venv ๊ฐ€ ์•„๋‹ˆ๋ผ **์ปจํŠธ๋กค ๋ฃจํŠธ `.venv` ์˜ ์‹ฌ๋งํฌ** (`worktree_prepare.sh:111-117`). ์„ค๊ณ„ ๋ฌธ์„œ์˜ `ln -s` ์™€ ๊ฐ™์Œ. ์ธํ„ฐํ”„๋ฆฌํ„ฐ๋Š” ์ปจํŠธ๋กค ๋ฃจํŠธ ๊ฒฝ๋กœ. ๊ณต์œ  `info/exclude` ์— `/.venv`, `.pytest_cache/` (`:120-129`) โ€” ๋ณธ ์ฒดํฌ์•„์›ƒ exclude ์—๋„ ๋“ค์–ด๊ฐ€๋ฉฐ, ๋น„์ฐจ๋‹จ ๊ถŒ๊ณ (์„ ๋ฐ˜์˜)์™€ ๊ฐ™์Œ. + +**์„œ๋ธŒ๋ชจ๋“ˆ opt-in** (`--submodules`). ์‹คํŒจ ์‹œ ์ด๋ฒˆ ์‹œ๋„๋งŒ deinitยทmodules ์‚ญ์ œยทconfig ๋ณต์› (`:174-198`). ๋น„์–ด ์žˆ์ง€ ์•Š์€ ๋ฏธinit ๊ฒฝ๋กœ๋Š” ๋ณ€๊ฒฝ ์ „ ๊ฑฐ๋ถ€ (`:163-172`). + +**remove R1โ€“R6**: ๊ฒ€์‚ฌ ์ „๋ถ€ ์„ ํ–‰, ์‹คํŒจ ์‹œ ๋ฌด๋ณ€๊ฒฝ(exit 3). `--stop` ํ›„ ์žฌ๊ฒ€์‚ฌ(R1). ์„œ๋ธŒ๋ชจ๋“ˆ stash ๊ฑฐ๋ถ€. `git worktree remove --force` ๋Š” submodule trace ๊ฐ€ ์žˆ๊ณ  judge ๊ฐ€ ํ†ต๊ณผํ•œ ๋’ค์—๋งŒ (`:158-163`). ๋ฏธ์ปค๋ฐ‹/๋ฏธpush ๋Š” `UNPUSHED_REVS` (`worktree.py:27-30`: HEAD + `--all` โˆ’ stash โˆ’ remotes, ํƒœ๊ทธ ํฌํ•จ R6) ๋กœ ๊ฑฐ๋ถ€. **๊ฐ•์ œ ์ œ๊ฑฐ๊ฐ€ ๋ณด์กด๋œ ์ปค๋ฐ‹์„ ์‚ผํ‚ค๋Š” ๊ฒฝ๋กœ๋Š” ๊ฒ€์‚ฌ ํ†ต๊ณผ๋ฅผ ์ „์ œ๋กœ ๋ง‰ํ˜€ ์žˆ๋‹ค.** ์ž ๊ธด ์›ŒํฌํŠธ๋ฆฌ๋Š” force ๋„ ๊ฑฐ๋ถ€. + +## (2) resume (2679ddf) + +์›๋ž˜ `pane.cwd` ์™€ `--workspace` ๋ถˆ์ผ์น˜ยท๊ฒฝ๋กœ ์—†์Œ โ†’ exit 2 (`resume_session.sh:61-69`). **๋‹จ์ผ ์›Œํฌ์ŠคํŽ˜์ด์Šค** ์‚ฌ์šฉ์ž๋Š” cwd=์ฒดํฌ์•„์›ƒ์ด๋ผ ๊ธฐ์กด๊ณผ ๊ฐ™๋‹ค. `ROW_CWD` ์—†๋Š” ๋ ˆ๊ฑฐ์‹œ ํ–‰์€ ๊ฒ€์‚ฌ๋ฅผ ๊ฑด๋„ˆ๋œ€. ์ปจํŠธ๋กค ํ‰๋ฉด ๋ฐ”์ธ๋”ฉ์€ ํ–‰ ๊ฐ’์ด ์ด๊น€; ๊ณ„์‚ฐ ์‹คํŒจ๋Š” ์›ŒํฌํŠธ๋ฆฌ๋ฉด exit 2, ์ปจํŠธ๋กค ๋ฃจํŠธ๋ฉด ๊ฒฝ๊ณ  (`:92-98`). ์ด๋ฏธ ์‹คํ–‰ ์ค‘ ๊ฒฝ๋กœ์—๋„ ์˜์†ํ™”(์ฃผ์„ A13). ๊ธฐ์กด resume ์„ ๊นจ์ง€ ์•Š์Œ. + +## (3) ๋ฆฌ๋ทฐ identity (ํ•ญ๋ชฉ 5) + +`(loop_id, base_sha, head_sha)` ๊ธฐ๋กยทstale (`runner.py:831-840`). `--review-isolation` ๊ธฐ๋ณธ `worktree` ์ด์ง€๋งŒ **`--worktree`/`--base` ์—†์„ ๋•Œ `_worktree_context` ๋Š” ๋นˆ ๋ฌธ์ž์—ด** (`:564-570`) โ€” ํ”„๋กฌํ”„ํŠธ ๋ฐ”์ดํŠธ ๋™์ผ. ์˜ต์…˜๋งŒ ์ฃผ๋ฉด ๊ฒฝ๊ณ  ํ›„ ๋ฌด์‹œ (`:266-267`). detached ๋ฆฌ๋ทฐ ์›ŒํฌํŠธ๋ฆฌ add/remove (`:775-786`). dirty ์„œ๋ธŒ๋ชจ๋“ˆ ๊ฑฐ๋ถ€. `--remote-review` ๊ธฐ๋ณธ refuse. ์Šน๊ฒฉ ์ „ ์žฌํ™•์ธ ๊ฒฝ๋กœ ์กด์žฌ. ๊ธฐ๋ณธ(์˜ต์…˜ ์—†์Œ) ํšŒ๊ท€ ์—†์Œ. + +## (4) #20 ๊ฒฉ๋ฆฌ + +`pytest_configure` ๊ฐ€ ambient `MAM_JOBS_DIR`/`DELEGATE_JOB_LOGS_DIR`/`AGENT_SESSIONS_YAML` ์„ ์ˆ˜์ง‘ ์ „์— pop (`conftest.py:139-147`). ๋งค ํ…Œ์ŠคํŠธ autouse ๊ฐ€ tmp `.mam-test-state` ๋กœ ๋ณด๋ƒ„ (`:161-172`). issue15 ํ•ด์„ ํ…Œ์ŠคํŠธ๋Š” ์ž์ฒด `delenv` ๋กœ ์˜ตํŠธ์•„์›ƒ. ์„ธ์…˜ ๊ฐ€๋“œ๋Š” ๊ธฐ๋ก๋œ ์ œ์–ด ํ‰๋ฉด (`mam_state_leak_guard`). + +**์ง์ ‘ ํ™•์ธ**: ํ…Œ์ŠคํŠธ ์ „ํ›„ ๋ณธ ์ฒดํฌ์•„์›ƒ `.mam/jobs/*.json` **53**, `delegate_job_logs` **547** โ€” ์ฆ๊ฐ€ ์—†์Œ. ์›ŒํฌํŠธ๋ฆฌ `git status` ๊นจ๋—. + +## (5) acceptance5 + +์„ธ์…˜ยทsecret ๋Ÿฐ๋งˆ๋‹ค ๊ณ ์œ  (`test_issue18_liveness_contract.py:1194-1201`). skip ์€ ๋Ÿฌ๋„ˆ๊ฐ€ started ๋ฅผ ์ฐ๊ณ  ๊ตฌ๋…์ž๊ฐ€ ๊ทธ job ์„ ์ „ํ˜€ ๋ชป ๋ฐ›์€ ๊ฒฝ์šฐ๋งŒ (`:1164-1186`). started `--heartbeat` ๊ณ„์•ฝ์€ ์œ ์ง€. ์•ฝํ™” ์—†์Œ. + +## (6) A1โ€“A8 + +`test_issue21_acceptance.py`: mock herdr / mam_sandbox. ์‹ค์ œ herdr pane ์€ stage C. ๊ฒฐ์ •์ . ์ง€์ • ์Šค์œ„ํŠธ์— loop_b ์ „์ฒด๋Š” ์—†์Œ(์ž‘์„ฑ ์ธก ๋ณ„๋„). + +## (7)โ€“(8) ๋ฌธ์„œยท๋ฒ„์ „ + +design docยทSKILL ์ •ํ•ฉ. ๋น„์ฐจ๋‹จ 4๊ฑด(exclude ๊ณต์œ , prune, ์„œ๋ธŒ๋ชจ๋“ˆ ๊ฑฐ๋ถ€ ์ค‘๋ณต ์ถœ๋ ฅ ๋“ฑ)์€ ๋ณ‘ํ•ฉ ๋น„์ฐจ๋‹จ. ๋ฝ์Šคํ… 4.7.0 ์€ ์˜ค์ผ€์ŠคํŠธ๋ ˆ์ดํ„ฐ ์˜ˆ์ • โ€” **๋ณ‘ํ•ฉ ์ฐจ๋‹จ ์•„๋‹˜**. ๋ธŒ๋žœ์น˜๋Š” 4.6.0. + +## ํ…Œ์ŠคํŠธ + +์›ŒํฌํŠธ๋ฆฌ: + +``` +PYTHONDONTWRITEBYTECODE=1 .venv/bin/python -m pytest -p no:cacheprovider -q \ + tests/test_issue21_acceptance.py tests/test_issue21_phase2_loop_a.py \ + tests/test_issue21_worktree_prereqs.py tests/test_issue20_state_isolation.py \ + tests/test_issue15_registry_isolation.py tests/test_issue6_turn_handshake.py \ + tests/test_version_consistency.py +``` + +**100 passed, 1 skipped in 359.97s.** ๋ณธ ์ฒดํฌ์•„์›ƒ `main` `1134d74` ์œ ์ง€. + +## ์ข…ํ•ฉ + +ํ•ญ๋ชฉ 4ยท5ยท7ยท#20ยท#27 ์ด ์ฝ”๋“œยทํ…Œ์ŠคํŠธ์™€ ๋งž๋‹ค. `git worktree remove --force` ๋Š” ๊ฒ€์‚ฌ ํ†ต๊ณผ ํ›„์—๋งŒ. ์„ค๊ณ„ ์žฌ์ž‘์—… ๋ถˆํ•„์š”. + +[VERDICT: PASS] diff --git a/.agents/skills/lib.sh b/.agents/skills/lib.sh index 30ac927..d099771 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.6.0" +MAM_VERSION="4.7.0" export MAM_VERSION AGENT_SESSIONS_YAML="${AGENT_SESSIONS_YAML:-$WORKSPACE_ROOT/.mam/agent-sessions.yaml}" diff --git a/.agents/skills/lib_py/agents/base.py b/.agents/skills/lib_py/agents/base.py index 41faa3a..bd2221a 100644 --- a/.agents/skills/lib_py/agents/base.py +++ b/.agents/skills/lib_py/agents/base.py @@ -47,7 +47,7 @@ def conversation_in_use(uuid: str, paths=()) -> bool: if os.environ.get(var, "").strip() == uuid: return True try: - out = subprocess.run(["ps", "-axo", "pid=,ppid=,command="], + out = subprocess.run(["ps", "-eo", "pid=,ppid=,args="], capture_output=True, text=True, timeout=10).stdout except Exception: out = "" diff --git a/.agents/skills/lib_py/delegation/heartbeat.py b/.agents/skills/lib_py/delegation/heartbeat.py index 11a8371..997ea1f 100644 --- a/.agents/skills/lib_py/delegation/heartbeat.py +++ b/.agents/skills/lib_py/delegation/heartbeat.py @@ -83,7 +83,7 @@ def _is_agent_cli(command: str) -> bool: def _process_table(): """{pid: (ppid, command)} from ps; empty when ps is unavailable.""" try: - out = subprocess.run(["ps", "-axo", "pid=,ppid=,command="], capture_output=True, text=True).stdout + out = subprocess.run(["ps", "-eo", "pid=,ppid=,args="], capture_output=True, text=True).stdout except OSError: return {} table = {} diff --git a/.agents/skills/lib_py/loop/eager.py b/.agents/skills/lib_py/loop/eager.py index 4fb4b34..d746479 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.6.0" +FRAMEWORK_VERSION = "4.7.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 94e0b28..406eabc 100644 --- a/.agents/skills/lib_py/loop/runner.py +++ b/.agents/skills/lib_py/loop/runner.py @@ -13,18 +13,24 @@ import glob import json import os import re +import secrets import shlex import shutil import signal import sqlite3 import subprocess import sys +import tempfile import time from pathlib import Path from typing import Any, Dict, List, Optional, Set, Tuple from lib_py.loop import checkpoint, eager, parser +UNAVAILABLE_MARKER = "!!! CHANGE SET UNAVAILABLE !!!" # diff_collect.sh MAM_DIFF_UNAVAILABLE_MARKER +SELF_REVIEW_PROMPT = ("์ž‘์—… ์™„๋ฃŒ ์ƒํƒœ์— ๋Œ€ํ•ด ์Šค์Šค๋กœ ๊ฒ€์ฆ(Self-Review)ํ•˜์—ฌ ๊ฒฐํ•จ์ด ์—†์Œ์„ ํ™•์ธํ•˜๊ณ  ์ข…๊ฒฐํ•ด์ฃผ์„ธ์š”. " + "๋ฆฌ๋ทฐ ๋ฆฌํฌํŠธ ๋งˆ์ง€๋ง‰์— ๋‹จ๋… ํ–‰์œผ๋กœ ๋ฐ˜๋“œ์‹œ '[VERDICT: PASS]' ํ˜น์€ '[VERDICT: NOT PASS]' ํƒœ๊ทธ๋ฅผ ๋ช…์‹œํ•ด์ฃผ์„ธ์š”.") + # ANSI colors matching run_loop.sh _COLOR_INFO = "\033[1;34m[*]\033[0m" _COLOR_SUCCESS = "\033[1;32m[โœ“]\033[0m" @@ -73,6 +79,14 @@ class LoopRunner: self.base_sha = "" self.base_source = "head" self.review_only = False + # Issue #21 item 5: review identity (active with --worktree/--base) + self.loop_id = "loop-" + time.strftime("%Y%m%dT%H%M%SZ", time.gmtime()) + "-" + secrets.token_hex(3) + self.review_isolation = "worktree" + self.remote_review = "refuse" + self._remote_recipients: Set[str] = set() + self._review_paths: List[str] = [] + self._rounds: List[Dict[str, Any]] = [] + self._accepted_round: Optional[Dict[str, Any]] = None self.created_jobs: List[str] = [] self.failed_submit_jobs: Set[str] = set() self.current_plan = "" @@ -111,6 +125,8 @@ class LoopRunner: " --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)", + " --review-isolation Review tree with --worktree/--base: worktree (default) or env", + " --remote-review Remote reviewers with --worktree/--base: refuse (default) or diff-only", ] print("\n".join(lines)) @@ -195,6 +211,19 @@ class LoopRunner: elif arg == "--review-only": self.review_only = True i += 1 + elif arg in ("--review-isolation", "--remote-review"): + if i + 1 >= len(args): + self.print_usage() + sys.exit(1) + allowed = ("worktree", "env") if arg == "--review-isolation" else ("refuse", "diff-only") + if args[i + 1] not in allowed: + print(f"ERROR: {arg} must be one of: {', '.join(allowed)}.", file=sys.stderr) + sys.exit(1) + if arg == "--review-isolation": + self.review_isolation = args[i + 1] + else: + self.remote_review = args[i + 1] + i += 2 else: print(f"Unknown option: {arg}") self.print_usage() @@ -234,6 +263,8 @@ class LoopRunner: if self.base_ref is not None: self.base_sha = self._resolve_base(self.base_ref) self.base_source = "cli" + if not self._context_active() and any(a in self.raw_argv for a in ("--review-isolation", "--remote-review")): + log_warn("--review-isolation/--remote-review only apply with --worktree or --base; ignored.") @staticmethod def _git(cwd: Path, *args: str) -> subprocess.CompletedProcess: @@ -490,22 +521,34 @@ class LoopRunner: return matches[0] return None - def collect_changes_diff(self) -> str: - """Collect git diff since base commit.""" + def collect_changes_diff(self, head_commit: Optional[str] = None) -> str: + """Collect git diff since base commit - of the live source tree, or, + with head_commit (review identity, B13), of base..head_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 {shlex.quote(str(diff_script))} && " - f"mam_collect_changes_diff {shlex.quote(src)} {shlex.quote(self.base_commit)}") + f"mam_collect_changes_diff {shlex.quote(src)} {shlex.quote(self.base_commit)}" + + (f" {shlex.quote(head_commit)}" if head_commit else "")) res = subprocess.run(["bash", "-c", cmd], capture_output=True, text=True, cwd=src) if res.returncode == 0: return res.stdout + if head_commit: + # the snapshot's evidence is not replaced by anything else (B13): + # the collector's UNAVAILABLE output is passed on as it is + return res.stdout if UNAVAILABLE_MARKER in res.stdout else ( + f"{UNAVAILABLE_MARKER}\nreason: diff collector failed (rc {res.returncode})\n") # Fallback to direct git diff try: - cmd = ["git", "diff", self.base_commit] if self.base_commit else ["git", "diff", "HEAD"] + if head_commit: + cmd = ["git", "diff", self.base_commit, head_commit] + else: + cmd = ["git", "diff", self.base_commit] if self.base_commit else ["git", "diff", "HEAD"] res = subprocess.run(cmd, capture_output=True, text=True, cwd=src) + if head_commit and res.returncode != 0: + return f"{UNAVAILABLE_MARKER}\nreason: git diff failed (rc {res.returncode})\n" return res.stdout except Exception: return "" @@ -518,9 +561,11 @@ class LoopRunner: def _context_active(self) -> bool: return self.worktree_given or self.base_ref is not None - def _worktree_context(self) -> str: + def _worktree_context(self, remote: bool = False, purpose: str = "work") -> str: """The source-root block put in front of every delegated prompt (D6); - empty without --worktree/--base, so prompts stay byte-identical.""" + empty without --worktree/--base, so prompts stay byte-identical. + purpose: 'work' (creator/planner), 'review' (a verdict job, whose + target is the [๋ฆฌ๋ทฐ identity] path) or 'adjudication' (a re-hearing).""" if not self._context_active(): return "" src = self.worktree_root @@ -533,19 +578,38 @@ class LoopRunner: 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") + st = self._git(src, "status", "--porcelain", "--untracked-files=all") dirty = "์•Œ ์ˆ˜ ์—†์Œ" if st.returncode != 0 else ("์žˆ์Œ" if st.stdout.strip() else "์—†์Œ") + # a remote reviewer (diff-only) cannot reach this host's paths (B6) + src_line = ("- ์†Œ์Šค ๊ฒฝ๋กœ: (์˜ค์ผ€์ŠคํŠธ๋ ˆ์ดํ„ฐ ํ˜ธ์ŠคํŠธ ์ „์šฉ โ€” ์›๊ฒฉ ๋ฆฌ๋ทฐ์–ด๋Š” ์ ‘๊ทผํ•  ์ˆ˜ ์—†์Œ)\n" if remote + else f"- ์†Œ์Šค ๊ฒฝ๋กœ: {src}\n") + if purpose == "review" and remote: + tail = "์ด ์žก์˜ ๊ฒ€์ฆ ๋Œ€์ƒ์€ ์•„๋ž˜ SHA ์™€ diff ๋‹ค.\n\n" + elif purpose == "review" and self.review_isolation == "worktree": + # one verification target per prompt (loop B review B-2) + tail = ("๋ฆฌ๋ทฐ ๊ฒ€์ฆ์€ ์•„๋ž˜ [๋ฆฌ๋ทฐ identity] ์˜ ๊ฒ€์ฆ ๊ฒฝ๋กœ์—์„œ๋งŒ ํ•œ๋‹ค. ์†Œ์Šค ๊ฒฝ๋กœ๋Š” creator ์˜ ์ž‘์—… ํŠธ๋ฆฌ์ด๋ฉฐ " + "๋ฆฌ๋ทฐ์–ด๋Š” ๊ฑฐ๊ธฐ์„œ ์‹คํ–‰ยท์ˆ˜์ •ํ•˜์ง€ ์•Š๋Š”๋‹ค. ๋ธŒ๋ฆฌํ”„์™€ ๋ฆฌํฌํŠธ๋Š” ๋ธŒ๋ฆฌํ”„์— ์ ํžŒ ์ ˆ๋Œ€๊ฒฝ๋กœ๋ฅผ ๊ทธ๋Œ€๋กœ ์“ด๋‹ค.\n\n") + elif purpose == "review": + tail = ("๋ฆฌ๋ทฐ ๊ฒ€์ฆ์€ ์•„๋ž˜ [๋ฆฌ๋ทฐ identity] ์˜ ๊ฒ€์ฆ ๊ฒฝ๋กœ(= ์œ„ ์†Œ์Šค ๊ฒฝ๋กœ)์—์„œ ํ•˜๋ฉฐ, ๊ฑฐ๊ธฐ์„œ ์ˆ˜์ •ยท์ปค๋ฐ‹ํ•˜์ง€ ์•Š๋Š”๋‹ค. " + "๋ธŒ๋ฆฌํ”„์™€ ๋ฆฌํฌํŠธ๋Š” ๋ธŒ๋ฆฌํ”„์— ์ ํžŒ ์ ˆ๋Œ€๊ฒฝ๋กœ๋ฅผ ๊ทธ๋Œ€๋กœ ์“ด๋‹ค.\n\n") + elif purpose == "adjudication" and remote: + tail = "์ด ์žก์€ ์•„๋ž˜ ๋ฐ˜๋ก ์˜ ์žฌ์‹ฌ์ด๋‹ค. ํŒ์ • ๊ทผ๊ฑฐ๋Š” ์ง์ „ ๋ฆฌ๋ทฐ์—์„œ ๋ฐ›์€ SHAยทdiff ์™€ ์•„๋ž˜ ๋ฐ˜๋ก  ๋ณธ๋ฌธ์ด๋‹ค.\n\n" + elif remote: + tail = "์ด ์žก์˜ ๊ฒ€์ฆ ๋Œ€์ƒ์€ ์•„๋ž˜ SHA ์™€ diff ๋‹ค.\n\n" + else: + tail = "์ด ์žก์˜ ์ฝ”๋“œ ์ž‘์—…ยท๊ฒ€์ฆ ๋Œ€์ƒ์€ ์œ„ ์†Œ์Šค ๊ฒฝ๋กœ๋‹ค. ๋ธŒ๋ฆฌํ”„์™€ ๋ฆฌํฌํŠธ๋Š” ๋ธŒ๋ฆฌํ”„์— ์ ํžŒ ์ ˆ๋Œ€๊ฒฝ๋กœ๋ฅผ ๊ทธ๋Œ€๋กœ ์“ด๋‹ค.\n\n" return ("[์›ŒํฌํŠธ๋ฆฌ ์ปจํ…์ŠคํŠธ]\n" - f"- ์†Œ์Šค ๊ฒฝ๋กœ: {src}\n" - f"- ๋ธŒ๋žœ์น˜: {branch}\n" + + src_line + + 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") + + tail) - def _dispatch(self, agent_session: str, agent_type: str, role: str, prompt: str) -> Optional[str]: + def _dispatch(self, agent_session: str, agent_type: str, role: str, prompt: str, + remote: bool = False, purpose: str = "work") -> 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) + return self.delegate_job(agent_session, agent_type, role, self._worktree_context(remote, purpose) + prompt) def _change_set_state(self) -> str: """'empty' | 'present' | 'unknown' for the source root against base, @@ -580,6 +644,204 @@ class LoopRunner: return False return True + # ------------------------------------------------------------------ + # Issue #21 item 5: review identity + # ------------------------------------------------------------------ + def _is_remote(self, name: str) -> bool: + return self.session_label(name).startswith("remote:") + + def _verdict_recipients(self, state: Dict[str, Any]) -> List[str]: + """Who receives a verdict job - the same rule Phase 3 applies; the + creator when there is no reviewer (self-review).""" + if self.all_reviewers: + names = self.resolve_all_reviewers(state) + elif self.reviewer_list: + names = [r.strip() for r in self.reviewer_list.split(",") if r.strip()] + else: + names = [] + return names or [self.creator] + + def _check_remote_recipients(self, state: Dict[str, Any]) -> bool: + """B14: a verdict recipient must be able to reach the evidence recorded + for it. Remote reviewers cannot reach this host's review tree: refused + by default, admitted with the snapshot diff as their only evidence + under --remote-review diff-only. Checked before any delegation.""" + remote = [n for n in self._verdict_recipients(state) if self._is_remote(n)] + self._remote_recipients = set(remote) + if remote and self.remote_review != "diff-only": + log_error(f"identity review needs the review tree on this host; remote reviewer(s) {' '.join(remote)} " + "cannot verify it. Use local reviewers, or pass --remote-review diff-only") + return False + if remote: + log_warn(f"remote reviewer(s) {' '.join(remote)} admitted with the snapshot diff as their only " + "evidence (--remote-review diff-only)") + return True + + def _worktree_tree(self) -> Optional[str]: + """The source worktree's state as a tree, through a temporary index + (the real index, status and refs are untouched); None on failure. + A dirty submodule is a failure: the tree records only its gitlink, so + its uncommitted content would be neither reviewed nor pinned.""" + dirty_subs = self._dirty_submodules() + if dirty_subs is None: + log_error(f"review snapshot: cannot check the submodules of {self.worktree_root}") + return None + if dirty_subs: + log_error(f"review snapshot: submodule(s) with uncommitted or untracked content: {' '.join(dirty_subs)}; " + "the snapshot records only their commit - commit or clean them first") + return None + fd, idx = tempfile.mkstemp(prefix="mam-review-index-") + os.close(fd) + os.unlink(idx) + env = dict(os.environ, GIT_INDEX_FILE=idx) + try: + for step in (("read-tree", "HEAD"), ("add", "-A"), ("write-tree",)): + r = subprocess.run(["git", "-C", str(self.worktree_root), *step], + capture_output=True, text=True, env=env) + if r.returncode != 0: + log_error(f"review snapshot: git {step[0]} failed in {self.worktree_root}: {r.stderr.strip()}") + return None + return r.stdout.strip() or None + finally: + for p in (idx, idx + ".lock"): + try: + os.unlink(p) + except OSError: + pass + + def _dirty_submodules(self) -> Optional[List[str]]: + """Checked-out submodules (recursively) with uncommitted or untracked + content; None when the check itself fails. Each submodule is asked + directly with --ignore-submodules=none and --untracked-files=all, so no + submodule.*.ignore, diff.ignoreSubmodules or status.showUntrackedFiles + setting can hide it.""" + script = ('s=$(git status --porcelain --untracked-files=all --ignore-submodules=none) || exit 1; ' + '[ -z "$s" ] || printf "%s\\n" "$displaypath"') + r = self._git(self.worktree_root, "submodule", "foreach", "--recursive", "--quiet", script) + if r.returncode != 0: + return None + return [line for line in r.stdout.splitlines() if line.strip()] + + def _gitlinks(self, treeish: str) -> Optional[Set[str]]: + r = self._git(self.worktree_root, "ls-tree", "-r", "-z", treeish) + if r.returncode != 0: + return None + links = set() + for ent in r.stdout.split("\0"): + meta, _, path = ent.partition("\t") + if meta.split(" ")[0] == "160000": + links.add(path) + return links + + def _identity_now(self) -> Tuple[str, str]: + return (self._source_head(), self._worktree_tree() or "") + + def _snapshot(self, n: int) -> Optional[Dict[str, Any]]: + """B3: pin the source state of review round n. Uncommitted and + untracked (not ignored) files go into a ref-less snapshot commit on + top of HEAD. An untracked nested repository cannot be pinned (only a + gitlink would be) and is refused.""" + head = self._source_head() + tree = self._worktree_tree() if head else None + if not head or not tree: + log_error(f"cannot snapshot {self.worktree_root}; refusing to request reviews") + return None + new_links = self._gitlinks(tree) + old_links = self._gitlinks(head) + if new_links is None or old_links is None: + log_error("cannot list the snapshot's entries; refusing to request reviews") + return None + untracked_repos = sorted(new_links - old_links) + if untracked_repos: + log_error(f"untracked nested git repository {' '.join(untracked_repos)} cannot be pinned into the review " + "snapshot; commit it, ignore it or move it out") + return None + dirty = tree != self._git(self.worktree_root, "rev-parse", "HEAD^{tree}").stdout.strip() + commit = head + if dirty: + ident = {"GIT_AUTHOR_NAME": "mam-loop", "GIT_AUTHOR_EMAIL": "mam-loop@localhost", + "GIT_COMMITTER_NAME": "mam-loop", "GIT_COMMITTER_EMAIL": "mam-loop@localhost"} + c = subprocess.run(["git", "-C", str(self.worktree_root), "commit-tree", tree, "-p", head, + "-m", f"mam review snapshot {self.loop_id} #{n}"], + capture_output=True, text=True, env=dict(os.environ, **ident)) + if c.returncode != 0 or not c.stdout.strip(): + log_error(f"cannot commit the review snapshot: {c.stderr.strip()}") + return None + commit = c.stdout.strip() + return {"head": head, "tree": tree, "dirty": dirty, "review_commit": commit} + + def _open_review_tree(self, snap: Dict[str, Any], n: int) -> Optional[str]: + path = Path(tempfile.gettempdir()) / "mam-review" / self.loop_id / str(n) + path.parent.mkdir(parents=True, exist_ok=True) + r = self._git(self.worktree_root, "worktree", "add", "--detach", str(path), snap["review_commit"]) + if r.returncode != 0: + log_error(f"cannot open the review worktree {path}: {r.stderr.strip()}; refusing to request reviews " + "(the isolation is not lowered silently)") + return None + self._review_paths.append(str(path)) + return str(path) + + def _close_review_tree(self, path: Optional[str]) -> None: + if not path or path not in self._review_paths: + return + r = self._git(self.worktree_root, "worktree", "remove", "--force", path) + if os.path.isdir(path): + shutil.rmtree(path, ignore_errors=True) + if r.returncode != 0: + # only when remove failed: prune drops this entry (its directory is + # gone now) - and any other stale one, which is why it is not routine + self._git(self.worktree_root, "worktree", "prune") + self._review_paths.remove(path) + try: + os.rmdir(os.path.dirname(path)) # TMPDIR/mam-review/, once empty + except OSError: + pass + + def _close_all_review_trees(self) -> None: + for p in list(self._review_paths): + self._close_review_tree(p) + + def _evidence(self, name: str) -> Dict[str, str]: + if name in self._remote_recipients: + return {"transport": "remote", "evidence": "diff-only", "isolation": "none (remote)"} + if self.review_isolation == "worktree": + return {"transport": "herdr", "evidence": "review-tree", "isolation": "worktree"} + return {"transport": "herdr", "evidence": "source-tree", "isolation": "env"} + + def _identity_block(self, snap: Dict[str, Any], path: Optional[str], remote: bool) -> str: + lines = ["[๋ฆฌ๋ทฐ identity]", + f"- loop_id: {self.loop_id}", + f"- base: {self.base_commit}", + f"- head: {snap['head']}", + f"- tree: {snap['tree']}"] + if snap["dirty"]: + lines.append(f"- ๋ฏธ์ปค๋ฐ‹ ๋ณ€๊ฒฝ ํฌํ•จ ์Šค๋ƒ…์ƒท ์ปค๋ฐ‹: {snap['review_commit']}") + if remote: + lines.append("๋‹น์‹ ์˜ ํ˜ธ์ŠคํŠธ์—๋Š” ์ด ํŠธ๋ฆฌ๊ฐ€ ์—†๋‹ค. ์ž๊ธฐ ์ฒดํฌ์•„์›ƒ์ด๋‚˜ ๋‹ค๋ฅธ ์ปค๋ฐ‹์—์„œ ์‹คํ–‰ํ•œ ๊ฒฐ๊ณผ๋ฅผ ํŒ์ • ๊ทผ๊ฑฐ๋กœ " + "์‚ผ์ง€ ๋ง ๊ฒƒ. ๊ทผ๊ฑฐ๋Š” ์•„๋ž˜ diff ๋ฟ์ด๋ฉฐ, ๋ฆฌํฌํŠธ์— 'diff ๋งŒ์œผ๋กœ ๊ฒ€ํ† ' ๋ฅผ ๋ฐํž ๊ฒƒ.") + else: + py = self.workspace_root / ".venv" / "bin" / "python" + py_cmd = str(py) if py.exists() else "python3" + lines.append(f"- ๊ฒ€์ฆ ๊ฒฝ๋กœ: {path}") + lines.append("์ด ๊ฒฝ๋กœ์—์„œ๋งŒ ๊ฒ€์ฆํ•˜๊ณ  ์ปค๋ฐ‹ยท๋ธŒ๋žœ์น˜ ๋ณ€๊ฒฝ์€ ํ•˜์ง€ ๋ง ๊ฒƒ. ํ…Œ์ŠคํŠธ๋Š” " + f"`PYTHONDONTWRITEBYTECODE=1 {py_cmd} -m pytest -p no:cacheprovider " + "--basetemp=<๊ฒ€์ฆ ๊ฒฝ๋กœ ๋ฐ–์˜ ์ž„์‹œ ๋””๋ ‰ํ„ฐ๋ฆฌ>` ๋กœ ์‹คํ–‰ํ•œ๋‹ค.") + return "\n".join(lines) + "\n\n" + + def _record_round(self, n: int, snap: Dict[str, Any], path: Optional[str], jobs: Dict[str, str], + verdicts: Dict[str, str], stale: bool) -> None: + self._rounds.append({ + "iteration": n, "base_sha": self.base_commit, "head_sha": snap["head"], "tree_sha": snap["tree"], + "review_commit": snap["review_commit"], "isolation": self.review_isolation, + "review_path": path or "", "jobs": jobs, "verdicts": verdicts, + "reviewers": {r: self._evidence(r) for r in jobs}, "stale": stale, + }) + self._record_checkpoint(iteration=n) + + def _round_is_stale(self, snap: Dict[str, Any]) -> bool: + """B7: the source changed while the round was being reviewed.""" + return self._identity_now() != (snap["head"], snap["tree"]) + def _record_checkpoint(self, iteration: int = 1, review_status: Optional[Dict[str, str]] = None) -> None: data = { "iteration": iteration, @@ -592,12 +854,16 @@ class LoopRunner: "review_only": self.review_only, "worktree_root": str(self.worktree_root), "head_commit": self._source_head() if self._context_active() else "", + "loop_id": self.loop_id, "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), } + if self._context_active(): + data["review_rounds"] = self._rounds checkpoint.save_checkpoint(self.checkpoint_file, data) def release_guard(self) -> None: """Release MAM_LOOP_MARKER and clean up freeze dir if owned.""" + self._close_all_review_trees() marker = os.environ.get("MAM_LOOP_MARKER") or str(self.mam_dir / "loop-guard-active") if os.path.isfile(marker): try: @@ -614,6 +880,12 @@ class LoopRunner: shutil.rmtree(freeze_dir, ignore_errors=True) def run(self) -> int: + try: + return self._run_body() + finally: + self._close_all_review_trees() # a return or exit mid-round leaves no review tree + + def _run_body(self) -> int: self.parse_args() atexit.register(self.release_guard) @@ -630,6 +902,7 @@ class LoopRunner: signal.signal(signal.SIGTERM, _handle_signal) log_info("Initializing multi-agent-mux-loop controller...") + log_info(f"Loop id: {self.loop_id}") log_info(f"Target Agent: {self.creator}") log_info(f"Task Goal: {self.task}") @@ -672,6 +945,11 @@ class LoopRunner: log_info(f"Resolved Planner session: {planner_session}") + # Issue #21 item 5 (B14): every verdict recipient must reach the + # evidence recorded for it - checked before anything is delegated. + if self._context_active() and not self._check_remote_recipients(state): + return 1 + # ======================================================================= # PHASE 1: PLANNING & DISCUSSIONS # ======================================================================= @@ -780,6 +1058,14 @@ class LoopRunner: rebut_total_budget = self.max_rebut * self.max_loop final_passed_reviewers: Dict[str, Tuple[str, Path]] = {} + identity = self._context_active() + # Identity mode: a creator self-review is a verdict like any other, so it + # takes the reviewer path - snapshot diff, evidence check, B10 re-check + # and sidecar (loop B review B-R2/B-R3). A NOT PASS re-reviews, as before. + self_review = identity and not reviewers + if self_review: + log_warn("No reviewers specified. Conducting Creator Self-Review...") + reviewers = [self.creator] for loop_count in range(1, self.max_loop + 1): log_info(f"Review Loop Iteration {loop_count}/{self.max_loop}...") @@ -788,10 +1074,25 @@ class LoopRunner: if not self._change_set_allows_review(): return 1 + # Issue #21 item 5: pin this round (B3), open its review tree (B4) + snap: Optional[Dict[str, Any]] = None + review_path: Optional[str] = None + if identity: + final_passed_reviewers.clear() # B8: only this round's approvals can be promoted + snap = self._snapshot(loop_count) + if snap is None: + return 1 + if self.review_isolation == "worktree": + review_path = self._open_review_tree(snap, loop_count) + if review_path is None: + return 1 + else: + review_path = str(self.worktree_root) + 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_prompt = SELF_REVIEW_PROMPT sr_jid = self._dispatch(self.creator, creator_type, "Reviewer", sr_prompt) if not sr_jid: log_error("Failed to register Self-Review job.") @@ -809,20 +1110,36 @@ class LoopRunner: continue log_info(f"Active reviewers: {' '.join(reviewers)}") - changes_diff = self.collect_changes_diff() + # B13: in identity mode the reviewers' diff is the snapshot's, so the + # evidence they get is exactly what the verdict is bound to. + changes_diff = self.collect_changes_diff(snap["review_commit"] if identity else None) + diff_only = [r for r in reviewers if r in self._remote_recipients] + if identity and diff_only and ("!!! DIFF TRUNCATED !!!" in changes_diff + or "!!! CHANGE SET UNAVAILABLE !!!" in changes_diff): + log_error(f"the snapshot diff is truncated/unavailable; diff-only remote reviewer(s) " + f"{' '.join(diff_only)} would have no evidence") + return 1 review_jobs: List[Tuple[str, str]] = [] # (rev, job_id) for rev in reviewers: 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._dispatch(rev, r_type, "Reviewer", r_prompt) + if self_review: + r_prompt = f"{SELF_REVIEW_PROMPT} ๋ณ€๊ฒฝ๋ถ„:\n{changes_diff}" + else: + r_prompt = f"๋‹ค์Œ ๊ตฌํ˜„ ์‚ฌํ•ญ(์ž‘์—… ๋ชฉํ‘œ: {self.task}) ๋ฐ ๋ˆ„์  ๋ณ€๊ฒฝ๋ถ„(git diff)์— ๋Œ€ํ•ด ๋ฆฐํŠธ, ๋™์ž‘์„ฑ, ์œ ์‹ค ๋“ฑ์˜ ๊ด€์ ์—์„œ ๊ต์ฐจ ์ฝ”๋“œ ๋ฆฌ๋ทฐ๋ฅผ ์ˆ˜ํ–‰ํ•ด์ฃผ์„ธ์š”. ํ™•์ธ ํ›„ ์ตœ์ข… Verdict๋กœ '[VERDICT: PASS]' ํ˜น์€ '[VERDICT: NOT PASS]' ํƒœ๊ทธ๋ฅผ ๋ฆฌ๋ทฐ ๋ฆฌํฌํŠธ ๋งˆ์ง€๋ง‰์— ๋‹จ๋… ํ–‰์œผ๋กœ ๋ช…์‹œ์ ์œผ๋กœ ์ž‘์„ฑํ•ด์ฃผ์„ธ์š”. ๋งŒ์•ฝ ๋‹จ์ˆœ ๋ฒ„๊ทธ ์ˆ˜์ •์œผ๋กœ๋Š” ๋ถ€์กฑํ•˜๊ณ  ์„ค๊ณ„ ๋ณ€๊ฒฝ/์žฌ์ž‘์—… ์ˆ˜์ค€์˜ ์žฌ๊ณ„ํš์ด ํ•„์š”ํ•˜๋‹ค๊ณ  ํŒ๋‹จ๋˜๋ฉด, ๋ฆฌํฌํŠธ ์•„๋ฌด ๊ณณ์—๋‚˜ ๋‹จ๋… ํ–‰์œผ๋กœ '[ESCALATE: PLANNER]' ํƒœ๊ทธ๋„ ํ•จ๊ป˜ ๋‚จ๊ฒจ์ฃผ์„ธ์š”. ๋ณ€๊ฒฝ๋ถ„:\n{changes_diff}" + r_remote = rev in self._remote_recipients + if identity: + r_prompt = self._identity_block(snap, review_path, r_remote) + r_prompt + r_jid = self._dispatch(rev, r_type, "Reviewer", r_prompt, remote=r_remote, + purpose="review" if identity else "work") if not r_jid: log_error(f"Failed to register review job for '{rev}'.") return 1 review_jobs.append((rev, r_jid)) all_passed = True + round_verdicts: Dict[str, str] = {} fail_revs: List[str] = [] fail_reports: List[Path] = [] feedback_parts: List[str] = [] @@ -830,12 +1147,14 @@ class LoopRunner: for rev, jid in review_jobs: if not self.wait_for_job(jid): log_warn(f"Reviewer '{rev}' job crashed.") + round_verdicts[rev] = "crashed" all_passed = False continue r_file = self.find_report(jid) if not r_file or not r_file.exists(): log_warn(f"Reviewer '{rev}' report not found. Counting as NOT PASS.") + round_verdicts[rev] = "missing" all_passed = False continue @@ -844,18 +1163,44 @@ class LoopRunner: if verdict != "PASS": log_warn(f"Reviewer '{rev}': NOT PASS") + round_verdicts[rev] = "NOT PASS" all_passed = False fail_revs.append(rev) fail_reports.append(r_file) feedback_parts.append(f"\n--- Reviewer ({rev}) Feedback ---\n{content}") else: log_success(f"Reviewer '{rev}': PASS") + round_verdicts[rev] = "PASS" final_passed_reviewers[rev] = (jid, r_file) + if identity: + # B7: judge the round against the source as it is now + if self.review_isolation == "worktree": + self._close_review_tree(review_path) + stale = self._round_is_stale(snap) + self._record_round(loop_count, snap, review_path, dict(review_jobs), round_verdicts, stale) + if stale: + final_passed_reviewers.clear() + log_warn(f"source changed during review (head {snap['head'][:12]}, tree {snap['tree'][:12]}); " + "approvals discarded, re-reviewing") + if loop_count == self.max_loop: + log_error("the source changed during review in the last iteration; no approval to accept") + return 1 + continue + if all_passed: + if identity: + self._accepted_round = self._rounds[-1] log_success("All reviewers issued [VERDICT: PASS]. Loop completed successfully.") break + if self_review: + log_warn("Self-Review NOT PASS.") + if loop_count == self.max_loop: + log_error("Reached max loop count. Self-review loop aborted with failures.") + return 1 + continue + if loop_count == self.max_loop: log_error(f"Reached max loop count ({self.max_loop}). Review loop aborted with failures.") return 1 @@ -910,7 +1255,8 @@ 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._dispatch(rt, rt_type, "Reviewer", adj_prompt) + adj_jid = self._dispatch(rt, rt_type, "Reviewer", adj_prompt, + remote=rt in self._remote_recipients, purpose="adjudication") 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) @@ -956,6 +1302,13 @@ class LoopRunner: tmp_plan.write_text(self.current_plan, encoding="utf-8") tmp_plan.replace(plan_dest_dir / f"plan-{self.plan_job_id}.md") + if final_passed_reviewers and identity and self._accepted_round: + # B10: an approval is promoted only for the source it approved + acc = self._accepted_round + if self._identity_now() != (acc["head_sha"], acc["tree_sha"]): + log_error("source changed after approval; not promoting") + return 1 + if final_passed_reviewers: log_info("Promoting final review reports to durable location...") for rev, (jid, rep_file) in final_passed_reviewers.items(): @@ -965,6 +1318,17 @@ class LoopRunner: tmp_dest = dest_dir / f"report-{jid}.md.tmp" shutil.copyfile(rep_file, tmp_dest) tmp_dest.replace(dest_file) + if identity and self._accepted_round: + acc = self._accepted_round + ev = self._evidence(rev) + sidecar = {"loop_id": self.loop_id, "base_sha": acc["base_sha"], "head_sha": acc["head_sha"], + "tree_sha": acc["tree_sha"], "review_commit": acc["review_commit"], + "reviewer": rev, "job_id": jid, "verdict": "PASS", **ev, + "verified_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())} + tmp_side = dest_dir / f"report-{jid}.identity.json.tmp" + tmp_side.write_text(json.dumps(sidecar, indent=2), encoding="utf-8") + tmp_side.replace(dest_dir / f"report-{jid}.identity.json") + log_info(f" {rev}: report-{jid}.md (evidence: {ev['evidence']})") # Cleanup if self.cleanup: diff --git a/.agents/skills/lib_py/worktree.py b/.agents/skills/lib_py/worktree.py new file mode 100644 index 0000000..8493dad --- /dev/null +++ b/.agents/skills/lib_py/worktree.py @@ -0,0 +1,239 @@ +"""Decisions behind the worktree helpers (Issue #21 phase 2, item 4). + +worktree_prepare.sh / worktree_remove.sh keep the orchestration in shell and +ask this module the questions that need parsing: is a path a linked worktree +of the control root (and is it locked), which session rows ran under it, which +of their jobs are still open, and whether its submodules hold anything a +forced removal would destroy. Standard library only. + +CLI (JSON on stdout, rc 0; the session state comes from MAM_STATE_JSON): + python -m lib_py.worktree is-linked
+ python -m lib_py.worktree is-locked
+ python -m lib_py.worktree rows + python -m lib_py.worktree open-jobs [--exclude id,id] + python -m lib_py.worktree submodules +""" + +from __future__ import annotations + +import glob +import json +import os +import subprocess +import sys +from typing import Dict, Iterable, List, Optional + +OPEN_STATUSES = ("pending", "running") +# The commits a forced removal would lose (review R6): reachable from HEAD or +# from any local ref - branches, tags, notes, ... - except refs/stash (the +# stash check covers it, reflog included), and from no remote-tracking ref. +UNPUSHED_REVS = ("HEAD", "--exclude=refs/stash", "--all", "--not", "--remotes") + + +def _git(cwd: str, *args: str) -> subprocess.CompletedProcess: + return subprocess.run(["git", "-C", str(cwd), *args], capture_output=True, text=True) + + +def _real(p: str) -> str: + return os.path.realpath(p) + + +def worktree_blocks(main: str) -> List[Dict[str, object]]: + """`git worktree list --porcelain`, one dict per worktree.""" + res = _git(main, "worktree", "list", "--porcelain") + blocks: List[Dict[str, object]] = [] + cur: Dict[str, object] = {} + for line in res.stdout.splitlines(): + if not line.strip(): + if cur: + blocks.append(cur) + cur = {} + continue + key, _, val = line.partition(" ") + cur[key] = val if val else True + if cur: + blocks.append(cur) + return blocks + + +def _block(main: str, path: str) -> Optional[Dict[str, object]]: + rp, rm = _real(path), _real(main) + if rp == rm: + return None + for b in worktree_blocks(main): + w = b.get("worktree") + if isinstance(w, str) and _real(w) == rp: + return b + return None + + +def is_linked_worktree(main: str, path: str) -> bool: + return _block(main, path) is not None + + +def is_locked(main: str, path: str) -> bool: + b = _block(main, path) + return bool(b and "locked" in b) + + +def rows_under(state: dict, path: str) -> List[dict]: + """Session rows whose pane.cwd is `path` or below it.""" + rp = _real(path).rstrip(os.sep) + out = [] + for s in state.get("herdr_sessions") or []: + cwd = (s.get("pane") or {}).get("cwd") or "" + if not cwd: + continue + rc = _real(cwd) + if rc == rp or rc.startswith(rp + os.sep): + out.append(s) + return out + + +def registry_dirs(rows: Iterable[dict], env=None) -> List[str]: + env = os.environ if env is None else env + cands = [env.get("MAM_JOBS_DIR") or ""] + if env.get("WORKSPACE_ROOT"): + cands.append(os.path.join(env["WORKSPACE_ROOT"], ".mam", "jobs")) + cands += [((s.get("control_plane") or {}).get("mam_jobs_dir") or "") for s in rows] + seen, out = set(), [] + for c in cands: + if c and os.path.isdir(c) and _real(c) not in seen: + seen.add(_real(c)) + out.append(c) + return out + + +def open_jobs(rows: List[dict], dirs: Iterable[str], exclude_ids: Iterable[str] = ()) -> List[dict]: + """Pending/running jobs addressed to one of the rows (herdr:).""" + names = {f"herdr:{s.get('name')}" for s in rows if s.get("name")} + skip = {j for j in exclude_ids if j} + out, seen = [], set() + for d in dirs: + for p in sorted(glob.glob(os.path.join(d, "*.json"))): + try: + with open(p, encoding="utf-8") as fh: + rec = json.load(fh) + except (OSError, ValueError): + continue + jid = rec.get("job_id") if isinstance(rec, dict) else None + if not jid or jid in skip or jid in seen: + continue + if rec.get("agent_session") in names and rec.get("status") in OPEN_STATUSES: + seen.add(jid) + out.append({"job_id": jid, "agent_session": rec.get("agent_session"), + "status": rec.get("status"), "registry_dir": d}) + return out + + +def _module_gitdirs(root: str): + """Repositories under a worktree's modules/ (nested ones included), and + directories that look like partial repositories (a repository marker but + not a checkable repository).""" + repos: List[str] = [] + partial: List[str] = [] + if not os.path.isdir(root): + return repos, partial + for dirpath, dirnames, filenames in os.walk(root): + if dirpath == root: + continue + if "HEAD" in filenames and "objects" in dirnames: + repos.append(dirpath) + dirnames[:] = [d for d in dirnames if d == "modules"] # nested submodules only + elif "HEAD" in filenames or "objects" in dirnames or "refs" in dirnames: + partial.append(dirpath) + dirnames[:] = [] + return repos, partial + + +def submodule_findings(wt: str) -> Dict[str, object]: + """trace: the worktree git dir holds modules/ (git then refuses a plain + `worktree remove`, even after a failed init); dirty / unpushed / stashes: + what a forced removal would destroy - it deletes the worktree's own + submodule repositories. The unpushed check covers HEAD and every local + ref except refs/stash (UNPUSHED_REVS): a submodule is on a detached HEAD + after `submodule update`, so its commits may be on no branch, and a commit + kept only by a tag (review R6) or a note is lost with the repository just + the same. Stashes live in refs/stash and its reflog; the stash check owns + them. --untracked-files=all: `status.showUntrackedFiles=no` in a + submodule would otherwise hide its untracked files from the check (review + R1), and display settings must not decide what a forced removal may + delete.""" + gd = _git(wt, "rev-parse", "--absolute-git-dir") + if gd.returncode != 0 or not gd.stdout.strip(): + return {"trace": False, "dirty": "", "unpushed": "", "stashes": "", + "error": gd.stderr.strip() or "rev-parse failed"} + res: Dict[str, object] = {"trace": os.path.isdir(os.path.join(gd.stdout.strip(), "modules")), + "dirty": "", "unpushed": "", "stashes": "", "error": ""} + if not res["trace"]: + return res + checks = (("dirty", "git status --porcelain --untracked-files=all"), + ("unpushed", "git log --oneline " + " ".join(UNPUSHED_REVS)), + ("stashes", "git stash list")) + errors = [] + for key, cmd in checks: + r = _git(wt, "submodule", "foreach", "--recursive", "--quiet", cmd) + if r.returncode != 0: + errors.append(r.stderr.strip() or f"{key} check failed (rc {r.returncode})") + else: + res[key] = r.stdout.strip() + # Every repository under modules/, whether its checkout is initialised or + # not (review R5): `submodule foreach` visits initialised checkouts only, + # but a deinit'd submodule keeps its repository - stash included - here, + # and the forced removal deletes it. A repository that cannot be checked + # is an error, never assumed safe. + repos, partial = _module_gitdirs(os.path.join(gd.stdout.strip(), "modules")) + extra: Dict[str, List[str]] = {"unpushed": [], "stashes": []} + for d in repos: + git = ["git", "--git-dir", d, "--work-tree", d] + u = subprocess.run(git + ["log", "--oneline", *UNPUSHED_REVS], + capture_output=True, text=True) + if u.returncode != 0: + errors.append(f"{d}: cannot list its unpushed commits ({u.stderr.strip() or u.returncode})") + elif u.stdout.strip(): + extra["unpushed"] += [f"{d}: {line}" for line in u.stdout.strip().splitlines()] + s = subprocess.run(git + ["for-each-ref", "--format=%(refname)", "refs/stash"], capture_output=True, text=True) + if s.returncode != 0: + errors.append(f"{d}: cannot read refs/stash ({s.stderr.strip() or s.returncode})") + elif s.stdout.strip(): + g = subprocess.run(git + ["log", "-g", "--format=%gd %s", "refs/stash"], capture_output=True, text=True) + lines = g.stdout.strip().splitlines() if g.returncode == 0 and g.stdout.strip() else ["refs/stash"] + extra["stashes"] += [f"{d}: {line}" for line in lines] + errors += [f"{p}: looks like a partial repository and cannot be checked" for p in partial] + for key, lines in extra.items(): + if lines: + res[key] = "\n".join(x for x in [str(res[key]).strip(), *lines] if x) + res["error"] = "; ".join(errors) + return res + + +def _state() -> dict: + try: + return json.loads(os.environ.get("MAM_STATE_JSON") or "{}") or {} + except ValueError: + return {} + + +def main(argv=None) -> int: + argv = list(sys.argv[1:] if argv is None else argv) + cmd = argv[0] if argv else "" + if cmd == "is-linked" and len(argv) == 3: + print(json.dumps(is_linked_worktree(argv[1], argv[2]))) + elif cmd == "is-locked" and len(argv) == 3: + print(json.dumps(is_locked(argv[1], argv[2]))) + elif cmd == "rows" and len(argv) == 2: + print(json.dumps(rows_under(_state(), argv[1]))) + elif cmd == "open-jobs" and len(argv) in (2, 4) and (len(argv) == 2 or argv[2] == "--exclude"): + rows = rows_under(_state(), argv[1]) + exclude = argv[3].replace(",", " ").split() if len(argv) == 4 else [] + print(json.dumps(open_jobs(rows, registry_dirs(rows), exclude))) + elif cmd == "submodules" and len(argv) == 2: + print(json.dumps(submodule_findings(argv[1]))) + else: + print(__doc__, file=sys.stderr) + return 2 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.agents/skills/multi-agent-mux-create/SKILL.md b/.agents/skills/multi-agent-mux-create/SKILL.md index fc5628c..b63c35f 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.6.0 +version: 4.7.0 author: godopu license: MIT platforms: [linux, macos] @@ -233,6 +233,24 @@ bash .agents/skills/multi-agent-mux-create/scripts/create_session.sh \ The script handles the YAML append, pane capture, and the `last_visible_status` placeholder. +## Issue worktrees: `worktree_prepare.sh` (Issue #21 item 4) + +Run from the control root's skills tree (lib.sh's `WORKSPACE_ROOT` = the main checkout that owns `.mam`): + +```bash +bash .agents/skills/multi-agent-mux-create/scripts/worktree_prepare.sh \ + --path ../multi-agent-mux-wt/issue-42 --branch fix/issue-42 [--base ] [--submodules] \ + [--creator-agent claude [--session ] [--role creator] [--herdr-session ] [--herdr-workspace ] [--onboard]] [--dry-run] +``` + +- Creates the worktree next to the control root (a path **inside** it is refused), idempotently; creates the worktree's **own** `.venv` from the control root's `.venv/bin/python` and installs the delegate-job skill's `requirements.txt` (paho-mqtt, PyYAML - what `deploy/install.sh` installs) plus `requirements.txt` / `requirements-dev.txt` when present (needs network access to the package index; a failed install removes the half-made venv and exits 1 so a re-run starts over; never a link - a leftover link is refused with exit 1; no control-root interpreter is a warning and `VENV=skipped`); adds `/.venv` and `.pytest_cache/` to the shared `info/exclude` (a linked worktree's exclude file is `$GIT_COMMON_DIR/info/exclude`, so it applies to the main checkout too). +- **Submodules are opt-in** (`--submodules`). Initialisation writes `submodule..url` to the shared `.git/config`, and any attempt - even a failed one - leaves a `modules/` directory that makes a plain `git worktree remove` refuse. A failed `--submodules` undoes only what that attempt created - new submodule checkouts are deinitialised before their git dirs are deleted, so the worktree stays a usable checkout; submodules initialised earlier and pre-existing `submodule.*` values are kept - and exits 1; re-run without `--submodules` to finish. +- `--creator-agent` creates the creator through this skill with the control root's binding and `MAM_NO_SUBSCRIBER_DAEMON=1`; the row's `pane.cwd` is the worktree. +- Prints `BASE=`: record it **before** delegating the implementation, then review with `run_loop.sh --worktree --base "$BASE" --review-only`. +- A submodule path that is not initialised yet but already holds files refuses `--submodules` up front (exit 3, nothing changed): the clone would fail on it and a rollback could not tell those files from its own. +- Exit codes: 0 ok, 1 git / submodule / create failure, 2 usage or path rule, 3 refused to protect existing content (nothing changed). +- create itself: a control-plane binding that cannot be computed is refused (exit 2) for a `--workspace` other than the control root and warned about for the control root; the immediate reconcile and the watchdog run the control root's code. + ## Pitfalls - **Don't use `nohup`/`disown`/`setsid` for the agent itself** โ€” those background the agent outside herdr. The whole point of this skill is *the herdr session is the supervisor*. `nohup` is OK only for *launching the wrapper* (which itself creates the herdr session via `herdr new-session -d`). 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 de2e809..c8f8d81 100755 --- a/.agents/skills/multi-agent-mux-create/scripts/create_session.sh +++ b/.agents/skills/multi-agent-mux-create/scripts/create_session.sh @@ -208,7 +208,20 @@ fi # 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)" +# A binding that cannot be computed is not swallowed (Issue #21 phase 2, A12): +# for a worktree (--workspace != control root) that would silently put the +# session on the wrong control plane, so refuse; for the control root itself +# warn and record the row without control_plane. +cp_rc=0 +CP_JSON="$(mam_control_plane_json "$WORKSPACE")" || cp_rc=$? +if [ "$cp_rc" -ne 0 ] || [ -z "$CP_JSON" ]; then + if [ "$(mam_abs_workspace "$WORKSPACE")" != "$(mam_abs_workspace "$WORKSPACE_ROOT")" ]; then + echo "ERROR: cannot compute the control-plane binding (rc=$cp_rc) for --workspace '$WORKSPACE' (control root '$WORKSPACE_ROOT'); refusing to start a session that would use the wrong control plane" >&2 + exit 2 + fi + echo "WARNING: control-plane binding unavailable (rc=$cp_rc); the row is recorded without control_plane" >&2 + CP_JSON="" +fi CP_PREFIX="" if [ -n "$CP_JSON" ]; then cp_rc=0 @@ -563,8 +576,10 @@ Task: $SUBMIT_JOB_PROMPT" delegate_publish_event "$DELEGATE_JOB_ID" started "multi-agent-mux session created and instructions injected" "$DELEGATE_JOB_TOKEN" "$DELEGATE_JOB_ATTEMPT" # Trigger immediate priority reconcile cycle asynchronously - (bash "$WORKSPACE/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh" --once >/dev/null 2>&1 &) || true - WD_PID=$(start_watchdog "$DELEGATE_JOB_ID" "$WORKSPACE") + # Issue #21 phase 2 (A8): the control root's reconcile/watchdog, never the + # (unreviewed) copy in a worktree --workspace; identical paths by default. + (bash "$SKILL_DIR/multi-agent-mux-monitor/scripts/reconcile.sh" --once >/dev/null 2>&1 &) || true + WD_PID=$(start_watchdog "$DELEGATE_JOB_ID" "$WORKSPACE_ROOT") echo "watchdog PID: $WD_PID" fi # Clear cleanup trap as registration was fully successful diff --git a/.agents/skills/multi-agent-mux-create/scripts/worktree_prepare.sh b/.agents/skills/multi-agent-mux-create/scripts/worktree_prepare.sh new file mode 100755 index 0000000..fd16030 --- /dev/null +++ b/.agents/skills/multi-agent-mux-create/scripts/worktree_prepare.sh @@ -0,0 +1,255 @@ +#!/usr/bin/env bash +# worktree_prepare.sh โ€” prepare an issue worktree next to the control root +# (Issue #21 item 4). Run it from the control root's skills tree: the control +# root is lib.sh's WORKSPACE_ROOT, the main checkout that owns .mam. +# +# Usage: +# worktree_prepare.sh --path --branch [--base ] [--submodules] +# [--creator-agent [--session ] [--role ] +# [--herdr-session ] [--herdr-workspace ] [--onboard]] [--dry-run] +# +# Steps: git worktree (idempotent) -> the worktree's own .venv (control root +# interpreter + requirements*.txt) -> /.venv and .pytest_cache/ in the shared info/exclude -> submodules only with +# --submodules (a failed init is rolled back: it would otherwise leave a +# modules/ trace that makes a plain `git worktree remove` refuse) -> optional +# creator session registered in the control root's .mam -> summary with BASE=. +# +# Exit codes: 0 ok, 1 git / submodule / create failure, 2 usage or path rule, +# 3 refused to protect existing content (nothing changed). +set -euo pipefail + +_script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$(cd "$_script_dir/../.." && pwd)/lib.sh" + +usage() { + sed -n '2,15p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' +} + +WT=""; BRANCH=""; BASE_REF=""; SUBMODULES=0; DRY_RUN=0 +CREATOR_AGENT=""; SESSION=""; ROLE="creator"; HS=""; HW=""; ONBOARD=0 +while [ $# -gt 0 ]; do + case "$1" in + --path) WT="${2:-}"; shift 2 ;; + --branch) BRANCH="${2:-}"; shift 2 ;; + --base) BASE_REF="${2:-}"; shift 2 ;; + --submodules) SUBMODULES=1; shift ;; + --creator-agent) CREATOR_AGENT="${2:-}"; shift 2 ;; + --session) SESSION="${2:-}"; shift 2 ;; + --role) ROLE="${2:-}"; shift 2 ;; + --herdr-session) HS="${2:-}"; shift 2 ;; + --herdr-workspace) HW="${2:-}"; shift 2 ;; + --onboard) ONBOARD=1; shift ;; + --dry-run) DRY_RUN=1; shift ;; + -h|--help) usage; exit 0 ;; + *) echo "ERROR: unknown arg: $1" >&2; usage >&2; exit 2 ;; + esac +done +[ -n "$WT" ] && [ -n "$BRANCH" ] || { echo "ERROR: --path and --branch are required" >&2; usage >&2; exit 2; } + +MAIN="$(mam_abs_workspace "$WORKSPACE_ROOT")" +top="$(git -C "$MAIN" rev-parse --show-toplevel 2>/dev/null || true)" +if [ -z "$top" ] || [ "$(mam_abs_workspace "$top")" != "$MAIN" ]; then + echo "ERROR: the control root '$MAIN' (WORKSPACE_ROOT) is not the top of a git checkout" >&2 + exit 2 +fi + +case "$WT" in /*) ;; *) WT="$PWD/$WT" ;; esac +WT="${WT%/}" +if [ -d "$WT" ]; then + WT="$(mam_abs_workspace "$WT")" +elif [ -d "$(dirname "$WT")" ]; then + WT="$(mam_abs_workspace "$(dirname "$WT")")/$(basename "$WT")" +fi +case "$WT/" in + "$MAIN"/*) + echo "ERROR: '$WT' is inside the control root '$MAIN'; a worktree there shows up as untracked files in the main tree. Put it next to the control root instead." >&2 + exit 2 ;; +esac + +PY="$(_delegate_py_bin)" +wt_py() { PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" "$PY" -m lib_py.worktree "$@"; } + +if [ -z "$SESSION" ] && [ -n "$CREATOR_AGENT" ]; then + slug="$(printf '%s' "$BRANCH" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9-' '-' | sed -E 's/-+/-/g; s/^-//; s/-$//')" + SESSION="creator-${CREATOR_AGENT}-${slug}" + SESSION="${SESSION:0:60}" + SESSION="${SESSION%-}" +fi + +if [ "$DRY_RUN" = "1" ]; then + echo "[dry-run] control root: $MAIN" + echo "[dry-run] worktree: $WT (branch $BRANCH${BASE_REF:+, base $BASE_REF})" + echo "[dry-run] own .venv (from $MAIN/.venv/bin/python + the delegate-job requirements.txt and requirements*.txt) and shared info/exclude entries; submodules: $([ "$SUBMODULES" = 1 ] && echo init || echo skipped)" + [ -n "$CREATOR_AGENT" ] && echo "[dry-run] creator: $CREATOR_AGENT as $SESSION (role $ROLE)" + exit 0 +fi + +# 1. The worktree (idempotent). +if [ "$(wt_py is-linked "$MAIN" "$WT")" = "true" ]; then + cur="$(git -C "$WT" branch --show-current)" + if [ "$cur" != "$BRANCH" ]; then + echo "ERROR: '$WT' is already a worktree, on branch '${cur:-(detached)}', not '$BRANCH'" >&2 + exit 2 + fi + echo "worktree already prepared: $WT ($BRANCH)" +else + if [ -e "$WT" ] && [ -n "$(ls -A "$WT" 2>/dev/null)" ]; then + echo "ERROR: '$WT' exists and is not a worktree of '$MAIN'" >&2 + exit 2 + fi + mkdir -p "$(dirname "$WT")" + if git -C "$MAIN" show-ref --verify --quiet "refs/heads/$BRANCH"; then + if [ -n "$BASE_REF" ]; then + echo "WARNING: branch '$BRANCH' already exists; --base '$BASE_REF' is ignored (the worktree checks the branch out as it is)" >&2 + fi + git -C "$MAIN" worktree add "$WT" "$BRANCH" || exit 1 + else + git -C "$MAIN" worktree add -b "$BRANCH" "$WT" "${BASE_REF:-HEAD}" || exit 1 + fi +fi + +# 2. .venv: the worktree's own environment, created with the control root's +# interpreter and its requirements files, never a link (review R3: a link +# aliases the control root's environment, and a `git add -A` in the +# worktree once committed the link itself - see .gitignore's managed +# block). Idempotent: an existing usable venv is kept. +VENV_STATE="skipped" +if [ -L "$WT/.venv" ]; then + echo "ERROR: '$WT/.venv' is a symbolic link; a worktree needs its own venv - remove the link and re-run" >&2 + exit 1 +elif [ -x "$WT/.venv/bin/python" ]; then + VENV_STATE="kept" +elif [ -x "$MAIN/.venv/bin/python" ]; then + "$MAIN/.venv/bin/python" -m venv "$WT/.venv" || { echo "ERROR: could not create '$WT/.venv'" >&2; exit 1; } + req=() + # the same files deploy/install.sh installs into the control root's venv + for f in .agents/skills/multi-agent-mux-delegate-job/requirements.txt requirements.txt requirements-dev.txt; do + [ -f "$WT/$f" ] && req+=(-r "$WT/$f") + done + if [ "${#req[@]}" -gt 0 ]; then + if ! "$WT/.venv/bin/python" -m pip install -q "${req[@]}"; then + rm -rf "$WT/.venv" # review D1: a half-made venv would be reported as kept by the next run + echo "ERROR: pip install into '$WT/.venv' failed (network to the package index is needed); the venv was removed, the worktree is kept - fix it and re-run" >&2 + exit 1 + fi + fi + VENV_STATE="created" +else + echo "WARNING: '$MAIN/.venv/bin/python' not found; no venv created in the worktree" >&2 +fi + +# 3. Ignore rules. A linked worktree's info/exclude is the shared +# $GIT_COMMON_DIR/info/exclude, so these lines apply to the main checkout too +# (they name artefacts it ignores as well). +excl="$(cd "$WT" && git rev-parse --path-format=absolute --git-path info/exclude)" +mkdir -p "$(dirname "$excl")" +touch "$excl" +if [ -s "$excl" ] && [ -n "$(tail -c1 "$excl")" ]; then echo >> "$excl"; fi +for pat in "/.venv" ".pytest_cache/"; do + grep -Fxq -- "$pat" "$excl" || printf '%s\n' "$pat" >> "$excl" +done + +# 4. Submodules: opt-in only. +SUB_STATE="skipped" +if [ -f "$WT/.gitmodules" ]; then + if [ "$SUBMODULES" = "1" ]; then + gd="$(git -C "$WT" rev-parse --absolute-git-dir)" + had_modules=0 + if [ -e "$gd/modules" ]; then had_modules=1; fi + # The state before this attempt, so a failure undoes only what it created + # (review R3): per submodule its path and whether its git dir already + # existed (an earlier init - kept), and the exact shared config values + # (init and deinit both write the common .git/config). Indexed arrays only + # (bash 3.2 has no associative ones). + sm_names=(); sm_paths=(); sm_pre=(); cfg_keys=(); cfg_had=(); cfg_vals=() + while read -r key path; do + [ -n "$key" ] || continue + name="${key#submodule.}"; name="${name%.path}" + sm_names+=("$name"); sm_paths+=("$path") + if [ -e "$gd/modules/$name" ]; then sm_pre+=(1); else sm_pre+=(0); fi + for k in url active; do + cfg_keys+=("submodule.$name.$k") + if v="$(git -C "$MAIN" config --get "submodule.$name.$k" 2>/dev/null)"; then + cfg_had+=(1); cfg_vals+=("$v") + else + cfg_had+=(0); cfg_vals+=("") + fi + done + done < <(git config --file "$WT/.gitmodules" --get-regexp '^submodule\..*\.path$' 2>/dev/null || true) + # A submodule path that is not initialised yet but already holds files + # (review R4): git refuses to clone into it, and a rollback could not tell + # those files from its own - refuse before changing anything. With this, + # everything under a not-yet-initialised path after a failed attempt was + # created by that attempt, which is what the rollback relies on. + blocked=() + for ((i = 0; i < ${#sm_names[@]}; i++)); do + p="$WT/${sm_paths[$i]}" + if [ "${sm_pre[$i]}" != "1" ] && [ -e "$p" ] && [ -n "$(ls -A "$p" 2>/dev/null)" ]; then + blocked+=("${sm_paths[$i]}") + fi + done + if [ "${#blocked[@]}" -gt 0 ]; then + echo "REFUSED: submodule path(s) not initialised yet but not empty: ${blocked[*]}. Initialising would fail on them and a rollback could not tell their files from its own; move the files away (or drop --submodules). Nothing was changed." >&2 + exit 3 + fi + if ! git -C "$WT" submodule update --init --recursive; then + # A submodule cloned by this attempt leaves a checkout whose .git file + # points into modules/: deinit it before its git dir goes, or the + # worktree stops being a usable git checkout. + for ((i = 0; i < ${#sm_names[@]}; i++)); do + if [ "${sm_pre[$i]}" = "1" ]; then continue; fi + git -C "$WT" submodule deinit -q -f -- "${sm_paths[$i]}" >/dev/null 2>&1 || true + if [ -f "$WT/${sm_paths[$i]}/.git" ]; then rm -f "$WT/${sm_paths[$i]}/.git"; fi + rm -rf "$gd/modules/${sm_names[$i]}" + done + if [ "$had_modules" != "1" ]; then rm -rf "$gd/modules"; fi + for ((i = 0; i < ${#cfg_keys[@]}; i++)); do + if [ "${cfg_had[$i]}" = "1" ]; then + git -C "$MAIN" config "${cfg_keys[$i]}" "${cfg_vals[$i]}" + else + git -C "$MAIN" config --unset-all "${cfg_keys[$i]}" 2>/dev/null || true + fi + done + if st_err="$(git -C "$WT" status --porcelain 2>&1 >/dev/null)"; then + echo "ERROR: submodule initialisation failed; this attempt's traces (new submodule checkouts, their git dirs, new submodule.* config keys) were removed and earlier submodule state was kept, so the worktree stays usable and removable. Re-run without --submodules to finish preparing." >&2 + else + echo "ERROR: submodule initialisation failed, and '$WT' is not a usable git checkout after the rollback: $st_err" >&2 + echo " inspect it with: git -C '$WT' submodule status - do not force-remove it before checking its submodules" >&2 + fi + exit 1 + fi + SUB_STATE="initialised" + else + echo "NOTE: submodules were not initialised (pass --submodules to initialise them)" + fi +fi + +# 5. Optional creator, registered in the control root's .mam. +if [ -n "$CREATOR_AGENT" ]; then + args=(--workspace "$WT" --agent "$CREATOR_AGENT" --role "$ROLE" --session "$SESSION") + if [ "$ONBOARD" = "1" ]; then args+=(--onboard); else args+=(--no-onboard); fi + if [ -n "$HS" ]; then args+=(--herdr-session "$HS"); fi + if [ -n "$HW" ]; then args+=(--herdr-workspace "$HW"); fi + env_add=(WORKSPACE_ROOT="$MAIN" + AGENT_SESSIONS_YAML="${AGENT_SESSIONS_YAML:-$MAIN/.mam/agent-sessions.yaml}" + MAM_JOBS_DIR="${MAM_JOBS_DIR:-$MAIN/.mam/jobs}" + DELEGATE_JOB_LOGS_DIR="${DELEGATE_JOB_LOGS_DIR:-$MAIN/.mam/delegate_job_logs}" + MAM_NO_SUBSCRIBER_DAEMON=1) + if [ -z "${MAM_ENV_FILE:-}" ] && [ -f "$MAIN/.mam.env" ]; then env_add+=(MAM_ENV_FILE="$MAIN/.mam.env"); fi + rc=0 + env "${env_add[@]}" bash "$SKILL_DIR/multi-agent-mux-create/scripts/create_session.sh" "${args[@]}" || rc=$? + if [ "$rc" -ne 0 ]; then + echo "ERROR: create_session.sh failed (rc=$rc); the worktree is kept - remove it with worktree_remove.sh --path '$WT'" >&2 + exit "$rc" + fi +fi + +BASE_SHA="$(git -C "$WT" rev-parse HEAD)" +echo "WORKTREE=$WT" +echo "BRANCH=$BRANCH" +echo "BASE=$BASE_SHA" +echo "SUBMODULES=$SUB_STATE" +echo "VENV=$VENV_STATE" +[ -n "$CREATOR_AGENT" ] && echo "SESSION=$SESSION" +echo "next: bash $SKILL_DIR/multi-agent-mux-loop/scripts/run_loop.sh --worktree '$WT' --base $BASE_SHA --review-only --creator --reviewer --task '...'" +echo "then: bash $SKILL_DIR/multi-agent-mux-stop/scripts/worktree_remove.sh --path '$WT' --stop" diff --git a/.agents/skills/multi-agent-mux-delegate-job/SKILL.md b/.agents/skills/multi-agent-mux-delegate-job/SKILL.md index 84ed3f4..2da2678 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.6.0 +version: 4.7.0 author: godopu license: MIT platforms: [linux, macos, windows] diff --git a/.agents/skills/multi-agent-mux-loop/SKILL.md b/.agents/skills/multi-agent-mux-loop/SKILL.md index d453597..a9fbb90 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.6.0 +version: 4.7.0 author: godopu license: MIT platforms: [linux, macos] @@ -58,7 +58,9 @@ process probing happens for it). The row still needs `role` and `status: running` to be picked up by `--all-reviewer`. Remote rows need the default Python loop runner: the legacy shell runner (`MAM_LOOP_PY=0`) addresses every session as `herdr:` and does not support -`transport: remote`. +`transport: remote`. With `--worktree`/`--base` (review identity), remote reviewers are +refused by default - they cannot reach the review tree on this host; `--remote-review diff-only` +admits them with the snapshot diff as their only evidence, recorded as `evidence: diff-only`. ## Roles & Responsibilities @@ -198,9 +200,13 @@ sequenceDiagram | **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 ์—๊ฒŒ ๊ฐ‘๋‹ˆ๋‹ค. | +| **Review isolation** | `--review-isolation worktree\|env` | `--worktree`/`--base` ์—์„œ๋งŒ ์˜๋ฏธ. `worktree`(๊ธฐ๋ณธ): ๋ผ์šด๋“œ๋งˆ๋‹ค ์Šค๋ƒ…์ƒท์˜ detached ๋ฆฌ๋ทฐ ์›ŒํฌํŠธ๋ฆฌ(TMPDIR)์—์„œ ๊ฒ€์ฆํ•ฉ๋‹ˆ๋‹ค. `env`: ์†Œ์Šค ๋ฃจํŠธ์—์„œ `PYTHONDONTWRITEBYTECODE=1 โ€ฆ -p no:cacheprovider --basetemp=<๋ฐ–>` ์ง€์นจ์œผ๋กœ ๊ฒ€์ฆํ•ฉ๋‹ˆ๋‹ค. ๋ฆฌ๋ทฐ ์›ŒํฌํŠธ๋ฆฌ๋ฅผ ์—ด ์ˆ˜ ์—†์œผ๋ฉด ์กฐ์šฉํžˆ ๋‚ฎ์ถ”์ง€ ์•Š๊ณ  exit 1. `env` ์—์„œ ๋ฆฌ๋ทฐ์–ด๊ฐ€ ignore ๋˜์ง€ ์•Š๋Š” ์‚ฐ์ถœ๋ฌผ์„ ์†Œ์Šค ๋ฃจํŠธ์— ๋‚จ๊ธฐ๋ฉด ๋ผ์šด๋“œ๊ฐ€ stale ๋กœ ํŒ์ •๋ผ ์žฌ๋ฆฌ๋ทฐ๊ฐ€ ๋ฐ˜๋ณต๋ฉ๋‹ˆ๋‹ค(fail-closed) โ€” ์‚ฐ์ถœ๋ฌผ์€ `--basetemp` ๋“ฑ์œผ๋กœ ์†Œ์Šค ๋ฐ–์— ๋‘ก๋‹ˆ๋‹ค. | +| **Remote reviewers** | `--remote-review refuse\|diff-only` | `--worktree`/`--base` ์—์„œ๋งŒ ์˜๋ฏธ. ์›๊ฒฉ ๋ฆฌ๋ทฐ์–ด(`transport: remote`)๋Š” ์ด ํ˜ธ์ŠคํŠธ์˜ ๋ฆฌ๋ทฐ ํŠธ๋ฆฌ์— ๋‹ฟ์„ ์ˆ˜ ์—†์œผ๋ฏ€๋กœ ๊ธฐ๋ณธ์œผ๋กœ **์–ด๋–ค ์žก๋„ ์œ„์ž„ํ•˜๊ธฐ ์ „์—** ๊ฑฐ๋ถ€(exit 1)ํ•ฉ๋‹ˆ๋‹ค. `diff-only` ๋Š” ์Šค๋ƒ…์ƒท SHA ์™€ diff ๋งŒ์„ ๊ทผ๊ฑฐ๋กœ ๋ฐ›์•„๋“ค์ด๊ณ  ๊ทธ๋ ‡๊ฒŒ ๊ธฐ๋กํ•ฉ๋‹ˆ๋‹ค. | `--worktree` ๋˜๋Š” `--base` ๊ฐ€ ์žˆ์œผ๋ฉด ๋งค ๋ฆฌ๋ทฐ ๋ผ์šด๋“œ(์…€ํ”„ ๋ฆฌ๋ทฐ ํฌํ•จ) ์ „์— ์†Œ์Šค ๋ฃจํŠธ์˜ ๋ณ€๊ฒฝ๋ถ„์„ git ์ข…๋ฃŒ ์ฝ”๋“œ๋กœ ํŒ์ •ํ•ฉ๋‹ˆ๋‹ค. ๋ณ€๊ฒฝ๋ถ„์ด ๋น„์—ˆ๊ฑฐ๋‚˜ ํŒ์ •ํ•  ์ˆ˜ ์—†์œผ๋ฉด ๋ฆฌ๋ทฐ๋ฅผ ์š”์ฒญํ•˜์ง€ ์•Š๊ณ  exit 1 ๋กœ ๋๋‚ฉ๋‹ˆ๋‹ค(fail-closed). ๋‘˜ ๋‹ค ์—†์œผ๋ฉด ๊ธฐ์กด ๋™์ž‘ ๊ทธ๋Œ€๋กœ์ž…๋‹ˆ๋‹ค. +**๋ฆฌ๋ทฐ identity (Issue #21 ํ•ญ๋ชฉ 5).** `--worktree`/`--base` ๊ฐ€ ์žˆ์œผ๋ฉด ๋งค ๋ฆฌ๋ทฐ ๋ผ์šด๋“œ๋ฅผ `(loop_id, base, head, tree)` ์— ๋ฌถ์Šต๋‹ˆ๋‹ค. ์†Œ์Šค์˜ ๋ฏธ์ปค๋ฐ‹ยทuntracked ์ƒํƒœ๊นŒ์ง€ ๋‹ด์€ ์Šค๋ƒ…์ƒท(ref ์—†๋Š” ์ปค๋ฐ‹)์„ ๋งŒ๋“ค๊ณ , ๋ฆฌ๋ทฐ์–ด๊ฐ€ ๋ฐ›๋Š” diff ๋„ ๊ทธ ์Šค๋ƒ…์ƒท์—์„œ ๋งŒ๋“ญ๋‹ˆ๋‹ค(`base..snapshot`). ๋ผ์šด๋“œ๊ฐ€ ๋๋‚œ ๋’ค ์†Œ์Šค๊ฐ€ ๋ฐ”๋€Œ์—ˆ์œผ๋ฉด ๊ทธ ๋ผ์šด๋“œ์˜ PASS ๋Š” ๋ฒ„๋ฆฌ๊ณ  ์ˆ˜์ • ์žก ์—†์ด ๋‹ค์‹œ ๋ฆฌ๋ทฐํ•ฉ๋‹ˆ๋‹ค(๋งˆ์ง€๋ง‰ ๋ฐ˜๋ณต์ด๋ฉด exit 1). ์Šน๊ฒฉ ์ง์ „์—๋„ ํ•œ ๋ฒˆ ๋” ํ™•์ธํ•˜๋ฉฐ, ์Šน๊ฒฉ๋œ ๋ฆฌํฌํŠธ ์˜†์— `report-.identity.json`(loop_idยทbaseยทheadยทtreeยท์Šค๋ƒ…์ƒท ์ปค๋ฐ‹ยท๋ฆฌ๋ทฐ์–ดยท`transport`ยท`evidence`: `review-tree`/`source-tree`/`diff-only`)์„ ๋‘ก๋‹ˆ๋‹ค. ์ถ”์ ๋˜์ง€ ์•Š๋Š” ์ค‘์ฒฉ git ์ €์žฅ์†Œ๋Š” ์Šค๋ƒ…์ƒท์— ๋‹ด์„ ์ˆ˜ ์—†์–ด ๊ฑฐ๋ถ€ํ•ฉ๋‹ˆ๋‹ค. ์„œ๋ธŒ๋ชจ๋“ˆ ์•ˆ์˜ ๋ฏธ์ปค๋ฐ‹ยทuntracked ๋‚ด์šฉ๋„ ์Šค๋ƒ…์ƒท์—๋Š” gitlink ์ปค๋ฐ‹๋งŒ ๋‚จ์œผ๋ฏ€๋กœ ๊ฑฐ๋ถ€ํ•ฉ๋‹ˆ๋‹ค(๋ผ์šด๋“œ ์‹œ์ž‘ ์‹œ exit 1, ๋ฆฌ๋ทฐ ์ค‘์— ์ƒ๊ธฐ๋ฉด ๊ทธ ๋ผ์šด๋“œ๋Š” stale). `submodule.*.ignore` ์„ค์ •์œผ๋กœ ๊ฐ€๋ ค์ง€์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ๋ฆฌ๋ทฐ์–ด๊ฐ€ ์—†์œผ๋ฉด creator ์…€ํ”„ ๋ฆฌ๋ทฐ๊ฐ€ ๊ฐ™์€ ๋ฆฌ๋ทฐ์–ด ๊ฒฝ๋กœ(์Šค๋ƒ…์ƒท diffยท์ฆ๊ฑฐ ๊ฒ€์‚ฌยท์Šน๊ฒฉ ์ง์ „ ์žฌํ™•์ธยท์‚ฌ์ด๋“œ์นด)๋ฅผ ๊ฑฐ์น˜๊ณ , NOT PASS ๋ฉด ์ˆ˜์ • ์žก ์—†์ด ๋‹ค์‹œ ๋ฆฌ๋ทฐํ•ฉ๋‹ˆ๋‹ค. ๋ฆฌ๋ทฐ ์žก์˜ `[์›ŒํฌํŠธ๋ฆฌ ์ปจํ…์ŠคํŠธ]` ๋Š” ๊ฒ€์ฆ ๋Œ€์ƒ์„ `[๋ฆฌ๋ทฐ identity]` ์˜ ๊ฒ€์ฆ ๊ฒฝ๋กœ ํ•˜๋‚˜๋กœ๋งŒ ์ง€์‹œํ•ฉ๋‹ˆ๋‹ค. + ๊ตฌํ˜„์ด ๋ฃจํ”„ ๋ฐ–(์›ŒํฌํŠธ๋ฆฌ์˜ creator ์—๊ฒŒ `delegate-job --type direct`)์—์„œ ๋๋‚ฌ๋‹ค๋ฉด, **๊ตฌํ˜„ ์œ„์ž„ ์ „์—** base ๋ฅผ ๊ธฐ๋กํ•ด ๋‘๊ณ  ๋ฆฌ๋ทฐ๋งŒ ๋Œ๋ฆฝ๋‹ˆ๋‹ค: ```bash diff --git a/.agents/skills/multi-agent-mux-loop/scripts/diff_collect.sh b/.agents/skills/multi-agent-mux-loop/scripts/diff_collect.sh index 3ba1fd4..7c75d76 100755 --- a/.agents/skills/multi-agent-mux-loop/scripts/diff_collect.sh +++ b/.agents/skills/multi-agent-mux-loop/scripts/diff_collect.sh @@ -55,6 +55,11 @@ _mam_untracked_diff() { mam_collect_changes_diff() { local repo_root="${1:-}" local base_commit="${2:-}" + # Optional third argument (Issue #21 item 5): a commit - the review + # snapshot - to diff against base, commit to commit. The snapshot already + # holds the untracked files, so no untracked scan; nothing falls back to + # the live tree. + local head_commit="${3:-}" if [ -z "$repo_root" ]; then printf '%s\nreason: repo_root parameter is required\n' "$MAM_DIFF_UNAVAILABLE_MARKER" @@ -70,6 +75,12 @@ mam_collect_changes_diff() { raw_diff=$( cd -P "$repo_root" 2>/dev/null || exit 3 git rev-parse --git-dir >/dev/null 2>&1 || exit 4 + if [ -n "$head_commit" ]; then + git cat-file -e "${base_commit}^{commit}" 2>/dev/null || exit 6 + git cat-file -e "${head_commit}^{commit}" 2>/dev/null || exit 6 + git diff "$base_commit" "$head_commit" || exit 5 + exit 0 + fi if [ -n "$base_commit" ] && git cat-file -e "${base_commit}^{commit}" 2>/dev/null; then git diff "$base_commit" || exit 5 else @@ -86,6 +97,8 @@ mam_collect_changes_diff() { reason="not a git repository: $repo_root" elif [ "$rc" -eq 5 ]; then reason="git diff command failed" + elif [ "$rc" -eq 6 ]; then + reason="base '$base_commit' or head '$head_commit' is not a commit" fi printf '%s\nreason: %s\n' "$MAM_DIFF_UNAVAILABLE_MARKER" "$reason" return 2 @@ -104,6 +117,10 @@ mam_collect_changes_diff() { local stat_summary="" stat_summary=$( cd -P "$repo_root" 2>/dev/null || exit 0 + if [ -n "$head_commit" ]; then + git diff "$base_commit" "$head_commit" --stat 2>/dev/null || true + exit 0 + fi if [ -n "$base_commit" ] && git cat-file -e "${base_commit}^{commit}" 2>/dev/null; then git diff "$base_commit" --stat 2>/dev/null || true else @@ -117,7 +134,11 @@ mam_collect_changes_diff() { printf 'The change set is %s bytes / %s lines, over the review limit (%s bytes / %s lines).\n' \ "$byte_cnt" "$line_cnt" "$MAM_DIFF_MAX_BYTES" "$MAM_DIFF_MAX_LINES" printf 'Only the file-level summary is shown below. You have NOT been shown the\n' - printf 'full change set -- inspect the working tree directly before voting.\n\n' + if [ -n "$head_commit" ]; then + printf 'full change set -- inspect the review snapshot (%s) directly before voting.\n\n' "$head_commit" + else + printf 'full change set -- inspect the working tree directly before voting.\n\n' + fi printf '%s\n\n' "$stat_summary" printf '%s\n' "$MAM_DIFF_TRUNCATED_MARKER" return 0 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 f83b853..da0cbdf 100644 --- a/.agents/skills/multi-agent-mux-loop/scripts/run_loop.sh +++ b/.agents/skills/multi-agent-mux-loop/scripts/run_loop.sh @@ -35,6 +35,8 @@ PLANNER_SESSION_OVERRIDE="" LOOP_WORKTREE="" LOOP_BASE="" LOOP_REVIEW_ONLY=0 +LOOP_REVIEW_ISOLATION="" +LOOP_REMOTE_REVIEW="" # Print usage instructions usage() { @@ -53,6 +55,8 @@ usage() { 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)" + echo " --review-isolation Review tree with --worktree/--base: worktree (default) or env" + echo " --remote-review Remote reviewers with --worktree/--base: refuse (default) or diff-only" exit 1 } @@ -92,6 +96,8 @@ while [[ "$#" -gt 0 ]]; do --worktree) LOOP_WORKTREE="$2"; shift 2 ;; --base) LOOP_BASE="$2"; shift 2 ;; --review-only) LOOP_REVIEW_ONLY=1; shift ;; + --review-isolation) LOOP_REVIEW_ISOLATION="$2"; shift 2 ;; + --remote-review) LOOP_REMOTE_REVIEW="$2"; shift 2 ;; -h|--help) usage ;; *) echo "Unknown option: $1"; usage ;; esac @@ -117,8 +123,9 @@ fi # 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 + || [ "$LOOP_REVIEW_ONLY" = "1" ] || [ -n "${MAM_LOOP_WORKTREE:-}" ] \ + || [ -n "$LOOP_REVIEW_ISOLATION" ] || [ -n "$LOOP_REMOTE_REVIEW" ]; }; then + echo "ERROR: --worktree/--base/--review-only/--review-isolation/--remote-review require the Python loop (MAM_LOOP_PY=1)" >&2 exit 1 fi diff --git a/.agents/skills/multi-agent-mux-monitor/SKILL.md b/.agents/skills/multi-agent-mux-monitor/SKILL.md index 570c860..01ca21f 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.6.0 +version: 4.7.0 author: godopu license: MIT platforms: [linux, macos] diff --git a/.agents/skills/multi-agent-mux-orc-onboard/SKILL.md b/.agents/skills/multi-agent-mux-orc-onboard/SKILL.md index 95cb8b0..9b33333 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.6.0 +version: 4.7.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 4410cf6..ace7f82 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.6.0 +version: 4.7.0 author: godopu license: MIT platforms: [linux, macos] @@ -123,6 +123,15 @@ bash .agents/skills/multi-agent-mux-resume/scripts/update_yaml_resumed.sh \ herdr agent attach "$SESSION_NAME" ``` +## Original cwd only (Issue #21 phase 2) + +A session is resumed only in the directory it ran in (its row's `pane.cwd`): the conversation is keyed by that directory, and a class A agent (agy/hermes/opencode/codex) would otherwise start a new conversation in the wrong tree. + +- `--workspace` differing from `pane.cwd` โ†’ **exit 2** with the path to use (claude/grok used to fail with exit 1 "No saved session"). +- `pane.cwd` no longer exists (worktree removed) โ†’ **exit 2**; recreate the worktree at that path to resume. +- Rows without `pane.cwd` are not checked. The check runs before session-id resolution and also under `--dry-run`. +- The control-plane binding is computed before the herdr-alive check, so an already-running session's row gets it too; a binding that cannot be computed is refused (exit 2) for a worktree and warned about for the control root. + ## Pitfalls - **`claude -r` requires the SAME project directory** โ€” if the workspace path differs from when the session was created, claude will create a new project dir key (`-home-...-different-name`) and put the resume in a different location. Always `-c` (cd to workspace) before running. 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 72fee21..c5a58ff 100755 --- a/.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh +++ b/.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh @@ -47,6 +47,57 @@ case "$AGENT" in esac [ -n "$SESSION_NAME" ] || { echo "ERROR: --session required" >&2; exit 2; } +# Issue #21 phase 2 (A11): a session resumes only in the directory it ran in +# (its conversation is keyed by that cwd; a class A agent would otherwise start +# a fresh conversation in the wrong tree). Existence first: mam_abs_workspace +# returns a missing path unchanged, which would read as "a different path". +ROW_CWD="$(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"]: + print((s.get("pane") or {}).get("cwd", "") or ""); break +' 2>/dev/null || true)" +if [ -n "$ROW_CWD" ]; then + if [ ! -d "$ROW_CWD" ]; then + echo "ERROR: session '$SESSION_NAME' ran in '$ROW_CWD', which no longer exists (worktree removed?). It cannot be resumed; recreate the worktree at that path, or create a new session." >&2 + exit 2 + fi + if [ "$(mam_abs_workspace "$ROW_CWD")" != "$(mam_abs_workspace "$WORKSPACE")" ]; then + echo "ERROR: session '$SESSION_NAME' was created in '$ROW_CWD'; resume it with --workspace '$ROW_CWD' (its conversation is keyed by that directory)." >&2 + exit 2 + 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 - on the already-running path too (phase 2, A13). A binding that +# cannot be computed is refused for a worktree and warned about for the +# control root itself (A12), as in create. +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_ROOT="$(printf '%s' "$ROW_CP" | python3 -c 'import json,sys +try: print(json.load(sys.stdin).get("workspace_root") or "") +except Exception: print("")' 2>/dev/null || true)" +CP_ROOT="${CP_ROOT:-$WORKSPACE_ROOT}" +cp_rc=0 +CP_JSON="$(mam_control_plane_json "$WORKSPACE" "$ROW_CP")" || cp_rc=$? +if [ "$cp_rc" -ne 0 ] || [ -z "$CP_JSON" ]; then + if [ "$(mam_abs_workspace "$WORKSPACE")" != "$(mam_abs_workspace "$CP_ROOT")" ]; then + echo "ERROR: cannot compute the control-plane binding (rc=$cp_rc) for --workspace '$WORKSPACE' (control root '$CP_ROOT'); refusing to resume a session that would use the wrong control plane" >&2 + exit 2 + fi + echo "WARNING: control-plane binding unavailable (rc=$cp_rc); the row is kept without control_plane" >&2 + CP_JSON="" +fi + # 1. Resolve the session id UUID=$(bash "$(dirname "${BASH_SOURCE[0]}")/resolve_session_id.sh" \ --workspace "$WORKSPACE" --agent "$AGENT" --session "$SESSION_NAME") @@ -91,7 +142,7 @@ if herdr has-session -t "$SESSION_NAME" 2>/dev/null; then fi echo "herdr '$SESSION_NAME' already running." # Just update YAML to make sure it's set to running - 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" \ ${HERDR_WORKSPACE_OPT:+--herdr-workspace "$HERDR_WORKSPACE_OPT"} @@ -150,19 +201,8 @@ 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)" +# The binding (computed above) is forwarded to the pane only when it differs +# from --workspace's own; a forward with whitespace is refused (spawn path only). CP_PREFIX="" if [ -n "$CP_JSON" ]; then cp_rc=0 diff --git a/.agents/skills/multi-agent-mux-status/SKILL.md b/.agents/skills/multi-agent-mux-status/SKILL.md index 73f0b30..304b52b 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.6.0 +version: 4.7.0 author: godopu license: MIT platforms: [linux, macos] diff --git a/.agents/skills/multi-agent-mux-stop/SKILL.md b/.agents/skills/multi-agent-mux-stop/SKILL.md index 43db5b3..afbf1cf 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.6.0 +version: 4.7.0 author: godopu license: MIT platforms: [linux, macos] @@ -109,6 +109,24 @@ The script: 6. Updates the YAML entry and SQLite database atomically 7. If `delegate_job_id` is set, publishes a `completed` event to the multi-agent-mux-delegate-job registry +## Removing an issue worktree: `worktree_remove.sh` (Issue #21 item 4) + +```bash +bash .agents/skills/multi-agent-mux-stop/scripts/worktree_remove.sh --path [--stop] [--dry-run] +``` + +Every check runs first and changes nothing; a refusal exits 3: + +1. a linked worktree of the control root, not locked (`git worktree unlock` first - git refuses even `--force` on a locked one); +2. running sessions under it only with `--stop` (stopped normally, conversations kept); +3. no open job of those sessions - never cancelled automatically, they may belong to a running loop (the delegate job of a session `--stop` stops is cancelled by stop itself); +4. a clean tree, submodules included (`status --porcelain --untracked-files=all --ignore-submodules=none` - a `status.showUntrackedFiles=no` setting cannot hide a file from a check that decides a forced removal); +5. inside submodules: no modification, no commit reachable from `HEAD` or any local ref that is on no remote (a submodule sits on a detached HEAD; a commit kept only by a tag or a note is protected too) and no stash (`refs/stash` is reachable from no branch and leaves the status clean) - checked in every repository under the worktree's `modules/`, including deinitialised submodules (their repository stays there) and nested ones; a repository that cannot be checked refuses. + +Then it stops the sessions, re-checks the jobs, runs checks 4 and 5 **again** (a session that is being stopped can still write, commit or stash - any change refuses with exit 3, sessions already stopped) and removes the worktree (the branch is kept): plain, or - when the worktree holds submodule state, which git refuses to remove otherwise - a **single `--force` justified by checks 4 and 5**. Do not run `git worktree remove --force` by hand on such a worktree: it deletes the worktree's own submodule repositories, unpushed commits included. + +Exit codes: 0 removed, 1 stop/git failure, 2 not a worktree of the control root, 3 refused (nothing changed). Sessions created in a removed worktree cannot be resumed until a worktree exists at that path again. + ## Pitfalls - **Don't delete on-disk artifacts by default** โ€” the agent's `*.jsonl` / `conversations/*.db` is the data that `multi-agent-mux-resume` needs. `--purge-conversation` is for when the user is genuinely done with the conversation and wants zero recovery chance. diff --git a/.agents/skills/multi-agent-mux-stop/scripts/worktree_remove.sh b/.agents/skills/multi-agent-mux-stop/scripts/worktree_remove.sh new file mode 100755 index 0000000..997f98d --- /dev/null +++ b/.agents/skills/multi-agent-mux-stop/scripts/worktree_remove.sh @@ -0,0 +1,171 @@ +#!/usr/bin/env bash +# worktree_remove.sh โ€” remove an issue worktree of the control root (Issue #21 +# item 4). Every check runs first and changes nothing; only when all pass are +# sessions stopped and the worktree removed. +# +# Usage: +# worktree_remove.sh --path [--stop] [--dry-run] +# +# Checks: (1) a linked worktree of the control root, not locked; (2) no running +# session under it unless --stop; (3) no open job of those sessions (the +# delegate job of a session --stop will stop is cancelled by stop itself); +# (4) clean tree, submodules included; (5) submodules: nothing modified and no +# commit reachable from HEAD or any local ref (tags included - a submodule is +# on a detached HEAD) that is on no remote, and no stash - in every repository under the worktree's modules/, +# initialised or not. (4) and (5) run again right before the removal, after +# --stop: a session that is being stopped can still write, commit or stash. +# Removal is plain, or - when the worktree holds submodule state, which +# git refuses to remove without it - a single --force justified by (4) and (5). +# Never run `git worktree remove --force` by hand on such a worktree. +# +# Exit codes: 0 removed, 1 stop/git failure, 2 not a worktree of the control +# root, 3 refused by a check (nothing changed). +set -euo pipefail + +_script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$(cd "$_script_dir/../.." && pwd)/lib.sh" + +usage() { + sed -n '2,20p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' +} + +WT=""; STOP=0; DRY_RUN=0 +while [ $# -gt 0 ]; do + case "$1" in + --path) WT="${2:-}"; shift 2 ;; + --stop) STOP=1; shift ;; + --dry-run) DRY_RUN=1; shift ;; + -h|--help) usage; exit 0 ;; + *) echo "ERROR: unknown arg: $1" >&2; usage >&2; exit 2 ;; + esac +done +[ -n "$WT" ] || { echo "ERROR: --path is required" >&2; usage >&2; exit 2; } + +MAIN="$(mam_abs_workspace "$WORKSPACE_ROOT")" +case "$WT" in /*) ;; *) WT="$PWD/$WT" ;; esac +WT="$(mam_abs_workspace "${WT%/}")" + +PY="$(_delegate_py_bin)" +wt_py() { PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" "$PY" -m lib_py.worktree "$@"; } +jget() { python3 -c 'import json,sys; d=json.load(sys.stdin); print(eval(sys.argv[1]))' "$1"; } + +if [ "$(wt_py is-linked "$MAIN" "$WT")" != "true" ]; then + echo "ERROR: '$WT' is not a linked worktree of the control root '$MAIN'" >&2 + exit 2 +fi + +REFUSED=0 +refuse() { echo "REFUSED: $*" >&2; REFUSED=1; } + +# (1) locked: even a single --force is refused by git +if [ "$(wt_py is-locked "$MAIN" "$WT")" = "true" ]; then + refuse "the worktree is locked (run: git worktree unlock '$WT')" +fi + +# (2) sessions +STATE="$(load_state_json)" +ROWS="$(MAM_STATE_JSON="$STATE" wt_py rows "$WT")" +RUNNING="$(printf '%s' "$ROWS" | jget '" ".join(s["name"] for s in d if s.get("status") == "running")')" +RUNNING_AGENTS="$(printf '%s' "$ROWS" | jget '" ".join(s["name"] + ":" + ((s.get("pane") or {}).get("cmd") or "") for s in d if s.get("status") == "running")')" +STOP_JIDS="$(printf '%s' "$ROWS" | jget '",".join(str(s.get("delegate_job_id")) for s in d if s.get("status") == "running" and s.get("delegate_job_id"))')" +if [ -n "$RUNNING" ] && [ "$STOP" != "1" ]; then + refuse "running session(s) in the worktree: $RUNNING (pass --stop to stop them before removal)" +fi + +# (3) open jobs (not the delegate jobs --stop will cancel) +EXCLUDE="" +[ "$STOP" = "1" ] && EXCLUDE="$STOP_JIDS" +JOBS="$(MAM_STATE_JSON="$STATE" wt_py open-jobs "$WT" --exclude "$EXCLUDE")" +if [ "$(printf '%s' "$JOBS" | jget 'len(d)')" != "0" ]; then + refuse "open job(s) of sessions in the worktree - not cancelled automatically, they may belong to a running loop:" + printf '%s' "$JOBS" | jget '"\n".join(" %s %s %s (%s)" % (j["job_id"], j["agent_session"], j["status"], j["registry_dir"]) for j in d)' >&2 + echo " close one after checking it: python3 .agents/skills/multi-agent-mux-delegate-job/scripts/registry.py --registry-dir close --job --request cancelled" >&2 +fi + +# (4) clean, submodules included, and (5) submodules. A function: it runs now, +# before any change, and again right before the removal (review R1). It sets +# METHOD from what it saw and returns 1 on any finding. --untracked-files=all: +# a status.showUntrackedFiles=no setting (worktree or submodule) must not hide +# files from a check that decides a forced removal (review R1). +METHOD="plain" # set before any early return below: the summary line reads it under set -u +judge_tree() { + local found=0 dirty sub trace key val + if ! dirty="$(git -C "$WT" status --porcelain --untracked-files=all --ignore-submodules=none 2>&1)"; then + refuse "git status failed in the worktree: $dirty" + if [ ! -d "$WT" ]; then + echo " the directory no longer exists; if that is intended, run: git -C '$MAIN' worktree prune" >&2 + fi + return 1 + fi + if [ -n "$dirty" ]; then + refuse "the worktree has uncommitted changes:" + printf '%s\n' "$dirty" | sed 's/^/ /' >&2 + found=1 + fi + sub="$(wt_py submodules "$WT")" + trace="$(printf '%s' "$sub" | jget 'd["trace"]')" + METHOD="plain" + if [ "$trace" = "True" ]; then + METHOD="single --force" + for key in error dirty unpushed stashes; do + val="$(printf '%s' "$sub" | jget "d.get('$key', '')")" + if [ -n "$val" ]; then + refuse "submodule check '$key' is not empty (a forced removal would destroy it):" + printf '%s\n' "$val" | sed 's/^/ /' >&2 + found=1 + fi + done + fi + return "$found" +} +judge_tree || true + +echo "checks: locked/sessions/jobs/clean/submodules -> $([ "$REFUSED" = 1 ] && echo refused || echo passed); removal method: $METHOD" +if [ "$REFUSED" = "1" ]; then + echo "nothing was changed" >&2 + exit 3 +fi +if [ "$DRY_RUN" = "1" ]; then + echo "[dry-run] would ${RUNNING:+stop: $RUNNING, then }remove '$WT' ($METHOD)" + exit 0 +fi + +# (6) stop, then re-check every open job +if [ "$STOP" = "1" ] && [ -n "$RUNNING_AGENTS" ]; then + for pair in $RUNNING_AGENTS; do + name="${pair%%:*}"; agent="${pair#*:}" + stop_args=(--session "$name") + [ -n "$agent" ] && stop_args+=(--agent "$agent") + if ! bash "$SKILL_DIR/multi-agent-mux-stop/scripts/stop_session.sh" "${stop_args[@]}"; then + echo "ERROR: stopping '$name' failed; the worktree was not removed" >&2 + exit 1 + fi + done + JOBS="$(MAM_STATE_JSON="$(load_state_json)" wt_py open-jobs "$WT")" + if [ "$(printf '%s' "$JOBS" | jget 'len(d)')" != "0" ]; then + echo "REFUSED: the sessions were stopped, but open job(s) appeared meanwhile; the worktree was not removed:" >&2 + printf '%s' "$JOBS" | jget '"\n".join(" %s %s %s" % (j["job_id"], j["agent_session"], j["status"]) for j in d)' >&2 + exit 3 + fi +fi + +# (6b) the tree again, right before removing it: the stopped sessions may have +# written, committed or stashed while they stopped (review R1). The +# removal method follows this latest look. +if ! judge_tree; then + echo "REFUSED: ${RUNNING:+the sessions were stopped, but }the worktree changed after the checks; it was not removed" >&2 + exit 3 +fi + +# (7) remove; the branch is kept +if [ "$METHOD" = "plain" ]; then + git -C "$MAIN" worktree remove "$WT" || exit 1 +else + git -C "$MAIN" worktree remove --force "$WT" || exit 1 + echo "submodule checks passed; removed with a single --force" +fi +echo "removed: $WT ($METHOD)" +NAMES="$(printf '%s' "$ROWS" | jget '" ".join(s["name"] for s in d)')" +if [ -n "$NAMES" ]; then + echo "Sessions created in this worktree ($NAMES) cannot be resumed; their conversations are kept, and recreating a worktree at the same path makes them resumable again." +fi diff --git a/BOOTSTRAP.ko.md b/BOOTSTRAP.ko.md index 4f568b3..c4f866c 100644 --- a/BOOTSTRAP.ko.md +++ b/BOOTSTRAP.ko.md @@ -2,7 +2,7 @@ ๋ณธ ๋ฌธ์„œ๋Š” `herdr_agent_orchestration` ์˜ค์ผ€์ŠคํŠธ๋ ˆ์ด์…˜ ๋ฐ ๋ฉ”์‹œ์ง• ๋ฐฑํ”Œ๋ ˆ์ธ ์›Œํฌํ”Œ๋กœ์šฐ๋ฅผ ์ƒˆ๋กœ์šด ํ”„๋กœ์ ํŠธ์— ๋„์ž…ํ•˜์—ฌ ์ด์‹ํ•˜๊ณ , ์ƒˆ๋กœ์šด ๊ฐœ๋ฐœ์ž/์—์ด์ „ํŠธ๊ฐ€ ์ดˆ๊ธฐ ๊ฐ€๋™์„ ์‹œ์ž‘ํ•  ๋•Œ ์ˆ˜ํ–‰ํ•ด์•ผ ํ•˜๋Š” ํ™˜๊ฒฝ ์„ค์ • ๋ฐ ๊ตฌ์ถ• ์ ˆ์ฐจ๋ฅผ ์•ˆ๋‚ดํ•ฉ๋‹ˆ๋‹ค. -[![Version: v4.6.0](https://img.shields.io/badge/version-v4.6.0-blue.svg)](VERSIONS.md) +[![Version: v4.7.0](https://img.shields.io/badge/version-v4.7.0-blue.svg)](VERSIONS.md) ์ƒˆ๋กœ์šด ์—์ด์ „ํŠธ๋Š” ์ด ์•ˆ๋‚ด์„œ์— ๊ธฐ์ˆ ๋œ ์ ˆ์ฐจ๋ฅผ ์ˆœ์ฐจ์ ์œผ๋กœ ์‹คํ–‰ํ•˜์—ฌ ์ดˆ๊ธฐ ํ™˜๊ฒฝ์„ ์•ˆ์ •์ ์œผ๋กœ ์„ค์ •ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. diff --git a/BOOTSTRAP.md b/BOOTSTRAP.md index fa304e2..880dead 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.6.0](https://img.shields.io/badge/version-v4.6.0-blue.svg)](VERSIONS.md) +[![Version: v4.7.0](https://img.shields.io/badge/version-v4.7.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 9efb484..ebc80c7 100644 --- a/MESSAGING.md +++ b/MESSAGING.md @@ -1,6 +1,6 @@ # Messaging System Technical Analysis & Architecture Report -Applies to: MAM v4.6.0 +Applies to: MAM v4.7.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 1faac87..2bb889d 100644 --- a/README.ko.md +++ b/README.ko.md @@ -2,7 +2,7 @@ Herdr์™€ MQTT ๋ธŒ๋กœ์ปค๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ ๊ตฌ์ถ•๋œ ๊ณ ์‹ ๋ขฐ์„ฑ **๋‹ค์ค‘ ์—์ด์ „ํŠธ ์˜ค์ผ€์ŠคํŠธ๋ ˆ์ด์…˜ ๋ฐ ๋ฉ”์‹œ์ง• ๋ฐฑํ”Œ๋ ˆ์ธ** ํ”„๋ ˆ์ž„์›Œํฌ์ž…๋‹ˆ๋‹ค. `claude`, `agy`, `hermes`, `grok`, `opencode`, `codex` ๋“ฑ ๋‹ค์ค‘ LLM ๋ฐฑ์—”๋“œ ์—์ด์ „ํŠธ ์ „๋ฐ˜์— ๊ฑธ์ณ ์žฅ์‹œ๊ฐ„ ์ˆ˜ํ–‰๋˜๋Š” ์ž‘์—…(์ฝ”๋“œ ์ƒ์„ฑ, ๋ฆฌํŒฉํ† ๋ง, ๋ณด์•ˆ ๊ฒ€ํ†  ๋“ฑ)์„ ์กฐ์ •, ๊ฒฉ๋ฆฌ ๋ฐ ๊ฐ์‚ฌํ•  ์ˆ˜ ์žˆ๋„๋ก ์„ค๊ณ„๋˜์—ˆ์Šต๋‹ˆ๋‹ค. -[![Version: v4.6.0](https://img.shields.io/badge/version-v4.6.0-blue.svg)](VERSIONS.md) +[![Version: v4.7.0](https://img.shields.io/badge/version-v4.7.0-blue.svg)](VERSIONS.md) --- diff --git a/README.md b/README.md index 931688d..611b1f5 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.6.0](https://img.shields.io/badge/version-v4.6.0-blue.svg)](VERSIONS.md) +[![Version: v4.7.0](https://img.shields.io/badge/version-v4.7.0-blue.svg)](VERSIONS.md) --- diff --git a/VERSIONS.md b/VERSIONS.md index dc0804c..0f2c9af 100644 --- a/VERSIONS.md +++ b/VERSIONS.md @@ -6,8 +6,8 @@ ## ๐Ÿ“Œ ํ˜„์žฌ ๋ฒ„์ „ ๊ฐœ์š” (Current Release) -- **ํ”„๋ ˆ์ž„์›Œํฌ ๋ฒ„์ „**: `v4.6.0` -- **์ตœ์‹  ๋ฆด๋ฆฌ์Šค ์ผ์‹œ**: 2026-09-14 (KST) +- **ํ”„๋ ˆ์ž„์›Œํฌ ๋ฒ„์ „**: `v4.7.0` +- **์ตœ์‹  ๋ฆด๋ฆฌ์Šค ์ผ์‹œ**: 2026-09-20 (KST) - **๊ธฐ์ค€ ๋ธŒ๋žœ์น˜**: `main` - **ํ•ต์‹ฌ ์•„ํ‚คํ…์ฒ˜**: - **Terminal ์ƒํƒœ ๋‹จ๋ฐฉํ–ฅ ๋ถˆ๋ณ€์„ฑ (`engine.py`, Issue #6 Phase 1)**: `completed`, `error`, `cancelled` ํ„ฐ๋ฏธ๋„ ์ƒํƒœ ์ง„์ž… ํ›„ ํ›„์† ์ด๋ฒคํŠธ๋‚˜ ์—…๋ฐ์ดํŠธ์— ์˜ํ•œ ๋ฎ์–ด์“ฐ๊ธฐ๋ฅผ ์—„๊ฒฉํžˆ ์ฐจ๋‹จ(Terminal Immutability). `registry.py close` ์„œ๋ธŒ์ปค๋งจ๋“œ ๋ฐ fail-closed ์กฐ๊ธฐ ์ข…๋ฃŒ ๋„์ž…. @@ -15,29 +15,49 @@ - **Loop ๋ชจ๋“œ `attempt_id` ์ถ”์  ๋ฐ ์•ˆ์ „ ์ „์ด (Issue #6 Phase 3)**: ์›Œ์ปค/๋ฆฌ๋ทฐ์–ด ์ „ํ™˜ ์‹œ ๋™์ผ ํšŒ์ฐจ ๋‚ด ์žฌ์ „์ด ์ถฉ๋Œ์„ ๋ฐฉ์ง€ํ•˜๋Š” `attempt_id` ๊ทœ์•ฝ ์ ์šฉ ๋ฐ 4๋‹จ๊ณ„ fail-closed `finalize_unfinished` ๋ณด์žฅ. - **๊ฒฐ์ •๋ก ์  ์ข…๋ฃŒ ์ฝ”๋“œ ๊ณ„์•ฝ (Issue #6 Phase 4 & R1)**: `0` (Success), `1` (Failure / Inject error), `2` (Subscriber timeout; ๋‹จ, ์—์ด์ „ํŠธ๊ฐ€ Herdr์—์„œ working ์ค‘์ด๋ฉด ๋ฒฝ์‹œ๊ณ„ --timeout๊นŒ์ง€ ๋””์Šคํฌ ์žฌ๊ด€์ธก ํ›„ fail-close), `3` (Broker unavailable), `5` (Precondition failed / session_busy) ํ‘œ์ค€ํ™”. (send_keys_safe ํ—ฌํผ์˜ ๋ฐฐ๋‹ฌ ์‹คํŒจ/busy ํƒ€์ž„์•„์›ƒ rc=4๋Š” ๋ž˜ํผ ์ฐจ์›์—์„œ rc=1 ์‹คํŒจ๋กœ ์ˆ˜์šฉ). - **ํ”„๋กœํ† ์ฝœ ๊ธฐ๋ฐ˜ ์ƒ์กดยท์ „๋‹ฌ ๊ณ„์•ฝ (Issue #18)**: heartbeat ์˜๋ฌด(N, ์ž๋™ ํƒ€์ด๋จธ) ยท ๋ฌด์‘๋‹ต ํŒ์ •(Kร—N) ยท ์ „๋‹ฌ ํ™•์ธ(T ๋‚ด `started`) ยท retained `/state` ยท ์›๊ฒฉ ์ „์†ก(`remote:`). TUI ์Šคํฌ๋ž˜ํ•‘์€ ์ฃผ์ž…ยท๋‹ค์ด์–ผ๋กœ๊ทธยท์ง„๋‹จ์—๋งŒ ๋‚จ๋Š”๋‹ค. + - **์›ŒํฌํŠธ๋ฆฌ ์šด์˜ ๋ชจ๋ธ 2๋‹จ๊ณ„ (Issue #21 ํ•ญ๋ชฉ 4ยท5ยท7)**: ์›ŒํฌํŠธ๋ฆฌ ์ค€๋น„ยทํ•ด์ฒด ํ—ฌํผ(๋ณ€๊ฒฝ ์ „ ํŒ์ •, untracked ํฌํ•จ, ๊ฒ€์ฆ๋œ ๋‹จ์ผ `--force`), ๋ฆฌ๋ทฐ identity(๋ผ์šด๋“œ๋ณ„ ์Šค๋ƒ…์ƒทยทstale ์žฌ๋ฆฌ๋ทฐยท์Šน๊ฒฉ ์ง์ „ ์žฌํ™•์ธยท`identity.json`), ์ˆ˜์šฉ ์‚ฌ๋ก€ A1โ€“A8. - **4-Way ๋ฒ„์ „ ๋ฝ์Šคํ…**: `lib.sh`(`MAM_VERSION`) โ†” `lib_py/loop/eager.py`(`FRAMEWORK_VERSION`) โ†” `VERSIONS.md` โ†” 8๊ฐœ `SKILL.md`. --- ## ๐Ÿงญ ์Šคํ‚ฌ ํŒจํ‚ค์ง€ ๋ฒ„์ „ ๋งคํŠธ๋ฆญ์Šค (Skills Version Matrix) -๋ชจ๋“  8๊ฐœ ์Šคํ‚ฌ์€ YAML frontmatter ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ(`author`, `version`, `platforms`, `environments`) ํ‘œ์ค€ํ™”๋ฅผ ํ†ตํ•ด `v4.6.0`๋กœ ๋™๊ธฐํ™”๋˜์–ด ๋ฐฐํฌ๋ฉ๋‹ˆ๋‹ค. +๋ชจ๋“  8๊ฐœ ์Šคํ‚ฌ์€ YAML frontmatter ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ(`author`, `version`, `platforms`, `environments`) ํ‘œ์ค€ํ™”๋ฅผ ํ†ตํ•ด `v4.7.0`๋กœ ๋™๊ธฐํ™”๋˜์–ด ๋ฐฐํฌ๋ฉ๋‹ˆ๋‹ค. | ์Šคํ‚ฌ๋ช… | ๋ฒ„์ „ | ์—ญํ•  ๋ฐ ์ฃผ์š” ์ฑ…์ž„ | ์ƒํƒœ | | :--- | :---: | :--- | :---: | -| **`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 ๊ฒฉ๋ฆฌ ๋“ฑ๋ก ๋ฐ ์„œ๋ธŒ ์„ธ์…˜ ์˜ค์—ผ ๋ฐฉ์ง€ | โœ… ๋ฐฐํฌ | +| **`multi-agent-mux-create`** | `4.7.0` | ์—์ด์ „ํŠธ ์„ธ์…˜ ์‹ ๊ทœ ์ƒ์„ฑ ๋ฐ Herdr ์ปจํ…Œ์ด๋„ˆ ๊ฒฉ๋ฆฌ ์Šคํฐ | โœ… ๋ฐฐํฌ | +| **`multi-agent-mux-stop`** | `4.7.0` | ๋Œ€ํ™” UUID ์›์ž์  ์บก์ฒ˜ ๋ฐ ์„ธ์…˜ ์•ˆ์ „ ์ข…๋ฃŒ (Graceful Stop) | โœ… ๋ฐฐํฌ | +| **`multi-agent-mux-resume`** | `4.7.0` | ์˜จ๋””์Šคํฌ ๋Œ€ํ™” ์ปจํ…์ŠคํŠธ ๊ธฐ๋ฐ˜ Tier-1 ์ดˆ๊ณ ์† ์„ธ์…˜ ๋ณต์› | โœ… ๋ฐฐํฌ | +| **`multi-agent-mux-status`** | `4.7.0` | ์‹ค์‹œ๊ฐ„ Herdr ์„ธ์…˜ ๋ฐ ๋ ˆ์ง€์ŠคํŠธ๋ฆฌ ๋“œ๋ฆฌํ”„ํŠธ ์Šค๋ƒ…์ƒท ์กฐํšŒ | โœ… ๋ฐฐํฌ | +| **`multi-agent-mux-monitor`** | `4.7.0` | YAML โ†” ๋Ÿฐํƒ€์ž„ ์ƒํƒœ ๊ฐ„ ์ž์œจ ์กฐ์ •์ž (Reconciler Loop) | โœ… ๋ฐฐํฌ | +| **`multi-agent-mux-delegate-job`** | `4.7.0` | MQTT ์ด๋ฒคํŠธ ์ฑ„๋„ ๊ธฐ๋ฐ˜ ๋น„๋™๊ธฐ ๋‹จ์œ„ ์ž‘์—… ์œ„์ž„ | โœ… ๋ฐฐํฌ | +| **`multi-agent-mux-loop`** | `4.7.0` | Planner-Creator-Reviewer 3์ž ์ž์œจ ๊ณ„ํšยท์‹คํ–‰ยทํ”ผ์–ด๋ฆฌ๋ทฐ ๋ฃจํ”„ | โœ… ๋ฐฐํฌ | +| **`multi-agent-mux-orc-onboard`** | `4.7.0` | ์˜ค์ผ€์ŠคํŠธ๋ ˆ์ดํ„ฐ UUID ๊ฒฉ๋ฆฌ ๋“ฑ๋ก ๋ฐ ์„œ๋ธŒ ์„ธ์…˜ ์˜ค์—ผ ๋ฐฉ์ง€ | โœ… ๋ฐฐํฌ | --- ## ๐Ÿ“‹ ๋ฒ„์ „๋ณ„ ์ƒ์„ธ ๋ณ€๊ฒฝ ๋‚ด์—ญ (Changelog) +### ๐Ÿš€ `v4.7.0` โ€” Issue #21 ์›ŒํฌํŠธ๋ฆฌ ์šด์˜ ๋ชจ๋ธ 2๋‹จ๊ณ„: ์›ŒํฌํŠธ๋ฆฌ ํ—ฌํผ, ๋ฆฌ๋ทฐ identity, ์ˆ˜์šฉ ์‚ฌ๋ก€ A1โ€“A8, #20 ํ…Œ์ŠคํŠธ ๊ฒฉ๋ฆฌ, #27, #28 ํ˜ธ์ŠคํŠธ ์ด์‹์„ฑ (2026-09-20, PR #29) + +> **๋งˆ์ด๋„ˆ ๋งˆ์ผ์Šคํ†ค (MINOR Release)**: ์›ŒํฌํŠธ๋ฆฌ ์šด์˜ ๋ชจ๋ธ(#21, `docs/design/worktree-creator-model.md`)์˜ ์ž‘์—… ํ•ญ๋ชฉ 4ยท5ยท7 ์„ ๊ตฌํ˜„ํ–ˆ๋‹ค. ์ด์Šˆ๋ณ„ ์›ŒํฌํŠธ๋ฆฌ๋ฅผ ๋งŒ๋“ค๊ณ  ์ง€์šฐ๋Š” ํ—ฌํผ 2๊ฐœ, ๋ฆฌ๋ทฐ ๋ผ์šด๋“œ๋ฅผ `(loop_id, base, head, tree)` ์— ๋ฌถ๋Š” ๋ฆฌ๋ทฐ identity ์™€ ๋ฃจํ”„ ์˜ต์…˜ 2๊ฐœ(`--review-isolation`, `--remote-review`), ์ˆ˜์šฉ ์‚ฌ๋ก€ 8๊ฑด์ด ์ถ”๊ฐ€๋˜์–ด MINOR ๋กœ ์˜ฌ๋ฆฐ๋‹ค. ํ•ญ๋ชฉ 8(๋‹ค์ค‘ ๋ฃจํ”„ยท๋ฆฌ๋ทฐ์–ด ์Šค์ผ€์ค„๋Ÿฌ)์€ ๋ณด๋ฅ˜๋กœ ๋‚จ์•„ #21 ์€ ์—ด์–ด ๋‘”๋‹ค. ํ•จ๊ป˜ #20(ํ…Œ์ŠคํŠธ๊ฐ€ ์‹ค์ œ `.mam` ์„ ์˜ค์—ผ), #27(`.gitignore` ๊ด€๋ฆฌ ๋ธ”๋ก์˜ `.venv`), #28(Linux/py3.14 ํ˜ธ์ŠคํŠธ์˜ ํ…Œ์ŠคํŠธ ์‹คํŒจ 33๊ฑด)์„ ๋‹ซ๋Š”๋‹ค. + +#### ๐ŸŒŸ ์ฃผ์š” ๊ฐœ์„  ์‚ฌํ•ญ +1. **์›ŒํฌํŠธ๋ฆฌ ์ค€๋น„ยทํ•ด์ฒด ํ—ฌํผ (ํ•ญ๋ชฉ 4, `multi-agent-mux-create/scripts/worktree_prepare.sh`, `multi-agent-mux-stop/scripts/worktree_remove.sh`)**: prepare ๋Š” ์ œ์–ด ๋ฃจํŠธ ์˜†์— ์›ŒํฌํŠธ๋ฆฌ๋ฅผ ๋ฉฑ๋“ฑํ•˜๊ฒŒ ๋งŒ๋“ค๊ณ (์•ˆ์ชฝ ๊ฒฝ๋กœ๋Š” ๊ฑฐ๋ถ€), ์›ŒํฌํŠธ๋ฆฌ **์ž์ฒด์˜** `.venv` ๋ฅผ ์ œ์–ด ๋ฃจํŠธ์˜ `.venv/bin/python` ์œผ๋กœ ๋งŒ๋“ค์–ด `requirements*.txt` ๋ฅผ ์„ค์น˜ํ•˜๋ฉฐ(์‹ฌ๋งํฌ๋Š” ๋งŒ๋“ค์ง€ ์•Š๊ณ  ๋‚จ์•„ ์žˆ์œผ๋ฉด exit 1 โ€” ๋งํฌ๊ฐ€ `git add -A` ๋กœ ์ปค๋ฐ‹๋œ ์‚ฌ๊ณ ์˜ ์žฌ๋ฐœ ๋ฐฉ์ง€), `/.venv`ยท`.pytest_cache/` ๋ฅผ ๊ณต์œ  `info/exclude` ์— ๋„ฃ๊ณ , ์„œ๋ธŒ๋ชจ๋“ˆ์€ `--submodules` ๋กœ๋งŒ ์ดˆ๊ธฐํ™”ํ•œ๋‹ค(์‹คํŒจํ•œ ์‹œ๋„๋Š” ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ์ฒดํฌ์•„์›ƒ์œผ๋กœ ๋กค๋ฐฑ). `--creator-agent` ๋Š” ์ œ์–ด ๋ฃจํŠธ ๋ฐ”์ธ๋”ฉ๊ณผ `MAM_NO_SUBSCRIBER_DAEMON=1` ๋กœ creator ์„ธ์…˜์„ ๋งŒ๋“ค๊ณ  `BASE=` ๋ฅผ ์ถœ๋ ฅํ•œ๋‹ค. remove ๋Š” ์ž ๊ธˆยท์‹คํ–‰ ์ค‘ ์„ธ์…˜(`--stop`)ยท์—ด๋ฆฐ ์žกยท์ˆ˜์ •(untracked ํฌํ•จ)ยท์„œ๋ธŒ๋ชจ๋“ˆ(๋ฏธpush ์ปค๋ฐ‹: HEAD ์™€ tagยทnotes ๋ฅผ ํฌํ•จํ•œ ๋ชจ๋“  ๋กœ์ปฌ ref, stash, deinit ๋œ ์ €์žฅ์†Œ, ๋ถ€๋ถ„ ์ €์žฅ์†Œ) ์„ **์•„๋ฌด๊ฒƒ๋„ ๋ฐ”๊พธ๊ธฐ ์ „์—** ํŒ์ •ํ•˜๊ณ , `--stop` ๋’ค ์ œ๊ฑฐ ์ง์ „์— ํ•œ ๋ฒˆ ๋” ํŒ์ •ํ•œ ๋’ค, ์„œ๋ธŒ๋ชจ๋“ˆ ์ƒํƒœ๊ฐ€ ์žˆ๋Š” ์›ŒํฌํŠธ๋ฆฌ๋งŒ ๊ฒ€์ฆ๋œ ๋‹จ์ผ `--force` ๋กœ ์ง€์šด๋‹ค. +2. **resume ๊ณ„์•ฝ (ํ•ญ๋ชฉ 4, `multi-agent-mux-resume`)**: ์„ธ์…˜์€ ๊ธฐ๋ก๋œ ์›๋ž˜ cwd ์—์„œ๋งŒ ์žฌ๊ฐœํ•œ๋‹ค(๋‹ค๋ฅธ ๊ฒฝ๋กœ๋Š” exit 2, cwd ์—†๋Š” ๊ธฐ์กด ํ–‰์€ ํ˜ธํ™˜). ์ด๋ฏธ ์‹คํ–‰ ์ค‘ ๊ฒฝ๋กœ์—์„œ๋„ ์ œ์–ด ํ‰๋ฉด ๋ฐ”์ธ๋”ฉ์„ ์˜์†ํ™”ํ•˜๊ณ , ์›ŒํฌํŠธ๋ฆฌ ๋ฐ”์ธ๋”ฉ ๊ณ„์‚ฐ ์‹คํŒจ๋Š” ์‚ผํ‚ค์ง€ ์•Š๊ณ  exit 2 ๋กœ ์•Œ๋ฆฐ๋‹ค. create ๋Š” ์ฆ‰์‹œ reconcileยทwatchdog ์„ ์ œ์–ด ๋ฃจํŠธ์˜ ์ฝ”๋“œ๋กœ ๋„์šด๋‹ค. +3. **๋ฆฌ๋ทฐ identity (ํ•ญ๋ชฉ 5, `lib_py/loop/runner.py`, `run_loop.sh --review-isolation worktree|env`, `--remote-review refuse|diff-only`)**: `--worktree`/`--base` ์—์„œ ๋งค ๋ฆฌ๋ทฐ ๋ผ์šด๋“œ ์ „์— ์†Œ์Šค์˜ ๋ฏธ์ปค๋ฐ‹ยทuntracked ์ƒํƒœ๊นŒ์ง€ ๋‹ด์€ ref ์—†๋Š” ์Šค๋ƒ…์ƒท ์ปค๋ฐ‹์„ ๋งŒ๋“ค๊ณ , ๋ฆฌ๋ทฐ์–ด์˜ diff ๋„ `base..snapshot` ์—์„œ ๋งŒ๋“ค๋ฉฐ, ๊ธฐ๋ณธ์€ ์Šค๋ƒ…์ƒท์˜ detached ๋ฆฌ๋ทฐ ์›ŒํฌํŠธ๋ฆฌ์—์„œ ๊ฒ€์ฆํ•œ๋‹ค. ๋ผ์šด๋“œ๊ฐ€ ๋๋‚œ ๋’ค ์†Œ์Šค๊ฐ€ ๋ฐ”๋€Œ์—ˆ์œผ๋ฉด PASS ๋ฅผ ๋ฒ„๋ฆฌ๊ณ  ๋‹ค์‹œ ๋ฆฌ๋ทฐํ•˜๊ณ (๋งˆ์ง€๋ง‰ ๋ฐ˜๋ณต์ด๋ฉด exit 1), ์Šน๊ฒฉ ์ง์ „์—๋„ ์žฌํ™•์ธํ•˜๋ฉฐ, ๋ฆฌํฌํŠธ ์˜†์— `report-.identity.json`(loop_idยทbaseยทheadยทtreeยท์Šค๋ƒ…์ƒทยท๋ฆฌ๋ทฐ์–ดยทtransportยทevidence)์„ ๋‘”๋‹ค. ์ถ”์ ๋˜์ง€ ์•Š๋Š” ์ค‘์ฒฉ ์ €์žฅ์†Œ์™€ dirty ์„œ๋ธŒ๋ชจ๋“ˆ(`--ignore-submodules=none --untracked-files=all`, ์–ด๋–ค `submodule.*.ignore`ยท`diff.ignoreSubmodules`ยท`status.showUntrackedFiles` ์„ค์ •๋„ ์ˆจ๊ธฐ์ง€ ๋ชปํ•จ)์€ ๊ฑฐ๋ถ€ํ•œ๋‹ค. ์›๊ฒฉ ๋ฆฌ๋ทฐ์–ด๋Š” ์ด ํ˜ธ์ŠคํŠธ์˜ ๋ฆฌ๋ทฐ ํŠธ๋ฆฌ์— ๋‹ฟ์„ ์ˆ˜ ์—†์œผ๋ฏ€๋กœ ์–ด๋–ค ์žก๋„ ์œ„์ž„ํ•˜๊ธฐ ์ „์— ๊ฑฐ๋ถ€ํ•˜๊ฑฐ๋‚˜ `diff-only` ๋กœ๋งŒ ๋ฐ›๋Š”๋‹ค. ์˜ต์…˜์ด ์—†์œผ๋ฉด ์ด์ „ ๋™์ž‘ ๊ทธ๋Œ€๋กœ๋‹ค. +4. **ํ…Œ์ŠคํŠธ ๊ฒฉ๋ฆฌ (#20, `tests/conftest.py`)**: ์ˆ˜์ง‘ ์ „์— ambient ์ œ์–ด ํ‰๋ฉด ๋ฐ”์ธ๋”ฉ์„ ๊ธฐ๋กยท์ œ๊ฑฐํ•˜๊ณ  ํ…Œ์ŠคํŠธ๋งˆ๋‹ค ๋ ˆ์ง€์ŠคํŠธ๋ฆฌยท๊ฐ์‚ฌ ๋กœ๊ทธ ๊ธฐ๋ณธ ๋ฃจํŠธ๋ฅผ tmp ๋กœ ๋ณด๋‚ธ๋‹ค. ๊ธฐ๋ก๋œ ์ œ์–ด ํ‰๋ฉด์˜ `.mam/jobs`ยท`.mam/delegate_job_logs` ์— ์ƒˆ ํ•ญ๋ชฉ์ด ์ƒ๊ธฐ๋ฉด ์„ธ์…˜ ๊ฐ€๋“œ๊ฐ€ ์‹คํŒจ์‹œํ‚ค๊ณ , ๊ธฐ๋ณธ ํ•ด์„ ๊ทœ์น™์„ ๊ฒ€์ฆํ•˜๋Š” #15 ํ…Œ์ŠคํŠธ๋Š” ๋ช…์‹œ์ ์œผ๋กœ ์˜ตํŠธ์•„์›ƒํ•œ๋‹ค. `MAM_ENV_FILE` ์€ ์กด์žฌํ•˜์ง€ ์•Š๋Š” ๊ฒฝ๋กœ๋กœ ๊ณ ์ •ํ•ด ์‹ค `.mam.env` ๋ฅผ ์ฝ์ง€ ์•Š๋Š”๋‹ค. +5. **ํ˜ธ์ŠคํŠธ ์ด์‹์„ฑ (#28)**: `tests/test_workspace_scope.py` ๋Š” `bash -c` ๋กœ ์‹คํ–‰(dash ์— `source` ์—†์Œ), argparse ๋„์›€๋ง ๋น„๊ต๋Š” `_normalize_help` ๋กœ py3.10+ ์˜ "options:" ๋ฅผ ํก์ˆ˜, `test_deploy_freshness.py` ์˜ private-server ๋ฌธ์„œยทdocker ์ž์‚ฐ ์˜์กด ํ…Œ์ŠคํŠธ๋Š” ์ž์‚ฐ์ด ์—†์œผ๋ฉด skip, `lib_py/agents/base.py`ยท`lib_py/delegation/heartbeat.py` ์˜ ํ”„๋กœ์„ธ์Šค ์Šค์บ”์€ POSIX `ps -eo pid=,ppid=,args=` ๋กœ ํ†ต์ผ(BSD ์ „์šฉ ์ธ์ž๊ฐ€ Linux ์—์„œ purge 12๊ฑด์„ ๊นจ๋œจ๋ฆฌ๋˜ ์›์ธ ๊ฐ€์„ค). `test_issue14_delivery_ack.py` ์˜ ๋ฒฝ์‹œ๊ณ„ close ์—ฌ์œ ๋Š” `timeout_s + 15`. +6. **`.gitignore` ๊ด€๋ฆฌ ๋ธ”๋ก (#27, `deploy/install.sh`, `deploy/install_mam.sh`)**: `.venv` ๋ฅผ ํŒŒ์ผยท๋””๋ ‰ํ„ฐ๋ฆฌยท๋งํฌ ์–ด๋А ํ˜•ํƒœ๋กœ๋„ ๋ฌด์‹œํ•œ๋‹ค(์žฌ์„ค์น˜๋Š” ์˜› ๊ทœ์น™๋งŒ ๋ฐ”๊พธ๊ณ  ์‚ฌ์šฉ์ž ์ค„์€ ๋ณด์กด). +7. **๋ฌธ์„œ**: ์„ค๊ณ„ ๋ฌธ์„œ(ํ•ญ๋ชฉ 4ยท5ยท7 ์ƒํƒœ, ์„œ๋ธŒ๋ชจ๋“ˆ ์ œ๊ฑฐ ๊ทœ์น™, resume cwd ๊ทœ์น™, ๋ฃจํŠธ 3์ข…ยท๋ฆฌ๋ทฐ identity), create/stop/resume/loop SKILL.md, ํ˜ธํ™˜์„ฑ ํ‘œ. + +#### โœ… ํ’ˆ์งˆ ๊ฒ€์ฆ +- **์‹ ๊ทœ ํ…Œ์ŠคํŠธ** (`tests/test_issue21_phase2_loop_a.py`, `tests/test_issue21_loop_b.py`, `tests/test_issue21_acceptance.py`(A1โ€“A8), `tests/test_issue20_state_isolation.py`, `tests/test_issue21_worktree_prereqs.py` ํ™•์žฅ): prepare ์˜ ๋ฉฑ๋“ฑ์„ฑยท๊ฑฐ๋ถ€ยทdry-runยทvenv(์ƒ์„ฑ/์œ ์ง€/๋งํฌ ๊ฑฐ๋ถ€/์ธํ„ฐํ”„๋ฆฌํ„ฐ ๋ถ€์žฌ)ยท์„œ๋ธŒ๋ชจ๋“ˆ ๋กค๋ฐฑ, remove ์˜ R1โ€“R6(์ข…๋ฃŒ ์ค‘ ์“ฐ๊ธฐ, ์„œ๋ธŒ๋ชจ๋“ˆ stash, ๋ถ€๋ถ„ init ๋กค๋ฐฑ, ๊ธฐ์กด ํŒŒ์ผ, deinit ์ €์žฅ์†Œ, tag ๋กœ๋งŒ ๋ณด์กดํ•œ ์ปค๋ฐ‹)๊ณผ `status.showUntrackedFiles=no` ๋กœ ์ˆจ๊ธด untracked ํŒŒ์ผ, ๋ฆฌ๋ทฐ identity ์˜ ์Šค๋ƒ…์ƒทยทstaleยท์Šน๊ฒฉ ์žฌํ™•์ธยท๊ฒฉ๋ฆฌ ์‹คํŒจยท์›๊ฒฉ ๊ฑฐ๋ถ€ยทdirty ์„œ๋ธŒ๋ชจ๋“ˆ(์ˆจ๊น€ ์„ค์ • ํฌํ•จ), #20 ๊ฐ€๋“œ. ์ˆ˜์šฉ ์‚ฌ๋ก€ A1โ€“A8 ์€ mock herdr ๊ธฐ์ค€์ด๋ฉฐ ์‹ค์ œ pane ์˜ env ๊ฐ’ ํ™•์ธ์€ ๋‹จ๊ณ„ C ๋กœ ๋‚จ๊ธด๋‹ค. +- **๋ฆฌ๋ทฐ (๋ณ‘ํ•ฉ ์›Œํฌ์ŠคํŽ˜์ด์Šค, PR head ๋ฅผ detached ์›ŒํฌํŠธ๋ฆฌ์— ๋ฐ›์•„ 4์ธ ๋…๋ฆฝ ๊ฒ€ํ† )**: @46ea4cc claudeยทagyยทgrok PASS, codex NOT PASS(R1 `status.showUntrackedFiles=no` ๊ฐ€ ์ˆจ๊ธด ์„œ๋ธŒ๋ชจ๋“ˆ untracked ํŒŒ์ผ์ด force ์ œ๊ฑฐ๋ฅผ ํ†ต๊ณผ, R2 ๊ฐ™์€ ๊ฒฐํ•จ์œผ๋กœ ๋ฆฌ๋ทฐ ์Šค๋ƒ…์ƒท์ด ๋ฏธํฌ์ฐฉ ๋‚ด์šฉ์„ ์Šน์ธ, R3 prepare ์˜ `.venv` ์‹ฌ๋งํฌ) โ†’ 08db1da ๋กœ ์ •์ •. ๋ธํƒ€ ๋ฆฌ๋ทฐ @08db1da claudeยทagyยทgrok PASS(๋น„์ฐจ๋‹จ D1โ€“D3 โ†’ 80a5614 ๋ฐ˜์˜), codex ๋Š” ๋ฃจํ”„์˜ ํ”„๋กฌํ”„ํŠธ ์ฃผ์ž… ์‹คํŒจ(์žก 14c7dc38, `started` ์—†์Œ)๋กœ ์ง์ ‘ ์žฌ์œ„์ž„ํ•ด @80a5614 PASS. ๋ฆฌํฌํŠธ 8๊ฑด์€ `.agents/reports//report-.md`(291e38b5ยทf27519ffยท5f1486eeยทb880c1f2ยทf1174fc0ยทd9275506ยท19d56cdeยทf6b21a0d). +- **์ „์ฒด ์Šค์œ„ํŠธ (๋ณ‘ํ•ฉ ์›Œํฌ์ŠคํŽ˜์ด์Šค)**: @46ea4cc 1013 ํ†ต๊ณผ / 1 skip / ์‹คํŒจ 1๊ฑด(๋ถ€ํ•˜ ์‹œ ๋ฒฝ์‹œ๊ณ„ ๊ฒฝ๊ณ„, 90a289d ๋กœ ์—ฌ์œ  ํ™•๋Œ€). @08db1da(์ž์ฒด venv ๊ฐ€ ์—†๋Š” ์›ŒํฌํŠธ๋ฆฌ์—์„œ ์‹คํ–‰) 1015 ํ†ต๊ณผ / 1 skip / 4 ์‹คํŒจ โ€” 4๊ฑด ๋ชจ๋‘ ์›ŒํฌํŠธ๋ฆฌ์˜ `.venv` ๋ถ€์žฌ๋กœ ์Šคํฌ๋ฆฝํŠธ๊ฐ€ system python(paho ์—†์Œ)์„ ์žก์€ ํƒ“์ด๋ฉฐ, ์›ŒํฌํŠธ๋ฆฌ ์ž์ฒด venv ๋ฅผ ๋งŒ๋“  ๋’ค 4๊ฑด ๋ชจ๋‘ ํ†ต๊ณผ(80a5614 ๊ฐ€ prepare ์˜ ์„ค์น˜ ๋ชฉ๋ก์— delegate-job requirements ๋ฅผ ๋„ฃ์€ ๋™๊ธฐ). +- **#28 ํ›„์†**: Linux/py3.14 ํ˜ธ์ŠคํŠธ์—์„œ `ps -eo` ์ˆ˜์ •์ด purge 12๊ฑด์„ ์‹ค์ œ๋กœ ๊ณ ์น˜๋Š”์ง€ ํ™•์ธ์ด ํ•„์š”ํ•˜๋‹ค(๊ฐ€์„ค ๋‹จ๊ณ„). + ### ๐Ÿš€ `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 ์— ๋‚จ์•„ ์žˆ๋‹ค. diff --git a/deploy/install.sh b/deploy/install.sh index c44e3ee..2a63d2b 100644 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -546,7 +546,7 @@ gi_path, start_marker, end_marker = sys.argv[1], sys.argv[2], sys.argv[3] block_lines = [ start_marker + "\n", - "/.venv/\n", + "/.venv\n", "/.mam/\n", "/.mam_deploy/\n", "/.mam.env\n", diff --git a/deploy/install_mam.sh b/deploy/install_mam.sh index cb98bfc..fa55e3f 100755 --- a/deploy/install_mam.sh +++ b/deploy/install_mam.sh @@ -246,7 +246,7 @@ gi_path, start_marker, end_marker = sys.argv[1], sys.argv[2], sys.argv[3] block_lines = [ start_marker + "\n", - "/.venv/\n", + "/.venv\n", "/.mam/\n", "/.mam_deploy/\n", "/.mam.env\n", diff --git a/docs/design/worktree-creator-model.md b/docs/design/worktree-creator-model.md index 4425536..5d3e652 100644 --- a/docs/design/worktree-creator-model.md +++ b/docs/design/worktree-creator-model.md @@ -33,31 +33,29 @@ MAM ์€ **์›Œํฌ์ŠคํŽ˜์ด์Šค = `.agents/skills` ๊ฐ€ ๋“ค์–ด ์žˆ๋Š” ์ฒดํฌ์•„์›ƒ ### C. (์ œ์•ˆ) creator ๋งŒ ์›ŒํฌํŠธ๋ฆฌ, ๋ฆฌ๋ทฐ์–ด ํ’€์€ ๊ณต์œ , ์ œ์–ด ํ‰๋ฉด์€ ํ•˜๋‚˜ - **creator = ์›ŒํฌํŠธ๋ฆฌ ์ „์šฉ ์ธ์Šคํ„ด์Šค.** ์ œํ’ˆ ์ฝ”๋“œ๋ฅผ ํŽธ์ง‘ยท์ปค๋ฐ‹ํ•˜๋Š” ์œ ์ผํ•œ ์—์ด์ „ํŠธ์ด๋ฏ€๋กœ cwd ๊ฐ€ ๊ณง ์ด์Šˆ ๋ธŒ๋žœ์น˜์—ฌ์•ผ ํ•œ๋‹ค. ์˜ค์ผ€์ŠคํŠธ๋ ˆ์ดํ„ฐ ์ปค๋ฐ‹๊ณผ ๋ฃจํ”„์˜ ๋ฆฌํฌํŠธ ์Šน๊ฒฉ(`runner.py` ์˜ Promotion ๋ธ”๋ก โ†’ ์ œ์–ด ๋ฃจํŠธ `.agents/reports/`)์€ ๋ณ„๋„ ์†Œ์œ ๋‹ค. ์ด์Šˆ๋งˆ๋‹ค `creator--` ๋กœ ๋งŒ๋“ค๊ณ  ๋๋‚˜๋ฉด ์ •์ง€(๋Œ€ํ™” ๋ณด์กด, ํ•„์š” ์‹œ ์žฌ๊ฐœ). ์žฌ๊ฐœํ•˜๋ ค๋ฉด ๊ฐ™์€ ์›ŒํฌํŠธ๋ฆฌ ๊ฒฝ๋กœ๊ฐ€ ์žˆ์–ด์•ผ ํ•œ๋‹ค(๋Œ€ํ™” ํ‚ค๊ฐ€ cwd). -- **๋ฆฌ๋ทฐ์–ด ํ’€ = ๋ณธ ์ฒดํฌ์•„์›ƒ ๊ณต์œ .** ๋ฆฌ๋ทฐ์–ด๋Š” ์†Œ์Šค๋ฅผ ํŽธ์ง‘ํ•˜์ง€ ์•Š๋Š”๋‹ค. ๊ทธ๋Ÿฌ๋‚˜ ํ…Œ์ŠคํŠธ ์‹คํ–‰์€ ์“ฐ๊ธฐ๋ฅผ ๋™๋ฐ˜ํ•˜๋ฏ€๋กœ '์ฝ๊ธฐ ์ „์šฉ'์ด ์•„๋‹ˆ๋‹ค. ๊ฒ€์ฆ ๊ฒฉ๋ฆฌ(head SHA detached ์›ŒํฌํŠธ๋ฆฌ ๋˜๋Š” ๊ฒฉ๋ฆฌ ์˜ต์…˜)์™€ (loop_id, base, head) ํŒ์ • ๊ฒฐ์†์€ ํ•ญ๋ชฉ 5 ์—์„œ ์ •ํ•œ๋‹ค. 1๋‹จ๊ณ„ ๋ธŒ๋ฆฌํ”„๋Š” ๋Ÿฌ๋„ˆ๊ฐ€ ๊ฒฝ๋กœยทbaseยทheadยท์ปค๋ฐ‹ ์ˆ˜๋ฅผ ์ž๋™ ์‚ฝ์ž…ํ•œ๋‹ค(`--worktree`/`--base`). ๋ฆฌ๋ทฐ๊ฐ€ ๋ณ‘๋ชฉ์ด๋ฉด ๋ฆฌ๋ทฐ์–ด ์ธ์Šคํ„ด์Šค๋ฅผ ์ถ”๊ฐ€ํ•œ๋‹ค. +- **๋ฆฌ๋ทฐ์–ด ํ’€ = ๋ณธ ์ฒดํฌ์•„์›ƒ ๊ณต์œ .** ๋ฆฌ๋ทฐ์–ด๋Š” ์†Œ์Šค๋ฅผ ํŽธ์ง‘ํ•˜์ง€ ์•Š๋Š”๋‹ค. ๊ทธ๋Ÿฌ๋‚˜ ํ…Œ์ŠคํŠธ ์‹คํ–‰์€ ์“ฐ๊ธฐ๋ฅผ ๋™๋ฐ˜ํ•˜๋ฏ€๋กœ '์ฝ๊ธฐ ์ „์šฉ'์ด ์•„๋‹ˆ๋‹ค. `--worktree`/`--base` ์—์„œ๋Š” ๋Ÿฌ๋„ˆ๊ฐ€ ๋งค ๋ผ์šด๋“œ๋ฅผ ์Šค๋ƒ…์ƒท `(loop_id, base, head, tree)` ์— ๋ฌถ๊ณ (ํ•ญ๋ชฉ 5), ๋ฆฌ๋ทฐ์–ด๋Š” ๊ทธ ์Šค๋ƒ…์ƒท์˜ detached ๋ฆฌ๋ทฐ ์›ŒํฌํŠธ๋ฆฌ(๊ธฐ๋ณธ) ๋˜๋Š” env ๊ฒฉ๋ฆฌ ์ง€์นจ์œผ๋กœ ์†Œ์Šค ๋ฃจํŠธ์—์„œ ๊ฒ€์ฆํ•œ๋‹ค. ๋ฆฌ๋ทฐ ์ค‘ ์†Œ์Šค๊ฐ€ ๋ฐ”๋€ ๋ผ์šด๋“œ์˜ ์Šน์ธ์€ ๋ฒ„๋ฆฌ๊ณ  ๋‹ค์‹œ ๋ฆฌ๋ทฐํ•˜๋ฉฐ, ์Šน๊ฒฉ ์ง์ „์—๋„ ๋‹ค์‹œ ํ™•์ธํ•œ๋‹ค. ์›๊ฒฉ ๋ฆฌ๋ทฐ์–ด๋Š” ๋ฆฌ๋ทฐ ํŠธ๋ฆฌ์— ๋‹ฟ์„ ์ˆ˜ ์—†์–ด ๊ธฐ๋ณธ ๊ฑฐ๋ถ€, `--remote-review diff-only` ๋กœ๋งŒ ๋ฐ›๋Š”๋‹ค. ๋ฆฌ๋ทฐ๊ฐ€ ๋ณ‘๋ชฉ์ด๋ฉด ๋ฆฌ๋ทฐ์–ด ์ธ์Šคํ„ด์Šค๋ฅผ ์ถ”๊ฐ€ํ•œ๋‹ค. - **์˜ค์ผ€์ŠคํŠธ๋ ˆ์ดํ„ฐ ์ปค๋ฐ‹ = ์›ŒํฌํŠธ๋ฆฌ.** ๋ณธ ์ฒดํฌ์•„์›ƒ์€ ๋ฆฌ๋ทฐ์–ด์˜ ์ž‘์—… ๊ณต๊ฐ„์ด๋ฏ€๋กœ ์˜ค์ผ€์ŠคํŠธ๋ ˆ์ดํ„ฐ๋Š” ๊ทธ๊ณณ์—์„œ ๋ธŒ๋žœ์น˜๋ฅผ ๋ฐ”๊พธ์ง€ ์•Š๋Š”๋‹ค. - **์ œ์–ด ํ‰๋ฉด = ๋ณธ ์ฒดํฌ์•„์›ƒ์˜ `.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๋‹จ๊ณ„ ์ˆ˜์ • ๋ฐ˜์˜) +## 3. C ์•ˆ์˜ ๋™์ž‘ ๋ฐฉ์‹ (1๋‹จ๊ณ„ + 2๋‹จ๊ณ„ ๋ฃจํ”„ A ๋ฐ˜์˜) ```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 +# ์›ŒํฌํŠธ๋ฆฌ ์ค€๋น„ + creator ์ƒ์„ฑ(ํ•ญ๋ชฉ 4, worktree_prepare.sh): git worktree add, ์ œ์–ด ๋ฃจํŠธ .venv ๋งํฌ, +# ๊ณตํ†ต info/exclude ์— /.venvยท.pytest_cache/, creator ๋Š” ์ œ์–ด ๋ฃจํŠธ ๋ฐ”์ธ๋”ฉ๊ณผ MAM_NO_SUBSCRIBER_DAEMON=1 ๋กœ ์ƒ์„ฑ. +# ํ–‰์˜ pane.cwd ๋Š” ์›ŒํฌํŠธ๋ฆฌ, control_plane ์€ ๋ณธ ์ฒดํฌ์•„์›ƒ. ์„œ๋ธŒ๋ชจ๋“ˆ์€ --submodules ๋ฅผ ์ค„ ๋•Œ๋งŒ ์ดˆ๊ธฐํ™”ํ•œ๋‹ค. +cd "$MAIN" && bash .agents/skills/multi-agent-mux-create/scripts/worktree_prepare.sh \ + --path "$WT" --branch fix/issue-20 --base main \ + --creator-agent claude --session creator-claude-issue20 \ + --herdr-session multi-agent-mux --herdr-workspace mam-agent +# ์ถœ๋ ฅ ๋์˜ BASE= ๊ฐ€ ์›ŒํฌํŠธ๋ฆฌ HEAD ๋‹ค. (b) ํ๋ฆ„์ด๋ฉด ๊ตฌํ˜„ ์œ„์ž„ ์ „์— ๊ธฐ๋กํ•ด ๋‘”๋‹ค. # (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 ๋ฅผ ๊ตฌํ˜„ ์œ„์ž„ **์ „์—** ๊ธฐ๋กํ•œ๋‹ค +# (b) ๊ตฌํ˜„์€ ๋ฃจํ”„ ๋ฐ–(1๋‹จ๊ณ„ ๊ธฐ๋ณธ): base ๋ฅผ ๊ตฌํ˜„ ์œ„์ž„ **์ „์—** ๊ธฐ๋กํ•œ๋‹ค(prepare ๊ฐ€ ์ถœ๋ ฅํ•œ 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 ..." @@ -65,10 +63,15 @@ bash "$MAIN/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate 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 ๋Š” ์›๋ž˜ ์›ŒํฌํŠธ๋ฆฌ ๊ฒฝ๋กœ. ์žฌ๊ฐœ๋Š” ํ–‰์— ์˜์†๋œ ๋ฐ”์ธ๋”ฉ์„ ๋‹ค์‹œ ์ „ํŒŒํ•œ๋‹ค +# ์ •์ง€ยท์žฌ๊ฐœ: --workspace ๋Š” ์›๋ž˜ ์›ŒํฌํŠธ๋ฆฌ ๊ฒฝ๋กœ์—ฌ์•ผ ํ•œ๋‹ค(๋‹ค๋ฅธ ๊ฒฝ๋กœยท์‚ญ์ œ๋œ ๊ฒฝ๋กœ๋Š” exit 2). +# ์žฌ๊ฐœ๋Š” ํ–‰์— ์˜์†๋œ ๋ฐ”์ธ๋”ฉ์„ ๋‹ค์‹œ ์ „ํŒŒํ•œ๋‹ค 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 + +# ์ •๋ฆฌ(worktree_remove.sh): ์ž ๊ธˆยท์„ธ์…˜ยท์žกยท์ฒญ๊ฒฐยท์„œ๋ธŒ๋ชจ๋“ˆ ํŒ์ •์„ ๋ชจ๋‘ ํ†ต๊ณผํ•ด์•ผ ์ •์ง€ยท์ œ๊ฑฐํ•œ๋‹ค. +# ํ•˜๋‚˜๋ผ๋„ ๊ฑธ๋ฆฌ๋ฉด exit 3 ์ด๊ณ  ์•„๋ฌด๊ฒƒ๋„ ๋ฐ”๊พธ์ง€ ์•Š๋Š”๋‹ค. ๋ธŒ๋žœ์น˜๋Š” ๋‚จ๊ธด๋‹ค. +bash "$MAIN/.agents/skills/multi-agent-mux-stop/scripts/worktree_remove.sh" --path "$WT" --stop ``` - `--task` ์— base ๋ฅผ ์ ์ง€ ๋ง ๊ฒƒ. ๋Ÿฌ๋„ˆ๊ฐ€ ๋„ฃ๋Š” base ์™€ ๋‘˜์ด ๋œ๋‹ค. ๋ฆฌ๋ทฐ์–ด๊ฐ€ ๋ฐ›๋Š” base ๋Š” ๋Ÿฌ๋„ˆ์˜ `[์›ŒํฌํŠธ๋ฆฌ ์ปจํ…์ŠคํŠธ]` ๋ธ”๋ก ํ•˜๋‚˜๋ฟ์ด์–ด์•ผ ํ•œ๋‹ค. @@ -83,19 +86,23 @@ bash "$MAIN/.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh" \ - **๋ฆฌ๋ทฐ ๋‹จ๊ณ„๋Š” ํ’€ ํฌ๊ธฐ๋งŒํผ๋งŒ ๋ณ‘๋ ฌ**์ด๋‹ค. ์ด์Šˆ 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` ๋กœ ์ฒ˜๋ฆฌํ•œ๋‹ค. +- ์ค€๋น„ยทํ•ด์ฒด ํ—ฌํผ๋Š” ํ•ญ๋ชฉ 4 ๋กœ ๊ตฌํ˜„ํ–ˆ๋‹ค(`worktree_prepare.sh`, `worktree_remove.sh`). + - `.mam.env` ๋Š” ๋ณต์‚ฌํ•˜์ง€ ์•Š๋Š”๋‹ค. creator ์—๋Š” ์ œ์–ด ๋ฃจํŠธ์˜ ๊ฒƒ์„ `MAM_ENV_FILE` ๋กœ ์ค€๋‹ค. + - `.venv` ๋Š” ์›ŒํฌํŠธ๋ฆฌ ์ž์ฒด์˜ ๊ฒƒ์ด๋‹ค: ์ œ์–ด ๋ฃจํŠธ์˜ `.venv/bin/python` ์œผ๋กœ ๋งŒ๋“ค๊ณ  delegate-job ์Šคํ‚ฌ์˜ `requirements.txt`(paho-mqttยทPyYAML, `deploy/install.sh` ๊ฐ€ ์„ค์น˜ํ•˜๋Š” ๊ฒƒ)์™€ `requirements*.txt` ๋ฅผ ์„ค์น˜ํ•œ๋‹ค. ์„ค์น˜๊ฐ€ ์‹คํŒจํ•˜๋ฉด ๋ฐ˜์ฏค ๋งŒ๋“  venv ๋ฅผ ์ง€์šฐ๊ณ  exit 1 ํ•œ๋‹ค(๋‹ค์Œ ์‹คํ–‰์ด kept ๋กœ ์˜คํŒํ•˜์ง€ ์•Š๋„๋ก). ์‹ฌ๋งํฌ๋Š” ๋งŒ๋“ค์ง€ ์•Š์œผ๋ฉฐ ๋‚จ์•„ ์žˆ์œผ๋ฉด prepare ๊ฐ€ ๊ฑฐ๋ถ€ํ•œ๋‹ค(๋งํฌ๋Š” ์ œ์–ด ๋ฃจํŠธ ํ™˜๊ฒฝ์„ ๊ทธ๋Œ€๋กœ ๊ฐ€๋ฆฌํ‚ค๊ณ , ์›ŒํฌํŠธ๋ฆฌ์˜ `git add -A` ๊ฐ€ ๋งํฌ ์ž์ฒด๋ฅผ ์ปค๋ฐ‹ํ•œ ์ ์ด ์žˆ๋‹ค). ๋ฌด์‹œ ๊ทœ์น™(`/.venv`, `.pytest_cache/`)์€ linked worktree ๊ฐ€ ๊ณต์œ ํ•˜๋Š” `$GIT_COMMON_DIR/info/exclude` ์— ๋“ค์–ด๊ฐ„๋‹ค. ๊ทธ๋ž˜์„œ ๋ณธ ์ฒดํฌ์•„์›ƒ์—๋„ ์ ์šฉ๋˜๋ฉฐ, ๋‘˜ ๋‹ค ๋ณธ ์ฒดํฌ์•„์›ƒ์—์„œ๋„ ๋ฌด์‹œํ•ด์•ผ ํ•˜๋Š” ์‚ฐ์ถœ๋ฌผ์ด๋‹ค. + - ์„œ๋ธŒ๋ชจ๋“ˆ์€ `--submodules` ๋กœ๋งŒ ์ดˆ๊ธฐํ™”ํ•œ๋‹ค. ์ดˆ๊ธฐํ™”๋Š” ์„ฑ๊ณตยท์‹คํŒจ์™€ ๋ฌด๊ด€ํ•˜๊ฒŒ ์›ŒํฌํŠธ๋ฆฌ git ๋””๋ ‰ํ„ฐ๋ฆฌ์— `modules/` ๋ฅผ ๋‚จ๊ธฐ๊ณ , ๊ทธ๋Ÿฌ๋ฉด git ์ด force ์—†๋Š” `worktree remove` ๋ฅผ ๊ฑฐ๋ถ€ํ•œ๋‹ค. ์ดˆ๊ธฐํ™”ํ•˜์ง€ ์•Š์€ ์„œ๋ธŒ๋ชจ๋“ˆ ๊ฒฝ๋กœ์— ์ด๋ฏธ ํŒŒ์ผ์ด ์žˆ์œผ๋ฉด ์•„๋ฌด๊ฒƒ๋„ ๋ฐ”๊พธ์ง€ ์•Š๊ณ  exit 3 ์œผ๋กœ ๊ฑฐ๋ถ€ํ•œ๋‹ค. prepare ๋Š” ์‹คํŒจํ•œ ์‹œ๋„๊ฐ€ ๋งŒ๋“  ๊ฒƒ๋งŒ ๋˜๋Œ๋ฆฌ๊ณ  exit 1 ํ•œ๋‹ค. ์ƒˆ ์ฒดํฌ์•„์›ƒ์€ git ๋””๋ ‰ํ„ฐ๋ฆฌ๋ณด๋‹ค ๋จผ์ € deinit ํ•ด์„œ ์›ŒํฌํŠธ๋ฆฌ๊ฐ€ ์“ธ ์ˆ˜ ์žˆ๋Š” ์ฒดํฌ์•„์›ƒ์œผ๋กœ ๋‚จ๊ฒŒ ํ•˜๊ณ , ์ƒˆ `submodule.*` ๊ณต์œ  ์„ค์ • ํ‚ค๋Š” ์ง€์šด๋‹ค. ์ด์ „ ์ดˆ๊ธฐํ™” ์ƒํƒœ์™€ ๊ธฐ์กด ์„ค์ • ๊ฐ’์€ ๋ณด์กดํ•œ๋‹ค. + - ์„œ๋ธŒ๋ชจ๋“ˆ์„ ์ดˆ๊ธฐํ™”ํ•œ ์›ŒํฌํŠธ๋ฆฌ๋Š” remove ๊ฐ€ ์ˆ˜์ •(untracked ํฌํ•จ, `--untracked-files=all` ์ด๋ผ `status.showUntrackedFiles` ์„ค์ •์œผ๋กœ ์ˆจ๊ธธ ์ˆ˜ ์—†๋‹ค), HEAD ์™€ ๋ชจ๋“  ๋กœ์ปฌ ref(tagยทnotes ํฌํ•จ)์—์„œ ๋„๋‹ฌํ•˜๋Š”, ์›๊ฒฉ์— ์—†๋Š” ์ปค๋ฐ‹(์„œ๋ธŒ๋ชจ๋“ˆ์€ detached HEAD ๋‹ค), stash ๋ฅผ ํ™•์ธํ•œ ๋’ค `--force` ๋ฅผ ํ•œ ๋ฒˆ๋งŒ ์จ์„œ ์ œ๊ฑฐํ•œ๋‹ค. ์ด ํ™•์ธ์€ ์ดˆ๊ธฐํ™”๋œ ์ฒดํฌ์•„์›ƒ๋ฟ ์•„๋‹ˆ๋ผ ์›ŒํฌํŠธ๋ฆฌ `modules/` ์•„๋ž˜์˜ ๋ชจ๋“  ์ €์žฅ์†Œ(deinit ๋œ ์„œ๋ธŒ๋ชจ๋“ˆ, ์ค‘์ฒฉ ์„œ๋ธŒ๋ชจ๋“ˆ ํฌํ•จ)์— ์ง์ ‘ ํ•˜๋ฉฐ, ํ™•์ธํ•  ์ˆ˜ ์—†๋Š” ์ €์žฅ์†Œ๊ฐ€ ์žˆ์œผ๋ฉด ๊ฑฐ๋ถ€ํ•œ๋‹ค. ์ด ํ™•์ธ์€ `--stop` ์œผ๋กœ ์„ธ์…˜์„ ์ •์ง€ํ•œ ๋’ค ์ œ๊ฑฐ ์ง์ „์— ํ•œ ๋ฒˆ ๋” ํ•œ๋‹ค(์ •์ง€ ์ค‘์—๋„ ์“ฐ๊ธฐยท์ปค๋ฐ‹ยทstash ๊ฐ€ ์ƒ๊ธธ ์ˆ˜ ์žˆ๋‹ค). **์‚ฌ๋žŒ์ด `git worktree remove --force` ๋ฅผ ์ง์ ‘ ์“ฐ์ง€ ๋ง ๊ฒƒ.** ์›ŒํฌํŠธ๋ฆฌ ์ „์šฉ ์„œ๋ธŒ๋ชจ๋“ˆ ์ €์žฅ์†Œ์™€ ๋ฏธํ‘ธ์‹œ ์ปค๋ฐ‹์ด ํ•จ๊ป˜ ์‚ฌ๋ผ์ง„๋‹ค. ## 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 ์ „ํŒŒ ํฌํ•จ) | ๋Œ€๊ธฐ | +| 1 | ๋ž˜ํผ ์ฃผ์ž… ๋ฌธ๊ตฌยท์‹œ์ž‘ ๋ช…๋ น(`--registry-dir`, ๊ฐ์‚ฌ ๋กœ๊ทธ ๋ฃจํŠธ)ยทOutput Report Path ์ ˆ๋Œ€๊ฒฝ๋กœํ™”, `MAM_JOBS_DIR` ์กด์ค‘ | ์™„๋ฃŒ(v4.6.0) | +| 2 | ๋ฃจํ”„ ๋Ÿฌ๋„ˆ `--worktree` / `--base` / `--review-only`, ๋นˆ ๋ณ€๊ฒฝ๋ถ„ fail-closed ๊ฐ€๋“œ, ๋Ÿฌ๋„ˆ์˜ `MAM_JOBS_DIR` ์กด์ค‘ | ์™„๋ฃŒ(v4.6.0) | +| 3 | ์ œ์–ด ํ‰๋ฉด ๋ฐ”์ธ๋”ฉ ์˜์†(`control_plane`)ยท๋ช…์‹œ ์ „ํŒŒ(create/resume), stopยทstatusยทreconcile ์˜ `MAM_JOBS_DIR`ยทํ–‰ ๋ฐ”์ธ๋”ฉ ์กด์ค‘ | ์™„๋ฃŒ(v4.6.0). ๋ฃจํ”„ A ์—์„œ ๋ฐ”์ธ๋”ฉ ๊ณ„์‚ฐ ์‹คํŒจ ์ฒ˜๋ฆฌยท์กฐ๊ธฐ ์ข…๋ฃŒ ๊ฒฝ๋กœ ์˜์† ๋ณด๊ฐ• | +| 4 | ์›ŒํฌํŠธ๋ฆฌ ์ค€๋น„ยทํ•ด์ฒด ํ—ฌํผ, resume ์„ ์›๋ž˜ cwd ๋กœ๋งŒ ํ—ˆ์šฉ, create ์˜ reconcile/watchdog ๊ธฐ๋™ ์œ„์น˜, `MAM_NO_SUBSCRIBER_DAEMON`(prepare ๊ฐ€ ์„ค์ •). reconcile ์˜ ๋ฃจํŠธ ๋ฐ– cwd ์žฌ๋ฐœ๊ฒฌ ๊ฑฐ๋ถ€๋Š” ์ œํ•œ์œผ๋กœ ๋‚จ๊น€ | ๋ฃจํ”„ A (์ด ๋ธŒ๋žœ์น˜) | +| 5 | ๋ฆฌ๋ทฐ identity: (loop_id, base_sha, head_sha) ํŒ์ • ๊ธฐ๋กยท๋ฌดํšจํ™”, ๋ฆฌ๋ทฐ์–ด ๊ฒ€์ฆ ๊ฒฉ๋ฆฌ(detached ์›ŒํฌํŠธ๋ฆฌ) | ๋ฃจํ”„ B (์ด ๋ธŒ๋žœ์น˜): ์Šค๋ƒ…์ƒท identityยท์Šค๋ƒ…์ƒท diffยทstale ์žฌ๋ฆฌ๋ทฐยท์Šน๊ฒฉ ์ง์ „ ์žฌํ™•์ธยท`identity.json`(evidence), ์›๊ฒฉ ๋ฆฌ๋ทฐ์–ด ๊ฑฐ๋ถ€/diff-only. ๋ฆฌ๋ทฐ ํ›„์†: dirty ์„œ๋ธŒ๋ชจ๋“ˆ ๊ฑฐ๋ถ€(์Šค๋ƒ…์ƒทยทstale ๊ฒ€์‚ฌ), identity ๋ชจ๋“œ ์…€ํ”„ ๋ฆฌ๋ทฐ๋„ ๋ฆฌ๋ทฐ์–ด ๊ฒฝ๋กœ๋กœ ๊ธฐ๋กยท์Šน๊ฒฉ, ๋ฆฌ๋ทฐ ํ”„๋กฌํ”„ํŠธ์˜ ๊ฒ€์ฆ ๋Œ€์ƒ ๋‹จ์ผํ™” | +| 6 | ์ด ์„ค๊ณ„ ๋ฌธ์„œ ์ •์ • | ์™„๋ฃŒ(v4.6.0), ๋ฃจํ”„ A ์—์„œ ๊ฐฑ์‹  | +| 7 | ์ˆ˜์šฉ ์‚ฌ๋ก€ ๊ณ ์ •(์‹ค์ œ herdr ์˜ pane env ์ „ํŒŒ ํฌํ•จ) | ๋ฃจํ”„ B (์ด ๋ธŒ๋žœ์น˜): ์ˆ˜์šฉ ์‚ฌ๋ก€ 8๊ฑด ํ…Œ์ŠคํŠธ ๊ณ ์ •(`tests/test_issue21_acceptance.py`, A1~A8). pane env ์ „ํŒŒ๋Š” mock herdr ์˜ `--env` ํ˜ธ์ถœ ๋‹จ์–ธ๊นŒ์ง€์ด๋ฉฐ, ์‹ค์ œ herdr pane ์•ˆ์˜ ๊ฐ’ ํ™•์ธ๊ณผ ์‹œํ—˜ ์šด์˜์€ ๋‹จ๊ณ„ C | | 8 | ๋‹ค์ค‘ ๋ฃจํ”„ ๋„ค์ž„์ŠคํŽ˜์ด์Šคยท๋ฆฌ๋ทฐ์–ด ์Šค์ผ€์ค„๋Ÿฌ | ๋ณด๋ฅ˜ | ## 6. ๋ฆฌ๋ทฐ์–ด์—๊ฒŒ ๋ฌป๋Š” ๊ฒƒ @@ -116,12 +123,14 @@ bash "$MAIN/.agents/skills/multi-agent-mux-resume/scripts/resume_session.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) | +| 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`. ๋ฐ”์ธ๋”ฉ ๊ณ„์‚ฐ ์‹คํŒจ ์‹œ ์›ŒํฌํŠธ๋ฆฌ๋ฉด exit 2, ์ œ์–ด ๋ฃจํŠธ๋ฉด ๊ฒฝ๊ณ  | โ€” (๋ฃจํ”„ A: ์ฆ‰์‹œ reconcileยทwatchdog ์€ ์ œ์–ด ๋ฃจํŠธ ์ฝ”๋“œ์™€ `WORKSPACE_ROOT` ๋กœ ๊ธฐ๋™, prepare ๊ฐ€ `MAM_NO_SUBSCRIBER_DAEMON=1`) | +| resume | ํ–‰ ๋ฐ”์ธ๋”ฉ ์šฐ์„ (ํ˜ธ์ถœ์ž env ์™€ ๋‹ค๋ฅด๋ฉด stderr ๊ฒฝ๊ณ ) ์žฌ์ „ํŒŒ. ๋ฐ”์ธ๋”ฉ ์—†๋Š” ๊ธฐ์กด ํ–‰์€ ํ˜ธ์ถœ์ž ๋ฐ”์ธ๋”ฉ์œผ๋กœ ์ฑ„์›€. ์ด๋ฏธ ์‚ด์•„ ์žˆ๋Š” ์„ธ์…˜์—๋„ ๋ฐ”์ธ๋”ฉ์„ ์˜์†ํ•œ๋‹ค. ๊ณ„์‚ฐ ์‹คํŒจ ์‹œ ์›ŒํฌํŠธ๋ฆฌ๋ฉด exit 2 | ์›๋ž˜ cwd ๋กœ๋งŒ: ๊ฒฝ๋กœ ๋ถˆ์ผ์น˜ยท๋ถ€์žฌ ์‹œ exit 2 | ์›ŒํฌํŠธ๋ฆฌ ์‚ญ์ œ ์‹œ ์žฌ๊ฐœ ๋ถˆ๊ฐ€(๋Œ€ํ™” ๋ณด์กด, ๊ฐ™์€ ๊ฒฝ๋กœ์— ๋‹ค์‹œ ๋งŒ๋“ค๋ฉด ์žฌ๊ฐœ) | +| stop | `MAM_JOBS_DIR` > ํ–‰์˜ `mam_jobs_dir` > `$WORKSPACE_ROOT/.mam/jobs` ์ˆœ์œผ๋กœ ์žก ์กฐํšŒยท๋ฐœํ–‰. ๊ฐ์‚ฌ ๋กœ๊ทธ๋„ `DELEGATE_JOB_LOGS_DIR` > ํ–‰์˜ `delegate_job_logs_dir` ์ˆœ | ํ–‰ cwd ๋กœ uuid ์บก์ฒ˜ยทpurge. ์›ŒํฌํŠธ๋ฆฌ ํ•ด์ฒด๋Š” `worktree_remove.sh` ๊ฐ€ ํŒ์ •(์ž ๊ธˆยท์„ธ์…˜ยท์žกยท์ฒญ๊ฒฐยท์„œ๋ธŒ๋ชจ๋“ˆ) ๋’ค์—๋งŒ ์ •์ง€ยท์ œ๊ฑฐ, ์„œ๋ธŒ๋ชจ๋“ˆ ์›ŒํฌํŠธ๋ฆฌ๋Š” ๊ฒ€์ฆ ํ›„ `--force` 1ํšŒ | โ€” | | 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) | +| monitor/reconcile | `MAM_JOBS_DIR` ์šฐ์„ (์ •์  ๊ฐ€๋“œ๋กœ๋งŒ ๊ฒ€์ฆ) | ๊ธฐ์กด ํ–‰์€ ํ–‰ cwd ๋กœ ๊ฒ€์ฆ | ๋ฃจํŠธ ๋ฐ– cwd ์˜ ๋ฏธ๋“ฑ๋ก ์„ธ์…˜์€ ์ž๋™ ๋“ฑ๋ก ์•ˆ ๋จ โ†’ create/prepare ๋กœ๋งŒ ๋“ฑ๋ก (๋‚จ์€ ์ œํ•œ) | | 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`)๋Š” ์ƒˆ ์˜ต์…˜ ๊ฑฐ๋ถ€ | +| loop | `MAM_JOBS_DIR` ์กด์ค‘, ๋ž˜ํผ์— `--registry-dir` ๋ช…์‹œ. `--worktree` ๋กœ ์†Œ์Šค ๋ฃจํŠธ ๋ถ„๋ฆฌ | diffยทbaseยทhead ๋งŒ ์†Œ์Šค ๋ฃจํŠธ. **๊ตฌํ˜„์ด ๋ฃจํ”„ ๋ฐ–์ด๋ฉด `--base <๊ตฌํ˜„ ์ „ sha> --review-only` ํ•„์ˆ˜**. `--worktree`/`--base` ๊ฐ€ ์žˆ์œผ๋ฉด ๋นˆ ๋ณ€๊ฒฝ๋ถ„ ๊ฑฐ๋ถ€ | ๋ฃจํ”„ ํ•˜๋‚˜(8). ์›๊ฒฉ ๋ฆฌ๋ทฐ์–ด๋Š” identity ๋ชจ๋“œ์—์„œ ๊ฑฐ๋ถ€(๊ธฐ๋ณธ) ๋˜๋Š” diff-only(`--remote-review diff-only`); ์›๊ฒฉ ๋ฆฌ๋ทฐ์–ด๊ฐ€ ํŠธ๋ฆฌ๋ฅผ ๊ฒ€์ฆํ•˜๊ฒŒ ํ•˜๋Š” ์Šค๋ƒ…์ƒท ๊ณต์œ (๊ณต์œ  ์›๊ฒฉ์˜ `refs/mam/review//`)๋Š” ๋ฒ”์œ„ ๋ฐ–. ๋ ˆ๊ฑฐ์‹œ ์…ธ ๋ฃจํ”„(`MAM_LOOP_PY=0`)๋Š” ์ƒˆ ์˜ต์…˜ ๊ฑฐ๋ถ€ | -์‹ค์ œ herdr ๊ฐ€ `pane split --env` / `workspace create --env` ๋กœ pane ํ™˜๊ฒฝ์„ ๋„˜๊ธฐ๋Š”์ง€๋Š” 1๋‹จ๊ณ„์—์„œ mock ์ˆ˜์ค€์œผ๋กœ๋งŒ ๊ฒ€์ฆํ–ˆ๋‹ค. ์‹ค์ œ ์ „ํŒŒ๋Š” ํ•ญ๋ชฉ 7 ์˜ ์ˆ˜์šฉ ์‚ฌ๋ก€๋กœ ๊ณ ์ •ํ•œ๋‹ค. +์‹ค์ œ herdr ๊ฐ€ `pane split --env` / `workspace create --env` ๋กœ pane ํ™˜๊ฒฝ์„ ๋„˜๊ธฐ๋Š”์ง€๋Š” mock ์ˆ˜์ค€์œผ๋กœ๋งŒ ๊ฒ€์ฆํ–ˆ๋‹ค. ์ˆ˜์šฉ ์‚ฌ๋ก€ A2 ๋Š” ๋Ÿฌ๋„ˆ๊ฐ€ ๋ฐ”์ธ๋”ฉ 4๊ฐœ๋ฅผ `--env` ๋กœ ์‹ฃ๊ณ  `agent start --` ๋’ค์—๋Š” ์‹ฃ์ง€ ์•Š์Œ์„ ๊ณ ์ •ํ•œ๋‹ค. ์‹ค์ œ herdr pane ์•ˆ์—์„œ ๊ฐ’์ด ๋ณด์ด๋Š”์ง€๋Š” ๋‹จ๊ณ„ C ์‹œํ—˜ ์šด์˜์—์„œ ํ™•์ธํ•œ๋‹ค. + +**ํ˜ธํ™˜์„ฑ ๋ฉ”๋ชจ (๋ฃจํ”„ B)**: 1๋‹จ๊ณ„๋ถ€ํ„ฐ ์žˆ๋˜ `--worktree`/`--base` + ์›๊ฒฉ ๋ฆฌ๋ทฐ์–ด ์กฐํ•ฉ์€ ์ด์ „์—๋Š” ์›๊ฒฉ ๋ฆฌ๋ทฐ์–ด๊ฐ€ ๋„๋‹ฌํ•  ์ˆ˜ ์—†๋Š” ๋กœ์ปฌ ๊ฒฝ๋กœ๋ฅผ ํ”„๋กฌํ”„ํŠธ์— ๋‹ด์•„ ์ง„ํ–‰ํ–ˆ๋‹ค. ์ด์ œ๋Š” ์–ด๋–ค ์žก๋„ ์œ„์ž„ํ•˜๊ธฐ ์ „์— ๊ธฐ๋ณธ์œผ๋กœ ๊ฑฐ๋ถ€ํ•˜๋ฉฐ, `--remote-review diff-only` ๋กœ opt-in ํ•  ์ˆ˜ ์žˆ๋‹ค. ์˜ต์…˜ ์—†๋Š” ๋ฃจํ”„๋Š” ๊ทธ๋Œ€๋กœ๋‹ค. ์Šค๋ƒ…์ƒท ์ปค๋ฐ‹์€ ref ๊ฐ€ ์—†์–ด gc ๊ฐ€ ์ •๋ฆฌํ•˜๊ณ (๊ธฐ๋ณธ 2์ฃผ), ๋ฆฌ๋ทฐ ์›ŒํฌํŠธ๋ฆฌ๋Š” ๋ผ์šด๋“œ๋งˆ๋‹ค ์ง€์šด๋‹ค. diff --git a/tests/_mam_state_guard.py b/tests/_mam_state_guard.py new file mode 100644 index 0000000..e90d23b --- /dev/null +++ b/tests/_mam_state_guard.py @@ -0,0 +1,157 @@ +"""Issue #20 - helpers for the session guard over real control planes. + +A pytest process can reach two kinds of `.mam`: this checkout's own, and the +one the agent pane's control-plane binding named before conftest's +pytest_configure dropped it (a worktree creator's pane carries +WORKSPACE_ROOT, AGENT_SESSIONS_YAML, MAM_JOBS_DIR and DELEGATE_JOB_LOGS_DIR, +all at the live main checkout). The session guard snapshots their job +registries and audit-log roots and, at the end, reports the new entries no +live delegation explains. Pure functions, unit-tested in +test_issue20_state_isolation.py (S4, S5); the name does not start with +`test_`, so pytest never collects it. +""" +from __future__ import annotations + +import json +import os +import re + +SESSION_PREFIXES = ("herdr:", "remote:") +# Top-level items of `herdr_sessions:` as atomic_dump_yaml writes them +# (`- name: ` at column 0); indented `- name:` lines belong to nested +# lists such as mcp_attachments. A regex keeps the guard free of PyYAML. +_SESSION_LINE = re.compile(r"""^- name:\s*['"]?([^'"\s]+)""") + + +def _listdir(path): + try: + return os.listdir(path) + except OSError: + return [] + + +def _load(path): + try: + with open(path, encoding="utf-8") as fh: + data = json.load(fh) + except (OSError, ValueError): + return {} + return data if isinstance(data, dict) else {} + + +def watch_roots(repo_root, recorded): + """`.mam` directories to watch, realpath-deduplicated, in order: this + checkout's own (always, so a leak that creates it is still seen), then + those the recorded binding names - `$WORKSPACE_ROOT/.mam` and the parents + of MAM_JOBS_DIR / DELEGATE_JOB_LOGS_DIR - when they exist.""" + roots = [os.path.realpath(os.path.join(str(repo_root), ".mam"))] + candidates = [] + if recorded.get("WORKSPACE_ROOT"): + candidates.append(os.path.join(recorded["WORKSPACE_ROOT"], ".mam")) + for var in ("MAM_JOBS_DIR", "DELEGATE_JOB_LOGS_DIR"): + if recorded.get(var): + candidates.append(os.path.dirname(os.path.abspath(recorded[var]))) + for path in candidates: + real = os.path.realpath(path) + if os.path.isdir(real) and real not in roots: + roots.append(real) + return roots + + +def state_dirs(roots, recorded): + """(registry dirs, audit-log dirs): `/jobs` and + `/delegate_job_logs` of every root, plus a recorded MAM_JOBS_DIR / + DELEGATE_JOB_LOGS_DIR that is not one of those.""" + jobs = [os.path.join(r, "jobs") for r in roots] + logs = [os.path.join(r, "delegate_job_logs") for r in roots] + for var, dirs in (("MAM_JOBS_DIR", jobs), ("DELEGATE_JOB_LOGS_DIR", logs)): + if recorded.get(var): + real = os.path.realpath(recorded[var]) + if os.path.isdir(real) and real not in dirs: + dirs.append(real) + return jobs, logs + + +def snapshot(roots, recorded): + """Registry records (`*.json`) and audit-log entries per watched dir; a + missing dir counts as empty.""" + jobs, logs = state_dirs(roots, recorded) + return { + "jobs": {d: {n for n in _listdir(d) if n.endswith(".json")} for d in jobs}, + "logs": {d: set(_listdir(d)) for d in logs}, + } + + +def session_names(yaml_path): + """Session names registered in an agent-sessions.yaml; empty if unreadable.""" + try: + with open(yaml_path, encoding="utf-8", errors="replace") as fh: + return {m.group(1) for m in map(_SESSION_LINE.match, fh) if m} + except OSError: + return set() + + +def _bare(session): + for prefix in SESSION_PREFIXES: + if session.startswith(prefix): + return session[len(prefix):] + return session + + +def leaks(roots, before, recorded): + """New entries since `before` that no live delegation explains. + + A real delegation writes its registry record before its audit log and + targets a registered session (registry.register_job), so a new entry is a + leak when + (a) it is an audit log whose job has no record in any watched registry, or + (b) its record's agent_session is missing or names no session in the + watched agent-sessions.yaml files. + Returns [{"path", "reason", "agent_session", "prompt"}] sorted by path.""" + now = snapshot(roots, recorded) + yamls = [os.path.join(r, "agent-sessions.yaml") for r in roots] + if recorded.get("AGENT_SESSIONS_YAML"): + yamls.append(recorded["AGENT_SESSIONS_YAML"]) + known = set() + for path in yamls: + known |= session_names(path) + records = {} + for d, names in now["jobs"].items(): + for name in names: + records.setdefault(name[:-len(".json")], os.path.join(d, name)) + + found = [] + + def judge(path, meta, reason=None): + session = meta.get("agent_session") + if reason is None: + if not session: + reason = "no agent_session" + elif _bare(session) not in known: + reason = "agent_session names no registered session" + else: + return + found.append({"path": path, "reason": reason, "agent_session": session, + "prompt": str(meta.get("prompt", ""))[:60]}) + + for d, names in now["jobs"].items(): + for name in names - before["jobs"].get(d, set()): + path = os.path.join(d, name) + judge(path, _load(path)) + for d, names in now["logs"].items(): + for name in names - before["logs"].get(d, set()): + path = os.path.join(d, name) + if name in records: + judge(path, _load(records[name])) + else: + judge(path, _load(os.path.join(path, "meta.json")), "audit log without a registry record") + return sorted(found, key=lambda f: f["path"]) + + +def describe(found): + """Failure message for the session guard.""" + lines = ["Issue #20: {} entr{} made during this session in a real control plane " + "(left in place; check and remove by hand):".format(len(found), "y" if len(found) == 1 else "ies")] + lines += [" {path} [{reason}] agent_session={agent_session!r} prompt={prompt!r}".format(**f) + for f in found] + return "\n".join(lines) diff --git a/tests/conftest.py b/tests/conftest.py index 32587ad..14ee7ac 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -109,6 +109,74 @@ def _pid_alive(pid): return bool(state) and not state.startswith("Z") +# ============================================================================== +# Issue #20: test state stays out of every real control plane. +# +# An agent pane can carry a control-plane binding: a worktree creator's pane +# gets WORKSPACE_ROOT, AGENT_SESSIONS_YAML, MAM_JOBS_DIR and +# DELEGATE_JOB_LOGS_DIR pointing at the live main checkout (create/resume +# forward the row's control_plane), and each of them wins over a test's own +# WORKSPACE_ROOT (mqtt_common.default_registry_dir/get_logs_dir, lib.sh's +# AGENT_SESSIONS_YAML). pytest_configure records the binding and drops it +# before collection - a session fixture is too late for a module that copies +# os.environ at import (test_orc_onboard.SCRUBBED_ENV). WORKSPACE_ROOT is +# dropped only when it names another checkout; naming this one, it is what +# lib.sh derives anyway. Every test then defaults to a registry and an +# audit-log root under its own tmp_path, and a session guard (next to the #19 +# one) reports new entries in the recorded control planes. +# ============================================================================== +_REPO_ROOT = Path(__file__).resolve().parent.parent +_TESTS_DIR = str(Path(__file__).resolve().parent) +if _TESTS_DIR not in sys.path: + sys.path.insert(0, _TESTS_DIR) +import _mam_state_guard # noqa: E402 + +_BINDING_VARS = ("WORKSPACE_ROOT", "AGENT_SESSIONS_YAML", "MAM_JOBS_DIR", "DELEGATE_JOB_LOGS_DIR") +_RECORDED_BINDING = {} +_DROPPED_BINDING = pytest.StashKey[dict]() + + +def pytest_configure(config): + _RECORDED_BINDING.clear() + _RECORDED_BINDING.update({k: os.environ[k] for k in _BINDING_VARS if os.environ.get(k)}) + dropped = {k: os.environ.pop(k) for k in ("AGENT_SESSIONS_YAML", "MAM_JOBS_DIR", "DELEGATE_JOB_LOGS_DIR") + if k in os.environ} + ws = os.environ.get("WORKSPACE_ROOT") + if ws and os.path.realpath(ws) != str(_REPO_ROOT): + dropped["WORKSPACE_ROOT"] = os.environ.pop("WORKSPACE_ROOT") + config.stash[_DROPPED_BINDING] = dropped + + +def pytest_unconfigure(config): + # Back for callers that run pytest.main() in-process. + os.environ.update(config.stash.get(_DROPPED_BINDING, {})) + + +@pytest.fixture(scope="session") +def _ambient_control_plane(): + """The binding pytest_configure recorded (and dropped), as a copy.""" + return dict(_RECORDED_BINDING) + + +@pytest.fixture(autouse=True) +def _isolate_mam_state_dirs(_ambient_control_plane, tmp_path, monkeypatch): + """Default registry and audit-log roots under this test's tmp_path; only + the env is set, nothing is created. mam_sandbox and a test's own + setenv/delenv run after this autouse fixture and win, and the shared + function-scope monkeypatch undoes them in LIFO order. A hidden directory, + so tests that use tmp_path/"jobs" as their own registry or list tmp_path + are unaffected. Takes _ambient_control_plane to state the order, not to + rely on it.""" + state = tmp_path / ".mam-test-state" + monkeypatch.setenv("MAM_JOBS_DIR", str(state / "jobs")) + monkeypatch.setenv("DELEGATE_JOB_LOGS_DIR", str(state / "delegate_job_logs")) + # Issue #28: a workspace whose real .mam.env names a reachable broker made + # "broker unavailable" tests (T1-4) read it through the wrapper's env loader. + # Point every test at an absent env file; a test that wants a real broker + # config sets MAM_ENV_FILE itself. + monkeypatch.setenv("MAM_ENV_FILE", str(state / ".mam.env.absent")) + + @pytest.fixture def mam_sandbox(tmp_path, monkeypatch, request): """ @@ -1381,10 +1449,27 @@ def _drop_ambient_mam_version(): os.environ["MAM_VERSION"] = previous +@pytest.fixture(scope="session", autouse=True) +def mam_state_leak_guard(_ambient_control_plane): + """Issue #20: no test may leave a record in a real `.mam/jobs` or an audit + log in a real `.mam/delegate_job_logs` - this checkout's, or the control + plane the ambient binding named before pytest_configure dropped it. + Delegations the live system runs meanwhile are told apart by + _mam_state_guard.leaks (record first, registered session). The per-test + defaults are the fix, this is the regression alarm; it reports and never + deletes - these are real control planes. The session registry yaml/db is + not hashed: reconcile and create write it during a run.""" + roots = _mam_state_guard.watch_roots(_REPO_ROOT, _ambient_control_plane) + before = _mam_state_guard.snapshot(roots, _ambient_control_plane) + yield + found = _mam_state_guard.leaks(roots, before, _ambient_control_plane) + assert not found, _mam_state_guard.describe(found) + + @pytest.fixture(scope="session", autouse=True) def subscriber_daemon_leak_guard(tmp_path_factory): """Issue #19: no `reconcile.sh --subscribe` daemon anchored in this session's - pytest basetemp may survive the suite. Mirrors the #15 registry guard: the + pytest basetemp may survive the suite. Mirrors the #20 state guard: the per-sandbox finalizer is the fix, this is the regression alarm. Leftovers are reaped after being reported so a failing run still cleans up.""" yield diff --git a/tests/test_deploy_freshness.py b/tests/test_deploy_freshness.py index 52a1218..faa6365 100644 --- a/tests/test_deploy_freshness.py +++ b/tests/test_deploy_freshness.py @@ -279,12 +279,19 @@ def _resolve_private_server_doc() -> str: PRIVATE_SERVER_DOC_PATH = _resolve_private_server_doc() +# Issue #28: these guards read the nats-docker submodule. A checkout without +# `git submodule update --init` has nothing to check; skip with the reason +# instead of failing on every host that cloned without submodules. +needs_private_server_doc = pytest.mark.skipif( + not os.path.exists(PRIVATE_SERVER_DOC_PATH), + reason="nats-docker submodule not initialized (git submodule update --init)") # -------------------------------------------------------------------------- # D-11 โ€” (G-D1) PRIVATE_SERVER.md must only document MQTT_* environment # variables that broker_config_from_env() actually parses. # -------------------------------------------------------------------------- +@needs_private_server_doc def test_d11_private_server_env_names_valid(): sys.path.insert(0, os.path.join(REPO_ROOT, ".agents", "skills", "multi-agent-mux-delegate-job", "scripts")) import mqtt_common @@ -315,6 +322,7 @@ def test_d11_private_server_env_names_valid(): # D-12 โ€” (G-D2) PRIVATE_SERVER.md must not contain invalid MAM_MQTT_* in # active configuration code blocks. # -------------------------------------------------------------------------- +@needs_private_server_doc def test_d12_private_server_no_mam_mqtt_in_code_fences(): doc_path = PRIVATE_SERVER_DOC_PATH with open(doc_path, "r", encoding="utf-8") as f: @@ -331,6 +339,7 @@ def test_d12_private_server_no_mam_mqtt_in_code_fences(): # D-13 โ€” (G-D3) nats-server launch instructions in PRIVATE_SERVER.md must # use valid config blocks (mqtt {) and not HTTP port flag (-m 1883). # -------------------------------------------------------------------------- +@needs_private_server_doc def test_d13_private_server_nats_config_valid(): doc_path = PRIVATE_SERVER_DOC_PATH with open(doc_path, "r", encoding="utf-8") as f: @@ -347,6 +356,7 @@ def test_d13_private_server_nats_config_valid(): # D-14 โ€” (G-D4) Verification commands in PRIVATE_SERVER.md must use valid # CLI flags matching the actual scripts' argparse parsers. # -------------------------------------------------------------------------- +@needs_private_server_doc def test_d14_private_server_cli_args_valid(): doc_path = PRIVATE_SERVER_DOC_PATH with open(doc_path, "r", encoding="utf-8") as f: @@ -367,6 +377,7 @@ def test_d14_private_server_cli_args_valid(): # D-15 โ€” (G-D5) store_dir in code blocks must be absolute path (/ or $HOME) # and heredocs writing it must be unquoted (< str: DOCKER_DIR = _resolve_docker_dir() COMPOSE_PATH = os.path.join(DOCKER_DIR, "docker-compose.yaml") +needs_docker_assets = pytest.mark.skipif( + not os.path.exists(COMPOSE_PATH), + reason="nats-docker submodule not initialized (git submodule update --init)") NATS_CONF_PATH = os.path.join(DOCKER_DIR, "nats.conf") ENV_EXAMPLE_PATH = os.path.join(DOCKER_DIR, ".env.example") DOCKER_README_PATH = os.path.join(DOCKER_DIR, "README.md") @@ -523,6 +541,7 @@ def _active_conf_lines(): # -------------------------------------------------------------------------- # D-22 โ€” docker/ assets exist and are populated # -------------------------------------------------------------------------- +@needs_docker_assets def test_d22_docker_assets_exist_and_are_populated(): for p in [COMPOSE_PATH, NATS_CONF_PATH, ENV_EXAMPLE_PATH, DOCKER_README_PATH]: assert os.path.exists(p), f"Required asset {p} does not exist" @@ -533,6 +552,7 @@ def test_d22_docker_assets_exist_and_are_populated(): # -------------------------------------------------------------------------- # D-23 โ€” compose image matches doc and is alpine # -------------------------------------------------------------------------- +@needs_docker_assets def test_d23_compose_image_matches_doc_and_is_alpine(): _, svc = _load_compose() compose_img = svc.get("image", "") @@ -552,6 +572,7 @@ def test_d23_compose_image_matches_doc_and_is_alpine(): # -------------------------------------------------------------------------- # D-24 โ€” compose port exposure contract # -------------------------------------------------------------------------- +@needs_docker_assets def test_d24_compose_port_exposure_contract(): _, svc = _load_compose() ports = svc.get("ports", []) @@ -573,6 +594,7 @@ def test_d24_compose_port_exposure_contract(): # -------------------------------------------------------------------------- # D-25 โ€” secrets are fail closed # -------------------------------------------------------------------------- +@needs_docker_assets def test_d25_secrets_are_fail_closed(): _, svc = _load_compose() env = svc.get("environment", {}) @@ -615,6 +637,7 @@ def test_d25_secrets_are_fail_closed(): # -------------------------------------------------------------------------- # D-26 โ€” nats_conf jetstream and mqtt contract # -------------------------------------------------------------------------- +@needs_docker_assets def test_d26_nats_conf_jetstream_and_mqtt_contract(): _, svc = _load_compose() volumes = svc.get("volumes", []) @@ -645,6 +668,7 @@ def test_d26_nats_conf_jetstream_and_mqtt_contract(): # -------------------------------------------------------------------------- # D-27 โ€” observer permissions match topic root # -------------------------------------------------------------------------- +@needs_docker_assets def test_d27_observer_permissions_match_topic_root(): sys.path.insert(0, os.path.join(REPO_ROOT, ".agents", "skills", "multi-agent-mux-delegate-job", "scripts")) import mqtt_common @@ -666,6 +690,7 @@ def test_d27_observer_permissions_match_topic_root(): # -------------------------------------------------------------------------- # D-28 โ€” healthcheck contract and image coupling # -------------------------------------------------------------------------- +@needs_docker_assets def test_d28_healthcheck_contract_and_image_coupling(): _, svc = _load_compose() hc = svc.get("healthcheck") @@ -684,6 +709,7 @@ def test_d28_healthcheck_contract_and_image_coupling(): # -------------------------------------------------------------------------- # D-29 โ€” env secrets never tracked # -------------------------------------------------------------------------- +@needs_docker_assets def test_d29_env_secrets_never_tracked(): is_submodule = os.path.exists(os.path.join(REPO_ROOT, ".gitmodules")) and "nats-docker" in DOCKER_DIR target_repo = os.path.join(REPO_ROOT, "nats-docker") if is_submodule else REPO_ROOT @@ -703,6 +729,7 @@ def test_d29_env_secrets_never_tracked(): # -------------------------------------------------------------------------- # D-30 โ€” websocket origin policy is startable # -------------------------------------------------------------------------- +@needs_docker_assets def test_d30_websocket_origin_policy_is_startable(): active_lines = _active_conf_lines() active_text = "\n".join(active_lines) diff --git a/tests/test_issue14_delivery_ack.py b/tests/test_issue14_delivery_ack.py index 0ad19c9..eec2e3e 100644 --- a/tests/test_issue14_delivery_ack.py +++ b/tests/test_issue14_delivery_ack.py @@ -510,22 +510,29 @@ def test_wrapper_idle_grace_never_outlives_the_wall_clock(tmp_path): import calendar closed_at = calendar.timegm(time.strptime(job_data["updated_at"], "%Y-%m-%dT%H:%M:%SZ")) close_delay = closed_at - started - assert close_delay <= timeout_s + 8, f"idle grace outlived --timeout {timeout_s}s: closed after {close_delay:.0f}s\n{stderr}" + # Under full-suite load the wrapper's final poll, close and registry write + # have landed 8-9 s past the deadline (three full runs); the defect this + # pins was a close at ~2 x TIMEOUT, so a 15 s margin still separates them. + assert close_delay <= timeout_s + 15, f"idle grace outlived --timeout {timeout_s}s: closed after {close_delay:.0f}s\n{stderr}" # --------------------------------------------------------------------------- # publish_event: a heartbeat reaches events.log before the wire publish # --------------------------------------------------------------------------- def _job_for_inprocess_publish(tmp_path, monkeypatch): - """A registered job in a private registry. The audit log resolves against - the cwd, so it lands under tmp_path too, and no real .mam.env is read.""" - from lib_py.delegation import registry + """A registered job in a private registry. Its audit log goes to the + per-test default under tmp_path (conftest, Issue #20): deleting + DELEGATE_JOB_LOGS_DIR here, to fall back to the cwd, fell through to an + ambient WORKSPACE_ROOT instead - the real .mam/delegate_job_logs (PR #22 + rec 4). No real .mam.env is read.""" + from lib_py.delegation import mqtt_common, registry monkeypatch.chdir(tmp_path) - monkeypatch.delenv("DELEGATE_JOB_LOGS_DIR", raising=False) monkeypatch.setenv("MAM_ENV_FILE", str(tmp_path / ".mam.env.absent")) reg_dir = tmp_path / "jobs" jid = registry.register_job("issue 14 heartbeat order", registry_dir=str(reg_dir)) + logs = Path(mqtt_common.get_logs_dir()) + assert tmp_path in logs.parents and (logs / jid).is_dir(), logs return reg_dir, jid diff --git a/tests/test_issue15_registry_isolation.py b/tests/test_issue15_registry_isolation.py index a1f232f..e1b9316 100644 --- a/tests/test_issue15_registry_isolation.py +++ b/tests/test_issue15_registry_isolation.py @@ -16,12 +16,13 @@ import json import os import subprocess import sys +import uuid from pathlib import Path +import _mam_state_guard + REPO_ROOT = Path(__file__).resolve().parent.parent SKILLS = REPO_ROOT / ".agents" / "skills" -REAL_JOBS = REPO_ROOT / ".mam" / "jobs" -REAL_LOGS = REPO_ROOT / ".mam" / "delegate_job_logs" sys.path.insert(0, str(SKILLS)) from lib_py.delegation import mqtt_common # noqa: E402 @@ -67,8 +68,10 @@ def test_logs_dir_follows_the_same_order(monkeypatch, tmp_path): def test_cli_defaults_are_resolved_per_process(monkeypatch, tmp_path): """registry.py / publish_event.py build their argparse defaults at call - time, so a process started with WORKSPACE_ROOT set registers there.""" + time, so a process started with WORKSPACE_ROOT set registers there - and + seeds its audit log there too.""" monkeypatch.delenv("MAM_JOBS_DIR", raising=False) + monkeypatch.delenv("DELEGATE_JOB_LOGS_DIR", raising=False) env = dict(os.environ, WORKSPACE_ROOT=str(tmp_path), PYTHONPATH=f"{SKILLS}:{os.environ.get('PYTHONPATH', '')}") env.pop("MAM_JOBS_DIR", None) res = subprocess.run( @@ -80,6 +83,7 @@ def test_cli_defaults_are_resolved_per_process(monkeypatch, tmp_path): records = list((tmp_path / ".mam" / "jobs").glob("*.json")) assert len(records) == 1, records assert json.loads(records[0].read_text())["agent_session"] == "herdr:issue15-probe" + assert (tmp_path / ".mam" / "delegate_job_logs" / records[0].stem).is_dir() # --------------------------------------------------------------------------- @@ -95,26 +99,65 @@ def _create_session_from_repo_root(sandbox: Path, session: str) -> subprocess.Co ) -def _assert_registered_in_sandbox_only(sandbox: Path, session: str, before_jobs: set, before_logs: set, res): +def _real_state_dirs(recorded: dict) -> list: + """Every real registry and audit-log root this run could leak into: this + checkout's .mam and the control plane the ambient binding named (conftest + records it before dropping it).""" + jobs, logs = _mam_state_guard.state_dirs(_mam_state_guard.watch_roots(REPO_ROOT, recorded), recorded) + return jobs + logs + + +def _entries(dirs: list) -> set: + return {(d, name) for d in dirs for name in _listing(Path(d))} + + +def _unique(prefix: str) -> str: + """A session name no concurrent run shares (Issue #20).""" + return f"{prefix}-{uuid.uuid4().hex[:8]}" + + +def _assert_registered_in_sandbox_only(sandbox: Path, session: str, real_dirs: list, before: set, res): + """Judged on this test's own job ids and session only (Issue #20): live + delegations and other test runs may add entries to the real .mam + meanwhile, so comparing whole listings gave false failures.""" assert res.returncode == 0, res.stderr + res.stdout - assert _listing(REAL_JOBS) == before_jobs, "onboarding job leaked into the repo's .mam/jobs" - assert _listing(REAL_LOGS) == before_logs, "audit log leaked into the repo's .mam/delegate_job_logs" - records = [json.loads(p.read_text()) for p in (sandbox / ".mam" / "jobs").glob("*.json")] - assert any(r.get("agent_session") == f"herdr:{session}" for r in records), records + records = {p.stem: json.loads(p.read_text()) for p in (sandbox / ".mam" / "jobs").glob("*.json")} + own = [jid for jid, r in records.items() if r.get("agent_session") == f"herdr:{session}"] + assert own, records + for d in real_dirs: + for jid in own: + assert not (Path(d) / f"{jid}.json").exists(), f"onboarding job {jid} leaked into {d}" + assert not (Path(d) / jid).exists(), f"audit log {jid} leaked into {d}" + # A job that never reached the sandbox has an id we do not know: look for + # the session in whatever appeared in the real roots meanwhile. + for d, name in _entries(real_dirs) - before: + entry = Path(d) / name + meta = entry if name.endswith(".json") else entry / "meta.json" + try: + session_of = json.loads(meta.read_text()).get("agent_session") + except (OSError, ValueError): + continue + assert session_of != f"herdr:{session}", f"onboarding job for {session} leaked: {entry}" -def test_onboarding_job_stays_in_sandbox_with_explicit_env(mam_sandbox, mock_herdr, mock_agents): - before_jobs, before_logs = _listing(REAL_JOBS), _listing(REAL_LOGS) - res = _create_session_from_repo_root(mam_sandbox, "issue15-explicit-creator-claude") - _assert_registered_in_sandbox_only(mam_sandbox, "issue15-explicit-creator-claude", before_jobs, before_logs, res) +def test_onboarding_job_stays_in_sandbox_with_explicit_env(mam_sandbox, mock_herdr, mock_agents, + _ambient_control_plane): + session = _unique("issue15-explicit-creator-claude") + real_dirs = _real_state_dirs(_ambient_control_plane) + before = _entries(real_dirs) + res = _create_session_from_repo_root(mam_sandbox, session) + _assert_registered_in_sandbox_only(mam_sandbox, session, real_dirs, before, res) -def test_onboarding_job_follows_workspace_root_alone(mam_sandbox, mock_herdr, mock_agents, monkeypatch): +def test_onboarding_job_follows_workspace_root_alone(mam_sandbox, mock_herdr, mock_agents, monkeypatch, + _ambient_control_plane): """Without the explicit MAM_JOBS_DIR / DELEGATE_JOB_LOGS_DIR overrides the WORKSPACE_ROOT that lib.sh exports must be enough to keep the record out of the repo's registry.""" monkeypatch.delenv("MAM_JOBS_DIR", raising=False) monkeypatch.delenv("DELEGATE_JOB_LOGS_DIR", raising=False) - before_jobs, before_logs = _listing(REAL_JOBS), _listing(REAL_LOGS) - res = _create_session_from_repo_root(mam_sandbox, "issue15-wsroot-creator-claude") - _assert_registered_in_sandbox_only(mam_sandbox, "issue15-wsroot-creator-claude", before_jobs, before_logs, res) + session = _unique("issue15-wsroot-creator-claude") + real_dirs = _real_state_dirs(_ambient_control_plane) + before = _entries(real_dirs) + res = _create_session_from_repo_root(mam_sandbox, session) + _assert_registered_in_sandbox_only(mam_sandbox, session, real_dirs, before, res) diff --git a/tests/test_issue18_liveness_contract.py b/tests/test_issue18_liveness_contract.py index 0ef2738..d2839dd 100644 --- a/tests/test_issue18_liveness_contract.py +++ b/tests/test_issue18_liveness_contract.py @@ -25,6 +25,7 @@ from __future__ import annotations import json import os +import re import signal import subprocess import sys @@ -1160,17 +1161,49 @@ def test_acceptance3_lost_heartbeats_close_the_job_and_a_late_completed_is_refus # 6. remote transport (broker): no shared disk, no pane, only the wire # --------------------------------------------------------------------------- +def _skip_if_broker_did_not_deliver(reg: Path, runner_log: Path) -> None: + """acceptance5 runs over a public broker. The remote runner already covers + the idle window with `started --heartbeat` (remote.py handle_dispatch). The + observed failures were of one kind: the runner published started and + completed successfully, the orchestrator had subscribed to the right topic, + and it received nothing at all for the job - the broker did not deliver. + Only that proven non-delivery skips; a drop (HMAC, identity), a wrong + topic, or a runner that failed to publish still fails.""" + log = runner_log.read_text(errors="replace") + m = re.search(r"published started seq=1 job=(\w+)", log) + if not m: + return + jid = m.group(1) + rec = reg / f"{jid}.json" + if not rec.exists(): + return + topic = json.loads(rec.read_text()).get("topic_prefix") or "" + out = reg / f"{jid}.subscriber.out" + sub = out.read_text(errors="replace") if out.exists() else "" + if (topic and f"SUBSCRIBED {topic}/events" in sub + and f"job={jid}" not in sub and f"drop event for job {jid}" not in sub): + pytest.skip(f"public broker did not deliver the runner's events to the orchestrator " + f"(runner published started for {jid}; subscriber received nothing for it)") + + def test_acceptance5_remote_runner_completes_a_job_over_the_broker_only(tmp_path): orch = tmp_path / "orchestrator" remote = tmp_path / "remote-host" for d in (orch, remote): (d / "jobs").mkdir(parents=True) - secret = "issue18-shared-secret" + # A fresh session name and secret per run: dispatches are retained on the + # (public) broker and the runner subscribes to every job's dispatch topic, + # so with fixed values a stale dispatch left by an earlier run was served + # instead of this run's job, which then timed out (observed: runner DONE + # for one job id, the orchestrator waiting on another). + tag = os.urandom(4).hex() + session = f"sim-worker-{tag}" + secret = f"issue18-shared-secret-{tag}" runner_env = _env(remote, {"MAM_DISPATCH_SECRET": secret, "MAM_JOBS_DIR": str(remote / "jobs")}) runner_log = remote / "runner.log" with open(runner_log, "wb") as log: runner = subprocess.Popen( - [sys.executable, str(REMOTE_PY), "run", "--session", "sim-worker", "--registry-dir", str(remote / "jobs"), + [sys.executable, str(REMOTE_PY), "run", "--session", session, "--registry-dir", str(remote / "jobs"), "--exec", 'echo "remote work done for $JOB_ID"', "--once", "--timeout", "150"], cwd=str(remote), env=runner_env, stdout=log, stderr=subprocess.STDOUT) try: @@ -1181,13 +1214,22 @@ def test_acceptance5_remote_runner_completes_a_job_over_the_broker_only(tmp_path pytest.skip("broker unreachable: " + runner_log.read_text(errors="replace")[-300:]) reg = orch / "jobs" - proc, env = _submit(orch, reg, "remote:sim-worker", "remote", - ["--heartbeat", "2", "--heartbeat-misses", "3", "--ack-timeout", "60"], + proc, env = _submit(orch, reg, f"remote:{session}", "remote", + ["--heartbeat", "2", "--heartbeat-misses", "10", "--ack-timeout", "60"], env_extra={"MAM_DISPATCH_SECRET": secret}, timeout_s=120) stdout, stderr = proc.communicate(timeout=180) jid = _wait_job_id(reg) + if proc.returncode != 0: + if ("no CONNACK from broker" in stderr + and "published started" not in runner_log.read_text(errors="replace")): + pytest.skip("broker unreachable when publishing the dispatch (no CONNACK); the runner never saw the job") + _skip_if_broker_did_not_deliver(reg, runner_log) assert proc.returncode == 0, f"rc={proc.returncode}\n{stdout}\n{stderr}\nrunner:\n{runner_log.read_text()}" - assert "subscriber received started" in stderr or "advanced to 'running'" in stderr + # delivery is confirmed by the protocol: the subscriber saw `started`, or + # the registry left pending - to running, or straight to completed when + # the broker delivered only the retained terminal event + assert ("subscriber received started" in stderr or "advanced to 'running'" in stderr + or "advanced to 'completed'" in stderr), stderr assert _job(reg, jid)["status"] == "completed", \ f"the subscriber mirrors the remote terminal into the registry\n{stdout}\n{stderr}\nrunner:\n{runner_log.read_text()}" assert _job(reg, jid)["transport"] == "remote" diff --git a/tests/test_issue20_state_isolation.py b/tests/test_issue20_state_isolation.py new file mode 100644 index 0000000..128bf38 --- /dev/null +++ b/tests/test_issue20_state_isolation.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python3 +"""Issue #20 - test state stays out of every real control plane. + +conftest records the control-plane binding an agent pane carries and drops it +before collection (pytest_configure), gives every test a registry and an +audit-log root under its own tmp_path (_isolate_mam_state_dirs), and a +session guard reports new entries in a real `.mam/jobs` or +`.mam/delegate_job_logs` (mam_state_leak_guard, helpers in _mam_state_guard). +""" +from __future__ import annotations + +import hashlib +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +import _mam_state_guard as guard + +REPO_ROOT = Path(__file__).resolve().parent.parent +SKILLS = REPO_ROOT / ".agents" / "skills" + + +def _fake_control_plane(root: Path, sessions=()) -> Path: + """A checkout-shaped tree with a `.mam` registry, audit-log root and session yaml.""" + mam = root / ".mam" + (mam / "jobs").mkdir(parents=True) + (mam / "delegate_job_logs").mkdir() + rows = "".join(f"- name: {s}\n status: running\n mcp_attachments:\n - name: stitch\n" for s in sessions) + (mam / "agent-sessions.yaml").write_text("herdr_sessions:\n" + rows if rows else "herdr_sessions: []\n") + return root + + +def _record(mam: Path, jid: str, session) -> None: + rec = {"job_id": jid, "prompt": f"prompt of {jid}"} + if session: + rec["agent_session"] = session + (mam / "jobs" / f"{jid}.json").write_text(json.dumps(rec)) + + +def _log(mam: Path, jid: str, session=None) -> None: + d = mam / "delegate_job_logs" / jid + d.mkdir() + if session: + (d / "meta.json").write_text(json.dumps({"job_id": jid, "agent_session": session})) + + +# --------------------------------------------------------------------------- +# the session guard (S4, S5) +# --------------------------------------------------------------------------- +def test_s4_guard_flags_only_entries_no_live_delegation_explains(tmp_path): + main = _fake_control_plane(tmp_path / "main", sessions=("creator-real",)) + mam = main / ".mam" + _record(mam, "old00001", "herdr:nobody") # before the snapshot: not judged + _log(mam, "old00001") + recorded = {"WORKSPACE_ROOT": str(main)} + roots = guard.watch_roots(tmp_path / "repo", recorded) + before = guard.snapshot(roots, recorded) + + _record(mam, "live0001", "herdr:creator-real") # a live delegation: record first, registered session + _log(mam, "live0001", "herdr:creator-real") + _record(mam, "live0002", "remote:creator-real") + _log(mam, "orphan01", "herdr:creator-real") # (a) audit log with no record, even for a known session + _record(mam, "stray001", "herdr:nobody") # (b) unknown session + _record(mam, "stitch01", "herdr:stitch") # (b) `stitch` is an mcp attachment, not a session + _record(mam, "anon0001", None) # (b) no session at all + + found = {Path(f["path"]).name: f for f in guard.leaks(roots, before, recorded)} + assert sorted(found) == ["anon0001.json", "orphan01", "stitch01.json", "stray001.json"], found + assert found["orphan01"]["reason"] == "audit log without a registry record" + assert found["anon0001.json"]["reason"] == "no agent_session" + assert found["stray001.json"]["reason"] == "agent_session names no registered session" + assert found["stray001.json"]["prompt"] == "prompt of stray001" + assert "orphan01" in guard.describe(list(found.values())) + + +def test_s5_watch_roots_follow_the_recorded_binding(tmp_path): + repo = _fake_control_plane(tmp_path / "repo") + main = _fake_control_plane(tmp_path / "main") + (tmp_path / "main-link").symlink_to(main) + own, other = str((repo / ".mam").resolve()), str((main / ".mam").resolve()) + + assert guard.watch_roots(repo, {}) == [own] + # WORKSPACE_ROOT naming this checkout is counted once + assert guard.watch_roots(repo, {"WORKSPACE_ROOT": str(repo)}) == [own] + # a worktree creator's pane: all four at another checkout, reached through a symlink too + pane = {"WORKSPACE_ROOT": str(tmp_path / "main-link"), + "AGENT_SESSIONS_YAML": str(main / ".mam" / "agent-sessions.yaml"), + "MAM_JOBS_DIR": str(main / ".mam" / "jobs"), + "DELEGATE_JOB_LOGS_DIR": str(main / ".mam" / "delegate_job_logs")} + assert guard.watch_roots(repo, pane) == [own, other] + # a recorded root that does not exist is skipped; the checkout's own .mam never is + bare = tmp_path / "bare" + assert guard.watch_roots(bare, {"WORKSPACE_ROOT": str(tmp_path / "gone")}) == [str((bare / ".mam").resolve())] + # a registry outside any /jobs is watched directly + odd = tmp_path / "elsewhere" / "registry" + odd.mkdir(parents=True) + rec = {"MAM_JOBS_DIR": str(odd)} + jobs, logs = guard.state_dirs(guard.watch_roots(repo, rec), rec) + assert str(odd.resolve()) in jobs and str(odd.resolve()) not in logs + + +# --------------------------------------------------------------------------- +# per-test defaults and the dropped binding (S1-S3, S6, S7) +# --------------------------------------------------------------------------- +# Copied at import, like test_orc_onboard.SCRUBBED_ENV: the binding has to be +# gone before collection, not merely before the first test (S6). +_IMPORT_ENV = dict(os.environ) +PROBE_ENV = "MAM_ISSUE20_PROBE" +BINDING = ("WORKSPACE_ROOT", "AGENT_SESSIONS_YAML", "MAM_JOBS_DIR", "DELEGATE_JOB_LOGS_DIR") + + +def _tree(root: Path) -> dict: + return {str(p.relative_to(root)): hashlib.sha256(p.read_bytes()).hexdigest() if p.is_file() else None + for p in sorted(root.rglob("*"))} + + +def _binding(root: Path) -> dict: + """The four variables a worktree creator's pane carries, aimed at `root`.""" + mam = root / ".mam" + return {"WORKSPACE_ROOT": str(root), "AGENT_SESSIONS_YAML": str(mam / "agent-sessions.yaml"), + "MAM_JOBS_DIR": str(mam / "jobs"), "DELEGATE_JOB_LOGS_DIR": str(mam / "delegate_job_logs")} + + +def test_s1_every_test_defaults_to_state_dirs_under_tmp_path(tmp_path, monkeypatch): + from lib_py.delegation import mqtt_common + + state = tmp_path / ".mam-test-state" + assert os.environ["MAM_JOBS_DIR"] == str(state / "jobs") + assert os.environ["DELEGATE_JOB_LOGS_DIR"] == str(state / "delegate_job_logs") + # a WORKSPACE_ROOT on a checkout with a .mam no longer decides where state goes + monkeypatch.setenv("WORKSPACE_ROOT", str(_fake_control_plane(tmp_path / "repo"))) + assert mqtt_common.default_registry_dir() == str(state / "jobs") + assert mqtt_common.get_logs_dir() == str(state / "delegate_job_logs") + + +def test_s2_mam_sandbox_overrides_the_default(mam_sandbox): + assert os.environ["MAM_JOBS_DIR"] == str(mam_sandbox / ".mam" / "jobs") + assert os.environ["DELEGATE_JOB_LOGS_DIR"] == str(mam_sandbox / ".mam" / "delegate_job_logs") + + +def test_s3_submit_with_workspace_root_on_a_checkout_leaves_its_mam_alone(tmp_path): + """The issue's acceptance check in small: WORKSPACE_ROOT names a checkout + with a real-looking .mam and the test sets nothing else.""" + repo = _fake_control_plane(tmp_path / "repo", sessions=("creator-real",)) + before = _tree(repo) + env = dict(os.environ, WORKSPACE_ROOT=str(repo), MAM_ENV_FILE=str(tmp_path / ".mam.env.absent"), + PYTHONPATH=f"{SKILLS}:{os.environ.get('PYTHONPATH', '')}") + res = subprocess.run( + [sys.executable, "-m", "lib_py.delegation.cli", "submit", "--prompt", "issue 20 probe", + "--agent", "claude-code", "--agent-session", "herdr:issue20-probe"], + capture_output=True, text=True, env=env, cwd=str(REPO_ROOT), timeout=60, + ) + assert res.returncode == 0, res.stderr + state = tmp_path / ".mam-test-state" + records = list((state / "jobs").glob("*.json")) + assert len(records) == 1, records + assert (state / "delegate_job_logs" / records[0].stem).is_dir() + assert _tree(repo) == before + + +def test_s6_a_pane_binding_is_dropped_before_collection(tmp_path): + """A pytest started with a worktree creator pane's binding - all four at + another checkout F - sees none of it, not even in a module-level copy of + os.environ, and leaves F untouched.""" + fake_main = _fake_control_plane(tmp_path / "main", sessions=("creator-real",)) + before = _tree(fake_main) + env = dict(os.environ, **_binding(fake_main), **{PROBE_ENV: "1"}) + # An explicit basetemp: the inner run must not clean up the outer one's. + res = subprocess.run( + [sys.executable, "-m", "pytest", f"{__file__}::test_s6_probe", "-q", "-p", "no:cacheprovider", + f"--basetemp={tmp_path / 'inner'}"], + capture_output=True, text=True, env=env, cwd=str(REPO_ROOT), timeout=300, + ) + assert res.returncode == 0, res.stdout[-4000:] + res.stderr[-2000:] + assert "1 passed" in res.stdout, res.stdout[-2000:] + assert _tree(fake_main) == before + + +@pytest.mark.skipif(not os.environ.get(PROBE_ENV), reason="the inner half of S6 (nested pytest) only") +def test_s6_probe(tmp_path): + assert not [k for k in BINDING if k in _IMPORT_ENV], {k: _IMPORT_ENV[k] for k in BINDING if k in _IMPORT_ENV} + assert "AGENT_SESSIONS_YAML" not in os.environ + assert "WORKSPACE_ROOT" not in os.environ # it named another checkout + state = tmp_path / ".mam-test-state" + assert os.environ["MAM_JOBS_DIR"] == str(state / "jobs") + assert os.environ["DELEGATE_JOB_LOGS_DIR"] == str(state / "delegate_job_logs") + + +def test_s7_lib_sh_takes_the_session_registry_from_a_tests_workspace_root(tmp_path): + """An ambient AGENT_SESSIONS_YAML (a worktree pane's) would win over the + test's WORKSPACE_ROOT in lib.sh; conftest dropped it before collection.""" + assert "AGENT_SESSIONS_YAML" not in os.environ + repo = tmp_path / "repo" + repo.mkdir() + script = f'source "{SKILLS / "lib.sh"}" >/dev/null 2>&1; printf %s "$AGENT_SESSIONS_YAML"' + res = subprocess.run(["bash", "-c", script], capture_output=True, text=True, + env=dict(os.environ, WORKSPACE_ROOT=str(repo)), timeout=60) + assert res.stdout == str(repo / ".mam" / "agent-sessions.yaml"), (res.stdout, res.stderr) diff --git a/tests/test_issue21_acceptance.py b/tests/test_issue21_acceptance.py new file mode 100644 index 0000000..7faaa77 --- /dev/null +++ b/tests/test_issue21_acceptance.py @@ -0,0 +1,278 @@ +"""Issue #21 item 7: the eight acceptance cases (A1-A8) of the worktree +creator model, one test (or pair) per case. + +Everything runs against mock herdr / stubbed delegation inside mam_sandbox or +tmp_path; nothing touches the real .mam. The real herdr pane (whether a pane +sees the --env values) and the trial operation belong to stage C - A2 pins +what the scripts hand to herdr. +""" + +from __future__ import annotations + +import json +import os +import re +import subprocess +import time +from pathlib import Path + +from test_issue21_loop_b import LOCAL_ROWS, _drive, _reviews, _sidecars, _state +from test_issue21_phase2_loop_a import (REM, _calls, _prepare, _resume, _rows, _run, _skill, _wt, # noqa: F401 + ctl) +from test_issue21_worktree_prereqs import (CP_ENV, WRAPPER, _assert_no_env_in_agent_args, _binding, _dry_submit, + _env, _env_flags, _injected, _kill_timer, _only_job_id) +from test_issue21_worktree_prereqs import _git as _g +from test_issue21_worktree_prereqs import wt_repo # noqa: F401 + +CREATE = ("multi-agent-mux-create", "scripts", "create_session.sh") +HS = "multi-agent-mux" # listed as running by mock herdr: an existing server + + +def _raw_calls(state_file) -> list: + return json.loads(Path(state_file).read_text()).get("calls", []) + + +def _sibling(main: Path, state_file): + """A1 setup: a session on the existing server in the control root, then a + sibling worktree's creator on the same server. Returns (wt, since, rows before).""" + res = _run(main, CREATE, "--workspace", str(main), "--agent", "claude", "--role", "reviewer", + "--herdr-session", HS, "--no-onboard") + assert res.returncode == 0, res.stdout + res.stderr + before = _rows(main) + since = len(_raw_calls(state_file)) + wt = _wt(main) + res = _prepare(main, wt, "--creator-agent", "claude", "--herdr-session", HS) + assert res.returncode == 0, res.stdout + res.stderr + return wt, since, before + + +# A1 ------------------------------------------------------------------------- + +def test_a1_sibling_worktree_on_an_existing_herdr_server(ctl, mock_herdr, mock_agents): + main = ctl + wt, since, before = _sibling(main, mock_herdr) + new = _raw_calls(mock_herdr)[since:] + assert not [c for c in new if c[-1:] == ["server"]], "no server bootstrap on an existing server" + rows = _rows(main) + assert rows[0] == before[0], "the existing row is untouched" + creator = [r for r in rows if r.get("role") == "creator"] + assert len(rows) == 2 and len(creator) == 1 + assert rows[0].get("herdr_session") == creator[0].get("herdr_session") == HS + assert os.path.realpath(creator[0]["pane"]["cwd"]) == os.path.realpath(wt) + assert creator[0]["control_plane"]["workspace_root"] == os.path.realpath(main) + + +# A2 ------------------------------------------------------------------------- + +def test_a2_pane_env_carries_the_binding_not_the_agent_args(ctl, mock_herdr, mock_agents): + main = ctl + wt, since, _ = _sibling(main, mock_herdr) + flags = _env_flags(mock_herdr, since) + for k, v in zip(CP_ENV, _binding(main).values()): + assert f"{k}={v}" in flags, flags + spawns = [c for c in _calls(mock_herdr) if c[:2] in (["pane", "split"], ["workspace", "create"])] + assert spawns and any("--env" in c for c in spawns), "the binding rides on the pane-creating call" + _assert_no_env_in_agent_args(mock_herdr, since) + + +# A3 ------------------------------------------------------------------------- + +def test_a3_brief_start_and_report_route_to_the_control_root(tmp_path): + main, wt = tmp_path / "main", tmp_path / "wt" + main.mkdir() + _g(main, "init", "-q") + (main / "a.txt").write_text("a\n") + _g(main, "add", "a.txt") + _g(main, "commit", "-q", "-m", "init") + _g(main, "worktree", "add", "-q", "-b", "feat", str(wt)) + real = os.path.realpath(main) + res = _dry_submit(main, _env(tmp_path, {"WORKSPACE_ROOT": main}), "--workdir", str(main)) + assert res.returncode == 0, res.stderr + reg = main / ".mam" / "jobs" + jid = _only_job_id(reg) + assert f"{real}/.mam/jobs/{jid}/brief.md" in res.stdout + start = _injected(res.stdout, "start") + assert f"--registry-dir {real}/.mam/jobs " in start and start.startswith(f"DELEGATE_JOB_LOGS_DIR={real}/") + # the brief (not written by --dry-run) derives its report path from that same + # absolute registry dir, so the report lands under MAIN as well + wrapper = WRAPPER.read_text() + assert "- **Output Report Path**: $REGISTRY_DIR/$JOB_ID/$AGENT-reports/report-final.md" in wrapper + try: + r1 = subprocess.run(["bash", "-c", start + " --attempts 1"], capture_output=True, text=True, + env=_env(tmp_path), cwd=str(wt), timeout=120) # no control env, broker unreachable + assert r1.returncode == 2, r1.stderr + assert json.loads((reg / f"{jid}.json").read_text())["status"] != "pending" + finally: + _kill_timer(reg, jid) + assert not (wt / ".mam").exists(), "nothing lands in the worktree" + + +# A4 ------------------------------------------------------------------------- + +def _main_state(main: Path) -> tuple: + branch = _g(main, "branch", "--show-current") + return (_g(main, "rev-parse", "HEAD"), branch, _g(main, "status", "--porcelain"), + _g(main, "rev-parse", f"refs/heads/{branch}"), _g(main, "worktree", "list", "--porcelain")) + + +def test_a4_main_checkout_is_unchanged_by_the_whole_cycle(ctl, monkeypatch): + main = ctl + # what prepare adds to info/exclude is added up front, so status compares like for like + excl = main / ".git" / "info" / "exclude" + excl.write_text(excl.read_text() + "/.venv\n.pytest_cache/\n") + scratch = main.parent / f"{main.name}-a4" + scratch.mkdir() + pre = _main_state(main) + wt = _wt(main) + res = _prepare(main, wt) + assert res.returncode == 0, res.stdout + res.stderr + base = _g(wt, "rev-parse", "HEAD") + (wt / "impl.txt").write_text("impl line\n") + _g(wt, "add", "impl.txt") + _g(wt, "commit", "-q", "-m", "impl") + _state(monkeypatch, LOCAL_ROWS) + rc, calls, r = _drive(monkeypatch, scratch, ["--worktree", str(wt), "--base", base, "--review-only"]) + assert rc == 0 and len(_reviews(calls)) == 1 + res = _run(main, REM, "--path", str(wt)) + assert res.returncode == 0, res.stdout + res.stderr + assert _main_state(main) == pre, "HEAD, branch, status, branch tip and worktree list are unchanged" + assert "mam-review" not in _g(main, "worktree", "list", "--porcelain") + + +# A5 ------------------------------------------------------------------------- + +def _review_path(prompt: str) -> Path: + m = re.search(r"^- ๊ฒ€์ฆ ๊ฒฝ๋กœ: (.+)$", prompt, re.M) + assert m, prompt[:800] + return Path(m.group(1)) + + +def _commit_h2(wt: Path) -> str: + (wt / "impl.txt").write_text("impl line\nsecond commit\n") + _g(wt, "commit", "-q", "-am", "h2") + return _g(wt, "rev-parse", "HEAD") + + +def test_a5_fixed_sha_review_while_the_creator_keeps_committing(wt_repo, monkeypatch, tmp_path): + main, wt = wt_repo + base = _g(wt, "rev-parse", "HEAD") + (wt / "impl.txt").write_text("impl line\n") + _g(wt, "add", "impl.txt") + _g(wt, "commit", "-q", "-m", "h1") + h1 = _g(wt, "rev-parse", "HEAD") + seen = {} + + def reviewer(r, n, prompt): + if seen: + return + path = _review_path(prompt) + seen["head"] = _g(path, "rev-parse", "HEAD") + seen["symref"] = subprocess.run(["git", "-C", str(path), "symbolic-ref", "-q", "HEAD"]).returncode + seen["content"] = (path / "impl.txt").read_text() + seen["h2"] = _commit_h2(wt) + + rc, calls, r = _drive(monkeypatch, tmp_path, ["--worktree", str(wt), "--base", base, "--review-only"], + on_review=reviewer, max_loop=2) + assert rc == 0 + assert (seen["head"], seen["symref"], seen["content"]) == (h1, 1, "impl line\n"), "round 1 saw H1, detached" + assert [role for _, role, _ in calls] == ["Reviewer", "Reviewer"], "no correction job for a stale round" + assert f"- head: {seen['h2']}" in _reviews(calls)[1][1] + rounds = r._rounds + assert rounds[0]["stale"] is True and rounds[1]["head_sha"] == seen["h2"] + (side,) = _sidecars(main) + data = json.loads(side.read_text()) + assert (data["head_sha"], data["evidence"]) == (seen["h2"], "review-tree") + + +def test_a5b_a_stale_last_round_is_not_accepted(wt_repo, monkeypatch, tmp_path, capsys): + main, wt = wt_repo + base = _g(wt, "rev-parse", "HEAD") + (wt / "impl.txt").write_text("impl line\n") + _g(wt, "add", "impl.txt") + _g(wt, "commit", "-q", "-m", "h1") + rc, calls, r = _drive(monkeypatch, tmp_path, ["--worktree", str(wt), "--base", base, "--review-only"], + on_review=lambda *_: _commit_h2(wt), max_loop=1) + assert rc == 1 and "changed during review" in capsys.readouterr().err + assert _sidecars(main) == [] + + +# A6 ------------------------------------------------------------------------- + +def test_a6_row_recovery_and_resume_only_at_the_original_cwd(ctl, mock_herdr, mock_agents): + main = ctl + wt = _wt(main) + res = _prepare(main, wt, "--creator-agent", "claude") + assert res.returncode == 0, res.stdout + res.stderr + (row,) = _rows(main) + name, binding = row["name"], row["control_plane"] + # the creator died: gone from herdr, the row terminated (reconcile drift A) + st = json.loads(Path(mock_herdr).read_text()) + st["agents"] = {} + Path(mock_herdr).write_text(json.dumps(st)) + from test_issue21_phase2_loop_a import _mutate + _mutate(main, "d['herdr_sessions'][0]['status'] = 'terminated'") + res = _resume(main, main, name) + assert res.returncode == 2 and (str(wt) in res.stderr or os.path.realpath(wt) in res.stderr), res.stderr + since = len(_raw_calls(mock_herdr)) + res = _resume(main, wt, name) + assert res.returncode == 0, res.stdout + res.stderr + (row,) = _rows(main) + assert row["status"] == "running" and row["control_plane"] == binding + flags = _env_flags(mock_herdr, since) + assert f"WORKSPACE_ROOT={binding['workspace_root']}" in flags, flags + + +# A7 ------------------------------------------------------------------------- + +def test_a7_reviewer_test_output_stays_out_of_the_source_and_the_sandbox(wt_repo, monkeypatch, tmp_path): + main, wt = wt_repo + base = _g(wt, "rev-parse", "HEAD") + (wt / "impl.txt").write_text("impl line\n") + status0 = _g(wt, "status", "--porcelain") + paths = [] + + def reviewer_runs_tests(r, n, prompt): + path = _review_path(prompt) + paths.append(path) + (path / ".pytest_cache").mkdir() + (path / ".pytest_cache" / "v").write_text("x\n") + (path / "__pycache__").mkdir() + (path / "__pycache__" / "x.pyc").write_bytes(b"\0") + (path / "tmp-output.txt").write_text("output\n") + + rc, calls, r = _drive(monkeypatch, tmp_path, ["--worktree", str(wt), "--base", base, "--review-only"], + on_review=reviewer_runs_tests) + assert rc == 0 and len(paths) == 1 + assert _g(wt, "status", "--porcelain") == status0, "the source worktree did not change" + assert not paths[0].exists() and str(paths[0]) not in _g(main, "worktree", "list", "--porcelain") + prompt = _reviews(calls)[0][1] + assert "PYTHONDONTWRITEBYTECODE=1" in prompt and "-p no:cacheprovider" in prompt + assert not (wt / ".mam").exists() and not (wt / ".agents").exists(), "state stays in the control root" + assert len(_sidecars(main)) == 1 + + +# A8 ------------------------------------------------------------------------- + +def test_a8_second_loop_is_refused_before_a_scheduler_exists(ctl): + main = ctl + wt2 = _wt(main, "wt2") + _g(main, "worktree", "add", "-q", "--detach", str(wt2)) + holder = subprocess.Popen(["sleep", "60"]) + try: + lstart = subprocess.run(["ps", "-p", str(holder.pid), "-o", "lstart="], capture_output=True, + text=True).stdout.strip() + marker = main.parent / f"{main.name}-loop-marker" + content = f"pid={holder.pid}\nlstart={lstart}\nstarted={time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())}\n" + marker.write_text(content) + env = {**os.environ, "WORKSPACE_ROOT": str(main), "MAM_LOOP_MARKER": str(marker), "MAM_LOOP_NO_FREEZE": "1"} + env.pop("MAM_LOOP_PY", None) + res = subprocess.run(["bash", _skill(main, "multi-agent-mux-loop", "scripts", "run_loop.sh"), + "--creator", "c", "--task", "t", "--worktree", str(wt2), "--base", "HEAD", + "--review-only", "--reviewer", "r"], + capture_output=True, text=True, env=env, cwd=str(main), timeout=120) + assert res.returncode == 1 and "already running" in (res.stdout + res.stderr), res.stdout + res.stderr + assert list((main / ".mam" / "jobs").glob("*.json")) == [], "no job was registered" + assert marker.read_text() == content, "the live holder's marker is kept" + finally: + holder.kill() + holder.wait() diff --git a/tests/test_issue21_loop_b.py b/tests/test_issue21_loop_b.py new file mode 100644 index 0000000..1e426c2 --- /dev/null +++ b/tests/test_issue21_loop_b.py @@ -0,0 +1,535 @@ +"""Issue #21 phase 2, loop B, item 5: review identity. + +With --worktree/--base every review round is pinned to a snapshot of the +source (loop_id, base, head, tree), reviewed in a detached review worktree (or +env isolation), judged stale when the source changes during the round, and +re-checked before promotion. Remote reviewers cannot reach this host's review +tree: refused by default, admitted diff-only on request, recorded as such. +Without the options nothing changes. All repositories live under tmp_path. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +from test_issue21_worktree_prereqs import GIT_ID, RUN_LOOP_SH, SKILLS, _git, wt_repo # noqa: F401 + +DIFF_SH = SKILLS / "multi-agent-mux-loop" / "scripts" / "diff_collect.sh" +REMOTE_ROW = {"name": "reviewer-far-01", "agent": "claude", "role": "reviewer", "status": "running", + "transport": "remote"} +LOCAL_ROWS = [{"name": "creator-claude-01", "agent": "claude", "role": "creator", "status": "running"}, + {"name": "reviewer-claude-01", "agent": "claude", "role": "reviewer", "status": "running"}] +WORK_TAIL = "์ฝ”๋“œ ์ž‘์—…ยท๊ฒ€์ฆ ๋Œ€์ƒ์€ ์œ„ ์†Œ์Šค ๊ฒฝ๋กœ๋‹ค" + + +def _state(monkeypatch, rows): + monkeypatch.setenv("MAM_STATE_JSON", json.dumps({"herdr_sessions": rows})) + + +def _drive(monkeypatch, tmp_path, argv, on_review=None, reviewers="reviewer-claude-01", max_loop=1): + """run() with delegation stubbed: every job completes, every report is a + PASS; returns (rc, [(session, role, prompt)], runner).""" + from lib_py.loop import runner as lr + monkeypatch.setattr(lr.signal, "signal", lambda *a, **k: None) + args = ["--creator", "creator-claude-01", "--task", "t21", "--max-loop", str(max_loop), *argv] + if reviewers: + args += ["--reviewer", reviewers] + r = lr.LoopRunner(args) + 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((session, role, prompt)) + if role == "Reviewer" and on_review: + on_review(r, len(calls), prompt) + 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 _reviews(calls): + return [(s, p) for s, role, p in calls if role == "Reviewer"] + + +def _impl(wt: Path) -> str: + """Commit an implementation in the worktree; return 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 _worktree_count(main: Path) -> int: + return _git(main, "worktree", "list", "--porcelain").count("\nworktree ") + 1 + + +def _sidecars(main: Path) -> list: + return sorted((main / ".agents" / "reports").rglob("*.identity.json")) + + +# --------------------------------------------------------------------------- + +def test_l1_without_options_nothing_changes(wt_repo, monkeypatch, tmp_path): + main, _ = wt_repo + (main / "a.txt").write_text("a\nchanged in main\n") + n = _worktree_count(main) + rc, calls, r = _drive(monkeypatch, tmp_path, []) + assert rc == 0 + assert all("[๋ฆฌ๋ทฐ identity]" not in p and "[์›ŒํฌํŠธ๋ฆฌ ์ปจํ…์ŠคํŠธ]" not in p for _, _, p in calls) + assert _worktree_count(main) == n + assert r.loop_id.startswith("loop-") and r._rounds == [] + assert _sidecars(main) == [] + + +def test_l2_snapshot_pins_uncommitted_and_untracked_not_ignored(wt_repo): + main, wt = wt_repo + (wt / ".gitignore").write_text("*.log\n") + _git(wt, "add", ".gitignore") + _git(wt, "commit", "-q", "-m", "ignore logs") + (wt / "a.txt").write_text("a\nmodified\n") + (wt / "new.txt").write_text("untracked\n") + (wt / "x.log").write_text("ignored\n") + status0 = _git(wt, "status", "--porcelain") + refs0 = _git(wt, "for-each-ref") + from lib_py.loop.runner import LoopRunner + r = LoopRunner(["--creator", "creator-claude-01", "--task", "t", "--worktree", str(wt)]) + r.parse_args() + r.base_commit = _git(wt, "rev-parse", "HEAD") + snap = r._snapshot(1) + assert snap and snap["dirty"] and snap["review_commit"] != snap["head"] + assert r._worktree_tree() == snap["tree"], "the snapshot tree is stable for an unchanged tree" + path = Path(r._open_review_tree(snap, 1)) + try: + assert (path / "new.txt").read_text() == "untracked\n" + assert "modified" in (path / "a.txt").read_text() + assert not (path / "x.log").exists(), "ignored files stay out of the snapshot" + assert _git(path, "rev-parse", "HEAD") == snap["review_commit"] + finally: + r._close_review_tree(str(path)) + assert not path.exists() + assert _git(wt, "status", "--porcelain") == status0 and _git(wt, "for-each-ref") == refs0 + + +def test_l3_l5_stale_round_is_re_reviewed_and_the_sidecar_matches(wt_repo, monkeypatch, tmp_path): + main, wt = wt_repo + before = _impl(wt) + seen = {} + + def creator_commits_during_first_review(r, n, prompt): + if "h1" not in seen: + seen["h1"] = _git(wt, "rev-parse", "HEAD") + (wt / "late.txt").write_text("late\n") + _git(wt, "add", "late.txt") + _git(wt, "commit", "-q", "-m", "late") + seen["h2"] = _git(wt, "rev-parse", "HEAD") + + rc, calls, r = _drive(monkeypatch, tmp_path, ["--worktree", str(wt), "--base", before, "--review-only"], + on_review=creator_commits_during_first_review, max_loop=2) + assert rc == 0 + assert [role for _, role, _ in calls] == ["Reviewer", "Reviewer"], "no correction job for a stale round" + assert r._rounds[0]["stale"] is True and r._rounds[1]["stale"] is False + assert r._rounds[1]["head_sha"] == seen["h2"] + assert f"- head: {seen['h2']}" in _reviews(calls)[1][1] + (side,) = _sidecars(main) + data = json.loads(side.read_text()) + assert data["loop_id"] == r.loop_id and data["head_sha"] == seen["h2"] + assert data["tree_sha"] == r._rounds[1]["tree_sha"] and data["base_sha"] == before + assert (data["transport"], data["evidence"], data["isolation"]) == ("herdr", "review-tree", "worktree") + + +def test_l3b_stale_last_round_fails(wt_repo, monkeypatch, tmp_path, capsys): + main, wt = wt_repo + before = _impl(wt) + + def commit(r, n, prompt): + (wt / f"late{n}.txt").write_text("late\n") + _git(wt, "add", ".") + _git(wt, "commit", "-q", "-m", "late") + + rc, calls, r = _drive(monkeypatch, tmp_path, ["--worktree", str(wt), "--base", before, "--review-only"], + on_review=commit, max_loop=1) + assert rc == 1 and "changed during review" in capsys.readouterr().err + assert _sidecars(main) == [] + + +def test_l4_source_changed_after_approval_is_not_promoted(wt_repo, monkeypatch, tmp_path, capsys): + main, wt = wt_repo + before = _impl(wt) + from lib_py.loop import runner as lr + real = lr.LoopRunner._identity_now + state = {"accepted": False} + + def identity_now(self): + return ("changed", "changed") if state["accepted"] else real(self) + + def mark(r, n, prompt): + state["accepted"] = False + + monkeypatch.setattr(lr.LoopRunner, "_identity_now", identity_now) + monkeypatch.setattr(lr.LoopRunner, "_round_is_stale", + lambda self, snap: state.__setitem__("accepted", True) or False) + rc, calls, r = _drive(monkeypatch, tmp_path, ["--worktree", str(wt), "--base", before, "--review-only"], + on_review=mark) + assert rc == 1 and "source changed after approval" in capsys.readouterr().err + assert not list((main / ".agents" / "reports").rglob("report-*.md")) + + +def test_l6_env_isolation_reviews_the_source_tree(wt_repo, monkeypatch, tmp_path): + main, wt = wt_repo + before = _impl(wt) + n = _worktree_count(main) + counts = [] + rc, calls, r = _drive(monkeypatch, tmp_path, + ["--worktree", str(wt), "--base", before, "--review-only", "--review-isolation", "env"], + on_review=lambda r, i, p: counts.append(_worktree_count(main))) + assert rc == 0 and counts == [n], "env isolation opens no review worktree" + prompt = _reviews(calls)[0][1] + assert f"- ๊ฒ€์ฆ ๊ฒฝ๋กœ: {os.path.realpath(wt)}" in prompt and "PYTHONDONTWRITEBYTECODE=1" in prompt + assert WORK_TAIL not in prompt and "๊ฒ€์ฆ ๊ฒฝ๋กœ(= ์œ„ ์†Œ์Šค ๊ฒฝ๋กœ)" in prompt, "one verification target (B-2)" + data = json.loads(_sidecars(main)[0].read_text()) + assert (data["evidence"], data["isolation"]) == ("source-tree", "env") + + +def test_l7_review_tree_failure_refuses_without_lowering_isolation(wt_repo, monkeypatch, tmp_path, capsys): + main, wt = wt_repo + before = _impl(wt) + from lib_py.loop import runner as lr + real_git = lr.LoopRunner._git + + def git(cwd, *args): + if args[:2] == ("worktree", "add"): + return subprocess.CompletedProcess(args, 128, "", "fatal: simulated") + return real_git(cwd, *args) + + monkeypatch.setattr(lr.LoopRunner, "_git", staticmethod(git)) + rc, calls, r = _drive(monkeypatch, tmp_path, ["--worktree", str(wt), "--base", before, "--review-only"]) + assert rc == 1 and _reviews(calls) == [] + assert "cannot open the review worktree" in capsys.readouterr().err + + +def test_l8_release_guard_closes_open_review_trees(wt_repo): + main, wt = wt_repo + (wt / "a.txt").write_text("a\nx\n") + from lib_py.loop.runner import LoopRunner + r = LoopRunner(["--creator", "creator-claude-01", "--task", "t", "--worktree", str(wt)]) + r.parse_args() + r.base_commit = _git(wt, "rev-parse", "HEAD") + path = r._open_review_tree(r._snapshot(1), 1) + assert os.path.isdir(path) + r.release_guard() + assert not os.path.exists(path) and path not in _git(main, "worktree", "list") + + +@pytest.mark.parametrize("opt", [["--review-isolation", "env"], ["--remote-review", "diff-only"]]) +def test_l9_l16_legacy_shell_loop_refuses_the_new_options(tmp_path, opt): + marker = tmp_path / "loop-marker" + env = {**os.environ, "WORKSPACE_ROOT": str(tmp_path), "MAM_LOOP_PY": "0", "MAM_LOOP_NO_FREEZE": "1", + "MAM_LOOP_MARKER": str(marker)} + res = subprocess.run(["bash", str(RUN_LOOP_SH), "--creator", "c", "--task", "t", *opt], + capture_output=True, text=True, env=env, cwd=str(tmp_path), timeout=60) + assert res.returncode == 1 and "require the Python loop (MAM_LOOP_PY=1)" in res.stderr + assert not marker.exists() + + +def test_l9b_invalid_values_are_rejected(wt_repo): + from lib_py.loop.runner import LoopRunner + for opt in (["--review-isolation", "bogus"], ["--remote-review", "maybe"]): + with pytest.raises(SystemExit) as e: + LoopRunner(["--creator", "c", "--task", "t", *opt]).parse_args() + assert e.value.code == 1 + + +@pytest.mark.parametrize("variant", ["reviewer", "all-reviewer", "env", "self-review"]) +def test_l10_remote_reviewer_refused_before_any_delegation(wt_repo, monkeypatch, tmp_path, capsys, variant): + main, wt = wt_repo + before = _impl(wt) + rows = LOCAL_ROWS + [REMOTE_ROW] + argv = ["--worktree", str(wt), "--base", before, "--review-only"] + reviewers = "reviewer-far-01" + if variant == "all-reviewer": + argv.append("--all-reviewer") + reviewers = None + elif variant == "env": + argv += ["--review-isolation", "env"] + elif variant == "self-review": + rows = [dict(LOCAL_ROWS[0], transport="remote")] + reviewers = None + _state(monkeypatch, rows) + rc, calls, r = _drive(monkeypatch, tmp_path, argv, reviewers=reviewers) + err = capsys.readouterr().err + assert rc == 1 and calls == [], "nothing is delegated" + assert "--remote-review diff-only" in err + assert ("creator-claude-01" if variant == "self-review" else "reviewer-far-01") in err + + +def test_l11_diff_only_remote_reviewer_gets_shas_and_the_snapshot_diff(wt_repo, monkeypatch, tmp_path): + main, wt = wt_repo + before = _impl(wt) + (wt / "a.txt").write_text("a\nuncommitted change\n") + _state(monkeypatch, LOCAL_ROWS + [REMOTE_ROW]) + rc, calls, r = _drive(monkeypatch, tmp_path, + ["--worktree", str(wt), "--base", before, "--review-only", "--remote-review", "diff-only"], + reviewers="reviewer-claude-01,reviewer-far-01") + assert rc == 0 + prompts = dict(_reviews(calls)) + remote, local = prompts["reviewer-far-01"], prompts["reviewer-claude-01"] + snap = r._rounds[0] + assert os.path.realpath(wt) not in remote and "๊ฒ€์ฆ ๊ฒฝ๋กœ" not in remote and "mam-review" not in remote + assert f"- head: {snap['head_sha']}" in remote and f"- tree: {snap['tree_sha']}" in remote + assert "diff ๋งŒ์œผ๋กœ" in remote and "+uncommitted change" in remote and "+impl line" in remote + assert "- ๊ฒ€์ฆ ๊ฒฝ๋กœ: " in local and "mam-review" in local + assert WORK_TAIL not in local and "[๋ฆฌ๋ทฐ identity] ์˜ ๊ฒ€์ฆ ๊ฒฝ๋กœ์—์„œ๋งŒ" in local, "one verification target (B-2)" + assert snap["reviewers"]["reviewer-far-01"]["evidence"] == "diff-only" + assert snap["reviewers"]["reviewer-claude-01"]["evidence"] == "review-tree" + side = {json.loads(p.read_text())["reviewer"]: json.loads(p.read_text()) for p in _sidecars(main)} + assert (side["reviewer-far-01"]["evidence"], side["reviewer-far-01"]["isolation"], + side["reviewer-far-01"]["transport"]) == ("diff-only", "none (remote)", "remote") + assert side["reviewer-claude-01"]["evidence"] == "review-tree" + + +def test_l12_remote_reviewer_without_options_works_as_before(wt_repo, monkeypatch, tmp_path, capsys): + main, _ = wt_repo + (main / "a.txt").write_text("a\nchanged\n") + _state(monkeypatch, LOCAL_ROWS + [REMOTE_ROW]) + rc, calls, r = _drive(monkeypatch, tmp_path, ["--remote-review", "diff-only"], reviewers="reviewer-far-01") + assert rc == 0 and [s for s, _ in _reviews(calls)] == ["reviewer-far-01"] + assert "[๋ฆฌ๋ทฐ identity]" not in _reviews(calls)[0][1] and _sidecars(main) == [] + assert "only apply with --worktree or --base" in capsys.readouterr().out + + +def _diff_collect(repo, *args, env=None): + cmd = "source " + str(DIFF_SH) + " && mam_collect_changes_diff " + " ".join(f"'{a}'" for a in (repo, *args)) + return subprocess.run(["bash", "-c", cmd], capture_output=True, text=True, env={**os.environ, **(env or {})}) + + +def test_l13_snapshot_diff_is_commit_to_commit(wt_repo, monkeypatch, tmp_path): + main, wt = wt_repo + base = _git(wt, "rev-parse", "HEAD") + (wt / ".gitignore").write_text("*.log\n") + (wt / "a.txt").write_text("a\nmodified\n") + (wt / "new.txt").write_text("untracked\n") + (wt / "x.log").write_text("ignored\n") + from lib_py.loop.runner import LoopRunner + r = LoopRunner(["--creator", "creator-claude-01", "--task", "t", "--worktree", str(wt)]) + r.parse_args() + r.base_commit = base + snap = r._snapshot(1) + out = _diff_collect(wt, base, snap["review_commit"]) + assert out.returncode == 0 and "+modified" in out.stdout and "+untracked" in out.stdout + assert "x.log" not in out.stdout + bad = _diff_collect(wt, base, "0" * 40) + assert bad.returncode == 2 and "CHANGE SET UNAVAILABLE" in bad.stdout + cut = _diff_collect(wt, base, snap["review_commit"], env={"MAM_DIFF_MAX_LINES": "1"}) + assert "DIFF TRUNCATED" in cut.stdout and f"inspect the review snapshot ({snap['review_commit']})" in cut.stdout + # the two-argument form is unchanged: same output as the script before this change + old = subprocess.run(["git", "-C", str(SKILLS.parent.parent), "show", "25f4be3:.agents/skills/multi-agent-mux-loop/scripts/diff_collect.sh"], + capture_output=True, text=True) + new2 = _diff_collect(wt, base) + assert new2.returncode == 0 and "+modified" in new2.stdout and "+untracked" in new2.stdout + if old.returncode == 0 and old.stdout: # needs git history (absent in a `git archive` export) + old_sh = tmp_path / "diff_collect_old.sh" + old_sh.write_text(old.stdout) + old2 = subprocess.run(["bash", "-c", f"source '{old_sh}' && mam_collect_changes_diff '{wt}' '{base}'"], + capture_output=True, text=True) + assert (new2.returncode, new2.stdout) == (old2.returncode, old2.stdout) + + +def test_l13b_prompt_diff_comes_from_the_snapshot(wt_repo, monkeypatch, tmp_path): + main, wt = wt_repo + before = _impl(wt) + from lib_py.loop import runner as lr + real = lr.LoopRunner._snapshot + + def snapshot_then_edit(self, n): + s = real(self, n) + (wt / "after-snapshot.txt").write_text("written after the snapshot\n") + return s + + monkeypatch.setattr(lr.LoopRunner, "_snapshot", snapshot_then_edit) + rc, calls, r = _drive(monkeypatch, tmp_path, ["--worktree", str(wt), "--base", before, "--review-only"]) + prompt = _reviews(calls)[0][1] + assert "+impl line" in prompt and "after-snapshot.txt" not in prompt + assert rc == 1, "the source changed after the snapshot: the round is stale" + + +def test_l14_untracked_nested_repository_is_refused(wt_repo, monkeypatch, tmp_path, capsys): + main, wt = wt_repo + before = _impl(wt) + nested = wt / "nested" + nested.mkdir() + _git(nested, "init", "-q") + (nested / "n.txt").write_text("n\n") + _git(nested, "add", "n.txt") + _git(nested, "commit", "-q", "-m", "n") + rc, calls, r = _drive(monkeypatch, tmp_path, ["--worktree", str(wt), "--base", before, "--review-only"]) + assert rc == 1 and _reviews(calls) == [] + assert "untracked nested git repository nested" in capsys.readouterr().err + + +def test_l14b_nested_repository_without_options_proceeds(wt_repo, monkeypatch, tmp_path): + main, _ = wt_repo + nested = main / "nested" + nested.mkdir() + _git(nested, "init", "-q") + rc, calls, r = _drive(monkeypatch, tmp_path, []) + assert rc == 0 and len(_reviews(calls)) == 1 + + +def test_l15_truncated_diff_refuses_diff_only_but_not_local(wt_repo, monkeypatch, tmp_path, capsys): + main, wt = wt_repo + before = _impl(wt) + # the runner uses the control root's collector (the python fallback never truncates) + dst = main / ".agents" / "skills" / "multi-agent-mux-loop" / "scripts" + dst.mkdir(parents=True) + (dst / "diff_collect.sh").write_text(DIFF_SH.read_text()) + monkeypatch.setenv("MAM_DIFF_MAX_LINES", "1") + _state(monkeypatch, LOCAL_ROWS + [REMOTE_ROW]) + argv = ["--worktree", str(wt), "--base", before, "--review-only", "--remote-review", "diff-only"] + rc, calls, r = _drive(monkeypatch, tmp_path, argv, reviewers="reviewer-claude-01,reviewer-far-01") + assert rc == 1 and _reviews(calls) == [] and "would have no evidence" in capsys.readouterr().err + rc, calls, r = _drive(monkeypatch, tmp_path, argv, reviewers="reviewer-claude-01") + assert rc == 0 and len(_reviews(calls)) == 1 + + +# --------------------------------------------------------------------------- +# loop B review follow-up: B-2, B-R1..B-R3, N1, N3, N5 +# --------------------------------------------------------------------------- + +def _add_submodule(wt: Path, tmp_path: Path) -> None: + src = tmp_path / "sm-src" + src.mkdir() + _git(src, "init", "-q") + (src / "x.txt").write_text("original\n") + _git(src, "add", "x.txt") + _git(src, "commit", "-q", "-m", "initial") + _git(wt, "-c", "protocol.file.allow=always", "submodule", "add", "-q", str(src), "sm") + _git(wt, "commit", "-q", "-m", "add sm") + + +@pytest.mark.parametrize("when", ["before", "before-ignored", "untracked", "untracked-hidden", "during", "during-hidden"]) +def test_l17_dirty_submodule_is_refused_or_makes_the_round_stale(wt_repo, monkeypatch, tmp_path, capsys, when): + """The -hidden variants (PR #29 review R2): status.showUntrackedFiles=no in + the submodule must not let an untracked file past the snapshot guard, + neither before the first review nor when it appears mid-round.""" + main, wt = wt_repo + base = _impl(wt) + _add_submodule(wt, tmp_path) + if when == "before-ignored": # no ignore setting hides the dirty content + _git(wt, "config", "submodule.sm.ignore", "all") + _git(wt, "config", "diff.ignoreSubmodules", "all") + if when.endswith("-hidden"): + _git(wt / "sm", "config", "status.showUntrackedFiles", "no") + + def dirty(*_): + (wt / "sm" / "x.txt").write_text("unreviewed work\n") + + def untracked(*_): + (wt / "sm" / "new.txt").write_text("untracked in the submodule\n") + + if when.startswith("before"): + dirty() + elif when.startswith("untracked"): + untracked() + on_review = {"during": dirty, "during-hidden": untracked}.get(when) + rc, calls, r = _drive(monkeypatch, tmp_path, ["--worktree", str(wt), "--base", base, "--review-only"], + on_review=on_review) + assert rc == 1 and _sidecars(main) == [] + err = capsys.readouterr().err + assert "submodule(s) with uncommitted or untracked content: sm" in err + if when.startswith("during"): + assert len(_reviews(calls)) == 1 and r._rounds[0]["stale"] is True + else: + assert _reviews(calls) == [], "refused before any review" + + +def test_l17b_clean_submodule_is_reviewed(wt_repo, monkeypatch, tmp_path): + main, wt = wt_repo + base = _impl(wt) + _add_submodule(wt, tmp_path) + rc, calls, r = _drive(monkeypatch, tmp_path, ["--worktree", str(wt), "--base", base, "--review-only"]) + assert rc == 0 and len(_sidecars(main)) == 1 + + +def test_l18_self_review_is_a_recorded_verdict_and_not_pass_re_reviews(wt_repo, monkeypatch, tmp_path): + main, wt = wt_repo + base = _impl(wt) + report = tmp_path / "pass-report.md" + + def verdicts(r, n, prompt): + report.write_text("[VERDICT: NOT PASS]\n" if n == 1 else "[VERDICT: PASS]\n") + + rc, calls, r = _drive(monkeypatch, tmp_path, ["--worktree", str(wt), "--base", base, "--review-only"], + on_review=verdicts, reviewers=None, max_loop=2) + assert rc == 0 + assert [(s, role) for s, role, _ in calls] == [("creator-claude-01", "Reviewer")] * 2, \ + "a NOT PASS self-review is re-reviewed, no correction job" + prompt = _reviews(calls)[1][1] + assert "Self-Review" in prompt and "+impl line" in prompt and "[๋ฆฌ๋ทฐ identity]" in prompt + assert r._rounds[0]["verdicts"] == {"creator-claude-01": "NOT PASS"} + (side,) = _sidecars(main) + data = json.loads(side.read_text()) + assert (data["reviewer"], data["loop_id"], data["head_sha"]) == ("creator-claude-01", r.loop_id, + _git(wt, "rev-parse", "HEAD")) + assert data["evidence"] == "review-tree" + + +def test_l18b_self_review_without_options_is_unchanged(wt_repo, monkeypatch, tmp_path): + main, _ = wt_repo + (main / "a.txt").write_text("a\nchanged\n") + rc, calls, r = _drive(monkeypatch, tmp_path, [], reviewers=None) + (review,) = _reviews(calls) + assert rc == 0 and review[0] == "creator-claude-01" + assert review[1].startswith("์ž‘์—… ์™„๋ฃŒ ์ƒํƒœ์— ๋Œ€ํ•ด ์Šค์Šค๋กœ ๊ฒ€์ฆ") and "๋ณ€๊ฒฝ๋ถ„" not in review[1] + assert _sidecars(main) == [] + + +def test_l19_remote_self_review_gets_the_snapshot_diff(wt_repo, monkeypatch, tmp_path): + main, wt = wt_repo + base = _impl(wt) + _state(monkeypatch, [dict(LOCAL_ROWS[0], transport="remote")]) + rc, calls, r = _drive(monkeypatch, tmp_path, + ["--worktree", str(wt), "--base", base, "--review-only", "--remote-review", "diff-only"], + reviewers=None) + assert rc == 0 + prompt = _reviews(calls)[0][1] + assert "diff --git" in prompt and "+impl line" in prompt and "๊ฒ€์ฆ ๊ฒฝ๋กœ" not in prompt + data = json.loads(_sidecars(main)[0].read_text()) + assert (data["reviewer"], data["evidence"]) == ("creator-claude-01", "diff-only") + + +def test_l20_snapshot_diff_failure_keeps_the_unavailable_marker(wt_repo): + main, wt = wt_repo + dst = main / ".agents" / "skills" / "multi-agent-mux-loop" / "scripts" + dst.mkdir(parents=True) + (dst / "diff_collect.sh").write_text("mam_collect_changes_diff() { return 1; }\n") + from lib_py.loop.runner import UNAVAILABLE_MARKER, LoopRunner + r = LoopRunner(["--creator", "creator-claude-01", "--task", "t", "--worktree", str(wt)]) + r.parse_args() + r.base_commit = _git(wt, "rev-parse", "HEAD") + assert UNAVAILABLE_MARKER in r.collect_changes_diff(r.base_commit) + assert UNAVAILABLE_MARKER not in r.collect_changes_diff(), "the live-tree form keeps its git fallback" + + +def test_l21_closing_a_review_tree_keeps_other_worktree_entries_and_its_parent_dir(wt_repo, monkeypatch, tmp_path): + main, wt = wt_repo + base = _impl(wt) + other = tmp_path / "detached-disk" + _git(main, "worktree", "add", "-q", "--detach", str(other)) + import shutil + import tempfile + shutil.rmtree(other) # a worktree whose directory is gone (a detached disk) + rc, calls, r = _drive(monkeypatch, tmp_path, ["--worktree", str(wt), "--base", base, "--review-only"]) + assert rc == 0 + assert str(other) in _git(main, "worktree", "list", "--porcelain"), "the loop does not prune others' entries" + assert not (Path(tempfile.gettempdir()) / "mam-review" / r.loop_id).exists() diff --git a/tests/test_issue21_phase2_loop_a.py b/tests/test_issue21_phase2_loop_a.py new file mode 100644 index 0000000..3dda3b1 --- /dev/null +++ b/tests/test_issue21_phase2_loop_a.py @@ -0,0 +1,709 @@ +"""Issue #21 phase 2, loop A: worktree helpers (item 4), resume only at the +original cwd, loud control-plane binding failures (recommendation 1), the +binding persisted on resume's already-running path (recommendation 2), and the +installer's `/.venv` ignore rule (#27). + +Every git repository lives under tmp_path / mam_sandbox; local submodules are +allowed through GIT_CONFIG_* environment only (never global config), and every +subprocess gets WORKSPACE_ROOT so lib.sh's .mam/shim lands in the sandbox. +""" + +from __future__ import annotations + +import json +import os +import shlex +import shutil +import subprocess +import sys +import time +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parent.parent +SKILLS = REPO_ROOT / ".agents" / "skills" +if str(SKILLS) not in sys.path: + sys.path.insert(0, str(SKILLS)) + +GIT_ID = {"GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@example.invalid", + "GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@example.invalid"} +FILE_OK = {"GIT_CONFIG_COUNT": "1", "GIT_CONFIG_KEY_0": "protocol.file.allow", "GIT_CONFIG_VALUE_0": "always"} +FILE_NO = {"GIT_CONFIG_COUNT": "1", "GIT_CONFIG_KEY_0": "protocol.file.allow", "GIT_CONFIG_VALUE_0": "never"} +PREP = ("multi-agent-mux-create", "scripts", "worktree_prepare.sh") +REM = ("multi-agent-mux-stop", "scripts", "worktree_remove.sh") +CP_KEYS = ("workspace_root", "agent_sessions_yaml", "mam_jobs_dir", "delegate_job_logs_dir") + + +def _git(cwd, *args, env=None, check=True): + return subprocess.run(["git", "-C", str(cwd), *args], capture_output=True, text=True, check=check, + env={**os.environ, **GIT_ID, **(env or {})}) + + +def _skill(root: Path, *parts) -> str: + return str(root / ".agents" / "skills" / Path(*parts)) + + +def _run(root: Path, parts, *args, env=None, timeout=180): + return subprocess.run(["bash", _skill(root, *parts), *args], capture_output=True, text=True, cwd=str(root), + env={**os.environ, "WORKSPACE_ROOT": str(root), **(env or {})}, timeout=timeout) + + +def _rows(root: Path) -> list: + import yaml + return (yaml.safe_load((root / ".mam" / "agent-sessions.yaml").read_text()) or {}).get("herdr_sessions") or [] + + +def _mutate(root: Path, code: str) -> None: + res = subprocess.run(["bash", "-c", f"source {_skill(root, 'lib.sh')} && atomic_dump_yaml {root / '.mam' / 'agent-sessions.yaml'}"], + input=code, capture_output=True, text=True, env={**os.environ, "WORKSPACE_ROOT": str(root)}) + assert res.returncode == 0, res.stderr + + +def _calls(mock_state_file, since=0) -> list: + calls = json.loads(Path(mock_state_file).read_text()).get("calls", [])[since:] + return [c[2:] if c[:1] == ["--session"] else c for c in calls] + + +def _spawn_calls(mock_state_file, since=0) -> list: + return [c for c in _calls(mock_state_file, since) + if c[:2] in (["pane", "split"], ["workspace", "create"], ["agent", "start"])] + + +def _worktrees(main: Path) -> str: + return _git(main, "worktree", "list", "--porcelain").stdout + + +@pytest.fixture +def ctl(mam_sandbox, monkeypatch): + """The sandbox is the control root: a git checkout with one commit and a .venv.""" + for k, v in GIT_ID.items(): + monkeypatch.setenv(k, v) + main = mam_sandbox + _git(main, "init", "-q") + (main / "README").write_text("r\n") + _git(main, "add", "README") + _git(main, "commit", "-q", "-m", "init") + (main / ".venv").mkdir(exist_ok=True) + return main + + +@pytest.fixture +def ctl_sub(ctl): + """Control root with a submodule (bare local remote) left uninitialised in + the main checkout, like nats-docker.""" + main = ctl + src, bare = main.parent / f"{main.name}-subsrc", main.parent / f"{main.name}-sub.git" + src.mkdir() + _git(src, "init", "-q") + (src / "s.txt").write_text("s\n") + _git(src, "add", "s.txt") + _git(src, "commit", "-q", "-m", "s") + subprocess.run(["git", "clone", "-q", "--bare", str(src), str(bare)], check=True, capture_output=True) + _git(main, "submodule", "add", "-q", str(bare), "sm", env=FILE_OK) + _git(main, "commit", "-q", "-m", "add sm") + _git(main, "submodule", "deinit", "-q", "-f", "sm") + assert _git(main, "config", "--get", "submodule.sm.url", check=False).returncode != 0 + return main + + +def _wt(main: Path, name: str = "wt") -> Path: + return main.parent / f"{main.name}-{name}" + + +def _prepare(main, wt, *extra, env=None): + return _run(main, PREP, "--path", str(wt), "--branch", f"b-{wt.name}", *extra, env=env) + + +# --------------------------------------------------------------------------- +# #27: the managed .gitignore block ignores .venv as a file, directory or link +# --------------------------------------------------------------------------- + +def _install_env(): + env = dict(os.environ) + env["MAM_REPO_URL"] = str(REPO_ROOT) + env["MAM_SKIP_VENV"] = "1" + env["MAM_INSTALLER_URL"] = "file://" + str(REPO_ROOT / "deploy" / "install.sh") + return env + + +GI_START = "# >>> MAM managed block (managed by install.sh โ€” do not edit) >>>" +GI_END = "# <<< MAM managed block <<<" + + +def _block(gi: str) -> list: + lines = gi.splitlines() + return lines[lines.index(GI_START) + 1:lines.index(GI_END)] + + +@pytest.mark.parametrize("installer", ["install.sh", "install_mam.sh"]) +def test_w10_managed_block_ignores_venv_in_every_form(tmp_path, installer): + tgt = tmp_path / "tgt" + tgt.mkdir() + cmd = ["bash", str(REPO_ROOT / "deploy" / installer)] + ([str(tgt)] if installer == "install.sh" else ["--target", str(tgt)]) + res = subprocess.run(cmd, capture_output=True, text=True, env=_install_env(), cwd=str(tmp_path), timeout=300) + assert res.returncode == 0, res.stdout[-2000:] + res.stderr[-2000:] + block = _block((tgt / ".gitignore").read_text()) + assert "/.venv" in block and "/.venv/" not in block + _git(tgt, "init", "-q") + v = tgt / ".venv" + + def clear(): + if v.is_symlink() or v.is_file(): + v.unlink() + elif v.exists(): + shutil.rmtree(v) + + if v.exists() or v.is_symlink(): # whatever the installer itself left there is ignored too + assert _git(tgt, "check-ignore", "-q", ".venv", check=False).returncode == 0 + for make in (lambda p: p.symlink_to(tmp_path), lambda p: p.write_text("x"), lambda p: p.mkdir()): + clear() + make(v) + assert _git(tgt, "check-ignore", "-q", ".venv", check=False).returncode == 0, make + clear() + + +def test_w11_reinstall_replaces_the_old_rule_and_keeps_user_lines(tmp_path): + tgt = tmp_path / "tgt" + tgt.mkdir() + # an installation made before #27: the same markers, the old directory-only rule + (tgt / ".gitignore").write_text(f"user-one\n{GI_START}\n/.venv/\n/.mam/\n{GI_END}\nuser-two\n") + res = subprocess.run(["bash", str(REPO_ROOT / "deploy" / "install.sh"), str(tgt)], + capture_output=True, text=True, env=_install_env(), cwd=str(tmp_path), timeout=300) + assert res.returncode == 0, res.stdout[-2000:] + res.stderr[-2000:] + gi = (tgt / ".gitignore").read_text() + assert gi.count(GI_START) == 1 and gi.count(GI_END) == 1 + assert "/.venv" in _block(gi) and "/.venv/" not in _block(gi) + assert "user-one" in gi.splitlines() and "user-two" in gi.splitlines() + for ledger in list(tgt.rglob("asset_hashes.txt")) + list(tgt.rglob("install_manifest.txt")): + assert ".gitignore" not in ledger.read_text().split(), ledger + + +# --------------------------------------------------------------------------- +# resume: original cwd only (A11), binding failures (A12), already running (A13) +# --------------------------------------------------------------------------- + +def _resume(root, ws, name, agent="claude", env=None): + return _run(root, ("multi-agent-mux-resume", "scripts", "resume_session.sh"), + "--workspace", str(ws), "--agent", agent, "--session", name, env=env) + + +def test_w7_resume_only_at_the_original_cwd(mam_sandbox, mock_herdr, mock_agents): + root = mam_sandbox + here, there, gone = root / "here", root / "there", root / "gone" + here.mkdir() + there.mkdir() + _mutate(root, f"""d['herdr_sessions'] = [ + {{'name': 'x-creator-claude', 'status': 'stopped', 'pane': {{'cwd': {str(here)!r}, 'cmd': 'claude'}}, + 'claude_session_id_own': '11111111-2222-4333-8444-555555555555', 'session_id_source': 'assigned'}}, + {{'name': 'x-creator-agy', 'status': 'stopped', 'pane': {{'cwd': {str(here)!r}, 'cmd': 'agy'}}}}, + {{'name': 'y-creator-claude', 'status': 'stopped', 'pane': {{'cwd': {str(gone)!r}, 'cmd': 'claude'}}}}]""") + since = len(_calls(mock_herdr)) + res = _resume(root, there, "x-creator-claude") + assert res.returncode == 2 and f"--workspace '{here}'" in res.stderr + res = _resume(root, there, "x-creator-agy", agent="agy") + assert res.returncode == 2 + res = _resume(root, gone, "y-creator-claude") + assert res.returncode == 2 and "no longer exists" in res.stderr + assert _spawn_calls(mock_herdr, since) == [], "nothing was spawned in the wrong directory" + + +def test_w8_binding_failure_refused_for_a_worktree_warned_for_the_control_root(mam_sandbox, mock_herdr, mock_agents): + root = mam_sandbox + Path(_skill(root, "lib_py", "control_plane.py")).write_text("raise SystemExit(1)\n") + wt = root.parent / f"{root.name}-wt8" + wt.mkdir() + since = len(_calls(mock_herdr)) + create = ("multi-agent-mux-create", "scripts", "create_session.sh") + res = _run(root, create, "--workspace", str(wt), "--agent", "claude", "--role", "creator", "--no-onboard") + assert res.returncode == 2 and "cannot compute the control-plane binding" in res.stderr + assert _rows(root) == [] and _spawn_calls(mock_herdr, since) == [] + res = _run(root, create, "--workspace", str(root), "--agent", "claude", "--role", "creator", "--no-onboard") + assert res.returncode == 0 and "WARNING: control-plane binding unavailable" in res.stderr, res.stderr + (row,) = _rows(root) + assert "control_plane" not in row + + _mutate(root, f"""d['herdr_sessions'].append({{'name': 'w8-creator-claude', 'status': 'stopped', + 'pane': {{'cwd': {str(wt)!r}, 'cmd': 'claude'}}, + 'claude_session_id_own': '11111111-2222-4333-8444-555555555555', 'session_id_source': 'assigned'}})""") + res = _resume(root, wt, "w8-creator-claude") + assert res.returncode == 2 and "cannot compute the control-plane binding" in res.stderr + + +def test_w9_already_running_resume_persists_the_binding(mam_sandbox, mock_herdr, mock_agents): + root = mam_sandbox + create = ("multi-agent-mux-create", "scripts", "create_session.sh") + res = _run(root, create, "--workspace", str(root), "--agent", "claude", "--role", "creator", "--no-onboard") + assert res.returncode == 0, res.stdout + res.stderr + name = _rows(root)[0]["name"] + _mutate(root, "d['herdr_sessions'][0].pop('control_plane', None)") + res = _resume(root, root, name) + assert res.returncode == 0 and "already running" in res.stdout, res.stdout + res.stderr + row = _rows(root)[0] + assert set(row["control_plane"]) == set(CP_KEYS) + assert row["control_plane"]["workspace_root"] == os.path.realpath(root) + + custom = {**row["control_plane"], "mam_jobs_dir": str(root / "custom-jobs")} + _mutate(root, f"d['herdr_sessions'][0]['control_plane'] = {custom!r}") + assert _resume(root, root, name).returncode == 0 + assert _rows(root)[0]["control_plane"] == custom, "an existing row binding is kept" + + +# --------------------------------------------------------------------------- +# prepare +# --------------------------------------------------------------------------- + +def test_w1_prepare_worktree_venv_exclude_base(ctl_sub): + """R3 (PR #29 review): the worktree gets its own venv from the control + root's interpreter, never a link to the control root's .venv (a link + aliases the environment and was once committed by `git add -A`).""" + main = ctl_sub + wt = _wt(main) + shutil.rmtree(main / ".venv") + subprocess.run([sys.executable, "-m", "venv", "--without-pip", str(main / ".venv")], check=True) + for expect in ("VENV=created", "VENV=kept"): + res = _prepare(main, wt) + assert res.returncode == 0, res.stdout + res.stderr + assert expect in res.stdout + assert os.path.realpath(wt) in _worktrees(main) + assert not os.path.islink(wt / ".venv") and os.access(wt / ".venv" / "bin" / "python", os.X_OK) + assert os.path.realpath(wt / ".venv") != os.path.realpath(main / ".venv") + excl = (main / ".git" / "info" / "exclude").read_text().splitlines() + assert excl.count("/.venv") == 1 and excl.count(".pytest_cache/") == 1 + assert _git(wt, "status", "--porcelain").stdout == "" + assert f"BASE={_git(wt, 'rev-parse', 'HEAD').stdout.strip()}" in res.stdout + assert "SUBMODULES=skipped" in res.stdout + gd = _git(wt, "rev-parse", "--absolute-git-dir").stdout.strip() + assert not os.path.exists(os.path.join(gd, "modules")), "no submodule trace without --submodules" + assert _git(main, "config", "--get", "submodule.sm.url", check=False).returncode != 0 + + +def test_w1b_prepare_refuses_a_venv_link_and_skips_without_a_control_root_interpreter(ctl): + main = ctl # ctl's .venv is an empty directory: no interpreter to create from + wt = _wt(main) + res = _prepare(main, wt) + assert res.returncode == 0 and "VENV=skipped" in res.stdout and "no venv created" in res.stderr + assert not (wt / ".venv").exists() + os.symlink(main / ".venv", wt / ".venv") + res = _prepare(main, wt) + assert res.returncode == 1 and "symbolic link" in res.stderr, res.stdout + res.stderr + assert os.path.islink(wt / ".venv"), "nothing is changed by the refusal" + + +def test_w1c_failed_requirements_install_removes_the_half_made_venv(ctl): + """Review D1 (PR #29 delta): after a failed pip install the next run must + not report VENV=kept for an environment without the requirements.""" + main = ctl + shutil.rmtree(main / ".venv") + subprocess.run([sys.executable, "-m", "venv", "--without-pip", str(main / ".venv")], check=True) + (main / "requirements.txt").write_text("this-package-does-not-exist-mam-w1c==0.0.0\n") + _git(main, "add", "requirements.txt") + _git(main, "commit", "-q", "-m", "req") + wt = _wt(main) + res = _prepare(main, wt, env={**os.environ, "PIP_INDEX_URL": "http://127.0.0.1:9/simple", "PIP_RETRIES": "0", "PIP_TIMEOUT": "1"}) + assert res.returncode == 1 and "pip install" in res.stderr, res.stdout + res.stderr + assert not (wt / ".venv").exists() and os.path.realpath(wt) in _worktrees(main) + + +def test_w2_prepare_refusals_and_dry_run(ctl): + main = ctl + res = _run(main, PREP, "--path", str(main / "inside"), "--branch", "x") + assert res.returncode == 2 and "inside the control root" in res.stderr + wt = _wt(main) + assert _prepare(main, wt).returncode == 0 + res = _run(main, PREP, "--path", str(_wt(main, "other")), "--branch", f"b-{wt.name}") + assert res.returncode == 1, "a branch checked out in another worktree: git's refusal is passed on" + dry = _wt(main, "dry") + res = _run(main, PREP, "--path", str(dry), "--branch", "dry", "--dry-run") + assert res.returncode == 0 and "[dry-run]" in res.stdout + assert not dry.exists() and _git(main, "show-ref", "--verify", "--quiet", "refs/heads/dry", check=False).returncode != 0 + + +def test_w3_w4_prepare_creator_binding_no_daemon_control_root_reconcile(ctl, mock_herdr, mock_agents, monkeypatch): + """The creator row points at the worktree but belongs to the control plane; + prepare suppresses the subscriber daemon; create's immediate reconcile runs + the control root's reconcile.sh, not the worktree's copy (A8).""" + from conftest import _subscriber_daemon_pids + main = ctl + wt = _wt(main) + marker_a, marker_b = main / "marker-control-root", main / "marker-worktree" + ctl_reconcile = Path(_skill(main, "multi-agent-mux-monitor", "scripts", "reconcile.sh")) + ctl_reconcile.write_text(f"#!/usr/bin/env bash\ntouch '{marker_a}'\n") + monkeypatch.delenv("MAM_NO_SUBSCRIBER_DAEMON", raising=False) + assert _prepare(main, wt).returncode == 0 + wt_reconcile = wt / ".agents" / "skills" / "multi-agent-mux-monitor" / "scripts" / "reconcile.sh" + wt_reconcile.parent.mkdir(parents=True) + wt_reconcile.write_text(f"#!/usr/bin/env bash\ntouch '{marker_b}'\n") + _git(wt, "add", ".agents") + _git(wt, "commit", "-q", "-m", "worktree copy of reconcile") + res = _prepare(main, wt, "--creator-agent", "claude", "--onboard") + assert res.returncode == 0, res.stdout + res.stderr + (row,) = _rows(main) + assert os.path.realpath(row["pane"]["cwd"]) == os.path.realpath(wt) + assert row["control_plane"]["workspace_root"] == os.path.realpath(main) + deadline = time.time() + 10 + while time.time() < deadline and not marker_a.exists(): + time.sleep(0.2) + assert marker_a.exists(), "the control root's reconcile.sh ran" + assert not marker_b.exists(), "the worktree's reconcile.sh must not run" + assert _subscriber_daemon_pids(main) == [], "prepare passes MAM_NO_SUBSCRIBER_DAEMON=1" + + +def test_w6c_submodule_init_failure_rolls_back_and_prepare_recovers(ctl_sub): + main = ctl_sub + wt = _wt(main) + res = _prepare(main, wt, "--submodules", "--creator-agent", "claude", env=FILE_NO) + assert res.returncode == 1 and "submodule initialisation failed" in res.stderr + gd = _git(wt, "rev-parse", "--absolute-git-dir").stdout.strip() + assert not os.path.exists(os.path.join(gd, "modules")) + assert _git(main, "config", "--get", "submodule.sm.url", check=False).returncode != 0 + assert _rows(main) == [], "no creator after a failed step" + assert _prepare(main, wt).returncode == 0, "re-running without --submodules finishes the preparation" + res = _run(main, REM, "--path", str(wt)) + assert res.returncode == 0 and "removal method: plain" in res.stdout, res.stdout + res.stderr + + +# --------------------------------------------------------------------------- +# remove +# --------------------------------------------------------------------------- + +def _row(main, name, wt, status="running", job=None): + return (f"{{'name': {name!r}, 'status': {status!r}, 'role': 'creator', 'delegate_job_id': {job!r}, " + f"'pane': {{'cwd': {str(wt)!r}, 'cmd': 'claude'}}}}") + + +def test_w5_remove_refusals_change_nothing(ctl, mock_herdr, mock_agents): + from lib_py.delegation import registry + main = ctl + wt = _wt(main) + assert _prepare(main, wt).returncode == 0 + jobs = main / ".mam" / "jobs" + own = registry.register_job("own", registry_dir=str(jobs), agent_session="herdr:wt-creator-claude") + _mutate(main, f"d['herdr_sessions'] = [{_row(main, 'wt-creator-claude', wt, job=own)}]") + + res = _run(main, REM, "--path", str(wt)) # (a) running, no --stop + assert res.returncode == 3 and "pass --stop" in res.stderr + res = _run(main, REM, "--path", str(wt), "--stop", "--dry-run") + assert res.returncode == 0, "with --stop the row's own delegate job is not a refusal" + + other = registry.register_job("other", registry_dir=str(jobs), agent_session="herdr:wt-creator-claude") + res = _run(main, REM, "--path", str(wt), "--stop") # (b) another open job + assert res.returncode == 3 and other in res.stderr + rec = json.loads((jobs / f"{other}.json").read_text()) + rec["status"] = "completed" + (jobs / f"{other}.json").write_text(json.dumps(rec)) + + (wt / "dirty.txt").write_text("x\n") # (c) dirty + res = _run(main, REM, "--path", str(wt), "--stop") + assert res.returncode == 3 and "uncommitted changes" in res.stderr + (wt / "dirty.txt").unlink() + + _git(main, "worktree", "lock", str(wt)) # (d) locked + res = _run(main, REM, "--path", str(wt), "--stop") + assert res.returncode == 3 and "locked" in res.stderr + _git(main, "worktree", "unlock", str(wt)) + + assert os.path.realpath(wt) in _worktrees(main) + (row,) = _rows(main) + assert row["status"] == "running", "no refusal may stop a session first" + assert json.loads((jobs / f"{own}.json").read_text())["status"] == "pending" + + +def test_w6_remove_plain_and_with_stop(ctl, mock_herdr, mock_agents): + main = ctl + wt1, wt2 = _wt(main, "one"), _wt(main, "two") + assert _prepare(main, wt1).returncode == 0 + _mutate(main, f"d['herdr_sessions'] = [{_row(main, 'one-creator-claude', wt1, status='stopped')}]") + res = _run(main, REM, "--path", str(wt1)) + assert res.returncode == 0 and "removal method: plain" in res.stdout, res.stdout + res.stderr + assert os.path.realpath(wt1) not in _worktrees(main) + assert _git(main, "show-ref", "--verify", "--quiet", f"refs/heads/b-{wt1.name}", check=False).returncode == 0 + + res = _prepare(main, wt2, "--creator-agent", "claude") + assert res.returncode == 0, res.stdout + res.stderr + name = [r for r in _rows(main) if os.path.realpath(r["pane"]["cwd"]) == os.path.realpath(wt2)][0]["name"] + res = _run(main, REM, "--path", str(wt2), "--stop") + assert res.returncode == 0, res.stdout + res.stderr + assert [r for r in _rows(main) if r["name"] == name][0]["status"] == "stopped" + assert os.path.realpath(wt2) not in _worktrees(main) + + +def test_w6b_submodule_worktree_single_force_after_checks(ctl_sub): + main = ctl_sub + wt = _wt(main) + res = _prepare(main, wt, "--submodules", env=FILE_OK) + assert res.returncode == 0 and "SUBMODULES=initialised" in res.stdout, res.stdout + res.stderr + + (wt / "sm" / "junk.txt").write_text("u\n") # (i) untracked in the submodule + res = _run(main, REM, "--path", str(wt)) + assert res.returncode == 3 and os.path.realpath(wt) in _worktrees(main) + (wt / "sm" / "junk.txt").unlink() + + (wt / "sm" / "new.txt").write_text("n\n") # (ii) detached commit, gitlink committed above + _git(wt / "sm", "add", "new.txt") + _git(wt / "sm", "commit", "-q", "-m", "local only") + local = _git(wt / "sm", "rev-parse", "HEAD").stdout.strip() + _git(wt, "add", "sm") + _git(wt, "commit", "-q", "-m", "bump sm") + assert _git(wt, "status", "--porcelain", "--ignore-submodules=none").stdout == "" + res = _run(main, REM, "--path", str(wt)) + assert res.returncode == 3 and "unpushed" in res.stderr, res.stdout + res.stderr + assert _git(wt / "sm", "cat-file", "-e", local, check=False).returncode == 0 + + _git(wt / "sm", "push", "-q", "origin", "HEAD:refs/heads/pushed", env=FILE_OK) + _git(wt / "sm", "fetch", "-q", "origin", env=FILE_OK) + res = _run(main, REM, "--path", str(wt)) + assert res.returncode == 0, res.stdout + res.stderr + assert "single --force" in res.stdout and os.path.realpath(wt) not in _worktrees(main) + + +def test_w6d_failed_init_trace_outside_prepare_is_removable(ctl_sub): + main = ctl_sub + wt = _wt(main) + assert _prepare(main, wt).returncode == 0 + assert _git(wt, "submodule", "update", "--init", env=FILE_NO, check=False).returncode != 0 + gd = _git(wt, "rev-parse", "--absolute-git-dir").stdout.strip() + assert os.path.isdir(os.path.join(gd, "modules")), "git leaves modules/ behind after a failed init" + res = _run(main, REM, "--path", str(wt)) + assert res.returncode == 0 and "single --force" in res.stdout, res.stdout + res.stderr + + +# --------------------------------------------------------------------------- +# review ffb5055e: work that a forced removal must not destroy; rollback that +# must leave a usable checkout +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("hidden", [False, True]) +def test_w6e_work_written_while_stopping_is_not_force_removed(ctl_sub, mock_herdr, mock_agents, hidden): + """R1: the checks run again after --stop; a file a stopping session wrote + into a submodule keeps the worktree (and the file) - also when the + submodule's status.showUntrackedFiles=no would hide it (PR #29 review R1).""" + main = ctl_sub + wt = _wt(main) + assert _prepare(main, wt, "--submodules", env=FILE_OK).returncode == 0 + if hidden: + _git(wt / "sm", "config", "status.showUntrackedFiles", "no") + _mutate(main, f"d['herdr_sessions'] = [{_row(main, 'late-creator-claude', wt)}]") + late = wt / "sm" / "late-user-work.txt" + stop = Path(_skill(main, "multi-agent-mux-stop", "scripts", "stop_session.sh")) + stop.write_text("#!/usr/bin/env bash\nprintf 'unsaved work\\n' > " + shlex.quote(str(late)) + "\n" + stop.read_text()) + res = _run(main, REM, "--path", str(wt), "--stop") + assert res.returncode == 3, res.stdout + res.stderr + assert "changed after the checks" in res.stderr + assert late.is_file() and os.path.realpath(wt) in _worktrees(main) + + +def test_w6e2_untracked_submodule_file_hidden_by_config_blocks_the_forced_removal(ctl_sub): + """PR #29 review R1: `status --porcelain` honours status.showUntrackedFiles; + a display setting must not decide what a forced removal deletes.""" + main = ctl_sub + wt = _wt(main) + assert _prepare(main, wt, "--submodules", env=FILE_OK).returncode == 0 + _git(wt / "sm", "config", "status.showUntrackedFiles", "no") + keep = wt / "sm" / "valuable-untracked.txt" + keep.write_text("not committed anywhere\n") + assert _git(wt, "status", "--porcelain", "--ignore-submodules=none").stdout == "", "git hides it by default" + res = _run(main, REM, "--path", str(wt)) + assert res.returncode == 3 and "'dirty'" in res.stderr, res.stdout + res.stderr + assert keep.is_file() and os.path.realpath(wt) in _worktrees(main) + _git(wt, "config", "status.showUntrackedFiles", "no") # the worktree's own status check too + keep.unlink() + top = wt / "valuable-top.txt" + top.write_text("x\n") + res = _run(main, REM, "--path", str(wt)) + assert res.returncode == 3 and "uncommitted changes" in res.stderr, res.stdout + res.stderr + assert top.is_file() and os.path.realpath(wt) in _worktrees(main) + + +def test_w6f_submodule_stash_blocks_the_forced_removal(ctl_sub): + """R2: a stash in a submodule is reachable from no branch and leaves the + status clean; it must still refuse the forced removal.""" + main = ctl_sub + wt = _wt(main) + assert _prepare(main, wt, "--submodules", env=FILE_OK).returncode == 0 + (wt / "sm" / "s.txt").write_text("valuable stashed work\n") + _git(wt / "sm", "stash", "push", "-q", "-m", "keep this work") + assert _git(wt, "status", "--porcelain", "--ignore-submodules=none").stdout == "" + res = _run(main, REM, "--path", str(wt)) + assert res.returncode == 3 and "'stashes'" in res.stderr, res.stdout + res.stderr + assert os.path.realpath(wt) in _worktrees(main) + assert _git(wt / "sm", "rev-parse", "--verify", "-q", "refs/stash", check=False).returncode == 0 + + +def _add_unreachable_submodule(repo: Path, missing: Path) -> None: + """A second submodule `zbad` (sorted after `sm`) whose remote does not exist.""" + sm_sha = _git(repo, "ls-tree", "HEAD", "sm").stdout.split()[2] + _git(repo, "config", "-f", ".gitmodules", "submodule.zbad.path", "zbad") + _git(repo, "config", "-f", ".gitmodules", "submodule.zbad.url", str(missing)) + _git(repo, "add", ".gitmodules") + _git(repo, "update-index", "--add", "--cacheinfo", f"160000,{sm_sha},zbad") + _git(repo, "commit", "-q", "-m", "second, unreachable submodule") + + +def test_w6g_partial_submodule_failure_rolls_back_to_a_usable_worktree(ctl_sub): + """R3: `sm` clones, `zbad` fails. The rollback deinits the new checkout + before deleting its git dir, so the worktree stays a usable checkout.""" + main = ctl_sub + _add_unreachable_submodule(main, main.parent / "missing-remote.git") + wt = _wt(main) + res = _prepare(main, wt, "--submodules", env=FILE_OK) + assert res.returncode == 1 and "submodule initialisation failed" in res.stderr + assert _git(wt, "status", "--porcelain", check=False).returncode == 0 + gd = _git(wt, "rev-parse", "--absolute-git-dir").stdout.strip() + assert not os.path.exists(os.path.join(gd, "modules")) + assert not (wt / "sm" / ".git").exists() + for name in ("sm", "zbad"): + assert _git(main, "config", "--get", f"submodule.{name}.url", check=False).returncode != 0 + assert _prepare(main, wt).returncode == 0 + res = _run(main, REM, "--path", str(wt)) + assert res.returncode == 0 and "removal method: plain" in res.stdout, res.stdout + res.stderr + + +def test_w6h_failed_retry_keeps_the_earlier_submodule_state(ctl_sub): + """R3: a submodule initialised by an earlier run (and its shared config) + survives the rollback of a later, failing run.""" + main = ctl_sub + wt = _wt(main) + assert _prepare(main, wt, "--submodules", env=FILE_OK).returncode == 0 + url_before = _git(main, "config", "--get", "submodule.sm.url").stdout.strip() + _add_unreachable_submodule(wt, main.parent / "missing-remote.git") + res = _prepare(main, wt, "--submodules", env=FILE_OK) + assert res.returncode == 1, res.stdout + res.stderr + assert _git(wt, "status", "--porcelain", check=False).returncode == 0 + gd = _git(wt, "rev-parse", "--absolute-git-dir").stdout.strip() + assert os.path.isdir(os.path.join(gd, "modules", "sm")) + assert not os.path.exists(os.path.join(gd, "modules", "zbad")) + assert (wt / "sm" / ".git").exists() and (wt / "sm" / "s.txt").exists() + assert _git(main, "config", "--get", "submodule.sm.url").stdout.strip() == url_before + assert _git(main, "config", "--get", "submodule.zbad.url", check=False).returncode != 0 + + +# --------------------------------------------------------------------------- +# reviews 8f141362 (N1) and 275fdc4f (R4, R5) +# --------------------------------------------------------------------------- + +def test_w6i_deleted_worktree_directory_is_refused_cleanly(ctl): + """N1: `git status` fails in a worktree whose directory is gone - the + helper refuses (exit 3) instead of dying on an unset METHOD.""" + main = ctl + wt = _wt(main) + assert _prepare(main, wt).returncode == 0 + shutil.rmtree(wt) + for extra in ([], ["--dry-run"]): + res = _run(main, REM, "--path", str(wt), *extra) + assert res.returncode == 3, res.stdout + res.stderr + assert "worktree prune" in res.stderr + assert "nothing was changed" in res.stdout + res.stderr + assert "unbound variable" not in res.stderr + assert str(wt) in _worktrees(main), "nothing was pruned or removed" + + +def _deinit_after(wt: Path, make_state) -> str: + """Put state into the initialised `sm`, then deinit it (no force); returns + the submodule's repository, which stays under the worktree's modules/.""" + make_state(wt / "sm") + gd = _git(wt / "sm", "rev-parse", "--absolute-git-dir").stdout.strip() + _git(wt, "submodule", "deinit", "-q", "sm") + assert not (wt / "sm" / ".git").exists() and os.path.isdir(gd) + return gd + + +def test_w6j_stash_in_a_deinitialised_submodule_repository_is_kept(ctl_sub): + """R5: foreach no longer visits a deinit'd submodule, but its repository - + and its stash - is still under modules/ and would be force-removed.""" + main = ctl_sub + wt = _wt(main) + assert _prepare(main, wt, "--submodules", env=FILE_OK).returncode == 0 + + def stash(sm): + (sm / "s.txt").write_text("valuable stashed work\n") + _git(sm, "stash", "push", "-q", "-m", "keep this work") + + gd = _deinit_after(wt, stash) + before = _git(gd, "rev-parse", "refs/stash").stdout.strip() + res = _run(main, REM, "--path", str(wt)) + assert res.returncode == 3 and "'stashes'" in res.stderr, res.stdout + res.stderr + assert _git(gd, "rev-parse", "refs/stash").stdout.strip() == before + + +def test_w6j2_unpushed_commit_in_a_deinitialised_submodule_repository_is_kept(ctl_sub): + main = ctl_sub + wt = _wt(main) + assert _prepare(main, wt, "--submodules", env=FILE_OK).returncode == 0 + + local = [] + + def commit(sm): + (sm / "new.txt").write_text("n\n") + _git(sm, "add", "new.txt") + _git(sm, "commit", "-q", "-m", "local only") + local.append(_git(sm, "rev-parse", "HEAD").stdout.strip()) + # record the new gitlink first: deinit refuses a submodule whose HEAD + # differs from it, and the top level must be clean for the scenario + _git(wt, "add", "sm") + _git(wt, "commit", "-q", "-m", "bump sm") + + gd = _deinit_after(wt, commit) + assert _git(wt, "status", "--porcelain", "--ignore-submodules=none").stdout == "" + res = _run(main, REM, "--path", str(wt)) + assert res.returncode == 3 and "'unpushed'" in res.stderr, res.stdout + res.stderr + assert _git(gd, "cat-file", "-e", local[0], check=False).returncode == 0 + + +def test_w6k_files_in_an_uninitialised_submodule_path_refuse_the_init(ctl_sub): + """R4: files that predate the attempt in a not-yet-initialised submodule + path refuse --submodules up front; nothing is changed or deleted.""" + main = ctl_sub + wt = _wt(main) + assert _prepare(main, wt).returncode == 0 + valuable = wt / "sm" / "user-work.txt" + valuable.write_text("existing user work\n") + res = _prepare(main, wt, "--submodules", env=FILE_OK) + assert res.returncode == 3 and "not empty" in res.stderr, res.stdout + res.stderr + assert valuable.read_text() == "existing user work\n" + gd = _git(wt, "rev-parse", "--absolute-git-dir").stdout.strip() + assert not os.path.exists(os.path.join(gd, "modules")) + assert _git(main, "config", "--get", "submodule.sm.url", check=False).returncode != 0 + + +# --------------------------------------------------------------------------- +# review ec50fbe7 (R6): a submodule commit kept only by a tag +# --------------------------------------------------------------------------- + +def _tag_only_commit(sm: Path, annotated: bool = False) -> str: + """Commit on the submodule's detached HEAD, keep it only by a tag, go back + to the recorded commit (the top level stays clean). Returns the commit.""" + orig = _git(sm, "rev-parse", "HEAD").stdout.strip() + (sm / "tagged.txt").write_text("kept only by a tag\n") + _git(sm, "add", "tagged.txt") + _git(sm, "commit", "-q", "-m", "tag-only work") + local = _git(sm, "rev-parse", "HEAD").stdout.strip() + _git(sm, "tag", *(["-a", "-m", "keep"] if annotated else []), "saved-work") + _git(sm, "checkout", "-q", orig) + return local + + +def _remove_refuses_tag_only_commit(main: Path, annotated: bool, deinit: bool) -> None: + wt = _wt(main) + assert _prepare(main, wt, "--submodules", env=FILE_OK).returncode == 0 + local = _tag_only_commit(wt / "sm", annotated) + gd = _git(wt / "sm", "rev-parse", "--absolute-git-dir").stdout.strip() + assert _git(wt, "status", "--porcelain", "--ignore-submodules=none").stdout == "" + if deinit: + _git(wt, "submodule", "deinit", "-q", "sm") + res = _run(main, REM, "--path", str(wt)) + assert res.returncode == 3 and "'unpushed'" in res.stderr, res.stdout + res.stderr + assert os.path.isdir(gd) + assert _git(gd, "rev-parse", "saved-work^{commit}").stdout.strip() == local + + +@pytest.mark.parametrize("deinit", [False, True]) +def test_w6l_tag_only_submodule_commit_is_kept(ctl_sub, deinit): + """R6: HEAD and --branches miss a commit only a tag reaches; both checks + (initialised checkout via foreach, retained repository via --git-dir) use + the same rev list and refuse.""" + _remove_refuses_tag_only_commit(ctl_sub, annotated=False, deinit=deinit) + + +def test_w6m_annotated_tag_only_submodule_commit_is_kept(ctl_sub): + _remove_refuses_tag_only_commit(ctl_sub, annotated=True, deinit=False) diff --git a/tests/test_issue21_worktree_prereqs.py b/tests/test_issue21_worktree_prereqs.py index 92b9a5e..b091832 100644 --- a/tests/test_issue21_worktree_prereqs.py +++ b/tests/test_issue21_worktree_prereqs.py @@ -362,23 +362,19 @@ def test_t2_5b_prompts_unchanged_without_options(wt_repo, monkeypatch, tmp_path) assert _roles(calls, "Reviewer")[0].startswith("๋‹ค์Œ ๊ตฌํ˜„ ์‚ฌํ•ญ(์ž‘์—… ๋ชฉํ‘œ: t21)") -def test_t2_6_worktree_env_fallback_and_no_base_env(wt_repo, tmp_path): +def test_t2_6_worktree_env_fallback_and_no_base_env(wt_repo, tmp_path, monkeypatch): 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) + monkeypatch.setenv("MAM_LOOP_WORKTREE", str(wt)) + monkeypatch.setenv("MAM_LOOP_BASE", _git(main, "rev-parse", "HEAD")) + 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" def test_t2_7_runner_reads_mam_jobs_dir(wt_repo, tmp_path, monkeypatch): diff --git a/tests/test_issue5_phase1_delegation.py b/tests/test_issue5_phase1_delegation.py index 6eee8d5..89de227 100644 --- a/tests/test_issue5_phase1_delegation.py +++ b/tests/test_issue5_phase1_delegation.py @@ -497,6 +497,11 @@ def test_importing_delegation_does_not_mutate_environment(mam_sandbox): assert set(os.environ) == keys +def _normalize_help(text: str) -> str: + text = text.replace("optional arguments:", "options:") + return re.sub(r"\s+", " ", text).strip() + + def test_shim_help_output_matches_pre_move_capture(): """The shim must be indistinguishable from the implementation at the CLI. @@ -512,7 +517,12 @@ def test_shim_help_output_matches_pre_move_capture(): capture_output=True, text=True, timeout=60, ) assert res.returncode == 0, res.stderr - assert res.stdout + res.stderr == golden, "--help drifted for {}".format(mod) + # argparse re-wraps usage/help text and renamed the "optional arguments" + # group across Python versions (Issue #28: the captures were taken on + # 3.9, the Linux host runs 3.14). Compare the content, not the layout: + # whitespace runs collapsed, the group label unified. + assert _normalize_help(res.stdout + res.stderr) == _normalize_help(golden), \ + "--help drifted for {}".format(mod) # --- PV-4: the context matrix, each scenario in a fresh interpreter ------------ diff --git a/tests/test_workspace_scope.py b/tests/test_workspace_scope.py index 251bd42..6688ccf 100644 --- a/tests/test_workspace_scope.py +++ b/tests/test_workspace_scope.py @@ -25,7 +25,9 @@ class TestWorkspaceScope(unittest.TestCase): lib_sh = os.path.join(repo_root, ".agents", "skills", "lib.sh") cmd = f"source {lib_sh} && env -u HERDR_SESSION_NAME -u HERDR_SERVER_NAME bash -c 'source {lib_sh} && resolve_herdr_session \"test-session\" \"{self.ws_dir}\"'" - res = subprocess.run(cmd, shell=True, capture_output=True, text=True) + # `source` is a bash builtin; on hosts where /bin/sh is dash `shell=True` + # fails with "source: not found" (Issue #28), so run the string under bash. + res = subprocess.run(["bash", "-c", cmd], capture_output=True, text=True) self.assertEqual(res.returncode, 0, f"Command failed: {res.stderr}") parent = os.path.basename(os.path.dirname(os.path.abspath(self.ws_dir))).lower().replace('_', '-') work = os.path.basename(os.path.abspath(self.ws_dir)).lower().replace('_', '-')