Compare commits
429
Commits
4d11308497
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3364e82e1 | ||
|
|
a582934f6a | ||
|
|
a7bf07bb1c | ||
|
|
3b81f99732 | ||
|
|
7cf63f4858 | ||
|
|
c0e9f5c8a5 | ||
|
|
3fd2416a30 | ||
|
|
5305cf21f9 | ||
|
|
b9c828f092 | ||
|
|
4490e7053b | ||
|
|
ad65d4833e | ||
|
|
7d2f65ad42 | ||
|
|
225c52710a | ||
|
|
56e6713292 | ||
|
|
ec7f5c4e57 | ||
|
|
1d63e882bc | ||
|
|
cf1b693e85 | ||
|
|
454b77215f | ||
|
|
253a0aa56e | ||
|
|
ba75718ac3 | ||
|
|
e3394655b3 | ||
|
|
595e34b2d1 | ||
|
|
8f325de979 | ||
|
|
515b3206b9 | ||
|
|
fce9459e42 | ||
|
|
47fe24e07c | ||
|
|
6359d65999 | ||
|
|
179473e5d5 | ||
|
|
ec378e82c9 | ||
|
|
0e1e2e87de | ||
|
|
342a9a4edf | ||
|
|
5f6065344f | ||
|
|
a47a4683f3 | ||
|
|
8b829647f4 | ||
|
|
0fc162e8cd | ||
|
|
a6fb39ba9f | ||
|
|
9410913da9 | ||
|
|
bd61ef532f | ||
|
|
015075077c | ||
|
|
263a72483a | ||
|
|
5cd72d2df3 | ||
|
|
e4c7c8fe9b | ||
|
|
9c21fa4e4f | ||
|
|
50e2998a20 | ||
|
|
4243da507c | ||
|
|
29cc4fd904 | ||
|
|
04e32212f7 | ||
|
|
bb496fde78 | ||
|
|
0461a632eb | ||
|
|
0454fe968b | ||
|
|
1614af5b67 | ||
|
|
7cac6f613e | ||
|
|
8f64174b6b | ||
|
|
ede9c62be3 | ||
|
|
a907cc5294 | ||
|
|
d115bf7156 | ||
|
|
c747374aa6 | ||
|
|
8c9a68cf1c | ||
|
|
e594d2b650 | ||
|
|
fa93cc5622 | ||
|
|
3a2219454e | ||
|
|
b8a30d5b93 | ||
|
|
ead490bddc | ||
|
|
dc886ecd6b | ||
|
|
0d5f10a546 | ||
|
|
84af709bb3 | ||
|
|
feccfd5e03 | ||
|
|
ff8b381520 | ||
|
|
38ea4f3b8e | ||
|
|
b0eccc5301 | ||
|
|
3ce5d5fdc1 | ||
|
|
cffe279eb0 | ||
|
|
6a37c0019b | ||
|
|
6a4e385a78 | ||
|
|
d17cd80bd9 | ||
|
|
7803d47026 | ||
|
|
463817ee34 | ||
|
|
614ab1420f | ||
|
|
ac5f1154c7 | ||
|
|
14d7a46181 | ||
|
|
4d49abb371 | ||
|
|
1d7b7dab58 | ||
|
|
cb2c84ccd7 | ||
|
|
0e05b1b677 | ||
|
|
cb5f439cce | ||
|
|
6b4b96fa18 | ||
|
|
f0d1c000f9 | ||
|
|
a311c23303 | ||
|
|
74333f87d0 | ||
|
|
6fd417a7b7 | ||
|
|
de6f3fc81f | ||
|
|
2a8d73825b | ||
|
|
81b6c2ea6b | ||
|
|
dd292c12cb | ||
|
|
c0248b88be | ||
|
|
60779ba1f8 | ||
|
|
e156179822 | ||
|
|
af8ed397f6 | ||
|
|
f943b0db30 | ||
|
|
6e0361cf43 | ||
|
|
bd09c6a133 | ||
|
|
26fb1c4130 | ||
|
|
6f4f40a4ad | ||
|
|
f0af98971e | ||
|
|
872d5f12fb | ||
|
|
57830cf00a | ||
|
|
f542596975 | ||
|
|
8a4f651bdf | ||
|
|
8edb8d6f0f | ||
|
|
6328e67469 | ||
|
|
b3461377ae | ||
|
|
ae98cf18c4 | ||
|
|
48cda51d85 | ||
|
|
366da3b3d6 | ||
|
|
9ccd3c1835 | ||
|
|
26e2c43753 | ||
|
|
7cf728e9f8 | ||
|
|
a7cb90e63a | ||
|
|
1c598bb546 | ||
|
|
151af99765 | ||
|
|
75c004222c | ||
|
|
63d816468e | ||
|
|
c57e7667aa | ||
|
|
c982616188 | ||
|
|
f299034d8d | ||
|
|
6d182b392c | ||
|
|
642a53b716 | ||
|
|
813cfd38d1 | ||
|
|
a159a790df | ||
|
|
cbd87734c2 | ||
|
|
b7a857d12f | ||
|
|
b8bed8124e | ||
|
|
e32c957464 | ||
|
|
121eab71d4 | ||
|
|
3a30d7da89 | ||
|
|
42e2a7ea9b | ||
|
|
e3bcdee834 | ||
|
|
62da805668 | ||
|
|
ae2813773c | ||
|
|
423abc7844 | ||
|
|
20b1014717 | ||
|
|
2ccf43911c | ||
|
|
50c8da8269 | ||
|
|
db7f4cab9c | ||
|
|
325b056057 | ||
|
|
525df2bb58 | ||
|
|
8b0b4a07dc | ||
|
|
ffe8665b27 | ||
|
|
21f0c12b9e | ||
|
|
efd27def47 | ||
|
|
a9ffedac36 | ||
|
|
320aa9f7a0 | ||
|
|
6c99fedf40 | ||
|
|
d50610c1ab | ||
|
|
7844ae2c5f | ||
|
|
30c25f193c | ||
|
|
9cbe08e8de | ||
|
|
9e78ff3ba3 | ||
|
|
3705bc72a8 | ||
|
|
05bffc6475 | ||
|
|
630f04bfdc | ||
|
|
e9efabf74b | ||
|
|
c29c5b2142 | ||
|
|
40293841e7 | ||
|
|
42e51735ba | ||
|
|
ca688dcef4 | ||
|
|
a93c9bf292 | ||
|
|
97c2946ee0 | ||
|
|
e0181ea9be | ||
|
|
ac48cb7861 | ||
|
|
8b47cc76ee | ||
|
|
d198dd50aa | ||
|
|
6b00b6e06f | ||
|
|
cd82ab6e72 | ||
|
|
65b78b6ec4 | ||
|
|
541c3febe8 | ||
|
|
8241bd53b1 | ||
|
|
c90a0afa96 | ||
|
|
d6cb01987c | ||
|
|
a670ef53ee | ||
|
|
f19f94099f | ||
|
|
25c99704f2 | ||
|
|
56d0bfda54 | ||
|
|
8fde39be33 | ||
|
|
4c86107846 | ||
|
|
518bedac5d | ||
|
|
c5cebe6c32 | ||
|
|
66d1490230 | ||
|
|
6947d3c785 | ||
|
|
3b3ce307df | ||
|
|
6bf9f92462 | ||
|
|
63d9c205ff | ||
|
|
7209750ac7 | ||
|
|
bf8a583afb | ||
|
|
ab93776d6d | ||
|
|
e8d9151226 | ||
|
|
36f0b8dfd6 | ||
|
|
35935f9e8a | ||
|
|
7c544c06ab | ||
|
|
811b080966 | ||
|
|
ce91cc11b9 | ||
|
|
eedcebcc12 | ||
|
|
feceddf49f | ||
|
|
fb1380fdc1 | ||
|
|
933af344c2 | ||
|
|
ed73f67a4f | ||
|
|
a640b0f072 | ||
|
|
c5c7eff6f7 | ||
|
|
65dc7fde82 | ||
|
|
150b4937f8 | ||
|
|
e1ed2708d8 | ||
|
|
6af4a796b1 | ||
|
|
44999ff2fb | ||
|
|
17eba0904e | ||
|
|
8d26106195 | ||
|
|
e6a46cd7c4 | ||
|
|
7670b41afd | ||
|
|
45d1621c93 | ||
|
|
3563a6fc17 | ||
|
|
75f364e4b4 | ||
|
|
95eabc693f | ||
|
|
1e92ddc563 | ||
|
|
c252ac4d54 | ||
|
|
fb1b93c665 | ||
|
|
8b046390be | ||
|
|
71ea8095c5 | ||
|
|
5edf913d22 | ||
|
|
75936f11c6 | ||
|
|
0051fd6c3b | ||
|
|
2e90c809d3 | ||
|
|
0eec795cd1 | ||
|
|
b58edace11 | ||
|
|
fb006374d6 | ||
|
|
34241d7a65 | ||
|
|
2826e709ae | ||
|
|
80dc2eb45e | ||
|
|
f1331d36ce | ||
|
|
e1eb22bd59 | ||
|
|
53f87e4560 | ||
|
|
9200562c91 | ||
|
|
af0a2a2557 | ||
|
|
1673818bb5 | ||
|
|
9ec0569401 | ||
|
|
bf0f5c1014 | ||
|
|
47e42744e7 | ||
|
|
622c1f8a9d | ||
|
|
ec9586bb9b | ||
|
|
8ea65bb238 | ||
|
|
22586d2a01 | ||
|
|
e80186f560 | ||
|
|
8a9101ed30 | ||
|
|
ed293dcef4 | ||
|
|
1d4f338b4c | ||
|
|
3fdbb626de | ||
|
|
8690ef4bd2 | ||
|
|
30c8c7ebab | ||
|
|
ec663a93c8 | ||
|
|
7a4a0c2091 | ||
|
|
c3e6a7e96d | ||
|
|
708262a598 | ||
|
|
59be1c8920 | ||
|
|
f26952ccc3 | ||
|
|
14d11bf062 | ||
|
|
f5cadd98c0 | ||
|
|
c77806b1b1 | ||
|
|
68a5d31381 | ||
|
|
1debc7d924 | ||
|
|
9fb037c084 | ||
|
|
05b0ea4762 | ||
|
|
f4ba205c9f | ||
|
|
087eacaf21 | ||
|
|
4805fd4511 | ||
|
|
1667fda8dd | ||
|
|
f7cee00dec | ||
|
|
10a678c010 | ||
|
|
0dfbb2cc8a | ||
|
|
ce802d21c8 | ||
|
|
33b91fe0c8 | ||
|
|
2cd20423ac | ||
|
|
123dca47bb | ||
|
|
5ba04a27ca | ||
|
|
3bcaa39ab0 | ||
|
|
1e78dae6eb | ||
|
|
6b7198f73c | ||
|
|
1e826515cc | ||
|
|
5fa592347c | ||
|
|
a334083760 | ||
|
|
e551e3baf1 | ||
|
|
a28711cccf | ||
|
|
6fc9194f89 | ||
|
|
cf5d1142e9 | ||
|
|
80a5614f7c | ||
|
|
08db1da9d5 | ||
|
|
90a289da44 | ||
|
|
7a94ba2c3e | ||
|
|
46ea4cc1e4 | ||
|
|
a2be70df1b | ||
|
|
c5844a8e12 | ||
|
|
7bd695c209 | ||
|
|
3433ffdc41 | ||
|
|
b1e313be04 | ||
|
|
a21986ec3a | ||
|
|
e6d2168a13 | ||
|
|
371099a82c | ||
|
|
c01a1192c8 | ||
|
|
7bc3e1a288 | ||
|
|
e57663ac4c | ||
|
|
4ad622e372 | ||
|
|
25f4be3359 | ||
|
|
c7c559b7aa | ||
|
|
bbbbb95ef6 | ||
|
|
7988db9adf | ||
|
|
657b4b70ed | ||
|
|
2679ddfffd | ||
|
|
fadd38cf56 | ||
|
|
1134d74b38 | ||
|
|
70e36350b9 | ||
|
|
d34b532ccf | ||
|
|
4a81d15d95 | ||
|
|
13259edb06 | ||
|
|
af8a54c028 | ||
|
|
b61c8b177a | ||
|
|
ee1d8966c1 | ||
|
|
d907359746 | ||
|
|
d3d3a76c83 | ||
|
|
dcf2fab06e | ||
|
|
fe96db1d96 | ||
|
|
5611859930 | ||
|
|
263d6bf30d | ||
|
|
2b1d590010 | ||
|
|
ab842f1ee0 | ||
|
|
7e1d3f0815 | ||
|
|
135d76f903 | ||
|
|
77f2829b8e | ||
|
|
d09c1627ce | ||
|
|
0550320590 | ||
|
|
c17c87439d | ||
|
|
66f23c7cbd | ||
|
|
897b505266 | ||
|
|
139bdd4b4c | ||
|
|
1bd643e979 | ||
|
|
134fe9704c | ||
|
|
8f9745b128 | ||
|
|
c096cb455b | ||
|
|
4da4a50682 | ||
|
|
38d8ccb316 | ||
|
|
ce315d8c5e | ||
|
|
8e7282c948 | ||
|
|
c714323dcf | ||
|
|
fbe2731980 | ||
|
|
9d52bb7024 | ||
|
|
ea911d7842 | ||
|
|
0fedae2f46 | ||
|
|
4176d7570f | ||
|
|
e629cc137f | ||
|
|
8a09e022a6 | ||
|
|
1cc84282f3 | ||
|
|
fae70c13d9 | ||
|
|
f5a6575bf2 | ||
|
|
7610258950 | ||
|
|
02823128fa | ||
|
|
c82ea75a15 | ||
|
|
bd3df890dc | ||
|
|
9e1f0b68b1 | ||
|
|
ebe58f6be0 | ||
|
|
952657efb4 | ||
|
|
d5a191c47c | ||
|
|
7c40bf4930 | ||
|
|
091891756b | ||
|
|
eaa49aaafe | ||
|
|
4b3134554a | ||
|
|
16376c5891 | ||
|
|
497633f64a | ||
|
|
4d444ce6cd | ||
|
|
636b47475f | ||
|
|
299ba7dd8b | ||
|
|
a08b3ca387 | ||
|
|
ec61511699 | ||
|
|
7b761be7b8 | ||
|
|
f98b9582c7 | ||
|
|
07cae71667 | ||
|
|
525764aaa3 | ||
|
|
387479732e | ||
|
|
b347efd760 | ||
|
|
f413f6ada6 | ||
|
|
8cd256cc7a | ||
|
|
e1203166d9 | ||
|
|
8d7e010f9f | ||
|
|
9a651ccc71 | ||
|
|
01b465f50c | ||
|
|
d87674679d | ||
|
|
cb7ed1aa38 | ||
|
|
12cba25c18 | ||
|
|
8adbe2245c | ||
|
|
803ba43884 | ||
|
|
3ea5757cfe | ||
|
|
9a827b3caa | ||
|
|
5eb9524fe7 | ||
|
|
4a5916a736 | ||
|
|
0e74aa1e4e | ||
|
|
cb575fa851 | ||
|
|
3b134e3358 | ||
|
|
56d08efa95 | ||
|
|
17a2dcd988 | ||
|
|
964abc6965 | ||
|
|
b49af56c3c | ||
|
|
2532e78453 | ||
|
|
9389d7ebe2 | ||
|
|
02a89490df | ||
|
|
015ab70e51 | ||
|
|
6ad6f08cc6 | ||
|
|
95f3e9a427 | ||
|
|
8def235dc7 | ||
|
|
dfa7212ea4 | ||
|
|
f5286a0e67 | ||
|
|
508ca99d71 | ||
|
|
a4b7b45e13 | ||
|
|
78cb9ecfa9 | ||
|
|
fb3498e23b | ||
|
|
265a1f6c3d | ||
|
|
fc0bd96d8a | ||
|
|
19d798bf8f | ||
|
|
dd774b1a9f | ||
|
|
f85fa5b6cf | ||
|
|
720774c1e7 | ||
|
|
fa59741a9b | ||
|
|
47640e2fd7 | ||
|
|
76f10b2096 | ||
|
|
de0a9c2e81 |
No files matched your search
@@ -18,7 +18,7 @@
|
|||||||
- **모호성 제거**: 사용자의 요구사항에 모호한 부분이 있다면 작업을 추측하여 진행하지 말고, 즉시 사용자에게 질문하여 명확히 해야 합니다 (`/grill-me` 슬래시 명령어 권장).
|
- **모호성 제거**: 사용자의 요구사항에 모호한 부분이 있다면 작업을 추측하여 진행하지 말고, 즉시 사용자에게 질문하여 명확히 해야 합니다 (`/grill-me` 슬래시 명령어 권장).
|
||||||
|
|
||||||
### 👥 Team Leaders (팀장)
|
### 👥 Team Leaders (팀장)
|
||||||
새롭게 생성되는 에이전트(`antigravity`, `claude`, `hermes`, `grok`, `opencode` 등)는 각 팀의 **팀장** 역할을 수행합니다. 총괄 매니저로부터 작업을 위임받아 개발 또는 리뷰 워크플로우를 주도합니다.
|
새롭게 생성되는 에이전트(`antigravity`, `claude`, `hermes`, `grok`, `opencode`, `codex` 등)는 각 팀의 **팀장** 역할을 수행합니다. 총괄 매니저로부터 작업을 위임받아 개발 또는 리뷰 워크플로우를 주도합니다.
|
||||||
- **Developer Team Leader (개발 팀장)**:
|
- **Developer Team Leader (개발 팀장)**:
|
||||||
- 총괄 매니저로부터 작업을 위임받습니다.
|
- 총괄 매니저로부터 작업을 위임받습니다.
|
||||||
- **작업 분석 및 계획**: 주어진 작업을 철저히 분석하고, 작은 단위로 문제를 나누어 세부 계획을 수립합니다.
|
- **작업 분석 및 계획**: 주어진 작업을 철저히 분석하고, 작은 단위로 문제를 나누어 세부 계획을 수립합니다.
|
||||||
@@ -151,6 +151,7 @@ TMUX 환경에서 실행되는 에이전트가 화면 스크롤 한계로 인해
|
|||||||
- **작업 위임**:
|
- **작업 위임**:
|
||||||
- *수동 경로*: 상세 사양과 계획 수립 등의 복잡한 작업 지시는 먼저 로컬 마크다운 파일(예: `.mam/reports/brief-<job_id>.md` 또는 지정된 워크스페이스 경로)로 작성한 후, 에이전트에게 `"Read <파일경로> and execute."` 라는 실행 명령만 전달하십시오.
|
- *수동 경로*: 상세 사양과 계획 수립 등의 복잡한 작업 지시는 먼저 로컬 마크다운 파일(예: `.mam/reports/brief-<job_id>.md` 또는 지정된 워크스페이스 경로)로 작성한 후, 에이전트에게 `"Read <파일경로> and execute."` 라는 실행 명령만 전달하십시오.
|
||||||
- *자동 경로*: 자동화 잡 런너(`multi-agent-mux-delegate-job submit`)는 잡 등록 시 `.mam/jobs/<job_id>/brief.md` 디렉터리에 지시서를 자동 집필하고 단일 라인 포인터 프롬프트만 에이전트 세션에 인가합니다.
|
- *자동 경로*: 자동화 잡 런너(`multi-agent-mux-delegate-job submit`)는 잡 등록 시 `.mam/jobs/<job_id>/brief.md` 디렉터리에 지시서를 자동 집필하고 단일 라인 포인터 프롬프트만 에이전트 세션에 인가합니다.
|
||||||
|
- *하위 위임*: 자기 잡의 일부를 다시 위임하는 에이전트는 `submit` 에 `--parent-job <자기 job_id>` 를 붙입니다. 그래야 루프가 그 하위 잡을 기다리고 리뷰어에게 보여 줍니다(Issue #34).
|
||||||
- **결과 안내 및 피드백**:
|
- **결과 안내 및 피드백**:
|
||||||
- *수동/영구 리뷰*: 상세 리뷰 결과, 설계 제안서 등은 `.mam/reports/<tmux_session_name>/report-<job_id>.md` 경로에 저장합니다.
|
- *수동/영구 리뷰*: 상세 리뷰 결과, 설계 제안서 등은 `.mam/reports/<tmux_session_name>/report-<job_id>.md` 경로에 저장합니다.
|
||||||
- *자동화 잡 보고서*: 자동 위임된 비동기 작업의 완료 결과는 잡 디렉터리 하위인 `.mam/jobs/<job_id>/<agent_name>-reports/report-final.md` (루프/Discuss 위임 시에는 `<clean_session_name>-reports/`) 경로에 기록해야 합니다.
|
- *자동화 잡 보고서*: 자동 위임된 비동기 작업의 완료 결과는 잡 디렉터리 하위인 `.mam/jobs/<job_id>/<agent_name>-reports/report-final.md` (루프/Discuss 위임 시에는 `<clean_session_name>-reports/`) 경로에 기록해야 합니다.
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ We clearly separate responsibilities and permissions between roles to reduce bot
|
|||||||
- **Ambiguity Resolution**: If a user's requirements contain ambiguous details, do not guess. Immediately ask the user for clarification (we recommend using the `/grill-me` slash command).
|
- **Ambiguity Resolution**: If a user's requirements contain ambiguous details, do not guess. Immediately ask the user for clarification (we recommend using the `/grill-me` slash command).
|
||||||
|
|
||||||
### 👥 Team Leaders (팀장)
|
### 👥 Team Leaders (팀장)
|
||||||
Newly spawned agents (e.g., `antigravity`, `claude`, `hermes`, `grok`, `opencode`) act as **Team Leaders** of their respective groups. They receive delegated tasks from the General Manager and manage implementation or review workflows.
|
Newly spawned agents (e.g., `antigravity`, `claude`, `hermes`, `grok`, `opencode`, `codex`) act as **Team Leaders** of their respective groups. They receive delegated tasks from the General Manager and manage implementation or review workflows.
|
||||||
- **Developer Team Leader (개발 팀장)**:
|
- **Developer Team Leader (개발 팀장)**:
|
||||||
- Receives tasks from the General Manager.
|
- Receives tasks from the General Manager.
|
||||||
- **Task Breakdown & Planning**: Thoroughly analyzes the task, breaks it down into small units, and creates a plan.
|
- **Task Breakdown & Planning**: Thoroughly analyzes the task, breaks it down into small units, and creates a plan.
|
||||||
@@ -151,6 +151,7 @@ To ensure that agents running in TMUX environments do not lose debug logs or pre
|
|||||||
- **Task Delegation**:
|
- **Task Delegation**:
|
||||||
- *Manual path*: Detailed task briefs may be written to a local Markdown file (e.g., `.mam/reports/brief-<job_id>.md` or a workspace path) first. The sender then issues a simple trigger command: `"Read <file_path> and execute."`
|
- *Manual path*: Detailed task briefs may be written to a local Markdown file (e.g., `.mam/reports/brief-<job_id>.md` or a workspace path) first. The sender then issues a simple trigger command: `"Read <file_path> and execute."`
|
||||||
- *Automated path*: The automated job runner (`multi-agent-mux-delegate-job submit`) automatically provisions the brief at `.mam/jobs/<job_id>/brief.md` and sends a short pointer instruction to the agent.
|
- *Automated path*: The automated job runner (`multi-agent-mux-delegate-job submit`) automatically provisions the brief at `.mam/jobs/<job_id>/brief.md` and sends a short pointer instruction to the agent.
|
||||||
|
- *Sub-delegation*: an agent that delegates part of its job passes `--parent-job <its job_id>` to `submit`, so the loop waits for the sub-job and shows it to reviewers (Issue #34).
|
||||||
- **Result Reporting & Feedback**:
|
- **Result Reporting & Feedback**:
|
||||||
- *Manual/Durable reviews*: Detailed reviews, design proposals, or audit reports must be saved under `.mam/reports/<tmux_session_name>/report-<job_id>.md`.
|
- *Manual/Durable reviews*: Detailed reviews, design proposals, or audit reports must be saved under `.mam/reports/<tmux_session_name>/report-<job_id>.md`.
|
||||||
- *Automated job reports*: Automated execution results are saved directly to `.mam/jobs/<job_id>/<agent_name>-reports/report-final.md` (or `<clean_session_name>-reports/` for loops) as transient files.
|
- *Automated job reports*: Automated execution results are saved directly to `.mam/jobs/<job_id>/<agent_name>-reports/report-final.md` (or `<clean_session_name>-reports/` for loops) as transient files.
|
||||||
@@ -168,7 +169,8 @@ To ensure that agents running in TMUX environments do not lose debug logs or pre
|
|||||||
- **Identity Verification**: The guard validates process liveness via `pid` + `lstart` to prevent livelocks on PID rollover.
|
- **Identity Verification**: The guard validates process liveness via `pid` + `lstart` to prevent livelocks on PID rollover.
|
||||||
|
|
||||||
### ⏱️ Timeout Configuration & Alignment Rules
|
### ⏱️ Timeout Configuration & Alignment Rules
|
||||||
- **Job Execution Limits (`timeout_sec` & `idle_timeout_sec`)**: Each job independently manages its overall execution timeout (`timeout_sec`, default 3600s) and idle timeout without receiving messages (`idle_timeout_sec`, default 120s).
|
- **Job Execution Limits (`timeout_sec` & `idle_timeout_sec`)**: Each job independently manages its overall execution timeout (`timeout_sec`, default 3600s) and idle timeout without receiving messages (`idle_timeout_sec`). Since v4.5.0 (Issue #18) the idle timeout is the liveness contract's K x N: the agent heartbeats every `heartbeat_sec` (N, default 30 — automatic once it publishes `started --heartbeat N`), and `heartbeat_misses` (K, default 4) missed heartbeats close the job as `deadline_timeout` (default 120 s, `--idle-timeout` overrides). `started` must arrive within `ack_timeout_sec` (T, default 180) of the injection or the job fails as `agent_blocked` without re-injection. herdr status and pane text are never used for these decisions.
|
||||||
|
- **Heartbeat timer and agent life**: the automatic timer is bound to the agent process that ran the start command (the nearest agent-CLI ancestor, or `--watch-pid`) and stops within one period once that process is gone; a crashed agent is therefore closed after K x N like any silent one, while an agent that is alive but stuck keeps heartbeating (process liveness, not work liveness).
|
||||||
- **Monitor Idle Waiting (`SUB_IDLE_TIMEOUT`)**: The idle timeout for the monitor script (`reconcile.sh`), `SUB_IDLE_TIMEOUT`, must always be set generously to `3600s` (1 hour) or more to align with the maximum job budget. This prevents the monitor from terminating early due to idle detection, which would lose control over background tasks before they finish.
|
- **Monitor Idle Waiting (`SUB_IDLE_TIMEOUT`)**: The idle timeout for the monitor script (`reconcile.sh`), `SUB_IDLE_TIMEOUT`, must always be set generously to `3600s` (1 hour) or more to align with the maximum job budget. This prevents the monitor from terminating early due to idle detection, which would lose control over background tasks before they finish.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# Job 155f5092 Final Report: Resolution of Follow-Up Findings T1 & T2
|
||||||
|
|
||||||
|
- **Job ID**: 155f5092
|
||||||
|
- **Reviewer Target**: `planner-reviewer-codex-01` (`ff721ed1`)
|
||||||
|
- **Status**: Completed (Both regression test findings T1 and T2 resolved and mutation-verified)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Summary of Changes
|
||||||
|
|
||||||
|
### T1 (P2): Invalid-metadata assertions only exercise "file not found"
|
||||||
|
- **Problem**: In `test_codex_verify_artifact_metadata_fail_closed`, the fixture file was placed outside the discovery path without the finder override being active for cases 1–5. Consequently, `verify_artifact` returned `False` on file existence before reaching metadata parsing, which allowed the old buggy verifier (from commit `76f10b2`) to pass the test.
|
||||||
|
- **Fix**:
|
||||||
|
- In `tests/test_a4_adapter_contract.py`, updated `test_codex_verify_artifact_metadata_fail_closed` to use pytest `monkeypatch`.
|
||||||
|
- Installed `adapter._find_rollout` monkeypatch override *before* running all assertions.
|
||||||
|
- Added an explicit assertion verifying that `adapter._find_rollout(uuid, ctx)` resolves the fixture file before evaluating malformed contents.
|
||||||
|
- Parameterized invalid metadata cases:
|
||||||
|
- `{}` (empty dict - R4 defect)
|
||||||
|
- `{"payload": {"cwd": ws}}` (missing `payload.id`)
|
||||||
|
- `{"payload": {"id": uuid}}` (missing `payload.cwd`)
|
||||||
|
- `{"payload": {"id": "other-id", "cwd": ws}}` (mismatched session ID)
|
||||||
|
- `{"payload": {"id": uuid, "cwd": "/wrong/path"}}` (mismatched workspace cwd)
|
||||||
|
- `"not-valid-json"` (malformed JSON syntax)
|
||||||
|
- `[]` (non-dict JSON)
|
||||||
|
- `{"payload": "not-a-dict"}` (non-dict payload)
|
||||||
|
- Added database/transcript metadata inconsistency test: DB has conflicting cwd, asserting fail-closed behavior even if rollout matches workspace.
|
||||||
|
- Retained a valid control case with matching DB cwd and transcript cwd, asserting `True`.
|
||||||
|
- **Mutation Verification**:
|
||||||
|
- Bound `old_verify_artifact` from commit `76f10b2` in memory: test **FAILED** with `AssertionError: Expected False for payload: {} (assert True is False)`.
|
||||||
|
- With current implementation: test **PASSED**.
|
||||||
|
|
||||||
|
### T2 (P2): Unmanaged-session test never supplies a session to drift-B
|
||||||
|
- **Problem**: `test_reconcile_drift_b_unmanaged_session_ignored` used a minimal custom mock script that did not support the `herdr ls -F "#{session_name}|#{session_created}"` or `herdr list-panes -F` query shapes used by `reconcile.sh`, meaning drift-B was never evaluated for the session.
|
||||||
|
- **Fix**:
|
||||||
|
- In `tests/test_tier1_unit.py`, updated `test_reconcile_drift_b_unmanaged_session_ignored` to use the contract-compatible `mock_herdr` fixture.
|
||||||
|
- Seeded `mock_herdr_state.json` with two sessions running `codex` in the workspace:
|
||||||
|
1. Unmanaged candidate: `"personal-session"` (unmanaged name, no `MAM_MANAGED` env marker).
|
||||||
|
2. Managed positive control: `"test-proj-creator-codex"` (standard managed name pattern).
|
||||||
|
- Asserted that both sessions are discoverable via `herdr ls -F "#{session_name}|#{session_created}"` and `herdr list-panes -t <name> -F "#{pane_pid}|#{pane_current_path}|#{pane_current_command}"` before running reconcile.
|
||||||
|
- Ran `reconcile.sh --once --emit-diff`.
|
||||||
|
- Asserted that `"personal-session"` is ignored (not present in `drifts` as class B, not in `agent-sessions.yaml`).
|
||||||
|
- Asserted that `"test-proj-creator-codex"` is registered as drift class B and added to `agent-sessions.yaml`.
|
||||||
|
- **Mutation Verification**:
|
||||||
|
- Temporarily restored the old fallback (`if not agent: if pm_check and pm_check.get("cmd") in ...: agent = ...`): test **FAILED** with `AssertionError: assert not any(d.get("name") == "personal-session" for d in drifts)` (because `"personal-session"` was incorrectly registered).
|
||||||
|
- With current implementation (fallback removed): test **PASSED**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Validation & Verification
|
||||||
|
|
||||||
|
1. `git diff --check`: Clean (exit code 0).
|
||||||
|
2. T1 Mutation Check:
|
||||||
|
- Against `76f10b2` `verify_artifact`: FAILED (`AssertionError: Expected False for payload: {}`).
|
||||||
|
- Against current `verify_artifact`: PASSED.
|
||||||
|
3. T2 Mutation Check:
|
||||||
|
- Against `76f10b2` fallback in `reconcile.sh`: FAILED (`assert not True` on personal-session registration).
|
||||||
|
- Against current `reconcile.sh`: PASSED.
|
||||||
|
4. Focused Test Execution:
|
||||||
|
- `pytest tests/test_a4_adapter_contract.py tests/test_tier1_unit.py -q -k "codex or adapter or facts_bridge or delegate_agent or cli_bridge"`: **39 passed, 61 deselected in 46.23s**.
|
||||||
|
- `pytest tests/test_tier1_unit.py::test_reconcile_drift_b_unmanaged_session_ignored tests/test_version_consistency.py tests/test_tier2_component.py::test_comp_codex_create_and_reconcile_and_stop -q`: **4 passed in 40.15s**.
|
||||||
@@ -0,0 +1,371 @@
|
|||||||
|
> **본 리포트는 Job `2770df36`(role: Planner)의 산출물인 Phase 2a 구현 계획서다.**
|
||||||
|
> durable 사본: `.agents/reports/creator-claude-01/plan-2770df36.md` (커밋됨).
|
||||||
|
>
|
||||||
|
> **범위 명시**: 본 job의 지시는 "구현 계획서를 작성"이며, 목표 문장의 (5) 테스트 작성·(6) 리뷰어 만장일치 PASS는 이 계획을 실행할 **후속 Worker/Reviewer job**의 완료 조건이다. 본 계획서는 그 job들이 그대로 실행할 수 있도록 커밋 5개·게이트 9개·인수인계 체크리스트까지 명세했다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 📑 Issue #5 Phase 2a 구현 계획서 — `lib_py/loop/` 코어 & Shadow Watcher
|
||||||
|
|
||||||
|
- **Job**: `2770df36` / **Planner**: `creator-claude-01`
|
||||||
|
- **기준 커밋**: `015ab70` (branch `refactor`, working tree clean) / Python `3.9.6` / paho-mqtt `2.1.0` / herdr `0.8.2` / **전체 테스트 수집 576**
|
||||||
|
- **요구 출처**: `implementation_plan.md` §2 Phase 2a 행, `task.md` P2A-01~P2A-04, `state-transitions.md` §2~§5
|
||||||
|
- **문서 성격**: Worker가 그대로 실행할 수 있는 단계별 명세. §1의 모든 인용은 계획 수립 중 **라이브 코드·라이브 CLI로 직접 확인**했다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 요약 (TL;DR)
|
||||||
|
|
||||||
|
1. **Phase 2a는 레지스트리에 단 1바이트도 쓰지 않는다.** Shadow watcher는 "Would-Complete" 판정을 로그로만 남긴다. 이 불변식을 문장이 아니라 **구조와 테스트로** 강제한다(§D5, G4).
|
||||||
|
2. **CAS 경계는 신규 파일이 아니라 기존 writer 3곳을 한 함수 뒤로 모으는 작업이다.** 현재 상태 writer는 3개이고 그중 **하나(`pick_pending`)는 이미 문서화된 chokepoint를 우회**한다(§1-2 실측).
|
||||||
|
3. **4중 귀속에 필요한 필드가 job 레코드에 없다**(`attempt_id`/`agent_session_uuid`/`pane_process_generation` 전부 부재, §1-4). Phase 1이 레코드 스키마 불변을 약속했고 Phase 2a는 레지스트리 쓰기가 금지되므로, **귀속 증거는 shadow 전용 저장소에 둔다.** 레코드 스키마 결정은 Phase 2b로 미룬다.
|
||||||
|
4. **`herdr agent wait --timeout`의 단위는 밀리초다**(라이브 `--help` 확인, Phase 0 fixture도 `integer_ms`로 정확히 기록). 초 단위로 오해하면 1000배 오차가 난다.
|
||||||
|
5. **락 안에서 감사 로그 I/O가 일어나고 있다**(`update_job_status`가 락 보유 중 `update_logged_status`/`append_event` 호출, §1-3). Phase 2a는 이를 "락 안에서 결정 + 알림 큐잉 → 락 해제 → 밖에서 flush"로 바꾼다.
|
||||||
|
6. **판정 로직의 테스트 벡터는 새로 만들지 않는다.** Phase 0에서 3인 리뷰로 검증된 watcher 시나리오 7종과 그 `expected_policy`가 그대로 엔진의 결정 테이블 테스트가 된다(§D7).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Planner 사전 실측
|
||||||
|
|
||||||
|
### 1-1. 대상 파일과 규모
|
||||||
|
|
||||||
|
| 항목 | 실측 |
|
||||||
|
|---|---|
|
||||||
|
| 기준 커밋 | `015ab70` |
|
||||||
|
| 전체 테스트 | **576 수집** (Phase 1 종료 시점) |
|
||||||
|
| `lib_py/` 기존 패키지 | `agents/`, `delegation/`, `atomic_yaml.py`, `layout.py`, `paths.py`, `state.py`, `verify_session.py`, `workspace_uuid.py` |
|
||||||
|
| `MAM_ENGINE_CAS` | **아직 존재하지 않음** (`grep` 결과 `implementation_plan.md` 언급 1건뿐) — Phase 2a가 신설 |
|
||||||
|
|
||||||
|
### 1-2. job 레코드 상태 writer 전수 조사 (P2A-01의 실제 범위)
|
||||||
|
|
||||||
|
`_atomic_write_record` 직접 호출자 = **5곳**, 그중 상태를 바꾸는 것은 **3곳**이다.
|
||||||
|
|
||||||
|
| # | 위치 | 하는 일 | CAS 여부 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| W1 | `mqtt_common.py:410` (`update_job_status`) | `record.update(fields)` **무조건 병합** | ❌ 없음 |
|
||||||
|
| W2 | `registry.py:131` (`pick_pending`) | 락 안에서 `status == "pending"` 필터 후 `running`으로 기록 | ⚠️ **사실상 CAS이나 W1을 우회한 별도 구현** |
|
||||||
|
| W3 | `mqtt_common.py:433` (`next_seq`) | `last_seq` 증가 (상태 무관) | 해당 없음 |
|
||||||
|
| — | `registry.py:107` (`register_job`) | 레코드 생성 | 해당 없음 |
|
||||||
|
| — | `registry.py:151` (`update_status`) → W1 | | |
|
||||||
|
| — | `registry.py:365` (`update` CLI) → W1 | | |
|
||||||
|
| — | `publish_event.py:179` (`record_outcome`) → W1 | | |
|
||||||
|
|
||||||
|
> **핵심 (정확히)**: `update_job_status`의 docstring은 자신을 "the single chokepoint for status writes"라고 선언하지만, `registry.py:135`의 주석이 스스로 인정하듯 `pick_pending`은 이를 우회한다.
|
||||||
|
>
|
||||||
|
> 단, **`pick_pending`이 틀린 것은 아니다.** 락 안에서 `status == "pending"`을 필터한 뒤 기록하므로 `pending → running`에 대해서는 **이미 올바른 compare-and-swap**이다(`registry.py:123`). 문제는 정확성이 아니라 **중복**이다: CAS 판정과 감사 로그 mirror가 두 벌로 존재하며, Phase 1의 O-1(1회성 전송 이중 구현)에서 확인했듯 **같은 로직의 사본은 한쪽에만 결함이 생기는 경로**다.
|
||||||
|
>
|
||||||
|
> 따라서 **P2A-01은 "없는 CAS를 새로 만드는" 작업이 아니라 "한쪽에만 있는 CAS를 모든 전이로 일반화하고 두 사본을 하나로 합치는" 작업이다.** 실제로 CAS가 전혀 없는 것은 W1(`record.update(fields)` 무조건 병합)이다.
|
||||||
|
|
||||||
|
셸/래퍼 측은 job 레코드를 직접 쓰지 않는다: 래퍼는 `registry.py status --set`(→W1)을, `reconcile.sh:192`는 `registry.load_job`(읽기 전용)만 쓴다. `run_loop.sh:254`는 job JSON을 **읽기만** 한다(3초 폴링). 즉 **모든 쓰기가 파이썬 안에 있다** — CAS 경계를 파이썬 한 곳에 두면 전수 커버가 가능하다.
|
||||||
|
|
||||||
|
### 1-3. 현재 락 트랜잭션의 I/O 실측
|
||||||
|
|
||||||
|
`update_job_status`(`:405-418`)는 `registry_lock` **보유 중에** 다음을 수행한다:
|
||||||
|
- `load_job` (읽기 I/O)
|
||||||
|
- `_atomic_write_record` (쓰기 + `fsync` + `os.replace`)
|
||||||
|
- `update_logged_status` (**감사 로그 디렉터리 쓰기**)
|
||||||
|
- `append_event` (**감사 로그 append**)
|
||||||
|
|
||||||
|
네트워크/서브프로세스는 락 안에 없다(`publish_event.main`의 순서: `next_seq`(락) → 발행(네트워크, 락 없음) → `record_outcome`(락)). **이 성질은 Phase 2a에서 반드시 보존해야 하며**, 추가로 감사 로그 I/O를 락 밖으로 빼는 것이 P2A-01의 "락 내 I/O 배제" 요구다.
|
||||||
|
|
||||||
|
### 1-4. 4중 귀속에 필요한 필드 부재 (설계를 좌우하는 사실)
|
||||||
|
|
||||||
|
라이브 job 레코드의 실제 키:
|
||||||
|
```
|
||||||
|
agent, agent_session, auth_token, broker, created_at, expected_artifacts,
|
||||||
|
idle_timeout_sec, iteration, job_id, job_type, last_seq, max_iterations,
|
||||||
|
prompt, reviewer, reviewer_session, role, schema_version, status,
|
||||||
|
timeout_sec, topic_prefix, updated_at
|
||||||
|
```
|
||||||
|
`state-transitions.md` §2가 요구하는 4중 귀속 중 **`attempt_id`·`agent_session_uuid`·`pane_process_generation`이 전부 없다**. `iteration`(루프 회차)은 있으나 attempt와 의미가 다르다.
|
||||||
|
|
||||||
|
동시에 Phase 1은 "스키마 불변(`SCHEMA_VERSION = 1`, 레지스트리 레코드 필드 불변)"을 계약으로 못박았고, P2A-03은 **레지스트리 쓰기 자체를 금지**한다. → **Phase 2a에서 귀속 증거를 레코드에 넣는 것은 불가능하며, 넣어서도 안 된다.** §D6이 이를 해결한다.
|
||||||
|
|
||||||
|
### 1-5. `herdr agent wait` 라이브 계약 (watcher.py의 입력)
|
||||||
|
|
||||||
|
```
|
||||||
|
Usage: herdr agent wait <TARGET> [OPTIONS]
|
||||||
|
--until <STATUS> idle|working|blocked|done|unknown (반복 가능)
|
||||||
|
--timeout <MS> Fail after this many milliseconds
|
||||||
|
Without --until, matches idle, done, or blocked.
|
||||||
|
Without --timeout, waits indefinitely.
|
||||||
|
```
|
||||||
|
- **단위는 밀리초**다. Phase 0 fixture(`wait-contract.json`)도 `"type": "integer_ms"`로 정확히 기록되어 있다.
|
||||||
|
- 성공 → `exit 0`, **stdout**에 `{"id":"cli:agent:wait","result":{"agent":{...}}}`.
|
||||||
|
- 타임아웃/대상 부재 → `exit 1`, **stderr**에 에러 JSON(`error.code` = `timeout` / `agent_not_found`). stdout은 비어 있다.
|
||||||
|
- `--until` 생략 시 기본 매칭은 `idle,done,blocked` — **`working`은 매칭되지 않는다.** watcher는 항상 `--until`을 명시한다.
|
||||||
|
- **단일 `wait`는 1개의 최종 스냅샷만 주는 동기 장벽**이다(`herdr-runtime.md` §2-1). 연속 관측은 반복 호출로 구성해야 한다.
|
||||||
|
|
||||||
|
### 1-6. Phase 0에서 이미 검증된 판정 벡터
|
||||||
|
|
||||||
|
`tests/fixtures/issue_5/watcher/`의 7종과 `expected_policy.decision`은 3인 교차 리뷰를 통과한 계약이다. Phase 2a 엔진의 결정 함수는 **이 7종을 그대로 입력받아 같은 결정을 내야 한다**.
|
||||||
|
|
||||||
|
| 시나리오 | 기대 결정 |
|
||||||
|
|---|---|
|
||||||
|
| `initial_idle` | `hold` (실행 앵커 없음) |
|
||||||
|
| `tool_running` | `hold` (도구 실행 중) |
|
||||||
|
| `report_pause_15s` | `hold` (매니페스트 없음 — 디바운스만으로 완료 금지) |
|
||||||
|
| `finalized_candidate` | `candidate_approved` |
|
||||||
|
| `blocked_permission` | `hold_blocked` |
|
||||||
|
| `transport_disconnected` | `hold_reconnect` |
|
||||||
|
| `restarted_generation` | `reject_stale` |
|
||||||
|
|
||||||
|
`dispatch_context` 필드: `job_id`, `attempt_id`, `agent_session_uuid`, `pane_process_generation`, `dispatch_ack`, `execution_anchor_observed`.
|
||||||
|
|
||||||
|
### 1-7. Phase 1에서 넘어온 인계 사항 (본 Phase가 게이트)
|
||||||
|
|
||||||
|
1. **`preexec_fn` (3인 리뷰어 공통 지적)**: `daemon.spawn`은 `preexec_fn`으로 SIGHUP을 무시하는데 이는 **멀티스레드 프로세스에서 안전하지 않다**. Shadow watcher가 스레드를 쓰면서 `DelegationService.ensure_subscriber_daemon`을 호출하면 이 게이트에 걸린다(§D9).
|
||||||
|
2. **O-4 (Claude)**: freeze 트리의 `_delegate_py_bin`은 `.venv`를 찾지 못해 paho 없는 시스템 python으로 떨어진다. Phase 2a가 freeze `lib.sh` 헬퍼를 호출하지 않는 한 무해하다(§D9에서 재확인).
|
||||||
|
3. **연결 지문 하드코딩 필드 목록**: `BrokerConfig` 필드 추가 시 조용히 누락된다. Phase 2a에서 `dataclasses.fields()` 유도로 바꾸거나 커버리지 단정 테스트를 추가한다(§3 P2A-04에 포함).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 설계 결정 (Design Decisions)
|
||||||
|
|
||||||
|
### D1. 패키지 배치
|
||||||
|
|
||||||
|
```
|
||||||
|
.agents/skills/lib_py/loop/
|
||||||
|
__init__.py # 공개 API 재수출
|
||||||
|
evidence.py # 증거 튜플 정규화 (watcher → engine 사이의 유일한 자료형)
|
||||||
|
attribution.py # 4중 귀속 + dispatch anchor 판정 (순수 함수)
|
||||||
|
policy.py # 결정 함수: 증거 -> hold|hold_blocked|hold_reconnect|reject_stale|candidate_approved
|
||||||
|
engine.py # 단일 전이 주체 + CAS 트랜잭션 경계
|
||||||
|
watcher.py # herdr agent wait 래핑, 재연결 백오프, 스냅샷 수집
|
||||||
|
shadow.py # Shadow 러너: watcher -> policy -> "would-complete" 로깅 (쓰기 없음)
|
||||||
|
store.py # shadow 전용 귀속/증거 저장소 (레지스트리와 분리)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`policy.py`는 순수 함수여야 한다**(I/O·시계·전역 상태 금지). Phase 0 fixture 7종을 그대로 먹여 테스트하기 위한 필수 조건이다.
|
||||||
|
- **D1(b) 재적용**: `lib_py.loop`는 `lib_py.delegation` **외의** 형제 `lib_py` 모듈을 임포트하지 않는다. 이유는 Phase 1과 동일하다 — freeze 하에서 부모 `lib_py`와 바인딩된 패키지가 다른 트리일 수 있다. AST grep 테스트로 고정한다(§D8).
|
||||||
|
- Python 3.9 호환 필수(`match`, PEP 604 런타임 유니온 금지). 게이트에서 `compileall`.
|
||||||
|
|
||||||
|
### D2. 단일 CAS 전이 경계 (P2A-01)
|
||||||
|
|
||||||
|
**`engine.transition()`이 유일한 상태 writer가 된다.**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# lib_py/loop/engine.py
|
||||||
|
class TransitionResult(object):
|
||||||
|
__slots__ = ("committed", "from_status", "to_status", "reason", "record", "notifications")
|
||||||
|
|
||||||
|
def transition(job_id, request, *, registry_dir, expected_attempt=None,
|
||||||
|
expected_status=None, fields=None, source="unknown") -> TransitionResult:
|
||||||
|
"""유일한 상태 전이 주체.
|
||||||
|
|
||||||
|
락 안에서 하는 일: load -> CAS 판정 -> (허용 시) 기록 -> 알림 큐잉.
|
||||||
|
락 밖에서 하는 일: 알림 flush(감사 로그), MQTT 발행.
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
CAS 규칙은 `state-transitions.md` §3 표를 그대로 구현한다:
|
||||||
|
|
||||||
|
| 현재 | 요청 | 조건 | 결과 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `pending` | `claim`/`started` | 세션·attempt 일치 | `running` |
|
||||||
|
| `running` | `progress`/`permission_required` | — | `running` (메타만 갱신) |
|
||||||
|
| `running` | explicit terminal | 유효 세션 서명 | 요청 terminal |
|
||||||
|
| `terminal` | 동일 terminal 재수신 | — | **No-op 멱등 ACK** |
|
||||||
|
| `terminal` | 상충 신호 | — | **거부 + conflict 감사 기록** |
|
||||||
|
| 임의 | stale attempt/generation | 불일치 | **거부 + stale 경고** |
|
||||||
|
| `running` | `deadline_timeout` | 타임아웃 확인 | `error` |
|
||||||
|
|
||||||
|
**기존 writer 3곳의 라우팅**:
|
||||||
|
- W1 `update_job_status` → 내부적으로 `engine.transition(request="merge", ...)`를 호출하도록 교체. **시그니처·반환값·예외는 불변**(Phase 1의 G-1~G-4가 이 함수 계약에 걸려 있다).
|
||||||
|
- W2 `pick_pending` → `engine.transition(request="claim", expected_status="pending")`로 교체. 이로써 우회가 사라진다.
|
||||||
|
- W3 `next_seq` → 상태를 바꾸지 않으므로 그대로 두되, 같은 락을 쓰므로 **트랜잭션 시간 예산**에만 포함한다.
|
||||||
|
|
||||||
|
**롤백 스위치 `MAM_ENGINE_CAS`(기본 `1`)**: `0`이면 W1/W2가 **현재의 무조건 병합 본체**로 분기한다. Phase 1의 `MAM_DELEGATION_PY`와 동일한 패턴이며, legacy 본체는 `_*_legacy`로 보존한다(제거는 Phase 4).
|
||||||
|
|
||||||
|
### D3. 락 규율 — 락 안에서는 결정만, I/O는 밖에서
|
||||||
|
|
||||||
|
```
|
||||||
|
with registry_lock(registry_dir): # 짧게
|
||||||
|
record = load_job(...)
|
||||||
|
decision = _cas_decide(record, request, expected_attempt, expected_status)
|
||||||
|
if decision.commit:
|
||||||
|
_atomic_write_record(...) # 레코드 1회 쓰기만 허용
|
||||||
|
pending = decision.notifications # 감사 로그는 "쓰지 않고" 큐에 담는다
|
||||||
|
# 락 해제 후
|
||||||
|
_flush_notifications(pending) # 감사 로그 append / status mirror
|
||||||
|
```
|
||||||
|
|
||||||
|
- **금지**: 락 보유 중 `subprocess`, 소켓, MQTT, `herdr` 호출, `time.sleep`.
|
||||||
|
- **허용**: `load_job` 1회 + `_atomic_write_record` 1회.
|
||||||
|
- 감사 로그 mirror(`update_logged_status`/`append_event`)를 락 밖으로 옮긴다. **순서 보장 손실 우려**: 현재는 락 안이라 감사 이력이 직렬화된다. 이를 유지하기 위해 알림에 **CAS가 부여한 단조 `transition_seq`**를 실어 보내고, flush가 늦게 도착해도 순서를 복원할 수 있게 한다.
|
||||||
|
- 이 규율은 문장이 아니라 테스트로 강제한다: 락 획득 중 `subprocess.run`/`socket.socket`/`time.sleep`을 monkeypatch로 감시해 호출 0건을 단정한다(G5).
|
||||||
|
- **저장소에 이미 선례가 있다**: `pick_pending`은 레코드 쓰기를 락 안에서 끝내고 감사 로그 mirror(`update_logged_status`/`append_event`)를 **락 해제 후에** 수행한다(`registry.py:131-145`). D3은 새 패턴을 발명하는 것이 아니라 **이 기존 패턴을 `update_job_status`에도 적용해 일반화**하는 것이다.
|
||||||
|
|
||||||
|
### D4. `watcher.py` — `herdr agent wait` 래핑
|
||||||
|
|
||||||
|
```python
|
||||||
|
def wait_once(target, until=("idle", "done", "blocked"), timeout_ms=15000, run=subprocess.run) -> WaitOutcome
|
||||||
|
```
|
||||||
|
|
||||||
|
- `--timeout`은 **밀리초**로 전달한다(§1-5). 초 단위 인자를 받지 않는다 — 단위 혼동을 타입 이름(`timeout_ms`)으로 차단한다.
|
||||||
|
- `--until`을 **항상 명시**한다(생략 시 `working`이 빠지는 기본값에 의존하지 않는다).
|
||||||
|
- `exit 0` → stdout JSON 파싱 → `result.agent` 스냅샷.
|
||||||
|
- `exit 1` → **stderr** JSON 파싱 → `error.code`로 분기: `timeout`(정상적 무이벤트), `agent_not_found`(대상 소멸).
|
||||||
|
- 그 외 exit code / JSON 파싱 실패 / 실행 실패 → `transport_error`.
|
||||||
|
- **재연결 백오프**: `transport_error` 연속 발생 시 1s → 2s → 4s → … → 최대 16s. 백오프 대기는 **락 밖에서만** 한다. 복구 시 **캐시된 스냅샷을 폐기하고 재스냅샷**한다(`transport_disconnected` → `hold_reconnect` 정책과 일치).
|
||||||
|
- watcher는 **판정하지 않는다.** 증거 튜플만 만들어 큐에 넣는다(Phase 0 합의: watcher는 관측, engine은 결정).
|
||||||
|
|
||||||
|
### D5. Shadow 모드 — 쓰기 불가를 구조로 보장 (P2A-03)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# shadow.py
|
||||||
|
class ShadowRunner:
|
||||||
|
def __init__(self, ..., decide=policy.decide, sink=logging_sink):
|
||||||
|
... # engine 을 아예 주입받지 않는다
|
||||||
|
```
|
||||||
|
|
||||||
|
- **ShadowRunner는 `engine.transition`에 대한 참조를 갖지 않는다.** "쓰지 않기로 약속"이 아니라 **쓸 수단이 없다**. 이것이 P2A-03을 문장이 아니라 구조로 만드는 방법이다.
|
||||||
|
- 판정 결과는 `would_complete` 레코드로 **shadow 전용 로그**에만 남는다: `.mam/shadow/<job_id>.ndjson` (레지스트리와 다른 디렉터리).
|
||||||
|
- 검증(G4): shadow 러너를 시나리오 전량으로 돌린 전후에 **레지스트리 디렉터리 전체의 파일 목록·mtime·SHA-256이 바이트 동일**함을 단정한다. "status를 안 썼다"보다 강한 단정이다.
|
||||||
|
|
||||||
|
### D6. 귀속 증거는 shadow 전용 저장소에 (§1-4 결론)
|
||||||
|
|
||||||
|
`store.py`가 `.mam/shadow/attribution/<job_id>.json`을 소유한다. 담기는 것:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "job_id": "...", "attempt_id": 1, "agent_session_uuid": "...",
|
||||||
|
"pane_process_generation": "w2X:p1:1234:2026-09-06T00:00:00Z",
|
||||||
|
"dispatch_ack": true, "dispatch_ack_at": "...", "execution_anchor_observed": false }
|
||||||
|
```
|
||||||
|
|
||||||
|
- **job 레코드는 건드리지 않는다** → Phase 1의 스키마 불변 계약과 P2A-03을 동시에 만족한다.
|
||||||
|
- `attempt_id`는 Phase 2a에서 **shadow가 자체 부여**한다(디스패치 관측 시 1부터). Phase 2b에서 레코드로 승격할지는 그때 결정한다 — **이 계획서는 레코드 스키마를 바꾸지 않는다.**
|
||||||
|
- `pane_process_generation`은 `<pane_id>:<pid>:<start_iso>` 형식이며, Phase 0의 정체성 검증(`_validate_observed_herdr_item`)이 이미 이 형식을 강제한다.
|
||||||
|
|
||||||
|
### D7. 판정 테스트 벡터 = Phase 0 fixture (P2A-04의 절반)
|
||||||
|
|
||||||
|
`policy.decide(scenario) -> decision` 이 Phase 0의 7종 시나리오에 대해 `expected_policy.decision`과 **정확히 일치**해야 한다. 신규 fixture를 만들기 전에 **기존 계약을 먼저 만족**시킨다. 이렇게 하면:
|
||||||
|
- 판정 로직이 3인 리뷰를 통과한 계약에 묶인다.
|
||||||
|
- Phase 0의 `validate_watcher_scenario`가 fixture 무결성을 계속 지킨다.
|
||||||
|
|
||||||
|
추가 시나리오(P2A-04 요구)는 같은 스키마로 작성하고 **`validate_watcher_scenario`를 통과해야 한다**: `race_double_dispatch`, `attempt_reuse`, `partial_report`, `unknown_status`, `restart_midflight`.
|
||||||
|
|
||||||
|
### D8. 트리 일관성 (Phase 1 R1의 교훈 재적용)
|
||||||
|
|
||||||
|
- `lib_py.loop`는 `lib_py.delegation`만 형제로 임포트한다. 그 외 `lib_py.*` 금지 → AST 테스트.
|
||||||
|
- 루프 프로세스는 shim을 인프로세스로 임포트하지 않으므로 `lib_py.loop`와 `lib_py.delegation`은 부모 `lib_py`를 통해 **같은 트리**로 해석된다. 이를 테스트로 고정한다(`__file__` 접두 비교).
|
||||||
|
- Shadow 러너를 서브프로세스로 띄울 때는 Phase 1의 `_delegation_py`와 **동일한 명시 부트스트랩**을 쓴다. `python -m`은 cwd가 `PYTHONPATH`를 이기므로 금지한다(R1 재발 방지).
|
||||||
|
|
||||||
|
### D9. Phase 1 인계 게이트 해소
|
||||||
|
|
||||||
|
- **`preexec_fn`**: Shadow watcher가 스레드를 쓸 경우 `daemon.spawn`의 `preexec_fn`이 위험해진다. **Phase 2a에서는 shadow 러너가 데몬을 스폰하지 않는다**(관측만). `ensure_subscriber_daemon` 호출 금지를 grep 테스트로 고정하고, 실제 제거/대체는 Phase 2b 스폰 도입 시 처리한다.
|
||||||
|
- **O-4**: shadow는 freeze `lib.sh` 헬퍼를 호출하지 않으므로 인터프리터 해석 문제에 노출되지 않는다. 호출 0건을 grep으로 고정한다.
|
||||||
|
- **연결 지문 필드 목록**: `dataclasses.fields(BrokerConfig)` 전량이 지문에 포함됨을 단정하는 테스트를 추가한다(필드 추가 시 조용히 누락되는 것을 차단).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 작업 분해 (WBS) — 커밋 5개
|
||||||
|
|
||||||
|
### P2A-00 특성화 선행 (커밋 1: `test(issue-5): phase 2a writer inventory and lock discipline characterization`)
|
||||||
|
- **파일**: `tests/test_issue5_phase2a_loop.py`(신규)
|
||||||
|
- 현행 동작을 **먼저 고정**한다(Phase 1에서 효과가 입증된 순서):
|
||||||
|
- X-01 `update_job_status`가 무조건 병합임을 단정(현행 사실).
|
||||||
|
- X-02 `pick_pending`의 현행 계약 고정: `pending`만 claim하고 동시 claim 시 한 세션만 이긴다(라우팅 후에도 이 성질이 유지되어야 한다).
|
||||||
|
- X-03 락 보유 중 감사 로그 I/O가 **발생함**을 단정(현행 사실 — D3 이후 이 테스트는 반대로 뒤집힌다).
|
||||||
|
- X-04 `next_seq`/`register_job`/`record_outcome` 경로의 현행 부작용 스냅샷.
|
||||||
|
- **완료 기준**: 레거시 코드에서 전부 통과, 576 + 신규 통과.
|
||||||
|
|
||||||
|
### P2A-01 엔진·CAS 경계 (커밋 2: `feat(issue-5): single CAS transition boundary for every job-status writer`)
|
||||||
|
- **파일**: `lib_py/loop/{__init__,engine,evidence,attribution}.py`; `mqtt_common.update_job_status`·`registry.pick_pending` 라우팅; `MAM_ENGINE_CAS` 분기 + `_*_legacy` 보존.
|
||||||
|
- **테스트**: `state-transitions.md` §3 표 9행 전부에 대한 전이 테스트, 멱등 재수신, 상충 거부 + conflict 감사, stale attempt 거부, `MAM_ENGINE_CAS=0` 양 경로 parametrize, **락 내 I/O 0건 단정**(subprocess/socket/sleep monkeypatch).
|
||||||
|
- **완료 기준**: Phase 1의 G-1~G-4가 그대로 통과(계약 불변 증명), 전체 스위트 무회귀.
|
||||||
|
|
||||||
|
### P2A-02 watcher (커밋 3: `feat(issue-5): herdr agent wait watcher with reconnect backoff`)
|
||||||
|
- **파일**: `lib_py/loop/watcher.py`, `store.py`
|
||||||
|
- **테스트**: 가짜 `run`으로 exit 0/stdout, exit 1/stderr `timeout`, exit 1/stderr `agent_not_found`, 파싱 실패, 실행 실패 5경로; 백오프 수열(1,2,4,8,16 상한)과 **재연결 시 캐시 폐기**; `--timeout`이 **밀리초로 전달**됨을 argv 단정; `--until` 항상 명시됨을 argv 단정. **herdr 실제 호출 0건**(전부 주입된 가짜).
|
||||||
|
- **완료 기준**: 위 통과 + 실제 herdr로 1회 스모크(수동, 보고서 첨부).
|
||||||
|
|
||||||
|
### P2A-03 shadow 러너 (커밋 4: `feat(issue-5): shadow watcher that logs would-complete without writing`)
|
||||||
|
- **파일**: `lib_py/loop/{policy,shadow}.py`
|
||||||
|
- **테스트**: Phase 0 시나리오 7종 → `policy.decide`가 `expected_policy.decision`과 일치; 신규 시나리오 5종 추가(`validate_watcher_scenario` 통과 필수); **레지스트리 디렉터리 SHA-256 전후 동일** 단정; `ShadowRunner`가 `engine`/`update_job_status`/`_atomic_write_record`를 참조하지 않음을 AST로 단정.
|
||||||
|
- **완료 기준**: shadow 전량 실행 후 레지스트리 바이트 불변.
|
||||||
|
|
||||||
|
### P2A-04 통합·문서 (커밋 5: `docs(issue-5): phase 2a contracts, scenarios and task checklist`)
|
||||||
|
- race/attempt 재사용/부분 리포트/도구 실행/blocked/unknown/재시작 시나리오 실행 결과 기록.
|
||||||
|
- `state-transitions.md`에 구현 매핑 각주, `task.md` P2A-01~04 체크 + 증거, `implementation_plan.md` Phase 2a 상태 갱신, `MAM_ENGINE_CAS` 운영 문서.
|
||||||
|
- Phase 1 인계 3건 처리 상태 명시(§D9).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 게이트
|
||||||
|
|
||||||
|
| # | 조건 | 측정 |
|
||||||
|
|---|---|---|
|
||||||
|
| G1 | 기존 **576** 전부 통과 + 신규 전부 통과, skipped 0 | `pytest tests/ -q` (약 13분) |
|
||||||
|
| G2 | 셸 문법·공백 | `bash -n`, `git diff --check` (**범위** 기준) |
|
||||||
|
| G3 | Python 3.9 컴파일 | `compileall .agents/skills/lib_py/loop` |
|
||||||
|
| **G4** | **Shadow가 레지스트리에 0바이트 기록** | 실행 전후 레지스트리 디렉터리 파일 목록·SHA-256 동일 |
|
||||||
|
| **G5** | **락 내 I/O 배제** | 락 보유 중 subprocess/socket/sleep 호출 0건 (monkeypatch 감시) |
|
||||||
|
| G6 | 모든 상태 writer가 CAS 경유 | `_atomic_write_record` 직접 호출자가 engine·`register_job`·`next_seq`뿐임을 AST로 단정 |
|
||||||
|
| G7 | 판정이 Phase 0 계약과 일치 | 시나리오 7종 + 신규 5종 `expected_policy` 일치 |
|
||||||
|
| G8 | 롤백 | `MAM_ENGINE_CAS=0`에서 특성화 통과 + 라이브 스모크 |
|
||||||
|
| G9 | 트리 일관성 | `lib_py.loop`/`lib_py.delegation` 동일 트리, 형제 임포트 0건 |
|
||||||
|
|
||||||
|
Worker 보고서에는 G1 pytest 요약 원문, G4의 해시 비교 출력, G5의 감시 로그, G7 표를 **그대로** 붙인다. `file:line` 인용은 `grep -n` 출력으로 뒷받침한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 롤백
|
||||||
|
|
||||||
|
1. **즉시**: `MAM_ENGINE_CAS=0` → W1/W2가 보존된 legacy 무조건 병합으로 분기. Shadow는 애초에 쓰기가 없으므로 프로세스 종료만으로 흔적이 사라진다.
|
||||||
|
2. **프로세스**: Shadow 러너 종료 + 인메모리 큐 폐기. `.mam/shadow/`는 삭제해도 무해(레지스트리와 분리되어 있음).
|
||||||
|
3. **커밋 단위**: 5→4→3→2 순 `git revert`. 커밋 1(특성화)은 레거시에서도 통과하므로 남겨도 된다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 리스크와 완화
|
||||||
|
|
||||||
|
| # | 리스크 | 완화 |
|
||||||
|
|---|---|---|
|
||||||
|
| R1 | CAS 도입이 Phase 1의 G-1~G-4 계약을 깬다 | `update_job_status` 시그니처·반환·예외 불변 유지, 두 경로 parametrize |
|
||||||
|
| R2 | 감사 로그를 락 밖으로 빼면서 순서가 뒤바뀐다 | CAS가 부여하는 단조 `transition_seq`를 알림에 실어 순서 복원 |
|
||||||
|
| R3 | `pick_pending` 라우팅이 **이미 올바른** 동시 claim 경쟁을 깨뜨린다 | 라우팅 전에 X-02로 현행 성질을 고정하고, `expected_status="pending"` CAS가 동일 성질을 유지함을 동시 claim 테스트로 검증 |
|
||||||
|
| R4 | `--timeout` 단위 혼동(초 vs ms) | 인자명 `timeout_ms`, argv 단정 테스트 |
|
||||||
|
| R5 | shadow가 실수로 쓰기 경로를 얻는다 | 구조적 차단(engine 미주입) + AST 단정 + 해시 불변 단정 |
|
||||||
|
| R6 | freeze/live 트리 혼재 | D8 (형제 임포트 금지 + 명시 부트스트랩) |
|
||||||
|
| R7 | `preexec_fn` 스레드 비안전 | Phase 2a는 데몬 스폰 없음(grep 고정), 실제 대체는 Phase 2b |
|
||||||
|
| R8 | 실브로커/실herdr 의존 테스트 | 전부 주입된 가짜, 실호출 0건. 라이브 스모크는 수동·정보성 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 명시적 비범위
|
||||||
|
|
||||||
|
- **자동완료 실제 커밋** — Phase 2b. Phase 2a는 `would_complete` 로깅까지만.
|
||||||
|
- job 레코드 스키마 변경(`attempt_id` 등 승격) — Phase 2b.
|
||||||
|
- `run_loop.sh`의 파이썬화 — Phase 3.
|
||||||
|
- 래퍼·`reconcile.sh`의 상태 머신 변경 — 전 Phase 공통 불변.
|
||||||
|
- `preexec_fn` 제거 — Phase 2b.
|
||||||
|
- `pgrep` 다중 PID 정리, Windows — Phase 4 후보.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Worker 인수인계 체크리스트
|
||||||
|
|
||||||
|
- [ ] P2A-00 커밋 1: 현행 동작 특성화(W1 무조건 병합, W2 우회, 락 내 I/O 발생)가 **레거시 코드에서** 통과
|
||||||
|
- [ ] P2A-01 커밋 2: `engine.transition` 단일 경계, W1/W2 라우팅, `MAM_ENGINE_CAS` 양 경로, 락 내 I/O 0건
|
||||||
|
- [ ] P2A-02 커밋 3: watcher 5경로 + 백오프 + ms 단위 argv 단정
|
||||||
|
- [ ] P2A-03 커밋 4: Phase 0 시나리오 7종 일치 + 신규 5종, 레지스트리 해시 불변
|
||||||
|
- [ ] P2A-04 커밋 5: 문서·체크리스트·인계 3건 처리 상태
|
||||||
|
- [ ] 보고서: G1 원문, G4 해시 출력, G5 감시 로그, G7 표, `grep -n` 인용
|
||||||
|
- [ ] 커밋 접두: `test(issue-5)`, `feat(issue-5)`, `feat(issue-5)`, `feat(issue-5)`, `docs(issue-5)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 부록 A. 계획 수립 시 실행한 검증 명령 (재현용)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git log --oneline -1 # 015ab70
|
||||||
|
.venv/bin/python -m pytest tests/ --collect-only -q | tail -1 # 576
|
||||||
|
grep -rn "_atomic_write_record" --include='*.py' .agents | grep -v "def " # writer 5곳
|
||||||
|
sed -n '125,155p' .agents/skills/lib_py/delegation/registry.py # pick_pending 우회
|
||||||
|
sed -n '395,420p' .agents/skills/lib_py/delegation/mqtt_common.py # 락 내 감사 I/O
|
||||||
|
HERDR_SESSION_NAME=multi-agent-mux herdr agent wait --help # --timeout <MS>
|
||||||
|
cat tests/fixtures/issue_5/herdr/wait-contract.json # integer_ms
|
||||||
|
grep -rn "MAM_ENGINE_CAS" --include='*.py' --include='*.sh' . # 미존재
|
||||||
|
.venv/bin/python -c "import json,glob; print(sorted(json.load(open(sorted(glob.glob('.mam/jobs/*.json'))[-1]))))" # 레코드 키 (attempt_id 부재)
|
||||||
|
```
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
# 📑 Issue #5 Phase 2b 구현 계획서 — 이중 감지 자동완료 (Flag-Gated / Opt-In)
|
||||||
|
|
||||||
|
- **Job**: `df85e978` / **Planner**: `creator-claude-01`
|
||||||
|
- **기준 커밋**: `4a5916a` (branch `refactor`, working tree clean) / Python `3.9.6` / herdr `0.8.2` / **전체 테스트 664**
|
||||||
|
- **요구 출처**: `implementation_plan.md` §2 Phase 2b 행, `task.md` P2B-01~P2B-03, `state-transitions.md` §3~§6
|
||||||
|
- **문서 성격**: Worker가 그대로 실행할 수 있는 단계별 명세. §1의 모든 인용은 라이브 코드·실행으로 직접 확인했다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 요약 (TL;DR)
|
||||||
|
|
||||||
|
1. **매니페스트를 쓰는 주체가 이 Phase의 전부다.** 워처가 스스로 해시를 계산해 매니페스트를 만들면 그것은 "워처가 안정된 파일을 봤다"는 자기 증명일 뿐이며, Round 2에서 Codex가 R1으로 기각한 바로 그 형태다. **끝났다는 사실을 아는 쪽(에이전트)이 확정을 선언해야 한다**(§D1).
|
||||||
|
2. **그래서 미지원 에이전트는 자동으로 fail-closed가 된다.** 확정 선언을 하지 않으면 매니페스트가 없고, 매니페스트가 없으면 `policy`가 이미 `hold`한다(Phase 2a에서 구현·테스트 완료). P2B-03의 "미지원 경로 100% 보존"은 새 분기가 아니라 **기존 동작의 자연스러운 귀결**이다.
|
||||||
|
3. **`attempt_id`와 `iteration`의 의미 충돌을 먼저 정리해야 한다.** 엔진은 `record.get("attempt_id", record.get("iteration"))`로 대체하는데, 레코드에는 `attempt_id`가 **없고** `iteration`만 있다(실측). 지금은 우연히 값이 겹쳐 동작하지만 두 개념은 다르며, 자동완료 게이트가 이 값에 걸리므로 **명시적 결정 없이 진행하면 안 된다**(§D2).
|
||||||
|
4. **지연 알림은 새 저장소를 만들지 않는다.** `state-transitions.md` §5가 "독립 Outbox DB 신설 금지"를 명시했고, 레코드 안 `pending_notification` 필드를 같은 CAS 트랜잭션에서 쓴다(§D4).
|
||||||
|
5. **자동완료 허용 백엔드는 실측 증거가 있는 것으로 한정한다.** Phase 0 능력 행렬 실측: 정지 상태(`idle`/`done`) 관측 증거가 있는 것은 **claude·grok(idle), codex(done)** 뿐이고 **agy·hermes·opencode는 없다**(§1-5). 플래그가 켜져도 이 셋 외에는 자동완료하지 않는다.
|
||||||
|
6. **664개 무회귀 + 신규.** Phase 2a와 동일하게 특성화 선행 → 구현 → 문서 순으로 커밋 5개.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Planner 사전 실측
|
||||||
|
|
||||||
|
### 1-1. 자동완료에 필요한 것 중 **존재하지 않는 것**
|
||||||
|
|
||||||
|
| 필요 | 현황 (실측) |
|
||||||
|
|---|---|
|
||||||
|
| 완료 매니페스트 | **없음.** 저장소 어디에도 매니페스트를 쓰거나 읽는 프로덕션 코드가 0건. 리포트 디렉터리에는 `report-final.md` 하나뿐 |
|
||||||
|
| 리포트 원자적 확정 | **없음.** 에이전트가 `report-final.md`를 직접 쓴다. tmp+rename 없음 |
|
||||||
|
| `attempt_id` 레코드 필드 | **없음.** 레코드에는 `iteration`만 존재 |
|
||||||
|
| `pending_notification` | **없음.** `grep` 0건 |
|
||||||
|
| `MAM_AUTO_COMPLETE` | **없음.** `grep` 0건 (참고: `MAM_ENGINE_CAS` 5건, `MAM_DELEGATION_PY` 4건은 존재) |
|
||||||
|
|
||||||
|
### 1-2. 이미 준비되어 있는 것 (Phase 2a 산출물)
|
||||||
|
|
||||||
|
| 있음 | 위치 |
|
||||||
|
|---|---|
|
||||||
|
| 단일 CAS 전이 경계 + 권한 축 | `lib_py/delegation/engine.py::transition` |
|
||||||
|
| `expected_attempt` fail-closed | 레코드가 attempt를 못 대면 거부 (Round 1 R2) |
|
||||||
|
| 관측 증거 **필수** 판정 | `lib_py/loop/policy.py` — 4식별자 + 매니페스트 `attempt_id` 부재 시 `hold` (Round 2 R1) |
|
||||||
|
| 매니페스트 미확정 시 `hold` | `policy.decide`의 `manifest_committed`/`hash_verified` 게이트 |
|
||||||
|
| `herdr agent wait` 래핑·백오프 | `lib_py/loop/watcher.py` |
|
||||||
|
| 쓰기 경로 없는 shadow 러너 | `lib_py/loop/shadow.py` |
|
||||||
|
| 지연 알림의 순서 보장 기반 | `transition_seq` + per-job 감사 락 (Round 2 R4) |
|
||||||
|
|
||||||
|
**즉 Phase 2b는 판정기를 새로 만드는 것이 아니라, 이미 fail-closed로 판정하고 있는 게이트에 "진짜 증거"를 공급하고 그 결과를 커밋에 연결하는 작업이다.**
|
||||||
|
|
||||||
|
### 1-3. `attempt_id` vs `iteration` — 실행으로 확인한 충돌
|
||||||
|
|
||||||
|
```
|
||||||
|
record keys with attempt semantics: {'iteration': 1}
|
||||||
|
transition(completed, expected_attempt=1) -> committed=True running -> completed
|
||||||
|
transition(completed, expected_attempt=2) -> committed=False stale attempt: expected 2, record has 1
|
||||||
|
```
|
||||||
|
엔진은 `attempt_id`가 없으면 `iteration`을 쓴다. 래퍼의 loop 흐름에서는 `redispatch`가 `iteration`을 증가시키므로 두 값이 우연히 일치하지만, `iteration`은 **루프 회차**이고 `attempt_id`는 **디스패치 시도**다. 직접(`--type direct`) 재디스패치나 크래시 후 재시도에서 갈라진다. 자동완료 게이트가 이 값에 걸리므로 §D2에서 명시적으로 결정한다.
|
||||||
|
|
||||||
|
### 1-4. 미지원 경로 보존의 실제 메커니즘
|
||||||
|
|
||||||
|
`job_subscriber._check_disk_fallback`(`:60-72`)은 레지스트리 status를 읽어 terminal이면 즉시 종료한다. 즉 **자동완료가 `completed`를 쓰면 래퍼는 명시 이벤트와 구별하지 못한다.** 이는 의도된 결합이지만, 동시에 "자동완료가 틀리면 래퍼가 조용히 잘못된 완료를 받는다"는 뜻이므로 §D6의 감사 표식이 필수다.
|
||||||
|
|
||||||
|
### 1-5. 자동완료 허용 백엔드 (Phase 0 능력 행렬 실측)
|
||||||
|
|
||||||
|
```
|
||||||
|
claude observed slots: ['idle']
|
||||||
|
agy observed slots: ['working', 'wait_timeout']
|
||||||
|
hermes observed slots: NONE
|
||||||
|
grok observed slots: ['idle']
|
||||||
|
opencode observed slots: NONE
|
||||||
|
codex observed slots: ['done']
|
||||||
|
```
|
||||||
|
자동완료는 **정지 상태 관측**을 전제로 한다. `idle`/`done` 증거가 있는 **claude·grok·codex**만 후보이며, `agy`는 `working`만 관측돼 정지 판정의 실측 근거가 없고, `hermes`·`opencode`는 관측 0건이다.
|
||||||
|
|
||||||
|
### 1-6. 에이전트 지시문 주입 지점 (조정 비용)
|
||||||
|
|
||||||
|
`create_session.sh:483`과 래퍼 `:202`/`:319`가 `publish_event.py --job <id>`를 지시문에 넣는다. 확정 선언 명령을 추가하려면 **이 두 곳만** 바꾸면 된다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 설계 결정
|
||||||
|
|
||||||
|
### D1. 매니페스트는 **끝난 쪽이 선언한다** (P2B-01의 핵심)
|
||||||
|
|
||||||
|
```
|
||||||
|
에이전트: 작업 종료 시
|
||||||
|
publish_event.py --job <id> --finalize-report <path> [--side-effect <kind>=<evidence>] ...
|
||||||
|
① <path>.tmp 를 <path> 로 원자적 rename (이미 최종본이면 rename 생략)
|
||||||
|
② SHA-256 계산
|
||||||
|
③ <report_dir>/report-manifest.json 원자적 기록:
|
||||||
|
{ schema_version, job_id, attempt_id, report_path, sha256,
|
||||||
|
finalized_at, side_effects: [...], declared_by }
|
||||||
|
```
|
||||||
|
|
||||||
|
- **워처는 매니페스트를 만들지 않는다.** 만들면 자기 증명이 되어 Round 2 R1에서 기각된 형태가 된다. 워처는 **읽고 검증만** 한다(해시 재계산 후 대조).
|
||||||
|
- **미지원 에이전트는 아무것도 하지 않으면 된다** — 매니페스트가 없으면 `policy`가 이미 `hold`한다. P2B-03의 "explicit event 경로 100% 보존"이 **새 코드 없이** 성립한다.
|
||||||
|
- 리포트가 재작성되면 해시가 어긋나 매니페스트가 즉시 무효가 된다(§4 계약 그대로).
|
||||||
|
|
||||||
|
### D2. `attempt_id`를 레코드의 1급 필드로 승격 (결정 필요 사항)
|
||||||
|
|
||||||
|
**권고: 승격한다.** 근거:
|
||||||
|
- 엔진의 `expected_attempt`는 이미 "레코드가 attempt를 대지 못하면 거부"로 fail-closed다(Round 1 R2). 자동완료가 이 게이트를 통과하려면 레코드가 attempt를 대야 한다.
|
||||||
|
- `iteration` 대체는 우연한 일치에 의존한다. 두 개념이 갈라지는 순간 **CAS 가드가 조용히 무력화**된다.
|
||||||
|
|
||||||
|
구현:
|
||||||
|
- `register_job`이 `attempt_id: 1`을 기록. `engine`의 `redispatch`가 `iteration`과 함께 `attempt_id`를 증가.
|
||||||
|
- 엔진의 `record.get("attempt_id", record.get("iteration"))` 대체를 **제거**하고 `attempt_id`만 본다(대체는 의미 혼동의 원천).
|
||||||
|
- **스키마 영향**: 필드 추가이므로 기존 리더는 무시하면 되고 `SCHEMA_VERSION`은 1을 유지한다. 다만 Phase 1이 "레코드 필드 불변"을 계약으로 명시했으므로 **이 계획서가 그 계약의 변경을 명시적으로 요청**한다. 리뷰어 승인 없이 진행하지 않는다.
|
||||||
|
- 마이그레이션: `attempt_id`가 없는 기존 레코드는 `iteration` 값으로 1회 채운다(읽기 시 lazy, 쓰기는 다음 전이에서).
|
||||||
|
|
||||||
|
### D3. 자동완료 게이트 (기존 판정기에 증거를 공급)
|
||||||
|
|
||||||
|
`shadow.ShadowRunner`를 `AutoCompleteRunner`로 확장하되 **판정 로직은 손대지 않는다**. 순서:
|
||||||
|
|
||||||
|
1. `watcher.wait_once`로 정지 상태 관측 → `collector_attribution` 봉투에 4식별자를 채워 증거 구성.
|
||||||
|
2. `report-manifest.json` 읽기 → 해시 **재계산 후 대조**(매니페스트 자기 신고를 신뢰하지 않는다) → `report_manifest.attempt_id`를 증거에 포함.
|
||||||
|
3. `policy.decide` → `candidate_approved`가 아니면 종료(로그만).
|
||||||
|
4. `candidate_approved`면 **게이트 3중 확인 후** `engine.transition(job_id, "completed", expected_attempt=<attempt>, source="auto_complete")`.
|
||||||
|
- 게이트: 플래그 ON + 백엔드 허용목록(§1-5) + 잡별 opt-in.
|
||||||
|
5. 커밋 결과가 `committed=False`면 **아무 일도 없었던 것으로 처리**하고 감사에 기록(늦은 conflict는 엔진이 이미 거부한다 — 새 코드 불필요).
|
||||||
|
|
||||||
|
### D4. 지연 알림 (P2B-02) — 레코드 안 outbox
|
||||||
|
|
||||||
|
`state-transitions.md` §5가 **독립 Outbox DB 신설을 금지**했다. 따라서:
|
||||||
|
|
||||||
|
- `engine.transition`이 커밋할 때 같은 락 안에서 `pending_notification: {event, seq, timestamp, signature}`를 레코드에 **함께** 기록한다(추가 I/O 없음 — 이미 쓰는 레코드에 필드 하나).
|
||||||
|
- 락 해제 후 `notifier.flush(job_id)`가 MQTT 발행 → 성공 시 `pending_notification`을 지우는 **별도 CAS 전이**(`request="notification_settled"`, 상태 불변).
|
||||||
|
- **재시작 복구**: 기동 시 `pending_notification`이 남은 레코드를 스캔해 재발행. 멱등하다 — 구독자가 `seq`로 중복을 거른다(기존 replay 방어).
|
||||||
|
- **락 규율 유지**: 발행은 절대 락 안에서 하지 않는다(Phase 2a G5 테스트가 계속 감시).
|
||||||
|
|
||||||
|
### D5. 부수효과 증거 (P2B-01 후반)
|
||||||
|
|
||||||
|
- 레코드의 기존 `expected_artifacts`(이미 존재, `--artifact`로 설정)를 **선언된 부수효과 목록**으로 사용한다. 새 필드를 만들지 않는다.
|
||||||
|
- `expected_artifacts`가 비어 있지 않은 job은 매니페스트의 `side_effects`가 **모든 항목을 덮어야** 자동완료 후보가 된다. 하나라도 증거가 없으면 `hold`.
|
||||||
|
- 증거 형식은 `{kind, reference, verified_at}`이며, Phase 2b는 **존재와 대응만** 검증한다(원격 확인은 비범위 — 정직하게 명시).
|
||||||
|
|
||||||
|
### D6. 플래그와 감사 표식 (P2B-03)
|
||||||
|
|
||||||
|
| 스위치 | 기본 | 범위 |
|
||||||
|
|---|---|---|
|
||||||
|
| `MAM_AUTO_COMPLETE` | **`0` (OFF)** | 전역 킬 스위치. `1`이어야 자동완료가 가능 |
|
||||||
|
| 백엔드 허용목록 | `claude,grok,codex` | §1-5 실측 근거. `MAM_AUTO_COMPLETE_BACKENDS`로 좁힐 수만 있게(넓히려면 증거 필요) |
|
||||||
|
| 잡별 opt-in | 레코드 `auto_complete: true` | 셋 다 참일 때만 커밋 |
|
||||||
|
|
||||||
|
**감사 표식 필수**: 자동완료로 들어간 terminal은 `completed_by: "auto_complete"`와 판정 근거(매니페스트 해시, 관측 seq)를 감사 로그에 남긴다. §1-4에서 확인했듯 래퍼는 자동/명시를 구별하지 못하므로, **사후에 구별할 수 있는 유일한 수단**이다.
|
||||||
|
|
||||||
|
### D7. 무판정 fail-closed
|
||||||
|
|
||||||
|
정책이 예외를 던지거나 알 수 없는 결정을 반환하면 `hold`로 취급한다. 자동완료 경로 전체를 `try/except`로 감싸 **어떤 실패도 커밋으로 이어지지 않게** 한다. "판정 못 함"은 절대 "완료"가 아니다.
|
||||||
|
|
||||||
|
### D8. 트리·권한 규칙 승계
|
||||||
|
|
||||||
|
- `lib_py.loop`는 `lib_py.delegation` 외 형제 임포트 금지(Phase 2a G9).
|
||||||
|
- 자동완료의 `source`는 **`auto_complete`** 이며 운영자 권한이 **아니다** — terminal을 되돌릴 수 없다.
|
||||||
|
- `preexec_fn`: 알림 재발행기가 스레드를 쓰면 Phase 1 인계 게이트에 걸린다. **2b는 데몬을 스폰하지 않고** 기존 구독자 프로세스 안에서 flush한다. 스폰이 필요해지면 그때 `preexec_fn`을 교체한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 작업 분해 (WBS) — 커밋 5개
|
||||||
|
|
||||||
|
### P2B-00 특성화 선행 (커밋 1: `test(issue-5): phase 2b pre-conditions and fail-closed characterization`)
|
||||||
|
- 현행 고정: 매니페스트 없는 job은 `hold`(이미 참), `MAM_AUTO_COMPLETE` 부재 시 자동완료 0건, `attempt_id` 부재 시 `expected_attempt`가 거부, 미지원 백엔드 목록.
|
||||||
|
- **완료 기준**: 레거시에서 전부 통과, 664 + 신규 통과.
|
||||||
|
|
||||||
|
### P2B-01 확정 계약 (커밋 2: `feat(issue-5): report finalization manifest and side-effect evidence`)
|
||||||
|
- **파일**: `lib_py/loop/finalize.py`(매니페스트 작성·검증), `publish_event.py`에 `--finalize-report`/`--side-effect`, `create_session.sh:483`·래퍼 `:202/:319` 지시문 1줄 추가.
|
||||||
|
- **테스트**: tmp→rename 원자성, 해시 재계산 대조(위조 매니페스트 거부), 리포트 재작성 시 무효화, `expected_artifacts` 미충족 시 `hold`, 매니페스트 부재 시 `hold`(불변).
|
||||||
|
|
||||||
|
### P2B-02 CAS 연동 + 지연 알림 (커밋 3: `feat(issue-5): auto-complete gate and durable pending notification`)
|
||||||
|
- **파일**: `lib_py/loop/autocomplete.py`, `lib_py/delegation/notifier.py`, `engine`에 `pending_notification` 기록 및 `notification_settled` 요청.
|
||||||
|
- **테스트**: 게이트 3중 확인, 늦은 conflict 거부(엔진 기존 동작), 크래시 후 재시작 재발행, 중복 발행 멱등, **락 안 발행 0건**(Phase 2a G5 재사용), `attempt_id` 불일치 시 거부.
|
||||||
|
|
||||||
|
### P2B-03 플래그·허용목록 (커밋 4: `feat(issue-5): default-off auto-complete flag with an evidence-backed backend allowlist`)
|
||||||
|
- **파일**: 플래그 3종, `completed_by` 감사 표식.
|
||||||
|
- **테스트**: 기본 OFF에서 자동완료 0건이며 명시 경로 결과가 **바이트 동일**, 허용목록 밖 백엔드는 플래그 ON에서도 미커밋, 잡별 opt-in 미설정 시 미커밋, 자동완료 terminal에 `completed_by` 존재.
|
||||||
|
|
||||||
|
### P2B-04 시나리오·문서 (커밋 5: `docs(issue-5): phase 2b contracts, scenarios and task checklist`)
|
||||||
|
- 경쟁/재시작/부분 리포트/부수효과 미충족/무판정 시나리오를 Phase 0 스키마로 추가(기존 `validate_watcher_scenario` 통과 필수).
|
||||||
|
- `state-transitions.md` §4·§5 구현 매핑, `task.md` P2B-01~03 증거, 운영 노트(`phase2b-autocomplete.md`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 게이트
|
||||||
|
|
||||||
|
| # | 조건 | 측정 |
|
||||||
|
|---|---|---|
|
||||||
|
| G1 | 기존 **664** 무회귀 + 신규 통과, skipped 0 | `pytest tests/ -q` |
|
||||||
|
| G2 | 셸 문법·공백 | `bash -n`, **범위** `git diff --check` |
|
||||||
|
| G3 | Python 3.9 컴파일 | `compileall` |
|
||||||
|
| **G4** | **기본 OFF에서 자동완료 0건** | 플래그 미설정 시 레지스트리 전후 해시 동일, 명시 경로 결과 바이트 동일 |
|
||||||
|
| **G5** | **매니페스트 위조 거부** | 해시 불일치·attempt 불일치·부재 전부 `hold` |
|
||||||
|
| G6 | 락 안 I/O 0건 | Phase 2a G5 감시 테스트 확장(발행 포함) |
|
||||||
|
| G7 | 재시작 복구 | 크래시 시뮬레이션 후 `pending_notification` 재발행 및 멱등 |
|
||||||
|
| G8 | 롤백 | `MAM_AUTO_COMPLETE=0` 즉시 중단, 이미 확정된 terminal은 보존 |
|
||||||
|
| G9 | 허용목록 | 증거 없는 백엔드는 플래그 ON에서도 미커밋 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 롤백
|
||||||
|
|
||||||
|
1. **즉시**: `MAM_AUTO_COMPLETE=0`. 신규 자동완료만 중단되고 **이미 확정된 terminal 레코드는 보존**한다(`implementation_plan.md` Phase 2b 롤백 조건 그대로).
|
||||||
|
2. `MAM_ENGINE_CAS=0`은 Phase 2a 경로까지 함께 되돌린다(자동완료는 CAS 위에 있으므로 자동으로 무력화).
|
||||||
|
3. 커밋 단위 revert는 5→2 순. 커밋 1(특성화)은 남겨도 무해.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 리스크
|
||||||
|
|
||||||
|
| # | 리스크 | 완화 |
|
||||||
|
|---|---|---|
|
||||||
|
| R1 | 워처가 스스로 매니페스트를 만들어 자기 증명이 됨 | D1: 작성 주체를 에이전트로 고정, 워처는 해시 재계산 대조만 |
|
||||||
|
| R2 | `attempt_id`/`iteration` 혼동으로 CAS 가드 무력화 | D2: 승격 + 대체 제거. **리뷰어 승인 전 착수 금지** |
|
||||||
|
| R3 | 잘못된 자동완료를 래퍼가 구별 못 함 | D6 `completed_by` 감사 표식 + 기본 OFF + 허용목록 |
|
||||||
|
| R4 | 지연 알림이 새 저장소를 만들어 계약 위반 | D4: 레코드 내 필드, §5 명시 준수 |
|
||||||
|
| R5 | 알림 재발행기가 스레드를 써 `preexec_fn` 게이트에 걸림 | D8: 2b는 스폰 없음 |
|
||||||
|
| R6 | 부수효과 "검증"을 과대 주장 | D5: 존재·대응만 검증한다고 문서에 명시, 원격 확인은 비범위 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 명시적 비범위
|
||||||
|
|
||||||
|
- `run_loop.sh` 파이썬화 — Phase 3.
|
||||||
|
- 부수효과의 **원격** 확인(실제 git push 도달 여부 등) — 증거 대응까지만.
|
||||||
|
- `agy`/`hermes`/`opencode` 자동완료 — 정지 상태 실측 증거 확보 후 별도 판단.
|
||||||
|
- `preexec_fn` 교체 — 스폰이 필요해지는 시점.
|
||||||
|
- 스키마 v2 — `attempt_id` 추가는 v1 유지(추가 필드).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Worker 인수인계 체크리스트
|
||||||
|
|
||||||
|
- [ ] **착수 전**: §D2(`attempt_id` 승격 = Phase 1 스키마 계약 변경)에 대한 **리뷰어 승인**을 받을 것
|
||||||
|
- [ ] P2B-00 커밋 1: 현행 fail-closed 특성화가 레거시에서 통과
|
||||||
|
- [ ] P2B-01 커밋 2: 매니페스트 작성/검증, 해시 재계산, 부수효과 대응
|
||||||
|
- [ ] P2B-02 커밋 3: 게이트 연동 + `pending_notification` + 재시작 복구
|
||||||
|
- [ ] P2B-03 커밋 4: 플래그 3종 + 허용목록 + `completed_by`
|
||||||
|
- [ ] P2B-04 커밋 5: 시나리오·문서
|
||||||
|
- [ ] 보고서: G1 원문, G4 해시 비교, G7 복구 로그, `grep -n` 인용
|
||||||
|
- [ ] 커밋 접두: `test/feat/feat/feat/docs(issue-5)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 부록 A. 계획 수립 시 실행한 검증 명령
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git log --oneline -1 # 4a5916a
|
||||||
|
.venv/bin/python -m pytest tests/ --collect-only -q | tail -1 # 664
|
||||||
|
find .mam/jobs -name "*manifest*" # 없음
|
||||||
|
grep -rn "pending_notification\|MAM_AUTO_COMPLETE" --include='*.py' .agents # 0건
|
||||||
|
grep -n 'record.get("attempt_id", record.get("iteration"))' .../engine.py # 대체 확인
|
||||||
|
python -c "... transition(completed, expected_attempt=1|2) ..." # iteration 대체 실증
|
||||||
|
python -c "... herdr/manifest.json observed slots ..." # 백엔드 증거 행렬
|
||||||
|
grep -n "publish_event.py --job" create_session.sh 래퍼 # 지시문 주입 2곳
|
||||||
|
```
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# 🛠️ Creator 수정 리포트 — codex R1(F2 실패 캐시) 반영 (job `5b2974f5`)
|
||||||
|
|
||||||
|
- **Author**: `creator-claude-01` (role: creator) · 2026-10-02
|
||||||
|
- **작업 트리**: `/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux-wt/followups` · `fix/followups-41-43` · `95eabc6` → **`75f364e`** (커밋 1개 추가, 미커밋 변경 없음, ignored 는 `.venv/` 뿐)
|
||||||
|
- **범위**: R1 수정 + 경미한 문서 권고 1건. push·merge·PR·버전 bump 없음. 실제 `.mam`(이 리포트 제외)·`.mam.env`(export 안 함)·세션·herdr·모니터 무조작.
|
||||||
|
|
||||||
|
## 1. 판단
|
||||||
|
|
||||||
|
R1 은 타당하다 — 반론 없음. `ensure_env_loaded()` 가 `_load_dotenv()` 전에 `_env_loaded = True` 를 세웠고, F2 의 `load_env_or_report()` 가 예외를 반환값으로 바꾸면서 같은 프로세스의 두 번째 호출이 깨진 파일을 건너뛰게 됐다. 리뷰어가 권한 방향(성공 뒤에만 캐시)을 그대로 택했다.
|
||||||
|
|
||||||
|
## 2. 변경 (`75f364e`)
|
||||||
|
|
||||||
|
- `.agents/skills/lib_py/delegation/mqtt_common.py` `ensure_env_loaded`: `_load_dotenv(workspace_dir)` **뒤에** `_env_loaded = True`. 실패는 다음 호출에서 다시 읽고, 성공은 전처럼 프로세스당 한 번. `envfile.apply` 는 파일 전체를 파싱한 뒤에 대입하므로 실패한 로드는 환경을 하나도 바꾸지 않는다(부분 적용 없음) — 재시도가 안전하다. 라이브러리 직접 호출(`ensure_env_loaded`)도 같은 규칙이 된다.
|
||||||
|
- `MESSAGING.md:369`(경미 권고): ERROR **한 줄**은 Python 진입점의 것이고, 래퍼는 그 줄 뒤에 `ERROR: cannot load <path> (see the line above)` 를 한 줄 더 찍는다고 정정.
|
||||||
|
- `tests/test_issue26_envfile.py::test_a1_a_failed_load_is_not_cached_as_loaded` — 한 프로세스에서 실제 `registry.main(['--registry-dir', <jobs>, 'register', '--prompt', …])` 를 깨진 UTF-8 파일로 두 번 호출하고 파일을 복구·변경하며 확인:
|
||||||
|
|
||||||
|
| 리뷰어 요구 | 단언 |
|
||||||
|
|---|---|
|
||||||
|
| (a) 연속 실패 두 번 모두 rc 1 | `rc == [1, 1]`, ERROR 줄이 두 번 |
|
||||||
|
| (d) 실패 중 등록 없음 | `registered == []` |
|
||||||
|
| (b) 복구 후 설정 적용 | `load_env_or_report()` → `[True, "from-file"]` |
|
||||||
|
| (c) 성공 후 once-per-process 유지 | 파일을 바꿔도 `[True, "from-file"]` |
|
||||||
|
|
||||||
|
## 3. 역검증
|
||||||
|
|
||||||
|
`git worktree add --detach <scratch>/head 95eabc6` 에 새 테스트 파일만 복사해 실행 → **실패**: `{'rc': [1, 0], 'registered': ['421e9769.json'], 'recovered': [True, None], 'cached': [True, None]}` (`assert [1, 0] == [1, 1]`). 리뷰어 탐침과 같은 결과다. 임시 워크트리는 제거했다(남은 워크트리: 본 체크아웃, followups, 그리고 내가 만들지 않은 `multi-agent-mux-wt/fu-suite` — 손대지 않음).
|
||||||
|
|
||||||
|
## 4. 테스트 (수정 후, 브리프 명령 형식, `.mam.env` export 없음)
|
||||||
|
|
||||||
|
`tests/test_issue26_envfile.py tests/test_issue5_phase1_delegation.py tests/test_tier1_unit.py tests/test_version_consistency.py tests/test_issue18_liveness_contract.py` → **205 passed in 178.52s** (envfile 38 · issue5 66 · tier1 67 · version 3 · issue18 31). issue5 의 `ensure_env_loaded` 멱등 단언(`:506-508`)도 통과. 바뀐 것은 env 로딩 캐시 한 곳뿐이라 나머지(F1·F3~F6) 파일은 다시 돌리지 않았다 — 직전 실행(`1e92ddc`/`95eabc6`) 결과와 리뷰어의 303 passed·52 passed 가 유효하다.
|
||||||
|
|
||||||
|
## 5. 위생
|
||||||
|
|
||||||
|
- `test_version_consistency` 가 기존부터 작업 트리에 `.mam/shim/herdr` 를 쓴다(직전 리포트에 보고한 base 동작). 내용이 그 심 하나뿐임을 확인하고 지웠다.
|
||||||
|
- scratchpad 경로를 가진 남은 프로세스 0개.
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
# 🛠️ Creator 리포트 — PR #41·#43 후속 F1~F6 (job `aa7f2b5f`)
|
||||||
|
|
||||||
|
- **Author**: `creator-claude-01` (role: creator) · 2026-10-02
|
||||||
|
- **작업 트리**: `/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux-wt/followups` · 브랜치 `fix/followups-41-43` · base `0051fd6` → head **`95eabc6`** (커밋 8개, 미커밋 변경 없음, ignored 는 `.venv/` 뿐)
|
||||||
|
- **범위 준수**: 본 체크아웃(`main @ 0051fd6`)은 읽기만 했다. push·merge·PR·이슈 변경 없음, 버전 bump 없음(4.8.1 그대로). #17 항목 4·#39·#40·#42 범위는 건드리지 않았다. 실제 `.mam`(이 리포트 파일 제외)·`.mam.env`(읽지도 출력하지도 않음)·모니터·에이전트 세션·herdr 서버는 손대지 않았고 `.mam.env` 는 export 하지 않았다(테스트는 conftest 의 로컬 브로커). basetemp·JUnit·로그·역검증용 임시 워크트리는 모두 작업 트리 밖 scratchpad 에 두었고 임시 워크트리는 제거했다.
|
||||||
|
|
||||||
|
## 1. 커밋
|
||||||
|
|
||||||
|
| 커밋 | 항목 | 요지 |
|
||||||
|
|---|---|---|
|
||||||
|
| `75936f1` | F1 | A8 이 마커 lstart 를 `loop_lock.sh` `mam_lstart` 와 같은 정규화(공백 압축·양끝 trim)로 기록. 두 칸 공백 `ps` 심 변형으로 날짜와 무관하게 고정. 제품 코드 무변경 |
|
||||||
|
| `5edf913` | F2 | `mqtt_common.load_env_or_report()` — publish_event·registry·job_subscriber·cli·bench·heartbeat·remote 의 main 이 `ERROR: <경로>: cannot read (<예외 종류>)` 한 줄 + rc 1. `ensure_env_loaded()` 는 여전히 예외(라이브러리는 exit 하지 않음) |
|
||||||
|
| `95eabc6` | F2 보정 | job_subscriber 는 종료 코드를 이름으로 돌려준다(`test_issue5` 스냅숏): `return RC_ERROR`(값 1 그대로) |
|
||||||
|
| `71ea809` | F3 | (a) 공통 파서를 `utf-8-sig` 로(BOM 제거, 두 로더 공통) (b) 중복 키 첫 값 우선을 `.mam.env.example` FORMAT·`envfile.py` 독스트링에 명시(동작 그대로) (c) `MESSAGING.md:369` 를 실제 동작(없으면 조용히 건너뜀·주변 탐색 안 함, 있는데 못 읽으면 `EnvFileError` → ERROR 한 줄 rc 1)으로 |
|
||||||
|
| `8b04639` | F4a·F4c | 러너 `_int_knob` = `_positive_int` 규칙 + 잘못된 값 경고 1회 후 기본값. loop SKILL.md 예산 행·하위 잡 문단에 `MAM_WAIT_GRACE_S` 와 정수 규칙 |
|
||||||
|
| `fb1b93c` | F4b | bash 노브 8개를 `mam_int_knob` 으로. `mam_int`/`mam_int_knob` 를 `lib_int.sh` 로 **그대로 이동**(lib.sh 가 source, lib.sh 없이 도는 `loop_lock.sh`·`diff_collect.sh` 는 함수가 없을 때 직접 source) |
|
||||||
|
| `c252ac4` | F5 | `stall_watch.py` 의 `MAM_TEST_UNRELIABLE_AGENT` 분기 삭제, S13 은 어댑터 seam 으로 |
|
||||||
|
| `1e92ddc` | F6 | delegate-job SKILL.md `<= 60 s` 를 herdr wait 에 한정, "대기 중 래퍼 소멸" 테스트를 두 번째 wait 안에서 소멸하도록 |
|
||||||
|
|
||||||
|
## 2. 항목별 내용과 완료 조건
|
||||||
|
|
||||||
|
### F1 — 날짜 의존 A8
|
||||||
|
- 원인: `ps -o lstart=` 는 한 자리 날짜를 `Oct 2` 로 두 칸 패딩한다. 제품 `mam_lstart` 는 `tr -s ' '` 로 줄여 비교하는데 A8 은 `.strip()` 만 한 원값을 기록 → 살아 있는 holder 가 stale 로 보여 두 번째 루프가 거부되지 않음(매달 1~9일).
|
||||||
|
- 수정(`tests/test_issue21_acceptance.py`): `_marker_lstart()` 가 `" ".join(out.split())`(= `tr -s ' '` + trim)로 기록. `@pytest.mark.parametrize("ps", ["real", "padded"])` — `padded` 는 PATH 앞의 `ps` 심이 lstart 요청에 항상 `Thu Oct 1 08:02:03 2026` 을 돌려주므로 **날짜와 무관하게** 두 칸 공백 경로를 지난다(심이 실제로 쓰였는지 `Thu Oct 1 …` 로 확인).
|
||||||
|
- 같은 패턴 조사: 살아 있는 lstart 를 마커에 쓰는 테스트는 `test_o3_scoped_guard._get_lstart`(이미 `" ".join(split())`)뿐. `test_o2_race_free_lock` 의 마커는 고정된 stale 값이고, 그 파일의 `get_lstart()` 는 **정의만 있고 쓰이지 않는다**(죽은 코드 — 손대지 않음). 제품 쪽 다른 소비자: `loop_delegation_guard.sh` 는 양쪽을 split-join, `lib.sh` 의 `ps -o pid=,lstart=` 는 `strptime` 이라 두 칸에 무관.
|
||||||
|
|
||||||
|
### F2 — Python 진입점의 EnvFileError
|
||||||
|
- `mqtt_common.load_env_or_report() -> bool`: `ensure_env_loaded()` 를 부르고 `EnvFileError` 면 `print(f"ERROR: {e}", file=sys.stderr)` 후 False. 각 main 은 `if not …: return 1`(job_subscriber 는 `RC_ERROR`). 메시지는 `envfile` 의 `"{path}: cannot read ({type})"` 그대로라 값·내용이 들어가지 않는다.
|
||||||
|
- 범위: 브리프가 든 7개 main. **reconcile.sh 의 임베디드 구독자**도 `ensure_env_loaded()` 를 부르지만 main 이 아니라 목록 밖이라 그대로 두었다(트레이스백 유지) — 필요하면 후속.
|
||||||
|
- 리뷰어 참고: job_subscriber 의 rc 1 은 원래 "감시한 잡이 error 로 끝남"(`RC_ERROR`)과 같은 값이다. 수정 전에도 트레이스백으로 rc 1 이었으므로 값은 바뀌지 않았고, 래퍼는 같은 파일을 먼저 읽고 스스로 rc 1 로 끝나 이 구독자를 띄우지 않는다. 별도 코드(예: 3 fatal)로 바꾸는 것은 계약 변경이라 하지 않았다.
|
||||||
|
|
||||||
|
### F3 — .mam.env 세부
|
||||||
|
- (a) `envfile.parse` 가 `encoding="utf-8-sig"` — 파일 맨 앞 BOM 만 벗기며, 래퍼(`envfile.py --shell`)와 `mqtt_common` 이 같은 함수를 쓰므로 두 로더 공통.
|
||||||
|
- (b) `apply()` 가 이미 있는 키를 건너뛰므로 첫 값이 이긴다(동작 무변경) — FORMAT 절에 `a key given twice keeps its FIRST value` 와 BOM 규칙 추가.
|
||||||
|
- (c) MESSAGING 2번 항목을 실제로: 명시 파일이 없으면 조용히 건너뛰고 주변 탐색을 하지 않는다(환경변수와 기본값으로 동작), 있는데 못 읽으면 `EnvFileError` → ERROR 한 줄 rc 1. 기존 문장의 "error is logged"·"fail-closed (`RuntimeError`)" 는 코드에 근거가 없어 지웠다.
|
||||||
|
- 참고(범위 밖, 손대지 않음): 같은 블록 3번 항목의 탐색 순서(`MAM_REAL_ROOT`·`WORKSPACE_ROOT`·cwd)는 `mqtt_common._load_dotenv`(모듈 위치에서 위로 `.agents`/`.git` 탐색)와 다르다.
|
||||||
|
|
||||||
|
### F4 — 정수 노브
|
||||||
|
- (a) `runner._int_knob(name, minimum)` → None(미설정·잘못된 값, 후자는 노브당 경고 1회) 이면 호출부 기본값. 최솟값과 근거(`tests/test_issue26_ints.py` `RUNNER_INT_KNOBS`):
|
||||||
|
|
||||||
|
| 노브 | 최솟값 | 기본 | 근거 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `MAM_WAIT_GRACE_S` | 0 | 300 | 0 = 예산만큼만 대기 |
|
||||||
|
| `MAM_CHILD_WAIT_S` | 0 | 예산 + grace | 0 = 하위 잡을 한 번만 확인(test_issue34 가 0 사용) |
|
||||||
|
| `MAM_JOB_TREE_MAX` | 1 | 50 | 0 은 모든 트리를 "over" 로 만든다; 기다리지 않기는 `MAM_CHILD_WAIT_S=0` |
|
||||||
|
|
||||||
|
동작 변화: `MAM_CHILD_WAIT_S` 는 전에 `float()` 이라 `0.5`·`1e3` 을 받았고 `abc` 는 ValueError, `MAM_JOB_TREE_MAX` 는 `abc`·빈 값이 ValueError, `MAM_WAIT_GRACE_S` 는 `" 30 "` 도 조용히 300 이었다 → 이제 모두 10진 정수 규칙(경고 후 기본값).
|
||||||
|
- (b) bash 노브(`INT_KNOBS` 에 추가, `NOT_ARITHMETIC` 에서 제거):
|
||||||
|
|
||||||
|
| 노브 | 위치 | 최솟값 | 기본 | 근거 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `MAM_READY_EMPTY_GIVEUP` | lib.sh `wait_for_tui_ready` | 1 | 5 | 0 은 1 과 같게 동작 |
|
||||||
|
| `SKS_EMPTY_GIVEUP` | lib.sh `_pane_quiescent` | 1 | 3 | 같음 |
|
||||||
|
| `SKS_IDLE_RETRIES` | lib.sh `wait_for_agent_idle` | 0 | 2 | 0 = 재시도 없음 |
|
||||||
|
| `SKS_IDLE_RETRY_BACKOFF_S` | 같음(sleep) | 0 | 10 | test_issue6 가 0 사용; 소수 사용처 없음 |
|
||||||
|
| `MAM_LOCK_STEAL_RETRIES` | loop_lock.sh | 0 | 3 | 0 = 한 번 시도 |
|
||||||
|
| `MAM_DIFF_MAX_BYTES`/`_LINES` | diff_collect.sh | 1 | 200000/4000 | 0 은 모든 diff 를 자름 |
|
||||||
|
| `RECONCILE_POLL_INTERVAL` | reconcile.sh(sleep) | 1 | 15 | 0 은 바쁜 루프; 소수 사용처 없음 |
|
||||||
|
|
||||||
|
`SKS_QUIESCENT_INTERVAL` 은 **기본값이 0.5 초**라 소수가 실제 의미 → 그대로 두고 `NOT_ARITHMETIC` 에 "sleep, fractional by default (0.5) - left raw" 로 사유를 남겼다.
|
||||||
|
- `lib_int.sh` 를 만든 이유: `diff_collect.sh` 는 Python 러너가 lib.sh 없이 `source` 하고(`runner.collect_changes_diff`), `loop_lock.sh` 도 테스트가 단독 source 한다. lib.sh 를 source 하면 herdr 심 설치 등 부작용이 있어 함수만 분리했다. 함수 본문은 한 글자도 바꾸지 않았다. 연쇄 변경: `deploy/remove.sh` 의 매니페스트 없는 폴백 목록에 `lib_int.sh` 추가(설치는 `.agents/skills` 의 모든 파일을 복사하므로 그대로 배포됨), `test_issue21_loop_b::l15` 가 가짜 제어 루트에 `diff_collect.sh` 만 두던 것을 `lib_int.sh` 도 함께 두게.
|
||||||
|
- (c) loop SKILL.md: 예산 행에 `MAM_WAIT_GRACE_S`(기본 300, 0 이상, 잘못된 값 경고 후 300, 덮는 꼬리 설명), 하위 잡 문단의 `MAM_CHILD_WAIT_S` 기본을 "예산 + `MAM_WAIT_GRACE_S`", 0 의 의미, `MAM_JOB_TREE_MAX` 1 이상, 정수 규칙.
|
||||||
|
|
||||||
|
### F5 — 제품 코드의 테스트 훅
|
||||||
|
- `StallWatch.__init__` 의 `MAM_TEST_UNRELIABLE_AGENT` 두 줄 삭제(이제 `.agents/skills` 전체에 `MAM_TEST_` 없음).
|
||||||
|
- S13: `_watch(..., unreliable=True)` 가 `python -c _UNRELIABLE_MAIN claude <args>` 로 실제 `stall_watch.main()` 을 실행하되, 그 프로세스에서 `type(get_adapter("claude")).agent_status_reliable = property(lambda self: False)`. 검증 내용은 그대로(5 초 동안 idle 알림·nudge 없음 → blocked 알림 `stalled:blocked Do you want to proceed?` → 로그 `idle judgement off for claude`).
|
||||||
|
|
||||||
|
### F6 — 문서 정밀도와 "대기 중 래퍼 소멸"
|
||||||
|
- SKILL.md: "한 herdr wait(<= 60 s) — 또는 알림 발행 중에 래퍼가 사라지면 그 발행이 끝날 때까지" 로 한정.
|
||||||
|
- 테스트: `_during_notice` 에 `in_wait(tick)` 훅(각 가짜 herdr wait 안에서 호출). 래퍼는 **두 번째 wait 안에서** 사라지고, 테스트는 `ticks == [1, 2]`(두 번째 wait 에 실제로 들어감)와 그때까지 alive 가 참이었음을 확인한 뒤 알림·키 0 을 단언한다.
|
||||||
|
|
||||||
|
## 3. 회귀 테스트가 수정 전 코드에서 실패함을 확인한 방법
|
||||||
|
|
||||||
|
`git worktree add --detach <scratch>/base 0051fd6`(작업 트리 밖)에 새 테스트 파일을 복사해 돌리고, 확인 후 `git checkout` 으로 되돌렸다. 마지막에 `git worktree remove --force` 로 제거(남은 워크트리 2개: 본 체크아웃·followups).
|
||||||
|
|
||||||
|
| 항목 | base(0051fd6)에서 | 수정 후 |
|
||||||
|
|---|---|---|
|
||||||
|
| F1 | 원래 A8: **실패**(오늘 10월 2일, `already running` 없음). 새 테스트에서 `_marker_lstart` 를 옛 `.strip()` 으로 되돌리면 `real`·`padded` **둘 다 실패** — `padded` 는 날짜와 무관 | 2 passed |
|
||||||
|
| F2 | 14 케이스(7 진입점 × 000 권한·UTF-8 깨짐) **14 실패**(트레이스백) | 14 passed |
|
||||||
|
| F3 | A7 3개 **3 실패**: BOM 이 `FIRST` 를 버림 / FORMAT 문구 없음 / MESSAGING 의 옛 문장 | 3 passed |
|
||||||
|
| F4a | 새 케이스 중 **15 실패**: grace 의 잘못된 값 6(경고 없이 300, `" 30 "` 은 300), child-wait `abc`·`-5`·`0.5`·`9999999999` 4(ValueError 또는 받아들임), tree-max `0`·`abc`·`-1`·빈 값 4(ValueError 또는 0 수용), 정적 검사 1. 통과한 14 는 결과가 같은 유효 값 | 44 passed |
|
||||||
|
| F4b | **14 실패**: 표 정적 검사 2, lib.sh 없이 source 한 스크립트 11(`abc`·`0900`·`" 7 "`·`-1`·`1.5`, diff 는 `0` 도), `_pane_quiescent` + `SKS_EMPTY_GIVEUP=abc`(`[` 오류) 1. 통과 3 은 유효 값 | 통과 |
|
||||||
|
| F5 | 정적 검사(`MAM_TEST_` 없음) **실패**. 추가로 수정 후 트리에서 seam 을 빼면(`unreliable=True` 제거) S13 이 **실패** → seam 이 unreliable 상황을 만든다는 증거 | 2 passed |
|
||||||
|
| F6 | 테스트만 바뀐 항목이라 두 가지로 확인: (1) 새 테스트에 옛 호출 횟수 대역을 넣으면 `assert [1] == [1, 2]` 로 **실패** — 옛 테스트는 두 번째 wait 에 들어가지도 않고 루프 머리에서 끝났다. (2) 제품의 wait 직후 `_wrapper_gone()` 검사를 지운 변이체에서 새 테스트가 `notes == ['stalled:idle']` 로 **실패**(수정 안 한 제품에서는 통과) | 1 passed |
|
||||||
|
|
||||||
|
## 4. 테스트 결과
|
||||||
|
|
||||||
|
명령(브리프 그대로, `.mam.env` export 없음):
|
||||||
|
`AGENT_PYTHON_BIN=<wt>/.venv/bin/python PYTHONDONTWRITEBYTECODE=1 <wt>/.venv/bin/python -m pytest -p no:cacheprovider -q --basetemp=<scratch>/bt-final --junitxml=<scratch>/final.xml <파일들>`
|
||||||
|
|
||||||
|
최종 실행 @ `1e92ddc` — **605 passed, 1 failed, 2 skipped (885 s)**. 실패 1건은 내 F2 회귀였다(`test_issue5_phase1_delegation::test_job_subscriber_cli_contract_snapshot` — job_subscriber 의 맨 숫자 `return 1`). `95eabc6` 로 고친 뒤 `test_issue5_phase1_delegation.py` + `test_issue26_envfile.py` 재실행 **103 passed**. `95eabc6` 의 변경은 같은 값(1)을 이름(`RC_ERROR`)으로 바꾼 것뿐이고 그 분기는 env 파일 실패에서만 탄다.
|
||||||
|
|
||||||
|
| 파일 | 통과 | 실패 | 스킵 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| test_issue26_envfile.py | 37 | 0 | 0 |
|
||||||
|
| test_issue26_ints.py | 93 | 0 | 0 |
|
||||||
|
| test_issue21_acceptance.py | 10 | 0 | 0 |
|
||||||
|
| test_issue30_stall_watch.py | 48 | 0 | 1 (`/proc` 전용) |
|
||||||
|
| test_issue30_stall_watch_stop.py | 11 | 0 | 0 |
|
||||||
|
| test_tier1_unit.py | 67 | 0 | 0 |
|
||||||
|
| test_version_consistency.py | 3 | 0 | 0 |
|
||||||
|
| test_issue33_budget.py | 20 | 0 | 0 |
|
||||||
|
| test_issue34_job_tree.py | 20 | 0 | 0 |
|
||||||
|
| test_issue21_loop_b.py | 34 | 0 | 0 |
|
||||||
|
| test_issue5_phase1_delegation.py | 65 → **재실행 66** | 1 → **0** | 0 |
|
||||||
|
| test_issue18_liveness_contract.py | 31 | 0 | 0 |
|
||||||
|
| test_o2_race_free_lock.py | 22 | 0 | 0 |
|
||||||
|
| test_o3_scoped_guard.py | 32 | 0 | 0 |
|
||||||
|
| test_b7_diff_untracked.py | 20 | 0 | 0 |
|
||||||
|
| test_issue36_prompt_transport.py | 23 | 0 | 1 (Linux `MAX_ARG_STRLEN`) |
|
||||||
|
| test_issue6_turn_handshake.py | 14 | 0 | 0 |
|
||||||
|
| test_c1_tui_readiness.py | 23 | 0 | 0 |
|
||||||
|
| test_b19_headless_reconcile_fixes.py | 16 | 0 | 0 |
|
||||||
|
| test_issue19_subscriber_daemon_lifecycle.py | 16 | 0 | 0 |
|
||||||
|
|
||||||
|
브리프 지정 7개 파일 + 바꾼 모듈의 테스트(러너: issue33/34·loop_b, env/진입점: issue5·issue18, bash 노브: o2·o3·b7·issue36·issue6·c1·b19·issue19). 전체 스위트(~60분)는 돌리지 않았다(오케스트레이터 몫). 정적 검사: `bash -n` 으로 바꾼 셸 파일 6개 문법 OK; `shellcheck`·`pyflakes` 는 이 호스트에 없다.
|
||||||
|
|
||||||
|
## 5. 위생
|
||||||
|
|
||||||
|
- 작업 트리: 최종 `git status --short --ignored` = `!! .venv/` 만. 단, 최종 실행 중 **`.mam/shim/herdr` 가 작업 트리에 생겼다** → 파일별로 나눠 돌려 원인을 찾았다: `tests/test_version_consistency.py::_get_lib_sh_mam_version` 이 `WORKSPACE_ROOT` 없이 `lib.sh` 를 source → `_init_herdr_isolation` 이 `<저장소>/.mam/shim/herdr` 를 쓴다. **base 에서도 똑같이 생긴다**(내 변경과 무관, 기존 동작). 내용이 재생성되는 심 하나뿐임을 확인하고 지웠다. ⚠️ 본 체크아웃에서 이 테스트를 돌리면 **실제 `.mam/shim/herdr` 를 덮어쓴다** — 후속 후보(테스트가 `WORKSPACE_ROOT` 를 샌드박스로 주기).
|
||||||
|
- 프로세스: 내 F5 확인(seam 을 일부러 뺀 S13 실패 실행)에서 **샌드박스 stall_watch 1개가 남았다**(S13 은 단언 실패 시 `finally` 에서 에이전트만 죽이고 watcher `w` 는 정리하지 않음 — 기존 테스트 구조). 그 watcher 는 basetemp 의 mock herdr·레지스트리만 상대했다. TERM 으로 종료했고, 이후 scratchpad 경로를 가진 프로세스 0개. 실제 모니터 데몬·세션은 건드리지 않았다(남은 `-m lib_py.loop` 1개는 오케스트레이터의 루프 드라이버).
|
||||||
|
- 실제 `.mam/jobs` 의 레코드는 읽기·쓰기 하지 않았다(이 리포트 파일만 작성).
|
||||||
|
|
||||||
|
## 6. 리뷰어 참고
|
||||||
|
|
||||||
|
- `lib.sh` 변경은 `mam_int`/`mam_int_knob` 블록을 `lib_int.sh` 로 옮기고 그 자리에서 `source "$(dirname "${BASH_SOURCE[0]}")/lib_int.sh"`. 생성되는 herdr 심(heredoc, 151–1367행)은 `mam_int` 를 쓰지 않는다.
|
||||||
|
- `local x="$(mam_int_knob …)"` 형태는 B5 정적 검사 정규식(`mam_int_knob NAME default min)`)이 잡도록 기존 호출부 모양을 따랐다. B5 가 이제 래퍼와 **모든** `.sh` 를 읽는다.
|
||||||
|
- 메모: 오래된 `.mam.env` export 지침(공개 브로커 시절)은 이 트리에서 틀리다 — conftest 가 세션 로컬 브로커를 띄우고 인증 변수를 지운다.
|
||||||
|
|
||||||
|
## 7. 교차 확인 명령
|
||||||
|
|
||||||
|
```
|
||||||
|
cd /Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux-wt/followups
|
||||||
|
git log --oneline 0051fd6..HEAD # 8 commits, head 95eabc6
|
||||||
|
git diff --stat 0051fd6..HEAD
|
||||||
|
grep -rn MAM_TEST_ .agents/skills # nothing
|
||||||
|
AGENT_PYTHON_BIN=$PWD/.venv/bin/python PYTHONDONTWRITEBYTECODE=1 .venv/bin/python -m pytest -p no:cacheprovider -q \
|
||||||
|
--basetemp=/tmp/<somewhere> tests/test_issue26_envfile.py tests/test_issue26_ints.py tests/test_issue21_acceptance.py \
|
||||||
|
tests/test_issue30_stall_watch.py tests/test_issue30_stall_watch_stop.py tests/test_tier1_unit.py tests/test_version_consistency.py
|
||||||
|
```
|
||||||
@@ -1,209 +1,391 @@
|
|||||||
# 🔌 Implementation Plan: OpenCode (`anomalyco/opencode`) Agent Adapter for MAM (Rev.3)
|
# 🔌 Implementation Plan: Codex CLI (`openai/codex`) Agent Adapter for MAM (Rev.2)
|
||||||
|
|
||||||
- **Author**: `planner-reviewer-claude-01` (Planner), refined in collaboration with `creator-agy-01` and `reviewer-creator-grok-01`
|
- **Author**: `planner-reviewer-claude-01` (Planner), refined in response to `creator-agy-01`'s architectural challenge
|
||||||
- **Job**: `de667f0f` → Rev.2 (`c65b0081`) → Rev.3 (`78f332a9`)
|
- **Job**: `e30e3123` → Rev.2 (`94a6db78`)
|
||||||
- **Status**: Plan only — **no code changed in the repo**. Ready for review/refinement.
|
- **Status**: Implemented (v4.2.0) — **full code, tests, and version lockstep present in repo**.
|
||||||
- **Scope**: Add `opencode` as a 5th first-class MAM agent backend (alongside `claude`, `agy`, `hermes`, `grok`), per `docs/NEW_AGENT_INTEGRATION_GUIDE.md`'s 5-step procedure.
|
- **Scope**: Add `codex` as a 6th first-class MAM agent backend (alongside `claude`, `agy`, `hermes`, `grok`, `opencode`), per `docs/NEW_AGENT_INTEGRATION_GUIDE.md`'s 5-step procedure.
|
||||||
- **Durable Path**: `.agents/reports/planner-reviewer-claude-01/plan-de667f0f.md` (and `.agents/reports/implementation_plan.md`)
|
- **Durable Path**: `.agents/reports/planner-reviewer-claude-01/plan-e30e3123.md` (mirrored at `.agents/reports/implementation_plan.md`).
|
||||||
|
- **Method**: Every CLI fact is verified against a **live, authenticated Codex CLI install already present on this machine** (`codex-cli 0.153.4`). Rev.2 additionally re-verified both of `creator-agy-01`'s challenge points live before accepting them — see §0.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Rev.3 Changelog (Job `78f332a9`)
|
## 0. Rev.2 Changelog — Response to `creator-agy-01`'s Architectural Challenge (Job `004c224d`)
|
||||||
|
|
||||||
Reviewers `planner-reviewer-claude-01` (Job `1d63595f`) and `reviewer-creator-grok-01` (Job `56120ad3`) reviewed the Rev.2 plan and working tree. **All 5 findings accepted — zero `[REBUT:]` filed**:
|
`creator-agy-01` filed a formal challenge against Rev.1 with **1 critical defect + 1 supporting architectural constraint**. Per this session's standing discipline, neither was accepted on the challenger's word alone — both were independently re-verified live on this machine before being incorporated. **Both are accepted — no `[REBUT:]` filed.**
|
||||||
|
|
||||||
1. **Restored Root `implementation_plan.md` & Namespaced Plan Path (F1)**:
|
| # | Challenge | My independent verification | Disposition |
|
||||||
- Root `implementation_plan.md` was restored to its tracked HEAD content (the active *MAM 메시징 백플레인 전환 실행 로드맵* NATS migration roadmap).
|
|---|---|---|---|
|
||||||
- This OpenCode integration plan is permanently housed under `.agents/reports/planner-reviewer-claude-01/plan-de667f0f.md` and mirrored at `.agents/reports/implementation_plan.md` to prevent future root-level collision.
|
| 1 | **Critical**: Rev.1's `orc_onboard.sh` detection regex (`resume[[:space:]]+[0-9a-f-]{36}`) cannot match the actual command Rev.1 itself specifies (`codex resume --dangerously-bypass-approvals-and-sandbox <uuid>`), because the flag sits between `resume` and the UUID | Ran the exact `cmd_line` from the challenge through Rev.1's regex myself: `echo "$cmd_line" \| grep -oE 'resume[[:space:]]+[0-9a-f-]{36}'` → **empty, exit 1**. Confirmed genuine — Rev.1 contained a self-contradiction between §2 (`resume_spec`) and §1.9/§3 item 27 (the detection regex). Then tested the challenge's proposed Plan A regex on the same input → **correctly extracts the UUID, exit 0**. | **Accepted.** §1.9 and wiring item #27 rewritten to the flag-tolerant regex (verified working, not just proposed) |
|
||||||
2. **Extended Lockstep Touch-Points Table to 29 Items (F2)**:
|
| 2 | **Supporting**: `~/.codex/state_N.sqlite` (a `threads` table indexing `id`/`cwd`/`rollout_path`) exists alongside the rollout JSONL files; Rev.1's "single-file format, no DB rows to delete" claim was only half true, and `purge_artifacts()` deleting only the `.jsonl` would leave an orphan DB row | `ls ~/.codex/*.sqlite*` on this machine → confirmed `state_5.sqlite` exists. `sqlite3 ~/.codex/state_5.sqlite ".schema threads"` → confirmed the exact `threads` table the challenge quoted. `sqlite3 ... "SELECT id,cwd,rollout_path FROM threads"` → confirmed 2 real rows exactly matching the 2 real rollout files found earlier. `strings` re-extraction confirmed the `stale_db_path`/`state db discrepancy`/`reconcile_rollout`/`read_repair_rollout_path: upsert_needed` literals the challenge cited. `codex delete --help` → confirmed `codex delete <SESSION> --force` ("Delete without prompting. SESSION must be a UUID") is real, exactly as the challenge's remediation option 1 proposed. | **Accepted**, with one refinement found during my own re-verification (not in the original challenge): `codex debug --help` has **no** `db path`-style introspection subcommand (unlike OpenCode's `opencode db path`), and the state file's numeric suffix (`state_5`) is unverified as stable across Codex versions/installs — hardcoding `state_5.sqlite` the way the challenge's raw example does would itself be fragile. §2's adapter design now resolves the state DB via a `state_*.sqlite` glob (most-recently-modified), and `purge_artifacts()` now shells out to `codex delete <uuid> --force` as the primary mechanism (letting Codex's own reconciliation logic keep the JSONL and DB consistent) with a manual-file-removal fallback only if that call fails |
|
||||||
- Added items 23–29 covering `create_session.sh` (`spawn()` dispatch case, YAML initialization `elif`, fallback `CMD_FULL` case), `lib.sh` (`send_keys_safe` `_sks_agent` resolution case), `tests/test_tier1_unit.py` (CLI usage assertions), 8× `SKILL.md` (supported agent lists), and `tests/conftest.py` (`mock_agents` stub binary).
|
|
||||||
3. **Official CLI `opencode db path` Subcommand Integration (F3)**:
|
Net effect: Rev.1's overall architecture and the 46-point wiring table stand — this challenge caught one genuine self-contradiction (the regex) and one genuine completeness gap (the state DB), both now closed. No other part of Rev.1 was found wrong during this re-verification pass.
|
||||||
- Updated §1.5, §2, and §6 to prioritize the official `opencode db path` subcommand for dynamic database location, dropping hardcoded `storage.db` assumptions.
|
|
||||||
4. **Headless Permission Architecture via `OPENCODE_PERMISSION` (F4)**:
|
|
||||||
- Documented the `OPENCODE_PERMISSION='{"*":"allow"}'` process environment variable alongside `--auto` in §1.4, §2, and §9 DoD for unattended automated operations without global config file mutations.
|
|
||||||
5. **Sandbox-Safe `ctx.home_dir` Binding in `auth_ok` and Artifact Paths (F5)**:
|
|
||||||
- Replaced `os.path.expanduser('~')` with `ctx.home_dir` in the adapter skeleton and test contract for hermetic sandbox execution.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Rev.2 Changelog (Job `c65b0081`)
|
## 1. Verified Codex CLI/Runtime Facts
|
||||||
|
|
||||||
`creator-agy-01` filed a formal architectural challenge (Job `ebf97869`) against Rev.1, raising two blind spots. Both accepted:
|
Source: live `codex --help` / `codex resume --help` / `codex exec --help` / `codex login --help` / `codex delete --help` / `codex debug --help` / `codex doctor --summary` / `codex login status` output, direct `ls`/`find` of `~/.codex/`, live `sqlite3` schema/row inspection of `~/.codex/state_5.sqlite`, and offline `strings` extraction of the installed binary — all run on this machine.
|
||||||
1. **`reconcile.sh` Drift-C Non-Generic Correction**: Identified that `reconcile.sh` drift-C materialization consists of bespoke per-agent blocks, requiring an explicit `# === drift C (opencode) ===` block for post-spawn discovery agents.
|
|
||||||
2. **Core `lib_py` Constants Added**: Added `atomic_yaml.py:132` (`running_keys`), `verify_session.py:69` (`mam_row_own_uuid()`), and `workspace_uuid.py:7-12,33` (`OWN_KEY`) to the touch-points list.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 0. Relationship to the existing `new_agent_types_roadmap.md`
|
|
||||||
|
|
||||||
`.agents/reports/new_agent_types_roadmap.md` (by `creator-agy-01`, 2026-08-26) sketches a generic "add any new agent" procedure and guesses at OpenCode specifics in its difficulty matrix. This plan **supersedes** that roadmap's OpenCode row:
|
|
||||||
|
|
||||||
| | Roadmap's guess | Verified reality (this doc) |
|
|
||||||
|---|---|---|
|
|
||||||
| Storage format | "JSON files in `~/.opencode/sessions/`" | **SQLite** since v1.2.0 (Feb 2026); dynamic lookup via `opencode db path` |
|
|
||||||
| Storage path | `~/.opencode/sessions/` | `~/.local/share/opencode/` (XDG data dir) or project storage via `opencode db path` |
|
|
||||||
| Ready tokens | `OpenCode\|Chat\|Welcome` | Candidate string pending live TUI pane capture verification (§4) |
|
|
||||||
| Adapter registry | `ClineAgentAdapter` shown | `cline` fully removed in v4.0.0. Targets current 4-adapter registry (`claude`, `agy`, `hermes`, `grok`) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Verified OpenCode CLI/Runtime Facts
|
|
||||||
|
|
||||||
Source: `opencode.ai/docs/{cli,config,permissions,tui}/`, `github.com/anomalyco/opencode`, and official documentation.
|
|
||||||
|
|
||||||
### 1.1 Identity & Install
|
### 1.1 Identity & Install
|
||||||
- **Upstream Repository**: `github.com/anomalyco/opencode` (distinct from the archived `opencode-ai/opencode` / "Crush").
|
- Binary: `codex` (this machine: `/Users/godopu16/.local/bin/codex`, version `codex-cli 0.153.4`).
|
||||||
- **Install**: `curl -fsSL https://opencode.ai/install | bash` (also npm/Homebrew).
|
- Config/state root: `~/.codex/` by default, **overridable via the `CODEX_HOME` environment variable** (confirmed: `codex exec --ignore-user-config` help text explicitly says "auth still uses `CODEX_HOME`").
|
||||||
|
|
||||||
### 1.2 Execution Modes
|
### 1.2 Execution Modes
|
||||||
- `opencode [project]` — launches the **TUI** (default).
|
- `codex [OPTIONS] [PROMPT]` — launches the **interactive TUI** (default, what MAM spawns).
|
||||||
- `opencode run [message..]` — **non-interactive** one-shot CLI execution.
|
- `codex exec [OPTIONS] [PROMPT]` (alias `e`) — **non-interactive** one-shot execution with `--json` JSONL event streaming and `-o/--output-last-message <FILE>`. Not used by MAM's spawn/resume flow, but confirms Codex has a scriptable mode.
|
||||||
- `opencode db path` — prints the active database file path on stdout.
|
- `codex resume [OPTIONS] [SESSION_ID] [PROMPT]` — resumes a specific session by **UUID or session name, passed positionally**; `--last` picks the most recent session without a picker.
|
||||||
|
- `codex delete <SESSION> --force` — permanently deletes a saved session by UUID; `--force` skips the confirmation prompt and requires `SESSION` to be a UUID (not a name). Verified via `codex delete --help`.
|
||||||
|
|
||||||
### 1.3 Relevant CLI Flags & Environment
|
### 1.3 Relevant CLI Flags & Environment
|
||||||
| Flag / Variable | Purpose |
|
| Flag / Variable | Purpose | Verified via |
|
||||||
|---|---|
|
|---|---|---|
|
||||||
| `--continue` / `-c` | Resume the most recent session |
|
| `--dangerously-bypass-approvals-and-sandbox` | Skip all confirmation prompts and run without sandboxing — the unattended-automation flag | `codex --help`, `codex resume --help` (accepted on both) |
|
||||||
| `--session <id>` / `-s <id>` | Continue a specific session ID (cannot pre-assign a new UUID at spawn) |
|
| `-s, --sandbox <read-only\|workspace-write\|danger-full-access>` / `-a, --ask-for-approval <on-request\|never>` | Finer-grained alternative to the single bypass flag above | `codex --help` |
|
||||||
| `--agent <name>` | Mode selection: `build` (default, write access) or `plan` (read-only) |
|
| `--approve-for-me` | Routes approvals through automatic review under `workspace-write` — **not** a full bypass | `codex --help` |
|
||||||
| `--auto` | Auto-approves tools that prompt ("ask"); explicit "deny" rules still enforced |
|
| `-C, --cd <DIR>` | Working-root directory for the agent | `codex --help` |
|
||||||
| `OPENCODE_PERMISSION` | Process environment variable for inline JSON permission overrides (e.g. `'{"*":"allow"}'`) |
|
| `--session-id <uuid>` | **Does not exist.** No such flag appears anywhere in `codex --help`'s full option list | Absence confirmed by reading the complete `--help` output |
|
||||||
|
| `CODEX_HOME` | Overrides `~/.codex` config/session root | `codex exec --ignore-user-config` help text |
|
||||||
|
| `OPENAI_API_KEY` | Read by `codex login --with-api-key` (via stdin); also stored inside `auth.json` under the `OPENAI_API_KEY` key once logged in | `codex login --help`, `~/.codex/auth.json` key inspection |
|
||||||
|
|
||||||
**Spawn / Discovery Consequence**: OpenCode has **no `--session-id <new-uuid>` pre-assignment flag**. It falls strictly in the **`hermes` / `agy` architectural bucket**: `spawn_spec()` ignores `session_uuid` on initial creation, and MAM learns the assigned session ID post-spawn via `discover()` and `reconcile.sh` Drift-C materialization.
|
### 1.4 Unattended-Execution Decision
|
||||||
|
Confirmed present. `--dangerously-bypass-approvals-and-sandbox` is accepted by both the top-level `codex` TUI launcher and `codex resume`, so `spawn_spec()`/`resume_spec()` can pass it unconditionally.
|
||||||
|
|
||||||
### 1.4 Permissions (Headless & Non-Interactive Configuration)
|
### 1.5 Session & Artifact Storage — Dual Layer (Rev.2: corrected)
|
||||||
MAM configures headless unattended execution at the process boundary without mutating user-global config files (`~/.config/opencode/opencode.json`):
|
- **Primary transcript**: JSONL "rollout" files, one per session, at `~/.codex/sessions/<YYYY>/<MM>/<DD>/rollout-<timestamp>-<uuid>.jsonl` — e.g. `~/.codex/sessions/2026/05/23/rollout-2026-05-23T08-23-56-019e5200-ab6a-70f2-b5da-e1ef403f7a40.jsonl`. First line is a `session_meta` record: `{"payload": {"id": "<uuid>", "cwd": "<path>", ...}}`.
|
||||||
1. **CLI Flag**: `--auto` passed in `spawn_spec` / `resume_spec`.
|
- **Secondary index (Rev.2, per the accepted challenge)**: `~/.codex/state_N.sqlite` (this machine: `state_5.sqlite`; **the numeric suffix is not assumed stable** — no `codex debug`/`codex` subcommand introspects its exact filename, unlike OpenCode's `opencode db path`, so the adapter must resolve it via a `state_*.sqlite` glob rather than hardcoding the suffix). Verified live schema: a `threads` table with `id TEXT PRIMARY KEY`, `rollout_path TEXT`, `cwd TEXT`, `created_at`/`updated_at` (plus many version-added trailing columns — the schema has clearly evolved via in-place `ALTER TABLE` across releases, another reason not to hand-roll fragile column-position assumptions). Verified live rows: both real sessions on this machine are indexed here with `id`/`cwd`/`rollout_path` exactly matching their on-disk `.jsonl` files.
|
||||||
2. **Process Environment Variable**: `OPENCODE_PERMISSION='{"*":"allow"}'` exported in the spawn subshell when full unattended bypass is required.
|
- **Codex's own resolution order** (inferred from binary string literals `state db discrepancy during find_thread_path_by_id_str_in_subdir: stale_db_path / mismatched_db_path / falling_back` and `reconcile_rollout` / `read_repair_rollout_path: upsert_needed`): Codex itself queries the state DB first, and has a **self-healing reconciliation path** for when the DB and filesystem disagree (falls back to a filesystem scan, then repairs the DB entry) — this is Codex's own built-in drift-correction, structurally analogous to MAM's own `reconcile.sh`.
|
||||||
3. **Optional Project/Global Config**: `<project>/opencode.json` or `~/.config/opencode/opencode.json` can define granular policies if configured by the user.
|
- **Architectural consequence for MAM's adapter**: mirror Codex's own resolution order — **query `state_*.sqlite` first** (fast, indexed, avoids any unbounded directory walk), **fall back to the bounded glob** over `sessions/*/*/*/rollout-*.jsonl` if the state DB is missing, unreadable, or the row is stale/absent (mirroring the exact "primary DB, fallback filesystem walk" shape `opencode.py`'s `_resolve_db()` already uses in this codebase, and mirroring what Codex's own binary does internally).
|
||||||
|
|
||||||
### 1.5 Session & Artifact Storage
|
### 1.6 TUI Readiness (candidate, pending empirical capture)
|
||||||
- **Format**: Single SQLite database (since v1.2.0).
|
Offline `strings` extraction surfaced real UI literals: `"Ask Codex to do anything"` (composer placeholder) and the banner token `"Codex"`. No `❯` glyph or `─{10,}` rule literal found — different TUI layout family (boxed/panelled, not readline-style). Proposed candidate:
|
||||||
- **Dynamic Path Resolution**: `opencode db path` prints the exact database location.
|
- `ready_tokens`: `'Codex|Ask Codex to do anything'`
|
||||||
- **Default Storage Roots**: `~/.local/share/opencode/` (global) or `project/<slug>/storage/`.
|
- `input_placeholder`: `'Ask Codex to do anything'` (high-confidence — extracted literal)
|
||||||
- **Pre-Implementation Spike (§1.5)**: Run `opencode db path` and `sqlite3 <db> ".schema"` on a live install to verify `session`, `message`, and `part` table column definitions (`cwd`, `created_at` timestamps) before writing SQL queries.
|
- `input_prompt` / `input_rule_pattern`: `None` (base-class default) pending live pane capture.
|
||||||
|
|
||||||
### 1.6 Authentication
|
**Hard gate before merge**: capture a real `codex` TUI pane via `herdr pane capture` to confirm exact rendered text.
|
||||||
- Interactive: `opencode auth login`.
|
|
||||||
- Multi-provider environment variables: `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, `GROQ_API_KEY`.
|
|
||||||
- Credentials cache: `~/.local/share/opencode/auth.json`.
|
|
||||||
- `auth_ok()` checks for `auth.json` in `ctx.home_dir` OR any supported provider environment variable.
|
|
||||||
|
|
||||||
### 1.7 Exit Key
|
### 1.7 Exit Convention (verified deviation from every existing adapter)
|
||||||
- Slash command `/exit` (aliases `/quit`, `/q`).
|
Binary string table twice describes exiting as a keypress: `"Ctrl+C to exit"`, `"ctrl+c quit"`. No `/exit`/`/quit`/`:q` literal found (real slash commands that do exist: `/keymap`, `/side`, `/review`, `/ide`, `/goal`).
|
||||||
|
|
||||||
|
`MAM_EXIT_KEY` is exported by the `facts` bridge and loaded into `send_keys_safe()`'s local scope (`lib.sh:2166`), but **no skill script actually sends it to a pane today, for any agent** — `stop_session.sh` never references `exit_key`/`MAM_EXIT_KEY`. So this remains a required-but-currently-inert contract field. Propose `exit_key = 'Ctrl+C'` (symbolic key name, matching herdr's existing `Escape` send convention), documented as a deliberate divergence.
|
||||||
|
|
||||||
|
### 1.8 Authentication
|
||||||
|
- Interactive: `codex login` (ChatGPT OAuth) or `codex login --with-api-key` (reads `OPENAI_API_KEY` from stdin).
|
||||||
|
- Credential file: `~/.codex/auth.json` (or `$CODEX_HOME/auth.json`) — verified present, keys `auth_mode`, `OPENAI_API_KEY`, `tokens`, `last_refresh`. `codex login status` confirms `"Logged in using ChatGPT"`; `codex doctor --summary` independently confirms `✓ auth is configured`.
|
||||||
|
- `auth_ok()`: `os.path.exists(f"{codex_home}/auth.json")` **or** `os.environ.get('OPENAI_API_KEY')`. **Caveat honestly flagged**: whether a bare env var alone (without ever running `codex login --with-api-key`) is sufficient was not empirically tested (this machine is logged in via ChatGPT, not a raw API key).
|
||||||
|
|
||||||
|
### 1.9 Session-ID Detection for `orc_onboard.sh` (Rev.2: regex defect fixed)
|
||||||
|
1. **argv form**: Codex passes the UUID as `codex resume <uuid>` — positional, no flag name. **Rev.1's regex was defective**: `grep -oE 'resume[[:space:]]+[0-9a-f-]{36}'` requires the UUID immediately after `resume`, but the real command line this plan's own `resume_spec`/`CMD_FULL` produce is `codex resume --dangerously-bypass-approvals-and-sandbox <uuid>` — a flag sits in between, so the regex never matches (verified live: exit 1, empty output). **Corrected regex** (challenge's Plan A, live-verified to work): allow arbitrary tokens between `resume` and the UUID, then extract only the UUID:
|
||||||
|
```bash
|
||||||
|
codex)
|
||||||
|
detected_id=$(echo "$cmd_line" | grep -oE 'resume[[:space:]]+.*[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}' | grep -oE '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}' | head -n 1 || true)
|
||||||
|
;;
|
||||||
|
```
|
||||||
|
Already safe under `orc_onboard.sh:135`'s existing `is_valid_id "$detected_id"` UUID-format re-validation, so a slightly looser extraction regex carries no correctness risk.
|
||||||
|
2. **env var fallback**: no `CODEX_SESSION_ID`-style environment variable found anywhere in `codex --help`. Leave `env_var=""` for `codex` — do not fabricate one.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. Adapter Contract Decisions (`BaseAgentAdapter`)
|
## 2. Adapter Contract Decisions (`BaseAgentAdapter`) — Rev.2
|
||||||
|
|
||||||
| Property/Method | Value | Rationale |
|
| Property/Method | Value | Rationale |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `name` | `'opencode'` | Lowercase identifier |
|
| `name` | `'codex'` | Lowercase identifier |
|
||||||
| `own_key` | `'opencode_session_id_own'` | Matches `<agent>_session_id_own` convention |
|
| `own_key` | `'codex_session_id_own'` | Matches `<agent>_session_id_own` convention |
|
||||||
| `ready_tokens` | `'OpenCode\|Chat'` (candidate) | Pending live TUI capture verification (§4) |
|
| `ready_tokens` | `'Codex\|Ask Codex to do anything'` (candidate, §1.6) | Extracted real UI literal, pending live pane-capture confirmation |
|
||||||
| `exit_key` | `'/exit'` | Confirmed in CLI docs |
|
| `exit_key` | `'Ctrl+C'` (§1.7) | No typed slash command exists; currently operationally inert framework-wide |
|
||||||
| `delegate_agent_key` | `'opencode-cli'` | Descriptive CLI identifier |
|
| `delegate_agent_key` | `'codex-cli'` | Matches the `<name>-cli`/`-agent`/`-build`/`-code` suffix convention |
|
||||||
| `identity_cache_fields` | `('session_id',)` | Single-field cache |
|
| `identity_cache_fields` | `('session_id',)` | Single-field cache, modern pattern |
|
||||||
| `spawn_spec(binary, session_uuid, use_wrapper)` | `f"{binary} --auto --agent build"` | Non-interactive build mode; ignores `session_uuid` at spawn |
|
| `input_placeholder` | `'Ask Codex to do anything'` | Verified real literal |
|
||||||
| `resume_spec(binary, session_uuid, materialized)` | `f"{binary} --session {session_uuid} --auto --agent build"` (if materialized) | Passes `--session <id>` once discovered |
|
| `input_prompt` / `input_rule_pattern` | `None` | No stable glyph found; different TUI layout family |
|
||||||
| `auth_ok(run_cmd)` | `os.path.exists(f"{ctx.home_dir}/.local/share/opencode/auth.json") or any(env)` | Multi-provider fallback + sandbox-safe path |
|
| `spawn_spec(binary, session_uuid, use_wrapper)` | `f"{binary} --dangerously-bypass-approvals-and-sandbox"` (ignores `session_uuid` — Class A) | §1.3/§1.4 |
|
||||||
| `discover(ctx)` | Queries SQLite DB resolved via `opencode db path` / default root | Discovers new session IDs created since `ctx.epoch` |
|
| `resume_spec(binary, session_uuid, materialized)` | if `materialized and session_uuid`: `f"{binary} resume --dangerously-bypass-approvals-and-sandbox {session_uuid}"`, else same as `spawn_spec` | Verified `codex resume [OPTIONS] [SESSION_ID]` usage template |
|
||||||
| `verify_artifact(uuid, ctx)` | Checks row existence in SQLite `session` table | Validates session persistence |
|
| `auth_ok(run_cmd)` | `os.path.exists(f"{codex_home}/auth.json") or bool(os.environ.get('OPENAI_API_KEY'))` | §1.8, sandbox-safe via `ctx.home_dir` |
|
||||||
| `purge_artifacts(uuid, ctx)` | Executes parameterized `DELETE FROM session/message/part WHERE session_id=?` | Cleans SQLite rows on stop `--purge-conversation` |
|
| `artifact_path(uuid, ctx)` | **Rev.2**: query `state_*.sqlite`'s `threads.rollout_path WHERE id=?` first; fall back to the bounded glob `sessions/*/*/*/rollout-*-{uuid}.jsonl` if the DB lookup misses | §1.5 — mirrors Codex's own DB-first, filesystem-fallback resolution order |
|
||||||
|
| `verify_artifact(uuid, ctx)` | Resolve path via the same DB-first/glob-fallback helper; parse first JSONL line's `payload.id`/`payload.cwd` (or use the DB row's `cwd` column directly, skipping the file read, when the DB hit succeeds); apply `ctx.epoch` mtime check | Rev.2: DB row already has `cwd` — avoids opening the file at all on the common (DB-hit) path |
|
||||||
|
| `purge_artifacts(uuid, ctx)` | **Rev.2**: primary — `subprocess.run([binary_or_'codex', 'delete', uuid, '--force'], ...)`; fallback (only if that call fails/binary unavailable) — manually `os.remove()` the rollout file located via the DB-first/glob-fallback helper, **and** best-effort `DELETE FROM threads WHERE id=?` against the resolved `state_*.sqlite` if it exists | §0 item 2 — avoids orphan DB rows; lets Codex's own reconciliation logic own consistency whenever possible instead of MAM hand-rolling SQL against an evolving schema |
|
||||||
|
| `discover(ctx)` | **Rev.2**: query `state_*.sqlite`'s `threads` table (`SELECT id, cwd, updated_at FROM threads ORDER BY updated_at DESC LIMIT 50`, filtered by `cwd`/`epoch` in Python) first; fall back to the recursive-glob approach from Rev.1 only if the DB is absent/unreadable | Rev.2 — DB query is a single indexed lookup vs. an N-file glob-and-open walk; also naturally bounded (no unbounded-growth concern for the DB path) |
|
||||||
|
|
||||||
|
### Adapter code sketch (`.agents/skills/lib_py/agents/adapters/codex.py`) — Rev.2
|
||||||
|
|
||||||
|
```python
|
||||||
|
import os, json, glob, sqlite3, subprocess
|
||||||
|
from typing import Optional, Any, List
|
||||||
|
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext
|
||||||
|
from lib_py.verify_session import workspace_key
|
||||||
|
|
||||||
|
class CodexAgentAdapter(BaseAgentAdapter):
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
return 'codex'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def own_key(self) -> str:
|
||||||
|
return 'codex_session_id_own'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ready_tokens(self) -> str:
|
||||||
|
return 'Codex|Ask Codex to do anything'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def exit_key(self) -> str:
|
||||||
|
return 'Ctrl+C'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def delegate_agent_key(self) -> str:
|
||||||
|
return 'codex-cli'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def identity_cache_fields(self) -> tuple:
|
||||||
|
return ('session_id',)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def input_placeholder(self) -> Optional[str]:
|
||||||
|
return 'Ask Codex to do anything'
|
||||||
|
|
||||||
|
def _codex_home(self, ctx: DiscoveryContext) -> str:
|
||||||
|
return os.environ.get('CODEX_HOME') or f"{ctx.home_dir}/.codex"
|
||||||
|
|
||||||
|
def _state_db(self, ctx: DiscoveryContext) -> Optional[str]:
|
||||||
|
# Rev.2: no 'codex db path'-equivalent subcommand exists; the numeric
|
||||||
|
# suffix (state_5.sqlite on this machine) is unverified as stable
|
||||||
|
# across versions, so resolve dynamically rather than hardcoding it.
|
||||||
|
matches = glob.glob(f"{self._codex_home(ctx)}/state_*.sqlite")
|
||||||
|
if not matches:
|
||||||
|
return None
|
||||||
|
return max(matches, key=os.path.getmtime)
|
||||||
|
|
||||||
|
def _find_rollout_via_db(self, uuid: str, ctx: DiscoveryContext):
|
||||||
|
"""Returns (rollout_path, cwd) from the state DB, or (None, None)."""
|
||||||
|
db = self._state_db(ctx)
|
||||||
|
if not db or not os.path.exists(db):
|
||||||
|
return None, None
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(db)
|
||||||
|
row = conn.execute("SELECT rollout_path, cwd FROM threads WHERE id=?", (uuid,)).fetchone()
|
||||||
|
conn.close()
|
||||||
|
if row and row[0] and os.path.exists(row[0]):
|
||||||
|
return row[0], row[1]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
def _find_rollout(self, uuid: str, ctx: DiscoveryContext) -> Optional[str]:
|
||||||
|
path, _ = self._find_rollout_via_db(uuid, ctx)
|
||||||
|
if path:
|
||||||
|
return path
|
||||||
|
# Fallback: bounded glob over the date-nested tree (state DB missing/stale)
|
||||||
|
matches = glob.glob(f"{self._codex_home(ctx)}/sessions/*/*/*/rollout-*-{uuid}.jsonl")
|
||||||
|
return matches[0] if matches else None
|
||||||
|
|
||||||
|
def artifact_path(self, uuid: str, ctx: DiscoveryContext) -> str:
|
||||||
|
return self._find_rollout(uuid, ctx) or f"{self._codex_home(ctx)}/sessions/rollout-{uuid}.jsonl"
|
||||||
|
|
||||||
|
def verify_artifact(self, uuid: str, ctx: DiscoveryContext) -> bool:
|
||||||
|
db_path, db_cwd = self._find_rollout_via_db(uuid, ctx)
|
||||||
|
path = db_path or self._find_rollout(uuid, ctx)
|
||||||
|
if not path or not os.path.exists(path):
|
||||||
|
return False
|
||||||
|
if ctx.epoch and os.path.getmtime(path) < ctx.epoch:
|
||||||
|
return False
|
||||||
|
found_cwd = db_cwd
|
||||||
|
if not found_cwd:
|
||||||
|
try:
|
||||||
|
with open(path) as f:
|
||||||
|
first = f.readline()
|
||||||
|
found_cwd = json.loads(first).get('payload', {}).get('cwd')
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
if found_cwd and workspace_key(found_cwd) != workspace_key(ctx.cwd):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def purge_artifacts(self, uuid: str, ctx: DiscoveryContext) -> List[str]:
|
||||||
|
purged = []
|
||||||
|
path = self._find_rollout(uuid, ctx)
|
||||||
|
# Rev.2: prefer the CLI's own delete path (keeps rollout file + state
|
||||||
|
# DB row consistent via Codex's own reconciliation logic).
|
||||||
|
try:
|
||||||
|
res = subprocess.run(['codex', 'delete', uuid, '--force'],
|
||||||
|
capture_output=True, text=True, timeout=5)
|
||||||
|
if res.returncode == 0:
|
||||||
|
if path:
|
||||||
|
purged.append(path)
|
||||||
|
return purged
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Fallback: manual removal of the file, plus a best-effort DB row delete.
|
||||||
|
if path and os.path.exists(path):
|
||||||
|
os.remove(path)
|
||||||
|
purged.append(path)
|
||||||
|
db = self._state_db(ctx)
|
||||||
|
if db and os.path.exists(db):
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(db)
|
||||||
|
conn.execute("DELETE FROM threads WHERE id=?", (uuid,))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
purged.append(f"state db row for thread: {uuid}")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return purged
|
||||||
|
|
||||||
|
def spawn_spec(self, binary: str, session_uuid: str = "", use_wrapper: bool = False) -> str:
|
||||||
|
return f"{binary} --dangerously-bypass-approvals-and-sandbox"
|
||||||
|
|
||||||
|
def resume_spec(self, binary: str, session_uuid: str, materialized: bool = False) -> str:
|
||||||
|
if materialized and session_uuid:
|
||||||
|
return f"{binary} resume --dangerously-bypass-approvals-and-sandbox {session_uuid}"
|
||||||
|
return f"{binary} --dangerously-bypass-approvals-and-sandbox"
|
||||||
|
|
||||||
|
def auth_ok(self, run_cmd: Optional[Any] = None) -> bool:
|
||||||
|
home = os.environ.get('HOME_DIR') or os.environ.get('HOME') or os.path.expanduser('~')
|
||||||
|
codex_home = os.environ.get('CODEX_HOME') or f"{home}/.codex"
|
||||||
|
return os.path.exists(f"{codex_home}/auth.json") or bool(os.environ.get('OPENAI_API_KEY'))
|
||||||
|
|
||||||
|
def discover(self, ctx: DiscoveryContext) -> List[str]:
|
||||||
|
db = self._state_db(ctx)
|
||||||
|
if db and os.path.exists(db):
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(db)
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT id, cwd, updated_at FROM threads ORDER BY updated_at DESC LIMIT 50"
|
||||||
|
).fetchall()
|
||||||
|
conn.close()
|
||||||
|
candidates = []
|
||||||
|
for uuid, cwd, _ in rows:
|
||||||
|
if ctx.workspace and cwd and workspace_key(cwd) != workspace_key(ctx.workspace):
|
||||||
|
continue
|
||||||
|
if uuid and self.verify_artifact(uuid, ctx):
|
||||||
|
candidates.append(uuid)
|
||||||
|
return candidates
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Fallback: recursive glob (state DB absent/unreadable)
|
||||||
|
root = self._codex_home(ctx)
|
||||||
|
candidates = []
|
||||||
|
paths = sorted(glob.glob(f"{root}/sessions/*/*/*/rollout-*.jsonl"), key=os.path.getmtime, reverse=True)[:50]
|
||||||
|
for path in paths:
|
||||||
|
uuid = os.path.basename(path)[:-6][-36:]
|
||||||
|
if uuid and self.verify_artifact(uuid, ctx):
|
||||||
|
candidates.append(uuid)
|
||||||
|
return candidates
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Complete 29-Point Wiring & Lockstep Table
|
## 3. Full Lockstep Wiring Table (Rev.2)
|
||||||
|
|
||||||
All 29 locations below must be updated in lockstep:
|
Same 46-item table as Rev.1, with item #27 corrected per §0/§1.9. Re-derived from the current, live state of every file that mentions `opencode` today (33 files, `.agents/reports/` excluded).
|
||||||
|
|
||||||
| # | Component / File | Specific Edit Required |
|
| # | Component / File | Specific Edit Required |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 1 | `.agents/skills/lib_py/agents/adapters/opencode.py` | Create new adapter class `OpenCodeAgentAdapter` |
|
| 1 | `.agents/skills/lib_py/agents/adapters/codex.py` | Create new adapter class `CodexAgentAdapter` per §2 (Rev.2: DB-first artifact resolution + `codex delete --force`-based purge) |
|
||||||
| 2 | `.agents/skills/lib_py/agents/registry.py` | Import `OpenCodeAgentAdapter` and register in `_ADAPTERS['opencode']` |
|
| 2 | `.agents/skills/lib_py/agents/registry.py:9,16` | Import `CodexAgentAdapter`; add `'codex': CodexAgentAdapter()` to `_ADAPTERS` |
|
||||||
| 3 | `.agents/skills/lib.sh` (kind mapping, ~L606-609) | `*-creator-opencode\|*-planner-opencode\|*-reviewer-opencode) kind="opencode" ;;` |
|
| 3 | `.agents/skills/lib_py/atomic_yaml.py:132` | Add `'codex_session_id_own'` to `running_keys` |
|
||||||
| 4 | `.agents/skills/lib.sh` (fallback grep chain, ~L610-619) | Add `elif echo "$name" \| grep -qi "opencode"; then kind="opencode"` |
|
| 4 | `.agents/skills/lib_py/verify_session.py:69` | Add `'codex_session_id_own'` to the `mam_row_own_uuid()` field list |
|
||||||
| 5 | `.agents/skills/lib.sh` (binary dedup tuple, ~L632) | Add `'opencode'` to `('claude', 'agy', 'hermes', 'grok', 'opencode')` |
|
| 5 | `.agents/skills/lib_py/workspace_uuid.py:7-12,34` | Add `'codex': 'codex_session_id_own'` to `OWN_KEY`; add `'codex_session_id_own'` to the `running_ids` field list |
|
||||||
| 6 | `create_session.sh` (line 93 `--agent` whitelist) | Add `opencode` to accepted values |
|
| 6 | `.agents/skills/lib.sh:669` (kind mapping) | Add `*-creator-codex\|*-planner-codex\|*-reviewer-codex) kind="codex" ;;` |
|
||||||
| 7 | `resume_session.sh` (line 45 `--agent` whitelist) | Add `opencode` to accepted values |
|
| 7 | `.agents/skills/lib.sh:675,680` (fallback grep chain, name + `final_cmd`) | Add `elif echo "$name" \| grep -qi "codex"; then kind="codex"` and the `final_cmd` equivalent |
|
||||||
| 8 | `resolve_session_id.sh` (line 39 `--agent` whitelist) | Add `opencode` to accepted values |
|
| 8 | `.agents/skills/lib.sh:694` (binary dedup tuple) | Add `'codex'` to `('claude', 'agy', 'hermes', 'grok', 'opencode', 'codex')` |
|
||||||
| 9 | `update_yaml_resumed.sh` (usage string) | Add `opencode` to usage documentation |
|
| 9 | `.agents/skills/lib.sh:2175` (`send_keys_safe` agent resolution) | Add `*-codex\|*-codex-[0-9]*\|codex\|codex-[0-9]*) _sks_agent="codex" ;;` |
|
||||||
| 10 | `stop_session.sh` (line 94 `--agent` whitelist) | Add `opencode` to accepted values |
|
| 10 | `create_session.sh:4,29,33` (usage/help text) | Add `codex` to the `<claude\|agy\|hermes\|grok\|opencode>` listings |
|
||||||
| 11 | `orc_onboard.sh` (line 106 case) | Add `opencode` to accepted values |
|
| 11 | `create_session.sh:93-94` (`--agent` whitelist) | Add `codex` to the accepted-values case and the error message |
|
||||||
| 12 | `tests/test_a4_adapter_contract.py` | Add `opencode` to `EXPECTED_OWN_KEYS`, `expected`, `expected_modal` (`None`), `facts_bridge`, and delegate bash snippets |
|
| 12 | `create_session.sh` (auth preflight, new `elif` block after the existing `opencode` one at line 129) | `elif [ "$AGENT" = "codex" ]; then` check `~/.codex/auth.json` (respecting `CODEX_HOME`) or `OPENAI_API_KEY`, else `codex --version` smoke check |
|
||||||
| 13 | `tests/test_a4_adapter_contract.py` | Add adapter-specific unit tests (`spawn_spec`, `resume_spec`, `auth_ok`, `purge_artifacts`, `discover`) |
|
| 13 | `create_session.sh:192` (fallback `CMD_FULL` case) | Add `codex) CMD_FULL="${RESOLVED_BIN} --dangerously-bypass-approvals-and-sandbox" ;;` |
|
||||||
| 14 | `tests/test_c1_tui_readiness.py` | Add `opencode` to facts-bridge and SKS session resolution loops |
|
| 14 | `create_session.sh:216` (`spawn()` dispatch case) | Add `codex` to the `agy\|hermes\|grok\|opencode)` case — no special env-var export needed |
|
||||||
| 15 | `tests/test_tier2_component.py` | Add OpenCode lifecycle tests (create → YAML → stop → resume) |
|
| 15 | `create_session.sh:330` (delegate-agent key resolution) | Add `codex) delegate_agent="codex-cli" ;;` |
|
||||||
| 16 | `.mam.env.example` | Document OpenCode multi-provider API keys and `OPENCODE_PERMISSION` |
|
| 16 | `create_session.sh:438` (YAML init Python block) | Add `elif agent == 'codex': entry['codex_session_id_own'] = None; entry['session_id_source'] = 'pending-discovery'; entry['session_id_verified'] = False; entry['last_visible_status'] = "unverified"` |
|
||||||
| 17 | `docs/NEW_AGENT_INTEGRATION_GUIDE.md` | Update difficulty matrix with OpenCode SQLite storage |
|
| 17 | `resolve_session_id.sh:4,16,39-40` | Add `codex` to usage text and the `--agent` whitelist case |
|
||||||
| 18 | `.agents/reports/new_agent_types_roadmap.md` | Add cross-link noting OpenCode details are superseded by this plan |
|
| 18 | `resume_session.sh:12,45` | Add `codex` to usage text and the `--agent` whitelist case |
|
||||||
| 19 | `.agents/skills/lib_py/atomic_yaml.py:132` | Add `'opencode_session_id_own'` to `running_keys` |
|
| 19 | `resume_session.sh:65` (FRESH_SPAWN Class-A case) | Add `codex` to `agy\|hermes\|opencode) FRESH_SPAWN=1 ;;` — Codex is Class A |
|
||||||
| 20 | `.agents/skills/lib_py/verify_session.py:69` | Add `'opencode_session_id_own'` to `mam_row_own_uuid()` |
|
| 20 | `resume_session.sh:121` (fallback spawn-spec case) | Add `codex) CMD_FULL="${RESOLVED_BIN} --dangerously-bypass-approvals-and-sandbox" ;;` |
|
||||||
| 21 | `.agents/skills/lib_py/workspace_uuid.py:7-12, 33` | Add `'opencode'` to `OWN_KEY` dict and `running_ids` list |
|
| 21 | `resume_session.sh:132` (fallback resume-spec case) | Add `codex) CMD_FULL="${RESOLVED_BIN} resume --dangerously-bypass-approvals-and-sandbox $UUID" ;;` |
|
||||||
| 22 | `reconcile.sh` (auto-reg & own keys) | Add `opencode` to loops (521, 540), entry initialization `elif` (584-601), and `OWN_KEY_BY_AGENT` (611) |
|
| 22 | `update_yaml_resumed.sh:7,14,17` | Add `codex` to usage docs and the Class-A comment listing |
|
||||||
| 23 | `create_session.sh` (spawn dispatch, ~L204) | Add `opencode` to `agy\|hermes\|grok\|opencode)` case |
|
| 23 | `update_yaml_resumed.sh:228` (Python block) | Add `elif agent == 'codex': target['pane']['cmd'] = 'codex'`; if `uuid`: `cmd_full = f'codex resume --dangerously-bypass-approvals-and-sandbox {uuid}'`, `target['codex_session_id_own'] = uuid`; else `cmd_full = os.environ.get('CMD_FULL_OPT', '') or 'codex --dangerously-bypass-approvals-and-sandbox'` |
|
||||||
| 24 | `create_session.sh` (YAML init, ~L399-419) | Add `elif agent == 'opencode': entry['opencode_session_id_own'] = None` |
|
| 24 | `stop_session.sh:4,15,94-95` | Add `codex` to usage docs and the `--agent` whitelist case + error message |
|
||||||
| 25 | `create_session.sh` (fallback CMD_FULL, ~L180-184) | Add `opencode) CMD_FULL="$BINARY --auto --agent build" ;;` |
|
| 25 | `stop_session.sh:290` (Python capture block) | Add `elif agent == 'codex': target['codex_session_id_own'] = captured` |
|
||||||
| 26 | `lib.sh` (`send_keys_safe` agent resolution, ~L2058-2061) | Add `*-opencode\|*-opencode-[0-9]*\|opencode\|opencode-[0-9]*) _sks_agent="opencode" ;;` |
|
| 26 | `orc_onboard.sh:106` (agent-family match) | Add `codex` to the `claude\|agy\|hermes\|grok\|opencode)` case |
|
||||||
| 27 | `tests/test_tier1_unit.py` (~L969, 974) | Update expected CLI error usage assertions to include `opencode` |
|
| 27 | `orc_onboard.sh:117-133` (CLI-argv detection) | **Rev.2 corrected**: new `codex)` branch using the flag-tolerant regex from §1.9 (`resume[[:space:]]+.*<uuid-pattern>` then extract just the UUID) — **not** the Rev.1 regex, which is confirmed broken against this plan's own `resume_spec` output |
|
||||||
| 28 | 8× `SKILL.md` supported-agent lists | Update skill documentation to list `opencode` |
|
| 28 | `orc_onboard.sh:142-148` (env-var fallback) | Add `codex) env_var=""` explicitly (no known env var — do not fabricate one) |
|
||||||
| 29 | `tests/conftest.py` (`mock_agents`) | Add mock `opencode` binary fixture and sandbox state support |
|
| 29 | `reconcile.sh:523,542` (agent-name tuples) | Add `'codex'` to both `('claude', 'agy', 'hermes', 'grok', 'opencode')` tuples |
|
||||||
|
| 30 | `reconcile.sh:603` (fresh-row entry init) | Add `elif agent == 'codex': entry['codex_session_id_own'] = None` |
|
||||||
|
| 31 | `reconcile.sh:616` (`OWN_KEY_BY_AGENT`) | Add `'codex'` to the tuple |
|
||||||
|
| 32 | `reconcile.sh` (new dedicated block, after the existing opencode block at line ~787-897) | New `# === drift C (codex) ===` block, **Rev.2**: state-DB-first with glob fallback (see §4) |
|
||||||
|
| 33 | 8× `SKILL.md` supported-agent lists (`multi-agent-mux-create:132,163-166`; `multi-agent-mux-orc-onboard:11,35,41`; `multi-agent-mux-resume:52,55,61,73,99,110`; `multi-agent-mux-stop:40`) | Add `codex` everywhere `opencode` currently appears in these four files; the other 4 `SKILL.md` files need only the version-frontmatter bump (§6) |
|
||||||
|
| 34 | `.agents/skills/multi-agent-mux-delegate-job/job-protocol.md:3`, `README.md:3` | No change needed — `codex` already listed informally |
|
||||||
|
| 35 | `tests/conftest.py` | Add a `codex` mock binary fixture (mirroring `opencode_bin`, line ~972-990) that writes a fake `rollout-<ts>-<uuid>.jsonl` under a temp `$CODEX_HOME/sessions/<Y>/<M>/<D>/` **and** a matching `state_1.sqlite` `threads` row (Rev.2: needed since `discover()`/`verify_artifact()` now query the DB first); add ready-banner/own-key dict entries (lines ~553, ~584) |
|
||||||
|
| 36 | `tests/test_a4_adapter_contract.py` | Add `codex` to `EXPECTED_OWN_KEYS`, the property tuple, `expected_modal` (`None`), the `facts_bridge` loop (line ~112), a `_seed_codex_session()` helper (mirroring `_seed_opencode_session`, line ~132, **Rev.2**: seeding both the `.jsonl` and the `state_*.sqlite` row), and unit tests for `spawn_spec`/`resume_spec`/`auth_ok`/`purge_artifacts` (**Rev.2**: including a test that `purge_artifacts` falls back to manual removal when the `codex delete` subprocess call fails)/`discover` |
|
||||||
|
| 37 | `tests/test_c1_tui_readiness.py` | Add `codex` to the facts-bridge and `send_keys_safe` agent-resolution loops (line ~491) and a `check_sks` assertion pair (mirroring lines 528-529, 539-540) |
|
||||||
|
| 38 | `tests/test_tier1_unit.py` | Update the agent-tuple assertions (lines ~963, 969, 974, 988-989, 994, 1033) to include `codex`; add `test_codex_shell_and_scripts_integration` mirroring `test_opencode_shell_and_scripts_integration` (lines 982-1044); **Rev.2**: add a dedicated regression test asserting `orc_onboard.sh`'s `codex` regex correctly extracts the UUID from `codex resume --dangerously-bypass-approvals-and-sandbox <uuid>` — this is the exact scenario Rev.1 got wrong, so it must be locked down by a test, not just fixed in prose |
|
||||||
|
| 39 | `tests/test_tier2_component.py` | Add `test_comp_codex_create_and_reconcile_and_stop` mirroring `test_comp_opencode_create_and_reconcile_and_stop` (line ~1229) |
|
||||||
|
| 40 | `.mam.env.example` (after the existing OpenCode block, line ~110) | Document `OPENAI_API_KEY` and `CODEX_HOME` as an optional override |
|
||||||
|
| 41 | `docs/NEW_AGENT_INTEGRATION_GUIDE.md` §3 (difficulty matrix) | Update the `Codex CLI` row from the old guessed values to the verified ones from §1 |
|
||||||
|
| 42 | `.agents/reports/new_agent_types_roadmap.md` §4 | Add a cross-link noting the Codex row is superseded by this plan |
|
||||||
|
| 43 | `.agents/MULTI_AGENT_RULES.md:21` / `.ko.md:21` | Add `codex` to the Team-Leader agent-name parenthetical, scope confined to that one line each |
|
||||||
|
| 44 | `README.md:3,17,45,136` / `README.ko.md:3,17,27,118` | Add `codex` to the 5-agent → 6-agent listings, incl. a `codex resume <uuid>` example (positional form, §1.9) |
|
||||||
|
| 45 | `BOOTSTRAP.md:197` / `.ko.md:196` | Add `codex` to the onboarding agent-name parenthetical |
|
||||||
|
| 46 | `deploy/INSTALL.md:3` | Add `Codex` to the parenthetical agent list |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. TUI Readiness & Empirical Verification
|
## 4. `reconcile.sh` Drift-C Block for Codex (Rev.2: state-DB-first)
|
||||||
|
|
||||||
- Initial candidate pattern: `OpenCode|Chat`
|
|
||||||
- **Empirical Capture Gate**: Before finalizing the PR, capture rendered text from a live `opencode` TUI pane using `herdr pane capture` to confirm the exact ready token and prompt glyphs (`input_prompt`, `input_placeholder`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Drift Detection Integration & Dedicated Drift-C Block
|
|
||||||
|
|
||||||
In `multi-agent-mux-monitor/scripts/reconcile.sh`, add the dedicated OpenCode materialization block:
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# === drift C (opencode): opencode 새 session id materialize (per-row own id) ===
|
# === drift C (codex): codex 새 session id materialize (per-row own id) ===
|
||||||
for s in d.get('herdr_sessions', []):
|
for s in d.get('herdr_sessions', []):
|
||||||
if row_agent(s) != 'opencode':
|
if row_agent(s) != 'codex':
|
||||||
continue
|
continue
|
||||||
if s.get('status') != 'running':
|
if s.get('status') != 'running':
|
||||||
continue
|
continue
|
||||||
if s.get('opencode_session_id_own'):
|
if s.get('codex_session_id_own'):
|
||||||
continue
|
continue
|
||||||
cwd = (s.get('pane') or {}).get('cwd', '')
|
cwd = (s.get('pane') or {}).get('cwd', '')
|
||||||
if not cwd:
|
if not cwd:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Resolve DB path via opencode db path or default XDG root
|
codex_home = os.environ.get('CODEX_HOME') or f"{home}/.codex"
|
||||||
db = _resolve_opencode_db(home, cwd)
|
|
||||||
if not os.path.exists(db):
|
|
||||||
continue
|
|
||||||
|
|
||||||
epoch_threshold = s.get('herdr_session_epoch', 0)
|
epoch_threshold = s.get('herdr_session_epoch', 0)
|
||||||
|
|
||||||
sibling_claimed = [
|
sibling_claimed = [
|
||||||
other.get('opencode_session_id_own')
|
other.get('codex_session_id_own')
|
||||||
for other in d.get('herdr_sessions', [])
|
for other in d.get('herdr_sessions', [])
|
||||||
if other is not s
|
if other is not s
|
||||||
and (other.get('pane') or {}).get('cwd') == cwd
|
and (other.get('pane') or {}).get('cwd') == cwd
|
||||||
and other.get('status') not in ('stopped', 'terminated')
|
and other.get('status') not in ('stopped', 'terminated')
|
||||||
and other.get('opencode_session_id_own')
|
and other.get('codex_session_id_own')
|
||||||
]
|
]
|
||||||
s_eval = dict(s)
|
s_eval = dict(s)
|
||||||
s_eval['_sibling_claimed_uuids'] = sibling_claimed
|
s_eval['_sibling_claimed_uuids'] = sibling_claimed
|
||||||
|
|
||||||
valid_candidates = []
|
valid_candidates = []
|
||||||
try:
|
try:
|
||||||
conn = sqlite3.connect(db)
|
# Rev.2: state DB first (indexed, single query) — dynamic filename,
|
||||||
rows = conn.execute(
|
# no hardcoded suffix (see §1.5 / §2 _state_db()).
|
||||||
"SELECT id FROM session WHERE cwd=? AND created_at >= ? ORDER BY created_at DESC LIMIT 20",
|
state_matches = glob.glob(f"{codex_home}/state_*.sqlite")
|
||||||
(cwd, epoch_threshold)
|
state_db = max(state_matches, key=os.path.getmtime) if state_matches else None
|
||||||
).fetchall()
|
rows = []
|
||||||
conn.close()
|
if state_db and os.path.exists(state_db):
|
||||||
for (uuid,) in rows:
|
conn = sqlite3.connect(state_db)
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT id, updated_at FROM threads WHERE cwd=? ORDER BY updated_at DESC LIMIT 20",
|
||||||
|
(cwd,)
|
||||||
|
).fetchall()
|
||||||
|
conn.close()
|
||||||
|
if not rows:
|
||||||
|
# Fallback: bounded glob over the date-nested tree
|
||||||
|
paths = sorted(
|
||||||
|
glob.glob(f"{codex_home}/sessions/*/*/*/rollout-*.jsonl"),
|
||||||
|
key=os.path.getmtime, reverse=True
|
||||||
|
)[:50]
|
||||||
|
rows = [(os.path.basename(p)[:-6][-36:], os.path.getmtime(p)) for p in paths]
|
||||||
|
|
||||||
|
for uuid, ts in rows:
|
||||||
|
if epoch_threshold and ts and ts < epoch_threshold:
|
||||||
|
continue
|
||||||
if uuid in (sibling_claimed or []):
|
if uuid in (sibling_claimed or []):
|
||||||
continue
|
continue
|
||||||
if verify_session_uuid(cwd, 'opencode', uuid, s_eval, mode="discover"):
|
if verify_session_uuid(cwd, 'codex', uuid, s_eval, mode="discover"):
|
||||||
valid_candidates.append(uuid)
|
valid_candidates.append(uuid)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@@ -215,35 +397,58 @@ for s in d.get('herdr_sessions', []):
|
|||||||
actions.append(f"ambiguous candidates: {s['name']}")
|
actions.append(f"ambiguous candidates: {s['name']}")
|
||||||
elif len(valid_candidates) == 1:
|
elif len(valid_candidates) == 1:
|
||||||
uuid = valid_candidates[0]
|
uuid = valid_candidates[0]
|
||||||
cmd = ['bash', '-c', f'source "{lib_sh}" && verify_tui_viewport "{s["name"]}" "opencode" "{cwd}"']
|
cmd = ['bash', '-c', f'source "{lib_sh}" && verify_tui_viewport "{s["name"]}" "codex" "{cwd}"']
|
||||||
rc = subprocess.run(cmd).returncode
|
rc = subprocess.run(cmd).returncode
|
||||||
if rc == 0:
|
if rc == 0:
|
||||||
_pin_and_verify_resume(s, 'opencode', cwd, uuid, degraded=False)
|
_pin_and_verify_resume(s, 'codex', cwd, uuid, degraded=False)
|
||||||
elif rc == 1:
|
elif rc == 1:
|
||||||
drifts.append({'class': 'C-warn', 'name': s['name'], 'msg': f"{s['name']}: TUI viewport mismatch for candidate {uuid} — not pinned, will retry next cycle"})
|
drifts.append({'class': 'C-warn', 'name': s['name'], 'msg': f"{s['name']}: TUI viewport mismatch for candidate {uuid} — not pinned, will retry next cycle"})
|
||||||
else:
|
else:
|
||||||
_pin_and_verify_resume(s, 'opencode', cwd, uuid, degraded=True)
|
_pin_and_verify_resume(s, 'codex', cwd, uuid, degraded=True)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
`verify_session_uuid()` (shared, `lib_py/verify_session.py`) needs a `codex_session_id_own` entry added to its own-key list (wiring item #4) for the `C0`-class "confirm, don't discover" path.
|
||||||
|
|
||||||
## 6. Definition of Done Checklist
|
|
||||||
|
|
||||||
- [ ] Execute `opencode db path` and SQLite `.schema` dump spike on a live install.
|
|
||||||
- [ ] Confirm TUI ready tokens and input prompt glyphs via empirical pane capture.
|
|
||||||
- [ ] Verify headless execution under `--auto` + `OPENCODE_PERMISSION='{"*":"allow"}'`.
|
|
||||||
- [ ] Implement `lib_py/agents/adapters/opencode.py` with sandbox-safe `ctx.home_dir`.
|
|
||||||
- [ ] Apply all 29 lockstep wiring points across `registry.py`, `lib.sh`, skill scripts, `lib_py` modules, and test fixtures.
|
|
||||||
- [ ] Add dedicated `reconcile.sh` Drift-C materialization block and corresponding integration test.
|
|
||||||
- [ ] Verify 100% test pass on full suite (`pytest tests/`).
|
|
||||||
- [ ] Perform manual end-to-end smoke test (create → prompt → discover → stop/purge).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. Effort Estimate
|
## 5. `status.sh`
|
||||||
|
|
||||||
- **Total Estimate**: **~1.5 – 2.0 Days**
|
Both `resume_on_disk()`-style helpers (lines ~56-61, ~234-239) iterate `('claude', 'agy', 'hermes', 'grok', 'opencode')`. Add `codex` to both tuples and a new `if agent == 'codex':` branch resolving via the same DB-first/glob-fallback helper as the adapter (§2), returning `'yes'`/`'MISSING'`/`'no'`.
|
||||||
- ½ day: SQLite schema spike (`opencode db path`) & TUI token live capture
|
|
||||||
- ½ day: Adapter implementation & 29-point mechanical wiring
|
---
|
||||||
- ½ day: Dedicated `reconcile.sh` Drift-C materialization block & integration tests
|
|
||||||
- ½ day: End-to-end manual smoke tests & regression verification
|
## 6. Version & Documentation Bump
|
||||||
|
|
||||||
|
Current: `MAM_VERSION="4.1.3"`. Propose **`4.1.3 → 4.2.0` (MINOR)**, matching the OpenCode precedent, in strict 3-way lockstep (`lib.sh:32`, `VERSIONS.md`, all 8 `SKILL.md` frontmatter) per `tests/test_version_consistency.py`. No new agent-specific guide needed (no local-LLM/Ollama angle for Codex).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Empirical Verification Gate (must happen before merge)
|
||||||
|
|
||||||
|
1. Live pane capture to confirm `ready_tokens`/`input_placeholder` (§1.6).
|
||||||
|
2. Live smoke test that `--dangerously-bypass-approvals-and-sandbox` suppresses every confirmation prompt in a real multi-file-edit task.
|
||||||
|
3. Live test of the corrected `orc_onboard.sh` regex (§1.9) against a real running `codex resume <uuid>` process's `ps`/`/proc` argv snapshot — the regex was verified against a synthetic string in this planning pass, not a real captured process line.
|
||||||
|
4. **(Rev.2, new)** Confirm the `state_*.sqlite` glob resolves correctly on a *different* Codex install/version than this machine's `state_5.sqlite` — the numeric suffix is not a documented, guaranteed-stable contract, only an observed fact on one install.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Definition of Done Checklist
|
||||||
|
|
||||||
|
- [ ] Complete Phase 0 empirical gate (§7), including the new Rev.2 state-DB-portability check.
|
||||||
|
- [ ] Implement `.agents/skills/lib_py/agents/adapters/codex.py` per §2 (DB-first resolution, `codex delete --force`-based purge with fallback).
|
||||||
|
- [ ] Apply all 46 lockstep wiring points in §3, including the **corrected** `orc_onboard.sh` regex (item #27).
|
||||||
|
- [ ] Add the dedicated `reconcile.sh` Drift-C block (§4, state-DB-first) and its `C-ambiguous`-scenario test.
|
||||||
|
- [ ] Add all test coverage in §3 items #35-39, **including the regression test locking down the corrected `orc_onboard.sh` regex** (item #38) and the `purge_artifacts` CLI-then-fallback test (item #36); `pytest tests/` shows 0 regressions.
|
||||||
|
- [ ] Bump version to `4.2.0` in strict 3-way lockstep (§6).
|
||||||
|
- [ ] Correct the two speculative prior reports' Codex rows (§3 items #41-42).
|
||||||
|
- [ ] Manual end-to-end smoke test: create → prompt → status shows `yes` → reconcile materializes `codex_session_id_own` → stop → resume round-trips → purge leaves no orphan `.jsonl` or `state_*.sqlite` row.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Effort Estimate
|
||||||
|
|
||||||
|
- **Total Estimate**: **~1.5-1.75 days** (Rev.2: +0.25 day vs. Rev.1 for the state-DB-aware adapter logic and the corrected/tested `orc_onboard.sh` regex).
|
||||||
|
- ~2-3 hrs: Phase 0 empirical gate (§7), including the new state-DB-portability check.
|
||||||
|
- ~4-5 hrs: adapter implementation (DB-first + glob-fallback throughout) + the 46-point mechanical wiring pass.
|
||||||
|
- ~2-3 hrs: dedicated `reconcile.sh` Drift-C block + `status.sh` + full test suite additions, including the two Rev.2 regression tests.
|
||||||
|
- ~2 hrs: docs/version bump + end-to-end manual smoke test (including a purge-leaves-no-orphan-row check).
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
# 📊 Issue #5 기술 심층 검토 통합 보고서 (Rev. 2)
|
||||||
|
|
||||||
|
- **대상 이슈**: [Gitea Issue #5 — `[Refactor] lib.sh 작업 위임 로직 및 run_loop.sh 오케스트레이터의 Python 모듈화 (lib_py 승격)`](https://git.godopu.com/tmpl/multi-agent-mux/issues/5)
|
||||||
|
- **작성 주체**: Antigravity (총괄 오케스트레이터)
|
||||||
|
- **검토 참여자**: 3개 팀 리더 리뷰어 에이전트 전원 참여 (1차 검토 및 2차 교차 검토 완료)
|
||||||
|
- `planner-reviewer-claude-01`
|
||||||
|
- `reviewer-creator-grok-01`
|
||||||
|
- `planner-reviewer-codex-01`
|
||||||
|
- **검토 일시**: 2026-09-06 (Rev.1 발행 및 2차 정밀 교차 검증 반영)
|
||||||
|
- **기준 브랜치 / 커밋**: `refactor` (`47640e2`)
|
||||||
|
- **종합 합의 상태**: **파이썬 모듈화 방향성 만장일치 지지 (단, 자동완료 증거 계약·CAS 상태 전이·런타임 불변성의 필수 안전 조건 선결을 전제로 한 조건부 동의이며, 기계 판정용 최종 구현 PASS가 아님)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 개요 및 합의 수준의 사실적 기술
|
||||||
|
|
||||||
|
본 보고서는 `lib.sh`의 작업 위임 로직 및 `run_loop.sh` 오케스트레이터 전체를 파이썬 패키지(`lib_py`)로 승격 및 모듈화하는 제안(Issue #5)에 대해, 세 팀 리더 리뷰어가 코드베이스 실측과 2차에 걸친 독립적 교차 분석을 통해 도출한 최종 정제 통합 보고서입니다.
|
||||||
|
|
||||||
|
세 리뷰어 모두 **파이썬 모듈화의 필요성과 아키텍처 방향성에 공통으로 찬성**합니다. 그러나 이 찬성은 "무조건적 구현 승인"이 아니며, 자동완료 오탐 방지, 다중 Writer 경합, 런타임 자기수정 크래시(B-13)의 단계적 해소 조건에 대해 엄격한 기술적 안전 계약이 수립되어야 함을 강력히 요구하고 있습니다.
|
||||||
|
|
||||||
|
### 📌 리뷰어별 평가 및 판정 토큰 현황 (사실 대조)
|
||||||
|
|
||||||
|
| 리뷰어 에이전트 | 세션명 | 1차 검토 Job | 2차 검토 Job | 공식 `[VERDICT: ...]` 토큰 | 종합 평가 성격 |
|
||||||
|
| :--- | :--- | :---: | :---: | :---: | :--- |
|
||||||
|
| **Claude** | `planner-reviewer-claude-01` | `1919bf5e` | `cdc926e3` | **미발행 (산문형 지지)** | 방향성 지지, Herdr 1급 API 실재 발견, 1,621줄 subprocess 테스트 재타겟팅 공수 경고 |
|
||||||
|
| **Grok** | `reviewer-creator-grok-01` | `9885ecd6` | `3e3b6d18` | **`[VERDICT: PASS]`** | 방향성 조건부 PASS, `interactive_ready` 배제 실측 증명, 15s 디바운스 및 6대 게이트 제시 |
|
||||||
|
| **Codex** | `planner-reviewer-codex-01` | `5da43b79` | `14f9eac4` | **미발행 (조건부 동의)** | 방향성 동의, 실행 귀속(Attribution)·리포트 확정 계약·단일 CAS 전이 필수 조건화 |
|
||||||
|
|
||||||
|
> 3명 중 1명(Grok)만 단독 행 `[VERDICT: PASS]` 토큰을 남겼으며, Claude와 Codex는 구체적 안전 조건을 요구하는 산문형 조건부 동의를 제출했습니다. 본 프로젝트의 엄밀한 fail-closed 계약에 따라 이를 "만장일치 최종 PASS"로 왜곡하지 않고 **"안전 계약 선결을 전제로 한 방향성 만장일치 합의"**로 명문화합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 2차 교차 검증을 통해 복원된 핵심 안전 계약
|
||||||
|
|
||||||
|
세 리뷰어의 2차 정밀 피드백을 통해 1차 요약 과정에서 누락되거나 단순화되었던 핵심 엔지니어링 계약들이 복원되었습니다.
|
||||||
|
|
||||||
|
### 2-1. 실행 귀속(Execution Attribution) 보장 계약 (Codex 복원, 전원 지지)
|
||||||
|
- 단순히 터미널이 `idle`이라고 해서 완료로 판단해서는 안 됨 (세션 재사용 시 이전 작업의 잔여 유휴 상태 오인 위험).
|
||||||
|
- 자동완료 후보는 반드시 다음 **4중 식별자**가 일치해야 함:
|
||||||
|
`Job ID` + `Attempt ID` + `Agent/Session UUID` + `Pane/Process Generation`
|
||||||
|
- 디스패치 확인 시점 이후 해당 시도(Attempt)의 실제 실행 및 응답이 관측된 경우에만 완료 후보로 인정.
|
||||||
|
- 동일 에이전트 세션에는 동시에 단 하나의 Job만 배정.
|
||||||
|
|
||||||
|
### 2-2. 리포트 확정 계약의 단계적 진화 (Grok vs Codex 이견 정리)
|
||||||
|
- **1차/보조 필터 (Grok 제안)**: `report-final.md` mtime이 일정 디바운스 구간(기본 권장 ≥15초, monotonic clock 측정) 동안 불변일 것.
|
||||||
|
- **2차/강화 계약 (Codex 제안)**: 시간 경과(15s)만으로는 파일 쓰기 완료를 완벽히 증명할 수 없으므로, 임시 파일 작성 후 **원자적 이름 변경(Atomic Rename)** 및 `Attempt ID`와 `Report Hash`가 명시된 **완료 매니페스트(Finalization Manifest)** 생성을 최종 목표로 설정.
|
||||||
|
- **적용 규칙**: Phase 2a에서는 mtime 디바운스(15s)를 보조 필터로 검증하고, Phase 2b에서는 확정 계약 미지원 작업은 자동 완료 대신 "복구 후보/수동 확인"으로 남김.
|
||||||
|
|
||||||
|
### 2-3. 단일 상태 전이 주체(Engine)와 원자적 CAS (Compare-And-Swap)
|
||||||
|
- **책임의 엄격한 격리**:
|
||||||
|
- `watcher.py`: 오직 관측 증거(증거 튜플: Event, Herdr status, Report path/hash)만 수집하여 큐로 전달.
|
||||||
|
- `engine.py`: 락 하에서 상태 전이를 결정하는 **유일한 주체**.
|
||||||
|
- **원자적 상태 전이 불변식**:
|
||||||
|
- Agent의 `publish_event`와 Engine의 Watcher 감시 모두 **공통 전이 함수/서비스**를 사용.
|
||||||
|
- `expected attempt` 및 `비terminal(started/progress)` 상태임을 확인한 후 원자적으로 전이 (CAS).
|
||||||
|
- 명시적 `error` 또는 `cancelled` 상태를 자동 `completed`로 덮어쓰지 않음.
|
||||||
|
- 완료 확정 후 뒤늦게 도착한 `progress`나 `started` 이벤트로 상태를 역행시키지 않음.
|
||||||
|
- **잠금(Lock) 내 I/O 및 재진입 대기 위험 방지**:
|
||||||
|
- Registry Lock 유지 상태에서 자식 프로세스 호출(subprocess)이나 네트워크 I/O(MQTT)를 실행하지 않음.
|
||||||
|
- 짧은 트랜잭션으로 상태 확정 및 Pending Notification 기록 ➔ Lock 즉시 해제 ➔ 비동기 MQTT 발행 및 멱등 재시도 수행.
|
||||||
|
- `auth_token != null`인 경우 레지스트리의 토큰으로 HMAC 서명하여 정규 발행 (누락 시 silent drop 방지).
|
||||||
|
|
||||||
|
### 2-4. Herdr 네이티브 API의 정확한 호출 규격 및 예외 처리
|
||||||
|
- **옵션 명시 필수**:
|
||||||
|
- `herdr agent wait <target>`의 CLI 기본값은 `idle, done, or blocked`이며, `blocked`는 permission_required나 입력 대기 상태이므로 완료가 아님.
|
||||||
|
- 따라서 Watcher는 반드시 상태를 명시적으로 지정하여 호출해야 함:
|
||||||
|
`herdr agent wait <TARGET> --until idle --until done --timeout <MS>`
|
||||||
|
- **`interactive_ready`의 성격 규정**:
|
||||||
|
- 에이전트가 코딩 작업 중(`agent_status=working`)일 때도 `interactive_ready=true`임이 실측으로 증명됨.
|
||||||
|
- 따라서 `interactive_ready`는 완료 충분조건이 아니며, 오직 `agent_status in {"idle", "done"}`만 완료 후보로 인정.
|
||||||
|
- **소켓 단절 및 버전 드리프트 가드**:
|
||||||
|
- Herdr 소켓 단절 시 반환되는 `unknown`을 idle로 오인하지 않고, 재연결 백오프 및 스냅샷 재동기화를 수행.
|
||||||
|
- 의존하는 Herdr CLI/JSON 필드의 최소 호환 버전을 검증하고, 불일치 시 fail-closed(수동 확인)로 폴백.
|
||||||
|
|
||||||
|
### 2-5. 특수 작업에 대한 예외 및 Fail-Closed 원칙
|
||||||
|
- **외부 부수효과 작업 예외**:
|
||||||
|
- Gitea 댓글 등록, Git push, 배포 등 리포트 파일 생성 외에 외부 시스템에 부수효과를 일으키는 작업은 리포트 파일 존재만으로 자동완료 처리하지 않음 (외부 실행 완료 증거가 없을 경우 수동 확인 유지).
|
||||||
|
- **무판정 Reviewer 리포트 처리**:
|
||||||
|
- Reviewer 작업의 리포트 마지막 줄에 `[VERDICT: PASS]` 또는 `[VERDICT: NOT PASS]` 토큰이 누락된 경우, `completed` 승격 절대 금지.
|
||||||
|
- 작업 deadline 도달 시 명시적 `error` 발행 또는 반려 처리 (Fail-Closed 엄격 유지).
|
||||||
|
- Reviewer의 `completed`(리뷰 행위 완료)와 `PASS`(검증 통과)를 절대 혼동하지 않음.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 합의된 안전 상태 머신 아키텍처
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
subgraph Watcher["이중 감시 체계 (Watcher - 관측 증거 수집 전용)"]
|
||||||
|
W1["채널 1: MQTT 이벤트 스트림 및 로컬 작업 레코드"]
|
||||||
|
W2["채널 2: herdr agent wait --until idle --until done"]
|
||||||
|
W3["파일 증거: 지정 경로 report-final.md mtime 디바운스 (≥15s)"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Gate["안전 검증 게이트 (Fail-Closed AND Gate)"]
|
||||||
|
G1{"4중 귀속 일치?\n(Job ID + Attempt + UUID + Generation)"}
|
||||||
|
G2{"agent_status in (idle, done)?\n(interactive_ready 단독 불인정)"}
|
||||||
|
G3{"리포트 확정 완료?\n(디바운스 경과 및 매니페스트 확인)"}
|
||||||
|
G4{"Reviewer: 마지막 행 정식 판정 토큰 존재?"}
|
||||||
|
G5{"외부 부수효과 작업 여부 확인\n(부수효과 시 별도 증거 필요)"}
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Engine["단일 상태 제어 엔진 (Engine - 배타적 전이)"]
|
||||||
|
E1["공통 상태 전이 트랜잭션 (CAS Lock)"]
|
||||||
|
E2["Terminal State Commit & Pending Notification 기록"]
|
||||||
|
E3["Lock 해제"]
|
||||||
|
E4["정규 publish_event 경로로 이벤트 발행 (HMAC 서명)"]
|
||||||
|
E5["다음 루프 단계 디스패치"]
|
||||||
|
end
|
||||||
|
|
||||||
|
W1 & W2 & W3 --> Gate
|
||||||
|
G1 & G2 & G3 & G4 & G5 -->|모두 충족 (AND)| Engine
|
||||||
|
Gate -->|하나라도 미충족| Wait["계속 대기 (Wait / Fail-Closed)"]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 4단계 마이그레이션 로드맵 (세부 기준 보완)
|
||||||
|
|
||||||
|
| 단계 | 작업 목표 | 상세 구현 내용 및 필수 완료 기준 (Gate) |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| **Phase 0** | **사전 계약 고정 및 Fixture 수집** | • CLI, 상태 이벤트, 리포트 형식, 락 계약 기준선 문서화<br>• Herdr 6개 어댑터별 런타임 상태 전이 fixture 수집<br>• 미합의 기술 항목(디바운스 vs 매니페스트)의 단계별 채택 기준 확정 |
|
||||||
|
| **Phase 1** | **`lib_py/delegation/` 구축** | • `lib.sh` L1833 위임 헬퍼를 파이썬 서비스 클래스로 추출<br>• `start_watchdog`은 실제 역할에 맞춰 `ensure_subscriber_daemon`으로 명명<br>• 인메모리 단일 MQTT 연결 재사용 프로토타입 (레이턴시는 벤치마크로 보고)<br>• 기존 CLI/non-fatal/HMAC/seq/디스크 폴백 회귀 100% 통과<br>• **자동완료 정책 변경 없음, 세션 DB와 Job 레지스트리 저장소 통합 시도 금지** |
|
||||||
|
| **Phase 2a** | **`lib_py/loop/` 코어 & Shadow Watcher** | • `engine.py` (단일 전이 주체), `watcher.py` (`herdr agent wait` 래핑) 구현<br>• 자동완료는 수행하지 않고 "Would-Complete" 로깅만 수행하는 Shadow 모드 가동<br>• 소켓 단절/재연결 백오프, 부분 리포트, 승인 대기, 도구 실행 시뮬레이션 검증 |
|
||||||
|
| **Phase 2b** | **이중 감지 자동완료 활성화 (Flag-Gated)** | • 4중 실행 귀속 + 리포트 확정 계약 + 단일 CAS 전이 게이트 검증<br>• 부수효과 작업 예외 규정 및 무판정 fail-closed 단위 테스트 완료<br>• 기본 off 플래그로 배포 후 검증 완료 시 기본값 활성화 |
|
||||||
|
| **Phase 3** | **`run_loop.sh` Thin Wrapper 전환 및 회귀** | • `run_loop.sh`를 인자 전달 래퍼(`exec python3 -m lib_py.loop "$@"`)로 전환<br>• `--creator` 필수, `--target-agent` 거부, bash 3.2, PYTHONPATH 계약 보존<br>• 5개 subprocess 전용 테스트(~1,621줄) 포함 **현행 전체 469개 테스트 0-Fail**<br>• **신·구 엔진의 동일 작업공간 동시 Writer 병행 금지** |
|
||||||
|
| **Phase 4** | **불변 런타임 검증 및 B-13 정리** | • 기동 시 전체 모듈 상주(Eager Import) 및 자식 인터프리터/템플릿 불변성 검증<br>• 작업 도중 스킬 파일 교체 및 늦은 경로(재심/승격/롤백) 스트레스 테스트 통과<br>• `/tmp/mam-loop-freeze.XXXXXX` 셸 임시 우회책 완전 제거 및 문서화 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 명시적 비범위(Out of Scope) 및 호환성 계약
|
||||||
|
|
||||||
|
1. **저장소 통합 금지**: 본 리팩토링에서 TMUX 세션 관리용 SQLite/YAML 저장소와 비동기 작업 관리용 `.mam/jobs/` JSON 레지스트리를 하나로 통합하려 시도하지 않습니다 (목적과 수명이 다름).
|
||||||
|
2. **원샷 스킬의 Live Tree 사용 유지**: `multi-agent-mux-create`, `resume`, `stop` 등 단발성 셸 스크립트는 Freeze 복사본이 아닌 실제 작업 트리의 코드를 실행하도록 유지합니다.
|
||||||
|
3. **타임아웃의 3원 분리 보존**: (a) 전체 벽시계 타임아웃, (b) MQTT 가입자 idle-timeout, (c) TUI idle 자동완료를 독립적으로 유지하며, 자동완료가 기존 타임아웃 메커니즘을 덮어쓰지 않습니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 결론
|
||||||
|
|
||||||
|
2차 교차 검토를 통해 본 통합 보고서는 세 리뷰어의 실질적 발견과 기술적 차이를 충실히 수렴하여 완성도를 대폭 끌어올렸습니다.
|
||||||
|
모든 리뷰어가 지적한 대로 **Phase 0(사전 계약 및 어댑터 fixture 정리)** 을 즉시 수립한 후, **Phase 1(`lib_py/delegation/` 구축 및 안전한 서비스 추출)** 에 착수하는 것이 가장 안전하고 확실한 진행 경로입니다.
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
# 📑 Issue #6 종합 리뷰 및 아키텍처 개선 통합 보고서 (Rev. 2)
|
||||||
|
|
||||||
|
- **작성 주체**: 오케스트레이터 (Orchestrator)
|
||||||
|
- **참여 리뷰어**: `planner-reviewer-claude-01` (Claude), `reviewer-creator-grok-01` (Grok), `planner-reviewer-codex-01` (Codex), `reviewer-opencode-01` (OpenCode)
|
||||||
|
- **검토 대상**: Gitea [Issue #6](https://git.godopu.com/tmpl/multi-agent-mux/issues/6) — `[Bug] delegate-job 워커 실패 시 레지스트리 미갱신(Fail-Silent) 및 TUI 턴 마감 레이스 컨디션 해결`
|
||||||
|
- **검토 기준 커밋**: `f98b958` (`main`, `refactor`, v4.4.0)
|
||||||
|
- **문서 버전**: Revision 2 (Rev. 2 — 2차 라운드 리뷰어 피드백 전건 반영)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 개요 및 총평
|
||||||
|
|
||||||
|
오케스트레이터 루프(`run_loop.sh` / `lib_py.loop`) 실행 중 발생했던 3,900초 영구 대기(hang) 결함(Issue #6)에 대해 4명의 리뷰어가 독립적으로 소스 코드 추적, 단위 격리 재현, 실측 포렌식, Herdr 0.8.2 네이티브 API 계약 분석을 수행했습니다.
|
||||||
|
|
||||||
|
**리뷰어 4인 전원의 만장일치 판정 결과:**
|
||||||
|
> **"Issue #6에서 제기한 결함 ①과 결함 ②는 모두 실제 코드베이스에 존재하는 심각한 실재 버그 및 설계 결함이다."**
|
||||||
|
|
||||||
|
특히 2차 라운드 교차 검토를 통해 **루프 실사용 경로(`--type direct`)의 숨은 결함**, **기존 `deadline_timeout` CAS 엔진 재사용 원칙**, **구독자 타임아웃 예산 자가포식 방지를 위한 사전 핸드셰이크 순서**, **브로커 장애(`rc=3`) 격리** 등 프로덕션 수준의 정밀한 가드레일이 통합 보고서 Rev.2에 완벽히 보강되었습니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 결함별 진위 검토 및 심층 분석
|
||||||
|
|
||||||
|
### 2.1 결함 ①: `multi-agent-mux-delegate-job` 워커 실패 시 레지스트리 미갱신 (Fail-Silent 버그)
|
||||||
|
- **판정**: **실재하는 핵심 결함 (CONFIRMED REAL, P0/P1)**
|
||||||
|
- **실측 및 재현 결과**:
|
||||||
|
1. **이슈 지목 위치(`:358-362`)의 진위**:
|
||||||
|
- `_phase == "worker"`에서 `job_status != "completed"`일 때 `break` 후 레지스트리(`registry.py status --set error`) 갱신 없이 함수 끝에서 로그 경로를 출력하며 `exit 0`으로 종료되는 현상이 정확히 확인되었습니다.
|
||||||
|
- 동일한 결함이 리뷰어 단계(`:375-378`) 조기 탈출 분기에도 동일하게 존재합니다.
|
||||||
|
2. **오케스트레이터 실사용 경로(`--type direct:227`)의 숨은 결함 (전원 공통 발견)**:
|
||||||
|
- 상위 오케스트레이터(`lib_py/loop/runner.py:271` 및 `run_loop.sh`)는 루프 내부에서 `--type loop`가 아닌 **`--type direct`**로 에이전트 작업을 위임합니다.
|
||||||
|
- direct 경로(`:227`)는 `run_agent` 실행 후 `wait "$sub_pid" || true`로 구독자 실패를 삼켜버리고, 상태 전이를 전혀 거치지 않은 채 `exit 0`을 반환합니다.
|
||||||
|
- 따라서 이슈가 언급한 루프 분기뿐만 아니라 **오케스트레이터가 실제로 타는 direct 경로 역시 완전히 침묵(Fail-Silent)**하고 있었습니다.
|
||||||
|
3. **파급 효과 (65분 Hang)**:
|
||||||
|
- 상위 루프의 `wait_for_job`은 `completed`, `error`, `cancelled` 3개 터미널 상태만 종료 조건으로 인식합니다.
|
||||||
|
- 구독자가 120초 idle timeout(`sub_rc=2`)으로 죽었음에도 레지스트리가 `pending` 또는 `running`으로 방치되어 상위 루프가 기본값인 **3,900초(65분) 동안 블로킹**됩니다.
|
||||||
|
- 실제 운영 레지스트리 포렌식 결과 `93a44347`, `2961b822`, `ee242671` 3건의 실운영 작업이 정확히 이 결함으로 장시간 교착 상태에 방치되었던 사실이 실증되었습니다.
|
||||||
|
|
||||||
|
### 2.2 결함 ②: 에이전트 이벤트 발행과 TUI 복귀 간의 레이스 컨디션
|
||||||
|
- **판정**: **실재하는 설계 결함 (CONFIRMED REAL, P1)**
|
||||||
|
- **실측 및 재현 결과**:
|
||||||
|
1. **비동기 상태 불일치 레이스**:
|
||||||
|
- 에이전트 프로세스가 MQTT로 `completed` 이벤트를 발행한 직후에도 터미널 화면은 마크다운 렌더링, 버퍼 플러시, 툴 호출 정리 등으로 인해 여전히 `working/busy` 상태일 수 있습니다.
|
||||||
|
- 실측 결과 Codex 등 대형 모델의 경우 `completed` 발행 후 TUI가 `done`으로 떨어지는 데 최대 31초가 소요되기도 했습니다.
|
||||||
|
2. **프롬프트 묵살 및 유실**:
|
||||||
|
- 다음 단계(리뷰어 등)가 즉시 프롬프트를 전송하면 TUI가 입력을 무시하거나 첫 글자/단어가 잘린 채 주입되어 문맥이 깨진 채 실행되거나 무응답 상태에 빠집니다.
|
||||||
|
3. **두 결함 간의 악순환 증폭**:
|
||||||
|
- 결함 ②(TUI 미준비 프롬프트 유실) 발생 → 워커 무응답 → 구독자 120초 타임아웃(`sub_rc=2`) → 결함 ①(Fail-Silent) 발동 → 레지스트리 `pending/running` 방치 → 상위 루프 65분 영구 교착(Hang).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 수정 난이도 및 공수 산정 (Consensus)
|
||||||
|
|
||||||
|
| 영역 | 난이도 | 예상 공수 | 핵심 변경 대상 |
|
||||||
|
| :--- | :---: | :---: | :--- |
|
||||||
|
| **결함 ① (Fail-Closed CAS)** | **중하 (Low-Med)** | 1.0 ~ 1.5 MD | `skills/.../multi-agent-mux-delegate-job`, `lib_py/delegation/engine.py` |
|
||||||
|
| **결함 ② (TUI 턴 핸드셰이크)** | **중 (Medium)** | 1.0 ~ 1.5 MD | `.agents/skills/multi-agent-mux-create/scripts/lib.sh` (`send_keys_safe`), Herdr API 연동 |
|
||||||
|
| **통합 테스트 & 다중 에이전트 실측** | **중 (Medium)** | 1.0 ~ 1.5 MD | 4개 에이전트(Claude/Grok/Codex/OpenCode) 실기동 E2E 검증, Mock 단위 테스트 |
|
||||||
|
| **총계** | **중 (Medium)** | **3.0 ~ 4.5 Engineer-Days** | 안전하고 통제된 점진적 구현 가능 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 상세 기술 아키텍처 및 구현 사양 (Rev. 2 보강)
|
||||||
|
|
||||||
|
### 4.1 결함 ① 해결책: 레지스트리 Fail-Closed 및 CAS 엔진 연동
|
||||||
|
1. **표준 CAS 전이(`deadline_timeout`) 활용**:
|
||||||
|
- 임의의 신규 CLI 플래그(`--expect-status`)나 운영자 강제 오버라이드를 발명하지 않고, 이미 검증된 `lib_py/delegation/engine.py:50`의 `deadline_timeout` 전이를 호출합니다.
|
||||||
|
- `deadline_timeout`은 잡이 `pending` 또는 `running`일 때만 원자적으로 `error`로 전이하며, 구독자 타임아웃 직후 백엔드에서 늦게 도착한 `completed` 이벤트가 이미 커밋된 경우 `"conflicting error after terminal completed; refusing"`으로 안전하게 거부(No-op)합니다.
|
||||||
|
- `expected_dispatch_token`을 전달하여 이전 시도의 늦은 타임아웃이 재시도 잡을 오염시키는 ABA 레이스를 원천 차단합니다.
|
||||||
|
2. **`multi-agent-mux-delegate-job` 공통 종료 헬퍼 (`finalize_unfinished`) 도입**:
|
||||||
|
- direct 경로(`:227`) 및 loop/discuss 경로(`:358`, `:375`)에 공통 적용.
|
||||||
|
- 구독자 종료 시 `wait "$sub_pid"` 수집 및 미완료 잡에 대해 `finalize_unfinished`를 호출하여 상태를 Fail-Closed 처리.
|
||||||
|
- 래퍼 자체도 자식 구독자 프로세스를 안전하게 수거(`kill "$sub_pid" 2>/dev/null; wait "$sub_pid"`)하여 3,600초 고아 프로세스 방지.
|
||||||
|
3. **브로커 장애(`rc=3`)와 워커 실패 격리 (B-15 계약 준수)**:
|
||||||
|
- 구독자가 `rc=3`(`broker_unavailable`)으로 종료되었으나 디스크 상에 이벤트가 살아있을 가능성이 있는 경우, 즉시 실패로 단정하지 않고 잡 데드라인까지 레지스트리를 재확인하여 브로커 일시 순단에 의한 거짓 실패를 방지합니다.
|
||||||
|
4. **리테인 이벤트 오염 방지 (O-1)**:
|
||||||
|
- loop/discuss 모드 실패 시 MQTT에 `error` 이벤트를 retain 상태로 발행하지 않아, 재시도 시 이전 에러 이벤트가 즉시 재수신되는 현상을 방지합니다.
|
||||||
|
5. **오케스트레이터 러너 Fail-Fast 연결**:
|
||||||
|
- `lib_py/loop/runner.py:delegate_job`이 래퍼 실패(rc≠0) 시에도 `registered job:` 출력을 파싱하여 `wait_for_job`에 넘기도록 보장 (디스크가 이미 `error`이므로 1초 내 False 반환하여 65분 교착 원천 차단).
|
||||||
|
|
||||||
|
### 4.2 결함 ② 해결책: TUI 턴 핸드셰이크 시퀀스 및 예산 보강
|
||||||
|
1. **핸드셰이크 실행 순서 (구독자 시작 전 배치)**:
|
||||||
|
- **중요**: `wait_for_agent_idle` 핸드셰이크는 반드시 **구독자(`job_subscriber.py`) 기동 전**에 완료되어야 합니다.
|
||||||
|
- 구독자 실행 후에 대기할 경우, 에이전트의 유휴 대기 시간(15~30초)이 구독자의 120초 idle timeout 예산을 잠식(자가 포식)하는 결함이 발생합니다.
|
||||||
|
2. **유휴 대기 예산 확대 (15초 → 120초)**:
|
||||||
|
- Codex 등 복잡한 마크다운/도구 출력을 렌더링하는 대형 모델은 `completed` 이벤트 후 TUI가 완전히 `done`으로 복귀하는 데 실측 31초가 걸렸습니다.
|
||||||
|
- 따라서 15초 하드코딩 대신 기본 120초(`SKS_IDLE_TIMEOUT_MS=120000`)를 적용하고 환경변수로 유연하게 조절할 수 있도록 합니다.
|
||||||
|
3. **Herdr 네이티브 대기 API 우선 사용 및 Fallback 견고화**:
|
||||||
|
- `herdr agent wait <session> --until idle --until done --timeout "$SKS_IDLE_TIMEOUT_MS"`를 우선 호출.
|
||||||
|
- `lib_py.loop.watcher.wait_once` 메커니즘을 재사용하여 쉘 중복 구현을 방지.
|
||||||
|
- 헤드리스 환경 등 `agent_status`가 `unknown`인 환경에서는 기존의 `_pane_quiescent` 정밀 휴리스틱으로 우아하게 Fallback.
|
||||||
|
4. **수락 확인 및 이전 턴 잔여 토큰 오판 방지**:
|
||||||
|
- 키 전송 후 `herdr agent prompt --wait --until working` 수락 확인.
|
||||||
|
- Fallback 경로에서 이전 턴의 잔여 busy 토큰(`esc to interrupt` 등)이 키 전송 전 캡처 화면에 이미 존재했던 경우, 이를 새 입력의 작업 시작 근거로 오판하지 않도록 사전 스냅샷과 비교 검증.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 단계별 실행 계획 (5-Phase Roadmap)
|
||||||
|
|
||||||
|
- **Phase 1: 레지스트리 CAS 표준 연동 및 delegate-job Fail-Closed 구현**
|
||||||
|
- `engine.py` `deadline_timeout` 호출 경로 확립
|
||||||
|
- `multi-agent-mux-delegate-job` 내 `direct`(:227) 및 `loop/discuss` 종료 정리부 일원화
|
||||||
|
- **Phase 2: `send_keys_safe` 사전 유휴 핸드셰이크(Pre-Subscriber Idle Wait) 구현**
|
||||||
|
- `SKS_IDLE_TIMEOUT_MS` 기본 120초 지원
|
||||||
|
- `herdr agent wait` 1차 게이트 및 잔여 토큰 오판 방지
|
||||||
|
- **Phase 3: 오케스트레이터 러너 Fail-Fast 방어막 보강**
|
||||||
|
- `lib_py/loop/runner.py` 예외 및 비정상 종료 시 조기 수거 연결
|
||||||
|
- **Phase 4: 단위/계약 격리 회귀 테스트 슈트 구축**
|
||||||
|
- `tests/test_issue6_fail_closed.py`
|
||||||
|
- `tests/test_issue6_turn_handshake.py`
|
||||||
|
- 기존 750개 pytest 100% 무결성 검증
|
||||||
|
- **Phase 5: 4개 실기동 에이전트(Claude, Grok, Codex, OpenCode) 대상 E2E 검증**
|
||||||
|
- 타임아웃 주입 및 TUI 연속 프롬프트 주입 실측 통과 확인
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 결론
|
||||||
|
|
||||||
|
Issue #6은 코드베이스의 구조적 결함이라기보다, **비동기 이벤트 분산 환경에서 발생하는 상태 불일치(Event vs TUI)와 비정상 종료 시의 예외 처리 누락(Fail-Silent)**이 맞물려 발생한 전형적인 분산 시스템 레이스 컨디션입니다.
|
||||||
|
|
||||||
|
모든 리뷰어가 진단과 해결책에 완전히 합의하였고, 2차 라운드를 통해 엣지케이스까지 방어 사양이 완벽히 수립되었으므로 본 Rev.2 사양에 따라 즉시 구현에 착수할 수 있습니다.
|
||||||
@@ -200,7 +200,7 @@ class NewAgentAdapter(BaseAgentAdapter):
|
|||||||
| Agent Type | Target CLI / Tool | Session Storage Format | Auth Strategy | Ready Tokens Pattern | Complexity Tier | Estimated Effort |
|
| Agent Type | Target CLI / Tool | Session Storage Format | Auth Strategy | Ready Tokens Pattern | Complexity Tier | Estimated Effort |
|
||||||
|---|---|---|---|---|:---:|:---:|
|
|---|---|---|---|---|:---:|:---:|
|
||||||
| **OpenCode** | `opencode` (CLI / TUI) | JSON files in `~/.opencode/sessions/` | Local token / API key | `OpenCode\|Chat\|Welcome` | **Tier 1 (Low)** | ~0.5 day |
|
| **OpenCode** | `opencode` (CLI / TUI) | JSON files in `~/.opencode/sessions/` | Local token / API key | `OpenCode\|Chat\|Welcome` | **Tier 1 (Low)** | ~0.5 day |
|
||||||
| **Codex CLI** | `codex` / `openai-codex` | JSONL in `~/.codex/projects/` | API Key (`OPENAI_API_KEY`) | `OpenAI Codex\|Assistant\|❯` | **Tier 1 (Low)** | ~0.5 day |
|
| **Codex CLI** | `codex` | Rollouts in `~/.codex/sessions/` & SQLite `state_*.sqlite` | `auth.json` / `OPENAI_API_KEY` | `Codex\|Ask Codex to do anything` | **Implemented (v4.2.0)** | Done |
|
||||||
| **Kimi CLI** | `kimi` (Moonshot CLI) | SQLite DB in `~/.kimi/history.db` | API Key in `~/.kimi/config` | `Kimi\|Moonshot\|Chat` | **Tier 1 (Low)** | ~0.5 day |
|
| **Kimi CLI** | `kimi` (Moonshot CLI) | SQLite DB in `~/.kimi/history.db` | API Key in `~/.kimi/config` | `Kimi\|Moonshot\|Chat` | **Tier 1 (Low)** | ~0.5 day |
|
||||||
| **Grok-Build** | `grok` / `grok-build` | JSON in `~/.grok/sessions/` | Token file / Env var | `Grok\|xAI\|Building` | **Tier 1 (Low)** | ~0.5 day |
|
| **Grok-Build** | `grok` / `grok-build` | JSON in `~/.grok/sessions/` | Token file / Env var | `Grok\|xAI\|Building` | **Tier 1 (Low)** | ~0.5 day |
|
||||||
| **Cursor CLI** | `cursor` (Headless/RPC) | State DB in `~/.cursor/` or RPC port | OAuth / Cookie | `Cursor\|Connected\|Listening` | **Tier 2 (Medium)** | ~1.5 days |
|
| **Cursor CLI** | `cursor` (Headless/RPC) | State DB in `~/.cursor/` or RPC port | OAuth / Cookie | `Cursor\|Connected\|Listening` | **Tier 2 (Medium)** | ~1.5 days |
|
||||||
|
|||||||
@@ -0,0 +1,463 @@
|
|||||||
|
# 📑 Issue #5 Phase 1 구현 계획서 (Rev.2) — `lib_py/delegation/` 추출 및 장수 MQTT 연결 프로토타입
|
||||||
|
|
||||||
|
- **Job ID**: `9aae4233` (Rev.1: `923e9403`) / **Planner**: `planner-reviewer-claude-01`
|
||||||
|
- **반영한 이의제기**: `creator-claude-01`의 Challenge (Job `c0b9c987`) — D2 shim 부트스트랩의 컨텍스트 충돌
|
||||||
|
- **Issue**: [Gitea Issue #5](https://git.godopu.com/tmpl/multi-agent-mux/issues/5) — Phase 1 (`implementation_plan.md` Rev.4 §2 Phase 1 행, `task.md` P1-01~P1-03)
|
||||||
|
- **기준 커밋**: `fc0bd96` (branch `refactor`, working tree clean) / Python `3.9.6` / paho-mqtt `2.1.0` / herdr `0.8.2` / 전체 테스트 수집 **510**
|
||||||
|
- **문서 성격**: Worker가 그대로 실행할 수 있는 단계별 구현 명세. 모든 인용 행 번호는 라이브 코드로 직접 확인함(§1). Rev.1 대비 변경은 §0-1에 모아 두었고, 본문에는 이미 반영되어 있다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 요약 (TL;DR)
|
||||||
|
|
||||||
|
1. **추출 대상은 `lib.sh` L1833–1941의 위임 블록 5개 함수뿐이다.** L1943 이후(`wait_for_tui_ready`, `send_keys_safe` 등)는 TUI 조작 헬퍼이며 Phase 1 비범위다.
|
||||||
|
2. **`scripts/{mqtt_common,registry,publish_event,job_subscriber}.py`의 파일 경로는 와이어 계약이다.** 에이전트에게 주입되는 지시문(`create_session.sh:483`, 래퍼 `:197/:318`), SKILL.md, BOOTSTRAP*.md, `deploy/install.sh:103`, 테스트 3종이 리터럴 경로로 호출한다. 구현체는 `lib_py/delegation/`으로 옮기되 `scripts/*.py`는 **동일 모듈 객체를 가리키는 호환 shim**으로 남긴다.
|
||||||
|
3. **shim은 `sys.path`를 건드리지 않고 `lib_py.delegation` 패키지만 자기 옆 트리에 "프로세스당 1회" 바인딩한다(Rev.2, D2).** 이의제기가 실증한 대로 `sys.path.insert(0, ...)` 방식은 pytest에서 위임과 무관한 `lib_py.*`까지 임시 사본으로 가로챈다. 이의제기가 제안한 "잎(leaf) 모듈 파일 직접 적재"도 재현 결과 비-잎 모듈(`publish_event`)에서 두 트리가 섞이므로 채택하지 않고, **패키지 단위 bind-once**로 확정했다(§1-8 근거).
|
||||||
|
4. **현재 셸 헬퍼 3종을 직접 검증하는 테스트는 0개다.** 이동 전에 특성화(golden) 테스트를 먼저 추가한다(P1-00).
|
||||||
|
5. **장수 연결 프로토타입은 라이브러리 전용이다.** CLI는 프로세스 단위로 종료되므로 재사용 이득이 없다. 벤치마크는 결정적 가짜 전송 + 실브로커(정보성) 2모드.
|
||||||
|
6. **자동완료 정책은 3개 표면에서 한 줄도 바뀌지 않는다.** 테스트로 고정한다.
|
||||||
|
|
||||||
|
### 0-1. Rev.1 → Rev.2 변경 내역 (이의제기 반영)
|
||||||
|
|
||||||
|
| 이의제기 항목 | Planner 독립 검증 결과 | 계획 변경 |
|
||||||
|
|---|---|---|
|
||||||
|
| §1 D2 `sys.path.insert(0)`이 pytest에서 `lib_py` 전체를 샌드박스로 고정 | **재현 확인** (§1-8 표, 변형 `d2_insert0` 시나리오 A: `lib_py.agents`가 sandbox에서 로드) | D2 전면 교체 |
|
||||||
|
| §2-1 정체성 단정(`is`)이 "같은 트리"를 증명하지 못함 | **동의**. 시나리오 A에서 두 참조가 모두 sandbox인데 단정은 `True` | D2-2 출처(provenance) 단정 PV-1~PV-4 추가, G4 재정의 |
|
||||||
|
| §2-2 R3 전제("실제 트리에서만 로드")가 D2와 모순 | **동의** | R3을 "수용"에서 "해소"로 재작성 |
|
||||||
|
| §3 `append`로 바꾸면 freeze가 깨짐 | **동의**. 본 세션 `PYTHONPATH`=라이브 트리, 실행 명령=freeze 경로, `run_loop.sh`에 `PYTHONPATH` 처리 0건 모두 재확인 | D2-1 컨텍스트 우선순위 표 신설 |
|
||||||
|
| §5-1 "옆 파일 직접 적재(`spec_from_file_location`) + 다르면 교체" | **부분 채택**. 잎 모듈 단독 임포트에서는 동작하나, `publish_event`만 임포트되는 실제 에이전트 호출 형태(C2_pe)에서 `publish_event`=freeze, `mqtt_common`=live로 **혼재**하고 정체성 단정이 `False`가 됨. 진짜 standalone(C1)에서는 부모 패키지 부재로 `ModuleNotFoundError`. 두 샌드박스가 섞이는 시나리오(D)에서도 혼재 | 적재 단위를 **모듈 → 패키지**로 올리고, "가장 가까운 사본으로 교체"가 아니라 **"프로세스당 최초 바인딩 유지(bind-once)"** 로 규칙 변경. standalone은 `lib_py` 부재 시에만 `sys.path.append` 폴백 |
|
||||||
|
| §5-2 출처 단정 병기 | 채택 | PV-1~PV-4 |
|
||||||
|
| §5-3 컨텍스트 4종 우선순위 표 + 4종 전부 테스트 | 채택 (6 컨텍스트로 확장) | D2-1, P1-01 테스트 |
|
||||||
|
| §5-4 세션 위생 테스트 | **부분 채택**. "tmp 경로가 `sys.path`에 없음"은 현행 `test_tier1_unit.py:23`이 샌드박스 scripts 디렉터리를 영구 삽입하므로 이미 실패함. 조건을 "`lib_py` 디렉터리를 포함하는 tmp 경로가 없음"으로 정밀화 | PV-3 |
|
||||||
|
| §6 D6·설치 경로 안전 확인 | **동의**. `mqtt_common.py`의 `os.environ` 참조는 전부 함수 내부(`:139,:196,:203,:225-233`), 모듈 상수(`:117-128`) env 무관, 나머지 3개 스크립트 `os.environ` 0건, `install.sh:127/131/135` 트리 통째 복사, `install_mam.sh:126` `.agents/` rsync 재확인 | 변경 없음 (P1-04의 `core_files` 추가는 무결성 목록 갱신 목적임을 명시) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Planner 사전 실측 (라이브 검증 결과)
|
||||||
|
|
||||||
|
### 1-1. 추출 범위 경계 (`lib.sh`, 총 2312행)
|
||||||
|
|
||||||
|
| 행 | 함수 | 분류 | 비고 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1833 | 섹션 주석 `multi-agent-mux-delegate-job integration helpers` | 경계 시작 | |
|
||||||
|
| 1841 | `_delegate_py_bin` | **셸 유지** | 파이썬 인터프리터 자체를 찾는 부트스트랩. L1598(`env_python`), L1660(`atomic_dump_yaml`), `reconcile.sh:67`, `create_session.sh:191/193/215/482`, `stop_session.sh:199`, `resume_session.sh:116/126`에서도 사용. `tests/test_c1_tui_readiness.py:168,513`이 mock으로 덮어씀 |
|
||||||
|
| 1865 | `_delegate_script` | **셸 유지** | 스크립트 경로 해석. shim이 남으므로 그대로 동작 |
|
||||||
|
| 1876 | `delegate_submit_job` | 추출 | `registry.py register` 호출, stdout에 JID만 출력 |
|
||||||
|
| 1897 | `delegate_publish_event` | 추출 | 비치명 계약: 빈 job_id/스크립트 부재/브로커 실패 모두 `return 0` |
|
||||||
|
| 1912 | `start_watchdog` | 추출 → `ensure_subscriber_daemon` | `pgrep -f "bash <monitor_script> --subscribe"` 선검사 후 `nohup bash reconcile.sh --subscribe --idle-timeout 0` 스폰, PID를 stdout 출력. 스크립트 부재 시 stderr `ERROR: monitor script not found: ...` + `return 1` |
|
||||||
|
| 1943~ | `wait_for_tui_ready`, `inject_instructions`, `_pane_*`, `send_keys_safe`, `handle_startup_dialogs` | **비범위** | TUI 조작 |
|
||||||
|
|
||||||
|
`lib_py`는 `lib.sh:25`(`SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"` 직후 `export PYTHONPATH="$SKILL_DIR:..."`) 및 `:164`로 임포트 가능해진다. 즉 **어느 트리의 `lib.sh`를 source 했느냐가 그 셸 프로세스의 `lib_py` 트리를 결정**한다. `env_python`(L1583)은 `PYTHONPATH=$SKILL_DIR`를 명시 전달하는 선례.
|
||||||
|
|
||||||
|
### 1-2. 셸 헬퍼 호출자 (라이브 트리)
|
||||||
|
|
||||||
|
| 호출자 | 헬퍼 | 계약상 의존 |
|
||||||
|
|---|---|---|
|
||||||
|
| `create_session.sh:345` | `delegate_submit_job` | `$( )`로 stdout 캡처 → JID |
|
||||||
|
| `create_session.sh:497,501` | `delegate_publish_event error/started` | 비치명 |
|
||||||
|
| `create_session.sh:504-505` | `start_watchdog "$JOB" "$WORKSPACE"` | `$( )`로 PID 캡처 후 `echo`만 함(파싱 없음) |
|
||||||
|
| `stop_session.sh:193,242,322` | `delegate_publish_event progress/cancelled` | `implementation_plan.md` §3-2가 보존 요구 |
|
||||||
|
| `update_yaml_resumed.sh:257` | `delegate_publish_event progress "resumed"` | 비치명 |
|
||||||
|
| `run_loop.sh:144-155` | (헬퍼 미사용) 래퍼 `multi-agent-mux-delegate-job`를 직접 실행 | Phase 1 무관 |
|
||||||
|
|
||||||
|
### 1-3. 파이썬 표면과 리터럴 경로 계약
|
||||||
|
|
||||||
|
- 스크립트 4종: `mqtt_common.py`(670행), `registry.py`(426행), `publish_event.py`(239행), `job_subscriber.py`(339행). 서로를 `import mqtt_common` / `import registry`처럼 **최상위 이름**으로 임포트한다. **다른 `lib_py` 모듈은 하나도 임포트하지 않는다**(`grep -n lib_py scripts/*.py` → 0건; D2의 전제).
|
||||||
|
- 리터럴 경로 호출자: 래퍼 12개소(`:136,166,197,277,287,318,355,394,400,544,550,571,575,583,586`), `create_session.sh:483`, `SKILL.md:78`, `BOOTSTRAP.md`/`BOOTSTRAP.ko.md:158-180`, `README.md:211`/`README.ko.md:194`, `MESSAGING.md:399`, `deploy/install.sh:103`, `reconcile.sh:98-113`(임베디드 구독자가 scripts 디렉터리를 `sys.path`에 **append** 후 `import mqtt_common, registry`).
|
||||||
|
- 테스트 임포트 방식: `tests/test_tier1_unit.py:23,571-820`과 `tests/test_tier2_component.py:25`는 샌드박스 scripts 디렉터리를 `sys.path`에 넣고 `import registry, publish_event, job_subscriber, mqtt_common`; `tests/test_tier4_e2e.py:414`는 `publish_event.py`를 서브프로세스로 실행. `tests/conftest.py:10-12`는 **실제** skills 디렉터리를 `sys.path[0]`에 넣고, `mam_sandbox`는 `.agents/skills` 전체를 `tmp_path/skills`와 `tmp_path/.agents/skills`로 복사한다(이동 후 사본에도 `lib_py/delegation/`이 포함됨).
|
||||||
|
- 본 job과 Rev.1 job의 위임 메시지 자체가 `.../mam-loop-freeze.PfAQNj/.agents/skills/multi-agent-mux-delegate-job/scripts/publish_event.py` 경로를 사용한다. 이때 에이전트 세션의 `PYTHONPATH`는 **라이브 트리**다(실측: `/Users/.../multi-agent-mux/.agents/skills:...`). `run_loop.sh`에는 `PYTHONPATH` 처리가 없고(`grep` 0건) `:14-17`에서 자기 위치 기준 `REPO_ROOT`의 `lib.sh`를 source 하므로 **루프 프로세스 자체는 freeze `lib.sh`가 export한 freeze `PYTHONPATH`** 를 갖는다.
|
||||||
|
|
||||||
|
### 1-4. 기존 테스트 커버리지 실측
|
||||||
|
|
||||||
|
| 대상 | 커버 테스트 | 비고 |
|
||||||
|
|---|---|---|
|
||||||
|
| `publish_event` 실패 시 status/감사로그/seq 소비 | `test_tier1_unit.py` G-1~G-4 (:568-698) | 127.0.0.1:65432 비라우팅 포트, 네트워크 미접촉 |
|
||||||
|
| `job_subscriber` 디스크 폴백/exit 0·1·3 | G-5~G-9 (:700-836) | |
|
||||||
|
| 래퍼 rc=3 매핑 리터럴 | G-10 (:838) | **래퍼 텍스트를 건드리면 깨짐** |
|
||||||
|
| HMAC/retry/topic/reason_code | `test_mqtt_*` (:339-420) | |
|
||||||
|
| seq 단조 증가, 모니터 중복 잠금 | `test_tier2_component.py:1082, :1007` | |
|
||||||
|
| `delegate_submit_job` / `delegate_publish_event` / `start_watchdog` | **없음 (0건)** | |
|
||||||
|
|
||||||
|
빠른 회귀 부분집합(G-1~G-10 + mqtt + seq + monitor lock): **18 passed in 1.74s**. 이 부분집합의 파일들은 `lib_py`를 모듈 레벨에서 임포트하지 않으므로(`test_tier1_unit.py`의 `lib_py` 사용은 `:848,:873,:902,:947,:978,:1037,:1072` 전부 함수 내부) 이의제기 §4의 지적대로 "샌드박스 shim이 먼저 임포트되는" 시나리오 A에 해당한다 — Rev.2 D2는 이 경우에도 `lib_py.agents` 등을 가로채지 않는다(§1-8).
|
||||||
|
|
||||||
|
### 1-5. 자동완료 정책이 구현된 3개 표면 (Phase 1 불변)
|
||||||
|
|
||||||
|
1. `publish_event.py:51-56` `EVENT_TO_STATUS` 및 `:227`의 `update_job_status` 동기화.
|
||||||
|
2. 래퍼 `:335-348` 구독자 exit code → `job_status` 매핑 및 `:394/:400`의 `registry.py status --set completed|error`.
|
||||||
|
3. `reconcile.sh:125-168` `_MUTATION` + `handle_terminal`.
|
||||||
|
|
||||||
|
`run_loop.sh:238-276` `wait_for_job`은 `.mam/jobs/<id>.json`의 `status`를 3초 간격 폴링할 뿐이므로 영향 없음.
|
||||||
|
|
||||||
|
### 1-6. 장수 연결의 실측 동기
|
||||||
|
|
||||||
|
Rev.1 job의 `started` 이벤트: payload `timestamp` 05:58:49Z → `attempt 1/3 failed: no CONNACK from broker` (05:59:00Z) → 모니터 수신 05:59:01.487Z. 1회 발행 약 12.5초, 원인은 `CONNECT_ACK_TIMEOUT=10`(`publish_event.py:58`)의 1회 실패 + 재연결. Rev.1 `completed` 발행과 본 job의 `started` 발행에서도 동일한 1회 CONNACK 실패가 재현됐다.
|
||||||
|
|
||||||
|
### 1-7. 환경 제약
|
||||||
|
|
||||||
|
- 로컬 브로커 없음(`mosquitto`, `nats-server`, `docker` 미설치). 벤치마크 실브로커 모드는 정보성.
|
||||||
|
- `mqtt_common.py:112`에서 `_load_dotenv()`가 **임포트 시점**에 실행되어 `.mam.env`(존재, 0600)를 주입. 루트 탐색은 `__file__` 기준 상향(`.agents` 또는 `.git`).
|
||||||
|
- B-13 freeze(`run_loop.sh:112-126`)는 `.agents/skills` 전체를 `cp -R` 하므로 `lib_py/delegation/`은 자동 포함(현재 freeze에 `lib_py/` 존재 확인).
|
||||||
|
- `baseline.md` §5-2 `VALID_STATUSES` 인용은 커밋 `19d798b`에서 `registry.py:38`로 정정 완료(`baseline.md:93`).
|
||||||
|
|
||||||
|
### 1-8. 이의제기 재현 및 대안 비교 (Rev.2 신규)
|
||||||
|
|
||||||
|
세 shim 변형을 `real`/`sandbox`/`sandbox2`/`freeze` 네 트리(각 `lib_py/{__init__,agents/__init__,delegation/{__init__,mqtt_common,publish_event}}.py` + `scripts/` shim)에 배치하고, `env -u PYTHONPATH`의 새 인터프리터에서 시나리오별로 실행했다. `publish_event`는 `from lib_py.delegation import mqtt_common`을 포함해 **비-잎 모듈**을 모델링한다. 열: 어느 트리에서 로드됐는가.
|
||||||
|
|
||||||
|
| 시나리오 (첫 임포트 → 이후) | `d2_insert0` (Rev.1) | `worker_leaf_file` (이의제기 §5-1) | `pkg_bind_once` (**Rev.2 채택**) |
|
||||||
|
|---|---|---|---|
|
||||||
|
| A pytest: real on path → sandbox shim(mc, pe) | mc=sb, pe=sb, **agents=sb** ✗ | mc=sb, pe=sb, agents=real | mc=sb, pe=sb, agents=real ✓ |
|
||||||
|
| A_pe pytest: sandbox shim(**pe만**) | agents=sb ✗ | **pe=sb, pe.dep=real, identity=False** ✗ | pe=sb, pe.dep=sb, identity=True ✓ |
|
||||||
|
| B pytest: real `lib_py.agents` 먼저 → sandbox shim | 전부 real | mc=sb, pe=sb, agents=real | mc=sb, pe=sb, agents=real ✓ |
|
||||||
|
| B2 pytest: real `lib_py.delegation` 먼저 → sandbox shim | (미측정) | **alias=sb, pkg=real, identity=False** ✗ | 전부 real, identity=True ✓ |
|
||||||
|
| C1 standalone(`PYTHONPATH` 없음): freeze shim | 전부 freeze | **`ModuleNotFoundError: lib_py`** ✗ (비-잎 모듈의 내부 임포트가 부모 패키지를 요구) | 전부 freeze ✓ (폴백 append) |
|
||||||
|
| C2 freeze shim + `PYTHONPATH`=live (mc, pe) | 전부 freeze | 전부 freeze, agents=real | 전부 freeze, agents=real ✓ |
|
||||||
|
| C2_pe freeze shim(**pe만**) + `PYTHONPATH`=live — **실제 에이전트 호출 형태** | 전부 freeze | **pe=freeze, pe.dep=real, identity=False** ✗ (B-13 부분 상실) | pe=freeze, pe.dep=freeze ✓ |
|
||||||
|
| D 두 샌드박스: sb shim(mc) → sb2 shim(pe) | agents=sb ✗ | **pe=sb2, pe.dep=sb** 혼재 ✗ | 전부 sb (최초 바인딩 유지) ✓ |
|
||||||
|
| E sandbox shim(mc) → 이후 `from lib_py.delegation import publish_event` 직접 임포트 | (해당 없음) | 직접 임포트=real, shim=sb 혼재 | 둘 다 sb ✓ |
|
||||||
|
|
||||||
|
결론: 이의제기의 **결함 지적은 정확**하고, 제안한 적재 방식은 "잎 모듈을 먼저 임포트하는" 경우에만 성립한다. 결정 기준은 (i) `lib_py.delegation.*`가 한 프로세스 안에서 항상 **단일 트리**일 것, (ii) `lib_py`의 다른 서브패키지는 shim이 **절대 건드리지 않을 것**, (iii) `sys.path` 무변경(standalone 폴백 제외) — 이를 모두 만족하는 것은 패키지 단위 bind-once뿐이다. 재현 하니스는 `tests/fixtures/issue_5/phase1/shim_contexts.py`로 그대로 옮겨 P1-01 테스트에 사용한다(부록 B).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 설계 결정 (Design Decisions)
|
||||||
|
|
||||||
|
### D1. 패키지 배치 — 기존 이름 보존, 신규 추가
|
||||||
|
|
||||||
|
```
|
||||||
|
.agents/skills/lib_py/delegation/
|
||||||
|
__init__.py # 공개 API 재수출: DelegationService, ensure_subscriber_daemon, PersistentPublisher
|
||||||
|
mqtt_common.py # git mv (scripts/mqtt_common.py) — 내용 변경은 D6·D8 항목만
|
||||||
|
registry.py # git mv (scripts/registry.py)
|
||||||
|
publish_event.py # git mv (scripts/publish_event.py) — D7의 함수 분리만
|
||||||
|
job_subscriber.py # git mv (scripts/job_subscriber.py)
|
||||||
|
service.py # DelegationService 파사드 (신규)
|
||||||
|
daemon.py # ensure_subscriber_daemon (신규, start_watchdog 이식)
|
||||||
|
publisher.py # OneShotPublisher / PersistentPublisher (신규)
|
||||||
|
cli.py # python -m lib_py.delegation.cli {submit|publish|ensure-daemon} (신규)
|
||||||
|
__main__.py # cli.main 위임 (신규)
|
||||||
|
bench.py # 벤치마크 하니스 (신규)
|
||||||
|
```
|
||||||
|
|
||||||
|
- `git mv`로 이동해 이력을 보존한다. 이동한 4개 모듈 내부의 `import mqtt_common` / `import registry` / `from mqtt_common import ...`는 전부 `from lib_py.delegation import mqtt_common, registry` 형태로 바꾼다.
|
||||||
|
- **패키지 내부 임포트 규칙(테스트로 grep 고정)**: (a) 최상위 이름(`import registry` 등) 금지 — `lib_py.agents.registry`와의 혼동 차단; (b) **`lib_py.delegation` 밖의 어떤 `lib_py` 모듈도 임포트 금지**(`paths`, `state`, `atomic_yaml`, `agents`, `layout`, `workspace_uuid`) — D2에서 부모 `lib_py`와 `lib_py.delegation`이 서로 다른 트리일 수 있으므로(C2), 형제 모듈을 임포트하면 트리가 섞인다. 현재 스크립트는 이미 이 규칙을 만족한다(§1-3).
|
||||||
|
- Python 3.9 호환 필수: `match`, PEP 604 런타임 유니온, `zip(strict=)` 금지. 게이트에서 `compileall`.
|
||||||
|
|
||||||
|
### D2. `scripts/*.py` shim — `lib_py.delegation` 패키지를 자기 옆 트리에 프로세스당 1회 바인딩 (Rev.2)
|
||||||
|
|
||||||
|
4개 shim은 아래 블록을 **바이트 동일하게** 포함한다(`{mod}`만 다름). 공유 헬퍼 파일로 빼지 않는 이유: `import _bootstrap` 같은 최상위 이름이 첫 샌드박스에 캐시되어 다른 트리의 shim이 남의 헬퍼를 쓰게 되기 때문이다. 동일성은 테스트로 고정한다.
|
||||||
|
|
||||||
|
```python
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Compatibility shim — implementation lives in lib_py.delegation.{mod}.
|
||||||
|
Binds the lib_py.delegation *package* to the tree next to this file, once per
|
||||||
|
process, without touching sys.path. See docs/contracts/issue-5/... (D2)."""
|
||||||
|
import importlib, importlib.util, os, sys
|
||||||
|
_SKILLS = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||||
|
_PKG = "lib_py.delegation"
|
||||||
|
if _PKG not in sys.modules: # bind once per process; never rebind
|
||||||
|
try:
|
||||||
|
import lib_py # parent resolved normally; shims never rebind it
|
||||||
|
except ModuleNotFoundError:
|
||||||
|
sys.path.append(_SKILLS) # standalone only: no lib_py exists anywhere to shadow
|
||||||
|
import lib_py
|
||||||
|
_d = os.path.join(_SKILLS, "lib_py", "delegation")
|
||||||
|
_spec = importlib.util.spec_from_file_location(
|
||||||
|
_PKG, os.path.join(_d, "__init__.py"), submodule_search_locations=[_d])
|
||||||
|
_pkg = importlib.util.module_from_spec(_spec)
|
||||||
|
sys.modules[_PKG] = _pkg # register BEFORE exec so __init__'s own imports resolve here
|
||||||
|
_spec.loader.exec_module(_pkg)
|
||||||
|
lib_py.delegation = _pkg
|
||||||
|
_impl = importlib.import_module(_PKG + ".{mod}") # resolved through the bound package's __path__
|
||||||
|
if __name__ != "__main__":
|
||||||
|
sys.modules[__name__] = _impl # `import {mod}` is lib_py.delegation.{mod}
|
||||||
|
else:
|
||||||
|
sys.exit(_impl.main())
|
||||||
|
```
|
||||||
|
|
||||||
|
규칙(문서화·테스트 대상):
|
||||||
|
1. **바인딩 단위는 패키지**다. `lib_py.delegation`이 `sys.modules`에 없을 때만 shim 옆 트리의 `lib_py/delegation/`을 패키지로 등록하고, 이후 모든 `lib_py.delegation.*` 임포트는 그 `__path__` 안에서 해석된다. 잎/비-잎 구분 없이 단일 트리가 보장된다.
|
||||||
|
2. **bind-once, never rebind.** 이미 바인딩돼 있으면(다른 트리라도) 그대로 쓴다. "가장 가까운 사본으로 교체"는 기존 참조(`sys.modules['mqtt_common']` 별칭 등)와 새 객체를 갈라놓아 정체성·monkeypatch를 깨뜨린다(§1-8 B2·D). 네 컨텍스트 중 한 프로세스가 트리를 바꿔야 하는 경우는 없다.
|
||||||
|
3. **부모 `lib_py`는 절대 재바인딩하지 않고, `sys.path`도 건드리지 않는다.** 유일한 예외는 `lib_py`가 어디에도 없는 standalone(C1)이며, 이때만 자기 트리를 `append` 한다(가릴 대상이 없으므로 안전).
|
||||||
|
4. 결과적으로 pytest에서는 위임 패키지만 "최초 shim의 샌드박스 사본(또는 먼저 직접 임포트된 실제 트리)"에 바인딩되고 `lib_py.agents`/`layout`/`state` 등은 항상 실제 트리다. 샌드박스 사본은 `copytree`된 동일 내용이므로 검증 대상 코드는 같다(PV-1이 이를 단정).
|
||||||
|
5. `mqtt_common.py`의 PEP 562 `__getattr__("LOGS_DIR")`은 실제 모듈에 남아 별칭을 통해 노출. 실행 비트 유지, `--help` 출력 동일.
|
||||||
|
6. `_load_dotenv`(D6 이후 `ensure_env_loaded`)의 루트 탐색은 실모듈 `__file__` 기준이므로 바인딩된 트리의 루트(저장소 루트 또는 freeze 루트)가 된다 — 이동 전과 동일.
|
||||||
|
|
||||||
|
### D2-1. 실행 컨텍스트별 "어느 트리가 이겨야 하는가" (Rev.2)
|
||||||
|
|
||||||
|
| # | 컨텍스트 | `lib_py.delegation` 해석 주체 | 이겨야 하는 트리 | Rev.2 결과 | 테스트 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| K1 | 루프 프로세스(`run_loop.sh`가 freeze `lib.sh` source → `PYTHONPATH`=freeze) 및 그 자식(래퍼, `-m lib_py.delegation.cli`) | `PYTHONPATH` | freeze | freeze | K1: `PYTHONPATH=<copy>` + `-m lib_py.delegation.cli --help` |
|
||||||
|
| K2 | 에이전트 세션(`PYTHONPATH`=live)이 위임 메시지의 **freeze 경로 스크립트**를 실행 | shim 옆 트리 | freeze (B-13) | freeze, `lib_py` 부모는 live(무해: D1(b)) | C2_pe |
|
||||||
|
| K3 | 라이브 헬퍼(create/stop/resume/reconcile; live `lib.sh` source) | `PYTHONPATH`=live 또는 shim 옆 트리(같음) | live | live | K3: 실제 트리에서 shim 실행 |
|
||||||
|
| K4 | standalone(`PYTHONPATH` 없음; BOOTSTRAP.md의 수동 명령, 외부 셸) | shim 폴백 append | 자기 트리 | 자기 트리 | C1, C1_pe |
|
||||||
|
| K5 | pytest 인프로세스(conftest가 실제 트리 삽입, 샌드박스 shim 임포트) | 최초 shim 또는 최초 직접 임포트 | 위임: 최초 바인딩(사본=동일 내용) / **나머지 `lib_py.*`: 실제 트리** | A, A_pe, B, B2, D, E | PV-1~PV-3 |
|
||||||
|
| K6 | pytest 서브프로세스(e2e `:414`, freeze 시뮬레이션) — 새 인터프리터, `PYTHONPATH`=live 상속 가능 | shim 옆 트리 | 샌드박스 | 샌드박스 | K6: 서브프로세스 JSON 출력 단정 |
|
||||||
|
|
||||||
|
### D2-2. 가드 — 정체성만으로는 불충분, 출처(provenance)를 병기 (Rev.2)
|
||||||
|
|
||||||
|
- **PV-1 (인프로세스, 각 shim 임포트 직후)**: `mqtt_common is lib_py.delegation.mqtt_common` **그리고** `Path(lib_py.delegation.__path__[0]).resolve()`가 `{실제 skills 디렉터리, pytest basetemp 하위}` 중 하나이며, 4개 별칭(`mqtt_common/registry/publish_event/job_subscriber`)의 `__file__`이 **모두 같은** `__path__[0]` 아래.
|
||||||
|
- **PV-2 (세션 종료 시, conftest의 session-scope autouse finalizer)**: `lib_py.__file__`, 그리고 `sys.modules`에 있는 모든 `lib_py.<x>` 중 `x != "delegation"`의 `__file__`이 실제 skills 디렉터리 아래. 위반 시 어느 모듈이 어느 tmp 경로에서 왔는지 출력.
|
||||||
|
- **PV-3 (세션 종료 시 위생)**: `sys.path` 항목 중 basetemp 하위인 것 가운데 `<entry>/lib_py`가 디렉터리인 항목 0개. (scripts 디렉터리 삽입은 `lib_py`를 담지 않으므로 허용 — 현행 `test_tier1_unit.py:23`과 호환.)
|
||||||
|
- **PV-4 (서브프로세스 매트릭스)**: §1-8의 시나리오 A, A_pe, B, B2, C1, C1_pe, C2, C2_pe, D, E를 실제 이동된 트리의 사본으로 재실행하고 JSON으로 트리명을 받아 단정. K1·K3·K6도 같은 하니스에 포함.
|
||||||
|
- **PV-5**: 4개 shim의 부트스트랩 블록이 `{mod}` 치환을 제외하고 바이트 동일.
|
||||||
|
|
||||||
|
### D3. 셸에 남는 것과 파이썬으로 가는 것
|
||||||
|
|
||||||
|
- `_delegate_py_bin`, `_delegate_script`: **셸 유지**. 시그니처·출력 불변.
|
||||||
|
- `delegate_submit_job`, `delegate_publish_event`, `ensure_subscriber_daemon`(신규 이름) + `start_watchdog`(별칭): 본체를 `python -m lib_py.delegation.cli ...` 호출로 교체. `PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}"`를 명시 전달(`env_python` L1583과 동일). `-m` 경로는 shim을 거치지 않으므로 D2-1의 K1/K3처럼 `SKILL_DIR` 트리가 결정한다.
|
||||||
|
- 롤백 스위치 `MAM_DELEGATION_PY`(기본 `1`): `0`이면 기존 인라인 본체(`_*_legacy`)로 분기. 특성화 테스트를 두 경로에 대해 parametrize. legacy 본체 제거는 Phase 4.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
start_watchdog() { ensure_subscriber_daemon "$@"; } # 기존 함수 별칭 보존
|
||||||
|
ensure_subscriber_daemon() {
|
||||||
|
if [ "${MAM_DELEGATION_PY:-1}" = "0" ]; then _start_watchdog_legacy "$@"; return $?; fi
|
||||||
|
PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" "$(_delegate_py_bin)" -m lib_py.delegation.cli \
|
||||||
|
ensure-daemon --job "$1" --workdir "${2:-$PWD}"
|
||||||
|
}
|
||||||
|
delegate_publish_event() {
|
||||||
|
local job_id="$1" event="$2" detail="${3:-}"
|
||||||
|
[ -n "$job_id" ] || return 0 # 비치명 계약 1
|
||||||
|
if [ "${MAM_DELEGATION_PY:-1}" = "0" ]; then _delegate_publish_event_legacy "$@"; return $?; fi
|
||||||
|
PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" "$(_delegate_py_bin)" -m lib_py.delegation.cli \
|
||||||
|
publish --job "$job_id" --event "$event" --detail "$detail" || true # 비치명 계약 2·3
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### D4. `cli.py`는 라우터, 로직은 기존 `main()` 재사용
|
||||||
|
|
||||||
|
- `publish` → `publish_event.main(argv)` 그대로 호출(exit 0/1/2, stdout 무출력, 옵션 전부 통과).
|
||||||
|
- `submit` → `registry.main(["--registry-dir", ..., "register", ...])`. stdout에 JID 한 줄, 로그는 stderr.
|
||||||
|
- `ensure-daemon` → `daemon.ensure_subscriber_daemon(job_id, workdir)`; stdout PID, exit 0 / 부재 시 stderr 동일 문구 + exit 1.
|
||||||
|
- 래퍼 `multi-agent-mux-delegate-job`(bash)은 **Phase 1에서 수정하지 않는다**(G-10 보호).
|
||||||
|
|
||||||
|
### D5. `daemon.ensure_subscriber_daemon(job_id, workdir) -> int` 의미 보존
|
||||||
|
|
||||||
|
| 항목 | 레거시(`lib.sh:1912-1941`) | 파이썬 이식 |
|
||||||
|
|---|---|---|
|
||||||
|
| 모니터 스크립트 경로 | `<workdir>/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh` | 동일 |
|
||||||
|
| 부재 시 | stderr `ERROR: monitor script not found: <path>`, rc 1 | 동일 문구·rc |
|
||||||
|
| 중복 검사 | `pgrep -f "bash <path> --subscribe"` | `subprocess.run(["pgrep","-f",...])` 동일 패턴 |
|
||||||
|
| 기존 PID 출력 | `echo "$pid"` (다중 매치면 여러 줄) | 동일하게 전량 출력(정리는 Phase 4 후보) |
|
||||||
|
| 스폰 | `nohup bash <path> --subscribe --idle-timeout 0 </dev/null >> <workdir>/.mam/multi-agent-mux-monitor.log 2>&1 &`, cwd=workdir | `Popen([...], cwd=workdir, stdin=DEVNULL, stdout=log(append), stderr=STDOUT, start_new_session=True, preexec_fn=SIGHUP 무시)` |
|
||||||
|
| 반환 | 스폰된 PID | 동일 |
|
||||||
|
| 데몬 자체 중복 방지 | `reconcile.sh:86-95` `.mam/monitor.lock` flock | 변경 없음 |
|
||||||
|
|
||||||
|
### D6. 임포트 시점 부작용 제거 — `_load_dotenv()`를 진입점 시점으로
|
||||||
|
|
||||||
|
`mqtt_common.py:112`의 모듈 레벨 `_load_dotenv()` 호출을 `ensure_env_loaded()`(멱등, 1회)로 바꾸고 **모든 프로세스 진입점** 첫 줄에서 호출: `publish_event.main`, `job_subscriber.main`, `registry.main`, `cli.main`, `bench.main`, `reconcile.sh` 임베디드 구독자(`import mqtt_common` 직후 1줄). 이의제기 §6이 독립 검증한 대로 `mqtt_common.py`의 env 참조는 전부 함수 내부이고 모듈 상수는 env 무관하므로 값이 달라지는 상수는 없다. `conftest.mam_sandbox`에 `monkeypatch.setenv("MAM_ENV_FILE", str(tmp_path / ".mam.env.absent"))` 추가(`_load_dotenv:48-52`는 명시 파일 부재 시 무로딩).
|
||||||
|
|
||||||
|
### D7. 발행 경로 분리 — 전송(transport)만 교체 가능하게
|
||||||
|
|
||||||
|
`publish_event.main()`(`:133-235`)을 동작 불변으로 세 조각으로 나눈다.
|
||||||
|
|
||||||
|
```python
|
||||||
|
def prepare_event(job_id, event, detail, data, registry_dir) -> PreparedEvent # load_job, broker cfg, topic, next_seq(소비), build_payload, retain 결정
|
||||||
|
def record_outcome(prepared, registry_dir, publish_ok, publish_error) -> None # append_event(audit) + registry.append_event + EVENT_TO_STATUS 동기화
|
||||||
|
def main(argv=None) -> int: # parse → ensure_env_loaded → prepare_event → with_retry(_publish_once) → record_outcome → 0/2
|
||||||
|
```
|
||||||
|
|
||||||
|
- G-1/G-2/G-4의 의미가 `record_outcome` 한 곳에 남는다.
|
||||||
|
- `publisher.OneShotPublisher.publish(prepared)` = 기존 `_publish_once`.
|
||||||
|
- `publisher.PersistentPublisher`: 컨텍스트 매니저. 1회 연결 + `loop_start()` + `reconnect_delay_set(1,16)`; `publish(prepared)`는 QoS1 발행 + `wait_for_publish(PUBLISH_ACK_TIMEOUT)`; 미확인 시 예외를 던져 호출자의 `with_retry`가 재시도. 브로커 설정이 다른 job 혼입 시 예외.
|
||||||
|
- `service.DelegationService(registry_dir, persistent=False)`: `submit_job()`, `publish_event()`, `ensure_subscriber_daemon()`. CLI/셸은 항상 `persistent=False`. Phase 2a 엔진이 `persistent=True`의 첫 소비자.
|
||||||
|
- 패리티 테스트: 동일 입력·가짜 전송에서 OneShot과 Persistent의 레지스트리(`last_seq`, `status`)·감사 레코드(`published`, `seq`, `retain`, `payload.data.hmac_sig`)가 필드 단위로 동일.
|
||||||
|
|
||||||
|
### D8. 벤치마크 — 결정적 지표와 정보성 지표 분리
|
||||||
|
|
||||||
|
`python -m lib_py.delegation.bench --n 50 --transport {fake|broker} [--connect-latency-ms 50] --out <json>`
|
||||||
|
|
||||||
|
| 지표 | fake 전송(CI에서 단정) | broker 전송(수동, 정보성) |
|
||||||
|
|---|---|---|
|
||||||
|
| 연결 수립 횟수 / N회 발행 | OneShot = N, Persistent = 1 (+재연결 수) — 테스트로 고정 | 동일 카운트 실측 |
|
||||||
|
| 발행당 지연 p50/p95/max | 합성 지연 기준 산술 확인 | 실측 (CONNACK 타임아웃 건수 별도) |
|
||||||
|
| 프로세스 포크 수(셸 경로) | `delegate_publish_event` 1회당 레거시 3 fork → 신규 2 fork | 분석값 |
|
||||||
|
|
||||||
|
결과는 `docs/contracts/issue-5/phase1-benchmark.md`에 환경과 함께 기록. 실브로커 수치는 게이트 조건이 아니다.
|
||||||
|
|
||||||
|
### D9. 비범위 및 호환성 계약(재확인)
|
||||||
|
|
||||||
|
- 저장소 통합 금지: `lib_py/delegation`은 `lib_py.state`/`lib_py.atomic_yaml`을 임포트하지 않는다 — D1(b)가 이를 포함하는 더 넓은 규칙.
|
||||||
|
- 스키마 불변: `SCHEMA_VERSION = 1`, 레코드 필드, 토픽, 감사 로그 레이아웃, `<id>.events.log` 형식 전부 불변 → §3 라이브 헬퍼 버전 게이트상 ABI 파괴 없음. K2에서 부모 `lib_py`(live)와 위임 패키지(freeze)가 다른 트리인 것은 D1(b) 덕분에 관측 가능한 차이를 만들지 않는다.
|
||||||
|
- `job_subscriber`는 이미 실행당 장수 연결이므로 전송 변경 없음.
|
||||||
|
- 자동완료 관련 코드 일체 추가 금지.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 작업 분해 (WBS) — 순서 고정, 커밋 5개
|
||||||
|
|
||||||
|
### P1-00 특성화 테스트 선행 (커밋 1: `test(issue-5): phase 1 delegation characterization suite`)
|
||||||
|
|
||||||
|
- **파일**: `tests/test_issue5_phase1_delegation.py`(신규), `tests/conftest.py`(`MAM_ENV_FILE` 1줄 + PV-2/PV-3 session finalizer), `tests/fixtures/issue_5/phase1/`(가짜 `reconcile.sh`, 가짜 `python` 스텁, `shim_contexts.py` 하니스 — 부록 B).
|
||||||
|
- **셸 헬퍼 golden 테스트** (샌드박스에서 `source lib.sh` 후 실행, `_delegate_py_bin`은 `test_c1_tui_readiness.py:513` 방식으로 스텁):
|
||||||
|
- C-01 `delegate_publish_event "" started` → rc 0, python 미실행.
|
||||||
|
- C-02 스크립트 부재 → rc 0.
|
||||||
|
- C-03 python 스텁 exit 2 → rc 0.
|
||||||
|
- C-04 정상 경로 argv에 `--job <id> --event <ev> --detail <detail>` 정확히 포함.
|
||||||
|
- C-05 `delegate_submit_job p a s` → stdout이 스텁의 JID 한 줄과 정확히 동일.
|
||||||
|
- C-06 `start_watchdog` 스크립트 부재 → stderr 정확 문구, rc 1.
|
||||||
|
- C-07 신규 스폰 → stdout 숫자 PID, 생존, cwd=workdir, 로그 파일 생성.
|
||||||
|
- C-08 두 번째 호출 → 동일 PID, 프로세스 수 불변.
|
||||||
|
- C-09 `start_watchdog` ≡ `ensure_subscriber_daemon` — P1-02 이후 활성.
|
||||||
|
- `MAM_DELEGATION_PY` ∈ {`0`,`1`} parametrize.
|
||||||
|
- **CLI 계약 스냅샷**: argparse 파서에서 옵션명·choices·default를 추출해 단정.
|
||||||
|
- **자동완료 불변 가드**: `EVENT_TO_STATUS` 동등; 래퍼 `:335-348` 리터럴; `reconcile.sh` `_MUTATION` 핵심 4줄 리터럴.
|
||||||
|
- **PV-2/PV-3 finalizer는 이 커밋에서 이미 활성** — 현행 트리에서 통과해야 한다(shim 도입 전 기준선).
|
||||||
|
- **완료 기준**: 신규 테스트 전부 통과(레거시 코드 그대로), 전체 스위트 510 + 신규 통과.
|
||||||
|
|
||||||
|
### P1-01 패키지 생성·이동·shim (커밋 2: `refactor(issue-5): move delegation scripts into lib_py.delegation with package-bound shims`)
|
||||||
|
|
||||||
|
- **파일**: `git mv` 4종, 내부 임포트 교정, `lib_py/delegation/__init__.py`, `scripts/*.py` 4종 shim(D2 블록), `mqtt_common.ensure_env_loaded()`(D6) + 4개 `main()` 및 `reconcile.sh` 임베디드 구독자 1줄.
|
||||||
|
- **추가 테스트**(Rev.2 확장): PV-1(4 별칭), PV-4 서브프로세스 매트릭스 10 시나리오 + K1/K3/K6, PV-5 shim 동일성, "라이브러리 임포트 후 `os.environ` 키 집합 불변", `ensure_env_loaded` 멱등, D1(a)(b) 임포트 규칙 grep, `reconcile.sh --subscribe --idle-timeout 1`이 `test_comp_monitor_concurrency_lock`과 동일 동작.
|
||||||
|
- **완료 기준**: 전체 스위트 통과, `git diff --check`, `compileall`, 3개 shim `--help` 출력이 이동 전 캡처본과 diff 없음, **PV-2/PV-3가 전체 스위트 실행 후에도 통과**(이것이 이의제기가 요구한 "어느 트리를 검증했는지" 증빙).
|
||||||
|
|
||||||
|
### P1-02 서비스·CLI·데몬 + 셸 헬퍼 교체 (커밋 3: `feat(issue-5): DelegationService, cli router, ensure_subscriber_daemon; lib.sh helpers delegate to python`)
|
||||||
|
|
||||||
|
- **파일**: `service.py`, `daemon.py`, `cli.py`, `__main__.py`; `lib.sh` L1876-1941 교체(D3).
|
||||||
|
- **추가 테스트**: daemon 단위(PID 생존·중복·로그 경로·부모 종료 후 생존), `cli publish` ≡ `publish_event.main`(G-1~G-4 cli 경로 parametrize), `cli submit` stdout, C-01~C-09 신규 본체 재통과.
|
||||||
|
- **완료 기준**: 두 경로 특성화 통과, `bash -n` 6개 파일, 전체 스위트 통과.
|
||||||
|
|
||||||
|
### P1-03 장수 연결 프로토타입 (커밋 4: `feat(issue-5): persistent MQTT publisher prototype and benchmark harness`)
|
||||||
|
|
||||||
|
- **파일**: `publish_event.py` 함수 분리(D7), `publisher.py`, `service.py` `persistent` 옵션, `bench.py`.
|
||||||
|
- **추가 테스트**: 가짜 paho 클라이언트로 (a) OneShot N회 = N connect, Persistent = 1, (b) 패리티, (c) PUBACK 미확인 → 재시도 → `record_outcome(published=False)`, (d) 브로커 설정 상이 혼입 예외, (e) `bench --transport fake` 스키마·카운트. 네트워크 접촉 0.
|
||||||
|
- **완료 기준**: 위 테스트 통과, G-1~G-4 동일 통과, 전체 스위트 통과.
|
||||||
|
|
||||||
|
### P1-04 벤치마크 실측 및 문서 (커밋 5: `docs(issue-5): phase 1 benchmark results, contract mapping, task checklist`)
|
||||||
|
|
||||||
|
- 실행: fake 모드 + broker 모드 1회(임시 레지스트리). `docs/contracts/issue-5/phase1-benchmark.md`에 기록.
|
||||||
|
- 문서 갱신: `SKILL.md`(version `4.2.0`→`4.3.0`, "구현은 `lib_py.delegation`, `scripts/*`는 패키지 바인딩 shim" 1단락 + D2-1 표 요약), 스킬 `README.md`, `registry.md`/`job-protocol.md` 각주, `deploy/install.sh` `core_files`에 `.agents/skills/lib_py/delegation/__init__.py` 추가(무결성 목록 갱신 목적; 배포 자체는 트리 통째 복사라 누락 위험 없음), `baseline.md` §6 매핑 행, `task.md` P1-01~03 체크 + 증거, `implementation_plan.md` Phase 1 행 상태.
|
||||||
|
- 라이브 스모크(수동, 보고서 기록): 임시 레지스트리에서 `registry.py register` → shim `publish_event.py --event started` → `-m lib_py.delegation.cli publish --event completed` → `<id>.events.log` 2줄, `events.ndjson` `published` 2건, `status.json`=completed. 이어 `MAM_DELEGATION_PY=0`으로 반복. **추가(Rev.2)**: 에이전트 세션과 같은 조건(`PYTHONPATH`=live)에서 **freeze 사본 경로**의 shim을 실행하고 `python -c`로 `lib_py.delegation.__path__`가 freeze 사본임을 출력해 첨부(K2 실증).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 계약 보존 매트릭스
|
||||||
|
|
||||||
|
| 계약 | 구현 위치(이동 후) | 보장 테스트 |
|
||||||
|
|---|---|---|
|
||||||
|
| 스크립트 리터럴 경로·실행 비트·`--help` | `scripts/*.py` shim | P1-01 `--help` diff, K6, e2e `:414` |
|
||||||
|
| `import mqtt_common` 등 최상위 이름 → 단일 트리의 동일 객체 | shim 패키지 바인딩 + 별칭 | **PV-1(정체성+출처)**, PV-4 |
|
||||||
|
| 위임 외 `lib_py.*`는 항상 실제 트리(pytest) / 부모 무재바인딩 | shim 규칙 3 | **PV-2, PV-3** |
|
||||||
|
| `publish_event` exit 0/1/2, stdout 무출력 | `publish_event.main` 불변 | G-1~G-4 + cli parametrize |
|
||||||
|
| seq 단조·실패 시 소비 | `prepare_event`→`next_seq` | G-4, `test_comp_monitor_monotonic_seq_updates` |
|
||||||
|
| HMAC `data.hmac_sig` | `build_payload` 단일 경로 | `test_mqtt_verify_hmac_*`, 패리티 |
|
||||||
|
| 터미널 이벤트 retain | `prepare_event` | 패리티(`retain`) |
|
||||||
|
| `job_subscriber` exit 0/1/2/3, 디스크 폴백 3s | 이동만 | G-5~G-9 |
|
||||||
|
| 셸 헬퍼 비치명 | 셸 가드 + `\|\| true` | C-01~C-04 (두 경로) |
|
||||||
|
| `delegate_submit_job` stdout=JID 1줄 | `cli submit` + stderr 로깅 | C-05 |
|
||||||
|
| 데몬 PID/수명/중복/로그 경로 | `daemon.py` | C-06~C-09, daemon 단위 |
|
||||||
|
| `start_watchdog` 이름 | 별칭 | C-09 |
|
||||||
|
| 감사 로그 레이아웃·`DELEGATE_JOB_LOGS_DIR` | `mqtt_common` 불변 | G-2, G-3 |
|
||||||
|
| env 로딩 순서(OS env 우선), 라이브러리 임포트 무부작용 | `ensure_env_loaded`(진입점) | env 테스트 2종 |
|
||||||
|
| 자동완료 3표면 불변 | 변경 없음 | 불변 가드 3종 |
|
||||||
|
| 래퍼 텍스트 불변 | 변경 없음 | G-10 + 확장 |
|
||||||
|
| 위임 패키지의 형제 `lib_py` 모듈 무임포트 | D1(b) | grep 테스트 |
|
||||||
|
| freeze(B-13) — K1/K2 모두 freeze 트리 | `PYTHONPATH`(K1) / shim 바인딩(K2) | K1, C2, C2_pe, P1-04 실증 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 게이트
|
||||||
|
|
||||||
|
| # | 조건 | 측정 방법 |
|
||||||
|
|---|---|---|
|
||||||
|
| G1 | 기존 510개 + 신규(예상 40±5) 전부 통과, skipped 0 | `.venv/bin/python -m pytest tests/ -q` (≈15분, 백그라운드) |
|
||||||
|
| G2 | 셸 문법·공백 클린 | `bash -n` 6개 파일, `git diff --check` |
|
||||||
|
| G3 | Python 3.9 컴파일 | `compileall -q .agents/skills/lib_py/delegation` |
|
||||||
|
| **G4** | **정체성+출처(PV-1), 위임 외 `lib_py` 실제 트리·`sys.path` 위생(PV-2/PV-3, 전체 스위트 종료 시점), 컨텍스트 매트릭스(PV-4 10+3), shim 동일성(PV-5), env 무오염** | P1-01 테스트 + conftest finalizer |
|
||||||
|
| G5 | 데몬/PID 계약 | C-06~C-09 두 경로 |
|
||||||
|
| G6 | 자동완료 3표면 불변 | 불변 가드 + `git diff --stat`(래퍼 무변경, `reconcile.sh`는 env 1줄만) |
|
||||||
|
| G7 | 벤치 결정적 지표 + 실측 문서 | fake 전송 테스트, `phase1-benchmark.md` |
|
||||||
|
| G8 | 롤백 드릴 | `MAM_DELEGATION_PY=0` + 라이브 스모크 |
|
||||||
|
| G9 | 라이브 스모크(shim·cli·**K2 freeze 사본**) 감사 로그 정합 | §3 P1-04 |
|
||||||
|
|
||||||
|
Worker 완료 보고서에는 G1 pytest 요약 원문, G4의 PV-2/PV-3 finalizer 통과 로그, G7 표, G9 발췌(K2 `__path__` 출력 포함)를 그대로 붙인다. file:line 인용은 `grep -n` 출력으로 뒷받침.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 롤백 절차
|
||||||
|
|
||||||
|
1. **즉시**: `export MAM_DELEGATION_PY=0` → 셸 헬퍼 legacy 분기. 패키지·shim은 그대로 두어도 무해.
|
||||||
|
2. **커밋 단위**: 5→4→3 순 `git revert`; 커밋 2까지 되돌리면 이동 전 상태. 커밋 1은 레거시에서도 통과.
|
||||||
|
3. **진행 중 루프**: freeze 스냅샷은 시작 시점 트리이므로 롤백은 다음 루프부터. 라이브 헬퍼는 즉시 legacy. 스키마 불변이므로 혼재 호환.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 리스크와 완화
|
||||||
|
|
||||||
|
| # | 리스크 | 완화 |
|
||||||
|
|---|---|---|
|
||||||
|
| R1 | 모듈 사본 분기(테스트 shim vs 프로덕션 패키지) | D2 패키지 바인딩 + PV-1 출처 단정 |
|
||||||
|
| R2 | 임포트 시 `.mam.env` 주입 오염 | D6 + conftest `MAM_ENV_FILE` |
|
||||||
|
| **R3 (Rev.2 재작성)** | shim 부트스트랩이 pytest에서 위임 외 `lib_py`를 tmp 사본으로 가로채거나(insert), freeze에서 live에 밀리거나(append), 비-잎 모듈에서 두 트리가 섞임(잎 파일 적재) | **해소**: 패키지 단위 bind-once, `sys.path` 무변경, 부모 무재바인딩. PV-2/PV-3/PV-4가 회귀를 검출 |
|
||||||
|
| R4 | Python 3.10+ 문법 | G3 |
|
||||||
|
| R5 | 공용 브로커 불안정 | 테스트는 비라우팅 포트/가짜 전송만 |
|
||||||
|
| R6 | `pgrep -f` 패턴 | 레거시 그대로, C-08 |
|
||||||
|
| R7 | `$( )` 캡처 stdout 오염 | stderr 로깅, C-05/C-07 |
|
||||||
|
| R8 | 래퍼 리터럴(G-10) 파손 | 래퍼 무수정 |
|
||||||
|
| **R9 (Rev.2 수정)** | `lib.sh` 미소싱/`PYTHONPATH` 부재 환경에서 shim·`-m` 실패 | shim: `lib_py` 부재 시에만 자기 트리 `append`(K4). `-m` 경로: 셸 헬퍼는 항상 `lib.sh` 내부이므로 `SKILL_DIR` 존재 + 명시 전달 |
|
||||||
|
| R10 | `lib_py.agents.registry` 혼동 | D1(a) grep |
|
||||||
|
| R11 | `start_new_session` vs `nohup` | SIGHUP 무시, 생존 테스트 |
|
||||||
|
| **R12 (신규)** | K2에서 부모 `lib_py`(live)와 위임 패키지(freeze)가 다른 트리 | D1(b): 위임 패키지는 형제 `lib_py` 모듈을 임포트하지 않음(grep 고정). 현재 0건 |
|
||||||
|
| **R13 (신규)** | 두 트리의 위임 모듈을 한 프로세스에서 동시에 써야 하는 경우 | 존재하지 않음(K1~K6 전수). 발생 시 bind-once 규칙상 최초 트리 유지 — 문서화 |
|
||||||
|
| **R14 (신규)** | pytest에서 위임 패키지가 샌드박스 사본에 바인딩되어 "실제 트리 미검증" 우려 | 사본은 `copytree` 동일 내용이며 PV-1이 사본 위치를 basetemp로 한정. K6 서브프로세스가 사본 경로 실행을 별도 검증 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 명시적 비범위
|
||||||
|
|
||||||
|
- `lib.sh` L1943 이후 TUI 헬퍼의 파이썬화.
|
||||||
|
- 래퍼, `run_loop.sh`, `reconcile.sh`(env 1줄 제외) 수정.
|
||||||
|
- `job_subscriber`/모니터 전송 변경, 스키마 v2, 토픽 변경.
|
||||||
|
- 자동완료 로직 일체(Phase 2a/2b).
|
||||||
|
- 저장소 통합, Windows, `pgrep` 다중 PID 정리(Phase 4 후보).
|
||||||
|
- 기존 tier1/tier2 테스트의 임포트 방식 변경(회귀 오라클 보존).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Worker 인수인계 체크리스트
|
||||||
|
|
||||||
|
- [ ] P1-00 커밋 1: 특성화·스냅샷·불변 가드 + **PV-2/PV-3 finalizer**가 레거시 코드에서 통과
|
||||||
|
- [ ] P1-01 커밋 2: `git mv` 4종, D2 shim 4종(PV-5 동일), `ensure_env_loaded`, PV-1/PV-4 매트릭스, 전체 스위트 녹색 **+ finalizer 녹색**
|
||||||
|
- [ ] P1-02 커밋 3: `service/daemon/cli`, `lib.sh` 교체(legacy 보존, `MAM_DELEGATION_PY`), 두 경로 특성화 녹색, `bash -n`
|
||||||
|
- [ ] P1-03 커밋 4: `prepare_event/record_outcome`, `PersistentPublisher`, 패리티·카운트, 네트워크 0
|
||||||
|
- [ ] P1-04 커밋 5: 벤치 문서, SKILL/README/registry.md/job-protocol.md/install.sh/baseline §6/task.md/implementation_plan.md, K2 실증 첨부
|
||||||
|
- [ ] 완료 보고서: G1 원문, G4 finalizer 로그, G7 표, G9 발췌, `grep -n` 인용
|
||||||
|
- [ ] 커밋 접두: `test(issue-5)`, `refactor(issue-5)`, `feat(issue-5)`, `feat(issue-5)`, `docs(issue-5)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 부록 A. 계획 수립 시 실행한 검증 명령 (Rev.1 + Rev.2)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
awk 'NR>=1800 && /^[a-zA-Z_][a-zA-Z0-9_]*\(\)/ {print NR": "$0}' .agents/skills/lib.sh
|
||||||
|
grep -rn 'delegate_publish_event\|delegate_submit_job\|start_watchdog\|_delegate_py_bin' --include='*.sh' --include='*.py' .agents tests
|
||||||
|
grep -l 'delegate_publish_event\|start_watchdog\|delegate_submit_job' tests/*.py # → (없음)
|
||||||
|
grep -n 'lib_py' .agents/skills/multi-agent-mux-delegate-job/scripts/*.py # → (없음) D1(b) 전제
|
||||||
|
echo "$PYTHONPATH" # 에이전트 세션 = 라이브 트리
|
||||||
|
grep -n PYTHONPATH .agents/skills/multi-agent-mux-loop/scripts/run_loop.sh # 0건
|
||||||
|
sed -n '24,25p;163,164p' .agents/skills/lib.sh # SKILL_DIR 기준 PYTHONPATH export
|
||||||
|
ls /var/folders/.../mam-loop-freeze.PfAQNj/.agents/skills/lib_py # freeze에 lib_py 존재
|
||||||
|
grep -n 'os.environ' .agents/skills/multi-agent-mux-delegate-job/scripts/mqtt_common.py # 전부 함수 내부
|
||||||
|
.venv/bin/python -m pytest tests/ --collect-only -q | tail -1 # 510
|
||||||
|
```
|
||||||
|
|
||||||
|
## 부록 B. shim 컨텍스트 재현 하니스 (P1-01 PV-4의 원형)
|
||||||
|
|
||||||
|
세 변형 × 시나리오를 새 인터프리터(`env -u PYTHONPATH`)에서 실행하고 각 모듈의 `TREE` 마커를 출력한다. Worker는 이 하니스를 `tests/fixtures/issue_5/phase1/shim_contexts.py`로 옮기고, 가짜 트리 대신 **실제 이동된 트리의 사본**(`copytree`)을 `real/sandbox/sandbox2/freeze`로 사용하며, `TREE` 마커 대신 `__file__`/`__path__` 접두어로 트리를 판별하도록 바꾼다.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 트리 구성: <tree>/lib_py/{__init__.py, agents/__init__.py(TREE), delegation/{__init__.py, mqtt_common.py(TREE), publish_event.py(TREE + from lib_py.delegation import mqtt_common)}}
|
||||||
|
# <tree>/multi-agent-mux-delegate-job/scripts/{mqtt_common,publish_event}.py ← D2 shim
|
||||||
|
# 시나리오 (sys.path 조작은 conftest/tier1/실행 방식을 모델링):
|
||||||
|
# A : insert(0, real); insert(0, sandbox/scripts); import mqtt_common, publish_event
|
||||||
|
# A_pe : 같음, import publish_event 만
|
||||||
|
# B : insert(0, real); import lib_py.agents; insert(0, sandbox/scripts); import mqtt_common, publish_event
|
||||||
|
# B2 : insert(0, real); import lib_py.delegation.mqtt_common; insert(0, sandbox/scripts); import mqtt_common, publish_event
|
||||||
|
# C1 : insert(0, freeze/scripts); import mqtt_common, publish_event (PYTHONPATH 없음)
|
||||||
|
# C1_pe: 같음, publish_event 만
|
||||||
|
# C2 : insert(0, real); insert(0, freeze/scripts); import mqtt_common, publish_event
|
||||||
|
# C2_pe: 같음, publish_event 만 ← 에이전트 호출 형태
|
||||||
|
# D : insert(0, real); insert(0, sandbox/scripts); import mqtt_common; insert(0, sandbox2/scripts); import publish_event
|
||||||
|
# E : insert(0, real); insert(0, sandbox/scripts); import mqtt_common; from lib_py.delegation import publish_event
|
||||||
|
# 단정: 각 시나리오에서 {alias_mc, pkg_mc, pe, pe.dep} 가 단일 트리이고, pytest 계열(A,A_pe,B,B2,D,E)에서 agents == real,
|
||||||
|
# identity(alias is pkg) == True, sys.path 에 lib_py 를 담은 비-real 루트가 없음(C1 계열 제외).
|
||||||
|
```
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
# 📑 Implementation Plan Rev.2 — Workspace Root Markdown 정리 및 최신화 (Job baea3f7e, 원본 45bd8635)
|
||||||
|
|
||||||
|
- **역할**: Planner (`planner-reviewer-claude-01`)
|
||||||
|
- **기준 커밋**: `07cae71` (`main`, v4.4.0, 전체 스위트 750 passed / 문서 바인딩 4모듈 49 passed)
|
||||||
|
- **개정 사유**: Creator(`creator-agy-01`, Job `87aa5b5a`) 이의제기 3건. **3건 모두 실측으로 사실 확인되어 전부 수용**하고, 검증 과정에서 Planner 자신의 Rev.1 오류 1건(D-1/Phase B-1 "잔재에 고유 내용 없음")을 추가로 발견하여 함께 정정한다.
|
||||||
|
- **Rev.1 대비 변경 범위**: §1 D-1, Phase A-2, Phase B 전면, Phase D-1, 게이트 4. 나머지 Phase C/E/F/G와 게이트 1·2·3·5·6은 Rev.1 그대로 유효(본문 하단에 원문 유지).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 이의제기 판정 (Adjudication of Challenge)
|
||||||
|
|
||||||
|
| # | Creator 주장 | Planner 실측 | 판정 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| C-1 | 완료 과제는 §5 단일 섹션이 아니라 §2(5건)·§3(2건)·§5(24블록)에 분산되어 있고, 헤더 "30건"은 전역 합계이며 Rev.1 Phase B-2 지시("§5의 `###` 수로 재계산해 헤더와 일치")는 실행 불가 | `awk`로 전 `###` 블록을 섹션별로 열거: §2 완료 5(B-21,B-20,B-19,B-14,B-15), §3 완료 2(O-6,A-4), §5 24블록(O-2 중복 2회), 헤더 ID 정확히 30개. **주장 사실.** 단 Creator의 산식 "5+2+23=30"은 우연한 일치다. ID↔블록이 1:1이 아니기 때문이다(아래 §1-A) | **수용** — 방안 A 채택, 단 계수 규칙을 블록 기준으로 명문화 |
|
||||||
|
| C-2 | 이관되는 `implementation_plan.md` §7 표의 `[.mam.env](.mam.env)` 링크는 gitignored 파일(`test_d29`가 추적 금지)이라 클린 체크아웃에서 게이트 4 `test -e`가 실패 | `.gitignore:24` `.mam.env`, `git ls-files`에 `.mam.env.example`만 추적, `tests/test_deploy_freshness.py:687 test_d29_env_secrets_never_tracked` 존재, 원문 151행 링크 확인. **주장 사실.** | **수용** — 링크 대상을 `.mam.env.example`로, 게이트 4에 ignore 예외 규칙 추가 |
|
||||||
|
| C-3 | Phase D-1 트리 추가 목록에 `IMPROVEMENTS.md` 누락, 영문 README 트리에 `README.ko.md`/`BOOTSTRAP.ko.md` 부재, 한국어 README에는 `BOOTSTRAP.ko.md`만 존재 → "줄수 일치" 게이트 기준 불명확 | `README.md:188-190` 3개 루트 md, `README.ko.md:170-173` 4개 루트 md. **주장 사실.** | **수용** — 루트 md 8개 전부를 양 README 트리에 동일 순서로 명시 |
|
||||||
|
|
||||||
|
부수 정정: Creator 보고서의 "450줄"은 `wc -l` 기준 449줄(마지막 줄 개행 있음)이나 판단에 영향 없음.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 정정된 결함 진단
|
||||||
|
|
||||||
|
### 1-A. `IMPROVEMENTS.md` 완료 과제 원장 (Planner 실측, 정본)
|
||||||
|
|
||||||
|
헤더 ID 30개와 완료 `###` 블록 31개(잔재 제외)의 대응은 다음과 같다. 이 표가 Phase B의 유일한 기준이다.
|
||||||
|
|
||||||
|
| 헤더 ID | 현재 블록 위치 | 비고 |
|
||||||
|
|---|---|---|
|
||||||
|
| B-21, B-20, B-19, B-14, B-15 | §2 (30/44/54/64/68행) | 미해결 섹션에 완료 항목 혼재 |
|
||||||
|
| O-6, A-4 | §3 (99/106행) | A-4 블록은 Rev.2 어댑터 계층 45행 분량 |
|
||||||
|
| B-9, B-13, B-10, B-5, C-6, P2-2, P2-1, B-4, O-3, A-1, A-3, C-2, A-5, B-1, B-3, C-1, O-1, Herdr-0.8.0-Compat-SanitizeHash | §5 | 1:1 대응 |
|
||||||
|
| B-7 | §5 `P0-1 (B-7)` (253행) | 키 표기 상이 |
|
||||||
|
| O-4-OrcOnboard | §5 `multi-agent-mux-orc-onboard` (242행) | 키 표기 상이 |
|
||||||
|
| **O-2** | §5 **2블록**: `P0-2 (O-2)` 247행(Rev.1: pid+lstart 마커, 46/46) + `O-2` 273행(Rev.2: 하드링크 원자 쓰기, steal_lock, 204/204) | **동일 과제의 연속 개정** → 병합 대상 |
|
||||||
|
| **C-3b** | 독립 블록 없음. §5 `P3-1 (A-4 Phase 2 / Option B / C-3b / M2~M7)` 224행 제목에 포함 | P3-1 블록이 A-4(2차)와 C-3b를 동시에 담음 |
|
||||||
|
| **B-8** | **독립 블록 없음.** §6.2 표 367행 `P1-2(과거) B-8 … (✅ 완료 — tests/test_b8_send_keys_verification.py 1/1 PASS)`에만 존재 | `tests/test_b8_send_keys_verification.py` 실재 확인 |
|
||||||
|
| (헤더에 없음) | §5 `Rev.2 (b4a1d094)` 260행 세션 ID 자동 할당 | 헤더 ID 목록에서 누락된 블록 |
|
||||||
|
|
||||||
|
결론: "헤더 30 = §2 5 + §3 2 + §5 23"은 (O-2 중복 −1, C-3b 블록 없음 −1, B-8 블록 없음 −1, Rev.2 블록 ID 없음 +1, P3-1 별도 블록 +1)이 상쇄된 우연이다. Rev.2는 **"§5의 `###` 블록 1개 = 헤더 ID 1개"**를 강제하여 이 우연을 제거한다.
|
||||||
|
|
||||||
|
### 1-B. Rev.1 오류 정정 — 154~186행 잔재는 단순 삭제 불가
|
||||||
|
Rev.1 Phase B-1은 "앞쪽 사본이 최신 서술을 모두 포함(확인됨)"이라 했으나 **틀렸다**. 잔재 전용 토큰 20종을 실측했다: B-14 원 진단(`publish_event.py:195-199`, `wait_for_job()`, `max_wait=3900s`, `status=running`), B-15 원 진단(`job_subscriber.py:172-251`, `queue.Empty`, `idle_timeout`, `multi-agent-mux-delegate-job:331-341`, `wait "$sub_pid"`, `sub_rc`, `job_status="error"`, `job_status="broker_unavailable"`, `load_job`/`read_logged_status`, `source: disk-fallback`), O-5 배경(`nats-py` Option B 기각 사유). 앞쪽 B-14/B-15 블록(64~70행)은 각 2줄 요약뿐이다. B-16 블록은 양쪽 동일(잔재 고유 내용 없음). 따라서 잔재는 **병합 후 삭제**한다(Phase B-1).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 개정된 작업 계획 (변경된 Phase만 기술; 미기재 Phase는 Rev.1 원문 적용)
|
||||||
|
|
||||||
|
### Phase A-2 (개정) — 이관 파일 링크 규칙
|
||||||
|
- `git mv implementation_plan.md docs/contracts/nats-backplane/implementation_plan.md` 후 상대 링크를 `../../../` 접두로 승격하되, **§7 표의 `[.mam.env](.mam.env)`는 `[.mam.env (템플릿: .mam.env.example)](../../../.mam.env.example)`로 변경**한다. 이유: `.mam.env`는 gitignored(`.gitignore:24`)이며 `test_d29`가 추적을 금지하므로 클린 체크아웃에 존재하지 않는다.
|
||||||
|
- 같은 이유로 문서 내 `.mam.env`를 **파일 링크 없이 코드 스팬으로만** 언급하는 것은 허용한다(23행·122행은 링크가 아니므로 변경 불필요).
|
||||||
|
|
||||||
|
### Phase B (전면 개정) — `IMPROVEMENTS.md` 단일 원장화 (방안 A 채택)
|
||||||
|
작업 순서를 엄수한다. 행 번호는 착수 시점 기준이며 이후 단계는 **헤더 문자열로 앵커링**한다.
|
||||||
|
|
||||||
|
1. **잔재 병합 후 삭제** (1-B 반영)
|
||||||
|
- 잔재 `### **B-14 (F-1 / P1)…`(156행)의 `현상`·`파급 효과` 불릿을 앞쪽 `### **B-14 (✅ 완료 — F-1 / P1)…` 블록의 `현상` 불릿 **아래에 "원 진단" 불릿으로 추가**(내용 원문 유지, 시제만 과거형 허용).
|
||||||
|
- 잔재 B-15(161행)의 `현상` 1·2항과 `파급 효과`를 앞쪽 B-15 블록에 동일 방식으로 추가.
|
||||||
|
- 잔재 O-5(179행)의 `현상` 문장 중 "`nats-py` 클라이언트 재작성(Option B)을 배제하고" 구절을 앞쪽 O-5 `현상`에 삽입.
|
||||||
|
- 잔재 B-16(170행)은 앞쪽과 동일하므로 추가 없음.
|
||||||
|
- 그 다음 두 번째 `## 2. 🟠 …(Edge-case Bugs — 3건)`부터 두 번째 `## 3.` 블록 끝(`## 4. ⚪ 레거시 잔재` 직전 구분선)까지 삭제.
|
||||||
|
- **검증**: 위 20종 토큰 전부가 삭제 후 파일에 여전히 존재(`grep -cF`로 각 1 이상), `grep -c '^## 2\.'`=1, `grep -c '^## 3\.'`=1.
|
||||||
|
2. **완료 블록 §5 이관** (방안 A)
|
||||||
|
- §2의 B-21, B-20, B-19, B-14, B-15 블록과 §3의 O-6, A-4 블록을 **현재 등장 순서 그대로** `## 5. 🎉 완료된 과제` 제목 바로 아래(기존 B-9 앞)에 삽입. 블록 본문은 한 글자도 바꾸지 않는다(제목의 `(✅ 완료 — …)` 표기 포함).
|
||||||
|
- 이관 후 §2에는 B-16, B-17, B-18만, §3에는 O-5만 남는다.
|
||||||
|
3. **O-2 중복 병합**
|
||||||
|
- `### **O-2: 동일 워크스페이스 … (Race-Free Mutex & Ownership)**`(Rev.2, 최종본)을 유지하고, `### **P0-2 (O-2): …**` 블록은 제목을 제거한 뒤 그 불릿 4개를 O-2 블록 **끝에 `- **Rev.1 (P0-2) 선행 조치**:` 하위 불릿으로** 편입한다. 제목은 `### **O-2 (P0-2): …**`로 갱신하여 키 대응을 유지.
|
||||||
|
4. **B-8 블록 신설** (원장 완결성)
|
||||||
|
- `## 5` 말미(Herdr-0.8.0 블록 뒤)에 `### **B-8 (P1-2): agy 주입 시 \`return 0\` 우회 제거 및 제출 검증 루프 이관** — ✅ 완료` 블록을 추가하고, 불릿 1개만 둔다: §6.2 표 367행의 서술을 그대로 옮기고 근거로 `tests/test_b8_send_keys_verification.py` (1/1 PASS)를 명기한다. **새 사실을 지어내지 않는다** — 표 367행 밖의 내용은 쓰지 않는다.
|
||||||
|
5. **섹션 헤더 카운트 확정** (계수 규칙: 해당 섹션 하위 `###` 블록 수)
|
||||||
|
- `## 1. 🔴 아키텍처 결함 (Architecture Flaws — 1건)` (A-2)
|
||||||
|
- `## 2. 🟠 엣지 케이스 및 런타임 버그 (Edge-case Bugs — 3건)` (B-16, B-17, B-18)
|
||||||
|
- `## 3. 🟡 오케스트레이션 최적화 과제 (Orchestration Optimizations — 1건)` (O-5)
|
||||||
|
- `## 4. ⚪ 레거시 잔재 및 죽은 코드 (Legacy Remnants — 0건 — 전원 완료)` (변경 없음)
|
||||||
|
- `## 5. 🎉 완료된 과제 (Completed Tasks — 31건)` = 기존 24 − 1(O-2 병합) + 7(이관) + 1(B-8) = **31**
|
||||||
|
6. **헤더 요약(3~6행) 동기화**
|
||||||
|
- `최종 갱신일`: `2026-09-07 (IMPROVEMENTS.md 단일 원장화: §2/§3 완료 항목 §5 이관, 머지 잔재 병합 제거, v4.1.x~v4.4.0 완료 사항 반영)`.
|
||||||
|
- `총 추적 미해결 과제`: **5건** 문구 유지(A-2 / B-16, B-17, B-18 / O-5).
|
||||||
|
- `완료된 과제`: **31건**으로 갱신하고 괄호 목록을 **§5 블록 키를 등장 순서대로 나열한 31개**로 교체: `B-21, B-20, B-19, B-14, B-15, O-6, A-4, B-9, B-13, B-10, B-5, C-6, P3-1-A4Phase2-C3b, P2-2-C3a-C4-LegacyCleanup, P2-1-DelegateJobSafe-TrapFix, O-4-OrcOnboard, O-2-P0-2, P0-1-B-7, Rev.2-b4a1d094-SessionIdPinning, B-4, O-3, A-1, A-3, C-2, A-5, B-1, B-3, C-1, O-1, Herdr-0.8.0-Compat-SanitizeHash, B-8`. (기존 `B-7`, `C-3b`, `O-2`, `A-4`는 위 키 안에 포함되어 유실되지 않는다.)
|
||||||
|
7. **v4.1.x~v4.4.0 반영** (Rev.1 B-3 유지, 위치만 명확화)
|
||||||
|
- 기존 `### **B-13 (Stage 2)…` 블록 끝에 1불릿: "v4.4.0(Issue #5 Phase 4)에서 freeze 스냅샷을 기본 경로에서 철거, `MAM_LOOP_FREEZE=1` 롤백 스위치만 잔존 (`docs/contracts/issue-5/phase4-freeze-removal.md`)".
|
||||||
|
- 신규 블록은 추가하지 않는다(codex/grok/opencode 통합, Python 코어 마이그레이션은 `VERSIONS.md`가 정본이며, 백로그 ID가 부여된 과제가 아니므로 §5에 등재하지 않는다). 대신 `📌 개요` 절 끝에 1문장: "v4.1.0 이후 릴리스 단위 변경 이력은 `VERSIONS.md`를 정본으로 하며 본 백로그는 ID가 부여된 과제만 추적한다."
|
||||||
|
8. **보존 필수 문자열**: `### **O-1: 타당하지 않은 리뷰 피드백 거부/반론 프로토콜 미지원 (Rebuttal Protocol)**` 제목과 본문은 변경 금지 (`test_o1_rebuttal.py::test_v10`).
|
||||||
|
- **Phase B 검증 스크립트 (Creator 실행 필수)**:
|
||||||
|
```bash
|
||||||
|
f=IMPROVEMENTS.md
|
||||||
|
test "$(grep -c '^## 2\.' $f)" = 1 && test "$(grep -c '^## 3\.' $f)" = 1
|
||||||
|
# §5 블록 수 = 31, 헤더 괄호 ID 수 = 31
|
||||||
|
awk '/^## 5\./{s=1;next} /^## 6\./{s=0} s&&/^### /{n++} END{print n}' $f # → 31
|
||||||
|
sed -n '6p' $f | grep -o -E '\(B-21.*\)' | tr ',' '\n' | grep -c . # → 31
|
||||||
|
# 각 완료 ID의 ### 제목이 정확히 1회
|
||||||
|
for id in B-21 B-20 B-19 B-14 B-15 O-6 O-2 B-8 A-4; do test "$(grep -c "^### \*\*$id[ :(]" $f)" = 1 || echo "DUP/MISSING $id"; done
|
||||||
|
# §2·§3에 ✅ 완료 블록 잔존 금지
|
||||||
|
awk '/^## 2\./{s=1} /^## 4\./{s=0} s&&/^### .*✅/{print "LEAK: "$0}' $f
|
||||||
|
.venv/bin/python -m pytest -q tests/test_o1_rebuttal.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase D-1 (개정) — README 트리의 루트 마크다운 표기 기준
|
||||||
|
- 양 README의 `text` 트리 **루트 레벨 마크다운 항목은 다음 8개를 알파벳 순으로 전부** 기재한다(언어별 미러 포함, 기존 3~4개 → 8개): `AGENTS.md`, `BOOTSTRAP.ko.md`, `BOOTSTRAP.md`, `IMPROVEMENTS.md`, `MESSAGING.md`, `README.ko.md`, `README.md`, `VERSIONS.md`. 설명 문구만 언어별로 다르다.
|
||||||
|
- 디렉터리 항목 추가 목록은 Rev.1 D-1 그대로(`lib_py/` 3패키지, `scripts/` shim 재표기, `docs/` 4항목, `nats-docker/`, `tests/`).
|
||||||
|
- **검증**: 두 파일의 트리 블록에서 `├──|└──`로 시작하는 줄 수가 동일하고, 루트 md 8개 문자열이 양쪽에 각 1회 존재.
|
||||||
|
|
||||||
|
### 게이트 4 (개정) — 깨진 상대 링크 0건
|
||||||
|
- 검사 대상: 루트 8개 + 이관 문서. 링크 경로 추출 후 `test -e`; **단 `git check-ignore -q <path>`가 참인 경로는 검사에서 제외**하지 않고 **오류로 처리**한다(추적되지 않는 파일로의 링크는 문서 결함이므로 Phase A-2 규칙에 따라 링크 자체를 제거/대체해야 한다). 즉 게이트 4 = "모든 링크 대상이 존재하고 git 추적 대상이다".
|
||||||
|
```bash
|
||||||
|
for f in README.md README.ko.md BOOTSTRAP.md BOOTSTRAP.ko.md MESSAGING.md IMPROVEMENTS.md AGENTS.md VERSIONS.md docs/contracts/nats-backplane/implementation_plan.md; do
|
||||||
|
d=$(dirname "$f")
|
||||||
|
grep -o -E '\]\(([^)#]+)(#[^)]*)?\)' "$f" | sed -E 's/^\]\(//; s/#.*//; s/\)$//' | grep -v -E '^(https?:|mailto:|file:)' | while read -r p; do
|
||||||
|
t="$d/$p"; { test -e "$t" && git ls-files --error-unmatch "$t" >/dev/null 2>&1; } || echo "BROKEN/UNTRACKED: $f -> $p"
|
||||||
|
done
|
||||||
|
done
|
||||||
|
```
|
||||||
|
- 주의: `IMPROVEMENTS.md` 248행 등에 `file:///Users/...` 절대 링크가 존재한다. 본 게이트에서는 제외(`file:` 필터)하되, Phase B-3 O-2 병합 시 해당 불릿의 `file://` 링크는 상대 경로 `.agents/skills/multi-agent-mux-loop/scripts/loop_lock.sh`로 교체한다(같은 불릿을 만지는 김에 최소 수정).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 최종 검증 게이트 (Rev.2 확정본)
|
||||||
|
1. 루트 마크다운 8개, `implementation_plan.md`는 `docs/contracts/nats-backplane/`.
|
||||||
|
2. `pytest -q tests/test_deploy_layout.py tests/test_o1_rebuttal.py tests/test_deploy_freshness.py tests/test_version_consistency.py` → 49 passed.
|
||||||
|
3. `pytest -q tests/` → 750 passed.
|
||||||
|
4. 위 개정 게이트 4 스크립트 출력 0줄.
|
||||||
|
5. `git diff --stat`에 `.agents/skills/**`, `tests/**`, `deploy/**` 변경 없음.
|
||||||
|
6. `MAM_INSTALL_DOCS=minimal|full` 설치 시 루트 문서 1개/4개.
|
||||||
|
7. **(신설)** Phase B 검증 스크립트 전 항목 통과: §5 블록 31 = 헤더 ID 31, §2/§3 완료 블록 누출 0, 잔재 토큰 20종 보존.
|
||||||
|
|
||||||
|
## 4. 범위 외 (Rev.1 §4 유지) + 추가
|
||||||
|
- `nats-docker/PRIVATE_SERVER.md:5` 깨진 링크 — 서브모듈 별도 커밋.
|
||||||
|
- 미해결 5건(A-2, B-16, B-17, B-18, O-5)의 코드 대조 재판정 — 별도 리뷰 잡. 특히 B-17은 `MESSAGING.md:310`이 "구현됨"으로 서술하고 있어 상태 불일치 의심이 있으나 **본 작업에서는 손대지 않는다**(코드 확인 없이 완료로 옮기면 원장 오염).
|
||||||
|
- README 배지 5번째 lockstep 레그 — 별도 잡.
|
||||||
|
|
||||||
|
## 5. 리스크
|
||||||
|
- Phase B는 이동·병합이 많아 유실 위험이 가장 크다. 반드시 **B-1 → B-2 → B-3 → B-4 순서**로 각 단계 후 검증 스크립트를 실행하고, 최종적으로 `git diff --stat IMPROVEMENTS.md`의 삭제 줄 수가 (잔재 33줄 + P0-2 제목 1줄 + 이관에 따른 이동분) 범위 안인지 확인한다. 이관은 `git diff`에서 삭제+추가로 보이므로 `git diff --color-moved=plain`으로 이동임을 확인한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
## 부록. Rev.1 원문 중 유효 유지 항목 (변경 없음)
|
||||||
|
- §0 실측 인벤토리 표 및 KEEP/이관 판정 9건.
|
||||||
|
- §1 D-2(MESSAGING lib_py 미반영), D-3(README 트리), D-4(BOOTSTRAP §1), D-5(implementation_plan 이관), D-6(AGENTS §5).
|
||||||
|
- Phase A-1/A-3/A-4, Phase C 전체, Phase D-2~D-4, Phase E, Phase F, Phase G.
|
||||||
@@ -1,16 +1,253 @@
|
|||||||
# Report: Job c65b0081 — Refined OpenCode Integration Plan (Rev.2) per `creator-agy-01` Challenge
|
> [!NOTE]
|
||||||
|
> This is the durable archive of the OpenCode (`anomalyco/opencode`) Integration Plan (Rev.3, Job `78f332a9`).
|
||||||
|
> The active mirror at `.agents/reports/implementation_plan.md` now houses the Codex CLI Integration Plan (v4.2.0).
|
||||||
|
|
||||||
**Durable output (updated in place)**: [.agents/reports/implementation_plan.md](../../../.agents/reports/implementation_plan.md)
|
# 🔌 Implementation Plan: OpenCode (`anomalyco/opencode`) Agent Adapter for MAM (Rev.3)
|
||||||
|
|
||||||
## Summary
|
- **Author**: `planner-reviewer-claude-01` (Planner), refined in collaboration with `creator-agy-01` and `reviewer-creator-grok-01`
|
||||||
|
- **Job**: `de667f0f` → Rev.2 (`c65b0081`) → Rev.3 (`78f332a9`)
|
||||||
|
- **Status**: Plan only — **no code changed in the repo**. Ready for review/refinement.
|
||||||
|
- **Scope**: Add `opencode` as a 5th first-class MAM agent backend (alongside `claude`, `agy`, `hermes`, `grok`), per `docs/NEW_AGENT_INTEGRATION_GUIDE.md`'s 5-step procedure.
|
||||||
|
- **Durable Path**: `.agents/reports/planner-reviewer-claude-01/plan-de667f0f.md` (and `.agents/reports/implementation_plan.md`)
|
||||||
|
|
||||||
`creator-agy-01` challenged Rev.1's §3/§6 (their job `ebf97869`) — not disputing the plan's core research (SQLite storage, no pre-assigned session ID, `anomalyco/opencode` identity all stand unchanged), but flagging two architectural blind spots. **Both independently re-verified against live source and accepted — no `[REBUT:]` filed**:
|
---
|
||||||
|
|
||||||
1. **§6's "reconcile.sh drift-C is generic" claim was false.** Read `reconcile.sh:507-780` directly: drift-C materialization is three separate, hardcoded, copy-pasted per-agent blocks (`claude` 634-671, `agy` 673-720, `hermes` 722-780), not a generic `adapter.discover()` loop. **Went beyond the challenge's own evidence**: independently discovered `grok` has *no* materialize block at all — because it always pre-assigns its session ID at spawn (confirmed in `grok.py`), so it only ever needs the shared C0 *confirmation* path (line 614-632), never post-spawn *discovery*. Since OpenCode cannot pre-assign (already established in Rev.1), it belongs in the `hermes`/`agy` bucket, not `grok`'s — it needs its own bespoke block. Rewrote §6 with a concrete illustrative code block mirroring `hermes`'s structure, and weighed two implementation options (an explicit new block vs. a generic drift-C refactor), recommending the explicit block for this PR and filing the generic refactor as an out-of-scope follow-up (consistent with the brief's "don't implement" constraint and with keeping this PR's blast radius limited to OpenCode rather than touching 3 other agents' already-battle-tested drift paths).
|
## Rev.3 Changelog (Job `78f332a9`)
|
||||||
2. **§3's 18-item touch-point table omitted 4 files**: `lib_py/atomic_yaml.py:132`, `lib_py/verify_session.py:69`, `lib_py/workspace_uuid.py` (3 locations), and `reconcile.sh` (4 more locations: 2 auto-registration loops, the entry-init `elif` chain, `OWN_KEY_BY_AGENT`). Verified all cited lines directly and confirmed each failure mode described. **Also independently traced a plausible worse failure mode** (an uncaught `KeyError` in `reconcile.sh`'s C0 block crashing reconciliation for *every* agent, not just OpenCode) by reading `create_session.sh:396,419`'s `session_id_source` assignment — ruled it out (OpenCode rows are correctly stamped `'pending-discovery'`, never reach the C0 dict-index), confirming the actual failure mode is the same silent data-completeness gap the challenge described, not a crash. Added all 4 files as items 19-22, raising the touch-point count from 18 to 22.
|
|
||||||
|
|
||||||
Also updated: §7 (lockstep requirement widened to 22 locations + the new drift-C block), §8 (added a dedicated drift-C integration test mirroring the existing Hermes reconcile tests — "the test that would have caught this blind spot mechanically instead of requiring a manual challenge to find it"), §9 (Definition of Done checklist extended), §10 (effort estimate revised from ~1 day to ~1.5 days to price in the real cost of the caught blind spot).
|
Reviewers `planner-reviewer-claude-01` (Job `1d63595f`) and `reviewer-creator-grok-01` (Job `56120ad3`) reviewed the Rev.2 plan and working tree. **All 5 findings accepted — zero `[REBUT:]` filed**:
|
||||||
|
|
||||||
Full Rev.2 text is in the durable plan linked above (new "Rev.2 Changelog" section at the top, rewritten §6, extended §3/§7/§8/§9/§10).
|
1. **Restored Root `implementation_plan.md` & Namespaced Plan Path (F1)**:
|
||||||
|
- Root `implementation_plan.md` was restored to its tracked HEAD content (the active *MAM 메시징 백플레인 전환 실행 로드맵* NATS migration roadmap).
|
||||||
|
- This OpenCode integration plan is permanently housed under `.agents/reports/planner-reviewer-claude-01/plan-de667f0f.md` and mirrored at `.agents/reports/implementation_plan.md` to prevent future root-level collision.
|
||||||
|
2. **Extended Lockstep Touch-Points Table to 29 Items (F2)**:
|
||||||
|
- Added items 23–29 covering `create_session.sh` (`spawn()` dispatch case, YAML initialization `elif`, fallback `CMD_FULL` case), `lib.sh` (`send_keys_safe` `_sks_agent` resolution case), `tests/test_tier1_unit.py` (CLI usage assertions), 8× `SKILL.md` (supported agent lists), and `tests/conftest.py` (`mock_agents` stub binary).
|
||||||
|
3. **Official CLI `opencode db path` Subcommand Integration (F3)**:
|
||||||
|
- Updated §1.5, §2, and §6 to prioritize the official `opencode db path` subcommand for dynamic database location, dropping hardcoded `storage.db` assumptions.
|
||||||
|
4. **Headless Permission Architecture via `OPENCODE_PERMISSION` (F4)**:
|
||||||
|
- Documented the `OPENCODE_PERMISSION='{"*":"allow"}'` process environment variable alongside `--auto` in §1.4, §2, and §9 DoD for unattended automated operations without global config file mutations.
|
||||||
|
5. **Sandbox-Safe `ctx.home_dir` Binding in `auth_ok` and Artifact Paths (F5)**:
|
||||||
|
- Replaced `os.path.expanduser('~')` with `ctx.home_dir` in the adapter skeleton and test contract for hermetic sandbox execution.
|
||||||
|
|
||||||
[VERDICT: N/A — planning artifact, review pending]
|
---
|
||||||
|
|
||||||
|
## Rev.2 Changelog (Job `c65b0081`)
|
||||||
|
|
||||||
|
`creator-agy-01` filed a formal architectural challenge (Job `ebf97869`) against Rev.1, raising two blind spots. Both accepted:
|
||||||
|
1. **`reconcile.sh` Drift-C Non-Generic Correction**: Identified that `reconcile.sh` drift-C materialization consists of bespoke per-agent blocks, requiring an explicit `# === drift C (opencode) ===` block for post-spawn discovery agents.
|
||||||
|
2. **Core `lib_py` Constants Added**: Added `atomic_yaml.py:132` (`running_keys`), `verify_session.py:69` (`mam_row_own_uuid()`), and `workspace_uuid.py:7-12,33` (`OWN_KEY`) to the touch-points list.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Relationship to the existing `new_agent_types_roadmap.md`
|
||||||
|
|
||||||
|
`.agents/reports/new_agent_types_roadmap.md` (by `creator-agy-01`, 2026-08-26) sketches a generic "add any new agent" procedure and guesses at OpenCode specifics in its difficulty matrix. This plan **supersedes** that roadmap's OpenCode row:
|
||||||
|
|
||||||
|
| | Roadmap's guess | Verified reality (this doc) |
|
||||||
|
|---|---|---|
|
||||||
|
| Storage format | "JSON files in `~/.opencode/sessions/`" | **SQLite** since v1.2.0 (Feb 2026); dynamic lookup via `opencode db path` |
|
||||||
|
| Storage path | `~/.opencode/sessions/` | `~/.local/share/opencode/` (XDG data dir) or project storage via `opencode db path` |
|
||||||
|
| Ready tokens | `OpenCode\|Chat\|Welcome` | Candidate string pending live TUI pane capture verification (§4) |
|
||||||
|
| Adapter registry | `ClineAgentAdapter` shown | `cline` fully removed in v4.0.0. Targets current 4-adapter registry (`claude`, `agy`, `hermes`, `grok`) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Verified OpenCode CLI/Runtime Facts
|
||||||
|
|
||||||
|
Source: `opencode.ai/docs/{cli,config,permissions,tui}/`, `github.com/anomalyco/opencode`, and official documentation.
|
||||||
|
|
||||||
|
### 1.1 Identity & Install
|
||||||
|
- **Upstream Repository**: `github.com/anomalyco/opencode` (distinct from the archived `opencode-ai/opencode` / "Crush").
|
||||||
|
- **Install**: `curl -fsSL https://opencode.ai/install | bash` (also npm/Homebrew).
|
||||||
|
|
||||||
|
### 1.2 Execution Modes
|
||||||
|
- `opencode [project]` — launches the **TUI** (default).
|
||||||
|
- `opencode run [message..]` — **non-interactive** one-shot CLI execution.
|
||||||
|
- `opencode db path` — prints the active database file path on stdout.
|
||||||
|
|
||||||
|
### 1.3 Relevant CLI Flags & Environment
|
||||||
|
| Flag / Variable | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `--continue` / `-c` | Resume the most recent session |
|
||||||
|
| `--session <id>` / `-s <id>` | Continue a specific session ID (cannot pre-assign a new UUID at spawn) |
|
||||||
|
| `--agent <name>` | Mode selection: `build` (default, write access) or `plan` (read-only) |
|
||||||
|
| `--auto` | Auto-approves tools that prompt ("ask"); explicit "deny" rules still enforced |
|
||||||
|
| `OPENCODE_PERMISSION` | Process environment variable for inline JSON permission overrides (e.g. `'{"*":"allow"}'`) |
|
||||||
|
|
||||||
|
**Spawn / Discovery Consequence**: OpenCode has **no `--session-id <new-uuid>` pre-assignment flag**. It falls strictly in the **`hermes` / `agy` architectural bucket**: `spawn_spec()` ignores `session_uuid` on initial creation, and MAM learns the assigned session ID post-spawn via `discover()` and `reconcile.sh` Drift-C materialization.
|
||||||
|
|
||||||
|
### 1.4 Permissions (Headless & Non-Interactive Configuration)
|
||||||
|
MAM configures headless unattended execution at the process boundary without mutating user-global config files (`~/.config/opencode/opencode.json`):
|
||||||
|
1. **CLI Flag**: `--auto` passed in `spawn_spec` / `resume_spec`.
|
||||||
|
2. **Process Environment Variable**: `OPENCODE_PERMISSION='{"*":"allow"}'` exported in the spawn subshell when full unattended bypass is required.
|
||||||
|
3. **Optional Project/Global Config**: `<project>/opencode.json` or `~/.config/opencode/opencode.json` can define granular policies if configured by the user.
|
||||||
|
|
||||||
|
### 1.5 Session & Artifact Storage
|
||||||
|
- **Format**: Single SQLite database (since v1.2.0).
|
||||||
|
- **Dynamic Path Resolution**: `opencode db path` prints the exact database location.
|
||||||
|
- **Default Storage Roots**: `~/.local/share/opencode/` (global) or `project/<slug>/storage/`.
|
||||||
|
- **Pre-Implementation Spike (§1.5)**: Run `opencode db path` and `sqlite3 <db> ".schema"` on a live install to verify `session`, `message`, and `part` table column definitions (`cwd`, `created_at` timestamps) before writing SQL queries.
|
||||||
|
|
||||||
|
### 1.6 Authentication
|
||||||
|
- Interactive: `opencode auth login`.
|
||||||
|
- Multi-provider environment variables: `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, `GROQ_API_KEY`.
|
||||||
|
- Credentials cache: `~/.local/share/opencode/auth.json`.
|
||||||
|
- `auth_ok()` checks for `auth.json` in `ctx.home_dir` OR any supported provider environment variable.
|
||||||
|
|
||||||
|
### 1.7 Exit Key
|
||||||
|
- Slash command `/exit` (aliases `/quit`, `/q`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Adapter Contract Decisions (`BaseAgentAdapter`)
|
||||||
|
|
||||||
|
| Property/Method | Value | Rationale |
|
||||||
|
|---|---|---|
|
||||||
|
| `name` | `'opencode'` | Lowercase identifier |
|
||||||
|
| `own_key` | `'opencode_session_id_own'` | Matches `<agent>_session_id_own` convention |
|
||||||
|
| `ready_tokens` | `'OpenCode\|Chat'` (candidate) | Pending live TUI capture verification (§4) |
|
||||||
|
| `exit_key` | `'/exit'` | Confirmed in CLI docs |
|
||||||
|
| `delegate_agent_key` | `'opencode-cli'` | Descriptive CLI identifier |
|
||||||
|
| `identity_cache_fields` | `('session_id',)` | Single-field cache |
|
||||||
|
| `spawn_spec(binary, session_uuid, use_wrapper)` | `f"{binary} --auto --agent build"` | Non-interactive build mode; ignores `session_uuid` at spawn |
|
||||||
|
| `resume_spec(binary, session_uuid, materialized)` | `f"{binary} --session {session_uuid} --auto --agent build"` (if materialized) | Passes `--session <id>` once discovered |
|
||||||
|
| `auth_ok(run_cmd)` | `os.path.exists(f"{ctx.home_dir}/.local/share/opencode/auth.json") or any(env)` | Multi-provider fallback + sandbox-safe path |
|
||||||
|
| `discover(ctx)` | Queries SQLite DB resolved via `opencode db path` / default root | Discovers new session IDs created since `ctx.epoch` |
|
||||||
|
| `verify_artifact(uuid, ctx)` | Checks row existence in SQLite `session` table | Validates session persistence |
|
||||||
|
| `purge_artifacts(uuid, ctx)` | Executes parameterized `DELETE FROM session/message/part WHERE session_id=?` | Cleans SQLite rows on stop `--purge-conversation` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Complete 29-Point Wiring & Lockstep Table
|
||||||
|
|
||||||
|
All 29 locations below must be updated in lockstep:
|
||||||
|
|
||||||
|
| # | Component / File | Specific Edit Required |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | `.agents/skills/lib_py/agents/adapters/opencode.py` | Create new adapter class `OpenCodeAgentAdapter` |
|
||||||
|
| 2 | `.agents/skills/lib_py/agents/registry.py` | Import `OpenCodeAgentAdapter` and register in `_ADAPTERS['opencode']` |
|
||||||
|
| 3 | `.agents/skills/lib.sh` (kind mapping, ~L606-609) | `*-creator-opencode\|*-planner-opencode\|*-reviewer-opencode) kind="opencode" ;;` |
|
||||||
|
| 4 | `.agents/skills/lib.sh` (fallback grep chain, ~L610-619) | Add `elif echo "$name" \| grep -qi "opencode"; then kind="opencode"` |
|
||||||
|
| 5 | `.agents/skills/lib.sh` (binary dedup tuple, ~L632) | Add `'opencode'` to `('claude', 'agy', 'hermes', 'grok', 'opencode')` |
|
||||||
|
| 6 | `create_session.sh` (line 93 `--agent` whitelist) | Add `opencode` to accepted values |
|
||||||
|
| 7 | `resume_session.sh` (line 45 `--agent` whitelist) | Add `opencode` to accepted values |
|
||||||
|
| 8 | `resolve_session_id.sh` (line 39 `--agent` whitelist) | Add `opencode` to accepted values |
|
||||||
|
| 9 | `update_yaml_resumed.sh` (usage string) | Add `opencode` to usage documentation |
|
||||||
|
| 10 | `stop_session.sh` (line 94 `--agent` whitelist) | Add `opencode` to accepted values |
|
||||||
|
| 11 | `orc_onboard.sh` (line 106 case) | Add `opencode` to accepted values |
|
||||||
|
| 12 | `tests/test_a4_adapter_contract.py` | Add `opencode` to `EXPECTED_OWN_KEYS`, `expected`, `expected_modal` (`None`), `facts_bridge`, and delegate bash snippets |
|
||||||
|
| 13 | `tests/test_a4_adapter_contract.py` | Add adapter-specific unit tests (`spawn_spec`, `resume_spec`, `auth_ok`, `purge_artifacts`, `discover`) |
|
||||||
|
| 14 | `tests/test_c1_tui_readiness.py` | Add `opencode` to facts-bridge and SKS session resolution loops |
|
||||||
|
| 15 | `tests/test_tier2_component.py` | Add OpenCode lifecycle tests (create → YAML → stop → resume) |
|
||||||
|
| 16 | `.mam.env.example` | Document OpenCode multi-provider API keys and `OPENCODE_PERMISSION` |
|
||||||
|
| 17 | `docs/NEW_AGENT_INTEGRATION_GUIDE.md` | Update difficulty matrix with OpenCode SQLite storage |
|
||||||
|
| 18 | `.agents/reports/new_agent_types_roadmap.md` | Add cross-link noting OpenCode details are superseded by this plan |
|
||||||
|
| 19 | `.agents/skills/lib_py/atomic_yaml.py:132` | Add `'opencode_session_id_own'` to `running_keys` |
|
||||||
|
| 20 | `.agents/skills/lib_py/verify_session.py:69` | Add `'opencode_session_id_own'` to `mam_row_own_uuid()` |
|
||||||
|
| 21 | `.agents/skills/lib_py/workspace_uuid.py:7-12, 33` | Add `'opencode'` to `OWN_KEY` dict and `running_ids` list |
|
||||||
|
| 22 | `reconcile.sh` (auto-reg & own keys) | Add `opencode` to loops (521, 540), entry initialization `elif` (584-601), and `OWN_KEY_BY_AGENT` (611) |
|
||||||
|
| 23 | `create_session.sh` (spawn dispatch, ~L204) | Add `opencode` to `agy\|hermes\|grok\|opencode)` case |
|
||||||
|
| 24 | `create_session.sh` (YAML init, ~L399-419) | Add `elif agent == 'opencode': entry['opencode_session_id_own'] = None` |
|
||||||
|
| 25 | `create_session.sh` (fallback CMD_FULL, ~L180-184) | Add `opencode) CMD_FULL="$BINARY --auto --agent build" ;;` |
|
||||||
|
| 26 | `lib.sh` (`send_keys_safe` agent resolution, ~L2058-2061) | Add `*-opencode\|*-opencode-[0-9]*\|opencode\|opencode-[0-9]*) _sks_agent="opencode" ;;` |
|
||||||
|
| 27 | `tests/test_tier1_unit.py` (~L969, 974) | Update expected CLI error usage assertions to include `opencode` |
|
||||||
|
| 28 | 8× `SKILL.md` supported-agent lists | Update skill documentation to list `opencode` |
|
||||||
|
| 29 | `tests/conftest.py` (`mock_agents`) | Add mock `opencode` binary fixture and sandbox state support |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. TUI Readiness & Empirical Verification
|
||||||
|
|
||||||
|
- Initial candidate pattern: `OpenCode|Chat`
|
||||||
|
- **Empirical Capture Gate**: Before finalizing the PR, capture rendered text from a live `opencode` TUI pane using `herdr pane capture` to confirm the exact ready token and prompt glyphs (`input_prompt`, `input_placeholder`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Drift Detection Integration & Dedicated Drift-C Block
|
||||||
|
|
||||||
|
In `multi-agent-mux-monitor/scripts/reconcile.sh`, add the dedicated OpenCode materialization block:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# === drift C (opencode): opencode 새 session id materialize (per-row own id) ===
|
||||||
|
for s in d.get('herdr_sessions', []):
|
||||||
|
if row_agent(s) != 'opencode':
|
||||||
|
continue
|
||||||
|
if s.get('status') != 'running':
|
||||||
|
continue
|
||||||
|
if s.get('opencode_session_id_own'):
|
||||||
|
continue
|
||||||
|
cwd = (s.get('pane') or {}).get('cwd', '')
|
||||||
|
if not cwd:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Resolve DB path via opencode db path or default XDG root
|
||||||
|
db = _resolve_opencode_db(home, cwd)
|
||||||
|
if not os.path.exists(db):
|
||||||
|
continue
|
||||||
|
|
||||||
|
epoch_threshold = s.get('herdr_session_epoch', 0)
|
||||||
|
sibling_claimed = [
|
||||||
|
other.get('opencode_session_id_own')
|
||||||
|
for other in d.get('herdr_sessions', [])
|
||||||
|
if other is not s
|
||||||
|
and (other.get('pane') or {}).get('cwd') == cwd
|
||||||
|
and other.get('status') not in ('stopped', 'terminated')
|
||||||
|
and other.get('opencode_session_id_own')
|
||||||
|
]
|
||||||
|
s_eval = dict(s)
|
||||||
|
s_eval['_sibling_claimed_uuids'] = sibling_claimed
|
||||||
|
|
||||||
|
valid_candidates = []
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(db)
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT id FROM session WHERE cwd=? AND created_at >= ? ORDER BY created_at DESC LIMIT 20",
|
||||||
|
(cwd, epoch_threshold)
|
||||||
|
).fetchall()
|
||||||
|
conn.close()
|
||||||
|
for (uuid,) in rows:
|
||||||
|
if uuid in (sibling_claimed or []):
|
||||||
|
continue
|
||||||
|
if verify_session_uuid(cwd, 'opencode', uuid, s_eval, mode="discover"):
|
||||||
|
valid_candidates.append(uuid)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if len(valid_candidates) > 1:
|
||||||
|
drifts.append({'class': 'C-ambiguous', 'name': s['name'],
|
||||||
|
'msg': f"{s['name']}: {len(valid_candidates)} candidate transcripts newer than session epoch; not pinning"})
|
||||||
|
s['last_visible_status'] = f"ambiguous: {len(valid_candidates)} candidates"
|
||||||
|
actions.append(f"ambiguous candidates: {s['name']}")
|
||||||
|
elif len(valid_candidates) == 1:
|
||||||
|
uuid = valid_candidates[0]
|
||||||
|
cmd = ['bash', '-c', f'source "{lib_sh}" && verify_tui_viewport "{s["name"]}" "opencode" "{cwd}"']
|
||||||
|
rc = subprocess.run(cmd).returncode
|
||||||
|
if rc == 0:
|
||||||
|
_pin_and_verify_resume(s, 'opencode', cwd, uuid, degraded=False)
|
||||||
|
elif rc == 1:
|
||||||
|
drifts.append({'class': 'C-warn', 'name': s['name'], 'msg': f"{s['name']}: TUI viewport mismatch for candidate {uuid} — not pinned, will retry next cycle"})
|
||||||
|
else:
|
||||||
|
_pin_and_verify_resume(s, 'opencode', cwd, uuid, degraded=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Definition of Done Checklist
|
||||||
|
|
||||||
|
- [ ] Execute `opencode db path` and SQLite `.schema` dump spike on a live install.
|
||||||
|
- [ ] Confirm TUI ready tokens and input prompt glyphs via empirical pane capture.
|
||||||
|
- [ ] Verify headless execution under `--auto` + `OPENCODE_PERMISSION='{"*":"allow"}'`.
|
||||||
|
- [ ] Implement `lib_py/agents/adapters/opencode.py` with sandbox-safe `ctx.home_dir`.
|
||||||
|
- [ ] Apply all 29 lockstep wiring points across `registry.py`, `lib.sh`, skill scripts, `lib_py` modules, and test fixtures.
|
||||||
|
- [ ] Add dedicated `reconcile.sh` Drift-C materialization block and corresponding integration test.
|
||||||
|
- [ ] Verify 100% test pass on full suite (`pytest tests/`).
|
||||||
|
- [ ] Perform manual end-to-end smoke test (create → prompt → discover → stop/purge).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Effort Estimate
|
||||||
|
|
||||||
|
- **Total Estimate**: **~1.5 – 2.0 Days**
|
||||||
|
- ½ day: SQLite schema spike (`opencode db path`) & TUI token live capture
|
||||||
|
- ½ day: Adapter implementation & 29-point mechanical wiring
|
||||||
|
- ½ day: Dedicated `reconcile.sh` Drift-C materialization block & integration tests
|
||||||
|
- ½ day: End-to-end manual smoke tests & regression verification
|
||||||
@@ -0,0 +1,454 @@
|
|||||||
|
# 🔌 Implementation Plan: Codex CLI (`openai/codex`) Agent Adapter for MAM (Rev.2)
|
||||||
|
|
||||||
|
- **Author**: `planner-reviewer-claude-01` (Planner), refined in response to `creator-agy-01`'s architectural challenge
|
||||||
|
- **Job**: `e30e3123` → Rev.2 (`94a6db78`)
|
||||||
|
- **Status**: Plan only — **no code changed in the repo**. Ready for review/refinement.
|
||||||
|
- **Scope**: Add `codex` as a 6th first-class MAM agent backend (alongside `claude`, `agy`, `hermes`, `grok`, `opencode`), per `docs/NEW_AGENT_INTEGRATION_GUIDE.md`'s 5-step procedure.
|
||||||
|
- **Durable Path**: `.agents/reports/planner-reviewer-claude-01/plan-e30e3123.md` (mirrored at `.agents/reports/implementation_plan.md`).
|
||||||
|
- **Method**: Every CLI fact is verified against a **live, authenticated Codex CLI install already present on this machine** (`codex-cli 0.153.4`). Rev.2 additionally re-verified both of `creator-agy-01`'s challenge points live before accepting them — see §0.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Rev.2 Changelog — Response to `creator-agy-01`'s Architectural Challenge (Job `004c224d`)
|
||||||
|
|
||||||
|
`creator-agy-01` filed a formal challenge against Rev.1 with **1 critical defect + 1 supporting architectural constraint**. Per this session's standing discipline, neither was accepted on the challenger's word alone — both were independently re-verified live on this machine before being incorporated. **Both are accepted — no `[REBUT:]` filed.**
|
||||||
|
|
||||||
|
| # | Challenge | My independent verification | Disposition |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | **Critical**: Rev.1's `orc_onboard.sh` detection regex (`resume[[:space:]]+[0-9a-f-]{36}`) cannot match the actual command Rev.1 itself specifies (`codex resume --dangerously-bypass-approvals-and-sandbox <uuid>`), because the flag sits between `resume` and the UUID | Ran the exact `cmd_line` from the challenge through Rev.1's regex myself: `echo "$cmd_line" \| grep -oE 'resume[[:space:]]+[0-9a-f-]{36}'` → **empty, exit 1**. Confirmed genuine — Rev.1 contained a self-contradiction between §2 (`resume_spec`) and §1.9/§3 item 27 (the detection regex). Then tested the challenge's proposed Plan A regex on the same input → **correctly extracts the UUID, exit 0**. | **Accepted.** §1.9 and wiring item #27 rewritten to the flag-tolerant regex (verified working, not just proposed) |
|
||||||
|
| 2 | **Supporting**: `~/.codex/state_N.sqlite` (a `threads` table indexing `id`/`cwd`/`rollout_path`) exists alongside the rollout JSONL files; Rev.1's "single-file format, no DB rows to delete" claim was only half true, and `purge_artifacts()` deleting only the `.jsonl` would leave an orphan DB row | `ls ~/.codex/*.sqlite*` on this machine → confirmed `state_5.sqlite` exists. `sqlite3 ~/.codex/state_5.sqlite ".schema threads"` → confirmed the exact `threads` table the challenge quoted. `sqlite3 ... "SELECT id,cwd,rollout_path FROM threads"` → confirmed 2 real rows exactly matching the 2 real rollout files found earlier. `strings` re-extraction confirmed the `stale_db_path`/`state db discrepancy`/`reconcile_rollout`/`read_repair_rollout_path: upsert_needed` literals the challenge cited. `codex delete --help` → confirmed `codex delete <SESSION> --force` ("Delete without prompting. SESSION must be a UUID") is real, exactly as the challenge's remediation option 1 proposed. | **Accepted**, with one refinement found during my own re-verification (not in the original challenge): `codex debug --help` has **no** `db path`-style introspection subcommand (unlike OpenCode's `opencode db path`), and the state file's numeric suffix (`state_5`) is unverified as stable across Codex versions/installs — hardcoding `state_5.sqlite` the way the challenge's raw example does would itself be fragile. §2's adapter design now resolves the state DB via a `state_*.sqlite` glob (most-recently-modified), and `purge_artifacts()` now shells out to `codex delete <uuid> --force` as the primary mechanism (letting Codex's own reconciliation logic keep the JSONL and DB consistent) with a manual-file-removal fallback only if that call fails |
|
||||||
|
|
||||||
|
Net effect: Rev.1's overall architecture and the 46-point wiring table stand — this challenge caught one genuine self-contradiction (the regex) and one genuine completeness gap (the state DB), both now closed. No other part of Rev.1 was found wrong during this re-verification pass.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Verified Codex CLI/Runtime Facts
|
||||||
|
|
||||||
|
Source: live `codex --help` / `codex resume --help` / `codex exec --help` / `codex login --help` / `codex delete --help` / `codex debug --help` / `codex doctor --summary` / `codex login status` output, direct `ls`/`find` of `~/.codex/`, live `sqlite3` schema/row inspection of `~/.codex/state_5.sqlite`, and offline `strings` extraction of the installed binary — all run on this machine.
|
||||||
|
|
||||||
|
### 1.1 Identity & Install
|
||||||
|
- Binary: `codex` (this machine: `/Users/godopu16/.local/bin/codex`, version `codex-cli 0.153.4`).
|
||||||
|
- Config/state root: `~/.codex/` by default, **overridable via the `CODEX_HOME` environment variable** (confirmed: `codex exec --ignore-user-config` help text explicitly says "auth still uses `CODEX_HOME`").
|
||||||
|
|
||||||
|
### 1.2 Execution Modes
|
||||||
|
- `codex [OPTIONS] [PROMPT]` — launches the **interactive TUI** (default, what MAM spawns).
|
||||||
|
- `codex exec [OPTIONS] [PROMPT]` (alias `e`) — **non-interactive** one-shot execution with `--json` JSONL event streaming and `-o/--output-last-message <FILE>`. Not used by MAM's spawn/resume flow, but confirms Codex has a scriptable mode.
|
||||||
|
- `codex resume [OPTIONS] [SESSION_ID] [PROMPT]` — resumes a specific session by **UUID or session name, passed positionally**; `--last` picks the most recent session without a picker.
|
||||||
|
- `codex delete <SESSION> --force` — permanently deletes a saved session by UUID; `--force` skips the confirmation prompt and requires `SESSION` to be a UUID (not a name). Verified via `codex delete --help`.
|
||||||
|
|
||||||
|
### 1.3 Relevant CLI Flags & Environment
|
||||||
|
| Flag / Variable | Purpose | Verified via |
|
||||||
|
|---|---|---|
|
||||||
|
| `--dangerously-bypass-approvals-and-sandbox` | Skip all confirmation prompts and run without sandboxing — the unattended-automation flag | `codex --help`, `codex resume --help` (accepted on both) |
|
||||||
|
| `-s, --sandbox <read-only\|workspace-write\|danger-full-access>` / `-a, --ask-for-approval <on-request\|never>` | Finer-grained alternative to the single bypass flag above | `codex --help` |
|
||||||
|
| `--approve-for-me` | Routes approvals through automatic review under `workspace-write` — **not** a full bypass | `codex --help` |
|
||||||
|
| `-C, --cd <DIR>` | Working-root directory for the agent | `codex --help` |
|
||||||
|
| `--session-id <uuid>` | **Does not exist.** No such flag appears anywhere in `codex --help`'s full option list | Absence confirmed by reading the complete `--help` output |
|
||||||
|
| `CODEX_HOME` | Overrides `~/.codex` config/session root | `codex exec --ignore-user-config` help text |
|
||||||
|
| `OPENAI_API_KEY` | Read by `codex login --with-api-key` (via stdin); also stored inside `auth.json` under the `OPENAI_API_KEY` key once logged in | `codex login --help`, `~/.codex/auth.json` key inspection |
|
||||||
|
|
||||||
|
### 1.4 Unattended-Execution Decision
|
||||||
|
Confirmed present. `--dangerously-bypass-approvals-and-sandbox` is accepted by both the top-level `codex` TUI launcher and `codex resume`, so `spawn_spec()`/`resume_spec()` can pass it unconditionally.
|
||||||
|
|
||||||
|
### 1.5 Session & Artifact Storage — Dual Layer (Rev.2: corrected)
|
||||||
|
- **Primary transcript**: JSONL "rollout" files, one per session, at `~/.codex/sessions/<YYYY>/<MM>/<DD>/rollout-<timestamp>-<uuid>.jsonl` — e.g. `~/.codex/sessions/2026/05/23/rollout-2026-05-23T08-23-56-019e5200-ab6a-70f2-b5da-e1ef403f7a40.jsonl`. First line is a `session_meta` record: `{"payload": {"id": "<uuid>", "cwd": "<path>", ...}}`.
|
||||||
|
- **Secondary index (Rev.2, per the accepted challenge)**: `~/.codex/state_N.sqlite` (this machine: `state_5.sqlite`; **the numeric suffix is not assumed stable** — no `codex debug`/`codex` subcommand introspects its exact filename, unlike OpenCode's `opencode db path`, so the adapter must resolve it via a `state_*.sqlite` glob rather than hardcoding the suffix). Verified live schema: a `threads` table with `id TEXT PRIMARY KEY`, `rollout_path TEXT`, `cwd TEXT`, `created_at`/`updated_at` (plus many version-added trailing columns — the schema has clearly evolved via in-place `ALTER TABLE` across releases, another reason not to hand-roll fragile column-position assumptions). Verified live rows: both real sessions on this machine are indexed here with `id`/`cwd`/`rollout_path` exactly matching their on-disk `.jsonl` files.
|
||||||
|
- **Codex's own resolution order** (inferred from binary string literals `state db discrepancy during find_thread_path_by_id_str_in_subdir: stale_db_path / mismatched_db_path / falling_back` and `reconcile_rollout` / `read_repair_rollout_path: upsert_needed`): Codex itself queries the state DB first, and has a **self-healing reconciliation path** for when the DB and filesystem disagree (falls back to a filesystem scan, then repairs the DB entry) — this is Codex's own built-in drift-correction, structurally analogous to MAM's own `reconcile.sh`.
|
||||||
|
- **Architectural consequence for MAM's adapter**: mirror Codex's own resolution order — **query `state_*.sqlite` first** (fast, indexed, avoids any unbounded directory walk), **fall back to the bounded glob** over `sessions/*/*/*/rollout-*.jsonl` if the state DB is missing, unreadable, or the row is stale/absent (mirroring the exact "primary DB, fallback filesystem walk" shape `opencode.py`'s `_resolve_db()` already uses in this codebase, and mirroring what Codex's own binary does internally).
|
||||||
|
|
||||||
|
### 1.6 TUI Readiness (candidate, pending empirical capture)
|
||||||
|
Offline `strings` extraction surfaced real UI literals: `"Ask Codex to do anything"` (composer placeholder) and the banner token `"Codex"`. No `❯` glyph or `─{10,}` rule literal found — different TUI layout family (boxed/panelled, not readline-style). Proposed candidate:
|
||||||
|
- `ready_tokens`: `'Codex|Ask Codex to do anything'`
|
||||||
|
- `input_placeholder`: `'Ask Codex to do anything'` (high-confidence — extracted literal)
|
||||||
|
- `input_prompt` / `input_rule_pattern`: `None` (base-class default) pending live pane capture.
|
||||||
|
|
||||||
|
**Hard gate before merge**: capture a real `codex` TUI pane via `herdr pane capture` to confirm exact rendered text.
|
||||||
|
|
||||||
|
### 1.7 Exit Convention (verified deviation from every existing adapter)
|
||||||
|
Binary string table twice describes exiting as a keypress: `"Ctrl+C to exit"`, `"ctrl+c quit"`. No `/exit`/`/quit`/`:q` literal found (real slash commands that do exist: `/keymap`, `/side`, `/review`, `/ide`, `/goal`).
|
||||||
|
|
||||||
|
`MAM_EXIT_KEY` is exported by the `facts` bridge and loaded into `send_keys_safe()`'s local scope (`lib.sh:2166`), but **no skill script actually sends it to a pane today, for any agent** — `stop_session.sh` never references `exit_key`/`MAM_EXIT_KEY`. So this remains a required-but-currently-inert contract field. Propose `exit_key = 'Ctrl+C'` (symbolic key name, matching herdr's existing `Escape` send convention), documented as a deliberate divergence.
|
||||||
|
|
||||||
|
### 1.8 Authentication
|
||||||
|
- Interactive: `codex login` (ChatGPT OAuth) or `codex login --with-api-key` (reads `OPENAI_API_KEY` from stdin).
|
||||||
|
- Credential file: `~/.codex/auth.json` (or `$CODEX_HOME/auth.json`) — verified present, keys `auth_mode`, `OPENAI_API_KEY`, `tokens`, `last_refresh`. `codex login status` confirms `"Logged in using ChatGPT"`; `codex doctor --summary` independently confirms `✓ auth is configured`.
|
||||||
|
- `auth_ok()`: `os.path.exists(f"{codex_home}/auth.json")` **or** `os.environ.get('OPENAI_API_KEY')`. **Caveat honestly flagged**: whether a bare env var alone (without ever running `codex login --with-api-key`) is sufficient was not empirically tested (this machine is logged in via ChatGPT, not a raw API key).
|
||||||
|
|
||||||
|
### 1.9 Session-ID Detection for `orc_onboard.sh` (Rev.2: regex defect fixed)
|
||||||
|
1. **argv form**: Codex passes the UUID as `codex resume <uuid>` — positional, no flag name. **Rev.1's regex was defective**: `grep -oE 'resume[[:space:]]+[0-9a-f-]{36}'` requires the UUID immediately after `resume`, but the real command line this plan's own `resume_spec`/`CMD_FULL` produce is `codex resume --dangerously-bypass-approvals-and-sandbox <uuid>` — a flag sits in between, so the regex never matches (verified live: exit 1, empty output). **Corrected regex** (challenge's Plan A, live-verified to work): allow arbitrary tokens between `resume` and the UUID, then extract only the UUID:
|
||||||
|
```bash
|
||||||
|
codex)
|
||||||
|
detected_id=$(echo "$cmd_line" | grep -oE 'resume[[:space:]]+.*[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}' | grep -oE '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}' | head -n 1 || true)
|
||||||
|
;;
|
||||||
|
```
|
||||||
|
Already safe under `orc_onboard.sh:135`'s existing `is_valid_id "$detected_id"` UUID-format re-validation, so a slightly looser extraction regex carries no correctness risk.
|
||||||
|
2. **env var fallback**: no `CODEX_SESSION_ID`-style environment variable found anywhere in `codex --help`. Leave `env_var=""` for `codex` — do not fabricate one.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Adapter Contract Decisions (`BaseAgentAdapter`) — Rev.2
|
||||||
|
|
||||||
|
| Property/Method | Value | Rationale |
|
||||||
|
|---|---|---|
|
||||||
|
| `name` | `'codex'` | Lowercase identifier |
|
||||||
|
| `own_key` | `'codex_session_id_own'` | Matches `<agent>_session_id_own` convention |
|
||||||
|
| `ready_tokens` | `'Codex\|Ask Codex to do anything'` (candidate, §1.6) | Extracted real UI literal, pending live pane-capture confirmation |
|
||||||
|
| `exit_key` | `'Ctrl+C'` (§1.7) | No typed slash command exists; currently operationally inert framework-wide |
|
||||||
|
| `delegate_agent_key` | `'codex-cli'` | Matches the `<name>-cli`/`-agent`/`-build`/`-code` suffix convention |
|
||||||
|
| `identity_cache_fields` | `('session_id',)` | Single-field cache, modern pattern |
|
||||||
|
| `input_placeholder` | `'Ask Codex to do anything'` | Verified real literal |
|
||||||
|
| `input_prompt` / `input_rule_pattern` | `None` | No stable glyph found; different TUI layout family |
|
||||||
|
| `spawn_spec(binary, session_uuid, use_wrapper)` | `f"{binary} --dangerously-bypass-approvals-and-sandbox"` (ignores `session_uuid` — Class A) | §1.3/§1.4 |
|
||||||
|
| `resume_spec(binary, session_uuid, materialized)` | if `materialized and session_uuid`: `f"{binary} resume --dangerously-bypass-approvals-and-sandbox {session_uuid}"`, else same as `spawn_spec` | Verified `codex resume [OPTIONS] [SESSION_ID]` usage template |
|
||||||
|
| `auth_ok(run_cmd)` | `os.path.exists(f"{codex_home}/auth.json") or bool(os.environ.get('OPENAI_API_KEY'))` | §1.8, sandbox-safe via `ctx.home_dir` |
|
||||||
|
| `artifact_path(uuid, ctx)` | **Rev.2**: query `state_*.sqlite`'s `threads.rollout_path WHERE id=?` first; fall back to the bounded glob `sessions/*/*/*/rollout-*-{uuid}.jsonl` if the DB lookup misses | §1.5 — mirrors Codex's own DB-first, filesystem-fallback resolution order |
|
||||||
|
| `verify_artifact(uuid, ctx)` | Resolve path via the same DB-first/glob-fallback helper; parse first JSONL line's `payload.id`/`payload.cwd` (or use the DB row's `cwd` column directly, skipping the file read, when the DB hit succeeds); apply `ctx.epoch` mtime check | Rev.2: DB row already has `cwd` — avoids opening the file at all on the common (DB-hit) path |
|
||||||
|
| `purge_artifacts(uuid, ctx)` | **Rev.2**: primary — `subprocess.run([binary_or_'codex', 'delete', uuid, '--force'], ...)`; fallback (only if that call fails/binary unavailable) — manually `os.remove()` the rollout file located via the DB-first/glob-fallback helper, **and** best-effort `DELETE FROM threads WHERE id=?` against the resolved `state_*.sqlite` if it exists | §0 item 2 — avoids orphan DB rows; lets Codex's own reconciliation logic own consistency whenever possible instead of MAM hand-rolling SQL against an evolving schema |
|
||||||
|
| `discover(ctx)` | **Rev.2**: query `state_*.sqlite`'s `threads` table (`SELECT id, cwd, updated_at FROM threads ORDER BY updated_at DESC LIMIT 50`, filtered by `cwd`/`epoch` in Python) first; fall back to the recursive-glob approach from Rev.1 only if the DB is absent/unreadable | Rev.2 — DB query is a single indexed lookup vs. an N-file glob-and-open walk; also naturally bounded (no unbounded-growth concern for the DB path) |
|
||||||
|
|
||||||
|
### Adapter code sketch (`.agents/skills/lib_py/agents/adapters/codex.py`) — Rev.2
|
||||||
|
|
||||||
|
```python
|
||||||
|
import os, json, glob, sqlite3, subprocess
|
||||||
|
from typing import Optional, Any, List
|
||||||
|
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext
|
||||||
|
from lib_py.verify_session import workspace_key
|
||||||
|
|
||||||
|
class CodexAgentAdapter(BaseAgentAdapter):
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
return 'codex'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def own_key(self) -> str:
|
||||||
|
return 'codex_session_id_own'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ready_tokens(self) -> str:
|
||||||
|
return 'Codex|Ask Codex to do anything'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def exit_key(self) -> str:
|
||||||
|
return 'Ctrl+C'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def delegate_agent_key(self) -> str:
|
||||||
|
return 'codex-cli'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def identity_cache_fields(self) -> tuple:
|
||||||
|
return ('session_id',)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def input_placeholder(self) -> Optional[str]:
|
||||||
|
return 'Ask Codex to do anything'
|
||||||
|
|
||||||
|
def _codex_home(self, ctx: DiscoveryContext) -> str:
|
||||||
|
return os.environ.get('CODEX_HOME') or f"{ctx.home_dir}/.codex"
|
||||||
|
|
||||||
|
def _state_db(self, ctx: DiscoveryContext) -> Optional[str]:
|
||||||
|
# Rev.2: no 'codex db path'-equivalent subcommand exists; the numeric
|
||||||
|
# suffix (state_5.sqlite on this machine) is unverified as stable
|
||||||
|
# across versions, so resolve dynamically rather than hardcoding it.
|
||||||
|
matches = glob.glob(f"{self._codex_home(ctx)}/state_*.sqlite")
|
||||||
|
if not matches:
|
||||||
|
return None
|
||||||
|
return max(matches, key=os.path.getmtime)
|
||||||
|
|
||||||
|
def _find_rollout_via_db(self, uuid: str, ctx: DiscoveryContext):
|
||||||
|
"""Returns (rollout_path, cwd) from the state DB, or (None, None)."""
|
||||||
|
db = self._state_db(ctx)
|
||||||
|
if not db or not os.path.exists(db):
|
||||||
|
return None, None
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(db)
|
||||||
|
row = conn.execute("SELECT rollout_path, cwd FROM threads WHERE id=?", (uuid,)).fetchone()
|
||||||
|
conn.close()
|
||||||
|
if row and row[0] and os.path.exists(row[0]):
|
||||||
|
return row[0], row[1]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
def _find_rollout(self, uuid: str, ctx: DiscoveryContext) -> Optional[str]:
|
||||||
|
path, _ = self._find_rollout_via_db(uuid, ctx)
|
||||||
|
if path:
|
||||||
|
return path
|
||||||
|
# Fallback: bounded glob over the date-nested tree (state DB missing/stale)
|
||||||
|
matches = glob.glob(f"{self._codex_home(ctx)}/sessions/*/*/*/rollout-*-{uuid}.jsonl")
|
||||||
|
return matches[0] if matches else None
|
||||||
|
|
||||||
|
def artifact_path(self, uuid: str, ctx: DiscoveryContext) -> str:
|
||||||
|
return self._find_rollout(uuid, ctx) or f"{self._codex_home(ctx)}/sessions/rollout-{uuid}.jsonl"
|
||||||
|
|
||||||
|
def verify_artifact(self, uuid: str, ctx: DiscoveryContext) -> bool:
|
||||||
|
db_path, db_cwd = self._find_rollout_via_db(uuid, ctx)
|
||||||
|
path = db_path or self._find_rollout(uuid, ctx)
|
||||||
|
if not path or not os.path.exists(path):
|
||||||
|
return False
|
||||||
|
if ctx.epoch and os.path.getmtime(path) < ctx.epoch:
|
||||||
|
return False
|
||||||
|
found_cwd = db_cwd
|
||||||
|
if not found_cwd:
|
||||||
|
try:
|
||||||
|
with open(path) as f:
|
||||||
|
first = f.readline()
|
||||||
|
found_cwd = json.loads(first).get('payload', {}).get('cwd')
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
if found_cwd and workspace_key(found_cwd) != workspace_key(ctx.cwd):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def purge_artifacts(self, uuid: str, ctx: DiscoveryContext) -> List[str]:
|
||||||
|
purged = []
|
||||||
|
path = self._find_rollout(uuid, ctx)
|
||||||
|
# Rev.2: prefer the CLI's own delete path (keeps rollout file + state
|
||||||
|
# DB row consistent via Codex's own reconciliation logic).
|
||||||
|
try:
|
||||||
|
res = subprocess.run(['codex', 'delete', uuid, '--force'],
|
||||||
|
capture_output=True, text=True, timeout=5)
|
||||||
|
if res.returncode == 0:
|
||||||
|
if path:
|
||||||
|
purged.append(path)
|
||||||
|
return purged
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Fallback: manual removal of the file, plus a best-effort DB row delete.
|
||||||
|
if path and os.path.exists(path):
|
||||||
|
os.remove(path)
|
||||||
|
purged.append(path)
|
||||||
|
db = self._state_db(ctx)
|
||||||
|
if db and os.path.exists(db):
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(db)
|
||||||
|
conn.execute("DELETE FROM threads WHERE id=?", (uuid,))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
purged.append(f"state db row for thread: {uuid}")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return purged
|
||||||
|
|
||||||
|
def spawn_spec(self, binary: str, session_uuid: str = "", use_wrapper: bool = False) -> str:
|
||||||
|
return f"{binary} --dangerously-bypass-approvals-and-sandbox"
|
||||||
|
|
||||||
|
def resume_spec(self, binary: str, session_uuid: str, materialized: bool = False) -> str:
|
||||||
|
if materialized and session_uuid:
|
||||||
|
return f"{binary} resume --dangerously-bypass-approvals-and-sandbox {session_uuid}"
|
||||||
|
return f"{binary} --dangerously-bypass-approvals-and-sandbox"
|
||||||
|
|
||||||
|
def auth_ok(self, run_cmd: Optional[Any] = None) -> bool:
|
||||||
|
home = os.environ.get('HOME_DIR') or os.environ.get('HOME') or os.path.expanduser('~')
|
||||||
|
codex_home = os.environ.get('CODEX_HOME') or f"{home}/.codex"
|
||||||
|
return os.path.exists(f"{codex_home}/auth.json") or bool(os.environ.get('OPENAI_API_KEY'))
|
||||||
|
|
||||||
|
def discover(self, ctx: DiscoveryContext) -> List[str]:
|
||||||
|
db = self._state_db(ctx)
|
||||||
|
if db and os.path.exists(db):
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(db)
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT id, cwd, updated_at FROM threads ORDER BY updated_at DESC LIMIT 50"
|
||||||
|
).fetchall()
|
||||||
|
conn.close()
|
||||||
|
candidates = []
|
||||||
|
for uuid, cwd, _ in rows:
|
||||||
|
if ctx.workspace and cwd and workspace_key(cwd) != workspace_key(ctx.workspace):
|
||||||
|
continue
|
||||||
|
if uuid and self.verify_artifact(uuid, ctx):
|
||||||
|
candidates.append(uuid)
|
||||||
|
return candidates
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Fallback: recursive glob (state DB absent/unreadable)
|
||||||
|
root = self._codex_home(ctx)
|
||||||
|
candidates = []
|
||||||
|
paths = sorted(glob.glob(f"{root}/sessions/*/*/*/rollout-*.jsonl"), key=os.path.getmtime, reverse=True)[:50]
|
||||||
|
for path in paths:
|
||||||
|
uuid = os.path.basename(path)[:-6][-36:]
|
||||||
|
if uuid and self.verify_artifact(uuid, ctx):
|
||||||
|
candidates.append(uuid)
|
||||||
|
return candidates
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Full Lockstep Wiring Table (Rev.2)
|
||||||
|
|
||||||
|
Same 46-item table as Rev.1, with item #27 corrected per §0/§1.9. Re-derived from the current, live state of every file that mentions `opencode` today (33 files, `.agents/reports/` excluded).
|
||||||
|
|
||||||
|
| # | Component / File | Specific Edit Required |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | `.agents/skills/lib_py/agents/adapters/codex.py` | Create new adapter class `CodexAgentAdapter` per §2 (Rev.2: DB-first artifact resolution + `codex delete --force`-based purge) |
|
||||||
|
| 2 | `.agents/skills/lib_py/agents/registry.py:9,16` | Import `CodexAgentAdapter`; add `'codex': CodexAgentAdapter()` to `_ADAPTERS` |
|
||||||
|
| 3 | `.agents/skills/lib_py/atomic_yaml.py:132` | Add `'codex_session_id_own'` to `running_keys` |
|
||||||
|
| 4 | `.agents/skills/lib_py/verify_session.py:69` | Add `'codex_session_id_own'` to the `mam_row_own_uuid()` field list |
|
||||||
|
| 5 | `.agents/skills/lib_py/workspace_uuid.py:7-12,34` | Add `'codex': 'codex_session_id_own'` to `OWN_KEY`; add `'codex_session_id_own'` to the `running_ids` field list |
|
||||||
|
| 6 | `.agents/skills/lib.sh:669` (kind mapping) | Add `*-creator-codex\|*-planner-codex\|*-reviewer-codex) kind="codex" ;;` |
|
||||||
|
| 7 | `.agents/skills/lib.sh:675,680` (fallback grep chain, name + `final_cmd`) | Add `elif echo "$name" \| grep -qi "codex"; then kind="codex"` and the `final_cmd` equivalent |
|
||||||
|
| 8 | `.agents/skills/lib.sh:694` (binary dedup tuple) | Add `'codex'` to `('claude', 'agy', 'hermes', 'grok', 'opencode', 'codex')` |
|
||||||
|
| 9 | `.agents/skills/lib.sh:2175` (`send_keys_safe` agent resolution) | Add `*-codex\|*-codex-[0-9]*\|codex\|codex-[0-9]*) _sks_agent="codex" ;;` |
|
||||||
|
| 10 | `create_session.sh:4,29,33` (usage/help text) | Add `codex` to the `<claude\|agy\|hermes\|grok\|opencode>` listings |
|
||||||
|
| 11 | `create_session.sh:93-94` (`--agent` whitelist) | Add `codex` to the accepted-values case and the error message |
|
||||||
|
| 12 | `create_session.sh` (auth preflight, new `elif` block after the existing `opencode` one at line 129) | `elif [ "$AGENT" = "codex" ]; then` check `~/.codex/auth.json` (respecting `CODEX_HOME`) or `OPENAI_API_KEY`, else `codex --version` smoke check |
|
||||||
|
| 13 | `create_session.sh:192` (fallback `CMD_FULL` case) | Add `codex) CMD_FULL="${RESOLVED_BIN} --dangerously-bypass-approvals-and-sandbox" ;;` |
|
||||||
|
| 14 | `create_session.sh:216` (`spawn()` dispatch case) | Add `codex` to the `agy\|hermes\|grok\|opencode)` case — no special env-var export needed |
|
||||||
|
| 15 | `create_session.sh:330` (delegate-agent key resolution) | Add `codex) delegate_agent="codex-cli" ;;` |
|
||||||
|
| 16 | `create_session.sh:438` (YAML init Python block) | Add `elif agent == 'codex': entry['codex_session_id_own'] = None; entry['session_id_source'] = 'pending-discovery'; entry['session_id_verified'] = False; entry['last_visible_status'] = "unverified"` |
|
||||||
|
| 17 | `resolve_session_id.sh:4,16,39-40` | Add `codex` to usage text and the `--agent` whitelist case |
|
||||||
|
| 18 | `resume_session.sh:12,45` | Add `codex` to usage text and the `--agent` whitelist case |
|
||||||
|
| 19 | `resume_session.sh:65` (FRESH_SPAWN Class-A case) | Add `codex` to `agy\|hermes\|opencode) FRESH_SPAWN=1 ;;` — Codex is Class A |
|
||||||
|
| 20 | `resume_session.sh:121` (fallback spawn-spec case) | Add `codex) CMD_FULL="${RESOLVED_BIN} --dangerously-bypass-approvals-and-sandbox" ;;` |
|
||||||
|
| 21 | `resume_session.sh:132` (fallback resume-spec case) | Add `codex) CMD_FULL="${RESOLVED_BIN} resume --dangerously-bypass-approvals-and-sandbox $UUID" ;;` |
|
||||||
|
| 22 | `update_yaml_resumed.sh:7,14,17` | Add `codex` to usage docs and the Class-A comment listing |
|
||||||
|
| 23 | `update_yaml_resumed.sh:228` (Python block) | Add `elif agent == 'codex': target['pane']['cmd'] = 'codex'`; if `uuid`: `cmd_full = f'codex resume --dangerously-bypass-approvals-and-sandbox {uuid}'`, `target['codex_session_id_own'] = uuid`; else `cmd_full = os.environ.get('CMD_FULL_OPT', '') or 'codex --dangerously-bypass-approvals-and-sandbox'` |
|
||||||
|
| 24 | `stop_session.sh:4,15,94-95` | Add `codex` to usage docs and the `--agent` whitelist case + error message |
|
||||||
|
| 25 | `stop_session.sh:290` (Python capture block) | Add `elif agent == 'codex': target['codex_session_id_own'] = captured` |
|
||||||
|
| 26 | `orc_onboard.sh:106` (agent-family match) | Add `codex` to the `claude\|agy\|hermes\|grok\|opencode)` case |
|
||||||
|
| 27 | `orc_onboard.sh:117-133` (CLI-argv detection) | **Rev.2 corrected**: new `codex)` branch using the flag-tolerant regex from §1.9 (`resume[[:space:]]+.*<uuid-pattern>` then extract just the UUID) — **not** the Rev.1 regex, which is confirmed broken against this plan's own `resume_spec` output |
|
||||||
|
| 28 | `orc_onboard.sh:142-148` (env-var fallback) | Add `codex) env_var=""` explicitly (no known env var — do not fabricate one) |
|
||||||
|
| 29 | `reconcile.sh:523,542` (agent-name tuples) | Add `'codex'` to both `('claude', 'agy', 'hermes', 'grok', 'opencode')` tuples |
|
||||||
|
| 30 | `reconcile.sh:603` (fresh-row entry init) | Add `elif agent == 'codex': entry['codex_session_id_own'] = None` |
|
||||||
|
| 31 | `reconcile.sh:616` (`OWN_KEY_BY_AGENT`) | Add `'codex'` to the tuple |
|
||||||
|
| 32 | `reconcile.sh` (new dedicated block, after the existing opencode block at line ~787-897) | New `# === drift C (codex) ===` block, **Rev.2**: state-DB-first with glob fallback (see §4) |
|
||||||
|
| 33 | 8× `SKILL.md` supported-agent lists (`multi-agent-mux-create:132,163-166`; `multi-agent-mux-orc-onboard:11,35,41`; `multi-agent-mux-resume:52,55,61,73,99,110`; `multi-agent-mux-stop:40`) | Add `codex` everywhere `opencode` currently appears in these four files; the other 4 `SKILL.md` files need only the version-frontmatter bump (§6) |
|
||||||
|
| 34 | `.agents/skills/multi-agent-mux-delegate-job/job-protocol.md:3`, `README.md:3` | No change needed — `codex` already listed informally |
|
||||||
|
| 35 | `tests/conftest.py` | Add a `codex` mock binary fixture (mirroring `opencode_bin`, line ~972-990) that writes a fake `rollout-<ts>-<uuid>.jsonl` under a temp `$CODEX_HOME/sessions/<Y>/<M>/<D>/` **and** a matching `state_1.sqlite` `threads` row (Rev.2: needed since `discover()`/`verify_artifact()` now query the DB first); add ready-banner/own-key dict entries (lines ~553, ~584) |
|
||||||
|
| 36 | `tests/test_a4_adapter_contract.py` | Add `codex` to `EXPECTED_OWN_KEYS`, the property tuple, `expected_modal` (`None`), the `facts_bridge` loop (line ~112), a `_seed_codex_session()` helper (mirroring `_seed_opencode_session`, line ~132, **Rev.2**: seeding both the `.jsonl` and the `state_*.sqlite` row), and unit tests for `spawn_spec`/`resume_spec`/`auth_ok`/`purge_artifacts` (**Rev.2**: including a test that `purge_artifacts` falls back to manual removal when the `codex delete` subprocess call fails)/`discover` |
|
||||||
|
| 37 | `tests/test_c1_tui_readiness.py` | Add `codex` to the facts-bridge and `send_keys_safe` agent-resolution loops (line ~491) and a `check_sks` assertion pair (mirroring lines 528-529, 539-540) |
|
||||||
|
| 38 | `tests/test_tier1_unit.py` | Update the agent-tuple assertions (lines ~963, 969, 974, 988-989, 994, 1033) to include `codex`; add `test_codex_shell_and_scripts_integration` mirroring `test_opencode_shell_and_scripts_integration` (lines 982-1044); **Rev.2**: add a dedicated regression test asserting `orc_onboard.sh`'s `codex` regex correctly extracts the UUID from `codex resume --dangerously-bypass-approvals-and-sandbox <uuid>` — this is the exact scenario Rev.1 got wrong, so it must be locked down by a test, not just fixed in prose |
|
||||||
|
| 39 | `tests/test_tier2_component.py` | Add `test_comp_codex_create_and_reconcile_and_stop` mirroring `test_comp_opencode_create_and_reconcile_and_stop` (line ~1229) |
|
||||||
|
| 40 | `.mam.env.example` (after the existing OpenCode block, line ~110) | Document `OPENAI_API_KEY` and `CODEX_HOME` as an optional override |
|
||||||
|
| 41 | `docs/NEW_AGENT_INTEGRATION_GUIDE.md` §3 (difficulty matrix) | Update the `Codex CLI` row from the old guessed values to the verified ones from §1 |
|
||||||
|
| 42 | `.agents/reports/new_agent_types_roadmap.md` §4 | Add a cross-link noting the Codex row is superseded by this plan |
|
||||||
|
| 43 | `.agents/MULTI_AGENT_RULES.md:21` / `.ko.md:21` | Add `codex` to the Team-Leader agent-name parenthetical, scope confined to that one line each |
|
||||||
|
| 44 | `README.md:3,17,45,136` / `README.ko.md:3,17,27,118` | Add `codex` to the 5-agent → 6-agent listings, incl. a `codex resume <uuid>` example (positional form, §1.9) |
|
||||||
|
| 45 | `BOOTSTRAP.md:197` / `.ko.md:196` | Add `codex` to the onboarding agent-name parenthetical |
|
||||||
|
| 46 | `deploy/INSTALL.md:3` | Add `Codex` to the parenthetical agent list |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. `reconcile.sh` Drift-C Block for Codex (Rev.2: state-DB-first)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# === drift C (codex): codex 새 session id materialize (per-row own id) ===
|
||||||
|
for s in d.get('herdr_sessions', []):
|
||||||
|
if row_agent(s) != 'codex':
|
||||||
|
continue
|
||||||
|
if s.get('status') != 'running':
|
||||||
|
continue
|
||||||
|
if s.get('codex_session_id_own'):
|
||||||
|
continue
|
||||||
|
cwd = (s.get('pane') or {}).get('cwd', '')
|
||||||
|
if not cwd:
|
||||||
|
continue
|
||||||
|
|
||||||
|
codex_home = os.environ.get('CODEX_HOME') or f"{home}/.codex"
|
||||||
|
epoch_threshold = s.get('herdr_session_epoch', 0)
|
||||||
|
|
||||||
|
sibling_claimed = [
|
||||||
|
other.get('codex_session_id_own')
|
||||||
|
for other in d.get('herdr_sessions', [])
|
||||||
|
if other is not s
|
||||||
|
and (other.get('pane') or {}).get('cwd') == cwd
|
||||||
|
and other.get('status') not in ('stopped', 'terminated')
|
||||||
|
and other.get('codex_session_id_own')
|
||||||
|
]
|
||||||
|
s_eval = dict(s)
|
||||||
|
s_eval['_sibling_claimed_uuids'] = sibling_claimed
|
||||||
|
|
||||||
|
valid_candidates = []
|
||||||
|
try:
|
||||||
|
# Rev.2: state DB first (indexed, single query) — dynamic filename,
|
||||||
|
# no hardcoded suffix (see §1.5 / §2 _state_db()).
|
||||||
|
state_matches = glob.glob(f"{codex_home}/state_*.sqlite")
|
||||||
|
state_db = max(state_matches, key=os.path.getmtime) if state_matches else None
|
||||||
|
rows = []
|
||||||
|
if state_db and os.path.exists(state_db):
|
||||||
|
conn = sqlite3.connect(state_db)
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT id, updated_at FROM threads WHERE cwd=? ORDER BY updated_at DESC LIMIT 20",
|
||||||
|
(cwd,)
|
||||||
|
).fetchall()
|
||||||
|
conn.close()
|
||||||
|
if not rows:
|
||||||
|
# Fallback: bounded glob over the date-nested tree
|
||||||
|
paths = sorted(
|
||||||
|
glob.glob(f"{codex_home}/sessions/*/*/*/rollout-*.jsonl"),
|
||||||
|
key=os.path.getmtime, reverse=True
|
||||||
|
)[:50]
|
||||||
|
rows = [(os.path.basename(p)[:-6][-36:], os.path.getmtime(p)) for p in paths]
|
||||||
|
|
||||||
|
for uuid, ts in rows:
|
||||||
|
if epoch_threshold and ts and ts < epoch_threshold:
|
||||||
|
continue
|
||||||
|
if uuid in (sibling_claimed or []):
|
||||||
|
continue
|
||||||
|
if verify_session_uuid(cwd, 'codex', uuid, s_eval, mode="discover"):
|
||||||
|
valid_candidates.append(uuid)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if len(valid_candidates) > 1:
|
||||||
|
drifts.append({'class': 'C-ambiguous', 'name': s['name'],
|
||||||
|
'msg': f"{s['name']}: {len(valid_candidates)} candidate transcripts newer than session epoch; not pinning"})
|
||||||
|
s['last_visible_status'] = f"ambiguous: {len(valid_candidates)} candidates"
|
||||||
|
actions.append(f"ambiguous candidates: {s['name']}")
|
||||||
|
elif len(valid_candidates) == 1:
|
||||||
|
uuid = valid_candidates[0]
|
||||||
|
cmd = ['bash', '-c', f'source "{lib_sh}" && verify_tui_viewport "{s["name"]}" "codex" "{cwd}"']
|
||||||
|
rc = subprocess.run(cmd).returncode
|
||||||
|
if rc == 0:
|
||||||
|
_pin_and_verify_resume(s, 'codex', cwd, uuid, degraded=False)
|
||||||
|
elif rc == 1:
|
||||||
|
drifts.append({'class': 'C-warn', 'name': s['name'], 'msg': f"{s['name']}: TUI viewport mismatch for candidate {uuid} — not pinned, will retry next cycle"})
|
||||||
|
else:
|
||||||
|
_pin_and_verify_resume(s, 'codex', cwd, uuid, degraded=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
`verify_session_uuid()` (shared, `lib_py/verify_session.py`) needs a `codex_session_id_own` entry added to its own-key list (wiring item #4) for the `C0`-class "confirm, don't discover" path.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. `status.sh`
|
||||||
|
|
||||||
|
Both `resume_on_disk()`-style helpers (lines ~56-61, ~234-239) iterate `('claude', 'agy', 'hermes', 'grok', 'opencode')`. Add `codex` to both tuples and a new `if agent == 'codex':` branch resolving via the same DB-first/glob-fallback helper as the adapter (§2), returning `'yes'`/`'MISSING'`/`'no'`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Version & Documentation Bump
|
||||||
|
|
||||||
|
Current: `MAM_VERSION="4.1.3"`. Propose **`4.1.3 → 4.2.0` (MINOR)**, matching the OpenCode precedent, in strict 3-way lockstep (`lib.sh:32`, `VERSIONS.md`, all 8 `SKILL.md` frontmatter) per `tests/test_version_consistency.py`. No new agent-specific guide needed (no local-LLM/Ollama angle for Codex).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Empirical Verification Gate (must happen before merge)
|
||||||
|
|
||||||
|
1. Live pane capture to confirm `ready_tokens`/`input_placeholder` (§1.6).
|
||||||
|
2. Live smoke test that `--dangerously-bypass-approvals-and-sandbox` suppresses every confirmation prompt in a real multi-file-edit task.
|
||||||
|
3. Live test of the corrected `orc_onboard.sh` regex (§1.9) against a real running `codex resume <uuid>` process's `ps`/`/proc` argv snapshot — the regex was verified against a synthetic string in this planning pass, not a real captured process line.
|
||||||
|
4. **(Rev.2, new)** Confirm the `state_*.sqlite` glob resolves correctly on a *different* Codex install/version than this machine's `state_5.sqlite` — the numeric suffix is not a documented, guaranteed-stable contract, only an observed fact on one install.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Definition of Done Checklist
|
||||||
|
|
||||||
|
- [ ] Complete Phase 0 empirical gate (§7), including the new Rev.2 state-DB-portability check.
|
||||||
|
- [ ] Implement `.agents/skills/lib_py/agents/adapters/codex.py` per §2 (DB-first resolution, `codex delete --force`-based purge with fallback).
|
||||||
|
- [ ] Apply all 46 lockstep wiring points in §3, including the **corrected** `orc_onboard.sh` regex (item #27).
|
||||||
|
- [ ] Add the dedicated `reconcile.sh` Drift-C block (§4, state-DB-first) and its `C-ambiguous`-scenario test.
|
||||||
|
- [ ] Add all test coverage in §3 items #35-39, **including the regression test locking down the corrected `orc_onboard.sh` regex** (item #38) and the `purge_artifacts` CLI-then-fallback test (item #36); `pytest tests/` shows 0 regressions.
|
||||||
|
- [ ] Bump version to `4.2.0` in strict 3-way lockstep (§6).
|
||||||
|
- [ ] Correct the two speculative prior reports' Codex rows (§3 items #41-42).
|
||||||
|
- [ ] Manual end-to-end smoke test: create → prompt → status shows `yes` → reconcile materializes `codex_session_id_own` → stop → resume round-trips → purge leaves no orphan `.jsonl` or `state_*.sqlite` row.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Effort Estimate
|
||||||
|
|
||||||
|
- **Total Estimate**: **~1.5-1.75 days** (Rev.2: +0.25 day vs. Rev.1 for the state-DB-aware adapter logic and the corrected/tested `orc_onboard.sh` regex).
|
||||||
|
- ~2-3 hrs: Phase 0 empirical gate (§7), including the new state-DB-portability check.
|
||||||
|
- ~4-5 hrs: adapter implementation (DB-first + glob-fallback throughout) + the 46-point mechanical wiring pass.
|
||||||
|
- ~2-3 hrs: dedicated `reconcile.sh` Drift-C block + `status.sh` + full test suite additions, including the two Rev.2 regression tests.
|
||||||
|
- ~2 hrs: docs/version bump + end-to-end manual smoke test (including a purge-leaves-no-orphan-row check).
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
# 📑 Issue #6 Implementation Plan (Phase 1–5 Execution & Gate Specification)
|
||||||
|
|
||||||
|
- **Document Version**: Rev.2 (Planner `planner-reviewer-claude-01`, job `f4873d82` — Creator `creator-agy-01` 이의제기 3건 반영; Rev.1은 job `d2d70f57`)
|
||||||
|
- **Issue**: [Gitea Issue #6 — `[Bug] delegate-job 워커 실패 시 레지스트리 미갱신(Fail-Silent) 및 TUI 턴 마감 레이스 컨디션 해결`](https://git.godopu.com/tmpl/multi-agent-mux/issues/6)
|
||||||
|
- **Source of Truth**: `.agents/reports/issue_6_consolidated_review_report.md` (Rev. 2) — 4인 리뷰어 합의 사양
|
||||||
|
- **Baseline Commit**: `f98b958` (`main`, v4.4.0), Herdr `0.8.2`, Python 3.9.6, 전체 스위트 750 passed
|
||||||
|
- **Status**: 착수 전 (Phase 1 대기)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 개요 및 설계 원칙
|
||||||
|
|
||||||
|
본 문서는 Rev.2 합의 사양(§4)을 **파일·함수·라인 단위의 실행 계획**으로 전개하고, 단계별 완료 게이트·롤백·비범위를 규정한다. 모든 라인 번호는 기준 커밋 `f98b958` 기준이며, 착수 시 `grep -n`으로 재확인한다.
|
||||||
|
|
||||||
|
### 📌 핵심 원칙 (Rev.2 §4 합의)
|
||||||
|
1. **기존 CAS 엔진 재사용**: 타임아웃 fail-closed는 `engine.py:50`의 `deadline_timeout` 요청(비-operator, terminal 이후 거부)으로만 수행한다. 신규 `--expect-status` 플래그와 operator `error` 오버라이드는 도입하지 않는다.
|
||||||
|
2. **사전 핸드셰이크 순서**: TUI 유휴 대기(`wait_for_agent_idle`)는 **구독자(`job_subscriber.py`) 기동 이전**에 완료한다. 구독자 idle 시계(`job_subscriber.py:265-267`, subscribe 시점 기산)를 잠식하지 않는다.
|
||||||
|
3. **유휴 예산 120초**: `SKS_IDLE_TIMEOUT_MS` 기본 `120000`(실측: codex `completed`→`done` 31s).
|
||||||
|
4. **브로커 장애 격리 (B-15 계약)**: 구독자 `rc=3`은 워커 실패가 아니다. 잡 데드라인까지 디스크를 재관측한 뒤에만 `deadline_timeout`을 적용한다.
|
||||||
|
5. **리테인 오염 방지 (O-1)**: loop/discuss 경로에서는 래퍼가 MQTT `error` 이벤트를 발행하지 않는다(레지스트리 전이만). direct 경로는 감사·모니터용으로 발행한다.
|
||||||
|
6. **롤백 스위치**: 각 행위 변경은 환경변수로 즉시 이전 동작으로 복귀 가능해야 한다 (Issue #5 관례).
|
||||||
|
7. **미주입 실패는 `error`가 아니다 (Rev.2 Secondary)**: 프롬프트가 한 글자도 전달되지 않은 실패(프리플라이트·유휴 대기 타임아웃)는 `deadline_timeout`이 아니라 **`cancelled`**(detail `session_busy`)로 닫고 래퍼 rc=5를 반환한다. 재스케줄 가능성을 보존하되 레코드는 비종료 상태로 남기지 않는다.
|
||||||
|
8. **셸→Python 경계에서 herdr 격리 명시 전달 (Rev.2 Primary)**: Python 쪽 `herdr` 호출은 반드시 `--session "$HERDR_SESSION_NAME"`을 명시하고, 셸 쪽 호출은 `mam_herdr`/`_sks_herdr`만 사용한다. 인터프리터는 항상 `"$(_delegate_py_bin)"`이다.
|
||||||
|
|
||||||
|
### 📌 실측으로 확정된 사실 (계획의 전제)
|
||||||
|
| 사실 | 근거 |
|
||||||
|
|---|---|
|
||||||
|
| direct 경로 `wait "$sub_pid" \|\| true`는 `:227`, loop 워커/리뷰어 조기 탈출은 `:359`/`:375` | `grep -n` (1차 보고서의 `:213`은 오기) |
|
||||||
|
| 명시적 주입 실패(`send_keys_safe` rc≠0)는 `set -e`로 exit 1 + EXIT trap(`:523`)이 `error` 발행 → **기본 레지스트리에서는 이미 fail-closed** | 실제 래퍼에 rc=4 주입 실측 |
|
||||||
|
| 단, trap 명령에 `--registry-dir`가 없어 커스텀 레지스트리에서는 `job record not found`로 `pending` 잔존 | 동일 실측 |
|
||||||
|
| 래퍼 exit 후 구독자 프로세스가 고아로 잔존 | 동일 실측 (`pgrep job_subscriber.py`) |
|
||||||
|
| `deadline_timeout`: pending→error 커밋, completed→`conflicting error after terminal completed; refusing` | `engine.transition` 직접 호출 실측 |
|
||||||
|
| 잡 완료 후 에이전트 상태는 `done`(idle 아님); working 중에도 `interactive_ready: true`, `❯` 렌더링 | `herdr agent list`/페인 캡처 실측 |
|
||||||
|
| `herdr agent wait`는 이미 목표 상태면 즉시 rc=0, 아니면 timeout 시 rc=1 + stderr JSON | 실측, `lib_py/loop/watcher.py` 모듈 docstring과 일치 |
|
||||||
|
| `dispatch_token`은 `claim`/`redispatch`에서만 발급(`engine.py:87,297-300`) → direct 잡은 토큰 없음, loop 2회차부터 존재 | 코드 |
|
||||||
|
| **실제 `herdr` 바이너리는 `HERDR_SESSION`·`HERDR_SOCKET_PATH` 환경변수와 `--session <name>` 플래그만 인식하며, MAM의 `HERDR_SESSION_NAME`은 인식하지 않는다.** `HERDR_SESSION_NAME`은 `lib.sh`의 `herdr()`/`mam_herdr`/`_real_herdr`(`lib.sh:234-243`)가 `--session`으로 번역하는 셸 함수 전용 변수라 **Python `subprocess`에는 상속되지 않는다** (Rev.2 Primary-A) | `env -u … herdr agent get` 5조합 실측: `HERDR_SESSION_NAME`만 → `server_not_running`(기본 소켓), `HERDR_SESSION`/`HERDR_SOCKET_PATH`/`--session` → 정상 |
|
||||||
|
| `lib.sh`를 source한 셸에서도 Python `subprocess.run(["herdr", …])`는 `/opt/homebrew/bin/herdr`로 해석되어 기본 소켓을 조회한다 | 동일 실측 (f) |
|
||||||
|
| **loop 모드 워커→리뷰어 전환은 v4.4.0에서 이미 깨져 있다**: `:276-283`의 `update --status pending --iteration $iteration`이 워커 `completed` 후 같은 iteration으로 `redispatch`를 요청 → `redispatch iteration 1 does not advance past 1` rc=4 → `set -e`로 래퍼 종료 (Rev.2 Tertiary) | `registry.py` CLI 실측 (iteration 2면 rc=0) |
|
||||||
|
| `cancelled` 요청은 비-operator 소스로 pending→cancelled 커밋, completed 이후 거부, 이후 operator `redispatch`(iteration 전진)로 재개 가능 | `engine.transition` 실측 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 단계별 구현 단위 및 필수 완료 기준 (Roadmap & Gates)
|
||||||
|
|
||||||
|
| 단계 | 작업 목표 | 세부 구현 (§3 참조) | 필수 완료 게이트 | 롤백 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| **Phase 1** | **레지스트리 CAS 표준 연동 및 delegate-job Fail-Closed** | • `registry.py close --request deadline_timeout\|cancelled` 서브커맨드<br>• 래퍼 `finalize_unfinished`/`reap_subscriber` 헬퍼, direct(`:227`)·loop(`:359`,`:375`)·`run_agent` 실패·프리플라이트 실패 5개 종료 경로 일원화<br>• rc=3 디스크 재관측, trap `--registry-dir`, dry-run 순서, 종료 코드 계약(rc 5 `session_busy` 포함)<br>• **loop 역할 전환 `attempt_id` 규약** (§3.1.6) | • `--agent human` 결정적 재현 4종(§5 T1-1~T1-4)이 수정 전 실패·수정 후 통과<br>• T1-10(워커 완료→리뷰어 재디스패치 성공) 수정 전 rc=4·수정 후 rc=0<br>• `test_wrapper_exit_code_mapping_literals_are_frozen`·F-A 3종 무변경 통과<br>• `MAM_DELEGATE_FAIL_CLOSED=0`에서 기존 exit 0 동작 재현 | `MAM_DELEGATE_FAIL_CLOSED=0` (헬퍼 no-op, `\|\| true` 복원) |
|
||||||
|
| **Phase 2** | **사전 TUI 유휴 핸드셰이크 및 제출 검증 보강** | • `lib_py/loop/watcher.py` CLI 진입점(`"$(_delegate_py_bin)" -m lib_py.loop.watcher wait … --session "$HERDR_SESSION_NAME"`)<br>• `lib.sh: wait_for_agent_idle` (사전 `mam_herdr agent get` → `unknown`/`blocked` 분기 → watcher CLI, `SKS_IDLE_RETRIES` 백오프 재시도, 최종 실패 rc 1 → 래퍼 rc 5 `cancelled`)<br>• 래퍼 `run_agent`를 `preflight_agent`+`inject_agent`로 분리, 순서: preflight → idle wait → subscriber → inject<br>• `send_keys_safe` 폴백 제출 검증에서 pre-submit busy 토큰 배제, `agent prompt --wait --until working` 수락 확인(재제출 금지) | • mock_herdr 상태 시퀀스 테스트 §5 T2-1~T2-11 통과<br>• `test_h16`(Enter 정확히 1회)·`test_b8`·`test_c1_*` 무변경 통과<br>• 래퍼 로그에서 `SUBSCRIBED` 이전에 idle 대기 완료 로그가 출력됨<br>• T2-10: watcher CLI argv에 `--session` 포함, T2-11: `lib.sh`에 bare `python`/`python3 -m lib_py.loop` 호출 0건(정적 린트) | `SKS_IDLE_HANDSHAKE=0` (게이트 스킵, 기존 `_pane_quiescent`만 사용), `SKS_PROMPT_WAIT=0` |
|
||||||
|
| **Phase 3** | **오케스트레이터 러너 Fail-Fast 연결** | • `runner.delegate_job`: rc≠0에서도 `registered job:` 파싱, `created_jobs`·checkpoint 기록, id 반환<br>• **rc=5(`session_busy`)는 `MAM_SESSION_BUSY_RETRY_S`(기본 30) 대기 후 1회 재위임(새 job id)**, 재실패 시 None<br>• 호출부는 `wait_for_job`이 디스크 `error`/`cancelled`로 1s 내 False<br>• `run_loop.sh` 레거시 `delegate_job_safe` 동일 파싱(`MAM_LOOP_PY=0` 경로 보존) | • `LoopRunner.wait_for_job`이 fail-closed 잡에서 ≤ 2s 반환(T3-1)<br>• 실패 job id가 `.mam/loop-checkpoint.json`에 기록(T3-2)<br>• rc=5 1회 재위임 후 성공/실패 분기(T3-3)<br>• `test_issue5_phase3_loop.py` 무변경 통과 | 함수 단위 revert (플래그 불필요: rc≠0 경로만 변경) |
|
||||||
|
| **Phase 4** | **단위/계약 회귀 스위트** | • `tests/test_issue6_fail_closed.py`, `tests/test_issue6_turn_handshake.py`<br>• `tests/conftest.py` mock_herdr에 `agent_status`·`agent wait`·`agent prompt --wait` 모킹<br>• 문서: SKILL.md 종료 코드 계약, MESSAGING.md §5.1 보강 | • 신규 테스트 전부 **수정 전 코드에서 실패함을 역검증**(Issue #5 관례)<br>• 전체 스위트 750 + 신규 ≥ 20, 0-Fail<br>• `git diff --check` 클린 | 테스트 파일 삭제만으로 원복 |
|
||||||
|
| **Phase 5** | **실기동 4개 에이전트 E2E 및 릴리스** | • Claude/Grok/Codex/OpenCode 각 1회: `completed` 직후 동일 세션 연속 위임 타임라인 기록<br>• 타임아웃 주입(에이전트 무응답)으로 65분 대신 ≤ `IDLE_TIMEOUT`+5s 내 `error` 확인<br>• 잔존 레코드 3건 폐쇄, IMPROVEMENTS.md 원장, VERSIONS.md `v4.4.1` 4-way 락스텝 | • 4개 백엔드 연속 위임 주입 실패 0건, 중복 제출 0건<br>• `test_version_consistency.py` 통과<br>• 리뷰어 교차 검토 `[VERDICT: PASS]` | 릴리스 태그 미부여 상태로 `main` revert |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 상세 구현 사양
|
||||||
|
|
||||||
|
### 3.1 Phase 1 — Fail-Closed
|
||||||
|
|
||||||
|
**3.1.1 `lib_py/delegation/registry.py` — `close` 서브커맨드 (신규, Rev.2: `timeout`을 일반화)**
|
||||||
|
```text
|
||||||
|
registry.py [--registry-dir D] close --job <id> --request {deadline_timeout,cancelled} [--detail <text>] [--expect-dispatch-token <tok>]
|
||||||
|
```
|
||||||
|
- 구현: `engine.transition(job_id, request, registry_dir=rd, source="delegate_wrapper", fields={"detail": detail}, expected_dispatch_token=tok_or_None)`. 두 요청 모두 비-operator이며 terminal 이후 거부된다(실측).
|
||||||
|
- 용도 구분: `deadline_timeout` = 프롬프트가 **전달된 뒤** 종료 이벤트 없이 데드라인 소진(실행 실패). `cancelled` + detail `session_busy` = 프롬프트가 **전달되기 전** 실패(프리플라이트·유휴 대기 타임아웃; Rev.2 Secondary). 후자는 `redispatch`로 재개 가능하다.
|
||||||
|
- 종료 코드: 커밋 → 0; 거부 사유가 `conflicting … after terminal` 또는 멱등 → **0**(stderr에 사유 1줄; 이미 닫힌 잡은 정상); 레코드 없음 → 1; 토큰 불일치 → 4.
|
||||||
|
- `status --set`는 건드리지 않는다(`_operator_write` 및 F-C 멱등 계약 보존).
|
||||||
|
|
||||||
|
**3.1.2 래퍼 `multi-agent-mux-delegate-job` — 공통 헬퍼 (신규, `run_agent` 위에 정의)**
|
||||||
|
```bash
|
||||||
|
# reap_subscriber <pid> — 자기 구독자만 회수. 원래 rc는 호출자가 보존.
|
||||||
|
reap_subscriber() { kill "$1" 2>/dev/null || true; wait "$1" 2>/dev/null || true; }
|
||||||
|
|
||||||
|
# finalize_unfinished <job_id> <job_status> <sub_rc> [<phase>]
|
||||||
|
# completed -> no-op (rc 0)
|
||||||
|
# broker_unavailable -> 남은 데드라인(TIMEOUT - 경과) 동안 3s 간격 디스크 재관측;
|
||||||
|
# completed/error 발견 시 그 상태 채택, 아니면 deadline_timeout
|
||||||
|
# 그 외(timeout/error/running) -> 즉시 registry.py close --request deadline_timeout
|
||||||
|
# phase == preflight|idle_wait -> registry.py close --request cancelled --detail session_busy (rc 5)
|
||||||
|
# (프롬프트 미전달: deadline_timeout 금지 — Rev.2 Secondary)
|
||||||
|
# direct 경로에서만 publish_event --event error|cancelled 추가 발행 (loop/discuss 금지: O-1)
|
||||||
|
# MAM_DELEGATE_FAIL_CLOSED=0 이면 전부 no-op (롤백)
|
||||||
|
```
|
||||||
|
- `disk_st` 재관측은 기존 `:342-347`의 인라인 파이썬을 함수 `_disk_status`로 추출해 재사용.
|
||||||
|
- `expected_dispatch_token`은 **구독자 기동 직전** 레코드에서 읽어 지역변수에 보관(`_disp_tok`), 존재할 때만 전달(direct 잡은 없음).
|
||||||
|
|
||||||
|
**3.1.3 종료 경로 5개 일원화**
|
||||||
|
| # | 위치 (f98b958) | 변경 |
|
||||||
|
|---|---|---|
|
||||||
|
| E1 | direct `:227` `wait "$sub_pid" \|\| true` | `wait "$sub_pid" \|\| sub_rc=$?` → 기존 `:340-354` 매핑 블록을 함수 `_map_sub_rc`로 추출(리터럴 문자열 보존) → `finalize_unfinished` → `exit_code=$(_rc_for "$job_status")` |
|
||||||
|
| E2 | loop 워커 `:359-362` | `break` 직전 `finalize_unfinished "$JOB_ID" "$job_status" "$sub_rc" worker` |
|
||||||
|
| E3 | loop 리뷰어 `:375-378` | 동일 (`reviewer`) |
|
||||||
|
| E4 | `run_agent` 실패 (`:327` 및 direct `:204`) | `if ! run_agent …; then reap_subscriber "$sub_pid"; finalize_unfinished "$JOB_ID" error 1 inject; exit 1; fi`. trap(`:523`)은 `--registry-dir "$REGISTRY_DIR"` 추가 후 최후 보루로만 유지 |
|
||||||
|
| E5 | 프리플라이트 실패(`:456-473` herdr/세션 부재, `:511-513` strict-role) 및 유휴 대기 최종 실패 | Phase 2에서 `preflight_agent`로 이동. 실패 시 잡 등록 후라면 `finalize_unfinished … preflight\|idle_wait` → `cancelled`(session_busy) 후 **`exit 5`** |
|
||||||
|
|
||||||
|
**3.1.4 종료 코드 계약 (SKILL.md에 문서화)**
|
||||||
|
| rc | 의미 |
|
||||||
|
|---|---|
|
||||||
|
| 0 | `completed` |
|
||||||
|
| 1 | `error` (에이전트 발행 / 주입·프리플라이트 실패) |
|
||||||
|
| 2 | 구독자 타임아웃 → `deadline_timeout` 적용됨 |
|
||||||
|
| 3 | 브로커 장애 + 데드라인 내 디스크 미확인 → `deadline_timeout` 적용됨 |
|
||||||
|
| 5 | `session_busy`: 프리플라이트/유휴 대기 실패로 프롬프트 미전달 → `cancelled` 적용됨 (재스케줄 가능) |
|
||||||
|
마지막 stdout 줄(감사 로그 경로)은 모든 경로에서 유지한다(`runner.delegate_job`/`run_loop.sh:373` 파싱 계약).
|
||||||
|
|
||||||
|
**3.1.5 dry-run 순서 (구독자 기동 `:166` → dry-run `return 0` `:224`)**: `DRY_RUN=1`이면 구독자를 기동하지 않는다(현재는 기동 후 `return 0`으로 고아 발생).
|
||||||
|
|
||||||
|
**3.1.6 loop/discuss 역할 전환의 `attempt_id` 규약 (Rev.2 Tertiary — 기존 결함 수정)**
|
||||||
|
- 문제: `:276-283`은 워커·리뷰어 phase 모두 같은 `$iteration`으로 `update --status pending`(=`redispatch`)을 요청한다. `engine.py:227-240`은 terminal에서의 `redispatch`에 `iteration` 전진을 요구하므로 워커 `completed` 직후 리뷰어 전환이 rc=4로 거부되고 `set -e`로 래퍼가 죽는다(실측). F-A 테스트(`test_issue5_phase2a_loop.py:727`)는 iteration 2를 넘겨 이 경로를 비켜간다.
|
||||||
|
- 설계: `iteration`은 루프 회차 라벨로 유지하고, **디스패치 단조 카운터 `attempt_id`**를 도입한다(엔진 `:171`이 이미 `record.get("attempt_id", record.get("iteration"))`으로 읽는 필드).
|
||||||
|
1. 래퍼: 잡별 `attempt=$((attempt+1))`을 각 phase 디스패치 직전에 증가시키고 `update … --attempt-id "$attempt"`를 함께 넘긴다(워커 r1=1, 리뷰어 r1=2, 워커 r2=3 …).
|
||||||
|
2. `registry.py update`에 `--attempt-id` 추가(`:296-302`).
|
||||||
|
3. `engine._decide` redispatch 규칙: `fields`에 `attempt_id`가 있으면 **`attempt_id`가 `record.attempt_id`(없으면 `iteration`)보다 커야** 커밋; 없으면 기존 `iteration` 규칙 유지(F-A 3종 무변경). `attempt_id`는 `redispatch`/`claim` 커밋 시 레코드에 기록.
|
||||||
|
4. `publish_event.py:250-255`의 `--finalize-report` 기본 `attempt_id`는 `record.attempt_id`를 우선하고 없으면 `iteration`.
|
||||||
|
- 범위 주의: 이 수정으로 리뷰어 전환의 CAS 거부는 사라지지만, **loop 모드의 리뷰어 phase는 O-1(리테인 재수락, §4)에 의해 여전히 즉시 종료된다.** 따라서 Phase 5 E2E는 direct 모드만 수행하고, loop 모드 종단 검증은 O-1 이슈 완료 후로 명시한다.
|
||||||
|
|
||||||
|
### 3.2 Phase 2 — 사전 TUI 핸드셰이크
|
||||||
|
|
||||||
|
**3.2.1 `lib_py/loop/watcher.py` CLI 진입점 (신규 `main`)**
|
||||||
|
```text
|
||||||
|
"$(_delegate_py_bin)" -m lib_py.loop.watcher wait <target> --session <herdr_session> [--until S]... [--timeout-ms N]
|
||||||
|
stdout: {"kind": "matched|timeout|agent_not_found|transport_error", "agent_status": "...", "error_code": "..."}
|
||||||
|
rc: matched 0 / timeout 2 / agent_not_found 3 / transport_error 4
|
||||||
|
```
|
||||||
|
- `wait_once`를 그대로 사용(셸 중복 구현 금지). `lib_py.delegation`은 `lib_py.loop`를 임포트하지 않는다(패키지 규칙 유지) — 호출자는 셸(`lib.sh`)이다.
|
||||||
|
- **격리 전달 (Rev.2 Primary-A)**: `build_argv(target, until, timeout_ms, session=None)`에 `session` 파라미터를 추가하고, 값이 있고 `default`가 아니면 argv를 `["herdr", "--session", session, "agent", "wait", …]`로 구성한다. CLI `--session`은 미지정 시 `HERDR_SESSION_NAME` 환경변수를 읽는다. 실제 `herdr`는 `HERDR_SESSION_NAME`을 인식하지 않으므로(실측) 이 명시 전달이 없으면 기본 소켓(`~/.config/herdr/herdr.sock`)을 조회해 `server_not_running`/`agent_not_found`가 된다. 기존 `wait_once` 호출자(`shadow.py` 등)는 `session=None`으로 동작 불변.
|
||||||
|
- **인터프리터 (Rev.2 Primary-B)**: bare `python`/`python3` 금지. `lib.sh`는 `"$(_delegate_py_bin)"`(`lib.sh:1841`)만 사용하며 `PYTHONPATH`는 `lib.sh:25,164`가 이미 보장한다. T2-11 정적 린트로 고정.
|
||||||
|
|
||||||
|
**3.2.2 `lib.sh: wait_for_agent_idle <sess> [timeout_ms]` (신규, `send_keys_safe` 위)**
|
||||||
|
1. `SKS_IDLE_HANDSHAKE=0` → return 0 (롤백).
|
||||||
|
2. `_sks_herdr agent get <target>`(=`mam_herdr`, 격리 인지 셸 함수)로 `agent_status` 조회: `unknown`/조회 실패 → return 3(휴리스틱 폴백 신호, 호출자는 기존 `_pane_quiescent` 경로 진행); `blocked` → return 2(주입 금지; `_pane_dialog_open` 처리로 위임).
|
||||||
|
3. `idle`/`done` → return 0. `working` → `"$(_delegate_py_bin)" -m lib_py.loop.watcher wait <target> --session "$HERDR_SESSION_NAME" --until idle --until done --until blocked --timeout-ms ${SKS_IDLE_TIMEOUT_MS:-120000}`: matched(idle/done) → 0, matched(blocked) → 2, timeout → 재시도, 그 외 → 3.
|
||||||
|
4. **재시도 (Rev.2 Secondary)**: timeout은 `SKS_IDLE_RETRIES`(기본 2)회까지 `SKS_IDLE_RETRY_BACKOFF_S`(기본 10)초 간격으로 반복한다(기본 총 예산 ≈ 3×120s + 20s). 최종 실패 → return 1("agent still working"). 호출자(래퍼)는 rc 1을 `finalize_unfinished … idle_wait`(→ `cancelled`/rc 5)로 처리하며 **`deadline_timeout`을 적용하지 않는다**.
|
||||||
|
- `interactive_ready`는 어떤 판정에도 사용하지 않는다.
|
||||||
|
|
||||||
|
**3.2.3 래퍼 실행 순서 재배치 (`cmd_submit`)**
|
||||||
|
```text
|
||||||
|
register → brief 작성 → preflight_agent(herdr 존재·세션 존재·역할 검사)
|
||||||
|
→ wait_for_agent_idle "$sess" (rc 1/2 → finalize_unfinished … idle_wait → cancelled/session_busy; exit 5)
|
||||||
|
→ _disp_tok 읽기 → 구독자 기동 + SUBSCRIBED 핸드셰이크
|
||||||
|
→ inject_agent(send_keys_safe) → wait sub → finalize
|
||||||
|
```
|
||||||
|
- loop/discuss 반복마다 동일 순서(`:286` 구독자 기동 앞에 idle wait).
|
||||||
|
|
||||||
|
**3.2.4 `send_keys_safe` 보강 (`lib.sh:2231-2345`)**
|
||||||
|
- 제출 검증(`:2330-2332`): `pre_submit`에 `esc to interrupt`/`● |✽ |[A-Za-z]+ing`가 **이미 있었으면** 해당 토큰을 성공 근거에서 제외하고 marker 소멸 + 화면 변화만 인정.
|
||||||
|
- 네이티브 경로(`:2279`): `SKS_PROMPT_WAIT=1`(기본)이면 `agent prompt <t> <text> --wait --until working --timeout 5000`. 성공 → 0. `agent_prompt_stalled`/timeout → **재제출하지 않고** 2s 후 `agent get` 1회: `working` → 0, 그 외 → rc 4("delivery unknown"). `agent_blocked` → rc 2.
|
||||||
|
- 폴백 경로 진입 전 busy 토큰 게이트: `wait_for_agent_idle`이 3(unknown)을 반환한 경우에만 `_pane_tail 8`의 busy 토큰을 `SKS_BUSY_TIMEOUT`(기본 120s)까지 대기.
|
||||||
|
|
||||||
|
### 3.3 Phase 3 — 러너 Fail-Fast
|
||||||
|
- `runner.py:279-289`: `res.returncode != 0`이어도 stdout에서 `registered job:` 매치 시 `created_jobs.append` + `_record_checkpoint()` 후 **id 반환**(로그는 error 레벨 유지). 매치 실패 시에만 `None`.
|
||||||
|
- **rc=5 (`session_busy`, Rev.2 Secondary)**: `delegate_job`이 `MAM_SESSION_BUSY_RETRY_S`(기본 30)초 대기 후 **1회** 재위임(새 job id 등록)한다. 재시도도 rc=5면 마지막 id를 반환하고 호출부는 `wait_for_job`이 `cancelled`로 즉시 False → 기존 abort 경로. 무한 재시도는 두지 않는다(교착의 새 형태).
|
||||||
|
- `wait_for_job`은 변경하지 않는다(3900s는 최후 안전망; `cancelled`는 이미 False 조건).
|
||||||
|
- `run_loop.sh:159-168, 373`: `delegate_job_safe` rc≠0에서도 출력에서 job id 추출(레거시 경로 동등성).
|
||||||
|
|
||||||
|
### 3.4 Phase 4 — 테스트 및 문서 (§5 참조)
|
||||||
|
- `tests/conftest.py` mock_herdr: `agent get`/`list`에 `agent_status`(기본 `idle`) 추가; `agent wait`는 상태 파일의 `status_sequence`(예: `["working","working","done"]`)를 호출마다 소비, 소진 시 마지막 값 유지, `--timeout`은 시퀀스 소진 전 종료 시 `{"error":{"code":"timeout"}}` rc 1; `agent prompt --wait`는 `prompt_wait_result` 필드(`ok|agent_prompt_stalled|agent_blocked`)로 제어.
|
||||||
|
- 문서: `SKILL.md` "Core Commands"에 종료 코드 표(§3.1.4), `MESSAGING.md` §5.1 구독자 타임아웃의 레지스트리 반영 서술, `BOOTSTRAP*.md` 에이전트 프로토콜에 "completed 발행 후 도구 호출 없이 턴 종료" 1문장.
|
||||||
|
|
||||||
|
### 3.5 Phase 5 — E2E·정리·릴리스
|
||||||
|
- E2E 절차(**direct 모드만**; loop 모드 종단은 O-1 완료 후): 각 백엔드에 `--agent human`이 아닌 실세션으로 잡 A 위임 → `completed` 수신 즉시 잡 B 위임 → 래퍼 로그의 idle 대기 시간, `herdr agent get` 타임라인, B의 `started` 지연을 기록(`docs/contracts/issue-6/phase5-e2e.md`).
|
||||||
|
- 타임아웃 주입: 세션에 `/exit` 없이 프롬프트를 무시하도록 `--idle-timeout 30`으로 위임 → 래퍼 rc=2, 레지스트리 `error`, `wait_for_job` ≤ 2s.
|
||||||
|
- 잔존 레코드 `93a44347`, `2961b822`, `ee242671`: `registry.py close --job <id> --request deadline_timeout --detail "Issue #6 backfill"`.
|
||||||
|
- 릴리스: `v4.4.1` (PATCH — 공개 계약 삭제 없음, 종료 코드는 오동작 정정, `timeout` 서브커맨드는 래퍼 내부 배관). 4-way 락스텝: `lib.sh:32`, `eager.py:16`, `VERSIONS.md`(개요+매트릭스+Changelog), 8개 `SKILL.md` frontmatter. `IMPROVEMENTS.md` §5에 `Issue-6-FailClosed-TurnHandshake` 블록 추가(§6 표·헤더 키 목록 동기화).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 명시적 비범위 (Out of Scope) — 별도 이슈
|
||||||
|
1. **O-1 리테인 터미널 재수락**: 재디스패치 후 2회차 구독자가 1회차 retained `completed`를 즉시 수락(실측). loop 모드 기존 결함. 본 이슈에서는 "loop 경로 retained 발행 금지"로 회피만 한다. **Rev.2 주의: §3.1.6의 attempt_id 수정 후에도 loop 모드 리뷰어 phase는 O-1 때문에 종단 동작하지 않는다. 별도 이슈를 Issue #6 릴리스 노트에 "loop/discuss 모드 사용 전 필수"로 명시한다.**
|
||||||
|
2. **레지스트리 오염**: `.mam/jobs`에 pytest 세션명 레코드 467건 누출 원인.
|
||||||
|
3. **세션별 전달 직렬화 락**, **direct 잡 `dispatch_token` 발급**: 현 구조(동기 위임 + loop lock)에서 불필요.
|
||||||
|
4. **`wait_for_job` 3900s 단축**: 증상 완화이므로 변경하지 않는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 회귀 테스트 행렬 (Phase 4 산출물)
|
||||||
|
|
||||||
|
### 5.1 `tests/test_issue6_fail_closed.py` (브로커 무관, `--agent human` + 짧은 타임아웃)
|
||||||
|
| ID | 시나리오 | 기대 |
|
||||||
|
|---|---|---|
|
||||||
|
| T1-1 | direct, 이벤트 없음 (`--timeout 6 --idle-timeout 2`) | rc=2, 레지스트리 `error`, events.log에 wrapper `error` 1건, 구독자 pid 소멸 |
|
||||||
|
| T1-2 | loop, 워커 phase 이벤트 없음 | rc=2, `error`, **retained 발행 없음** |
|
||||||
|
| T1-3 | 구독자 타임아웃 직후 `publish_event completed` 경쟁 | 최종 `completed` 유지, `timeout` 서브커맨드 rc 0(거부 로그) |
|
||||||
|
| T1-4 | `MQTT_HOST` 불가 + 디스크 비종료 (`--timeout 8`) | rc=3, 데드라인까지 재관측 로그, 최종 `error` |
|
||||||
|
| T1-5 | rc=3 + 재관측 중 디스크 `completed` 출현 | rc=0, `completed` |
|
||||||
|
| T1-6 | `close --request deadline_timeout`을 `completed` 레코드에 적용 | rc=0, 상태 불변 |
|
||||||
|
| T1-10 | loop: 워커 `completed` 후 리뷰어 `update --status pending --iteration 1 --attempt-id 2` | rc=0, `pending`, `attempt_id=2` (수정 전 rc=4 → 역검증) |
|
||||||
|
| T1-11 | 같은 `--attempt-id 2` 재요청(리플레이) | rc=4 거부 |
|
||||||
|
| T1-12 | `--attempt-id` 없이 iteration 2 (F-A 경로) | rc=0 (기존 규칙 보존), F-A 3종 무변경 통과 |
|
||||||
|
| T1-13 | `close --request cancelled --detail session_busy`: pending→cancelled rc=0, completed→ rc=0·상태 불변, 이후 `redispatch` iteration 2 → pending | 전이 규칙 고정 |
|
||||||
|
| T1-7 | 커스텀 `--registry-dir` + `send_keys_safe` 실패(BASH_ENV 오버라이드) | rc=1, 해당 레지스트리 `error` |
|
||||||
|
| T1-8 | `MAM_DELEGATE_FAIL_CLOSED=0` | T1-1 조건에서 rc=0, `pending` (구 동작 재현) |
|
||||||
|
| T1-9 | dry-run direct | 구독자 프로세스 미기동 |
|
||||||
|
| 고정 | `test_wrapper_exit_code_mapping_literals_are_frozen` | 통과 |
|
||||||
|
|
||||||
|
### 5.2 `tests/test_issue6_turn_handshake.py` (mock_herdr 상태 시퀀스)
|
||||||
|
| ID | 시나리오 | 기대 |
|
||||||
|
|---|---|---|
|
||||||
|
| T2-1 | `status_sequence=["working","done"]` | `agent wait` 호출 후 `agent prompt` 호출(순서 단정), Enter/C-m 0회(네이티브) |
|
||||||
|
| T2-2 | `working` 고정, `SKS_IDLE_TIMEOUT_MS=1000` | rc=1, `agent prompt` 미호출, 래퍼 rc=1·`error` |
|
||||||
|
| T2-3 | `unknown` | `agent wait` 미호출, `_pane_quiescent` 폴백 경로 진입 |
|
||||||
|
| T2-4 | `blocked` | rc=2, 주입 없음 |
|
||||||
|
| T2-5 | `done` 즉시 | 대기 0회, 즉시 주입 |
|
||||||
|
| T2-6 | 폴백 경로, pre_submit에 `esc to interrupt` 존재 + 페이스트 드롭 | rc=4 (기존 코드에서는 0 → 역검증) |
|
||||||
|
| T2-7 | `prompt_wait_result=agent_prompt_stalled` + 이후 `working` | rc=0, prompt 호출 **1회** |
|
||||||
|
| T2-8 | 래퍼 순서: idle 대기 로그가 `subscriber pid:` 로그보다 앞 | 로그 순서 단정 |
|
||||||
|
| T2-9 | `working` 고정 + `SKS_IDLE_RETRIES=1`, `SKS_IDLE_TIMEOUT_MS=500` | `agent wait` 2회 호출, 래퍼 rc=5, 레지스트리 `cancelled`(detail `session_busy`), `deadline_timeout` 미호출, 구독자 미기동 |
|
||||||
|
| T2-10 | `HERDR_SESSION_NAME=iso-x`에서 `wait_for_agent_idle` | mock 호출 기록에 `herdr --session iso-x agent wait …` (argv 순서 단정); `build_argv(session="default")`는 `--session` 생략 |
|
||||||
|
| T2-11 | 정적 린트: `lib.sh`·래퍼에서 `^\s*python3? -m lib_py\.loop` 또는 bare `python -m` 패턴 0건, watcher 호출은 `_delegate_py_bin` 경유 | grep 단정 |
|
||||||
|
| 호환 | `test_h16`, `test_b8`, `test_c1_*`, `test_herdr_shim_contract` | 무변경 통과 |
|
||||||
|
|
||||||
|
### 5.3 Phase 3 (`tests/test_issue6_fail_closed.py` 내)
|
||||||
|
| ID | 시나리오 | 기대 |
|
||||||
|
|---|---|---|
|
||||||
|
| T3-1 | `LoopRunner.delegate_job` rc≠0 + `registered job:` 출력 | id 반환, `wait_for_job` ≤ 2s False |
|
||||||
|
| T3-2 | 동일 | `.mam/loop-checkpoint.json`에 id 기록 |
|
||||||
|
| T3-3 | 래퍼 stub이 1회차 rc=5, 2회차 rc=0 (`MAM_SESSION_BUSY_RETRY_S=0`) | 재위임 1회 후 2회차 id 반환; 두 번 다 rc=5면 마지막 id 반환 + `wait_for_job` False |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 공수 및 순서
|
||||||
|
| 단계 | 공수 | 선행 |
|
||||||
|
|---|---|---|
|
||||||
|
| Phase 1 | 1.25 MD (+0.25 attempt_id) | — |
|
||||||
|
| Phase 2 | 1.25–1.75 MD (+0.25 세션 전달·재시도) | Phase 1 (finalize 헬퍼 재사용) |
|
||||||
|
| Phase 3 | 0.25 MD | Phase 1 |
|
||||||
|
| Phase 4 | 0.75 MD (Phase 1~3와 병행 작성, 역검증 포함) | — |
|
||||||
|
| Phase 5 | 0.5–1.0 MD | 전부 |
|
||||||
|
| **합계** | **4.0–5.0 MD** (통합 보고서 Rev.2 §3 상한 대비 +0.5 MD; 사유 §7) | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Creator 이의제기 판정 (Rev.1 → Rev.2)
|
||||||
|
|
||||||
|
| 이의 | 판정 | 실측 근거 | Rev.2 반영 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Primary-A** watcher.py가 `HERDR_SESSION_NAME` 격리를 잃는다 | **수용** (세부 정정: 실제 herdr 플래그는 `-L`이 아니라 `--session`; `-L`은 `.mam/shim` 번역 전용. 격리 변수는 셸 함수 `mam_herdr`/`_real_herdr`가 번역하며 Python 서브프로세스에는 상속되지 않음) | `HERDR_SESSION_NAME`만 있는 환경에서 bare `herdr` → `server_not_running`; `--session`/`HERDR_SESSION`/`HERDR_SOCKET_PATH` → 정상. `lib.sh` source 후 Python `subprocess`도 동일 실패 | §1 원칙 8, §3.2.1 `--session` 전달, §3.2.2 `_sks_herdr` 사용, T2-10 |
|
||||||
|
| **Primary-B** bare `python -m` 호출은 `ModuleNotFoundError` | **수용** (Rev.1 표기 오류) | `lib.sh:1841 _delegate_py_bin`, `PYTHONPATH` export `lib.sh:25,164` | §3.2.1/3.2.2 `"$(_delegate_py_bin)"`, T2-11 정적 린트 |
|
||||||
|
| **Secondary** 주입 전 유휴 대기 실패를 `deadline_timeout`(terminal `error`)로 닫으면 미주입 잡이 실행 실패로 낙인 + 러너 즉시 abort | **수용** (대안 채택: 래퍼 내 bounded 재시도 → `cancelled`/`session_busy` rc 5 → 러너 1회 재위임). "재시도 기회 영구 박탈" 부분은 부분 정정: `cancelled`도 operator `redispatch`로 재개 가능(실측) | `engine.transition`: pending→cancelled 커밋, completed→cancelled 거부, cancelled→redispatch 성공 | §1 원칙 7, §3.1.1 `close`, §3.1.2/3.1.4 rc 5, §3.2.2 재시도, §3.3 러너 재위임, T1-13/T2-9/T3-3 |
|
||||||
|
| **Tertiary** 워커→리뷰어 전환 시 같은 iteration의 `redispatch` 거부 | **수용** (인용 코드는 실제 `engine.py:227-240`과 다르나 결론은 정확). v4.4.0에서 **이미 깨져 있는 기존 결함**임을 확인 | `registry.py update --status pending --iteration 1`(워커 completed 후) → rc=4 `does not advance past 1`; iteration 2 → rc=0 | §3.1.6 `attempt_id` 규약, T1-10~T1-12 |
|
||||||
|
| (부수) Rev.2 통합 보고서 §3의 `lib.sh` 경로 | Rev.1과 동일하게 실제 경로 `.agents/skills/lib.sh` 사용 | — | — |
|
||||||
|
|
||||||
|
공수 +0.5 MD의 사유: `attempt_id` 규약(엔진 규칙·CLI·publish_event 기본값·테스트 3종)과 세션 전달·재시도 로직·러너 재위임(테스트 3종)이 Rev.1에 없던 신규 작업이다.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"loop_id": "loop-20261025T023502Z-d6f876",
|
||||||
|
"base_sha": "0461a632eb667c7988fd33be92e9a4b36151f22a",
|
||||||
|
"head_sha": "bb496fde785133ad1dbec288aa7431766efb6eb5",
|
||||||
|
"tree_sha": "15a72c9a1933c64018a2f2d9aa9707348905bb4d",
|
||||||
|
"review_commit": "bb496fde785133ad1dbec288aa7431766efb6eb5",
|
||||||
|
"reviewer": "planner-reviewer-claude-01",
|
||||||
|
"job_id": "05af786c",
|
||||||
|
"verdict": "PASS",
|
||||||
|
"transport": "herdr",
|
||||||
|
"evidence": "review-tree",
|
||||||
|
"isolation": "worktree",
|
||||||
|
"verified_at": "2026-10-25T03:40:44Z"
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
# PR #70 (Issue #66) 리뷰 — head bb496fd
|
||||||
|
|
||||||
|
- 리뷰어: planner-reviewer-claude-01 (macOS, bash 3.2, /usr/bin/python3 3.9.6)
|
||||||
|
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261025T023502Z-d6f876/1`
|
||||||
|
- identity 확인: HEAD bb496fde785133ad1dbec288aa7431766efb6eb5, tree 15a72c9a1933c64018a2f2d9aa9707348905bb4d, `git status --porcelain --ignored` 비어 있음, `.mam` 없음 (시작·종료 시 모두). main HEAD 0461a63 그대로.
|
||||||
|
- 범위: base 0461a63..bb496fd (커밋 1개). 바뀐 파일은 세 개다: `.agents/skills/lib_py/stall_watch.py`, `tests/conftest.py`, `tests/test_issue66_stall_watch_sanitized_name.py`.
|
||||||
|
|
||||||
|
## 1. 확인한 것과 근거
|
||||||
|
|
||||||
|
### 1) 완료 조건
|
||||||
|
|
||||||
|
herdr 를 부르는 곳은 모두 변환된 이름을 쓴다.
|
||||||
|
- `HerdrSource.__init__` 이 `self.name = sanitize_herdr_agent_name(target)` 를 만든다 (`stall_watch.py:162`).
|
||||||
|
- 이 이름을 쓰는 곳:
|
||||||
|
- `agent get` (:183)
|
||||||
|
- `agent read` (:206)
|
||||||
|
- `agent prompt` (:211)
|
||||||
|
- `watcher.wait_once(self.herdr_name, …)` (:331, 값은 :229)
|
||||||
|
- `pane process-info` 와 `pane read` (:187, :193)는 `agent get` 이 돌려준 pane_id 를 쓰므로 이름과 상관이 없다.
|
||||||
|
- `--herdr-session` 은 서버 이름으로만 쓰인다. `self.session` (:159) 과 `session=self.args.herdr_session` (:331) 이고, 변환되지 않는다. 둘을 섞어 쓴 곳은 없다.
|
||||||
|
|
||||||
|
로그와 알림은 원래 이름을 쓴다.
|
||||||
|
- 로그 :373 (blocked), :397 (입력창 비어 있지 않음), :406 (idle) 은 모두 `self.args.target` 이다.
|
||||||
|
- 알림 상세(`stalled:idle`, `stalled:blocked …`)에는 이름이 들어가지 않는다.
|
||||||
|
- 회귀 테스트는 `agent {target} idle for` 와 `agent {target} is blocked` 가 원래 이름으로 나오는지 확인한다 (test :85, :106).
|
||||||
|
|
||||||
|
변환이 필요 없는 이름(`creator-claude-t`)은 그대로이고, 그 경우의 테스트도 통과한다 (test :68 의 PLAIN).
|
||||||
|
|
||||||
|
### 2) 회귀 테스트: base 에서 실패, head 에서 통과
|
||||||
|
|
||||||
|
- head: 7 passed.
|
||||||
|
- base(0461a63) 사본에 head 의 새 테스트와 conftest 를 넣고 돌리면 5 failed, 2 passed 다.
|
||||||
|
- 실패: LONG, UPPER 두 이름 각각의 idle nudge 와 blocked 보고 (4개), 긴 codex 이름의 종류 판정 (1개).
|
||||||
|
- 통과: strict mock 자체를 확인하는 테스트와, 변환이 필요 없는 PLAIN.
|
||||||
|
|
||||||
|
### 3) 변환 함수가 lib.sh 와 같은 결과를 내는가
|
||||||
|
|
||||||
|
lib.sh `_sanitize_herdr_agent_name` (:293-316) 과 `lib_py.agents.sanitize.sanitize_herdr_agent_name` 에 같은 이름 10개를 넣어 비교했다.
|
||||||
|
- 비교한 이름: 그대로 둘 이름, 대문자, 32자 초과(루프 세션 이름 포함), 정확히 32자, 33자, 숫자로 시작, `.`·공백·`@`, 비 ASCII `Ü`, `_` 로 시작.
|
||||||
|
- 결과: 10개 모두 같고, 두 번 변환해도 바뀌지 않는다.
|
||||||
|
- 그래서 호출자가 이미 변환된 이름을 넘겨도 안전하다.
|
||||||
|
- 호출자는 원래 세션 이름을 넘긴다: `multi-agent-mux-delegate-job:431`, `:615` → `start_stall_watch "$AGENT_SESSION"` 와 `"$current_session"`.
|
||||||
|
|
||||||
|
### 4) resolve_agent 를 원래 이름으로 하는 것이 맞는가
|
||||||
|
|
||||||
|
맞다.
|
||||||
|
- 긴 이름은 변환되면 끝이 해시로 바뀐다. 예: `mam-wt-issue-66-very-long-creator-codex` → `…-<sha8>`. 그래서 종류는 원래 접미사에서 읽어야 한다.
|
||||||
|
- `HerdrSource` (:163) 과 `StallWatch` (:230) 둘 다 원래 이름을 쓴다.
|
||||||
|
- 테스트 :111-129 가 `--agent ""` 일 때 codex 로 정해져 nudge 가 가는 것을 확인한다.
|
||||||
|
- 종류를 변환된 이름에서 읽도록 바꾼 변이(m3)는 이 테스트에 잡힌다.
|
||||||
|
|
||||||
|
### 5) conftest `strict_agent_names`
|
||||||
|
|
||||||
|
- 기본(키 없음)일 때 `_match_agent` 는 예전과 똑같이 네 가지 비교를 한다 (`conftest.py` 수정분 :473-476). get/read 의 실패 출력도 예전 문구 그대로다. 그래서 기존 테스트의 동작은 바뀌지 않는다.
|
||||||
|
- 켜면 등록된 이름과 정확히 같을 때만 답하고, 아니면 `{"error":{"code":"agent_not_found"}}` 를 stderr 에 쓰고 exit 1 한다. 실제 herdr 처럼 등록 이름(변환된 이름)으로만 찾는다.
|
||||||
|
- 테스트 :51-65 가 get, read, wait, prompt 네 명령 모두 원래 이름에는 agent_not_found 를, 변환된 이름에는 rc 0 을 내는지 확인한다.
|
||||||
|
|
||||||
|
### 6) macOS 호환
|
||||||
|
|
||||||
|
- `/usr/bin/python3` 3.9.6 으로 `lib_py.stall_watch` import 와 `resolve_agent` 호출이 된다.
|
||||||
|
- 새 코드에 `X | Y`, `match`, tomllib 가 없다. 셸은 바뀌지 않았다.
|
||||||
|
|
||||||
|
### 7) test_issue17_item4_decisions::test_c_finalize_ends_with_an_unreaped_wrapper[broker_unavailable-3-0]
|
||||||
|
|
||||||
|
이 변경과 무관하다.
|
||||||
|
- 이 테스트는 래퍼의 finalize 를 자체 shim(`PY=<shim>`)으로 돌린다. stall watcher 는 argv 를 기록하는 대역이다 (`tests/test_issue17_item4_decisions.py:218`).
|
||||||
|
- conftest 의 mock herdr 와 `lib_py.stall_watch` 를 쓰지 않는다.
|
||||||
|
- 판정 조건은 `took < 4.5` 와 terminate 뒤 레코드가 그대로인지 (:598-601) 로, 시간과 정리 단계에 의존한다.
|
||||||
|
- 이 PR 이 바꾼 것(stall_watch 의 이름 변환, 기본 꺼짐인 mock 옵션)은 이 경로에 닿지 않는다.
|
||||||
|
- 이 호스트에서도 통과했다(아래 표).
|
||||||
|
|
||||||
|
## 2. 실행한 테스트
|
||||||
|
|
||||||
|
모두 `env -i`, 임시 HOME/XDG, `PYTHONDONTWRITEBYTECODE=1 -p no:cacheprovider --basetemp=<scratchpad>` 로 돌렸다. .mam.env 를 export 하지 않았다.
|
||||||
|
|
||||||
|
| 대상 | 결과 |
|
||||||
|
|---|---|
|
||||||
|
| 검증 경로 `tests/test_issue66_stall_watch_sanitized_name.py` | 7 passed |
|
||||||
|
| base 사본 + 새 테스트·conftest | 5 failed, 2 passed |
|
||||||
|
| 검증 경로: test_issue30_stall_watch, test_issue30_stall_watch_stop, test_issue17_item4_decisions, test_tier2_component, test_issue63_same_cwd_ids, test_issue50_dispatch_herdr, test_c1_tui_readiness (mock herdr 를 쓰는 인접 테스트) | 457 passed, 1 skipped, 0 failed |
|
||||||
|
|
||||||
|
변이 테스트 (head 사본, issue66 테스트):
|
||||||
|
|
||||||
|
| # | 변이 | 결과 |
|
||||||
|
|---|---|---|
|
||||||
|
| m1 | `wait_once` 를 원래 이름으로 | 잡힘 (5 failed) |
|
||||||
|
| m2 | `agent prompt` 를 원래 이름으로 | 잡힘 (3 failed) |
|
||||||
|
| m3 | 종류를 변환된 이름에서 판정 | 잡힘 (1 failed) |
|
||||||
|
| m4 | blocked 로그를 변환된 이름으로 | 잡힘 (2 failed) |
|
||||||
|
| m5 | `agent read` 를 원래 이름으로 | 잡힘 (3 failed) |
|
||||||
|
|
||||||
|
실제 herdr 서버(pid 19169 `herdr server`, 23492 `herdr --session multi-agent-mux server`)는 테스트 전후로 같다.
|
||||||
|
|
||||||
|
## 3. 차단 결함
|
||||||
|
|
||||||
|
없음.
|
||||||
|
|
||||||
|
## 4. 비차단
|
||||||
|
|
||||||
|
- **N1**: 변환된 이름을 두 곳에서 따로 만든다(`HerdrSource.name` :162, `StallWatch.herdr_name` :229). 주입된 source 를 쓰는 테스트 때문에 둘 다 필요해 보인다. 결과가 같으므로 동작에는 문제가 없다.
|
||||||
|
- **N2** (기존, 이 PR 과 무관): `--agent` 가 비어 있고 이름이 `…-claude-01` 처럼 숫자로 끝나면 `resolve_agent` 는 종류를 정하지 못한다(None). 실제 호출자는 항상 `--agent "$AGENT"` 를 넘기므로 지금은 영향이 없다.
|
||||||
|
- **N3** (기존, 이 PR 과 무관): lib.sh 에 `_sanitize_herdr_agent_name` 이 두 벌 있다(:36, :293). sha 도구가 없을 때의 대처가 서로 다르다(:36 은 32자로 자르고, :293 은 python3 로 해시). 이 호스트처럼 shasum 이 있으면 두 벌 모두 Python 판과 같다.
|
||||||
|
|
||||||
|
[VERDICT: PASS]
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
# 🔍 Review Report — PR #38 병합 전 독립 리뷰 (job `107d435d`)
|
||||||
|
|
||||||
|
- **Reviewer**: `planner-reviewer-claude-01` (role: planner,reviewer) · 2026-09-25
|
||||||
|
- **대상**: PR #38 `fix/issue-34` **@ `ed293dc`**, base `main 7a4a0c2` (v4.7.3). 6커밋, 11파일 `+924/−16`, `git diff --check` 클린.
|
||||||
|
- **검증 경로**: `/var/folders/.../T/mam-review/loop-20260925T055502Z-a37cf1/1`. **identity 확인**: `HEAD = ed293dce…67cf`, `HEAD^{tree} = 671b0e59…899c` — 브리프와 일치.
|
||||||
|
- **안전 규칙 준수**: 검증 경로·소스 워크트리(`multi-agent-mux-wt/pr-38`)·본 체크아웃(`main 7a4a0c2`) 모두 수정·커밋·checkout 없음. 등록부 실험은 scratchpad 임시 등록부에서 `registry.py` 를 직접 호출(래퍼 미사용, 실제 herdr 에 잡 생성 없음), 러너 실험은 in-process(`env -u MAM_VERSION`, 임시 git 저장소), lib.sh 는 함수 추출 + `env -i`. 실제 `.mam`·데몬·세션·herdr 서버 조작 없음, push·merge·PR 상태 변경 없음. 이 pane 의 `HERDR_PANE_ID`/`HERDR_WORKSPACE_ID` 는 **읽기만** 했다.
|
||||||
|
|
||||||
|
## 결론 요약
|
||||||
|
|
||||||
|
**병합을 막을 결함은 없다.** 부모 기록·자손 대기·리뷰 근거 표·stale 가드·같은 서버 다른 워크스페이스 조회 규칙을 각각 독립 실험으로 확인했고, 새 테스트 17건 중 **16건이 수정 전 소스에서 실패**한다. 비차단 관찰 4건(K1–K4). 그중 K1(귀속되지 않은 열린 잡만 있을 때 판정 규칙 문구가 없음)은 브리프 항목 2·4 의 "충분한 근거" 질문에 직접 닿는다.
|
||||||
|
|
||||||
|
## 1. 부모 기록 (항목 1)
|
||||||
|
|
||||||
|
| 확인 | 결과 |
|
||||||
|
|---|---|
|
||||||
|
| `--parent-job` 검증 | `registry.py register`(`:540-544`) 가 등록부에 없는 부모를 `unknown parent job <id>` + rc 1 로 거부. 래퍼(`:217-226`)는 명시 `--parent-job` 을 그대로 넘겨 **같은 검증**에 걸리게 하고, 기본 `MAM_JOB_ID` 는 이 등록부에 그 레코드가 있을 때만 쓰며 없으면 `WARNING: … registering without a parent job` 후 부모 없이 등록 — 두 층의 규칙이 일치한다 |
|
||||||
|
| 자기 참조 | `--job-id X --parent-job X` 는 등록 시점에 X 가 없어 거부됨(실측). `update` 에는 `--parent-job` 이 없어 **부모는 등록 후 불변** → 정상 경로로는 순환이 만들어질 수 없다 |
|
||||||
|
| 순환 방어 | 레코드 파일을 직접 고쳐 `root.parent = grandchild` 순환을 만든 뒤 `descendants_of` 를 돌려도 종료한다(`seen` 집합, 방문 1회): root 기준 `[(child,1),(grandchild,2)]`, grandchild 기준 `[(root,1),(child,2)]` |
|
||||||
|
| `depth` | 저장하지 않고 BFS 에서 계산(자식 1, 손자 2 — 실측) |
|
||||||
|
| 비용 | `descendants_of` 는 폴링(3 s)마다 `list_jobs` **전체 스캔** 1회(레코드 수 N 의 JSON 읽기). `_subjobs_block` 은 회차마다 root 수만큼 스캔 + `unattributed_open_jobs` 1회. 수백 건이면 문제없고 수천 건이면 3 s 폴링당 수천 파일 읽기 — 등록부 정리 정책이 없는 오래된 제어 평면에서 체감될 수 있다(K4) |
|
||||||
|
| `MAM_JOB_TREE_MAX`(50) | 넘으면 `log_error` 후 **기다리지 않고** 리뷰로 간다. 안전한 쪽인가: 열린 자손은 `[하위 위임 잡]` 표에 OPEN 으로 나오고 SUBJOB_RULE 이 NOT PASS 를 지시하므로 "조용히 통과"는 아니다. 다만 51개 이상의 행이 프롬프트에 붙는다 |
|
||||||
|
|
||||||
|
## 2. 부모 ID 전달 (항목 2)
|
||||||
|
|
||||||
|
- 로컬 에이전트: 래퍼가 주입문 끝(`_instructions`, test18 문구 보존)과 `brief.md` 의 `Parent for sub-delegations` 줄(`:251`, `:427`), create 의 온보딩 주입문(`create_session.sh:646`)으로 알린다. 에이전트가 붙이지 않으면 추적이 빠지는 것은 사실이고, 안전망은 두 가지다: (a) `[귀속되지 않은 열린 잡]` 줄 — `unattributed_open_jobs` 가 "부모 없음 + pending/running + 이 루프가 만든 잡 제외 + creator 잡 등록 이후" 로 고른다(실측: 루프 자신의 뒤이은 잡은 제외, 남의 root 잡은 표시); (b) `--wait-open-jobs` 로 그 잡들까지 대기(기본 off — 같은 제어 평면의 무관한 잡이 루프를 잡을 수 있으므로; 문서와 일치).
|
||||||
|
- **K1 (비차단, 근거 충분성)**: 하위 잡이 하나도 없고 귀속되지 않은 열린 잡만 있을 때 블록은 `[하위 위임 잡] 없음 … [귀속되지 않은 열린 잡]: <id>(…)` 만 담고 **SUBJOB_RULE 을 붙이지 않는다**(`_subjobs_block` 의 `if not rows:` 분기 — 실측: `has rule: False`). SUBJOB_RULE 자체도 "하위 잡"만 말하고 귀속되지 않은 잡을 어떻게 볼지는 말하지 않는다. 즉 "creator 가 `--parent-job` 을 빠뜨린" 바로 그 경우에 리뷰어는 사실은 받지만 지시는 받지 못한다. 한 문장("귀속되지 않은 열린 잡이 이 작업의 하위 위임으로 보이면 OPEN 하위 잡과 같이 취급")을 stray 줄 뒤에 붙이면 닫힌다.
|
||||||
|
- 원격 러너: `MAM_JOB_ID` 가 자동 부모가 되지만 원격 호스트의 등록부에는 그 잡이 없을 수 있어 "있을 때만" 규칙이 맞다(래퍼 주석과 일치).
|
||||||
|
|
||||||
|
## 3. 자손 대기 (항목 3)
|
||||||
|
|
||||||
|
- `wait_for_job_tree` 는 실행(`:1291`)·수정(`:1520`)·순응(`:1591`) 세 creator 잡 모두에 걸리고, `round_roots` 에 누적된다.
|
||||||
|
- `MAM_CHILD_WAIT_S`(3900) 의 기준점은 `wait_for_descendants` 진입 시각 = **creator 잡 종결 시점**(문서와 일치). 초과 시 `log_warn` 후 False 를 돌려주지만 `wait_for_job_tree` 는 root 의 결과만 반환하므로 루프는 리뷰로 진행하고 열린 잡은 표에 OPEN 으로 남는다(설계대로).
|
||||||
|
- `interrupted`: 시그널 핸들러가 체크포인트 저장 후 `sys.exit(128+signum)` 하므로(`_handle_signal`) 폴링 루프의 `if self.interrupted: return False` 는 실제로는 닿지 않는 방어 코드다 — 무해.
|
||||||
|
- 분류(`_subjob_state`)와 `get_feedback` 의 실제 값: 래퍼는 마감 시 `close --request deadline_timeout` 뒤 `publish_event --event error --detail deadline_timeout`(inject 단계면 `agent_blocked`)을 남기고(`:928-930`), preflight/idle 실패는 `cancelled --detail session_busy` 또는 새 `target_not_found`(`:896`, `:952`), create 롤백은 `cancelled "rolled back by create_session"` 이다. `get_feedback` 은 마지막 `completed/error`(감사 로그) 또는 events.log 의 detail 을 돌려주므로 `ORCHESTRATOR_CLOSED` 튜플·`startswith("rolled back")`·`session_busy|target_not_found` 가 정확히 그 문자열들과 맞는다. 사유 없는 `error` 를 CLOSED-UNCONFIRMED 로 보는 규칙은 "에이전트 자신의 보고가 아니다" 는 뜻에서 보수적이며 옳다.
|
||||||
|
|
||||||
|
## 4. 리뷰어 근거 (항목 4)
|
||||||
|
|
||||||
|
in-process 실측(`_subjobs_block`):
|
||||||
|
|
||||||
|
| 상황 | 결과 |
|
||||||
|
|---|---|
|
||||||
|
| 하위 잡 없음·stray 없음 | **`''`** — 프롬프트 바이트 동일 |
|
||||||
|
| 이 루프가 뒤에 만든 잡(`created_jobs`)만 존재 | `''` — 리뷰어 자신의 잡·앞 회차 리뷰 잡은 stray 로 오인되지 않는다(회차의 블록은 리뷰 잡 위임 **전에** 계산된다) |
|
||||||
|
| 남의 root 잡(부모 없음, creator 잡 이후 등록) | 블록 생성, stray 줄에 id 표시, **규칙 문구 없음**(K1) |
|
||||||
|
| pending 자식 1개 | 표(`\| OPEN \| - \| 1 \|`) + 집계 + SUBJOB_RULE |
|
||||||
|
|
||||||
|
`descendants_of` 가 `depth` 를 주고 `_subjob_state` 가 상태·사유를 주므로 표의 열은 정확하다. 표는 앞 회차 creator 잡의 자손까지 매 회차 다시 읽는다(`round_roots` 누적, T9).
|
||||||
|
|
||||||
|
## 5. stale 가드 — 옵션 없는 루프 (항목 5)
|
||||||
|
|
||||||
|
`_fingerprint`(`runner.py:954-972`)는 `head` 와, `.mam`(`mam_dir`)·`jobs_dir`·감사 로그 디렉터리(`DELEGATE_JOB_LOGS_DIR`, 상대면 `workspace_root` 기준으로 결합)를 `git add -A -- . :(exclude)<rel>` 로 뺀 tree 다. in-process 실측(임시 git 저장소, **`.gitignore` 없음** — 즉 `.mam` 이 무시되지 않는 최악 조건):
|
||||||
|
|
||||||
|
| 상황 | 결과 |
|
||||||
|
|---|---|
|
||||||
|
| `DELEGATE_JOB_LOGS_DIR=.mam/delegate_job_logs`(상대) 로 감사 로그·`jobs/*.json` 을 새로 쓴 뒤 | 지문 **불변** — 제어 평면 쓰기는 제외된다 |
|
||||||
|
| 소스에 untracked 파일 1개 추가 | 지문 **변경** |
|
||||||
|
| 감사 로그 디렉터리가 소스 **밖**(절대경로) | `relative_to` 가 `ValueError` → 제외 목록에서 빠지고 지문 계산은 정상 |
|
||||||
|
|
||||||
|
- **켜지는 조건**: `light = self._fingerprint()` 는 `not identity and subjobs`(`:1350-1352`) — 하위 잡 표나 stray 줄이 있을 때만이다(1d4f338). 하위 위임 없는 루프는 옛 동작 그대로라 `test_tier4_e2e.py::test_e2e_scenario5` 류(소스에 매 잡 바뀌는 untracked 가 있는 경우)가 다시 살아난다 — 이번 실행에서 tier4 포함 통과(§7).
|
||||||
|
- **적용 지점**: 셀프 리뷰 뒤(`:1368`), 리뷰 판정 수집 뒤(`:1463`, identity 모드의 B7 과 `elif` 로 배타), 승격 직전(`:1595`, identity 모드의 B10 은 `:1613` 로 별도). identity 모드에서는 `light` 가 `None` 으로 남아 **이중 적용이 없다**.
|
||||||
|
- 지문을 만들 수 없으면(예: dirty 서브모듈) 경고 1회 후 가드 off — 옵션 없는 모드의 기존 동작으로 되돌아가는 것이라 수용 가능하다.
|
||||||
|
|
||||||
|
## 6. 같은 서버 다른 워크스페이스 위임 (항목 6)
|
||||||
|
|
||||||
|
`_herdr_scope_is_pane_inherited`(`lib.sh:1464-1473`)와 `mam_herdr` 의 `elif`(`:1485-1486`)를 함수 추출 + `env -i` 로 10가지 조합에 돌렸다(전부 설계와 일치):
|
||||||
|
|
||||||
|
| 호출 | 환경 | 결과 |
|
||||||
|
|---|---|---|
|
||||||
|
| `agent`/`has-session`/`kill-session`(`-L` 접두 포함) | `HERDR_WORKSPACE_ID == HERDR_PANE_ID` 의 워크스페이스, 같은 서버 | **WS id 만 제거**(pane·tab id 유지) |
|
||||||
|
| `new-session`, `pane split` | 동일 | **유지** — pane 배치·워크스페이스 패킹(#7) 불변 |
|
||||||
|
| `agent` | WS id ≠ pane 의 워크스페이스(어긋난 스코프) | 유지(의도된 스코프) |
|
||||||
|
| `agent` | pane id 없음(pane 밖 명시 스코프) | 유지 |
|
||||||
|
| `agent` | 기본 서버 pane → 이름 있는 서버 | **CROSS-all3** — #23 규칙이 `if` 로 먼저 잡아 세 id 모두 제거, 이 규칙은 `elif` 라 닿지 않는다(충돌 없음) |
|
||||||
|
| `agent` | 대상 미지정(앰비언트) | WS id 만 제거(같은 서버이므로 같은 취급) |
|
||||||
|
|
||||||
|
- 이 pane 의 실제 값도 규칙의 전제(`HERDR_PANE_ID = "<ws>:<pane>"`, `HERDR_WORKSPACE_ID = <ws>`)와 맞았다(값은 읽기만).
|
||||||
|
- 이름 유일성 근거(herdr `--skill` 문구)는 코드 주석에 인용돼 있고, T10/T11 이 두 방향을 고정한다.
|
||||||
|
- `target_not_found`: preflight 의 `has-session` 실패가 `PREFLIGHT_REASON` 을 세우고(`:952`) `finalize_unfinished` 가 `cancelled --detail target_not_found` 로 닫는다(`:896`). **rc 는 종전과 같은 5** 다(T12 가 `returncode == 5` 를 고정) — 즉 이벤트 detail 은 정확해졌지만 종료 코드는 "busy" 와 같아서, 루프의 busy 1회 재시도(`MAM_SESSION_BUSY_RETRY_S`)가 없는 대상에도 한 번 더 돌아간다(K3).
|
||||||
|
|
||||||
|
## 7. 새 테스트 (항목 7)
|
||||||
|
|
||||||
|
- **실제 경로**: T1·T12 는 실제 래퍼(`--agent human`, 도달 불가 브로커)와 `registry.py` 로 등록·종결한다. T3~T9 는 `python -m lib_py.loop` 를 하위 프로세스로 띄우고 가짜 래퍼가 **실제 래퍼**(`FAKE_REAL_WRAPPER`)로 하위 잡을 등록해 자손 대기·표·가드를 통과시킨다. T10~T12 는 mock herdr 위의 샌드박스. `HOME`·`WORKSPACE_ROOT`·`DELEGATE_JOB_LOGS_DIR` 는 `tmp_path`.
|
||||||
|
- **수정 없이는 실패하는가 — 확인함**: 검증 경로 사본에서 PR 이 바꾼 소스 6파일을 `7a4a0c2` 판으로 되돌리고 새 파일을 돌린 결과 **16 failed / 1 passed**(16.7 s). 통과 1건은 T11("호출자가 고른 워크스페이스 id 는 여전히 스코프") — 정정 전에도 성립해야 하는 불변 조건이다.
|
||||||
|
- **검증 경로에서의 실행** (9파일: 새 파일 + issue5 phase1·tier4 e2e·issue21 loop B/acceptance·issue23/24·loop_cli·issue25·herdr shim contract):
|
||||||
|
|
||||||
|
```
|
||||||
|
→ 1 failed, 243 passed in 1460.19s (0:24:20)
|
||||||
|
FAILED tests/test_issue23_24_startup_path.py::test_s7c_a_started_that_was_followed_by_a_terminal_status_still_counts
|
||||||
|
```
|
||||||
|
|
||||||
|
- 새 파일 17건 **전부 통과**, `test_tier4_e2e.py::test_e2e_scenario5`(브리프 항목 5 의 회귀) **통과**(873 s — 이 호스트에서 다른 스위트가 동시에 돌아 load avg 2.5 였다), registry CLI 스냅샷·help 캡처(issue5 phase1) 통과 → `--parent-job` 추가가 기존 CLI 계약을 깨지 않는다.
|
||||||
|
- **실패 1건은 부하 flake** 로 판정한다: s7c 는 create/registry 의 `started` 증거 시험이며 PR 은 그 경로를 건드리지 않았다(create 는 주입문에 문장 하나 추가뿐). 합동 실행에서 40 s 걸려 `_seed_job` 단계(`--ack-timeout 2`)에서 실패했고, **단독 재실행에서는 s7b 와 함께 2 passed in 10.8 s** 로 통과했다.
|
||||||
|
- **격리**: 실제 `.mam/jobs`·`.mam/delegate_job_logs` 전후 **차이 0 / 차이 0**. 검증 경로 tracked 파일 변화 없음(HEAD `ed293dc`), 본 체크아웃 `main 7a4a0c2`.
|
||||||
|
- **실행 시간**: 새 파일 자체는 되돌린 사본 기준 17 s 안팎이고, 합동 실행의 24 분은 tier4 e2e(약 17 분)와 호스트 부하 몫이다.
|
||||||
|
|
||||||
|
## 8. 문서 (항목 8)
|
||||||
|
|
||||||
|
- loop SKILL.md `:214`(`--wait-open-jobs` 표), `:221`(하위 위임 잡: 대기 범위·`MAM_CHILD_WAIT_S` 기준점·`MAM_JOB_TREE_MAX`·표/규칙·회차 누적), `:223`(stale 가드) — 코드와 일치한다.
|
||||||
|
- delegate-job SKILL.md `:49-53`·Best Practices, MULTI_AGENT_RULES(ko/en) `:154` — `--parent-job` 의무와 기본값·거부 규칙이 서술돼 있다.
|
||||||
|
- 래퍼 usage 와 `brief.md` 의 `Parent for sub-delegations` 줄, create 온보딩 주입문 — 모두 같은 문장이다.
|
||||||
|
|
||||||
|
## 9. 병합 차단 여부 (항목 9)
|
||||||
|
|
||||||
|
버전 락스텝 bump 부재(`lib.sh:32` `4.7.3`, 오케스트레이터가 4.7.4 로 처리 예정) 외에 **병합을 막을 결함은 없다**.
|
||||||
|
|
||||||
|
| # | 비차단 관찰 | 근거 / 권고 |
|
||||||
|
|---|---|---|
|
||||||
|
| K1 | 귀속되지 않은 열린 잡**만** 있을 때 블록에 SUBJOB_RULE 이 없고, 규칙 문구도 그 잡을 언급하지 않는다 — `--parent-job` 을 빠뜨린 바로 그 경우에 리뷰어가 지시 없이 사실만 받는다 | `runner.py:697-724` 의 `if not rows:` 분기, 실측 `has rule: False`. stray 줄 뒤에 한 문장 권고 |
|
||||||
|
| K2 | `MAM_JOB_TREE_MAX` 초과 시 대기를 건너뛰고 리뷰로 간다 — 표의 OPEN 행과 규칙이 NOT PASS 를 유도하므로 조용한 통과는 아니지만, 50+ 행이 프롬프트에 붙는다 | `:646-654`. 정보 |
|
||||||
|
| K3 | `target_not_found` 는 이벤트 detail 만 바뀌고 래퍼 rc 는 `session_busy` 와 같은 **5** 라 루프의 busy 1회 재시도가 없는 대상에도 돈다 | `:896`, `:952`, T12 `returncode == 5`. rc 분리 권고(후속) |
|
||||||
|
| K4 | `descendants_of`/`unattributed_open_jobs` 는 폴링(3 s)마다 등록부 전체 JSON 을 읽는다 — 수천 건 누적된 제어 평면에서 체감 가능 | `registry.py:259-282`. 등록부 정리 정책 또는 인덱스가 생기기 전까지는 정보 |
|
||||||
|
|
||||||
|
## 10. 교차검증 가이드
|
||||||
|
|
||||||
|
```bash
|
||||||
|
RP=/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20260925T055502Z-a37cf1/1
|
||||||
|
PY=/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.venv/bin/python
|
||||||
|
git -C "$RP" rev-parse HEAD 'HEAD^{tree}' # ed293dc… / 671b0e5…
|
||||||
|
# 등록부: 부모 기록·list --parent·미지 부모/자기 참조 거부 (임시 등록부, registry.py 직접)
|
||||||
|
# descendants_of: 손자 depth 2, 레코드 파일로 순환을 만들어도 종료
|
||||||
|
# 러너 in-process (env -u MAM_VERSION): _subjobs_block '' / stray / child 표, _fingerprint 의 .mam 제외
|
||||||
|
# lib.sh: _herdr_scope_is_pane_inherited 10케이스 (§6)
|
||||||
|
# 되돌린 사본: 소스 6파일을 7a4a0c2 판으로 → tests/test_issue34_job_tree.py → 16 failed / 1 passed
|
||||||
|
(cd "$RP" && AGENT_PYTHON_BIN="$PY" PYTHONDONTWRITEBYTECODE=1 "$PY" -m pytest -p no:cacheprovider -q --basetemp=/tmp/bt \
|
||||||
|
tests/test_issue23_24_startup_path.py::test_s7c_a_started_that_was_followed_by_a_terminal_status_still_counts) # 단독 통과
|
||||||
|
```
|
||||||
|
|
||||||
|
[VERDICT: PASS]
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"loop_id": "loop-20261008T133858Z-9e7f5b",
|
||||||
|
"base_sha": "6b00b6e06f55299cb60f156ccef09ede4bae5bdd",
|
||||||
|
"head_sha": "ac48cb78617af88584db529b5766acf02f4af73c",
|
||||||
|
"tree_sha": "b043034eda891bd83b547dcb34d7422a6977b89d",
|
||||||
|
"review_commit": "ac48cb78617af88584db529b5766acf02f4af73c",
|
||||||
|
"reviewer": "planner-reviewer-claude-01",
|
||||||
|
"job_id": "1364df46",
|
||||||
|
"verdict": "PASS",
|
||||||
|
"transport": "herdr",
|
||||||
|
"evidence": "review-tree",
|
||||||
|
"isolation": "worktree",
|
||||||
|
"verified_at": "2026-10-08T18:40:20Z"
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# 리뷰 리포트 — Job 1364df46 (Issue #17 후속 3회차: 상속 WORKSPACE_ROOT, 6b00b6e..ac48cb7)
|
||||||
|
|
||||||
|
- 리뷰어: planner-reviewer-claude-01 (claude)
|
||||||
|
- 리뷰 identity: loop-20261008T133858Z-9e7f5b / base 6b00b6e / head ac48cb7 / tree b043034 — 검증 경로(…/3)에서 `git rev-parse HEAD 'HEAD^{tree}'` 일치, 미커밋 변경 없음
|
||||||
|
- 범위: 2회차 head 8b47cc7 위에 커밋 1개(ac48cb7, +9줄): test_o2_race_free_lock.py 의 o2_12 에 테스트 단위 `own_workspace_root` 마크, test_o1_rebuttal.py 에 모듈 마크, W1 NODES 에 두 노드 추가. 제품 코드·버전·VERSIONS 변경 없음.
|
||||||
|
|
||||||
|
## 결론 요약
|
||||||
|
2회차의 차단 지적(test_o2_race_free_lock.py:196-203 이 run_loop.sh → lib.sh 로 자기 체크아웃의 `.mam/shim/herdr` 를 씀)이 닫혔고, grok 이 지적한 test_o1_rebuttal 도 같은 방식으로 덮였다. 2회차에서 깨끗했던 나머지 후보들은 그대로 깨끗하다. 차단 결함 없음.
|
||||||
|
|
||||||
|
## 1. 지적 종결
|
||||||
|
| 지적 | 수정 | 확인 |
|
||||||
|
|---|---|---|
|
||||||
|
| claude(2회차): o2_12 가 `dict(os.environ)` 로 run_loop.sh 실행, 마크·sandbox 없음 → 체크아웃 shim 재작성 | test_o2_race_free_lock.py:196-198 — o2_12 에만 `@pytest.mark.usefixtures("own_workspace_root")`(모듈 마크를 피한 이유: o2_7 이 tmp_path 항목 수를 센다 — 타당); W1 NODES 에 o2_12 추가(:41) | HEAD 사본에서 `WORKSPACE_ROOT=<사본>` 으로 o2_12 단독·파일 전체(22 passed) 모두 `.mam` **미생성**; `WORKSPACE_ROOT` 미설정으로도 미생성(2회차에는 두 경우 모두 생성) |
|
||||||
|
| grok: test_o1_rebuttal 도 run_loop.sh 실행 | 모듈 마크(:10-12), W1 NODES 에 v3 노드 추가(:42) | HEAD 사본 파일 전체(10 passed) 미생성. 새 W1 노드가 의미 있는지: base 트리에서 그 노드 1개를 `WORKSPACE_ROOT=<base 사본>` 으로 실행 → `.mam/shim/herdr` **생성** — 수정 전 결함을 실제로 고정한다 |
|
||||||
|
|
||||||
|
## 2. 놓친 모듈이 더 없는가 (2회차 방법 반복)
|
||||||
|
HEAD 트리 사본에서 `WORKSPACE_ROOT=<사본>` 으로 내 후보 13개 파일(test_o2, test_o1, test_issue30_stall_watch_stop, test_issue36_prompt_transport, test_issue19, test_a4, test_issue21_acceptance, test_issue20, test_issue34, test_issue23_24, test_b4, test_issue15, test_issue10_13; lib.sh 를 source 하는 코드를 실행하면서 sandbox 인자가 없는 테스트 50개)을 파일별로 돌리고 매번 `.mam` 을 확인: **13개 모두 clean**. 2회차에 유일하게 썼던 o2 가 이제 깨끗하다.
|
||||||
|
|
||||||
|
## 3. 할 일 4 — 실행 (검증 경로, 워크트리 .venv Python 3.9.6, `env -i` 로 HERDR_*·MAM_*·MQTT_* 제거)
|
||||||
|
| 실행 | 결과 |
|
||||||
|
|---|---|
|
||||||
|
| (a) WORKSPACE_ROOT 없이: 수정 16개 파일(W1·W2 포함) + test_issue33_budget, test_issue5_phase1_delegation, test_tier1_unit | **675 passed / 0 failed** (862 s) |
|
||||||
|
| (b) WORKSPACE_ROOT=<워크트리 밖 센티널>: 수정 16개 파일 | **522 passed / 0 failed** (707 s); 실행 뒤 센티널 **비어 있음** |
|
||||||
|
| 본 체크아웃 .mam/shim/herdr mtime | 전후 동일(1791480333); 실제 .mam/jobs 항목 수 불변 |
|
||||||
|
- W1 은 (a)(b) 양쪽에서 통과했고, 이제 15개 노드(모듈 15개)를 체크아웃 사본에서 돌려 `.mam` 미생성을 고정한다.
|
||||||
|
- 이식성: Python 테스트 코드만. bash 3.2·macOS 영향 없음.
|
||||||
|
|
||||||
|
## 4. 1·2회차 판단 유지
|
||||||
|
`_env` 의 tmp_path 지정(lib.sh:26 기본값 때문), conftest `pytest_configure` 의 "다른 체크아웃만 제거" 정책과 opt-in fixture 설계(autouse 불가 이유 포함), unittest 클래스의 명시 env, test_orc_onboard 의 호출 시점 root — 모두 이전 리포트대로 타당하다. W1·W2 가 base 에서 실패함은 2회차에 확인했다.
|
||||||
|
|
||||||
|
## 5. 비차단
|
||||||
|
- W1 의 모듈 목록은 수동 유지다(2회차 비차단 그대로). 새 테스트가 lib.sh 를 source 하는 모듈을 추가하면 목록도 늘려야 한다는 점을 conftest 의 fixture docstring 이나 W1 머리말에 한 줄 적어 두면 좋다.
|
||||||
|
- 새 관찰 없음.
|
||||||
|
|
||||||
|
[VERDICT: PASS]
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# 🔍 Cross-Review (Loop Final) — Issue #5 Phase 1 누적 변경분 `8def235..6ad6f08` — Job `1418f44c`
|
||||||
|
|
||||||
|
- **Reviewer**: `planner-reviewer-claude-01` (Reviewer role)
|
||||||
|
- **대상**: 브리프에 첨부된 누적 diff. `git diff 8def235..HEAD`(HEAD=`6ad6f08`, working tree clean)와 **바이트 동일**(639행, md5 `6880f4fb…`, 10 files, +348/−67)임을 확인했다. 내용은 (a) 커밋 `95f3e9a` — Round 1 교차 리뷰 지적(Codex R1/R2, Claude F-1/O-1, 픽스처·문서)의 수정, (b) 커밋 `6ad6f08` — Round 2 비차단 잔여 2건의 문서 반영(코드 변경 없음).
|
||||||
|
- **선행 검증**: `95f3e9a`는 내가 job `25609c81`에서 전 항목 독립 재검증해 `[VERDICT: PASS]` `[ADJUDICATION: SUSTAINED]`를 냈다. 본 job은 그 결론을 재사용하지 않고 HEAD 기준으로 핵심 검증을 다시 실행했으며(§2), 신규 커밋 `6ad6f08`을 별도로 검토했다(§3).
|
||||||
|
- **루프 컨텍스트**: 본 루프의 freeze 스냅샷(`mam-loop-freeze.RgjWtB`)은 `8def235` 시점 트리다(`lib.sh`·`publisher.py`·`publish_event.py`가 `8def235` blob과 동일, 생성 시각 16:29:14 = `8def235` 커밋 8초 후). 즉 루프는 수정 전 트리로 고정된 채 두 라운드의 수정 커밋을 검토했다 — B-13이 의도대로 동작한 사례다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 결론 요약
|
||||||
|
|
||||||
|
- `95f3e9a`의 수정 5건(F-1, O-1, R1, R2, 픽스처 정합화)은 HEAD에서 그대로 유효하다. 특성화·PV·회귀 66종 통과, 빠른 회귀 18종 통과, 범위 `fc0bd96..HEAD` `git diff --check` 클린, `bash -n`·`compileall` 클린(§2).
|
||||||
|
- `6ad6f08`은 문서 2파일 +4/−1로, 벤치 §4 fork 표의 경로 명칭을 실제 구현(`_delegation_py`)으로 정정하고 내 O-4를 `task.md` P3-02 인계 항목으로 기록했다. 두 서술 모두 코드·실측과 일치한다(§3).
|
||||||
|
- 자동완료 3표면(래퍼, `run_loop.sh`, `reconcile.sh`)은 이 범위에서 **0행** 변경.
|
||||||
|
- **본 job의 시작 이벤트 자체가 K2 실증이다**: 위임 메시지가 지정한 freeze 경로의 shim(`8def235` 버전)을 라이브 `PYTHONPATH` 환경에서 실행했고, 같은 조건을 재현해 출처를 출력하면 위임 패키지는 freeze, 부모 `lib_py`는 라이브로 바인딩된다(§2-4). 이벤트는 정상 발행됐다(`1418f44c.events.log` seq 1).
|
||||||
|
- 신규 결함 없음. 재계획(`[ESCALATE: PLANNER]`)이 필요한 사안 없음.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 범위 동일성
|
||||||
|
|
||||||
|
```
|
||||||
|
brief: 639 lines md5 6880f4fb3043921c3403b764ec5dca83
|
||||||
|
head : 639 lines md5 6880f4fb3043921c3403b764ec5dca83 (git diff 8def235..HEAD)
|
||||||
|
IDENTICAL
|
||||||
|
```
|
||||||
|
파일 10종: `lib.sh`, `lib_py/delegation/{bench,daemon,mqtt_common,publish_event,publisher}.py`, `multi-agent-mux-delegate-job/SKILL.md`, `docs/contracts/issue-5/{phase1-benchmark,task}.md`, `tests/test_issue5_phase1_delegation.py`. 브리프 본문의 과업 설명("265a1f6부터 dfa7212까지 … 54개 신규 테스트, 564개")은 루프 시작 시점의 목표 문구이며, 실제 첨부 diff·현재 수치(66/576)와는 위와 같이 대조했다.
|
||||||
|
|
||||||
|
## 2. HEAD 기준 재검증 (`95f3e9a` 항목)
|
||||||
|
|
||||||
|
### 2-1. F-1 / O-1 (내 Round 1 지적)
|
||||||
|
- `publisher.py:27-43` `_send()`가 `info.rc == MQTT_ERR_NO_CONN`만 `ConnectionError`로 번역, 재시도 튜플(`:56`) 불변. Round 2에서 실제 paho 2.1.0으로 재현·검증한 결과(3회 시도 모두 전송 도달, 끊김 2회 후 복구 성공, 다른 `rc`는 비재시도)는 HEAD의 동일 코드에 그대로 적용된다. paho `publish()`가 `info.rc`에 넣는 값은 `SUCCESS`/`QUEUE_SIZE`/`NO_CONN`뿐이고 `QUEUE_SIZE`는 이미 튜플 안의 `ValueError`이므로 번역은 완결적이다.
|
||||||
|
- `publish_event.py:94-102` `_publish_once` → `OneShotPublisher().publish_raw()` 위임, 타임아웃 상수 단일 소유. `test_one_shot_publish_has_a_single_implementation`이 재발을 막는다.
|
||||||
|
|
||||||
|
### 2-2. R1 / R2 (Codex 지적)
|
||||||
|
- `lib.sh:1952-1968` `_delegation_py`: `python -c` + `sys.path.insert(0, SKILL_DIR)`, cwd 보존, `PYTHONPATH` 전달 유지. Round 2에서 cwd=skills 디렉터리·decoy `lib_py` 조건으로 구형 `-m`은 라이브를, 신형은 자기 트리를 해석함을 실측했다. 프로덕션 cwd(워크스페이스 루트)에는 `lib_py`가 없어 R1은 잠재 결함이었으나 수정은 옳다.
|
||||||
|
- `publisher.py:140-158` 연결 정체성 10필드 불변 스냅샷 비교, 오류는 필드명만 노출.
|
||||||
|
|
||||||
|
### 2-3. 실행 결과 (HEAD, 직접 실행)
|
||||||
|
| 검증 | 결과 |
|
||||||
|
|---|---|
|
||||||
|
| `tests/test_issue5_phase1_delegation.py` | **66 passed in 11.63s** |
|
||||||
|
| 빠른 회귀(G-1~G-10, mqtt, seq, monitor lock) | **18 passed in 1.74s** |
|
||||||
|
| `git diff --check fc0bd96..HEAD` | 클린 |
|
||||||
|
| `bash -n` (`lib.sh`, `reconcile.sh`, 래퍼) / `compileall lib_py/delegation` | 클린 |
|
||||||
|
| 자동완료 3표면 `git diff --stat 8def235..HEAD` | 래퍼 0행, `run_loop.sh` 0행, `reconcile.sh` 0행 |
|
||||||
|
| 전체 스위트 | §4 |
|
||||||
|
|
||||||
|
### 2-4. K2 라이브 실증 (본 job의 실행 조건 그대로)
|
||||||
|
```
|
||||||
|
delegation -> /var/folders/.../mam-loop-freeze.RgjWtB/.agents/skills/lib_py/delegation
|
||||||
|
lib_py -> /Users/.../multi-agent-mux/.agents/skills/lib_py/__init__.py
|
||||||
|
```
|
||||||
|
`PYTHONPATH`=라이브 트리인 프로세스에서 freeze 경로의 `publish_event.py`(shim)를 실행한 뒤 출처를 출력한 결과다. 위임 패키지는 freeze, 부모는 라이브 — 계획 D2-1 K2 행 그대로이며, 형제 임포트 금지(D1(b))로 혼재가 무해함은 AST 테스트가 보장한다.
|
||||||
|
|
||||||
|
## 3. 신규 커밋 `6ad6f08` 검토 (문서 2파일, 코드 무변경)
|
||||||
|
|
||||||
|
| 변경 | 검증 |
|
||||||
|
|---|---|
|
||||||
|
| `phase1-benchmark.md:69-71` fork 표의 신규 경로 명칭을 `-m lib_py.delegation.cli` → `_delegation_py`(`python -c` + `sys.path.insert`)로 정정, `-m` 폐기 사유 각주 | `lib.sh:1960-1966` 실제 구현과 일치. fork 수 3은 `$(_delegate_py_bin)`, `$(_delegate_script)`, python으로 변함없음 — 정확 |
|
||||||
|
| `task.md:94` P3-02에 O-4 인계(freeze `lib.sh`의 `_delegate_py_bin`이 `.venv` 상향 탐색에 실패해 시스템 `python3`로 폴백; Phase 3에서 `WORKSPACE_ROOT`/`MAM_REAL_ROOT` 앵커링 필요) | 내 `25609c81` §6 O-4와 내용 일치. 전제 재확인: `mam-loop-freeze.RgjWtB/`에는 `.agents`만 있고 `.venv` 없음. 현재 freeze `lib.sh` 헬퍼를 호출하는 프로덕션 경로가 없다는 판단(래퍼는 `pick_python()`, 원샷 스크립트는 라이브 트리, `run_loop.sh`·래퍼는 헬퍼 미호출)도 Round 2에서 grep으로 확인한 그대로 |
|
||||||
|
|
||||||
|
커밋 메시지의 "The unanimous PASS was issued on 95f3e9a"는 사실과 부합한다(내 Round 2 PASS 포함). 문서 커밋을 별도로 분리해 코드 커밋의 검토 결과를 오염시키지 않은 점은 적절하다.
|
||||||
|
|
||||||
|
## 4. 전체 스위트 실행 결과
|
||||||
|
|
||||||
|
HEAD(`6ad6f08`)에서 직접 실행 (`.venv/bin/python -m pytest tests/ -q`, 백그라운드): **576 passed in 814.59s (0:13:34)**, 0 failed, 0 skipped. Round 2와 동일한 576(=510 기준선 + 57 Phase 1 + 9 Round 1 회귀). `6ad6f08`은 문서 전용이므로 수집 수 불변.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 결론
|
||||||
|
|
||||||
|
누적 변경분은 Round 1·2에서 세 리뷰어가 제기한 결함을 모두 수정한 `95f3e9a`와, 잔여 문서 항목을 정확히 반영한 `6ad6f08`로 구성된다. HEAD에서 핵심 검증을 재실행한 결과 회귀·린트·계약·자동완료 불변·freeze(B-13) 보존이 모두 유지되며, 본 job의 시작 이벤트 발행 자체가 freeze shim의 K2 동작을 실증했다. 재계획이 필요한 설계 결함은 없다.
|
||||||
|
|
||||||
|
[VERDICT: PASS]
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# 🔍 Cross-Review — Codex CLI Agent Backend, Post-Remediation (Job `175b653b`)
|
||||||
|
|
||||||
|
- **Reviewer**: `planner-reviewer-claude-01`
|
||||||
|
- **Target**: cumulative working-tree diff implementing `plan-e30e3123.md` Rev.2 — same codex integration I already reviewed as `[VERDICT: PASS]` in job `36148213`, now including a remediation for the blocking defect that `reviewer-creator-grok-01` (job `5bfd80b8`, NOT PASS) and `reviewer-opencode-01` (job `9cc33e22`, NOT PASS) both independently found *after* my earlier PASS.
|
||||||
|
- **Method**: Diff was truncated in the brief (221KB/3239 lines over the review limit), so per the brief's own instruction I inspected the working tree directly rather than the truncated text. I did not take any peer's verdict — including my own prior one — at face value; every claim below is re-verified against live files or live test runs in this pass.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. What changed since my prior PASS (job `36148213`)
|
||||||
|
|
||||||
|
File-level diff stat confirms the core implementation is **untouched** since my last review: `codex.py`, `registry.py`, `lib.sh`, all 6 skill scripts, and all 7 codex-specific test additions show the same shape as before. The only substantive addition is to `.agents/reports/planner-reviewer-claude-01/plan-de667f0f.md` (+246/-9 lines) plus a one-line Status-field edit to the Codex plan.
|
||||||
|
|
||||||
|
## 2. Independently confirming the blocking defect Grok/OpenCode found (and I missed)
|
||||||
|
|
||||||
|
My own `36148213` review checked code correctness and test results but never inspected whether the archived plan-document *paths* themselves were still intact — a real gap in my prior pass. Re-derived from primary evidence, not from trusting either NOT-PASS report's narrative:
|
||||||
|
|
||||||
|
- `git show HEAD:.agents/reports/implementation_plan.md` → the tracked HEAD content is the **full 249-line OpenCode Rev.3 plan**.
|
||||||
|
- Before this fix, `.agents/reports/planner-reviewer-claude-01/plan-de667f0f.md` (the document that's supposed to be the OpenCode Rev.3 plan's *permanent, namespaced* archive per its own Rev.3 changelog text) was only a 16-line stub reading "**Durable output (updated in place)**: [.agents/reports/implementation_plan.md]" — i.e. it pointed at the shared mirror instead of containing the content itself.
|
||||||
|
- I (as Planner, in job `e30e3123`) overwrote `.agents/reports/implementation_plan.md` with the Codex plan. Since the stub was the *only* other reference, this action would have deleted the OpenCode Rev.3 plan's full text from the working tree entirely (recoverable only via `git show HEAD:...`, not from any live path) — a genuine 유실 (data-loss) defect, and, per `reviewer-opencode-01`'s report, the same recurring failure class as two earlier incidents in this repo's history (`aca0b7e8`, job `56120ad3`'s F1).
|
||||||
|
|
||||||
|
## 3. Verifying the remediation is complete and correct — not just present
|
||||||
|
|
||||||
|
- `git diff -- .agents/reports/planner-reviewer-claude-01/plan-de667f0f.md`: the file now contains a 4-line `> [!NOTE]` banner followed by the complete original OpenCode Rev.3 text.
|
||||||
|
- Byte-level check: `diff <(tail -n +5 <git-show-HEAD-copy>) <(tail -n +5 plan-de667f0f.md)` → **empty diff** — the restored body is character-for-character identical to the original tracked content, not a paraphrase or a partial/garbled restore. Line counts confirm exactly (249 original + 4-line banner = 253).
|
||||||
|
- `.agents/reports/implementation_plan.md` still holds the Codex plan (the mirror pairing for the *current* plan is preserved) — `diff` against `.agents/reports/planner-reviewer-claude-01/plan-e30e3123.md` returns empty, confirming the two stay in lockstep as intended.
|
||||||
|
- The Codex plan's Status line was updated from the stale "Plan only — no code changed in the repo" to "Implemented (v4.2.0) — full code, tests, and version lockstep present in repo" — resolving `reviewer-opencode-01`'s third (non-blocking) point.
|
||||||
|
- Swept for the same fragile pattern elsewhere: `grep -rl "Durable output (updated in place)"` finds 4 more stub-style archives (`plan-264c3b5d.md`, `plan-a33a133e.md`, `plan-28f9b565.md`, `plan-a348e91c.md`), but none of them point at `implementation_plan.md` or any other file currently targeted by an active mirror pattern — they're a pre-existing, unrelated hygiene concern, not a live risk this diff introduces or needs to fix. Noting it for awareness, not counting it against this PR.
|
||||||
|
|
||||||
|
## 4. Re-confirming the underlying codex implementation is still sound
|
||||||
|
|
||||||
|
Did not simply assume "unchanged code = still correct" — re-ran verification fresh against the current tree:
|
||||||
|
- `pytest tests/test_version_consistency.py tests/test_a4_adapter_contract.py -k "codex or version"` → 6 passed.
|
||||||
|
- Full suite, run by me just now: `pytest tests/ -q` → **462 passed, 0 failed, 819.92s (13m39s)**.
|
||||||
|
- Version lockstep re-confirmed at `4.2.0` (`lib.sh:32`, `VERSIONS.md`, all 8 `SKILL.md`).
|
||||||
|
|
||||||
|
## 5. Standing minor finding (unchanged, still non-blocking)
|
||||||
|
|
||||||
|
The dormant escaping bug I flagged in `36148213` (`tests/conftest.py`'s codex mock writes a literal `\\n` instead of a real newline in the mock rollout JSONL, ~line 658) is still present and still dormant for the same reason (`verify_artifact()`'s DB-first path never parses that file in any current test). Not fixed in this round; still recommend a one-line follow-up, still not blocking.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Verdict
|
||||||
|
|
||||||
|
The blocking defect that correctly earned this diff two NOT PASS votes has been remediated, and I independently verified the fix is complete, byte-faithful, and doesn't introduce any new problem — not merely that a fix file exists. The underlying codex implementation remains fully correct per a fresh full-suite run (462/462). One pre-existing minor test-fixture nit remains open as a non-blocking follow-up.
|
||||||
|
|
||||||
|
[VERDICT: PASS]
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"loop_id": "loop-20260924T085626Z-b8987e",
|
||||||
|
"base_sha": "59be1c89209a232059aec0c5aeecc2329929cb49",
|
||||||
|
"head_sha": "708262a598c4301c0ccc70efba5401093b4b1e30",
|
||||||
|
"tree_sha": "d601c2d01bb4c09931e5e1989e140b01d51f5b96",
|
||||||
|
"review_commit": "708262a598c4301c0ccc70efba5401093b4b1e30",
|
||||||
|
"reviewer": "planner-reviewer-claude-01",
|
||||||
|
"job_id": "1a9fbbc4",
|
||||||
|
"verdict": "PASS",
|
||||||
|
"transport": "herdr",
|
||||||
|
"evidence": "review-tree",
|
||||||
|
"isolation": "worktree",
|
||||||
|
"verified_at": "2026-09-24T09:09:43Z"
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# 🔍 Review Report — PR #37 델타 리뷰 2회차 (job `1a9fbbc4`)
|
||||||
|
|
||||||
|
- **Reviewer**: `planner-reviewer-claude-01` (role: planner,reviewer) · 2026-09-24
|
||||||
|
- **대상 델타**: `59be1c8..708262a` (1커밋, 3파일 `+63/−7`, `git diff --check` 클린), base `main 1debc7d` (v4.7.2).
|
||||||
|
- **검증 경로**: `/var/folders/.../T/mam-review/loop-20260924T085626Z-b8987e/1`. **identity 확인**: `HEAD = 708262a5…1e30`, `HEAD^{tree} = d601c2d0…5b96` — 브리프와 일치.
|
||||||
|
- **1회차 내 판정**: PASS + H1~H5 (job `4ab12ae2`). 이번에는 H1(러너 쓰기 실패)·H3(빈 파일)·S9 이식성의 정정을 본다.
|
||||||
|
- **안전 규칙 준수**: 검증 경로·소스 워크트리·본 체크아웃(`main 1debc7d`) 모두 수정·커밋·checkout 없음. 러너 실험은 in-process 스텁, 래퍼는 `--dry-run`+`--agent human`+도달 불가 브로커, 되돌린 사본은 scratchpad. 실제 herdr·세션·`.mam`·데몬 조작 없음, push·merge·PR 상태 변경 없음.
|
||||||
|
|
||||||
|
## 결론 요약
|
||||||
|
|
||||||
|
**세 정정 모두 지적을 닫고, 1회차 판정을 뒤집을 요소는 없다.** 내가 1회차에서 H1 을 재현했던 바로 그 스크립트를 정정된 러너에 다시 돌려 "실패 제출·부분 파일 없음·래퍼 미실행"을 확인했고, 정상·exec 실패·쓰기 실패 세 경로에서 파일이 **정확히 한 번**만 지워지는 것도 확인했다. 비차단 관찰 1건(I1).
|
||||||
|
|
||||||
|
## 1. R1/H1 — 러너의 쓰기 실패 (항목 1)
|
||||||
|
|
||||||
|
`runner.py:472-491`: `mkdir`·`NamedTemporaryFile`·`write` 가 하나의 `try` 로 들어가고, `prompt_file` 은 `""` 로 초기화된 뒤 파일이 생기는 즉시(`write` 이전) 이름을 받는다. `except OSError` 는 이름이 있으면 지우고 `CompletedProcess(…, 126, "", "cannot write the prompt file under <mam_dir>: <err> (prompt N bytes)")` 를 돌려준다. exec 용 `try/finally`(`:504-516`)는 그 뒤에 따로 있어 두 블록이 겹치지 않는다.
|
||||||
|
|
||||||
|
### 실측 (검증 경로 러너 in-process)
|
||||||
|
|
||||||
|
| 경로 | 결과 |
|
||||||
|
|---|---|
|
||||||
|
| **쓰기 실패**(1회차 H1 재현 스크립트 그대로: `write` 가 `OSError(28)`) | `delegate_job` 이 **None** 을 반환(예외 없음), 로그 `cannot write the prompt file under …: [Errno 28] No space left on device (prompt 5 bytes)`, `.mam` 잔존 **0**, 래퍼 호출 **0** |
|
||||||
|
| 정상 | 래퍼 호출 시점에 파일 존재·내용 `'hello'`·권한 **0600**, `os.unlink` 호출 **1회**, 잔존 0 |
|
||||||
|
| exec 실패(`subprocess.run` 이 `OSError(2)`) | rc 126 경로, `os.unlink` 호출 **1회**, 잔존 0, 반환 None |
|
||||||
|
|
||||||
|
- 반환하는 `CompletedProcess` 는 exec 실패와 같은 rc 126 이라 기존 처리(`submit failed (exit 126)` 로그, busy 재시도 없음, id 없음 → `failed_submit_jobs` 미등록·None 반환 → 호출자 `return 1`)에 그대로 합류한다. S4c 가 `submit failed (exit 126)`·`cannot write the prompt file`·`Traceback` 부재·래퍼 미실행·잔존 없음을 한꺼번에 단언한다.
|
||||||
|
- `NamedTemporaryFile` 생성 자체가 실패하면 `prompt_file` 이 빈 채로 `except` 에 들어가 `unlink` 를 건너뛴다 — 지울 것이 없으니 맞다. `mkdir` 실패도 같은 블록이다.
|
||||||
|
|
||||||
|
## 2. H3 — 빈 `--prompt-file` 거부 (항목 2)
|
||||||
|
|
||||||
|
`multi-agent-mux-delegate-job:193` 의 `[[ -s "$PROMPT_FILE" ]]` 는 **래퍼 `submit` 의 입력**에만 걸린다. 정당한 빈 프롬프트 사용처를 막는지 실측:
|
||||||
|
|
||||||
|
| 경로 | 결과 |
|
||||||
|
|---|---|
|
||||||
|
| 래퍼 `submit --prompt-file empty.md` | `submit: --prompt-file <절대경로> is empty`, rc 1 (S1e) |
|
||||||
|
| 래퍼 `submit --prompt-file newline.md`(개행 1바이트) | 등록됨 — `-s` 는 바이트 크기 기준이라 공백만 있는 파일은 통과한다(I1) |
|
||||||
|
| 래퍼 `submit --prompt ""` | 종전대로 `submit requires --prompt or --prompt-file` 거부 — 텍스트 형식과 대칭 |
|
||||||
|
| `registry.py register --prompt-file empty.md`(직접) | 등록됨(rc 0) — 등록부는 그대로 |
|
||||||
|
| loop 형 회차의 `update --prompt-file "$job_dir/prompt.md"` | `cmd_submit` 을 거치지 않으므로 검사 밖(변경 없음) |
|
||||||
|
|
||||||
|
즉 새 검사는 "빈 텍스트를 거부하던" 기존 규칙을 파일 형식으로 확장한 것이고, 등록부·loop 회차 경로는 건드리지 않는다. 막히는 정당한 사용처는 없다.
|
||||||
|
|
||||||
|
## 3. R2/N1 — S9 의 macOS 이식성
|
||||||
|
|
||||||
|
`sys.platform == "linux"` 일 때만 `File too large` 를 단언하고, rc≠0·`prompt-shloop-*` 잔존 없음·`delegate_job_safe: cannot write the prompt file` 은 모든 플랫폼에서 단언한다. 1회차에서 내가 하네스를 직접 돌려 확인한 macOS 동작(rc 1·잔존 없음·원인 문구 출력)과 정확히 일치하는 계약이다.
|
||||||
|
|
||||||
|
## 4. 테스트 (항목 1·3)
|
||||||
|
|
||||||
|
### 4-1. 수정 없이는 실패하는가 — 확인함
|
||||||
|
|
||||||
|
검증 경로 사본에서 `runner.py` 와 래퍼만 `59be1c8` 판으로 되돌리고 `-k "s4c or s1e or s9 or s4 or s1"` 을 돌렸다:
|
||||||
|
|
||||||
|
```
|
||||||
|
2 failed, 6 passed, 1 skipped in 3.11s
|
||||||
|
FAILED test_s4c_a_prompt_file_that_cannot_be_written_is_a_failed_submit ← H1
|
||||||
|
FAILED test_s1e_an_empty_prompt_file_is_refused ← H3
|
||||||
|
```
|
||||||
|
|
||||||
|
S9 는 되돌린 사본에서도 통과한다 — `run_loop.sh` 쪽 수정은 이미 `59be1c8` 에 있었고 이번 델타는 테스트의 단언만 이식 가능하게 바꿨기 때문이다(macOS 에서 통과 = R2 정정 확인). S1/S1c/S1d/S4/S4b 는 양쪽 모두 통과(불변 조건).
|
||||||
|
|
||||||
|
### 4-2. 검증 경로에서의 실행
|
||||||
|
|
||||||
|
```
|
||||||
|
tests/test_issue36_prompt_transport.py tests/test_issue5_phase1_delegation.py tests/test_loop_cli.py
|
||||||
|
tests/test_issue14_delivery_ack.py tests/test_issue25_plan_reuse.py
|
||||||
|
→ 127 passed, 1 skipped in 215.94s failed 0 (skip = linux_only S1b)
|
||||||
|
```
|
||||||
|
|
||||||
|
1회차의 유일한 실패였던 S9 가 이 호스트에서 통과하고, 새 파일 24건(23 통과 + Linux 전용 skip 1)과 인접 계약 스위트가 모두 통과한다.
|
||||||
|
|
||||||
|
### 4-3. 격리
|
||||||
|
|
||||||
|
| 항목 | 결과 |
|
||||||
|
|---|---|
|
||||||
|
| 실제 `.mam/jobs` 전후 | **차이 0** |
|
||||||
|
| `prompt-*` 잔존(실제 `.mam` / 검증 경로 `.mam`) | **0 / 0** |
|
||||||
|
| 검증 경로 tracked 파일 | 변화 없음(HEAD `708262a`), 본 체크아웃 `main 1debc7d` |
|
||||||
|
|
||||||
|
## 5. 병합 차단 여부 (항목 3)
|
||||||
|
|
||||||
|
1회차 PASS 를 뒤집을 요소는 없다. 델타는 러너의 `try` 범위 확장(`runner.py:472-491`), 래퍼의 `-s` 한 줄(`:193`), 테스트 3건(S4c·S1e·S9 조건부 단언)뿐이고, 정정 대상만 정확히 실패→통과로 바뀌었다. 버전 락스텝 bump 부재(`4.7.2`, 오케스트레이터가 4.7.3 으로 처리 예정) 외에 **병합을 막을 결함은 없다**. 1회차 H2(loop SKILL.md 의 `MAM_DIFF_MAX_*` 의미)·H4(`$PROMPT` 후행 개행)는 이 델타 범위 밖이라 그대로 열려 있다.
|
||||||
|
|
||||||
|
| # | 비차단 관찰 | 근거 |
|
||||||
|
|---|---|---|
|
||||||
|
| I1 | `-s` 는 바이트 크기 검사라 개행·공백만 있는 프롬프트 파일은 통과한다. 텍스트 형식(`--prompt ""`)의 거부와 완전히 대칭이려면 `[[ -n "$(tr -d '[:space:]' < "$PROMPT_FILE")" ]]` 류가 필요하지만, 실무에서 그런 입력은 없다 | `multi-agent-mux-delegate-job:193`, 실측 |
|
||||||
|
|
||||||
|
## 6. 교차검증 가이드
|
||||||
|
|
||||||
|
```bash
|
||||||
|
RP=/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20260924T085626Z-b8987e/1
|
||||||
|
PY=/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.venv/bin/python
|
||||||
|
git -C "$RP" rev-parse HEAD 'HEAD^{tree}' # 708262a… / d601c2d…
|
||||||
|
# H1 재현 스크립트(1회차 §1 의 것)를 정정된 러너에 → returned None, leftover [], wrapper called 0
|
||||||
|
# 되돌린 사본: runner.py·래퍼를 59be1c8 판으로 → -k "s4c or s1e or s9 or s4 or s1" → 2 failed / 6 passed / 1 skipped
|
||||||
|
(cd "$RP" && AGENT_PYTHON_BIN="$PY" PYTHONDONTWRITEBYTECODE=1 "$PY" -m pytest -p no:cacheprovider -q --basetemp=/tmp/bt tests/test_issue36_prompt_transport.py) # 23 passed, 1 skipped
|
||||||
|
```
|
||||||
|
|
||||||
|
[VERDICT: PASS]
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"loop_id": "loop-20261027T201245Z-63c71b",
|
||||||
|
"base_sha": "1d63e882bc1c46e9af8484647d5091b296d8fa3f",
|
||||||
|
"head_sha": "ec7f5c4e57286fb3c4238b8220f39094f4640391",
|
||||||
|
"tree_sha": "fbadb24de60ccdae684b23e2e0a41cdf23ab81eb",
|
||||||
|
"review_commit": "ec7f5c4e57286fb3c4238b8220f39094f4640391",
|
||||||
|
"reviewer": "planner-reviewer-claude-01",
|
||||||
|
"job_id": "1d2c21dc",
|
||||||
|
"verdict": "PASS",
|
||||||
|
"transport": "herdr",
|
||||||
|
"evidence": "review-tree",
|
||||||
|
"isolation": "worktree",
|
||||||
|
"verified_at": "2026-10-27T21:06:27Z"
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
# PR #75 후속 라운드 리뷰 — 1d63e88..ec7f5c4 (review round 5)
|
||||||
|
|
||||||
|
리뷰어: planner-reviewer-claude-01 (claude) · job 1d2c21dc
|
||||||
|
|
||||||
|
## 0. 리뷰 경로 확인
|
||||||
|
|
||||||
|
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261027T201245Z-63c71b/1`
|
||||||
|
- HEAD `ec7f5c4e57286fb3c4238b8220f39094f4640391`, tree `fbadb24de60ccdae684b23e2e0a41cdf23ab81eb` 로 [리뷰 identity] 와 같다.
|
||||||
|
- `git status --porcelain --ignored` 는 비어 있다. `.mam` 은 없다.
|
||||||
|
- 리뷰 전후로 확인했다. 본 체크아웃 main 은 `0e1e2e8` 그대로다.
|
||||||
|
- 범위: 1d63e88..HEAD 의 커밋 `ec7f5c4` 하나다.
|
||||||
|
- 바뀐 파일: `.agents/skills/lib.sh`(+7/−1), `tests/test_issue72_update.py`(+32/−3)
|
||||||
|
- 브리프가 정한 범위(R2 와 테스트의 `pkill` 제거) 밖은 건드리지 않았다.
|
||||||
|
|
||||||
|
## 1. R2 — legacy `start_watchdog` 의 프로세스 그룹 분리
|
||||||
|
|
||||||
|
### 1.1 코드 (`lib.sh:2297-2310`)
|
||||||
|
|
||||||
|
- 모니터를 띄우는 `nohup bash … &` 직전에 `set -m` 을 켠다.
|
||||||
|
- 켜기 전에 호출자의 `$-` 에 `m` 이 있었는지 `had_m` 에 기억한다.
|
||||||
|
- `pid=$!` 를 읽은 뒤, 원래 꺼져 있었으면 `set +m` 으로 되돌린다.
|
||||||
|
- job control 이 켜진 상태에서 띄운 백그라운드 잡은 자기 프로세스 그룹의 리더가 된다(PGID = PID). 그래서 다음 효과가 있다.
|
||||||
|
- `$!` 는 그대로 그 PID 다. nohup 과 bash 가 exec 해도 PID 는 바뀌지 않는다.
|
||||||
|
- 따라서 정확한 PID 보고(`echo "$pid"`, `:2314-2316`)와 `mam_monitor_started` 확인이 바뀌지 않는다.
|
||||||
|
- 다음 기존 동작은 바뀌지 않았다.
|
||||||
|
- 중복 방지(`_monitor_pids`, `:2291`)
|
||||||
|
- `MAM_NO_SUBSCRIBER_DAEMON=1` 분기(`:2295-2296`)
|
||||||
|
- stdin `/dev/null`, 로그 append(`>> "$log_file"`), 작업 공간 cwd, `--idle-timeout 0`
|
||||||
|
- 바뀐 것은 분기 안의 세 줄뿐이다.
|
||||||
|
- 호출하는 곳은 모두 `$(…)` 안이다. 그래서 `set -m` 은 그 서브셸 안에서만 잠깐 켜진다.
|
||||||
|
- `update.sh:106`, `post_update.sh:41`, `install_phase.sh:64`
|
||||||
|
- `create_session.sh:748`, `resume_session.sh:162`
|
||||||
|
|
||||||
|
### 1.2 이 Mac 에서 직접 확인 (`/bin/bash` 3.2, tty 없음, `set -euo pipefail`)
|
||||||
|
|
||||||
|
- 같은 모양의 함수를 스크래치 스크립트로 만들어 돌렸다.
|
||||||
|
- 결과 1: `$(f)` 안에서 띄운 자식의 PGID 는 자기 PID 와 같다(53727 = 53727). 호출자의 PGID(53714)와 다르다.
|
||||||
|
- 결과 2: 직접 호출한 경우도 마찬가지다.
|
||||||
|
- 결과 3: 호출 뒤 `$-` 는 `ehuB` 로 그대로다. 곧 `m` 이 남지 않는다.
|
||||||
|
- 결과 4: "no job control", "[1] PID", "Done" 같은 잡 메시지가 stdout 에도 stderr 에도 나오지 않았다. 그래서 `$(…)` 로 받는 PID 출력이 오염되지 않는다.
|
||||||
|
- **고치기 전 코드에서 실패하는지도 확인했다.**
|
||||||
|
- 방법: `git archive HEAD` 로 TMPDIR 에 사본을 풀었다. 그 사본의 `lib.sh` 만 1d63e88 판으로 바꿨다.
|
||||||
|
- 그 사본에서 `-k test_every_restart` 를 실행했다.
|
||||||
|
- 결과: **mode=0 의 3개(installed, killed, rolled-back)가 `left in the caller's group` 로 실패했다. mode=1 의 3개는 통과했다(3 failed, 3 passed).**
|
||||||
|
- 즉 새 assertion(`tests/test_issue72_update.py:723-724`)이 R2 를 실제로 잡는다. 수정 뒤 6개가 모두 통과한다(§3).
|
||||||
|
- 사본은 확인 뒤 지웠고, 남은 프로세스도 없다.
|
||||||
|
- 세 경로와 두 모드: 정상 설치, 정지 시간 초과 뒤 복원, rollback 뒤 복원을 mode 0·1 로 돌리는 6 조합이 모두 같은 함수를 지나고, 모두 통과한다.
|
||||||
|
- rollback 경로는 되돌린 트리의 `lib.sh` 를 쓴다. 테스트 상자에서는 그 트리가 이 버전이다.
|
||||||
|
|
||||||
|
### 1.3 python 경로와의 차이 (비차단, 참고)
|
||||||
|
|
||||||
|
- 두 경로가 분리하는 정도가 다르다.
|
||||||
|
- python 경로(`daemon.py:95`): `start_new_session=True` 로 새 **세션**을 만든다. 그래서 제어 터미널에서도 떨어진다.
|
||||||
|
- legacy 경로: 새 **프로세스 그룹**만 만든다. 세션과 제어 터미널은 호출자와 같다.
|
||||||
|
- 결정 2 의 요구("부른 쪽 프로세스 그룹에서 분리")는 이것으로 충족된다.
|
||||||
|
- 프로세스 그룹 신호(테스트 러너·timeout 래퍼의 killpg, 터미널의 SIGINT)는 이제 모니터에 닿지 않는다. 터미널 신호는 전경 그룹에만 가는데, 모니터는 배경 그룹이다.
|
||||||
|
- 터미널이 닫힐 때의 SIGHUP 은 nohup 이 무시한다.
|
||||||
|
- 배경 그룹이 tty 에 읽고 쓸 때 생기는 SIGTTIN/SIGTTOU 도 없다. stdin 은 `/dev/null` 이고 출력은 로그 파일로 간다.
|
||||||
|
- `lib.sh:246-253`(shim 서버 기동)은 같은 문제에 `start_new_session=True` 를 쓰는 관례를 적어 두었다. legacy 경로만 `set -m` 을 쓴다.
|
||||||
|
- 브리프가 "가장 작은 수정" 을 요구했고, legacy 경로는 opt-in 이므로 결함으로 보지 않는다.
|
||||||
|
|
||||||
|
## 2. 테스트의 `pkill` 제거 (`tests/test_issue72_update.py:184-194`, `:218-240`)
|
||||||
|
|
||||||
|
### 2.1 새 teardown 이 정리하는 것
|
||||||
|
|
||||||
|
- 패턴 kill 이 없어졌다. `tests/test_issue72_*.py` 전체에 `pkill`·`killall` 이 남아 있지 않다(grep 으로 확인).
|
||||||
|
- 대신 다음 순서로 정리한다.
|
||||||
|
1. 테스트가 `Box.helper`(`:132-135`, `start_new_session=True`)로 띄우고 `b.helpers` 에 기록한 프로세스의 그룹을 `killpg` 하고 `wait` 한다.
|
||||||
|
2. 이 테스트의 작업 공간 `ws` 에 대해 `bash <ws>/…/reconcile.sh --subscribe` 형태로 정확히 맞는 모니터(`_exact_monitor_pids`)와 그 자손(`_descendants`, `pgrep -P` 재귀)만 SIGKILL 한다.
|
||||||
|
- 남은 프로세스 확인: 명령줄에 `ws` 가 들어 있는 살아 있는 프로세스가 10초 안에 없어지지 않으면 이름을 적어 실패한다(`_processes_naming`, `:236-240`).
|
||||||
|
- `ws` 는 basetemp 아래의 테스트별 경로다. 그래서 다른 작업 공간의 프로세스와 겹치지 않는다.
|
||||||
|
|
||||||
|
### 2.2 남은 프로세스 확인이 약해지지 않았는지
|
||||||
|
|
||||||
|
- 예전 `pkill -KILL -f <ws>` 는 `ws` 를 명령줄에 가진 것을 **조용히 죽였을** 뿐이다. 남은 프로세스를 확인하지는 않았다.
|
||||||
|
- 새 방식은 정확히 고른 것만 죽인다. 나머지 가운데 `ws` 를 가진 프로세스가 남으면 **실패로 드러낸다**.
|
||||||
|
- 그래서 확인은 더 강해졌다.
|
||||||
|
- 대가가 하나 있다. 그런 프로세스가 생기면 정리되지 않고 남는다. 다만 이름이 실패 메시지에 나오므로 사람이 정리할 수 있다.
|
||||||
|
- `ws` 를 명령줄에 갖지 않은 손자(예: stub 모니터의 `sleep 120`)는 두 방식 모두 패턴으로는 못 찾는다.
|
||||||
|
- 새 방식은 모니터의 자손으로 찾아서 정리한다. 따라서 이 부분도 약해지지 않았다.
|
||||||
|
- 이번 실행에서 teardown 실패는 없었다. 실행 뒤 `ps` 로 봐도 테스트가 남긴 프로세스가 없다(§3).
|
||||||
|
|
||||||
|
## 3. 실행한 테스트 (이 Mac: macOS, `/bin/bash` 3.2, `/usr/bin/python3` 3.9.6)
|
||||||
|
|
||||||
|
```
|
||||||
|
cd <검증 경로>
|
||||||
|
env -u HERDR_SOCKET_PATH -u HERDR_PANE_ID -u HERDR_TAB_ID -u HERDR_WORKSPACE_ID -u HERDR_SESSION_NAME \
|
||||||
|
-u HERDR_SESSION -u WORKSPACE_ROOT -u MAM_ENV_FILE -u MAM_JOBS_DIR -u DELEGATE_JOB_LOGS_DIR \
|
||||||
|
PYTHONDONTWRITEBYTECODE=1 /Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.venv/bin/python \
|
||||||
|
-m pytest -p no:cacheprovider --basetemp="$TMPDIR/claude-j1d2c-bt" -q -rfs \
|
||||||
|
tests/test_issue72_update.py tests/test_issue19_subscriber_daemon_lifecycle.py \
|
||||||
|
tests/test_issue5_phase1_delegation.py
|
||||||
|
```
|
||||||
|
|
||||||
|
- 결과: **132 passed, 0 failed, 0 skipped** (191 s)
|
||||||
|
- `test_every_restart_starts_a_real_monitor_in_both_delegation_modes` 의 6 조합이 이 안에 들어 있다.
|
||||||
|
- `test_issue19`·`test_issue5_phase1` 의 watchdog 시험(중복 방지, `MAM_NO_SUBSCRIBER_DAEMON`, PID 보고, legacy·python 동등성)이 모두 통과한다.
|
||||||
|
- 고치기 전 대조 실행: TMPDIR 사본, `-k test_every_restart` → 3 failed(mode=0), 3 passed(mode=1). 자세한 것은 §1.2.
|
||||||
|
- 테스트 뒤 남은 프로세스: 테스트가 띄운 `reconcile.sh`, stub monitor, mock herdr, 구독자는 없다. 남은 것은 원래 있던 셋이다.
|
||||||
|
- 본 체크아웃 모니터 3714
|
||||||
|
- 이번 루프 7552
|
||||||
|
- 이번 잡을 디스패치할 때 띄운 job_subscriber 53086
|
||||||
|
|
||||||
|
## 4. 발견 사항
|
||||||
|
|
||||||
|
### 차단
|
||||||
|
|
||||||
|
- 없음.
|
||||||
|
|
||||||
|
### 비차단
|
||||||
|
|
||||||
|
- **N8 (이월, R2 에도 해당).** rollback 뒤 재기동은 결정 2 에 따라 **되돌린 옛 트리의** `start_watchdog` 을 쓴다.
|
||||||
|
- 그래서 실제로 이 PR 이전 버전으로 되돌리면, `MAM_DELEGATION_PY=0` 에서는 옛 `nohup … &` 그대로라 호출자 그룹에 남는다.
|
||||||
|
- 이 PR 이후 버전 사이의 rollback 에서는 해결된다.
|
||||||
|
- 결정 2 가 정한 방식("되돌린 트리의 함수")에서 생기는 한계라 결함으로 보지 않는다.
|
||||||
|
- **1.3 의 세션 차이.** legacy 경로는 프로세스 그룹만 분리하고, python 경로는 세션까지 분리한다. 결정 요구는 충족한다.
|
||||||
|
- N5·N6·N1·N3·N4 는 이번 범위 밖이라 그대로다.
|
||||||
|
|
||||||
|
## 5. 사용자 확인 필요
|
||||||
|
|
||||||
|
- N8: rollback 대상이 이 PR 이전 버전이고 `MAM_DELEGATION_PY=0` 인 경우를 받아들일지.
|
||||||
|
- 받아들이지 않는다면 결정 2 의 "되돌린 트리의 start_watchdog" 대신 `install_phase.sh` 복사본 쪽에서 분리해 띄우도록 결정을 바꿔야 한다.
|
||||||
|
- 이전 라운드에서 넘어온 N2, 그리고 결정 8 문구 보강 건은 그대로다.
|
||||||
|
|
||||||
|
## 6. 결론
|
||||||
|
|
||||||
|
- R2 는 정상 설치, 시간 초과 복원, rollback 복원의 세 경로와 두 모드 모두에서 해결됐다.
|
||||||
|
- 고치기 전 코드에서 mode=0 이 실패하고, 고친 뒤 6개가 모두 통과하는 것을 이 Mac 에서 확인했다.
|
||||||
|
- 분리 방식(`set -m`)은 중복 방지, opt-out, PID 보고, 로그를 바꾸지 않는다. bash 3.2 에서 잡 메시지 없이 동작한다.
|
||||||
|
- 테스트의 패턴 kill 은 기록한 PID 와 정확히 맞는 모니터·자손 정리로 바뀌었다. 남은 프로세스 확인은 오히려 강해졌다.
|
||||||
|
|
||||||
|
[VERDICT: PASS]
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"loop_id": "loop-20261019T015801Z-05463c",
|
||||||
|
"base_sha": "1d7b7dab58a10fe86fd747b7290d47674ca93230",
|
||||||
|
"head_sha": "6a37c0019baa31ee28b32a086e57b4c8239413c4",
|
||||||
|
"tree_sha": "87013e7ef7f5239f1bf6ea38a2cbda214257be3c",
|
||||||
|
"review_commit": "6a37c0019baa31ee28b32a086e57b4c8239413c4",
|
||||||
|
"reviewer": "planner-reviewer-claude-01",
|
||||||
|
"job_id": "1fb2cefa",
|
||||||
|
"verdict": "PASS",
|
||||||
|
"transport": "herdr",
|
||||||
|
"evidence": "review-tree",
|
||||||
|
"isolation": "worktree",
|
||||||
|
"verified_at": "2026-10-19T02:37:22Z"
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# PR #62 리뷰 2회차 — Issue #60 Linux 확인 후속 (job 1fb2cefa)
|
||||||
|
|
||||||
|
- 리뷰어: planner-reviewer-claude-01
|
||||||
|
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261019T015801Z-05463c/1`
|
||||||
|
- identity: HEAD `6a37c0019baa31ee28b32a086e57b4c8239413c4`, tree `87013e7ef7f5239f1bf6ea38a2cbda214257be3c` 가 일치한다. 작업 트리는 clean 이고 `.mam` 은 없다.
|
||||||
|
- 범위: base `1d7b7da`..head 9커밋. 판정은 base..head 전체에 대한 것이다. 1회차 이후 바뀐 것은 6a37c00 하나이고, `opencode.py`·`status.sh`·`reconcile.sh`·`test_issue60_linux_fixes.py` 4개 파일만 바뀌었다.
|
||||||
|
- 1회차(@6a4e385)의 확인은 6a37c00 이 건드리지 않은 코드라 그대로 유효하다. 1회차에 확인한 것: 임시 DB 에서 `_ro_connect` 세 갈래, `_DB_PATHS` 키, `auth-ok` CLI, stop 문구.
|
||||||
|
- 환경: macOS, Python 3.9.6, sqlite 3.51.0. 실제 opencode 데이터는 `ls`·`grep -c`·`ps`·`lsof` 로만 봤다.
|
||||||
|
|
||||||
|
## 결론
|
||||||
|
|
||||||
|
1회차의 공통 차단(B1)과 codex·agy 지적이 모두 해결됐고, 새 회귀는 찾지 못했다. base..head 전체를 병합 가능으로 판단한다.
|
||||||
|
|
||||||
|
## 1회차 지적별 확인
|
||||||
|
|
||||||
|
**B1 (clean 상태 5개가 macOS 에서 실패) — 해결.**
|
||||||
|
- `make("clean")` 이 close 뒤 남은 `-wal`·`-shm` 을 지운다(`tests/test_issue60_linux_fixes.py:158-162`). 1회차 사본에서 내가 검증한 수정과 같다.
|
||||||
|
- 검증 경로에서 이 모듈이 모두 통과한다(아래 실행 결과).
|
||||||
|
- 뮤테이션 i1(immutable 분기를 `mode=ro` 로)이 이제 이 Mac 에서 5 failed 로 잡힌다. 1회차에는 fixture 가 먼저 실패해서 이 뮤테이션이 가려졌었다.
|
||||||
|
|
||||||
|
**안내 문구 패턴이 사용자가 친 `Ask anything…` 를 지움(codex) — 해결.**
|
||||||
|
- 패턴이 `Ask anything(?:…|\.\.\.) "[^"]*"?` 가 되어 따옴표 예시가 필수다(`opencode.py:80`).
|
||||||
|
- 두 fixture 에 넣어 직접 확인한 결과:
|
||||||
|
- `Ask anything…`, `Ask anything...` 는 친 그대로 읽힌다.
|
||||||
|
- 홈 화면 원본, 잘린 예시, `Ask anything… "` 는 빈 입력으로 읽힌다.
|
||||||
|
- 회귀 테스트 2개가 추가됐다(`:84-85`). 뮤테이션 p1(예시를 다시 선택 사항으로)은 4 failed 로 잡힌다.
|
||||||
|
|
||||||
|
**auth_ok 가 실행 불가한 `~/.opencode/bin/opencode` 도 인정(codex) — 해결.**
|
||||||
|
- 이제 `isfile and access(X_OK)` 로 본다(`opencode.py:322-323`).
|
||||||
|
- 권한 644 파일과 디렉터리는 모두 False 로 고정됐다(`test_a_home_install_that_cannot_run_is_not_enough`). 뮤테이션 a1(`exists` 로 되돌림)은 1 failed 로 잡힌다.
|
||||||
|
- `shutil.which` 가 이미 실행 가능 여부를 보므로 PATH 쪽은 그대로다.
|
||||||
|
|
||||||
|
**status.sh·reconcile.sh 의 `sqlite3.connect(odb)`(agy) — 해결.**
|
||||||
|
- status 의 opencode 두 블록(`status.sh:115-117`, `:316-318`)과 reconcile drift C(`reconcile.sh:953-955`)가 `_ro_connect` 를 쓴다.
|
||||||
|
- 두 스크립트는 lib_py 를 이미 import 하는 경로에서 돌기 때문에 import 가 된다(status 는 `env_python`/PYTHONPATH, reconcile 은 `env_python`).
|
||||||
|
- status 블록에서 `import sqlite3` 를 지웠지만 영향이 없다. 같은 함수의 다른 블록(hermes `:90`, codex `:132`)은 각자 `import sqlite3` 를 한다.
|
||||||
|
- 행동 확인: git archive 사본에 임시 e2e 를 넣었다. 순서는 이렇다.
|
||||||
|
1. mam_sandbox+mock 에서 create → 자식 세션 추가 → `journal_mode=wal` → 사이드카 삭제(Linux 의 닫힌 상태)
|
||||||
|
2. `reconcile.sh --once --emit-diff` → `status.sh --json` 과 plain status
|
||||||
|
- 결과(head): rc 는 모두 0. 부모 id 로 고정됐고 `resume_state == "yes"` 이다. DB 디렉터리는 실행 전후 모두 `['opencode.db']` 였다.
|
||||||
|
- 대조(두 스크립트를 `sqlite3.connect(odb)` 로 되돌린 사본): reconcile 뒤 `opencode.db-shm`·`opencode.db-wal` 이 생겨 같은 테스트가 실패했다.
|
||||||
|
- 참고: mock opencode 가 만드는 DB 는 WAL 모드가 아니다. 그래서 WAL 로 바꾸지 않으면 이 차이가 드러나지 않는다.
|
||||||
|
|
||||||
|
## 테스트 실행 (검증 경로)
|
||||||
|
|
||||||
|
- 조건: `env -i`, 임시 HOME, `XDG_DATA_HOME=<scratchpad>`, basetemp 는 scratchpad, `-p no:cacheprovider`, `PYTHONDONTWRITEBYTECODE=1`.
|
||||||
|
- 대상 12개 모듈: issue60_linux_fixes, issue60_opencode_v2, a4, tier1, tier2, issue31, issue55, issue57, issue21_worktree_prereqs, issue50_dispatch, issue30_stall_watch, c1_tui_readiness.
|
||||||
|
- 결과: **463 passed, 2 skipped, 실패 0**, 16분.
|
||||||
|
- skip 1: `/proc` 전용 테스트.
|
||||||
|
- skip 2: PATH 에 opencode 가 없어 건너뛴 실바이너리 계약 테스트(1회차·이전 이슈에서 따로 확인함).
|
||||||
|
- 부작용 확인:
|
||||||
|
- scratchpad XDG 디렉터리는 실행 뒤에도 비어 있었다.
|
||||||
|
- 실제 `~/.local/share/opencode` 의 목록은 전후가 같았다.
|
||||||
|
- 실제 log 의 `debug` 줄 수는 2 → 2 였다.
|
||||||
|
- 남은 opencode 프로세스는 사용자 서비스 하나뿐이다(pid 23630, 시작 08:13, 이 잡 이전).
|
||||||
|
- #55 가드 오탐은 없었다.
|
||||||
|
- 린트:
|
||||||
|
- `git diff --check 1d7b7da..HEAD` 는 깨끗하다.
|
||||||
|
- 바뀐 Python 은 3.9 로 파싱된다.
|
||||||
|
- `status.sh`·`reconcile.sh` 는 `/bin/bash -n`(3.2)을 통과한다.
|
||||||
|
|
||||||
|
## 비차단
|
||||||
|
|
||||||
|
- **N1.** 새 `test_status_and_reconcile_open_the_database_read_only` 는 소스 문자열만 센다. `sqlite3.connect(odb, timeout=…)` 같은 변형은 지나간다. 위의 임시 e2e(WAL DB, 사이드카 없음 → reconcile·status → 목록 불변)를 tier2 opencode e2e 에 붙이면 행동으로 고정할 수 있다.
|
||||||
|
- **N2.** 1회차 비차단은 의도된 완화로 그대로 남아 있다(브리프에도 기록됨):
|
||||||
|
- immutable 경쟁 창(ms 수준)
|
||||||
|
- `_DB_PATHS` 의 프로세스 수명 캐시
|
||||||
|
- create 사전 검사의 완화
|
||||||
|
- **N3.** 아주 좁은 화면에서 예시의 여는 따옴표까지 잘리면 `Ask anything…` 만 남는다. 이때는 친 초안으로 읽힌다(안전한 쪽). 실제 캡처로는 확인하지 않았다.
|
||||||
|
|
||||||
|
[VERDICT: PASS]
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# 리뷰 리포트 — PR #59 (Issue #31, claude purge 잔여물) / job 207c65b8
|
||||||
|
|
||||||
|
- 리뷰어: planner-reviewer-claude-01 (Opus 5.5)
|
||||||
|
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261016T003524Z-a51548/1`
|
||||||
|
- identity: HEAD `6f4f40a4adb2115d5997dffd31fb6473f05cab68`, tree `f4d9422ad0c1d616b5f37065f3e96b34c28f78d8` — 일치. 트리 clean, `.mam` 없음(테스트 실행 후에도 동일).
|
||||||
|
- 범위: base 26e2c43 .. head 6f4f40a (13 커밋, 12 파일)
|
||||||
|
|
||||||
|
## 요약
|
||||||
|
|
||||||
|
설계와 대부분의 구현은 이 Mac에서 맞게 동작한다. 실제 `~/.claude` 구조, BSD `ps -o lstart=`, `/tmp → /private/tmp` 스크래치패드 레이아웃이 어댑터 가정과 일치하고, 다른 세션·공유 파일·심볼릭 링크 너머는 지우지 않았다. 다만 **삭제 범위 가드(`_plausible_id`)가 UUID가 아닌 id를 거르지 못해, id `memory` 로 purge 하면 `projects/<key>/memory/`(Claude Code 자동 메모리 디렉터리)가 통째로 지워진다**(F1). 이 PR이 `<id>.jsonl` 삭제에서 `<id>` 디렉터리 rmtree 로 범위를 넓히면서 생긴 회귀이고, 브리프 중점 항목("잘못된 uuid 거부")에 해당하므로 NOT PASS 로 판정한다. 수정은 한 줄 수준이다.
|
||||||
|
|
||||||
|
## 차단 결함
|
||||||
|
|
||||||
|
### F1. 비-UUID id `memory` 가 자동 메모리 디렉터리를 지운다
|
||||||
|
|
||||||
|
- 위치: `.agents/skills/lib_py/agents/base.py:89-92` (`_plausible_id`: 빈 문자열·점만·`/`·`..`·`*` 만 거부), `.agents/skills/lib_py/agents/adapters/claude.py:262-268` (`base = projects/<key>/<uuid>`, 디렉터리면 `_remove` → `shutil.rmtree`), `claude.py:287-289` (`has_artifacts` 가 같은 목록을 써서 `purge auto` 도 claude 로 해석).
|
||||||
|
- 실제 이 Mac 의 `~/.claude/projects/<key>/` 에는 세션 디렉터리와 나란히 `memory/` 가 있다(읽기만 확인: `projects/-Users-godopu16-PuKi-laa-canary-projects-multi-agent-mux/memory/`). 세션 id 와 같은 층에 있는 유일한 비-UUID 이름이다.
|
||||||
|
- 재현 1(어댑터 직접, 샌드박스 HOME/CLAUDE_PROJECT_DIR/CLAUDE_CODE_TMPDIR/TMPDIR, `/usr/bin/python3` 3.9.6):
|
||||||
|
`has_artifacts("memory") → True`, `purge_artifacts("memory") → [.../projects/<key>/memory, .../file-history/memory]`, `MEMORY.md survives: False`.
|
||||||
|
- 재현 2(CLI): `python -m lib_py.agents purge claude memory <ws>` → 이 호스트에서는 다른 프로세스 29개의 argv 에 "memory" 가 들어 있어 우연히 in-use(rc 4)로 거부됐다. argv 에 "memory" 가 없는 호스트를 흉내 내려고 `ps` 출력에서 그 줄만 거르는 스텁을 PATH 앞에 두면 rc 0, `"purged": [".../projects/<key>/memory"]`, `memory survives: False`.
|
||||||
|
(스크립트: `/private/tmp/claude-501/-Users-godopu16-PuKi-laa-canary-projects-multi-agent-mux/8cb38cbf-0d61-4d78-8c7e-065e409ce13d/scratchpad/j207/probe_mem.py`)
|
||||||
|
- 도달 경로: 스킬 진입점은 안전하다 — `orc_onboard.sh:88-95` 은 정규식으로 UUID 를 강제하고, `stop_session.sh` 의 `PURGE_UUID` 는 `find_workspace_uuid` 가 `<id>.jsonl` 의 sessionId 일치로 검증한 값이다. 위험한 경로는 직접 쓰는 `lib_py.agents purge`/어댑터 API(VERSIONS.md 에 문서화)다. 그러나 이 PR 이전에는 같은 입력이 존재하지 않는 `memory.jsonl` 만 겨냥해 무해했다. 지워지는 것은 복구할 수 없는 사용자 메모리이고, 테스트(`tests/test_issue31_purge_residual.py:242` 의 `bad` 목록)도 이 경우를 다루지 않는다.
|
||||||
|
- 제안: claude 어댑터의 `_owned_paths`/`residual_artifacts`(또는 `purge_artifacts`·`has_artifacts` 입구)에서 정규 UUID(`[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}`, 대소문자 무시)만 받게 하고, `bad` 목록에 `"memory"` 와 UUID 가 아닌 평범한 이름을 추가하는 회귀 테스트. 다른 어댑터는 이름 스캔(`scan_names`)·보고만 하므로 공용 `_plausible_id` 는 그대로 둬도 된다.
|
||||||
|
|
||||||
|
## 확인한 사항 (정상)
|
||||||
|
|
||||||
|
1. **BSD `ps -o lstart=`**: 이 Mac 출력은 `Fri Oct 16 08:25:14 2026` (LC_ALL=C 와 기본 로캘 동일). `" ".join(out.split())` + `%a %b %d %H:%M:%S %Y` 로 파싱된다. 실제 살아 있는 claude 4개의 `sessions/<pid>.json` 에서 `startedAt − lstart` = 0.69 / 1.14 / 1.93 / 2.43 s → 모두 live 판정(`lstart <= startedAt+2`). 샌드박스 `sleep` 프로세스로: 방금 쓴 startedAt → in-use rc 4(`--wait 2` 로 2.2 s 대기 후), purge CLI rc 4, jsonl 보존; 프로세스 종료 후 rc 0. startedAt 을 1시간 전으로 둔(pid 재사용) 경우 → free, 그 세션 파일만 삭제. `ps` 실패·빈 출력·파싱 실패·`startedAt` 없음 → live(`claude.py:66-74`).
|
||||||
|
2. **tmp 루트**: TMPDIR=`/var/folders/.../T/` 아래에는 `claude-501` 이 없고, 실제 스크래치패드는 `/private/tmp/claude-501/<key>/<uuid>/{scratchpad,tasks}` 다. 후보에 항상 `/tmp/claude-<uid>` 가 들어가고(`claude.py:41`) realpath 로 `/private/tmp` 와 중복 제거된다. `<key>` 는 계산하지 않고 루트 아래 모든 키를 나열해 `<k>/<uuid>` 만 고르므로 키 규칙 차이에 영향받지 않는다. 샌드박스에서 CLAUDE_CODE_TMPDIR·TMPDIR 양쪽 루트의 `<uuid>` 만 지워졌다.
|
||||||
|
3. **실제 `~/.claude` 구조**(읽기만): `sessions/<pid>.json`(int `pid`, ms `startedAt`, `sessionId`) + `<pid>.<64hex>.key`, `jobs/<8hex>/state.json`(sessionId[:8] = 디렉터리명), `file-history/<uuid>/`, `session-env/<uuid>/`, `projects/<key>/<uuid>/{tool-results,…}` — 어댑터 가정과 일치.
|
||||||
|
4. **삭제 범위**(샌드박스, `/usr/bin/python3`): 같은 jobs 접두사의 다른 세션, 다른 세션의 sessions json·key, 같은 pid 접두사이지만 64hex 가 아닌 `.key`, `history.jsonl`, 다른 uuid 의 file-history·jsonl·스크래치패드가 모두 보존됐다. `session-env/<U>/link → 외부`, 스크래치패드 안의 `linkout → 외부` 심볼릭 링크는 링크만 지워지고 대상 파일은 남았다. 재실행 → rc 5(남은 것 없음). `""`/`.`/`..`/`...`/`a/b`/`*`/`../x` → 아무것도 지우지 않음(rc 1 또는 5).
|
||||||
|
5. **stop exit 4 계약**: 게이트(`stop_session.sh:285-300`)는 레지스트리 잠금 밖, YAML 쓰기 전에 실행되고 0 이외는 모두 exit 4 + cancelled 이벤트. `_delegate_py_bin` 가 `/usr/bin/python3` 로 떨어져도 `-m lib_py.agents in-use` 는 paho 없이 동작한다(직접 확인). bash 3.2 `-n` 통과, `inuse_rc=0; … || inuse_rc=$?` 는 `set -u` 에서 안전.
|
||||||
|
6. **residual 보고**: purge 후 `residual_artifacts` 가 빈 목록이면 `residual: none (agent: …)`. 실제 `~/.claude` 에 대한 이름 스캔(읽기 전용, 가짜 uuid)은 0.02 s.
|
||||||
|
7. **Python 3.9 / bash 3.2**: 변경된 .py 8개 모두 `feature_version=(3,9)` 로 파싱되고, 프로브는 전부 `/usr/bin/python3` 3.9.6 으로 실행됐다(registry 가 모든 어댑터를 import). `lib.sh`·`stop_session.sh` 는 `/bin/bash` 3.2.57 `-n` 통과.
|
||||||
|
8. **문서·skill_code_blocks.json**: stop SKILL.md 의 "purge 는 행을 지운다"·exit 4·삭제 목록이 코드와 맞고, `test_issue42_skill_blocks.py` 통과(sha·passthrough 라인 갱신 정합).
|
||||||
|
9. **codex·grok·opencode residual**: 이름 스캔 + `mode=ro` SQLite 조회만 하므로 쓰기 없음.
|
||||||
|
|
||||||
|
## 테스트
|
||||||
|
|
||||||
|
- 검증 경로에서 `env -i …`(HERDR_*·WORKSPACE_ROOT·MAM_*·DELEGATE_JOB_LOGS_DIR·CLAUDE_CONFIG_DIR 미설정), `--basetemp` 는 스크래치패드, `PYTHONDONTWRITEBYTECODE=1 -p no:cacheprovider`:
|
||||||
|
`test_issue31_purge_residual, test_issue10_13_purge, test_a4_adapter_contract, test_issue42_skill_blocks, test_issue55_agent_homes, test_orc_onboard, test_uuid_target` → **188 passed, 1 skipped** (276 s). 실행 후 트리 clean.
|
||||||
|
- 사전 수정 대조: `git archive HEAD` 복사본에서 제품 파일 8개를 base 26e2c43 로 되돌리고 `test_issue31_purge_residual.py` 실행 → **24 failed, 8 passed**. 통과한 8개는 "다른 세션을 건드리지 않음"·"잘못된 uuid 거부"처럼 이전 코드에서도 성립하는 안전성 테스트로, 기대한 결과다. stop 게이트 테스트(`test_stop_purge_refuses_while_the_claude_process_lives`)와 pid 재사용 테스트도 사전 코드에서 실패한다.
|
||||||
|
|
||||||
|
## 비차단 메모
|
||||||
|
|
||||||
|
- N1. DST: `time.mktime(time.strptime(...))` 는 `tm_isdst=-1` 이라 서머타임이 끝나는 겹치는 1시간에는 lstart 가 1시간 늦게 해석될 수 있고, 그러면 살아 있는 세션을 stale 로 볼 수 있다(fail-open). 이 Mac 은 KST(서머타임 없음)라 영향 없음. 실제 세션 파일에 `procStart`(UTC 문자열, `pidDomain: "darwin"`)가 있으니 이를 쓰거나 비교를 UTC 로 하는 것도 고려할 만하다.
|
||||||
|
- N2. stop 의 exit 4 메시지(`stop_session.sh:296`)는 `~/.claude/sessions/` 만 가리킨다. CLAUDE_CONFIG_DIR 을 쓰면 위치가 다르고, argv 에 uuid 를 담은 아무 프로세스(에디터·grep·위임 래퍼)도 거부 사유가 된다. orc_onboard SKILL.md 처럼 `ps -axww | grep <uuid>` 안내를 함께 두면 좋다.
|
||||||
|
- N3. SKILL.md exit 4 행의 "(nothing was changed)" — 프로세스 게이트는 herdr kill 이후에 돌므로 세션은 이미 내려가 있을 수 있다(레코드는 그대로). 기존 herdr 게이트 문구와 같은 뉘앙스이지만 "records preserved" 가 더 정확하다.
|
||||||
|
- N4. stop 이 이제 모든 에이전트에 in-use 게이트를 적용한다. agy 처럼 CLI 종료 뒤에도 다른 프로세스가 DB/락을 열어 두는 구성이면 이전에는 purge 되던 것이 exit 4 로 바뀐다. 의도된 fail-closed 이지만 실제 agy 로는 확인하지 못했다(브리프상 실제 에이전트 실행 금지).
|
||||||
|
- N5. 테스트는 하드코딩된 `/tmp/claude-<uid>` 후보를 격리하지 못한다(실제 `/private/tmp/claude-501` 을 나열). 무작위 uuid 라 실제로 지워지는 것은 없지만, `_tmp_roots` 를 테스트에서 패치할 수 있게 하면 더 안전하다.
|
||||||
|
|
||||||
|
[VERDICT: NOT PASS]
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# 리뷰 리포트 — Job 24b1f07f (Issue #17 후속: 테스트의 상속 WORKSPACE_ROOT, 6b00b6e..d198dd5)
|
||||||
|
|
||||||
|
- 리뷰어: planner-reviewer-claude-01 (claude)
|
||||||
|
- 리뷰 identity: loop-20261008T133858Z-9e7f5b / base 6b00b6e / head d198dd5 / tree 114ec21 — 검증 경로에서 `git rev-parse HEAD 'HEAD^{tree}'` 일치, 미커밋 변경 없음
|
||||||
|
- 범위: 커밋 1개(d198dd5), tests/test_issue17_item4_decisions.py 만(+36/−2). 제품 코드(.agents/skills)·버전·VERSIONS 변경 없음(브리프대로).
|
||||||
|
|
||||||
|
## 결론 요약
|
||||||
|
할 일 1·3·4 가 요구대로 반영됐고, 새 회귀 테스트는 수정 전 `_env` 에서 실제로 실패한다(센티널에 `.mam/shim/herdr` 생성). 할 일 2(다른 테스트 조사)는 이 PR 에 코드 변경이 없는데, 내가 직접 grep 한 결과로도 **같은 결함(다른 워크스페이스에 쓰기)은 conftest 가 이미 프로세스 수준에서 막고 있어** 추가 수정 대상이 없다 — 다만 남는 두 파일(§3)은 creator 리포트의 표에 있어야 한다. 차단 결함 없음.
|
||||||
|
|
||||||
|
## 1. 할 일 1 — `_env` (tests/test_issue17_item4_decisions.py:224-233)
|
||||||
|
- 상속 제외 목록에 `WORKSPACE_ROOT` 추가(:229), `env.update(... WORKSPACE_ROOT=str(tmp_path) ...)`(:231) 로 테스트별 임시 경로를 명시. "제거"가 아니라 "지정"을 고른 이유는 주석(:225-227)에 있고 맞다: lib.sh:26 은 `WORKSPACE_ROOT` 가 비어 있으면 `$SKILL_DIR/../..` = **체크아웃 자신**을 쓰므로, 제거만 하면 래퍼를 source 하는 헬퍼 4곳(_run_both :246/:372/:561/:586 등)이 체크아웃의 `.mam/shim/herdr` 를 다시 쓴다. 지정하면 shim 이 tmp_path 아래에 생긴다(회귀 테스트가 `(work/.mam/shim/herdr).is_file()` 로 고정, :498).
|
||||||
|
- `extra` 로 넘기는 명시 WORKSPACE_ROOT(:692 의 wait-rc 테스트)는 `env.update(extra)` 가 뒤에 오므로 그대로 이긴다 — 의도적 지정 테스트가 깨지지 않는다.
|
||||||
|
|
||||||
|
## 2. 할 일 3 — 회귀 테스트 test_c_helpers_write_nothing_to_an_inherited_workspace (:477-498)
|
||||||
|
- 센티널 디렉터리를 `WORKSPACE_ROOT` 로 monkeypatch 한 뒤 `_run_both`(옛·새 양쪽)와 래퍼 직접 source 를 돌리고, 센티널 아래 파일 없음·체크아웃 shim mtime 불변·셸이 보는 `$WORKSPACE_ROOT` 가 테스트 tmp 임·shim 이 tmp 에 생김을 단언한다.
|
||||||
|
- **수정 전 코드에서 실패 확인**: HEAD 의 테스트 파일에서 `_env` 두 줄만 base(6b00b6e) 판으로 되돌린 스크래치 사본 → `AssertionError: written into the sentinel`, `['.mam', '.mam/shim', '.mam/shim/herdr'] == []` (1 failed). 현재 코드에서 통과.
|
||||||
|
- conftest 의 `pytest_configure` 는 다른 체크아웃을 가리키는 WORKSPACE_ROOT 를 수집 전에 지우므로(tests/conftest.py:139-148), 이 테스트가 monkeypatch 로 다시 설정하는 것이 맞는 설계다(지우지 않으면 센티널이 보이지 않는다).
|
||||||
|
|
||||||
|
## 3. 할 일 2 — 같은 결함이 다른 테스트에 있는가 (직접 grep)
|
||||||
|
- **conftest 가 막는 범위**: `pytest_configure`(conftest.py:139-148)가 AGENT_SESSIONS_YAML·MAM_JOBS_DIR·DELEGATE_JOB_LOGS_DIR 을 항상, `WORKSPACE_ROOT` 는 **realpath 가 이 체크아웃이 아닐 때만** os.environ 에서 지운다(주석 :122-124: 이 체크아웃을 가리키면 "lib.sh 가 어차피 유도하는 값"). 따라서 어떤 테스트든 os.environ 을 복사해 lib.sh 를 돌려도 **다른 워크스페이스에는 쓸 수 없다**. 남는 경우는 (i) 체크아웃 자신의 shim 재작성, (ii) 테스트가 monkeypatch 로 다시 설정한 값, (iii) pytest 밖에서 헬퍼를 직접 돌리는 경우(PR #51 리뷰 때의 사고는 리뷰어의 수동 탐침, 즉 (iii)).
|
||||||
|
- **이 파일이 "빠진" 이유**: 빠진 것이 아니라 (i)(iii) 에 노출돼 있었다 — `_env` 가 WORKSPACE_ROOT 를 남겨 둔 채 래퍼→lib.sh 를 source 했으므로, 스위트 실행 중에는 체크아웃 자신의 shim 을 매번 다시 썼고, 수동 실행에서는 상속값 그대로 썼다. 이번 수정이 둘 다 막는다.
|
||||||
|
- os.environ 을 복사하고 lib.sh/래퍼를 돌리는 테스트 파일 36개를 grep 했다. `mam_sandbox` 를 쓰는 파일은 fixture 가 `WORKSPACE_ROOT=tmp_path` 를 설정하므로(conftest.py:215) 복사본이 sandbox 를 물려받아 안전하다. fixture 없이 lib.sh 를 실제로 source 하는 파일은 둘뿐이며, 둘 다 (i) 에만 해당한다:
|
||||||
|
|
||||||
|
| 파일:라인 | 동작 | 판단 |
|
||||||
|
|---|---|---|
|
||||||
|
| tests/test_b19_headless_reconcile_fixes.py:326-329, :366-369, :401-402 | `source "$SKILL_DIR/lib.sh"` 하는 스크립트를 SKS_* 만 뺀 os.environ 으로 실행 | 다른 체크아웃은 conftest 가 지움; 이 체크아웃이면 자신의 shim 재작성(conftest 가 명시적으로 허용하는 경우). 고치지 않아도 되나 표에 있어야 함 |
|
||||||
|
| tests/test_version_consistency.py:30 | `bash -c 'source lib.sh …'` 를 env 지정 없이 cwd=REPO_ROOT 로 실행 | 위와 같음 |
|
||||||
|
| tests/test_deploy_freshness.py, test_deploy_layout.py | lib.sh 를 복사·읽기만, source 안 함 | 해당 없음 |
|
||||||
|
- 제품 코드 변경 필요 여부: 없음. conftest 한 곳에서 "이 체크아웃" 경우까지 지우는 방법은 lib.sh 가 어차피 같은 값을 유도하므로 효과가 없고(주석 :122-124 의 근거), 체크아웃 shim 재작성을 피하려면 각 헬퍼가 tmp 를 지정해야 한다 — 이 PR 의 방식이 맞다.
|
||||||
|
|
||||||
|
## 4. 할 일 4 — 실행 (검증 경로, 워크트리 .venv Python 3.9.6, `env -i` 로 HERDR_*·MAM_*·MQTT_* 제거)
|
||||||
|
| 실행 | 결과 |
|
||||||
|
|---|---|
|
||||||
|
| (a) WORKSPACE_ROOT 없이: test_issue17_item4_decisions, test_issue33_budget, test_issue5_phase1_delegation, test_tier1_unit | **443 passed / 0 failed** (478 s) |
|
||||||
|
| (b) WORKSPACE_ROOT=<워크트리 밖 센티널>: test_issue17_item4_decisions | **290 passed / 0 failed** (319 s); 실행 뒤 센티널 **비어 있음**(`find` 출력 없음) |
|
||||||
|
| 회귀 테스트, 수정 전 `_env` 사본 | 1 failed(센티널에 .mam/shim/herdr) |
|
||||||
|
- 본 체크아웃 `.mam/shim/herdr` mtime 실행 전후 동일(1791469013). 실제 .mam/jobs 항목 수 불변. 브로커 bind 제약 없음.
|
||||||
|
- (b) 에서 센티널은 realpath 가 체크아웃이 아니라 conftest 가 지우므로, 대부분의 테스트는 conftest 로, 회귀 테스트는 `_env` 로 보호되는 셈이다 — 둘 다 통과했으니 브리프 (b) 의 목적(어느 층이든 센티널에 쓰지 않음)은 충족된다.
|
||||||
|
|
||||||
|
## 5. 이식성·비차단
|
||||||
|
- 변경은 Python 테스트 코드뿐(3.9 문법, `stat().st_mtime_ns`, `Path.rglob`). bash 3.2·macOS 영향 없음.
|
||||||
|
- 비차단: §3 표의 두 파일이 creator 리포트의 "고치지 않은 후보" 표에 없다면 추가를 권한다. 새 관찰 없음.
|
||||||
|
|
||||||
|
[VERDICT: PASS]
|
||||||
@@ -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.<n>.url|active`)를 **인덱스 배열**(bash 3.2 호환)로 기록한다. 초기화 안 된 경로에 파일이 이미 있으면 **아무것도 바꾸기 전에** exit 3 으로 거부한다(R4) — 롤백이 "이번 시도가 만든 것"만 지울 수 있다는 전제를 실제로 성립시키는 올바른 순서다. 실패 시 deinit → `.git` 파일 제거 → `modules/<name>` 제거 → 없던 `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-<jid>.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/<loop_id>/<n>` 에 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 <snap>` 실패). 일상적으로도 unreachable 객체는 기본 2주 뒤 대상이다. 미커밋이 있던 라운드에만 해당하며(클린 트리면 `review_commit == head_sha`), 판정 자체의 정합성에는 영향이 없다.
|
||||||
|
|
||||||
|
→ 권고: `refs/mam/review/<loop_id>/<n>` 같은 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 <pr-29 워크트리> && 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]
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"loop_id": "loop-20260921T150212Z-97d845",
|
||||||
|
"base_sha": "6fc9194f896eb2e13e123b8ebe7a9089b629764d",
|
||||||
|
"head_sha": "1e826515cc2a07ea1f8da0ff1259e1ea4dde86fd",
|
||||||
|
"tree_sha": "4fd82ad1c2682d12c3f5114818f97291af28a032",
|
||||||
|
"review_commit": "1e826515cc2a07ea1f8da0ff1259e1ea4dde86fd",
|
||||||
|
"reviewer": "planner-reviewer-claude-01",
|
||||||
|
"job_id": "293e905f",
|
||||||
|
"verdict": "PASS",
|
||||||
|
"transport": "herdr",
|
||||||
|
"evidence": "review-tree",
|
||||||
|
"isolation": "worktree",
|
||||||
|
"verified_at": "2026-09-21T15:25:44Z"
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
# 🔍 Review Report — PR #32 병합 전 독립 리뷰 (job `293e905f`)
|
||||||
|
|
||||||
|
- **Reviewer**: `planner-reviewer-claude-01` (role: planner,reviewer) · 2026-09-22
|
||||||
|
- **대상**: PR #32 `fix/issue-25` **@ `1e82651`**, base `main 6fc9194` (v4.7.0). 5커밋, 4파일 `+486/−31`.
|
||||||
|
- **검증 경로**: `/var/folders/.../T/mam-review/loop-20260921T150212Z-97d845/1` (브리프가 지정한 리뷰 워크트리). 실제로 거기서만 검증했다.
|
||||||
|
- **identity 확인**: 검증 경로의 `HEAD = 1e826515cc2a07ea1f8da0ff1259e1ea4dde86fd`, `HEAD^{tree} = 4fd82ad1c2682d12c3f5114818f97291af28a032` — 브리프의 `head`·`tree` 와 **일치**한다(항목 5 identity 가 실제로 작동함을 이 잡 자체가 보여준다).
|
||||||
|
- **안전 규칙 준수**: 검증 경로·소스 워크트리(`multi-agent-mux-wt/pr-32`)·본 체크아웃(`main 6fc9194`) 모두 수정·커밋·checkout 없음. 실험은 전부 scratchpad 임시 디렉터리(uuid4 이름)에서 했고, pytest 는 `--basetemp` 을 검증 경로 밖으로 주었다. 실제 `.mam`·데몬·세션 조작 없음, push·merge·PR 상태 변경 없음, purge·scrub 실행 없음.
|
||||||
|
|
||||||
|
## 결론 요약
|
||||||
|
|
||||||
|
**병합을 막을 결함은 없다.** 핵심 변경(암묵적 계획서 로드 제거, `--plan-file` 명시 재사용, 네 지점 공통 계획 텍스트 검사)은 코드·문서·테스트가 일치하며, 새 테스트 17건 중 **13건이 수정 전 소스에서는 실패**한다(직접 확인). 비차단 지적 3건(E1–E3)을 남긴다.
|
||||||
|
|
||||||
|
## 1. 암묵적 로드 제거 (항목 1)
|
||||||
|
|
||||||
|
| 확인 | 결과 |
|
||||||
|
|---|---|
|
||||||
|
| Python 러너 | `runner.py` 에서 `glob` import 가 사라졌고, 남은 `glob` 사용처는 `find_report` 의 `report-final.md` 탐색(`:568`) 하나뿐이다. `.agents/reports/<planner>/plan-*.md` 를 읽는 코드가 없다 |
|
||||||
|
| 레거시 셸 러너 | `run_loop.sh` 의 `EXISTING_PLAN_FILE`/`ls -t .../plan-*.md` 블록이 통째로 사라지고 `log_info "No plan loaded; the Creator plans by itself."` 만 남았다 |
|
||||||
|
| 다른 경로 | 저장소 전체에서 `plan-*.md` 를 읽는 코드는 없다(승격 쪽 **쓰기**만 `runner.py:1368-1372` 에 남는다 — 정상) |
|
||||||
|
| planner 자동 선택 | `resolve_planner_session`(`run_loop.sh:386`)·`resolve_planner_session` 파이썬 대응은 남아 있으나, 그 결과는 **plan 모드의 위임 대상과 승격 경로**에만 쓰인다. 자기계획 모드에서 planner 이름으로 파일을 찾는 코드는 없다 |
|
||||||
|
|
||||||
|
## 2. `--plan-file` (항목 2) — 실측
|
||||||
|
|
||||||
|
검증 경로의 러너를 임시 제어 루트(`WORKSPACE_ROOT=<tmp>/ctl`)로 직접 돌려 확인했다.
|
||||||
|
|
||||||
|
| 입력 | 결과 |
|
||||||
|
|---|---|
|
||||||
|
| 없는 파일 | `[✗] --plan-file <절대경로>: not a readable file with plan text in it.` · **exit 1** |
|
||||||
|
| 빈 파일 / 공백만 있는 파일 | 같은 메시지 · exit 1 |
|
||||||
|
| 디렉터리 | 같은 메시지 · exit 1 (`is_file()` 로 걸린다) |
|
||||||
|
| 읽기 권한 없음(`chmod 000`) | 같은 메시지 · exit 1 (`OSError` → `None`) |
|
||||||
|
| `--plan` 과 함께 | `ERROR: --plan-file cannot be combined with --plan.` + 사용 예 2줄 · exit 1 |
|
||||||
|
| 상대경로 | **제어 루트 기준**. 호출 cwd(`<tmp>/elsewhere`)에 `plans/p.md` 가 실제로 있어도 러너는 `<ctl>/plans/p.md` 를 찾고 실패한다 — #25 의 "다른 디렉터리의 동명 파일을 집어오는" 사고를 구조적으로 막는다 |
|
||||||
|
| 공백 있는 경로(`my plans/p.md`, 상대) | 정상 로드: `Using the plan given with --plan-file: <ctl>/my plans/p.md (modified ... UTC)` (셸을 거치지 않으므로 인용 문제 없음) |
|
||||||
|
| 절대경로·`~` | `Path.__truediv__` 가 절대경로를 그대로 두고 `expanduser()` 가 앞서 적용된다(P3d 가 고정) |
|
||||||
|
| `--review-only` 와 함께 | 경고 후 무시(`log_warn`), `current_plan` 을 채우지 않는다 |
|
||||||
|
| 레거시 셸(`MAM_LOOP_PY=0`) | `ERROR: --worktree/--base/--review-only/--review-isolation/--remote-review/--plan-file require the Python loop (MAM_LOOP_PY=1)` · exit 1 — **동등 구현 대신 명시적 거부**이며, 기본 경로(`MAM_LOOP_PY=1`)는 `exec "$_py" -m lib_py.loop "${MAM_LOOP_ARGV[@]}"`(`run_loop.sh:262`)로 원본 argv 를 그대로 넘기므로 공백 경로도 안전하다 |
|
||||||
|
|
||||||
|
읽기가 **잡을 하나도 만들기 전**(parse_args 단계)에 끝나는 것도 확인했다 — 실패 시 레지스트리에 아무 흔적이 없다.
|
||||||
|
|
||||||
|
## 3. 계획 텍스트 검사 (항목 3)
|
||||||
|
|
||||||
|
`_plan_text()`(`runner.py:303-317`)가 "읽을 수 있는 일반 파일 + 공백 제외 내용 있음"을 단일 판정으로 만들고, **파이썬 러너의 네 지점이 모두 이를 쓴다**: `--plan-file`(`:265`), `--plan` 초기 계획서(`:1025`), 논의 턴 refine(`:1054`), 리뷰 에스컬레이션 refine(`:1284`).
|
||||||
|
|
||||||
|
- 빈 초기 계획서 → `log_error` 후 **return 1**. 이때 이미 만든 잡은 `release_guard`/정리 경로가 담당하고, 자기계획으로의 조용한 격하가 사라졌다.
|
||||||
|
- 빈 refine → 경고 + **이전 계획 유지**. 그래서 "갱신된 다음 계획서에 입각하여…" 프롬프트가 빈 계획을 싣는 일이 없다.
|
||||||
|
- 에스컬레이션 경로(`[ESCALATE: PLANNER]`)는 `if self.plan_mode and complex_fix`(`:1275`)로 **plan 모드에만** 걸린다. `--plan-file` 로 계획을 실은 실행에서 ESCALATE 판정이 나오면 재계획 없이 일반 수정 라운드로 간다 — 샌드박스에서 직접 구동해 확인했다(Planner 위임 0건, 수정 프롬프트는 "리뷰어들이 지적한 다음 피드백…"). SKILL.md:31 의 "수정 라운드에는 리뷰 피드백만" 서술과 **일치**한다. 다만 이 동작 자체는 E2 로 남긴다.
|
||||||
|
|
||||||
|
## 4. 체크포인트 (항목 4)
|
||||||
|
|
||||||
|
`.mam/loop-checkpoint.json` 의 `current_plan` 은 **쓰기 전용**이다. `checkpoint.load_checkpoint()` 는 정의되어 있으나 `runner.py` 어디에서도 호출하지 않는다(`grep 'checkpoint\.'` → `save_checkpoint`·`remove_checkpoint` 뿐). 따라서 본 체크아웃 체크포인트에 남아 있는 Issue #6 계획 텍스트가 다음 루프나 재개에서 되살아날 경로는 없다. 계획은 매 실행마다 `--plan-file` 또는 `--plan` 으로만 들어온다.
|
||||||
|
|
||||||
|
## 5. 옵션 없을 때의 동작 변화 (항목 5)
|
||||||
|
|
||||||
|
- 자기계획 프롬프트(`runner.py:1095`)와 승격 경로(`:1367` `if self.plan_mode and self.current_plan and self.plan_job_id`)는 그대로다. 즉 `--plan-file` 로 실은 계획서는 **승격되지 않는다**(승격은 plan 모드의 planner 산출물만) — 옳은 구분이다.
|
||||||
|
- 바뀐 것은 "planner 리포트 디렉터리에서 최신 plan-*.md 를 끌어오던" 동작 하나뿐이다. 그 외 프롬프트 문구·옵션 기본값·종료 코드는 동일하다.
|
||||||
|
- 기존 스위트 정합은 §6 에서 실측했다.
|
||||||
|
|
||||||
|
## 6. 테스트 (항목 6)
|
||||||
|
|
||||||
|
### 6-1. 검증 경로에서의 실행
|
||||||
|
|
||||||
|
```
|
||||||
|
cd <검증 경로> && PYTHONDONTWRITEBYTECODE=1 <제어 루트 .venv/bin/python> -m pytest -p no:cacheprovider -q -rfEs \
|
||||||
|
--basetemp=<검증 경로 밖> tests/test_issue25_plan_reuse.py tests/test_loop_cli.py tests/test_issue5_phase3_loop.py \
|
||||||
|
tests/test_issue21_loop_b.py tests/test_issue21_phase2_loop_a.py tests/test_issue21_acceptance.py
|
||||||
|
→ 2 failed, 108 passed in 220.98s (skipped 0)
|
||||||
|
```
|
||||||
|
|
||||||
|
**실패 2건은 PR 회귀가 아니라 리뷰 워크트리에 `.venv` 가 없어서 생긴 환경 문제다.**
|
||||||
|
|
||||||
|
| 실패 | 원인 | 확인 |
|
||||||
|
|---|---|---|
|
||||||
|
| `test_loop_cli.py::test_creator_parses_and_reaches_session_check` | `run_loop.sh` 를 하위 프로세스로 띄우는데, `REPO_ROOT`(= 검증 경로)에 `.venv` 가 없어 `_py` 가 시스템 python 으로 떨어지고 `RuntimeError: Failed to eager load lib_py.loop.autocomplete: No module named 'paho'` 로 죽는다(`run_loop.sh:253`) | `AGENT_PYTHON_BIN=<제어 루트 .venv/bin/python>` 만 주고 둘을 다시 돌리면 **2 passed in 0.34s** |
|
||||||
|
| `test_issue5_phase3_loop.py::test_p3_02_run_loop_wrapper_delegates_to_python_default` | 동일 | 동일 |
|
||||||
|
|
||||||
|
두 시험 모두 PR 이 건드리지 않은 파일이며, 실패 메시지에 계획서·`--plan-file` 은 등장하지 않는다.
|
||||||
|
|
||||||
|
### 6-2. 수정 없이는 실패하는가 — 확인함
|
||||||
|
|
||||||
|
검증 경로 사본을 만들고 `runner.py`·`run_loop.sh`·`SKILL.md` **세 파일만 base `6fc9194` 판으로 되돌려** 새 테스트를 돌렸다(검증 경로 자체는 건드리지 않았다).
|
||||||
|
|
||||||
|
```
|
||||||
|
13 failed, 4 passed in 1.51s
|
||||||
|
FAILED P1, P2, P3b, P3c, P3d, P5, P9[empty], P9[whitespace], P10, P11, P7, P7b, P8
|
||||||
|
```
|
||||||
|
|
||||||
|
통과한 4건(P3·P4·P5b·P6)은 옵션 없는 일반 CLI 오류·plan 모드 정상 경로라 수정과 무관한 것들이다. 즉 새 테스트는 이 변경을 정확히 겨냥한다.
|
||||||
|
|
||||||
|
### 6-3. 격리
|
||||||
|
|
||||||
|
| 항목 | 결과 |
|
||||||
|
|---|---|
|
||||||
|
| 실제 `.mam/jobs` 전후 | **차이 0** |
|
||||||
|
| 실제 `.agents/reports` | 새 항목 없음(테스트는 각자 `tmp_path` 안에 제어 루트를 만든다 — `ctl` 픽스처) |
|
||||||
|
| 검증 경로 tracked 파일 | 변화 없음(`git status --short` 클린, HEAD `1e82651` 유지) |
|
||||||
|
| 검증 경로 ignored | 내 실행이 `.mam/`(무시됨)을 만들었다. tracked 파일·커밋에는 영향이 없으며, 지우면 리뷰 트리를 수정하는 셈이라 그대로 두었다 → E3 |
|
||||||
|
| 실행 시간 | 새 파일 `test_issue25_plan_reuse.py` 는 전체 17건이 **1.5 s** 안에 끝난다(브로커·herdr 의존 없음, 모두 stub) |
|
||||||
|
|
||||||
|
## 7. 문서 (항목 7)
|
||||||
|
|
||||||
|
- 옛 서술("기존에 승격된 계획서가 있다면 이를 로드")은 `.agents`·`docs` 어디에도 남아 있지 않다(전수 grep).
|
||||||
|
- SKILL.md 의 사용법 문자열·CLI 표·Workflow 예시·Mermaid 다이어그램이 모두 `--plan-file` 기준으로 맞춰졌고, "계획서가 비어 있을 때" 절이 네 지점의 판정을 정확히 설명한다.
|
||||||
|
- 러너 usage 와 셸 usage 문자열이 동일하다(P7 이 이를 고정한다).
|
||||||
|
- 배포 사본: 저장소의 `.claude/skills/*` 는 `../../.agents/skills/*` **심링크**라 자동으로 동기화된다. 사용자 홈 `~/.claude/skills` 에는 mux 스킬 사본이 없다. 옛 문구를 가진 사본은 발견되지 않았다.
|
||||||
|
|
||||||
|
## 8. 병합 차단 여부 (항목 8)
|
||||||
|
|
||||||
|
버전 락스텝 bump 부재(`lib.sh:32`·`eager.py:16`·loop SKILL.md 모두 `4.7.0`, 오케스트레이터가 4.7.1 로 처리 예정) 외에 **병합을 막을 결함은 없다**.
|
||||||
|
|
||||||
|
| # | 비차단 지적 | 근거 |
|
||||||
|
|---|---|---|
|
||||||
|
| E1 | 레거시 셸 러너(`MAM_LOOP_PY=0`)의 plan 모드 읽기에는 빈 계획서 검사가 없다: `CURRENT_PLAN=$(cat "$PLAN_FILE")`(`run_loop.sh:501`). D13/D14 계약은 파이썬 러너에만 성립하는데 SKILL.md 의 "계획서가 비어 있을 때" 절은 이를 명시하지 않는다 | 후속: 한 줄 거부 또는 문서에 "Python 러너 기준" 명시 |
|
||||||
|
| E2 | `--plan-file` 로 계획을 실은 실행에서 `[ESCALATE: PLANNER]` 는 재계획 없이 일반 수정 라운드로 내려간다(`runner.py:1275` 가 `plan_mode` 로 게이트). 샌드박스 구동으로 확인(Planner 위임 0건). 이 게이트는 PR 이전부터 있던 것이고 SKILL.md 서술과도 모순되지 않지만, "계획서를 갖고 도는 루프인데 에스컬레이션이 무력화된다"는 점은 문서에 없다 | 문서 보강 권고 |
|
||||||
|
| E3 | 리뷰 격리 워크트리에는 `.venv` 가 없어, `run_loop.sh` 를 하위 프로세스로 띄우는 스위트는 `AGENT_PYTHON_BIN` 없이는 반드시 실패한다(§6-1). 또한 그 워크트리에서 pytest 를 돌리면 무시되는 `.mam/` 이 생긴다. **PR #32 가 아니라 Issue #21 항목 5 운영 쪽 후속**이다 | 리뷰 브리프의 테스트 명령에 `AGENT_PYTHON_BIN=<제어 루트 .venv/bin/python>` 를 포함하는 것을 권고 |
|
||||||
|
| E4 | `runner.py:1070` 의 `os.path.getmtime(self.plan_file)` 는 파싱 시점과 Phase 1 사이에 파일이 사라지면 예외로 죽는다(메시지 없는 트레이스백). 극히 좁은 경합 | 사소 |
|
||||||
|
|
||||||
|
## 9. 교차검증 가이드
|
||||||
|
|
||||||
|
```bash
|
||||||
|
RP=/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20260921T150212Z-97d845/1
|
||||||
|
PY=/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.venv/bin/python
|
||||||
|
git -C "$RP" rev-parse HEAD HEAD^{tree} # 1e8265... / 4fd82a... = 브리프의 head·tree
|
||||||
|
# --plan-file 실패 경로 (임시 제어 루트에서만)
|
||||||
|
C=$(mktemp -d); mkdir -p "$C/ctl" "$C/elsewhere/plans"; : > "$C/ctl/empty.md"; echo x > "$C/elsewhere/plans/p.md"
|
||||||
|
(cd "$C/elsewhere" && PYTHONPATH="$RP/.agents/skills" WORKSPACE_ROOT="$C/ctl" MAM_STATE_JSON='{"herdr_sessions":[]}' \
|
||||||
|
"$PY" -m lib_py.loop --creator c --task t --plan-file plans/p.md; echo "rc=$?") # ctl 기준 경로로 실패, rc 1
|
||||||
|
# 수정 없이는 실패하는지 (사본에서만)
|
||||||
|
S=$(mktemp -d); rsync -a --exclude .venv --exclude .git --exclude .mam --exclude nats-docker "$RP/" "$S/"
|
||||||
|
for f in .agents/skills/lib_py/loop/runner.py .agents/skills/multi-agent-mux-loop/scripts/run_loop.sh \
|
||||||
|
.agents/skills/multi-agent-mux-loop/SKILL.md; do git -C "$RP" show 6fc9194:"$f" > "$S/$f"; done
|
||||||
|
(cd "$S" && PYTHONDONTWRITEBYTECODE=1 "$PY" -m pytest -q -p no:cacheprovider --basetemp=/tmp/bt tests/test_issue25_plan_reuse.py) # 13 failed
|
||||||
|
# 실패 2건이 환경 문제임을 확인
|
||||||
|
(cd "$RP" && AGENT_PYTHON_BIN="$PY" PYTHONDONTWRITEBYTECODE=1 "$PY" -m pytest -q -p no:cacheprovider --basetemp=/tmp/bt2 \
|
||||||
|
tests/test_loop_cli.py::test_creator_parses_and_reaches_session_check \
|
||||||
|
tests/test_issue5_phase3_loop.py::test_p3_02_run_loop_wrapper_delegates_to_python_default) # 2 passed
|
||||||
|
```
|
||||||
|
|
||||||
|
[VERDICT: PASS]
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"loop_id": "loop-20261029T045625Z-74296d",
|
||||||
|
"base_sha": "b9c828f09214b4bed6651a223cbb941e7b432647",
|
||||||
|
"head_sha": "3b81f9973223d2a910369711c08adc772003639d",
|
||||||
|
"tree_sha": "ff705b61bf25fe40d0ff054c372a399eacc79730",
|
||||||
|
"review_commit": "3b81f9973223d2a910369711c08adc772003639d",
|
||||||
|
"reviewer": "planner-reviewer-claude-01",
|
||||||
|
"job_id": "2ab23f4e",
|
||||||
|
"verdict": "PASS",
|
||||||
|
"transport": "herdr",
|
||||||
|
"evidence": "review-tree",
|
||||||
|
"isolation": "worktree",
|
||||||
|
"verified_at": "2026-10-29T06:08:06Z"
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
# PR #79 (Issue #68) 리뷰 — head 3b81f99
|
||||||
|
|
||||||
|
리뷰어: planner-reviewer-claude-01 (claude) · job 2ab23f4e
|
||||||
|
|
||||||
|
## 0. 리뷰 경로 확인
|
||||||
|
|
||||||
|
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261029T045625Z-74296d/1`
|
||||||
|
- HEAD 는 `3b81f9973223d2a910369711c08adc772003639d`, tree 는 `ff705b61bf25fe40d0ff054c372a399eacc79730` 로 [리뷰 identity] 와 같다. 리뷰 전과 후 두 번 확인했다.
|
||||||
|
- `git status --porcelain --ignored` 결과는 비어 있고, `.mam` 도 없다.
|
||||||
|
- 리뷰 범위: base `b9c828f` 와 HEAD 사이의 커밋 5개, 9개 파일, +277/−22 줄.
|
||||||
|
- 기준 문서(읽기만 했다): `tea issues 68 --comments` 의 결정 1–4 와 완료 조건, `tea pulls 79` 설명, `INSTRUCTION.md`.
|
||||||
|
|
||||||
|
## 1. 결정·완료 조건 대조표
|
||||||
|
|
||||||
|
| 조건 | 구현(파일:줄) | 테스트 | 판정 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 결정 1: create 의 `--no-onboard` 유지, 기본은 온보딩 | `create_session.sh:67` `ONBOARD=1`, `:87-88` 그대로 | 기존 테스트 전체가 그대로 통과 | ✅ |
|
||||||
|
| 결정 2: worktree_prepare 기본을 온보딩으로 | `worktree_prepare.sh:30` `ONBOARD=1` | `test_prepare_onboards_by_default_end_to_end`(온보딩 잡 completed, "onboarding completed" 출력) | ✅ |
|
||||||
|
| 결정 2: `--no-onboard` 는 건너뛰고 create 에 그대로 넘김 | `:43`, `:232` `args+=(--no-onboard)` | `test_prepare_no_onboard_sends_none`, `test_prepare_forwards_onboarding_choice[opts2]` | ✅ |
|
||||||
|
| 결정 2: 기존 `--onboard` 도 같은 동작 | `:42` | `forwards[opts1]`, `[opts3]`(둘 다 주면 마지막 것이 이김) | ✅ |
|
||||||
|
| 결정 3: agy·codex·hermes·opencode 에만 경고, claude·grok 에는 없음 | `create_session.sh:208-211`, facts `lib_py/agents/__main__.py:75` | `test_facts_expose_post_discovered[6]`, `test_no_onboard_warning_dry_run[6]`, `..._keeps_exit_and_row[6]` | ✅ |
|
||||||
|
| 결정 3: 경고 내용 "첫 대화 전까지 ID 미기록, 같은 폴더 같은 종류는 기다림(#63)" | `:210` 의 문구가 이 두 가지와 `MAM_ID_ORDER_WAIT_S` 를 말한다 | 경고에 `Issue #63` 과 에이전트 이름이 들어 있는지 확인 | ✅ |
|
||||||
|
| 결정 3: 종료 코드·행이 바뀌지 않음 | 경고 출력만 하고 분기는 없다 | `keeps_exit_and_row`: rc 0, 행 필드(이름, status, role, server, workspace, cmd, cmd_full, cwd, id source·verified, 상태, control_plane)를 기대값과 비교 | ✅ |
|
||||||
|
| 결정 3: 뒤에서 거부돼도 경고가 먼저 보임 | spawn·#63 검사(`:287` 부근)보다 앞에서 출력 | `test_no_onboard_warning_precedes_a_later_refusal` | ✅ |
|
||||||
|
| 결정 4: 문서는 create SKILL.md 옵션 표와 worktree 절만 고침 | `SKILL.md:173`, `:205`, `:210`, `:213` 네 줄 | `test_issue42_skill_blocks`(블록 sha 와 근거 핀) | ✅ |
|
||||||
|
| 완료: "기본은 온보딩 안 함" 을 전제로 한 worktree 테스트를 고침 | `test_issue21_acceptance.py:47`, `:207`, `test_issue21_phase2_loop_a.py:422` | 모두 통과 | ✅ |
|
||||||
|
| 완료: macOS(bash 3.2, Python 3.9)에서 통과 | — | §3 | ✅ |
|
||||||
|
|
||||||
|
빠진 조건은 없다.
|
||||||
|
|
||||||
|
## 2. 꼼꼼히 본 것
|
||||||
|
|
||||||
|
### 2.1 경고 판정과 facts
|
||||||
|
|
||||||
|
- 이 Mac 에서 `lib_py.agents facts <agent>` 를 직접 실행했다.
|
||||||
|
- agy, codex, hermes, opencode 는 `MAM_ID_POST_DISCOVERED=1` 이다.
|
||||||
|
- claude, grok 은 `0` 이다.
|
||||||
|
- 값은 어댑터의 `id_lifecycle`(`base.py:174-175` 의 `PRE_ASSIGNED`/`POST_DISCOVERED`)에서 나온다. 셸 쪽에는 에이전트 목록을 두지 않았다.
|
||||||
|
- facts 를 쓰는 다른 곳에는 영향이 없다.
|
||||||
|
- `lib.sh:2596`(eval 뒤 필요한 키만 읽음), `:2957`(eval), `:3156`(`sed -n 's/^KEY=//p'` 로 키별 추출)은 모두 키 단위로 읽는다. 그래서 줄이 하나 늘어도 문제가 없다.
|
||||||
|
- facts 출력을 다루는 테스트(`test_a4_adapter_contract.py`, `test_issue23_24_startup_path.py`)도 이 Mac 에서 103 passed 였다.
|
||||||
|
- facts 를 얻지 못하는 경우(파이썬 실패 등) `eval "$(… || true)"` 는 아무 값도 정하지 않는다. 그러면 `${MAM_ID_POST_DISCOVERED:-0}` 가 0 이 되어 경고 없이 지나간다. 동작이 바뀌지 않는 쪽으로 실패하므로 안전하다.
|
||||||
|
- `MAM_DELEGATION_PY=0` 는 위임(delegation) 경로에만 쓰이는 스위치다. `lib_py.agents facts` 와는 관계가 없어 경고 판정에 영향이 없다.
|
||||||
|
- `test_issue26_ints.py:111` 은 이 값을 "문자열 비교" 로 분류해 정수 기본값 검사 대상에서 뺐다. 코드도 실제로 문자열로 비교하므로(`= 1`) 맞는 분류다.
|
||||||
|
|
||||||
|
### 2.2 `--creator-agent` 를 부르는 모든 곳 (`git grep -n creator-agent`)
|
||||||
|
|
||||||
|
- 테스트
|
||||||
|
- `test_issue21_acceptance.py:47`, `:207` 와 `test_issue21_phase2_loop_a.py:422` 에는 이제 `--no-onboard` 가 붙어 있다.
|
||||||
|
- `test_issue21_phase2_loop_a.py:339`, `test_issue50_onboard.py:192`, `test_issue57_logs_follow_registry.py:276`, `test_issue50_dispatch_herdr.py:868` 는 원래 `--onboard` 를 명시하므로 동작이 같다.
|
||||||
|
- `test_issue21_phase2_loop_a.py:355`(w6c)는 옵션이 없다. 하지만 서브모듈 초기화가 실패해 create 이전에 exit 1 로 끝나고, 행이 없음을 확인하는 테스트다. 그래서 온보딩까지 가지 않는다. 뒤에 다시 부르는 `_prepare(main, wt)` 에는 `--creator-agent` 가 없다.
|
||||||
|
- 새 테스트 파일의 세 호출은 의도한 대로다.
|
||||||
|
- 문서와 기록
|
||||||
|
- `docs/design/worktree-creator-model.md:48-51` 의 예시에는 옵션이 없다. 그래서 이제 온보딩하는데, 예시가 보여 주려는 creator 흐름에도 그게 맞다. 이 문서에는 "기본은 온보딩 안 함" 이라는 문장이 없어 고칠 내용도 없다. 결정 4 의 "최소 변경" 과도 맞는다.
|
||||||
|
- `VERSIONS.md:632` 는 과거 기록이고, `README*.md:51` 은 create 에 대한 설명이라 맞다.
|
||||||
|
- 그 밖의 스크립트에서는 `worktree_prepare.sh` 를 부르는 곳이 없다.
|
||||||
|
- 결론적으로 온보딩 대기(최대 `MAM_ONBOARD_WAIT_S`) 때문에 의도치 않게 느려지거나 44/45 로 끝날 호출은 남아 있지 않다.
|
||||||
|
|
||||||
|
### 2.3 SKILL.md 와 코드의 줄 단위 대조 (INSTRUCTION.md)
|
||||||
|
|
||||||
|
- `SKILL.md:173` 은 "`--no-onboard` 로 만든, ID 가 첫 턴 뒤에 생기는 에이전트(`id_lifecycle`)는 그때까지 ID 를 기록하지 않고, 같은 폴더의 같은 종류 create 를 막는다(#63). create 가 경고한다" 고 설명한다.
|
||||||
|
- 코드(`create_session.sh:209-210`)의 조건과 문구가 이와 같다.
|
||||||
|
- #63 쪽은 정확히는 "막는다" 가 아니라 "기다린 뒤 거부" 다. 하지만 표 한 줄 요약으로는 맞다.
|
||||||
|
- `SKILL.md:205` 의 시놉시스 `[--no-onboard]` 는 `worktree_prepare.sh:9`, `:43` 과 같다.
|
||||||
|
- `SKILL.md:210` 의 "onboards it … `--no-onboard` skips it, `--onboard` is the default and still accepted" 는 `:30`, `:42-43`, `:232` 와 같다.
|
||||||
|
- `SKILL.md:213` 의 종료 코드 "44/45 are create's (…onboarding is the default…)" 는 스크립트 머리말 `:17-19` 와 같고, create 의 44/45 정의(`create_session.sh:22-23`)와도 같다.
|
||||||
|
- 판단이 필요한 내용만 문서에 있고, 판정은 코드(adapter facts)에 있다. INSTRUCTION.md 를 따른 것이다.
|
||||||
|
|
||||||
|
### 2.4 `tests/data/skill_code_blocks.json`
|
||||||
|
|
||||||
|
- 근거 핀의 줄 번호 이동은 실제로 늘어난 줄 수와 맞는다.
|
||||||
|
- `create_session.sh` 는 경고 4줄을 `:208` 에 넣었으므로 그 뒤 근거가 모두 +4 줄 밀렸다(360→364, 518-545→522-549 등).
|
||||||
|
- `worktree_prepare.sh` 는 `--no-onboard` case 한 줄 때문에 +1 줄 밀렸다(250→251).
|
||||||
|
- 내용이 바뀐 블록은 하나다. `SKILL.md:202-206` 의 worktree 시놉시스가 `[--onboard]` 에서 `[--no-onboard]` 로 바뀌었고, 그 블록의 sha 만 `38836d31bf38` 에서 `8bfabafb0328` 로 바뀌었다.
|
||||||
|
- `test_issue42_skill_blocks.py` 가 sha 와 근거를 다시 계산해 통과했다(§3).
|
||||||
|
|
||||||
|
### 2.5 새 테스트가 결함을 잡는지
|
||||||
|
|
||||||
|
- base 대조 실행: `git archive HEAD` 를 TMPDIR 에 풀고, 코드 3개 파일(`__main__.py`, `create_session.sh`, `worktree_prepare.sh`)만 b9c828f 판으로 바꾼 뒤 `tests/test_issue68_onboard_default.py` 를 돌렸다.
|
||||||
|
- 결과는 **20 failed, 5 passed** 로, 작성자의 주장과 같다.
|
||||||
|
- 통과한 5건은 base 에서도 맞아야 하는 경우다. claude·grok 의 dry-run 과 행 시험 4건, 그리고 `--onboard` 전달 1건이다.
|
||||||
|
- 사본은 확인한 뒤 지웠다.
|
||||||
|
- Python 3.9 호환성: `str.removeprefix` 는 3.9 부터 있다. `list[str]` 주석은 `from __future__ import annotations` 덕분에 3.9 에서도 문제가 없다.
|
||||||
|
|
||||||
|
## 3. 실행한 테스트 (이 Mac: macOS, `/bin/bash` 3.2, `/usr/bin/python3` 3.9.6)
|
||||||
|
|
||||||
|
```
|
||||||
|
cd <검증 경로>
|
||||||
|
env -u HERDR_SOCKET_PATH -u HERDR_PANE_ID -u HERDR_TAB_ID -u HERDR_WORKSPACE_ID -u HERDR_SESSION_NAME \
|
||||||
|
-u HERDR_SESSION -u WORKSPACE_ROOT -u MAM_ENV_FILE -u MAM_JOBS_DIR -u DELEGATE_JOB_LOGS_DIR \
|
||||||
|
PYTHONDONTWRITEBYTECODE=1 /Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.venv/bin/python \
|
||||||
|
-m pytest -p no:cacheprovider --basetemp="$TMPDIR/claude-j2ab-bt" -q -rfs \
|
||||||
|
tests/test_issue68_onboard_default.py tests/test_issue21_acceptance.py tests/test_issue21_phase2_loop_a.py \
|
||||||
|
tests/test_issue26_ints.py tests/test_issue42_skill_blocks.py tests/test_issue50_onboard.py \
|
||||||
|
tests/test_issue57_logs_follow_registry.py tests/test_issue63_same_cwd_ids.py
|
||||||
|
```
|
||||||
|
|
||||||
|
- 결과: **265 passed, 1 skipped, 0 failed** (724 s).
|
||||||
|
- skip 1건은 `test_issue42_skill_blocks.py:266` 이다. untracked `.claude/skills` 가 worktree 에 없어서 생기는 환경 문제다.
|
||||||
|
- facts 를 쓰는 다른 테스트 `tests/test_a4_adapter_contract.py`, `tests/test_issue23_24_startup_path.py` 는 **103 passed** (152 s).
|
||||||
|
- base 대조 실행(§2.5)은 `test_issue68_onboard_default.py` 기준 20 failed, 5 passed.
|
||||||
|
- `test_issue50_dispatch_herdr.py` 는 실제 herdr 서버를 띄우므로 브리프 제약에 따라 실행하지 않았다. 이 파일이 worktree_prepare 를 부르는 곳(`:868`)은 `--onboard` 를 명시하므로 이번 변경으로 동작이 바뀌지 않는다.
|
||||||
|
- 테스트가 끝난 뒤 남은 프로세스: 테스트가 띄운 `reconcile.sh`, mock herdr, 구독자는 없다. 남아 있는 것은 원래 있던 셋뿐이다.
|
||||||
|
- 본 체크아웃 모니터 3714
|
||||||
|
- 이번 루프 88445
|
||||||
|
- 이번 잡의 job_subscriber 88788 (13:56:28 시작)
|
||||||
|
|
||||||
|
## 4. 발견 사항
|
||||||
|
|
||||||
|
### 차단
|
||||||
|
|
||||||
|
- 없음.
|
||||||
|
|
||||||
|
### 비차단
|
||||||
|
|
||||||
|
- **N1.** `tests/test_issue68_onboard_default.py:17` 의 `import yaml` 은 쓰이지 않는다. PR 설명에서도 남긴 것으로 밝혔다.
|
||||||
|
- **N2.** `--no-onboard --submit-job "<프롬프트>"` 를 함께 주면, 제출한 잡이 첫 턴이 되는데도 경고가 나온다.
|
||||||
|
- 이 조합에서는 원래 온보딩 프롬프트를 만들지 않는다(`create_session.sh:440`). 그래서 `--no-onboard` 가 사실상 아무 일도 하지 않는다.
|
||||||
|
- 결정 3 의 조건("`--no-onboard` 를 주고 ID 후발견 에이전트이면")을 그대로 구현한 결과다. 그래서 결함은 아니고, 문구가 조금 지나치게 경고할 뿐이다.
|
||||||
|
- **N3.** facts 를 얻지 못했는데 호출자 환경에 `MAM_ID_POST_DISCOVERED=1` 이 export 돼 있으면, claude 에도 경고가 나올 수 있다. 현실적인 경로가 없어 참고로만 적는다.
|
||||||
|
|
||||||
|
## 5. 사용자 확인 필요
|
||||||
|
|
||||||
|
- N2: `--submit-job` 과 함께 준 `--no-onboard` 에도 경고를 낼지. 지금은 결정 3 의 문언대로 경고를 낸다.
|
||||||
|
|
||||||
|
## 6. 결론
|
||||||
|
|
||||||
|
- 결정 1–4 와 완료 조건이 모두 구현됐고 테스트로 덮였다.
|
||||||
|
- 새 테스트는 base 에서 20건이 실패하므로 결함을 실제로 잡는다.
|
||||||
|
- 바뀐 테스트 파일과 create/worktree 관련 테스트가 이 Mac 에서 모두 통과한다.
|
||||||
|
- SKILL.md 의 설명은 코드와 줄 단위로 맞는다.
|
||||||
|
|
||||||
|
[VERDICT: PASS]
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
# 🔍 PR Review — Job 2b62056b (PR #22 `feat/issue-21-worktree-prereqs` @ `4a81d15`, base `main` `5611859`)
|
||||||
|
|
||||||
|
- **Reviewer**: `planner-reviewer-claude-01` (role: planner,reviewer) · 2026-09-14
|
||||||
|
- **대상**:
|
||||||
|
- 워크트리 `multi-agent-mux-wt/pr-22`(detached `4a81d15`)
|
||||||
|
- `git log 5611859..HEAD` 9커밋, `git diff 5611859...HEAD` 19파일 +1905/−78
|
||||||
|
- 설계 커밋 `fe96db1`·`dcf2fab` 과 구현·교정 커밋 7개(`d3d3a76` → `4a81d15`)
|
||||||
|
- **방법**:
|
||||||
|
- 워크트리 diff 를 정독하고 이슈 #21·PR 본문과 대조했다.
|
||||||
|
- 임시 디렉터리에서 실험 1건을 했다(공백 경로 명령 실행).
|
||||||
|
- 워크트리에서 **전체 스위트**를 읽기 전용 조건(`PYTHONDONTWRITEBYTECODE=1 -p no:cacheprovider`)으로 실행했다. 실패는 base(본 체크아웃 `5611859`)에서 같은 시험으로 대조했다.
|
||||||
|
- 코드는 수정하지 않았다.
|
||||||
|
- **안전 규칙 준수**:
|
||||||
|
- 본 체크아웃은 `main` `5611859` 를 유지했다(checkout·switch·merge 없음). 워크트리의 추적 파일은 바꾸지 않았다(HEAD·`git status` 전후 동일). 다만 스위트 실행 중 일부 시험이 무시 대상 `.mam/`(`delegate_job_logs`·`shim`)을 워크트리에 만들었다. 기존 #20 위생 문제이며 §7 에 적었다.
|
||||||
|
- 실제 `.mam`·monitor 데몬·에이전트 세션은 조작하지 않았다. push·merge·이슈/PR 상태 변경도 없었다.
|
||||||
|
|
||||||
|
## 0. 요약
|
||||||
|
|
||||||
|
| # | 항목 | 판정 |
|
||||||
|
|---|---|---|
|
||||||
|
| (1) | 래퍼 절대경로화 | ✅ 공백 경로·다른 cwd 에서도 동작한다(실험 E1) |
|
||||||
|
| (2) | 루프 러너 `--worktree`/`--base`/`--review-only` | ✅ 소스 루트와 제어 루트가 정확히 나뉜다. 빈 변경분 가드는 diff 수집과 같은 정의라 문서만 바뀐 경우를 오탐하지 않는다 |
|
||||||
|
| (3) | `control_plane.py` 와 create/resume/stop/status/reconcile | ✅ 바인딩 없는 기존 행과 기본 워크스페이스는 동작이 바뀌지 않는다(하위 호환) |
|
||||||
|
| (4) | `lib.sh`·shim 계약 시험 | ✅ `_sks_*`·`send_keys_safe` 를 건드리지 않았다. h25 는 오히려 강화되었다 |
|
||||||
|
| (5) | 설계 문서 정정 | ✅ codex NOT PASS 사유 4가지를 반영했다. 비차단 보강 1건 |
|
||||||
|
| (6) | 신규 시험 결정성·격리 | ✅ tmp/sandbox 만 쓰고 브로커가 필요 없다. 위생 비차단 1건 |
|
||||||
|
| (7) | 스위트 결과로 회귀 확인 | ✅(비차단) 937 passed / 4 failed. 4건 모두 기존 T-1 시간 임계(공개 브로커) 시험이고, base 에서도 같은 부류가 실패한다. PR 회귀 없음 |
|
||||||
|
| (8) | #21 항목 완료·범위 | ✅ 1·2·3·6 완료, 4·5·7·8 은 타당하게 제외됐다. 후속 권고 2건 추가 |
|
||||||
|
| (9) | 알려진 미비 (a)(b) 외 차단 결함 | 없음. (a)(b)는 확인했다(§9). 나머지는 비차단 권고뿐이다 |
|
||||||
|
|
||||||
|
## 1. (1) 래퍼 절대경로화 (`d3d3a76`)
|
||||||
|
|
||||||
|
- **레지스트리**:
|
||||||
|
- `REGISTRY_DIR_DEFAULT="${MAM_JOBS_DIR:-.mam/jobs}"` 로 이제 `MAM_JOBS_DIR` 을 존중한다.
|
||||||
|
- `cmd_submit` 은 `cd "$WORKDIR"` 직후 `_abs_registry_dir` 로 `cd -P … && pwd -P` 절대화하고 `SKS_REGISTRY_DIR` 로 export 한다. 상대 레지스트리는 종전처럼 `WORKDIR` 기준이다.
|
||||||
|
- **명령 문자열**: `_pub_cmd` 가 모든 토큰을 `printf %q` 로 인용한다.
|
||||||
|
- 감사 로그 루트는 구독자와 같은 해석기(`get_logs_dir`)의 절대경로를 `DELEGATE_JOB_LOGS_DIR=%q` 접두로 고정한다.
|
||||||
|
- `_instructions` 는 `%s` 자리표시자만 쓰므로 경로의 `%` 로 인한 포맷 주입이 없다.
|
||||||
|
- **경로 목록**:
|
||||||
|
- 브리프 경로: `$REGISTRY_DIR/$JOB_ID/brief.md`
|
||||||
|
- Output Report Path(direct·loop 두 곳)
|
||||||
|
- 상대 `PY` 를 `cd` 전에 절대화
|
||||||
|
- create 의 onboarding 발행 명령(`create_session.sh` delegate 블록)
|
||||||
|
- **실험 E1** — 레지스트리 `…/reg a b/jobs`, 감사 로그 `…/logs dir`, 모두 공백 포함. 주입된 명령을 **다른 cwd** 에서 그대로 `eval` 했다(브로커는 닫힌 포트).
|
||||||
|
|
||||||
|
| 확인 | 결과 |
|
||||||
|
|---|---|
|
||||||
|
| 시작 명령 | `DELEGATE_JOB_LOGS_DIR=…/logs\ dir …/python …/publish_event.py --registry-dir …/reg\ a\ b/jobs --job … --event started --heartbeat 30` |
|
||||||
|
| 시작·성공 명령 rc | 2, 2(브로커 불가, 로컬 기록 뒤) |
|
||||||
|
| 공백 레지스트리의 events | `['started', 'completed']`, 레코드 `completed` |
|
||||||
|
| 감사 로그 루트 | 지정한 `logs dir` 아래에 job 디렉터리 |
|
||||||
|
| 다른 cwd 에 `.mam` 생성 | 0 |
|
||||||
|
| heartbeat 타이머 잔여 | 0(터미널 발행이 멈춤) |
|
||||||
|
|
||||||
|
- 기존 주입 문구 계약: `test_t1_4` 가 소스에서 `.mam/jobs/$JOB_ID` 리터럴을 금지한다. issue14·issue6·issue18 시험 결과는 §7.
|
||||||
|
- 남는 제한: 작은따옴표가 든 경로는 지원하지 않는다(주입 문구가 명령을 `'…'` 로 감싸는 prose 형식). 문서 §7 에 적혀 있다.
|
||||||
|
|
||||||
|
## 2. (2) 루프 러너 (`d907359`)
|
||||||
|
|
||||||
|
- **의미와 상호작용**:
|
||||||
|
- `--worktree` 는 소스 루트다. 경로가 디렉터리이고, `--show-toplevel` 이 자기 자신이며, `git-common-dir` 가 제어 루트와 같아야 한다(`runner.py` `_validate_worktree`).
|
||||||
|
- `--base` 는 소스 HEAD 의 조상인 커밋이어야 한다(`_resolve_base`). env 폴백은 없다(D17).
|
||||||
|
- `--review-only` 는 `--base` 가 필수이고 `--plan` 과 배타다.
|
||||||
|
- `MAM_LOOP_WORKTREE` env 는 CLI 가 우선이다.
|
||||||
|
- **루트 분리**:
|
||||||
|
- **소스 루트 기준**: diff(제어 루트의 `diff_collect.sh` 를 소스 cwd 에서 실행 — 워크트리의 미검토 수집기는 실행하지 않음), `base_commit`(`--base` 또는 Phase 2 시작 시 소스 HEAD), head, 변경분 가드.
|
||||||
|
- **제어 루트 기준**: wrapper 경로, 래퍼에 넘기는 `--registry-dir self.jobs_dir`, `wait_for_job`·`find_report`·cleanup 의 `jobs_dir`, 체크포인트(`worktree_root`·`head_commit` 필드 추가), loop guard, 리포트 승격.
|
||||||
|
- `jobs_dir` 는 `MAM_JOBS_DIR` 을 존중한다. 상대값은 제어 루트 기준이며, 래퍼 해석과 같다(`t2_15`).
|
||||||
|
- **빈 변경분 fail-closed**:
|
||||||
|
- `git diff --quiet <base>`(추적 파일의 작업 트리 대 base)와 `ls-files -o --exclude-standard`(미추적)로 판정한다. 이는 `diff_collect.sh` 가 수집하는 내용(`git diff "$base"` + 미추적 파일, `:40-48`, `:73-74`)과 같은 정의다.
|
||||||
|
- 따라서 **문서만 바뀐 리뷰 전용 흐름도 `present`** 로 판정된다. 오탐이 없다.
|
||||||
|
- `empty` 는 base 대비 추적 변경과 미추적 파일이 모두 없을 때뿐이다. 무시 파일만 바뀐 경우는 리뷰할 것이 없으므로 거부가 맞다.
|
||||||
|
- git 오류는 `unknown` 이 되어 역시 거부된다. 가드는 `--worktree`/`--base` 가 있을 때만, 리뷰 반복마다 동작한다.
|
||||||
|
- **`run_loop.sh`**:
|
||||||
|
- 새 인자는 기존 `MAM_LOOP_ARGV` 로 Python 러너에 그대로 전달된다. freeze 재실행도 원래 argv 를 넘긴다.
|
||||||
|
- 레거시 `MAM_LOOP_PY=0` 은 새 옵션(과 `MAM_LOOP_WORKTREE`)을 freeze·loop lock **이전에** exit 1 로 거부한다(`t2_8`, `mam_sandbox` 에서).
|
||||||
|
- 참고: 가드는 "작업 트리 대 base" 이고, 브리프의 `[워크트리 컨텍스트]` 는 커밋 수와 미커밋 변경 유무를 함께 보여 준다. 따라서 커밋 0개·미커밋 변경만 있는 상태도 리뷰어에게 드러난다.
|
||||||
|
|
||||||
|
## 3. (3) `control_plane.py` 와 전파 경로 (`ee1d896`·`af8a54c`·`13259ed`)
|
||||||
|
|
||||||
|
- **스키마**: 행 필드 `control_plane = {workspace_root, agent_sessions_yaml, mam_jobs_dir, delegate_job_logs_dir}`, 모두 realpath 다.
|
||||||
|
- **해석 순서**: `describe()` 는 행 바인딩(완전할 때)을 호출자 env 보다 우선하고, 다르면 stderr 로 경고한다. `forward` 는 `--workspace` 의 기본 바인딩과 다를 때만 참이다.
|
||||||
|
- **전파**:
|
||||||
|
- create·resume 은 `K=V ` 접두를 `CMD_FULL` 앞에 붙인다. `lib.sh` new-session 이 이것을 `--env` 로 바꾸는 기존 경로를 쓴다.
|
||||||
|
- `13259ed`/`af8a54c`: env 플래그가 있으면 **idle pane 을 재사용하지 않고** `pane split`/`workspace create` 호출에 `--env` 를 싣는다(`lib.sh` 775-787 부근).
|
||||||
|
- 재사용 pane 에 `env K=V` 를 `agent start --` 뒤 에이전트 인자로 넘기던 종전 결함(R1)이 없어졌다.
|
||||||
|
- 공백이 든 바인딩을 전파해야 하면 rc 3 → create·resume 이 exit 2 로 거부한다(`lib.sh` 가 `--env` 플래그를 인용 없이 나누므로).
|
||||||
|
- **resume**: 행 바인딩을 다시 전파한다. 바인딩 없는 기존 행은 이번 resume 의 바인딩으로 채운다(`update_yaml_resumed.sh`).
|
||||||
|
- **stop**: env 가 없을 때만 행의 `mam_jobs_dir`·`delegate_job_logs_dir` 를 export 한다. 취소 발행과 감사 로그가 행의 제어 평면으로 간다(R3).
|
||||||
|
- **status·reconcile**:
|
||||||
|
- `MAM_JOBS_DIR`·행 바인딩·감사 로그를 먼저 보고, 그 뒤 종전 후보를 그대로 본다.
|
||||||
|
- reconcile 구독 루프는 `MAM_JOBS_DIR` 을 우선한다(`reconcile.sh:239-245`). 이 부분의 시험은 소스 정적 가드뿐이다(`t3_7`).
|
||||||
|
- **하위 호환**:
|
||||||
|
- `--workspace` 가 제어 루트와 같은 일반 세션은 바인딩이 기본값과 같다. 그래서 기록만 되고 전파되지 않으며(`t3_2`), pane 생성 경로(idle pane 재사용 포함)가 종전과 같다.
|
||||||
|
- 바인딩 없는 기존 행은 status·stop·reconcile 에서 종전 후보 순서로 처리된다.
|
||||||
|
- 비차단: `mam_control_plane_json … 2>/dev/null || true`(create)가 실패를 삼키면 바인딩 없이 종전 동작으로 조용히 떨어진다. PR 후속 1·2 와 같다.
|
||||||
|
|
||||||
|
## 4. (4) `lib.sh`·shim 계약 시험
|
||||||
|
|
||||||
|
- **`lib.sh` 변경은 두 곳뿐이다**:
|
||||||
|
- new-session 의 idle pane 재사용 조건에 `&& [ -z "$env_flags" ]` 를 추가하고, 재사용 pane 에 `env …` 를 붙이던 코드를 제거했다.
|
||||||
|
- 파일 끝에 얇은 진입점 `mam_control_plane_json`·`mam_control_plane_prefix` 를 추가했다.
|
||||||
|
- `_sks_*`·`send_keys_safe` 를 포함한 다른 hunk 는 없다.
|
||||||
|
- **h25 는 약화가 아니라 강화다**:
|
||||||
|
- 종전 h25 는 결함 있는 동작(idle pane 재사용 + 에이전트 인자로 env)을 고정하고 있었다.
|
||||||
|
- 새 h25 는 env 가 **생성 호출의 `--env`** 에 있고 `agent start --` 인자에는 없음을 단정한다.
|
||||||
|
- env 없는 재사용은 h24 가 그대로 고정한다.
|
||||||
|
|
||||||
|
## 5. (5) 설계 문서 정정 (`b61c8b1`)
|
||||||
|
|
||||||
|
- **codex NOT PASS 사유별 반영**:
|
||||||
|
- 부분 지원: §3 제목이 "현재 코드 기준" 에서 "부분 지원 + 1단계 수정 반영" 으로 바뀌었다.
|
||||||
|
- 루트 3종: §7 이 제어·소스·런타임 코드 루트를 정의한다.
|
||||||
|
- 리뷰 identity: §2-C 가 항목 5 로 미뤘다고 명시한다.
|
||||||
|
- 과장 표현: A 의 "완전 격리", 리뷰어 "읽기 전용", 잡 토픽과 지문을 정정했다(§1 표).
|
||||||
|
- **추가된 내용**:
|
||||||
|
- 스킬별 호환성 표(§7)
|
||||||
|
- 1단계 운영 형태(구현은 루프 밖, 리뷰는 `--review-only` 로 하나씩)
|
||||||
|
- 두 번째 루프는 **거부**된다는 서술
|
||||||
|
- "실제 herdr `--env` 전파는 mock 수준까지만 검증" 이라는 정직한 단서
|
||||||
|
- 비차단 보강: §3 의 create 레시피에 `MAM_NO_SUBSCRIBER_DAEMON=1` 을 넣을 것.
|
||||||
|
- `create_session.sh:567` 의 `start_watchdog "$DELEGATE_JOB_ID" "$WORKSPACE"` 는 여전히 워크트리의 `reconcile.sh` 로 데몬을 띄울 수 있다(항목 4 에서 기본화 예정).
|
||||||
|
- "모니터는 본 체크아웃에서만"(§3 본문)을 지키려면, 항목 4 가 오기 전까지는 레시피 한 줄로 막는 편이 안전하다.
|
||||||
|
|
||||||
|
## 6. (6) 신규 시험 780줄의 결정성·격리
|
||||||
|
|
||||||
|
- 파일 머리의 규칙대로 모든 쓰기가 `tmp_path`·`mam_sandbox` 아래에서 일어난다.
|
||||||
|
- `wt_repo` 픽스처는 `monkeypatch.setenv` 로 `WORKSPACE_ROOT`·`MAM_JOBS_DIR` 을 임시 루트에 묶는다.
|
||||||
|
- 래퍼 시험은 브로커를 `127.0.0.1:9`(`UNREACHABLE`)로 두어 **브로커가 필요 없다**.
|
||||||
|
- 비차단 위생: `t2_6` 이 `monkeypatch` 대신 `os.environ` 을 직접 바꾼다. `try/finally` 로 복구하므로 새지는 않는다(PR 후속 3).
|
||||||
|
- `t3_7` 은 reconcile 의 `MAM_JOBS_DIR` 우선을 소스 문자열로만 고정한다(브로커가 필요한 경로라서다). 약하지만 명시되어 있다.
|
||||||
|
- 격리 실측(실제 `.mam/jobs`, 데몬, 워크트리 상태)은 §7.
|
||||||
|
|
||||||
|
## 7. (7) 테스트 실행 결과
|
||||||
|
|
||||||
|
| 실행 | 결과 |
|
||||||
|
|---|---|
|
||||||
|
| **전체 스위트** @ `4a81d15`(워크트리, 11:52–12:27Z, 오케스트레이터·다른 리뷰어 스위트와 동시) | **937 passed, 4 failed in 2103 s**. HEAD 전후 동일(`4a81d15`) |
|
||||||
|
| 실패 4건 | pane-busy `test_issue14…::…ignores_pane_busy_token`(rc 0: 늦은 completed 가 수락됨), 벽시계 `…never_outlives_the_wall_clock`(closed after **28.12 s** > 28), AC1(rc 2), AC5(rc 2) |
|
||||||
|
| 4건 단독 재실행 — PR `4a81d15` | 3 failed / 1 passed(AC1 통과). 이때 공개 브로커가 `no CONNACK from broker` 로 재시도하고 있었다 |
|
||||||
|
| 4건 단독 재실행 — base `5611859`(본 체크아웃) | 2 failed / 2 passed(AC1·AC5 실패) |
|
||||||
|
|
||||||
|
- **판단**:
|
||||||
|
- 4건은 #18 리뷰에서 main 계열 코드(`135d76f`·`7e1d3f0`·`ab842f1`)로도 같은 방식으로 여러 번 실패했던 T-1 부류다. 벽시계 28.1 s 는 그때와 같은 값이다.
|
||||||
|
- 같은 브로커 상태에서 base 도 이 부류가 실패한다.
|
||||||
|
- PR 이 래퍼 submit 에 python 호출 1회(`_logs_dir_abs`)를 추가했지만, 측정된 벽시계 값은 main 과 같다.
|
||||||
|
- **PR 이 만든 실패는 0건으로 본다.**
|
||||||
|
- PR 본문의 "33건 환경 실패" 는 이 환경(macOS, venv Python 3.9)에서는 재현되지 않았다. 이 환경의 실패는 위 4건뿐이다.
|
||||||
|
- **격리**:
|
||||||
|
- 본 체크아웃은 `main` `5611859` 클린을 유지했다. 실제 `.mam/jobs` 추가는 0 이다.
|
||||||
|
- 워크트리의 추적 파일은 변하지 않았다(`?? .venv` 만).
|
||||||
|
- **그러나 무시 대상 `.mam/` 이 워크트리에 생겼다**: `.mam/delegate_job_logs`(잡 21개)와 `.mam/shim`. 생성 시각이 20:52–21:13 KST 로 이 스위트 실행 중이다.
|
||||||
|
- 일부 시험이 `$REPO_ROOT/.mam` 에 쓴다(#20 위생 문제). PR 후속 4 가 지목한 `test_issue14…::_job_for_inprocess_publish` 의 감사 로그와, 샌드박스 없이 `lib.sh` 를 소싱할 때 생기는 shim 이다.
|
||||||
|
- PR 이 새로 만든 문제는 아니지만, 워크트리 병렬 운영에서는 워크트리마다 쌓인다.
|
||||||
|
- 읽기 전용 규칙에 따라 지우지 않았다. 오케스트레이터가 정리할 것을 권한다.
|
||||||
|
- 샌드박스 heartbeat 타이머: 실행 직후 집계에서 pytest 경로 타이머 3개가 보였으나, 재확인했을 때는 0개였다(동시에 돌던 다른 스위트의 것으로 보인다).
|
||||||
|
- 모니터 데몬 전후 비교는 스냅샷 필터 오류로 판정하지 못했다.
|
||||||
|
|
||||||
|
## 8. (8) #21 작업 항목과 후속
|
||||||
|
|
||||||
|
- **완료**: 항목 1(래퍼 경로), 2(러너 옵션·가드), 3(바인딩 영속·전파, status/reconcile/stop/loop 의 `MAM_JOBS_DIR`), 6(문서 정정). 각각 시험으로 고정되어 있다.
|
||||||
|
- **제외의 타당성**: 4(준비·해체 헬퍼, 데몬 기본화), 5(리뷰 identity·검증 격리), 7(실제 herdr 수용 사례), 8(다중 루프). 모두 설계 결정이나 실환경이 필요한 별도 작업이다. 1단계 운영(구현은 루프 밖, 리뷰 루프 하나)에 필요한 최소 변경만 이 PR 에 들어갔다는 판단에 동의한다.
|
||||||
|
- **후속 권고 5건은 적절하다**. 추가로 두 가지를 권한다.
|
||||||
|
- 6) 항목 4 전까지 §3 레시피에 `MAM_NO_SUBSCRIBER_DAEMON=1`(§5).
|
||||||
|
- 7) 첫 실제 워크트리 creator 전에 항목 7 의 "실제 herdr `--env` 전파" 수용 사례를 먼저 확인한다(현재는 mock 검증뿐). 전파가 안 되더라도 주입 명령이 절대경로라 잡 발행은 동작하지만, creator 가 스스로 부르는 스킬은 워크트리 `.mam` 을 쓰게 된다.
|
||||||
|
|
||||||
|
## 9. (9) 병합을 막을 결함
|
||||||
|
|
||||||
|
- **알려진 미비**:
|
||||||
|
- (a) 버전 bump 가 없다(4.5.0 유지). 새 CLI 옵션과 새 모듈이므로 4.6.0(MINOR)과 VERSIONS 항목이 필요하다.
|
||||||
|
- (b) `docs/design/reviews/review-agy-fd384d6b.md` 의 markdown 줄바꿈용 trailing whitespace 로 `git diff --check` 가 실패한다.
|
||||||
|
- 둘 다 확인했다. 코드 파일에는 whitespace 문제가 없다.
|
||||||
|
- **그 밖에 병합을 막을 결함은 찾지 못했다**.
|
||||||
|
- 비차단:
|
||||||
|
- create 의 바인딩 해석 실패 삼킴(§3, PR 후속 1)
|
||||||
|
- §3 레시피의 `MAM_NO_SUBSCRIBER_DAEMON=1`(§5)
|
||||||
|
- `t2_6` 의 `os.environ` 직접 조작(§6)
|
||||||
|
- 시험의 `$REPO_ROOT/.mam` 쓰기(§7, #20)
|
||||||
|
- T-1 시간 임계 시험
|
||||||
|
|
||||||
|
## 10. 종합
|
||||||
|
|
||||||
|
- PR #22 는 #21 항목 1·2·3·6 을 정확히 구현했다.
|
||||||
|
- 래퍼 경로는 공백 경로와 다른 cwd 에서도 절대·인용이 맞다(실험).
|
||||||
|
- 러너는 소스 루트와 제어 루트를 분리하고 diff 수집과 같은 정의의 fail-closed 가드를 둔다.
|
||||||
|
- 제어 평면 바인딩을 영속·전파하면서도 기존 워크스페이스와 바인딩 없는 행의 동작을 바꾸지 않는다.
|
||||||
|
- `lib.sh` 변경은 idle pane 재사용 조건에 한정되며 계약 시험은 강화되었다.
|
||||||
|
- 전체 스위트의 실패 4건은 base 에서도 같은 부류로 실패하는 기존 브로커 시간 임계 시험이다.
|
||||||
|
- 병합 전에 알려진 미비 (a)(b)를 릴리스 커밋으로 처리하면 된다. 설계 재검토는 필요 없다.
|
||||||
|
|
||||||
|
[VERDICT: PASS]
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
# 🔍 Review Report — PR #41 델타 리뷰 2회차 (job `2cc309ea`)
|
||||||
|
|
||||||
|
- **Reviewer**: `planner-reviewer-claude-01` (role: planner,reviewer) · 2026-09-26
|
||||||
|
- **대상 델타**: `1673818..af0a2a2` (1커밋, 5파일 `+406/−12`, `git diff --check` 클린), base `main 8ea65bb` (v4.7.4).
|
||||||
|
- **검증 경로**: `/var/folders/.../T/mam-review/loop-20260926T052018Z-597343/1`. **identity 확인**: `HEAD = af0a2a25…3a1a`, `HEAD^{tree} = 03106826…6ce8` — 브리프와 일치.
|
||||||
|
- **1회차 내 판정**: NOT PASS(N1) + M1~M9 (job `ed0e1492`). 이번에는 N1·codex R1/R2 정정과 내 비차단 항목의 처리를 본다.
|
||||||
|
- **주의**: 같은 델타(동일 base·head·tree)에 대한 직전 잡 `ce114c80` 은 내가 검증 중에 오케스트레이터 쪽에서 `cancelled` 되었고 그 검증 경로는 사라졌다. 코드 검토·하네스 실험은 그때 것을(트리가 동일하므로) 그대로 쓰고, 테스트 실행은 **이 잡의 검증 경로에서 다시** 돌렸다(§6).
|
||||||
|
- **안전 규칙 준수**: 검증 경로·소스 워크트리·본 체크아웃(`main 8ea65bb`) 모두 수정·커밋·checkout 없음. 실험은 scratchpad 하네스(`stop_stall_watch` 함수 추출, 대역 프로세스는 내가 만들고 내가 정리)·in-process 모듈 검사·되돌린 사본에서만. 실제 세션에 stall_watch 를 붙이지 않았고 실제 `.mam`·데몬·herdr 서버 조작 없음, push·merge·PR 상태 변경 없음. 1회차에서 남았던 멈춘 래퍼·감시자는 이번 시작 시점에 더 이상 없었다.
|
||||||
|
|
||||||
|
## 결론 요약
|
||||||
|
|
||||||
|
**N1 은 닫혔다.** 래퍼의 `stop_stall_watch` 는 TERM 을 무시하는 감시자에 대해서도 **6 s 안에 돌아오고**(실측), 감시자는 `--watch-pid` 로 래퍼가 사라지면 스스로 끝난다. R2 의 재검사(`_dispatch_open`)는 wait 직후·nudge 직전·루프 머리 세 곳에 있고, 예외 정책도 타당하다. 비차단 관찰 2건(P1·P2).
|
||||||
|
|
||||||
|
## 1. N1 — 유계 stop (검토 요청 1~2)
|
||||||
|
|
||||||
|
`multi-agent-mux-delegate-job:710-727` `stop_stall_watch`: `kill -0` → `kill`(TERM) → 0.2 s × 25 폴링 → 남아 있으면 stderr `ignored TERM; killed` + `kill -9` → `wait`. 함수를 그대로 추출해 네 가지 대역에 돌렸다:
|
||||||
|
|
||||||
|
| 대역 | 결과 |
|
||||||
|
|---|---|
|
||||||
|
| TERM 을 무시(`trap "" TERM; exec sleep 300`) | **6 s** 에 rc 0 반환, stderr `stall watch for j1 ignored TERM; killed (pid …)`, 대역 사망 |
|
||||||
|
| 정상(TERM 에 죽음) | 0 s |
|
||||||
|
| `STALL_PID` 비어 있음 | 즉시 return 0 |
|
||||||
|
| 이미 종료(rc 2) | 즉시 반환, stderr `stall watch for j1 exited rc=2:` + 로그 꼬리 (기존 보고 유지) |
|
||||||
|
|
||||||
|
`wait` 가 다시 무한이 될 경로: KILL 뒤의 `wait` 는 SIGKILL 된 자식에 대해 즉시 돌아오고(실측), `kill -0` 실패 분기의 `wait` 는 이미 끝난 자식의 저장된 상태를 돌려준다. 남는 이론적 경우는 자식이 D 상태(커널 내 대기)로 KILL 을 못 받는 것뿐인데 감시자는 파이프·소켓 대기라 해당하지 않는다. **1회차 N1 의 원인 가설(fork-exec 창에서 TERM 이 삼켜짐)** 은 PR 이 래퍼 trace 로 확인했다고 밝혔고, 내 1회차 관찰(래퍼 `__wait4`, 감시자 정상 루프, 수동 TERM 에 즉시 종료)과 정합한다 — 어느 쪽이든 유계 stop 이면 닫힌다.
|
||||||
|
|
||||||
|
`--watch-pid "$$"`(`:695`): `start_stall_watch` 는 메인 셸의 함수라 `$$` 는 래퍼 pid 다. 감시자 `run()` 루프 머리에서 `_alive(watch_pid)` 가 거짓이면 rc 0(`stall_watch.py:298-303`). `_alive` 실측: 살아 있는 자식 True, **pid 1(권한 없음) True**, reaped/없는 pid False — 권한 오류를 "살아 있음" 으로 보는 규칙이 맞고, `watch_pid` 가 0/None 이면 `if self.args.watch_pid and …` 가 검사를 건너뛴다(`os.kill(0,0)` 은 프로세스 그룹 시그널이라 항상 True 인데, 그 값이 들어올 경로는 없다).
|
||||||
|
|
||||||
|
## 2. R2 — 재검사 위치와 예외 정책 (검토 요청 4)
|
||||||
|
|
||||||
|
`_dispatch_open`(`stall_watch.py:266-285`): 레코드 재읽기 → 종단 상태·`dispatch_token` 불일치·`attempt_id` 불일치면 False. 호출 지점 세 곳: 루프 머리(`:296`), **herdr wait 직후**(`:332-334`, 블로킹 대기 중 닫힌/재디스패치된 잡), **nudge 직전**(`:384-386`, `collect()` 가 시간을 쓴 뒤). 충분하다 — prompt 를 보내는 유일한 자리 앞에 두 겹이 있다.
|
||||||
|
- 예외: `FileNotFoundError → False`(레코드 삭제 = 잡 없음, 감시 중단이 안전), 그 외 `OSError/ValueError → True`(원자적 갱신 중의 일시 상태를 종결로 오인하지 않음) — 타당하다.
|
||||||
|
- blocked 경로는 재검사하지 않는다: 보내는 것이 `progress` 알림뿐이고 prompt 가 없다. 닫힌 잡에 대한 `progress` 는 `publish_event` 의 CAS(토큰)·종단 뒤 거부가 막고, 설령 기록돼도 사람에게 가는 알림 한 줄이라 무해 — 그대로 둬도 된다.
|
||||||
|
- `_notify` 가 False(rc ∉ {0,2})면 nudge 를 보내지 않고 `nudged=True` 로 두어 재시도하지 않는다(`:387-391`) — "잡당 1회" 계약 유지.
|
||||||
|
|
||||||
|
## 3. R1 — `--watch-pid` 만으로 닫은 판단 (검토 요청 5)
|
||||||
|
|
||||||
|
동의. 래퍼가 TERM/KILL 로 사라지면 그 셸의 어떤 trap 도 돌지 않으므로 trap 합성은 R1 을 닫지 못한다; 자식이 부모 생존을 보는 것이 유일하게 확실한 방법이고 #18 heartbeat 타이머의 `--watch-pid` 와 같은 계약이다. 지연은 최대 herdr wait 한 번(≤ 60 s)이며 문서(`SKILL.md:51-52`)가 그렇게 말한다. 감시자가 대기 중 만든 herdr 자식은 `--timeout` 으로 스스로 끝나고, 새 테스트 (d) 가 "TERM 받은 래퍼 뒤에 감시자가 30 s 안에 rc 0" 을 고정한다. 기존 bootstrap EXIT trap 과의 합성은 항목 4 로 미루는 것이 맞다(래퍼 흐름 변경).
|
||||||
|
|
||||||
|
## 4. M8 좁힘과 r4 (검토 요청 6)
|
||||||
|
|
||||||
|
`test_issue30_stall_watch.py:39-40` 이 모듈 마크를 `PROC` 데코레이터로 바꾸고 S6(`/proc` 직접 읽기)에만 붙였다. `box` 기반 시험은 실제 프로세스를 띄우고 `background_children` 의 macOS `ps` 경로를 지나므로 이 호스트에서 **실제 검증**이 된다(1회차에 내가 손으로 확인한 경로). r4 의 stub 시계 `monotonic → time`: stub 프로세스와 판정 프로세스가 서로 다른 프로세스라 macOS 의 프로세스별 monotonic 기준점 차이를 피하는 것이 맞다(codex 지적 타당).
|
||||||
|
|
||||||
|
## 5. 1회차 내 항목의 처리
|
||||||
|
|
||||||
|
| # | 1회차 항목 | 이번 델타 |
|
||||||
|
|---|---|---|
|
||||||
|
| N1 | 래퍼가 감시자를 기다리며 영구 정지 | **닫힘** — 유계 stop + `--watch-pid` + 테스트 (a)(b)(d) |
|
||||||
|
| M1 | 유예 루프의 시계 판정이 bash | 열림 → #17 항목 4 목록(PR 명시) |
|
||||||
|
| M2 | 감시 여부 판정이 bash | 열림 → 항목 4 |
|
||||||
|
| M3 | 감시자 수명이 래퍼 정상 경로에만 묶임 | **닫힘** — `--watch-pid` |
|
||||||
|
| M4 | `MAM_JOB_TIMEOUT_S` 오류 시 러너 무언 | 열림 → 항목 4 |
|
||||||
|
| M5 | "before v4.7.5" | **닫힘** — v4.8.0 |
|
||||||
|
| M6 | `MAM_WAIT_GRACE_S` 미문서 | 열림 → 항목 4 목록(PR 명시) |
|
||||||
|
| M7 | `MAM_TEST_UNRELIABLE_AGENT` 훅 | 열림(후속, 사유 타당) |
|
||||||
|
| M8 | 모듈 전체 Linux skip | **닫힘** — S6 만 |
|
||||||
|
| M9 | B3 의 공개 브로커 의존 | 열림(후속; "started 자체가 안 닿는 경우" 라는 진단에 동의 — 로컬 events.log 로 heartbeat 를 넣는 방식이 남은 선택지) |
|
||||||
|
|
||||||
|
## 6. 테스트 (검토 요청 3 + 검증 방법)
|
||||||
|
|
||||||
|
### 6-1. 이 잡의 검증 경로에서의 실행 (브리프의 명령 그대로, pr-41 `.venv` 인터프리터)
|
||||||
|
|
||||||
|
```
|
||||||
|
tests/test_issue30_stall_watch_stop.py tests/test_issue30_stall_watch.py tests/test_issue14_delivery_ack.py tests/test_tier1_unit.py
|
||||||
|
→ 1 failed, 132 passed, 1 skipped in 589.33s (0:09:49)
|
||||||
|
FAILED test_issue30_stall_watch.py::test_s9_the_wrapper_runs_the_watcher_for_the_jobs_life[0]
|
||||||
|
SKIPPED test_issue30_stall_watch.py:331 (S6, real process trees via /proc)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **새 stop 테스트 7건 전부 통과**, `test_issue30` 는 M8 좁힘대로 S6 하나만 skip 이고 나머지(box 기반 S1–S5·S7·S8·S13, R1–R4 포함)가 이 호스트에서 **실제로 돈다**. 1회차에서 멈췄던 `test_issue14::test_wrapper_honours_registry_ack_over_unconfirmed_inject` 는 합동·단독 모두 통과 — **N1 재현 없음**.
|
||||||
|
- **S9[0] 실패는 공개 브로커 flake** 다: 단언은 `t18._publish(... "started").returncode == 0` 이고 stderr 는 `publish failed after 2 attempts: no CONNACK from broker`. S9 는 `_live_broker()` 로 **일부러** 공개 브로커를 쓰며(래퍼 구독자가 와이어를 봐야 하므로), 진짜 도달 불가 브로커(127.0.0.1:9)에 대해 `publish_event` 는 6/6 회 rc 2 를 돌려준다 — 즉 0 을 기대하는 이 단언은 브로커가 응답할 때만 성립한다. 같은 세 시험(S9[0]·R2[True]·issue14 pane_busy)을 단독으로 다시 돌리자 그 셋은 통과하고 이번엔 **R2[False]** 가 같은 `no CONNACK` 로 실패했다 — 어느 케이스가 걸리는지가 브로커 응답 여부로 바뀌는 전형적 T-1 이다. `publish_event` 는 이 델타가 건드리지 않았다(P2).
|
||||||
|
- 같은 트리(1673818..af0a2a2 는 동일 tree `0310682`)에 대한 직전 잡 `ce114c80` 의 실행(취소 전 완료)도 130 passed / 3 failed(S9[0]·R2[True]·issue14 pane_busy — 전부 위 부류) / 1 skipped 로 같은 그림이었다.
|
||||||
|
|
||||||
|
### 6-2. 수정 없이는 실패하는가 — 확인함
|
||||||
|
|
||||||
|
사본에서 래퍼와 `stall_watch.py` 만 `1673818` 판으로 되돌리고 `test_issue30_stall_watch_stop.py` 를 돌린 결과 **7 failed / 0 passed (134 s)** — (a)(b)(c)(d) 는 `TimeoutExpired`, (e)(f)(g) 는 AssertionError. 되돌린 실행이 남긴 프로세스는 없었다(대역·shim 은 테스트가 정리).
|
||||||
|
|
||||||
|
### 6-3. 결정성·잔류 (검토 요청 3)
|
||||||
|
|
||||||
|
- (a) 대역은 `kill -9` 로, (b) shim 은 래퍼가, (c)(d) 의 `sleep 300`/감시자는 테스트의 `finally` 가 죽인다 — 두 번의 전체 실행과 되돌린 실행 뒤 **`stall_watch --job`/`sleep 300` 잔류 0**(호스트에 남은 `lib_py` 프로세스는 오케스트레이터의 루프 러너뿐).
|
||||||
|
- 시간 상한은 (a) 4–12 s, (b) 40 s, (c)(d) 30 s 로 stop 의 5 s 폴링과 감시자의 최대 60 s wait 를 넉넉히 감싼다. 이번 실행에서 stop 테스트들은 상위 8 느린 시험에 들지 않았다.
|
||||||
|
- 실제 `.mam/jobs` 전후: 이 잡 자신의 heartbeat/레코드 파일 외 **추가 0**. 검증 경로 tracked 파일 변화 없음(HEAD `af0a2a2`), 본 체크아웃 `main 8ea65bb`.
|
||||||
|
|
||||||
|
## 7. 판정
|
||||||
|
|
||||||
|
N1(래퍼 영구 정지)은 유계 stop(실측 6 s)과 `--watch-pid`(부모 소멸 시 자진 종료)로 **양쪽에서** 닫혔고, R2 는 prompt 앞 두 겹 재검사로 닫혔으며, R1 은 `--watch-pid` 만으로 닫은 판단이 맞다. 1회차 판정을 뒤집을 요소는 없고, 델타 밖의 남은 항목(M1/M2/M4/M6 → 항목 4, M7·M9 후속)은 PR 이 명시했다. 버전 락스텝 bump 는 브리프대로 판정에서 제외한다.
|
||||||
|
|
||||||
|
| # | 비차단 관찰 | 근거 |
|
||||||
|
|---|---|---|
|
||||||
|
| P1 | 감시자가 부모 소멸을 알아채는 지연이 최대 herdr wait 한 번(≤ 60 s)이라 문서와 일치하지만, idle 상태에서는 wait 가 30 s 단위라 실제 지연은 그보다 짧고 `blocked`/`working` 대기(60 s)에서 가장 길다 | `stall_watch.py:276-283`; 정보 |
|
||||||
|
| P2 | S9/R2 가 공개 브로커에 의존해 `no CONNACK` 로 간헐 실패한다(이 호스트 3회 실행 중 매번 1–3건). `_live_broker()` 가 설계라면 rc 0 대신 로컬 `events.log` 의 `started` 줄로 단언하거나 rc 2 를 허용하는 편이 결정적 | `test_issue30_stall_watch.py` S9 `_publish(...).returncode == 0`; T-1 부류 |
|
||||||
|
|
||||||
|
## 8. 교차검증 가이드
|
||||||
|
|
||||||
|
```bash
|
||||||
|
RP=/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20260926T052018Z-597343/1
|
||||||
|
PY=/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux-wt/pr-41/.venv/bin/python
|
||||||
|
git -C "$RP" rev-parse HEAD 'HEAD^{tree}' # af0a2a2… / 0310682…
|
||||||
|
# stop_stall_watch 만 추출해 TERM 무시 대역에 → 6 s 안에 "ignored TERM; killed" 후 반환 (§1 표)
|
||||||
|
# _alive: 살아있는 자식 True / pid 1 True(권한) / reaped False (in-process)
|
||||||
|
# 되돌린 사본: 래퍼·stall_watch.py 를 1673818 판으로 → tests/test_issue30_stall_watch_stop.py → 7 failed
|
||||||
|
(cd "$RP" && AGENT_PYTHON_BIN="$PY" PYTHONDONTWRITEBYTECODE=1 "$PY" -m pytest -p no:cacheprovider -q --basetemp=/tmp/bt \
|
||||||
|
tests/test_issue30_stall_watch_stop.py tests/test_issue30_stall_watch.py tests/test_issue14_delivery_ack.py tests/test_tier1_unit.py)
|
||||||
|
```
|
||||||
|
|
||||||
|
[VERDICT: PASS]
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# 🔍 Review Report — Job 2d3f24e4 (branch `fix/issue-9-14`, HEAD `ebe58f6`)
|
||||||
|
|
||||||
|
- **Reviewer**: `planner-reviewer-claude-01` (role: planner,reviewer) · 2026-09-13
|
||||||
|
- **대상**: `git log 4d444ce..HEAD` 9커밋 — `497633f`, `16376c5`, `4b31345`, `eaa49aa`, `0918917`(release v4.4.2), `7c40bf4`, `d5a191c`, `952657e`, **`ebe58f6`**(이번 라운드 신규). 이전 라운드에서 본 커밋은 변경분만 재확인하고, `ebe58f6` 는 전체를 검토했다.
|
||||||
|
- **방법**: 코드 정독, `events.log` 소비자 전수 확인, 선택 테스트(부하 없는 단독 실행) + publish 계약 스위트. 리뷰 중 45초 간격 `progress` heartbeat 발행. 코드 수정 없음.
|
||||||
|
|
||||||
|
## 0. 직전 라운드(HEAD `952657e`) 결과와 처리
|
||||||
|
|
||||||
|
| 리뷰어 | 판정 | 요지 |
|
||||||
|
|---|---|---|
|
||||||
|
| `planner-reviewer-claude-01`(`9323564c`) | PASS | 벽시계 경계 확인, t1_20 부하 민감성 권고 |
|
||||||
|
| `reviewer-agy-01`(`f191b557`) | PASS | — |
|
||||||
|
| `reviewer-grok-01`(`b0591d20`) | **NOT PASS** | `publish_event.py` 가 heartbeat 를 **와이어 발행(브로커 ACK·재시도) 뒤에** `events.log` 에 기록 → R1 의 디스크 근거가 늦게 도착해 살아 있는 잡이 `deadline_timeout` 으로 닫힘. 실제 `publish_event` 경로 테스트가 2/2 실패 |
|
||||||
|
| `planner-reviewer-codex-01`(`e657c660`) | **리뷰 없음** | 구독자가 120 s 동안 이벤트 0건(`idle timeout … still pending`) 후 `deadline_timeout`. codex 가 `started` 조차 발행하지 않았으므로 heartbeat 순서 문제와는 무관 |
|
||||||
|
|
||||||
|
creator 는 grok 지적을 반론 없이 수용해 `ebe58f6` 로 고쳤다. `952657e` 에서 새 순서 테스트 2건과 grok 의 테스트가 실패함을 역검증했고, 수정 후 grok 의 테스트는 무변경으로 3/3 통과했다.
|
||||||
|
|
||||||
|
## 1. `ebe58f6` 검토 — ✅
|
||||||
|
|
||||||
|
### 1-1. 순서와 중복 방지
|
||||||
|
- **CLI `publish_event.main`**:
|
||||||
|
1. token·attempt 검증 — 불일치는 rc 4 로 거부되며 **기록되지 않는다**(`publish_event.py:290-341`)
|
||||||
|
2. `prepare_event`(seq 소비)(`:343-356`)
|
||||||
|
3. **`record_local_event`**(`:360`) → `registry.append_event`(`:165-175`)
|
||||||
|
4. `with_retry(_publish_once)`(`:364-376`)
|
||||||
|
5. `record_outcome(..., local_event_recorded=True)`(`:385-388`) — 감사 로그 `published` 항목과 상태 동기화만 발행 뒤에 수행하고, `events.log` append 는 건너뛴다(`:204-205`)
|
||||||
|
- **`DelegationService.publish_event`**: 같은 순서다(`service.py:82-100`).
|
||||||
|
- **다른 호출자**:
|
||||||
|
- `record_outcome` 의 기본값은 `local_event_recorded=False` 라서 직접 호출하는 테스트(G-1/G-2/G-4)는 동작이 그대로다.
|
||||||
|
- `prepare_event` 의 유일한 다른 호출자 `bench.py:149` 는 원래 `events.log` 를 쓰지 않았으므로 영향이 없다.
|
||||||
|
- 새 테스트가 "발행 시작 순간 이미 1줄", "재시도 2회에도 1줄 + 감사 `published=False`"를 단정한다.
|
||||||
|
|
||||||
|
### 1-2. `events.log` 소비자 영향
|
||||||
|
- **`_recent_disk_heartbeat`(래퍼)**: 의도한 수혜자다. 브로커 지연(실측 약 5 s/발행)과 무관하게 heartbeat 가 즉시 디스크에 보인다.
|
||||||
|
- **`registry.get_feedback` 폴백(`registry.py:241-260`)**: 감사 ndjson 에 terminal 이벤트가 없을 때만 `events.log` 에서 detail 을 읽는다. 호출 시점이 잡 종료 이후이고, 기록 **내용**은 이전과 같다(CAS 로 거부된 terminal 이벤트도 원래 기록되었다). 달라진 것은 시점뿐이다.
|
||||||
|
- **그 밖의 소비자**: 셸·파이썬 전수 grep 결과 없다. 상태의 권위는 계속 레지스트리 레코드(`_disk_status`)다.
|
||||||
|
|
||||||
|
### 1-3. 의도된 의미 변화 (creator 문서화)
|
||||||
|
- 발행 도중 프로세스가 죽으면 `events.log` 에는 줄이 남지만 감사 `published` 항목과 상태 동기화는 없다.
|
||||||
|
- terminal 이벤트라면 `events.log` 에 `completed` 가 있어도 레코드는 아직 비종료일 수 있다. R1 은 이를 **liveness 근거로만** 쓰고 결과는 레코드에서 읽으므로 잘못된 완료 판정으로 이어지지 않는다.
|
||||||
|
|
||||||
|
## 2. 검증 항목별 결과
|
||||||
|
|
||||||
|
| # | 항목 | 결과 |
|
||||||
|
|---|---|---|
|
||||||
|
| (1) | 서버 env denylist | ✅ 무변경(1회차 판단 유지, 호스트 마커 권고는 비차단) |
|
||||||
|
| (2) | `MAM_NO_AUTOSTART` · `ls` 번역 | ✅ 무변경(`952657e` 에서 확인한 구조화 판정·unknown 종료 유지) |
|
||||||
|
| (3) | `send_keys_safe` | ✅ 무변경 |
|
||||||
|
| (4) | `_inject_acknowledged` · `_agent_still_working` · `_recent_disk_heartbeat` · R1 창 | ✅ 벽시계 경계(`952657e`)는 그대로다. 이번 커밋으로 heartbeat 가 발행 **전에** 디스크에 기록되어 grok 이 재현한 미탐이 사라졌다(해당 테스트가 이번 실행에서 통과) |
|
||||||
|
| (5) | T1-7 트리거 | ✅ 무변경 |
|
||||||
|
| (6) | 버전 락스텝 · 릴리스 커밋 | ✅ `lib.sh:32`·`eager.py:16` = 4.4.2. `VERSIONS.md` 항목 6 이 "와이어 발행 **전** 로컬 기록(CLI·service 두 경로)"으로 코드와 일치. "신규 테스트 21개" = `test_issue9_server_env.py` 9 + `test_issue14_delivery_ack.py` 12 함수(직접 셈) |
|
||||||
|
| (7) | 테스트 · 정적 검사 | ✅ 아래 |
|
||||||
|
|
||||||
|
- **선택 실행 11파일**(부하 없이 단독): **163 passed in 431 s**
|
||||||
|
- 대상: `test_issue9_server_env`, `test_issue14_delivery_ack`, `test_issue6_fail_closed`, `test_issue6_turn_handshake`, `test_a4_adapter_contract`, `test_loop_cli`, `test_issue5_phase4_runtime`, `test_version_consistency`, `test_herdr_shim_contract`, `test_b19_headless_reconcile_fixes`, `test_deploy_registry_merge`
|
||||||
|
- grok 의 `test_wrapper_idle_timeout_grace_accepts_disk_heartbeat`, 새 순서 테스트 2건, 직전 라운드에 부하 속에서 한 번 실패했던 `test_t1_20` 모두 통과
|
||||||
|
- **publish 계약 스위트** `tests/test_issue5_phase1_delegation.py`(G-1/G-2/G-4, transport parity, `record_outcome` 직접 호출): **66 passed**(주 실행이 끝난 뒤 따로 실행)
|
||||||
|
- **정적 검사**: `py_compile`(publish_event·service), `bash -n`(lib.sh·래퍼·status.sh·reconcile.sh), `git diff --check 4d444ce...HEAD` 모두 클린. 작업 트리 깨끗, HEAD `ebe58f6`.
|
||||||
|
|
||||||
|
## 3. 비차단 권고
|
||||||
|
|
||||||
|
1. **`events.log` 의미 명시**: 이제 이 파일은 "수락된 상태"가 아니라 "시도된 이벤트"의 기록이다(발행 전 기록, CAS 거부분 포함). `MESSAGING.md` 나 `registry.append_event` docstring 에 한 줄 적어 두면, 이후 이 파일을 상태 판단에 쓰는 코드가 생기는 것을 막을 수 있다.
|
||||||
|
2. **t1_20 예산**(직전 라운드 권고, 미반영): 이번 부하 없는 실행에서는 통과했지만 `--timeout 12` 는 부하가 걸린 CI 에서 여전히 간헐 실패할 수 있다. 20–25 s 로 늘리기를 권한다.
|
||||||
|
3. **loop 모드 마감 서술**(직전 라운드 권고, 미반영): `START_TIME` 은 `cmd_submit` 에서 한 번만 정해진다(`:166-167`). 주석의 "this dispatch's deadline" 은 loop 모드에서 "이 래퍼 실행의 마감"이 정확하다.
|
||||||
|
4. 보류된 후속 항목 묶음(호스트 마커, `$WRAPPER` 경로, pane 변화 신호, ack dispatch 고정, heartbeat token 필터, `_within_deadline` 의 `Terminated` 표시 잡음)은 변동이 없다. 하나의 후속 이슈로 모으기를 권한다.
|
||||||
|
|
||||||
|
## 4. GM 참고
|
||||||
|
|
||||||
|
- **codex 는 `952657e`·`ebe58f6` 를 리뷰하지 않았다.** 마지막 codex 판정은 `d5a191c` 에 대한 NOT PASS(`75c1d06c`)였고, 그 두 지적은 `952657e` 에서 해소되었다(본 세션 확인). `e657c660` 은 codex 가 이벤트를 하나도 발행하지 않은 채 닫혔다. 병합 전에 codex 세션 상태를 확인하고 재리뷰할지 결정할 것을 권한다.
|
||||||
|
|
||||||
|
## 5. 종합
|
||||||
|
|
||||||
|
- `ebe58f6` 는 grok 의 차단 지적(디스크 heartbeat 가 브로커 지연 뒤에 기록됨)을 정확한 위치에서 최소 변경으로 고쳤다. 검증 우선·seq·G-1/G-2/G-4 계약을 유지하고, 중복 기록이 없으며, `events.log` 소비자에도 부작용이 없다.
|
||||||
|
- 브랜치 전체가 브리프의 7개 검증 항목을 충족하고, 부하 없는 선택 실행 163건과 계약 스위트 66건이 모두 통과했다. 설계 재검토는 필요 없다.
|
||||||
|
|
||||||
|
[VERDICT: PASS]
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# 리뷰 리포트 — Job 2f19fe7f (PR #46, fix/issue-42 단계 1·2)
|
||||||
|
|
||||||
|
- 리뷰어: planner-reviewer-claude-01 (claude)
|
||||||
|
- 리뷰 identity: loop-20261003T222707Z-a0de27 / base 45d1621 / head 8d26106 / tree b579380 — 검증 경로에서 `git rev-parse HEAD 'HEAD^{tree}'` 로 일치 확인, 미커밋 변경 없음
|
||||||
|
- 커밋: 7670b41(단계 1·2), e6a46cd(R1: stop exit 1 이 stop 뒤에도 남), 8d26106(R2: passthrough 코드는 표에 있어야 함)
|
||||||
|
|
||||||
|
## 결론 요약
|
||||||
|
차단 결함 없음. 지운 (a) 블록 5개는 모두 진입 스크립트가 그대로 하는 일이고, 새 옵션·종료 코드 표와 "정정" 다섯 곳은 스크립트 코드 및 샌드박스 실행 결과와 일치한다. 제품 코드 변경 없음. 비차단 관찰 4건(아래 §6).
|
||||||
|
|
||||||
|
## 1. 분류의 정확성
|
||||||
|
### resume Workflow (base resume SKILL.md:59-118) → resume_session.sh
|
||||||
|
| 지운 단계 | 스크립트 위치 |
|
||||||
|
|---|---|
|
||||||
|
| 세션 ID 해석 (`resolve_session_id.sh --session`) | resume_session.sh:111-112 |
|
||||||
|
| Class A fresh-spawn 폴백 / claude·grok exit 1 | :122-130 (spawn-spec :173-184) |
|
||||||
|
| HERDR_SESSION_NAME·MAM_WS_LABEL 해석 | :132-144 |
|
||||||
|
| herdr alive 분기 | :147-158 — **attach 하지 않고** `update_yaml_resumed.sh` 후 exit 0 |
|
||||||
|
| CMD_FULL (에이전트별) | :185-197 (adapter resume-spec + 동일 표 폴백) |
|
||||||
|
| 기동 | :237 `_herdr new-session` |
|
||||||
|
| handle_startup_dialogs | :241 |
|
||||||
|
| update_yaml_resumed.sh | :247-250 |
|
||||||
|
| attach | **스크립트에 없음** |
|
||||||
|
|
||||||
|
attach 만 스크립트에 없는 단계(= (b))인데, 문서는 이를 지우지 않고 "It never attaches; attaching is a separate step: `herdr agent attach <name>`" 로 남겼다(resume SKILL.md Workflow 아래 문단, Case 3 정정). 따라서 절차 유실 없음. 문서에만 있던 `$CLAUDE_ID_FLAG`(정의되지 않은 변수)도 함께 사라졌다(인벤토리 mismatch R4) — 정정이 맞다.
|
||||||
|
|
||||||
|
### stop pre-flight (base stop:38-63) → stop_session.sh
|
||||||
|
YAML 존재 :99(exit 1), 등록 확인 :127-143(exit 1), 이미 stopped no-op :184-188(exit 0). 문서에만 있던 `--force-no-yaml` 은 어떤 스크립트도 받지 않는다(:76-90 파서에 없음) — 삭제가 맞다.
|
||||||
|
|
||||||
|
### create pre-flight (base create:35-52) → create_session.sh
|
||||||
|
워크스페이스 :111, has_real_herdr :114, CLI in PATH :115, 인증 :117-157(선택된 에이전트만). B-3 함정(command -v / type -P) 설명은 산문으로 보존.
|
||||||
|
|
||||||
|
### create Workflow (base create:168-220) → create_session.sh
|
||||||
|
이미 존재 :166-168(**exit 3**, 문서는 1이었음), spawn-spec :207, spawn :252-276, TUI 대기 :361-398(exit 42), 롤백 trap :320-352(exit 43). `sleep 6` 하드코딩과 claude 무플래그 기동은 문서 쪽 오류였다.
|
||||||
|
|
||||||
|
### create YAML 수동 추가 (base create:226-252)
|
||||||
|
행 기록은 create_session.sh:491-610 heredoc 이 한다. lib.sh shim 주석은 Output format 아래 산문으로 이동.
|
||||||
|
|
||||||
|
### (b) 로 남은 블록 5개
|
||||||
|
create:217·resume:103·stop:127 (사후 확인), monitor:182 (에이전트 폴링 지침), status:30 (status.sh 에 has_real_herdr 검사가 없음 — status.sh:22 는 YAML 만). 각 스크립트를 grep 해 같은 명령을 제공하는 진입점이 없음을 확인했다. 3단계 입력으로 타당하다.
|
||||||
|
|
||||||
|
### 블록 수 대조
|
||||||
|
base 의 펜스 블록을 테스트의 `parse_blocks` 로 세면 37개(create 11, delegate-job 1, loop 5, monitor 7, orc-onboard 2, resume 2, status 4, stop 5). 지운 (a) 5개의 base_start/base_sha 는 모두 base 와 일치한다. 현재 33개 = 37 − 5(a) − 1 + 2(새 Workflow 호출 블록). 이 "− 1"은 관찰 O1 참고.
|
||||||
|
|
||||||
|
## 2. 옵션·종료 코드 표 대조
|
||||||
|
- create 옵션 표 ↔ 파서 create_session.sh:76-92: 12개 항목 모두 일치(별칭 `--name`, `--herdr-server` 포함; `--isolate` no-op :89-90; `--wrapper` 는 :257 에서 존재 여부와 무관하게 강제).
|
||||||
|
- create 종료: 1 = :111-156 사전 검사 + :636·:659 (spawn 뒤 브리프/주입 실패, trap 롤백); 2 = :92-110 인자, :234 바인딩 거부, :245 공백; 3 = :168; 4 = heredoc `raise SystemExit(4)` :515 (중복 running 행, set -e 로 통과 → trap 롤백); 42 = :398; 43 = :348.
|
||||||
|
- resume 옵션 표 ↔ :37-46 일치. 종료 0 = :150·:158(alive, attach 없음)·:227(dry-run)·정상 종료; 1 = :128·:204·:209 + update_yaml_resumed.sh:47; 2 = :46-56·:73·:77·:104·:221 + update_yaml_resumed.sh:42-46·:74.
|
||||||
|
- stop 종료 0 = :188; 1 = :99·:142 + heredoc :310(R7); 2 = :86·:89·:95·:98·:121; 3 = :198; 4 = :281.
|
||||||
|
- status `--json` 키: reconcile.sh:1081-1088 의 6개 최상위 키 + status.sh:213-232 의 sessions_detail 17개 키와 정확히 일치. resume_state 값 yes/MISSING/scan/no 도 status.sh 의 resume_on_disk 반환값과 일치.
|
||||||
|
|
||||||
|
샌드박스 실행(임시 HOME·WORKSPACE_ROOT·AGENT_SESSIONS_YAML, `env -i`, herdr 미호출 경로만):
|
||||||
|
| 실행 | rc | 표 |
|
||||||
|
|---|---|---|
|
||||||
|
| stop `--purge-conversation` (no --yes) | 3 | 3 ✓ |
|
||||||
|
| stop 미등록 세션 | 1 | 1 ✓ |
|
||||||
|
| stop `--mode x` | 2 | 2 ✓ |
|
||||||
|
| resume 원래 cwd 없음 `--dry-run` | 2 | 2 ✓ |
|
||||||
|
| create 없는 워크스페이스 | 1 | 1 ✓ |
|
||||||
|
| create `--agent foo` | 2 | 2 ✓ |
|
||||||
|
샌드박스 YAML 은 변경되지 않았다.
|
||||||
|
|
||||||
|
## 3. 문서만으로 같은 작업이 가능한가
|
||||||
|
- 세 진입점 모두 호출 한 줄에 필수 옵션이 표시되어 있고, 지운 절차는 전부 스크립트 안에 있으므로 호출 한 줄로 충분하다.
|
||||||
|
- 사람이 판단할 지점은 보존·강화됐다: stop 예제가 `--purge-conversation --yes` 로 바뀌면서 "되돌릴 수 없다, 사용자가 확인한 뒤에만 `--yes`" 문장이 추가됐다(실제로 --yes 없으면 exit 3 이므로 이전 예제는 동작하지 않았다).
|
||||||
|
- resume 은 spawn 뒤 실패 시 롤백하지 않는다는 점과 `multi-agent-mux-status` 로 먼저 확인하라는 복구 지침이 새로 들어갔다(resume_session.sh 에 trap 없음 — 사실과 일치). stop 표 아래 복구 문단도 같다.
|
||||||
|
|
||||||
|
## 4. 문서 가드 테스트 견고성
|
||||||
|
스크래치 사본에서 변이 시험:
|
||||||
|
| 변이 | 결과 |
|
||||||
|
|---|---|
|
||||||
|
| create `exit 43`→`exit 44` | 2 failed ✓ |
|
||||||
|
| create `exit 42` 제거(표 행이 낡음) | 2 failed ✓ |
|
||||||
|
| 주석 `# exit 9` 추가 | 통과 ✓ (주석 제외 규칙) |
|
||||||
|
| 실제 `exit 9` 추가 | 2 failed ✓ |
|
||||||
|
| stop 표의 3 행 삭제 | 2 failed ✓ |
|
||||||
|
| stop 예제의 `--yes` 제거 | 1 failed ✓ |
|
||||||
|
| base 의 create pre-flight 블록 되살림 | 1 failed ✓ |
|
||||||
|
| stop SKILL.md 블록 위에 산문 한 줄 추가(정상 편집) | 1 failed (관찰 O2) |
|
||||||
|
| `$(( 1 << 2 ))` 뒤 `exit 9` | 통과 ✗ (관찰 O3) |
|
||||||
|
|
||||||
|
추출 규칙: `(?<![\w.])exit\s+(\d+)` 은 `sys.exit(7)`(stop:139) 을 세지 않고, heredoc 본문은 `SystemExit(N)` 만 passthrough 로 모은다. `<<<` here-string 은 lookbehind 로 제외된다. 기존 가드 test_tier2_component.py::test_comp_docs_stop_examples_pass_agent(stop 예제 ≥3, 모두 --agent)는 아래 테스트 실행에서 통과한다.
|
||||||
|
|
||||||
|
## 5. 범위
|
||||||
|
`git diff --stat 45d1621 8d26106`: SKILL.md 4개 + tests/data/skill_code_blocks.json + tests/test_issue42_skill_blocks.py. 스크립트·Python·버전 파일 변경 없음(버전 bump 없음).
|
||||||
|
|
||||||
|
## 6. 비차단 관찰
|
||||||
|
- **O1 인벤토리 누락 1건**: base create SKILL.md:263-266 의 (c) 호출 블록(`create_session.sh … --session "$SESSION_NAME"`, sha ceb96a8c1293)이 지워졌지만 skill_code_blocks.json 의 blocks·removed 어디에도 없다. 내용은 새 Workflow 호출 블록이 대체하므로 정보 유실은 없지만, "37개를 분류했다"는 데이터는 실제로 36개만 다룬다. 가드는 현재 블록만 비교하므로 잡지 못한다. removed 에 class "c" 로 기록하거나 test_no_duplicate_block_left 의 `class == "a"` 를 완화하는 정리를 권한다.
|
||||||
|
- **O2 줄 번호 결합**: 인벤토리가 start/end 줄 번호를 고정하므로 블록 위에 산문 한 줄만 넣어도 실패한다. 실패 메시지가 실제 튜플을 출력해 갱신은 기계적이지만, 3단계에서 산문 편집이 잦다면 sha+순서만 비교하거나 재생성 스크립트를 두는 편이 낫다.
|
||||||
|
- **O3 산술 시프트 오인**: `_HEREDOC` 이 `$(( 1 << 2 ))` 를 heredoc 시작으로 읽어 이후 모든 줄을 건너뛴다. 현재 세 진입 스크립트에는 이 패턴이 없어(grep 확인) 실제 영향은 없다.
|
||||||
|
- **O4 표 세부 문구**: (a) create exit 42 는 "프로세스가 사라짐" 외에 `MAM_KILL_ON_READY_TIMEOUT=1` 이면 살아 있어도 42 (create_session.sh:388-398). (b) stop "이미 stopped 면 no-op" 은 `--purge-conversation` 이 아닐 때만(:185). (c) resume 문서의 "lines 50-223" 은 사전 검사의 실제 범위(:50-221)와 대략 맞다. 모두 문서 정밀도 수준이다.
|
||||||
|
|
||||||
|
## 7. 항목 6 — purge 기준 실패
|
||||||
|
이 호스트에서 검증 경로의 tests/test_issue10_13_purge.py: **23 passed**. 이 PR 은 제품 코드를 바꾸지 않으므로 Linux 의 12건(#28)과 무관하다. "알려진 기준 실패" 표현은 Linux 기준이라는 한정이 필요하다.
|
||||||
|
|
||||||
|
## 8. 테스트
|
||||||
|
지정 6개 파일(test_issue42_skill_blocks, test_tier2_component, test_orc_onboard, test_loop_cli, test_o1_rebuttal, test_version_consistency): **129 passed / 0 failed / 1 skipped** (296 s, rc 0). skip 은 test_claude_skills_link_resolves_to_agents_skills(워크트리에 .claude/skills 없음 — 브리프상 정상). test_issue42_skill_blocks.py 단독: 28 passed / 1 skipped.
|
||||||
|
|
||||||
|
외부 브로커 미사용(MQTT_* unset, .mam.env 미export). 실제 .mam/jobs 항목 수 변화: +0 (이 잡 디렉터리 외 생성 없음).
|
||||||
|
|
||||||
|
[VERDICT: PASS]
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"loop_id": "loop-20261001T230324Z-b511bf",
|
||||||
|
"base_sha": "0051fd6c3b088f8b265928ccd94ea31053fdde7e",
|
||||||
|
"head_sha": "75f364e4b47d8db95ce2a7e041b69b8e8b473e64",
|
||||||
|
"tree_sha": "ec1fdc453ec6fdf2c09165c92a64d54e684f5a3a",
|
||||||
|
"review_commit": "75f364e4b47d8db95ce2a7e041b69b8e8b473e64",
|
||||||
|
"reviewer": "planner-reviewer-claude-01",
|
||||||
|
"job_id": "31a04751",
|
||||||
|
"verdict": "PASS",
|
||||||
|
"transport": "herdr",
|
||||||
|
"evidence": "review-tree",
|
||||||
|
"isolation": "worktree",
|
||||||
|
"verified_at": "2026-10-02T00:58:55Z"
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# 🔍 Review Report — 후속 `fix/followups-41-43` 2회차 (job `31a04751`)
|
||||||
|
|
||||||
|
- **Reviewer**: `planner-reviewer-claude-01` (role: planner,reviewer) · 2026-10-02
|
||||||
|
- **대상**: `0051fd6..75f364e`(9커밋). 1회차(`95eabc6`, 내 판정 PASS — job `76ca3b1b`) 이후 델타는 `75f364e` 1커밋(3파일 `+41/−2`, `git diff --check` 클린).
|
||||||
|
- **검증 경로**: `/var/folders/.../T/mam-review/loop-20261001T230324Z-b511bf/2`. **identity 확인**: `HEAD = 75f364e4…3e64`, `HEAD^{tree} = ec1fdc45…5a3a` — 브리프와 일치.
|
||||||
|
- **안전 규칙 준수**: 검증 경로·소스 워크트리·본 체크아웃(`main 0051fd6`) 수정·커밋·checkout 없음. 되돌린 사본은 scratchpad 에서만. 실제 `.mam.env` 를 읽거나 출력·export 하지 않음. 실제 `.mam`·데몬·세션·herdr 조작 없음, push·merge·PR 상태 변경 없음.
|
||||||
|
|
||||||
|
## 결론 요약
|
||||||
|
|
||||||
|
**델타는 codex R1(실패한 env 로드가 "로드됨" 으로 캐시됨)을 정확히 닫고, 1회차 판정(F1–F6 완료, PASS)을 뒤집을 요소는 없다.** 1회차 내 리포트의 F1–F6 판단은 이 트리에도 그대로 유효하다 — 델타가 F2 의 `mqtt_common.py` 두 줄, MESSAGING.md 한 줄, 테스트 1건뿐이기 때문이다.
|
||||||
|
|
||||||
|
## 1. 75f364e — 실패한 로드를 캐시하지 않음 (`mqtt_common.py:128`)
|
||||||
|
|
||||||
|
- **결함(codex R1)**: F2 에서 `load_env_or_report()` 가 프로세스를 끝내지 않고 False 를 돌려주게 되자, `ensure_env_loaded()` 가 `_load_dotenv()` **이전에** `_env_loaded = True` 를 세우던 순서가 문제가 됐다. 한 프로세스 안에서 두 번째 `main()` 은 "이미 로드됨" 으로 보고 **설정 없이** 진행했다.
|
||||||
|
- **수정**: 플래그를 `_load_dotenv()` **뒤로** 옮겨, 성공한 로드만 캐시한다. `EnvFileError` 가 나면 플래그는 거짓으로 남아 다음 호출이 파일을 다시 읽는다.
|
||||||
|
- **수정 없이는 실패하는가 — 확인함**: 사본에서 `mqtt_common.py` 만 `95eabc6` 판으로 되돌리고 새 `test_a1_a_failed_load_is_not_cached_as_loaded` 를 돌리면 실패한다. 실패 내용이 결함 그대로다: `rc: [1, 0]`(두 번째 `registry.main` 이 rc 0), `registered: ['<job>.json']`(**설정 없이 잡을 등록**), `recovered: [True, None]`(고친 파일도 다시 읽지 않음). 새 코드에서는 `rc [1, 1]`·등록 0건·두 번 모두 한 줄 `ERROR`·고친 파일 읽힘·성공 뒤 캐시 유지(바꾼 파일을 다시 읽지 않음)를 모두 단언한다.
|
||||||
|
- **부분 적용 여부**: `envfile.parse` 는 파일 전체를 먼저 읽고 디코딩한 뒤에만 값을 넣으므로, 실패한 로드가 환경을 절반만 바꾸는 일은 없다. `.mam.env` 와 옛 `.env` 가 함께 있으면 `_load_dotenv` 는 `.mam.env` 하나만 읽는다. 따라서 재시도 때 두 파일이 섞이는 경로도 없다.
|
||||||
|
- **MESSAGING.md:369**: 문장 끝을 실제 동작(파일 없음 → 조용히 건너뜀, 있는데 못 읽음 → 진입점이 `ERROR` 한 줄 + rc 1)에 맞춘 문구 정리.
|
||||||
|
|
||||||
|
## 2. 테스트
|
||||||
|
|
||||||
|
```
|
||||||
|
tests/test_issue26_envfile.py tests/test_issue26_ints.py tests/test_issue21_acceptance.py tests/test_issue30_stall_watch.py
|
||||||
|
tests/test_issue30_stall_watch_stop.py tests/test_tier1_unit.py tests/test_version_consistency.py
|
||||||
|
tests/test_issue5_phase1_delegation.py tests/test_issue19_subscriber_daemon_lifecycle.py
|
||||||
|
(.mam.env 미export, MQTT_* 미설정, followups .venv)
|
||||||
|
→ 352 passed, 1 skipped in 488.95s failed 0 (skip = S6, /proc)
|
||||||
|
```
|
||||||
|
|
||||||
|
- 브리프 목록 7파일에 두 파일을 더했다. `test_issue5_phase1_delegation.py` 는 CLI 스냅샷이고, `test_issue19_subscriber_daemon_lifecycle.py` 는 F4b 로 바뀐 `reconcile.sh` 의 `RECONCILE_POLL_INTERVAL` 을 거치는 구독자 데몬 수명 시험이다.
|
||||||
|
- 로그에 외부 브로커 흔적 0건.
|
||||||
|
- 실제 `.mam/jobs` 는 이 잡 자신의 파일 외 **추가 0**. 검증 경로 tracked 파일 변화 없음(HEAD `75f364e`), 본 체크아웃 `main 0051fd6`.
|
||||||
|
|
||||||
|
## 3. 판정
|
||||||
|
|
||||||
|
1회차 비차단 관찰(T1 `test_o2` 의 미사용 `get_lstart`, T2 reconcile 내장 구독자의 트레이스백, T3 설치 확인 목록)은 범위 밖으로 그대로 열려 있다. 버전 bump 는 판정에서 제외한다. 병합을 막을 결함은 없다.
|
||||||
|
|
||||||
|
[VERDICT: PASS]
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# 리뷰 리포트 — Job 3a93c30b (PR #48 2회차, 35935f9..e8d9151)
|
||||||
|
|
||||||
|
- 리뷰어: planner-reviewer-claude-01 (claude)
|
||||||
|
- 리뷰 identity: loop-20261005T081844Z-e92124 / base c5c7eff / head e8d9151 / tree 8b39188 — 검증 경로에서 `git rev-parse HEAD 'HEAD^{tree}'` 일치, 미커밋 변경 없음
|
||||||
|
- 범위: 커밋 36f0b8d, e8d9151. `git diff --stat 35935f9 e8d9151`: create SKILL.md 1줄, VERSIONS.md, 테스트 3개. lib.sh·lib_py·스크립트 변경 없음(제품 코드 불변).
|
||||||
|
|
||||||
|
## 결론 요약
|
||||||
|
1회차의 차단 3건(B1~B3)과 문서 지적(D1)이 모두 닫혔다. 이 macOS 호스트(Python 3.9 venv)에서 지정 6개 파일이 실패 없이 통과하고, 실제 herdr 서버 테스트 5개가 skip 없이 돈다. 차단 결함 없음.
|
||||||
|
|
||||||
|
## 1. 1회차 지적 종결
|
||||||
|
| 지적 (1회차) | 수정 | 확인 |
|
||||||
|
|---|---|---|
|
||||||
|
| B1 test_issue45_herdr_config.py:16 최상단 `import tomllib` → 3.9 에서 수집 중단 | try/except 로 선택적 import(:18-21), 결과를 파싱하거나 설정 유지를 기대하는 테스트만 `needs_tomllib` skip(:36) | 3.9: 수집 정상, tomllib 의존 검사만 skip. Python 3.11(Homebrew + PYTHONPATH): **51 passed**. 3.9 에서 남는 22건 중 test_without_tomllib_restore_is_still_off(:91-96)가 tomllib 없을 때 MINIMAL + "no tomllib" 경고를 직접 확인하므로 3.9 경로도 검사된다 |
|
||||||
|
| B2 test_issue45_restore.py `/proc` 와 `/tmp` | 루트 `.resolve()`(:175), 서버 환경은 `/proc` 가 있으면 거기서, 없으면 `ps -E -ww`(:160-169, :239-240) | test_issue45_restore.py 5개 모두 **실행·통과**(skip 아님). 1회차에 내가 스크래치 사본으로 확인한 것과 같은 두 수정이며, 그때 A·B 수정을 끄면 각각 실패함(A: `--resume <id>`, B: pane 2개)을 확인했다. 제품 코드가 그 뒤 바뀌지 않았으므로 대조 결과는 그대로 유효하다 |
|
||||||
|
| B3 test_issue9_server_env.py:157 tomllib, bootstrap 의 python3 가정 | bootstrap 과 같은 PATH 의 `python3` 로 결과를 파싱, tomllib 이 없으면 키 하나짜리 파일 + `confirm_close` 없음 + 경고 "no tomllib" 를 기대(:169-183) | 통과. 이 호스트에서는 else 분기(3.9)가 실행된다. `subprocess.run(["python3"…], env=env)` 는 POSIX 에서 env 의 PATH 로 실행 파일을 찾으므로 bootstrap 과 같은 인터프리터다. 경고 문구는 herdr_config.py:147 와 일치 |
|
||||||
|
| D1 (비차단) python3 < 3.11 이면 사용자 설정 없이 뜬다는 문서 부재 | create SKILL.md:120 와 VERSIONS 4.8.5 항목 1, 품질 검증 항목에 추가 | 내용은 코드(lib.sh:241 의 bare `python3`, herdr_config.py:146-147)와 일치 |
|
||||||
|
|
||||||
|
## 2. 테스트 실행
|
||||||
|
| 실행 | 결과 |
|
||||||
|
|---|---|
|
||||||
|
| 지정 6개 파일 (pr-48 .venv, Python 3.9.6) | **152 passed / 30 skipped / 0 failed** (216 s). skip = tomllib 필요 29건 + .claude/skills 1건(워크트리 정상). test_issue45_restore 는 skip 0 |
|
||||||
|
| tests/test_issue45_herdr_config.py (python3.11) | **51 passed** |
|
||||||
|
- 격리: 실행 전후 herdr 프로세스는 기존 `multi-agent-mux` 서버·attach 클라이언트와 오케스트레이터의 `agent wait` 뿐(잔류 서버 없음), `/private/tmp/m45*` 없음, `~/.config/herdr` 목록·config.toml md5·sessions 목록 불변. MQTT_* unset, .mam.env 미export. 실제 .mam/jobs 에 새 항목 없음.
|
||||||
|
|
||||||
|
## 3. 비차단 관찰
|
||||||
|
- **N1 VERSIONS.md 4.8.5 항목 1 문장 흐름**: 새 문장이 "…경고가 남는다:" 로 끝나고 바로 뒤에 원래 있던 "출력이 원본 자신(별칭 포함)이거나 … 원본별 이름을 쓴다" 가 이어져, 출력 이름 규칙이 Python 버전 설명의 결과처럼 읽힌다. 원래는 "사용자 파일은 고치지 않는다:" 뒤에 붙던 설명이다. 새 문장을 항목 끝으로 옮기면 된다.
|
||||||
|
- **N2 `_process_env` 의 macOS 분기**(test_issue45_restore.py:166-169)는 `ps -E` 출력을 공백으로 나누므로 공백이 든 경로는 맞추지 못한다. 테스트 루트가 `/private/tmp/m45…` 로 고정이라 지금은 영향 없다.
|
||||||
|
- **N3 tomli 백포트 대신 문서화한 결정**에 대한 의견: 복원은 어느 경우에도 꺼지므로(#45 의 핵심) 안전 측면에서 문제없고, 이 호스트의 herdr 설정이 키 하나뿐이라는 사정에서 합리적이다. 나중에 사용자 설정이 커지면, 새 의존성 없이 PATH 에서 3.11+ 인터프리터를 찾는 방법도 고려할 만하다.
|
||||||
|
|
||||||
|
[VERDICT: PASS]
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# 리뷰 리포트 — Job 40f4077c (PR #47, fix/issue-42-skills-link)
|
||||||
|
|
||||||
|
- 리뷰어: planner-reviewer-claude-01 (claude)
|
||||||
|
- 리뷰 identity: loop-20261004T043243Z-baf133 / base 6af4a79 / head e1ed270 / tree 2f781e0 — 검증 경로에서 `git rev-parse HEAD 'HEAD^{tree}'` 일치, 미커밋 변경 없음
|
||||||
|
|
||||||
|
## 결론 요약
|
||||||
|
수정은 두 배치(단일 링크 / 스킬별 링크 디렉터리)를 모두 올바르게 받고, 잘못된 배치는 실패시킨다. 차단 결함 없음. 비차단 관찰 2건.
|
||||||
|
|
||||||
|
## 1. 범위 (항목 3)
|
||||||
|
`git diff --stat 6af4a79 e1ed270`: tests/test_issue42_skill_blocks.py 한 파일, +10/−1 (test_claude_skills_link_resolves_to_agents_skills, :240-253). 제품 코드·버전 변경 없음.
|
||||||
|
|
||||||
|
## 2. 배치별 동작 (항목 1)
|
||||||
|
검증 경로 안에 임시 .claude/skills(.gitignore:40 의 `.claude/` 로 무시됨)를 만들어 이 테스트만 돌리고 매번 지웠다. 시작 전 .claude 가 없음을 확인했고, 끝난 뒤 `.claude` 잔여물 없음·`git status --short --ignored` 비어 있음·HEAD 불변을 확인했다. 본 체크아웃의 .claude/skills 는 `ls -la` 로 읽기만 했다(스킬 8개 링크 + lib_py·lib.sh 링크 = 10개 항목, 모두 ../../.agents/skills/<이름>).
|
||||||
|
|
||||||
|
| 배치 | 결과 | 판단 |
|
||||||
|
|---|---|---|
|
||||||
|
| A. 없음 | skip | 의도대로 |
|
||||||
|
| B. 단일 링크 → .agents/skills | pass | 의도대로 |
|
||||||
|
| C. 스킬별 링크 8개 + lib.sh·lib_py 링크(본 체크아웃과 같은 배치) | pass | 의도대로 |
|
||||||
|
| C-old. 같은 배치에서 base(6af4a79) 의 테스트 | **fail** | 수정 대상 결함 재현 |
|
||||||
|
| C2. C + 여분 디렉터리(foreign-skill) | pass | 여분 허용 |
|
||||||
|
| D. stop 링크가 loop 를 가리킴 | fail (`.claude/skills/multi-agent-mux-stop -> …/multi-agent-mux-loop`) | 의도대로 |
|
||||||
|
| E. 스킬 링크 하나 없음 | fail (`… is missing`) | 의도대로 |
|
||||||
|
| F. 스킬 링크 하나가 깨짐 | fail (`… is missing`) | 의도대로 |
|
||||||
|
| G. 링크 대신 복사본 디렉터리 | fail (resolve 가 .claude 쪽) | 의도대로 — 복사본은 드리프트하므로 막는 것이 맞다 |
|
||||||
|
| H. .claude/skills 가 일반 파일 | fail (`… is missing`) | 의도대로 |
|
||||||
|
| J. 단일 링크가 다른 곳(스킬 하나)을 가리킴 | fail | 의도대로 |
|
||||||
|
| K. 빈 디렉터리 | fail | 의도대로 |
|
||||||
|
| L. 스킬별 링크가 중간 링크를 거쳐 해석됨 | pass | resolve() 가 최종 대상을 비교하므로 맞다 |
|
||||||
|
| I. **단일 링크가 깨짐** (`.claude/skills -> ../nowhere`) | **skip** | 관찰 N1 |
|
||||||
|
|
||||||
|
- SKILL_NAMES 가 비면 스킬별 루프가 공집합이라 통과하겠지만, 같은 파일의 test_inventory_covers_eight_skills 가 `len(SKILL_NAMES) == 8` 을 단언하므로(파일 상단 SKILL_NAMES 정의와 함께) 놓치지 않는다.
|
||||||
|
|
||||||
|
## 3. 엄격성 (항목 2)
|
||||||
|
- 여분 항목(lib_py, lib.sh, 다른 스킬 디렉터리)은 허용된다(C, C2). Claude Code 가 스킬로 읽는 것은 SKILL.md 를 가진 하위 디렉터리이므로 여덟 MAM 스킬이 .agents/skills 의 것인지만 보는 현재 기준이 적절하다. 다른 스킬을 같이 두는 정상 사용을 막지 않는다.
|
||||||
|
- lib.sh·lib_py 링크는 검사하지 않는데, 진입 스크립트는 `$SCRIPT_DIR/../..` 에서 lib.sh 를 찾고 없으면 `${WORKSPACE_ROOT:-$PWD}/.agents/skills/lib.sh` 로 폴백하므로(예: reconcile.sh:20-21) 테스트의 필수 조건으로 둘 이유는 없다. 문서의 호출도 모두 `bash .agents/skills/...` 경로를 쓴다.
|
||||||
|
|
||||||
|
## 4. 비차단 관찰
|
||||||
|
- **N1 깨진 단일 링크가 skip 으로 숨는다** (tests/test_issue42_skill_blocks.py:245-246): `link.exists()` 는 링크를 따라가므로 대상이 없는 단일 링크는 "없음"으로 보고 skip 한다. 이 경우 Claude Code 는 스킬을 하나도 못 읽는데 테스트는 통과(skip)한다. base 테스트도 같은 동작이었으므로 이 PR 의 회귀는 아니다. `if not link.exists() and not link.is_symlink(): skip` 으로 바꾸면 깨진 링크는 아래 resolve 비교에서 실패한다.
|
||||||
|
- **N2 실패 메시지**: G(복사본)의 메시지가 `-> <.claude 안 경로>` 로 나와 "링크가 아니라 복사본"이라는 원인이 바로 드러나지 않는다. 사소함.
|
||||||
|
|
||||||
|
## 5. 테스트
|
||||||
|
검증 경로에서 `tests/test_issue42_skill_blocks.py` 전체: **31 passed / 1 skipped** (skip = 이 테스트, 워크트리에 .claude 없음 — 정상). 배치 탐침은 §2.
|
||||||
|
|
||||||
|
[VERDICT: PASS]
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# 리뷰 리포트 — Job 416d9768 (PR #48, fix/issue-45)
|
||||||
|
|
||||||
|
- 리뷰어: planner-reviewer-claude-01 (claude)
|
||||||
|
- 리뷰 identity: loop-20261005T064728Z-a4e9c8 / base c5c7eff / head 35935f9 / tree dff9db1 — 검증 경로에서 `git rev-parse HEAD 'HEAD^{tree}'` 일치, 미커밋 변경 없음
|
||||||
|
|
||||||
|
## 결론 요약
|
||||||
|
제품 동작(A: MAM 서버 복원 꺼짐, B: 자기 세션의 빈 셸만 닫기)은 이 macOS 호스트의 실제 herdr(0.9.1)에서 동작함을 확인했다. 그러나 **브리프가 지정한 검증 명령이 이 호스트(리포지토리 .venv = Python 3.9.6)에서 수집 단계에서 중단**되고, 그 파일을 빼고 돌려도 **새 테스트 5개가 macOS 에서 실패**한다. 원인은 모두 테스트의 Linux·Python 3.11 가정이며 수정은 작다. 스킬 frontmatter 가 `platforms: [linux, macos]` 이므로 차단으로 판단한다.
|
||||||
|
|
||||||
|
## 차단 (테스트 이식성)
|
||||||
|
### B1. tests/test_issue45_herdr_config.py:16 — 모듈 최상단 `import tomllib`
|
||||||
|
- Python 3.9 에서 `ModuleNotFoundError` → pytest 가 "Interrupted: 1 error during collection" 으로 **세션 전체를 중단**한다. 브리프 지정 명령(7개 파일)은 결과 0건, rc 2. 오케스트레이터의 전체 스위트도 같은 .venv 면 같은 이유로 중단된다.
|
||||||
|
- 제품 쪽(lib_py/herdr_config.py:54-57)은 tomllib 부재를 처리하는데 테스트만 강제한다. `tomllib = pytest.importorskip("tomllib")` 처럼 모듈 skip 또는 tomllib 이 필요 없는 경우만 남기는 처리가 필요하다.
|
||||||
|
- 참고: 같은 파일을 Homebrew python3.11 + 기존 venv 의 pytest(PYTHONPATH)로 돌리면 **51 passed**. 내용 자체는 정상이다.
|
||||||
|
|
||||||
|
### B2. tests/test_issue45_restore.py:227, :163 — Linux 전용 가정 (macOS 4 failed)
|
||||||
|
| 테스트 | 실패 | 원인 |
|
||||||
|
|---|---|---|
|
||||||
|
| test_a_…[plain-user-config], test_a_…[user-config-with-session-table] | FileNotFoundError `/proc/<pid>/environ` | :227 `/proc` 는 macOS 에 없다 |
|
||||||
|
| test_b_only_the_resumed_sessions_restored_shell_is_closed | `'/private/tmp/m45…' == '/tmp/m45…'` (:263) | :163 `mkdtemp(dir="/tmp")` — macOS 에서 /tmp 는 /private/tmp 링크이고 herdr 는 실경로를 보고한다 |
|
||||||
|
| test_b_resume_leaves_no_restored_shell_behind | StopIteration (:325, cwd 비교) | 같은 원인 |
|
||||||
|
- 검증: 스크래치 사본에서 **이 두 군데만** 고친 테스트(`.resolve()` 추가, `/proc` 대신 `ps -E -ww -o command= -p <pid>`)는 **5 passed**(65 s). 즉 제품은 맞고 테스트만 이식되지 않았다. 양성 대조도 이 사본에서 동작한다.
|
||||||
|
|
||||||
|
### B3. tests/test_issue9_server_env.py:157 — 테스트 안 `import tomllib`, 그리고 bootstrap 의 python3 가정
|
||||||
|
- 3.9 에서 import 로 실패. 또 3.11 로 테스트를 돌려도 bootstrap 은 `python3`(lib.sh:241, 이 호스트는 /usr/bin/python3 3.9.6)로 herdr_config 를 실행하므로 결과는 MINIMAL 이고, :170 의 `cfg == {"ui": …, "session": …}` 단언은 실패한다. `python3` 에 tomllib 이 없으면 skip(또는 MINIMAL 을 기대)해야 한다.
|
||||||
|
|
||||||
|
## 검토 항목별 판단
|
||||||
|
### 1. A 의 설정 병합 (lib_py/herdr_config.py)
|
||||||
|
- 원본 보호: 출력은 항상 output_path(:192-205)를 거친다 — 원본 자신(samefile/realpath, 별칭 포함)이나 다른 원본용·비생성 파일이면 해시 이름으로, 그것도 막히면 OSError → 쓰지 않음. 폴백(.mam)도 같은 함수(:273, R3 2회차 수정). 쓰기는 같은 디렉터리 임시 파일 + os.replace(:208-222), 원본 권한 복사(:255).
|
||||||
|
- 재귀: HERDR_CONFIG_PATH 가 생성 파일이면 2행의 `# source:` 로 원본을 따라간다(:65-92). source 행이 없으면 XDG/HOME 으로.
|
||||||
|
- 병합 정확성: tomllib parse → `session` 에 키만 덮어씀 → dumps → 재파싱이 기대값과 **정확히 같을 때만** 사용(:156-164). 인라인 표·점 표기·따옴표 키·배열은 파싱 결과(dict)로 다루므로 표기와 무관. 주석·표기 형식은 생성 파일에서 사라지지만 원본은 그대로이고 의미는 같다. 실패 시 MINIMAL + 경고.
|
||||||
|
- herdr 검사: 쓴 뒤 `herdr config check`(:169-180)가 "parse error"/"using defaults" 를 내면 MINIMAL 로 재시도, 그것도 거부되면 None → bootstrap 이 경고하고 예전처럼(복원 켜짐) 기동(lib.sh:241-245). 단위 테스트가 실제 herdr 로 이를 확인(3.11 에서 51 passed).
|
||||||
|
- 사용자 디렉터리 흔적: `~/.config/herdr/mam-server-config.toml`(또는 해시 이름) 1개, 중단 시 `.mam-server-config.*.tmp` 가능. create SKILL.md:120-121 에 위치가 적혀 있다.
|
||||||
|
- 비차단 D1 (아래): python3 < 3.11 호스트에서는 병합이 일어나지 않는다.
|
||||||
|
|
||||||
|
### 2. 적용 경로
|
||||||
|
- MAM 이 서버를 띄우는 곳은 shim 의 headless bootstrap 한 곳(lib.sh:261 `"$REAL_HERDR" --session "$_MAM_SESSION" server`)뿐이고 A 가 여기에 걸려 있다. create·resume·delegate 는 모두 shim 을 거친다. 다른 `server` 기동은 grep 으로 찾지 못했다. MAM_NO_AUTOSTART 호출자(status/reconcile)는 서버를 띄우지 않는다(lib.sh:196-201).
|
||||||
|
- 걸리지 않는 경로: 사용자가 `herdr session attach`/`herdr --session X` 로 직접 띄운 서버, 기본 `default` 서버, 이미 떠 있는 서버. create SKILL.md:121 이 "rebuilt each time MAM starts the server … next started", "A server you start yourself (`herdr session attach`) does not get it" 로 적고 있다(합의된 한계와 일치).
|
||||||
|
|
||||||
|
### 3. B 의 닫기 조건 (lib_py/herdr_restore.py)
|
||||||
|
- 조건(:54-65): 세션 id 비어 있지 않음, 서버가 default 아님, `agent` 없음, `agent_session.value == id`, 전경 = shell_pid(shell_pid 없으면 False). 사용자 pane(agent_session 없음)·다른 세션·전경 프로세스 있음·에이전트 살아 있음은 제외 — 실제 서버 테스트(사본)에서 사용자 셸·Z(실행 중)·X2(sleep 60)가 남고 X·Y 만 닫힘을 확인.
|
||||||
|
- fail-safe: herdr 오류·JSON 오류는 예외 → exit 1(:79-83) → resume 은 경고만(resume_session.sh:240-242) 하고 진행. default 서버는 herdr 를 부르지도 않는다(test_b_default_server_is_never_touched 통과). Class A fresh-spawn(UUID 없음)은 즉시 반환.
|
||||||
|
|
||||||
|
### 4. 테스트의 격리·결정성
|
||||||
|
- test_issue45_restore 는 HOME 을 `/tmp/m45*` 로, 세션을 `mamt45-<pid>-<4hex>` 로 두고 HERDR_*·XDG_CONFIG_HOME·HERDR_CONFIG_PATH 를 지운다(:42-44, :161-168). 정리는 그 세션 이름·그 루트 경로로만 pkill(:186-195). conftest 도 HERDR_CONFIG_PATH·XDG_CONFIG_HOME 을 지운다.
|
||||||
|
- 실행 전후 확인(실패 실행 3회, 사본 실행 3회 모두): herdr 프로세스는 기존 `multi-agent-mux` 서버·attach 클라이언트뿐(잔류 없음), `/tmp/m45*` 없음, `~/.config/herdr` 목록·config.toml md5·sessions 목록 불변.
|
||||||
|
- 양성 대조: 사본에서 lib.sh 의 `HERDR_CONFIG_PATH` 주입을 끄면 test_a 가 `--resume <id>` 로 실패(#45 재현), resume 의 close 호출을 끄면 test_b_resume 가 pane 2개로 실패. 테스트가 수정에 실제로 의존한다.
|
||||||
|
- 결정성: 폴링 + 고정 대기(최대 수 초)라 느린 호스트에서 다소 민감할 수 있으나 3회 반복 모두 같은 결과였다.
|
||||||
|
|
||||||
|
### 5. 문서·인벤토리
|
||||||
|
- 버전: lib.sh·eager.py·SKILL.md 8개·VERSIONS 가 4.8.5 로 일치(test_version_consistency 통과).
|
||||||
|
- skill_code_blocks.json: blocks 의 (skill, class, kind) 순서 동일, create 블록 4개의 start/end 이동과 resume removed 항목의 evidence 줄 번호(+8) 이동뿐. 분류 변경 없음.
|
||||||
|
- **D1 (비차단, 문서)**: VERSIONS 4.8.5 항목 1과 create SKILL.md:120 은 서버 설정을 "사용자 설정 + 키"로 설명하지만, bootstrap 은 `python3`(lib.sh:241)로 실행되므로 python3 < 3.11 인 호스트(이 macOS 의 /usr/bin/python3 3.9.6)에서는 **항상 키 하나만 있는 파일**로 기동하고 사용자 herdr 설정(예: default_shell·키 바인딩)이 MAM 서버에 적용되지 않으며, 기동마다 경고가 나온다. 복원은 꺼지므로 안전하지만, 이 한계를 문서에 적거나 3.11+ 인터프리터를 찾는 편이 좋다. VERSIONS 의 "실패 시 키 하나만" 목록에도 Python 3.11 미만이 빠져 있다.
|
||||||
|
|
||||||
|
### 6. purge 기준 실패
|
||||||
|
검증 경로에서 tests/test_issue10_13_purge.py **23 passed**(macOS). 이 PR 과 무관하며 "12 실패"는 Linux 기준이다.
|
||||||
|
|
||||||
|
### 7. 경계(#17)
|
||||||
|
새 판정(병합·검사·출력 경로, 닫기 조건)은 모두 Python(herdr_config.py, herdr_restore.py)에 있다. bash 쪽 추가는 호출과 빈 값 경고(lib.sh:241-245, resume_session.sh:240-242)뿐이다.
|
||||||
|
|
||||||
|
## 테스트 실행 요약
|
||||||
|
| 실행 | 결과 |
|
||||||
|
|---|---|
|
||||||
|
| 브리프 지정 7개 파일 (pr-48 .venv, Python 3.9.6) | **수집 오류로 중단, 0건 실행, rc 2** (B1) |
|
||||||
|
| herdr_config 제외 6개 파일 | **5 failed** / 192 passed / 1 skipped (B2 4건, B3 1건; skip = .claude/skills, 워크트리 정상) |
|
||||||
|
| test_issue45_herdr_config.py (python3.11) | 51 passed |
|
||||||
|
| test_issue45_restore.py 이식성 2곳만 고친 사본 | 5 passed; A·B 수정을 끄면 각각 실패 |
|
||||||
|
| test_issue10_13_purge.py | 23 passed |
|
||||||
|
MQTT_* unset, .mam.env 미export. 실제 .mam/jobs 에 새 항목 없음.
|
||||||
|
|
||||||
|
## 수정 제안 (최소)
|
||||||
|
1. test_issue45_herdr_config.py:16 → `tomllib = pytest.importorskip("tomllib")`.
|
||||||
|
2. test_issue45_restore.py:163 → `Path(tempfile.mkdtemp(...)).resolve()`; :227 → `/proc` 가 없으면 `ps -E -ww -o command= -p <pid>` 로 확인(또는 그 단언만 Linux 한정).
|
||||||
|
3. test_issue9_server_env.py:157 → bootstrap 이 쓸 `python3` 에 tomllib 이 없으면 skip 하거나 MINIMAL 을 기대.
|
||||||
|
4. (비차단) D1 문서화.
|
||||||
|
|
||||||
|
[VERDICT: NOT PASS]
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# 리뷰 리포트 — Job 4566e690 (Issue #17 후속, fix/issue17-followups 56d0bfd..8241bd5)
|
||||||
|
|
||||||
|
- 리뷰어: planner-reviewer-claude-01 (claude)
|
||||||
|
- 리뷰 identity: loop-20261007T224638Z-cf1efb / base 56d0bfd / head 8241bd5 / tree efef640 — 검증 경로에서 `git rev-parse HEAD 'HEAD^{tree}'` 일치, 미커밋 변경 없음
|
||||||
|
- 커밋 6개: 25c9970·f19f940(항목 1), a670ef5(항목 2), d6cb019(항목 3), c90a0af(항목 4), 8241bd5(테스트 환경 고정). 버전·VERSIONS.md 변경 없음(브리프대로).
|
||||||
|
|
||||||
|
## 결론 요약
|
||||||
|
항목 1~4 가 요구대로 반영됐고, 정상 경로의 종료 코드·출력·레지스트리·이벤트는 변하지 않았다. 추가 테스트 13건은 수정 전 코드에서 실제로 실패한다(255·127·2 등 예측된 코드로). 항목 1 에는 브리프 문구("테스트 전제 수정")를 넘는 **제품 코드 변경**(herdr_restore.py 의 placeholder 닫기)이 포함되는데, 실제 herdr 0.9.3 에서 그 변경 없이는 테스트도 resume 의 닫기도 성립하지 않음을 확인했으므로 정당한 범위 확장으로 판단한다(§1). 차단 결함 없음. 비차단 관찰 3건(§6).
|
||||||
|
|
||||||
|
## 1. 항목 1 — herdr 0.9.3 과 test_b
|
||||||
|
- 브리프의 전제 "닫기 동작은 cwd 를 보지 않는다 → :275 만 두 경로 허용" 은 불완전했다. herdr 0.9.3 은 디렉터리가 사라진 pane 을 **프로세스 없는 placeholder**(`restore_error` 설정)로 복원하고 `pane process-info` 는 `pane_not_found` 로 rc 1 을 낸다. 실제 서버 대조: 스크래치 사본에 **이전 herdr_restore.py** 만 넣고 test_b 를 돌리면 `close-restored-shells: Command […'pane','process-info','--pane','w2:p1'] returned non-zero exit status 1` 로 실패(1 failed, 11.8 s) — 즉 옛 코드는 Y 의 placeholder 에서 예외를 내 resume 의 닫기 단계 전체가 rc 1 로 끝나고(래퍼는 경고만), 아무 pane 도 닫지 못한다.
|
||||||
|
- 수정(herdr_restore.py:58-66 `_nothing_running`): `_shell_in_foreground` 가 CalledProcessError 를 내면 **pane 에 `restore_error` 가 있고 출력에 `pane_not_found` 가 있을 때만** "실행 중인 것 없음"으로 보고, 그 외 실패는 그대로 올린다. 두 조건이 모두 herdr 자신이 붙인 표지라 사용자 pane·다른 세션 pane 이 이 경로로 닫힐 수 없다(agent_session 일치 조건 :83 은 그 앞에서 여전히 적용). 단위 테스트 2건이 "restore_error 없음 + pane_not_found", "restore_error 있음 + 다른 오류" 를 모두 예외 유지로 고정한다. fail-safe 방향(닫는 쪽이 아니라 두는 쪽) 유지.
|
||||||
|
- `_herdr` 는 `subprocess.run(..., capture_output=True, check=True)` 이므로 CalledProcessError.stderr/.output 이 채워진다(:46-49) — 판정에 쓰는 문자열이 실제로 들어온다.
|
||||||
|
- test_issue45_restore.py:267·:275-277 주석·단언이 0.9.1($HOME)/0.9.3(원래 경로) 둘 다 허용. 다른 문서의 "$HOME 으로 복원" 주장: SKILL.md 들에는 없음(create:120, resume:63 은 "empty shells" 만 말함), VERSIONS.md 과거 항목은 브리프대로 제외.
|
||||||
|
- 실행: 이 Mac(herdr 0.9.3)에서 tests/test_issue45_restore.py **5개 모두 통과, skip 0**.
|
||||||
|
|
||||||
|
## 2. 항목 2 — N2 fail-closed
|
||||||
|
- 래퍼(:103-127): `_decision_failed` 는 stderr 에 사유를 적고 `exit 1`; `_decided <var> <sub> …` 는 rc≠0 또는 빈 출력이면 실패; `_exit_for` 는 답이 `^[0-9]+$` 가 아니면 실패. 다섯 `exit "$(_decide exit-code …)"` 가 `_exit_for` 로, 네 `sub-status` 대입과 `finalize-begin` 이 `_decided` 로, `finalize-end` 는 rc 검사(:856-857), `cmd_wait` 의 wait-rc 는 `_decided` + 숫자 검사(:1070-1071). 모든 호출이 서브셸 밖(직접 호출)이라 `exit 1` 이 래퍼를 끝낸다; `{ …; } 4>&1` 그룹도 서브셸이 아니다.
|
||||||
|
- 정상 경로 불변: `_decided` 는 같은 명령의 같은 한 줄 출력을 변수에 넣고, `finalize-end` 의 "소유자 사라짐 → rc 0·빈 답" 은 전과 같이 FINAL_JOB_STATUS="" 로 남는다(의도된 기존 동작). test_f_the_normal_path_is_unchanged(실제 래퍼, human 잡, 닿지 않는 브로커 → rc 3, 마지막 줄 = 로그 경로, 오류 문구 없음) 통과. 옛·새 비교 테스트(test_c_*) 전부 통과.
|
||||||
|
- rc 로만 읽는 호출: `subscriber-up` 실패(모듈 깨짐) → "subscriber died early" 경로 → `_decided sub-status` 가 실패 → exit 1(fail-closed, OK). `stall-watch` 실패 → `|| return 0` 으로 **감시만 꺼진 채 진행**(:718). 브리프가 "범위에 넣지 않은 것은 이유를 적으라" 했는데 리뷰어에게 그 이유는 보이지 않는다 — 관찰 N1. #30 감시자는 notify-only 라 잡 종결에는 영향이 없어 비차단.
|
||||||
|
- 추가 테스트가 수정 전 코드에서 실패하는가: 이전 래퍼(56d0bfd)를 넣은 사본에서 test_f_* 11건 + test_s_no_printed_decision_is_read_raw 모두 **실패** — exit-code 는 `exit: : numeric argument required` 로 **255**, 인터프리터 부재는 **127**, finalize-begin 빈 답은 finalize-end argparse `--evidence-until: invalid int value: ''` 로 **2**. 세 가지 모두 브리프가 금지한 종료 방식이며 새 코드는 전부 1 로 끝난다.
|
||||||
|
- 이식성: `/bin/bash -n` 통과(3.2), `printf -v`·`[[ =~ ]]` 는 3.2 에서 지원. 테스트는 `/bin/sh` 셸 스크립트 대역과 `sys.executable` 만 쓴다.
|
||||||
|
|
||||||
|
## 3. 항목 3 — N1 문구
|
||||||
|
wrapper.py:24-29 가 "이 모듈은 stdlib 만 import 하지만 `python -m lib_py.delegation.wrapper` 는 패키지 `__init__` 을 먼저 실어 paho 가 로드되며, 래퍼의 $PY 는 원래 paho 가 필요하다"로 정정. 실행 경로 변경 없음(diff 상 docstring 만).
|
||||||
|
|
||||||
|
## 4. 항목 4 — N3 주석
|
||||||
|
lib_int.sh:9-10 에 "PATH 의 python3 를 쓴다(단독 source 라 $PY 없음); 없으면 mam_int/mam_int_knob 이 아무것도 출력하지 않는다(rc 127)" 추가. 코드 불변.
|
||||||
|
|
||||||
|
## 5. 테스트 실행 (검증 경로, 워크트리 .venv Python 3.9.6, paho 있음, `env -i` 로 HERDR_*·MAM_*·MQTT_*·WORKSPACE_ROOT 제거)
|
||||||
|
| 실행 | 결과 |
|
||||||
|
|---|---|
|
||||||
|
| tests/test_issue45_restore.py, test_issue45_restore_unit.py, test_issue17_item4_decisions.py, test_issue33_budget.py | **310 passed / 0 failed / 0 skipped** (425 s) |
|
||||||
|
| 새 테스트 16건, 이전 래퍼·이전 herdr_restore.py 사본 | **13 failed** / 3 passed (실패 = F 11 + 정적 가드 + placeholder 단위) |
|
||||||
|
| test_b (실서버), 이전 herdr_restore.py 사본 | 1 failed (pane_not_found 예외) |
|
||||||
|
격리: 실행 전후 herdr 프로세스는 기존 `multi-agent-mux` 서버·attach·오케스트레이터 `agent wait`(pid 만 교체)뿐, `/private/tmp/m45*` 없음, `~/.config/herdr` sessions·config.toml md5 불변. 실제 .mam/jobs 항목 수 불변. 루프백 bind 제약 없음(브로커 테스트 정상 실행).
|
||||||
|
|
||||||
|
## 6. 비차단 관찰
|
||||||
|
- **N1 stall-watch 판정 실패 시 조용히 감시 생략**(래퍼 :718 `|| return 0`): 모듈이 깨지면 stderr 에 아무 말 없이 stall watcher 가 꺼진다. 종결·레지스트리·이벤트에는 영향이 없어 fail-closed 요구 밖이지만, `_decide` 의 rc 가 1(판정 "감시 안 함")인지 그 밖(실패)인지 구분해 후자는 경고 한 줄을 남기면 진단이 쉬워진다. 브리프가 요구한 "범위 제외 이유" 가 리포트에 있는지 오케스트레이터가 확인하길 권한다.
|
||||||
|
- **N2 `_env` 가 상속 WORKSPACE_ROOT 를 비우지 않음**(tests/test_issue17_item4_decisions.py:224-230): HERDR_/MAM_/SKS_ 만 걷어낸다. 새 테스트(:659-660, :692)는 WORKSPACE_ROOT 를 명시하지만, 래퍼를 source 하는 기존 `_run_both` 4곳(:246, :372, :561, :586, PR #51 유래)은 `_env(work, …)` 만 써서 에이전트 pane 에서 돌리면 그 pane 의 WORKSPACE_ROOT 로 lib.sh 가 초기화된다 — 브리프가 적은 사고(상속된 WORKSPACE_ROOT 로 다른 워크스페이스의 shim 재작성)와 같은 경로다. `_env` 에서 WORKSPACE_ROOT 를 제거하고 tmp_path 로 고정하면 한 줄로 막힌다. 이 델타가 만든 문제가 아니라 비차단.
|
||||||
|
- **N3 docstring 줄바꿈**(herdr_restore.py:7-8): "A pane keeps the\n`agent_session` its agent\nreported" 로 어색하게 끊겨 있다. 내용 문제 없음.
|
||||||
|
|
||||||
|
[VERDICT: PASS]
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
# 🔍 Review Report — Job 48b1b0f8 (Issue #18 델타 리뷰, reviewed HEAD `263d6bf`)
|
||||||
|
|
||||||
|
- **Reviewer**: `planner-reviewer-claude-01` (role: planner,reviewer) · 2026-09-14
|
||||||
|
- **대상**: `git diff ab842f1...HEAD` 2커밋, 4파일 +273/−10.
|
||||||
|
- `2b1d590`: 잡별 lifecycle lock, 구현 세션 커밋
|
||||||
|
- `263d6bf`: codex F1·F2 교정, creator 커밋
|
||||||
|
- 전체 브랜치는 내가 `ab842f1` 에 PASS 한 범위다(`98215f55`).
|
||||||
|
- 브리프의 "변경분" 은 작업 트리의 추적되지 않은 codex 리포트 파일 하나뿐이었다. 그래서 델타는 git 으로 직접 확인했다.
|
||||||
|
- HEAD 는 이 잡 내내 `263d6bf` 이다(`completed` 발행 명령에서 다시 확인).
|
||||||
|
- **방법**:
|
||||||
|
- 델타를 정독했다.
|
||||||
|
- 임시 레지스트리에서 실제 타이머 프로세스로 독립 실험 4건을 했다(브로커 없음, 닫힌 포트·블랙홀 주소).
|
||||||
|
- 선택 테스트 9파일을 실행하고 정적 검사를 했다.
|
||||||
|
- 이 잡은 `started --heartbeat 30` 으로 시작했다. 코드는 수정하지 않았다.
|
||||||
|
- **안전 규칙 준수**:
|
||||||
|
- 실제 `.mam/jobs` 레코드는 수정하지 않았다(실행 전후 비교).
|
||||||
|
- monitor 데몬과 `.mam/monitor.lock` 은 건드리지 않았다. purge 는 실행하지 않았다.
|
||||||
|
- 실험 타이머 잔여는 0 이다.
|
||||||
|
|
||||||
|
## 0. 경위
|
||||||
|
|
||||||
|
| 리뷰 | HEAD | 판정 | 요지 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| claude `98215f55`·agy·grok | `ab842f1` | PASS | — |
|
||||||
|
| codex (4회차) | `ab842f1` | NOT PASS | 동시 `start_timer` 2건이 둘 다 spawn 하고 하나만 추적됨 → `2b1d590` |
|
||||||
|
| codex `d1eb9f50` | `2b1d590` | NOT PASS | **F1**: 종료 중인 타이머가 drain 중인 부모가 쥔 lock 을 기다리는 유한 교착(항상 SIGKILL 로 끝남). **F2**: pidfile 기록 실패 뒤 추적되지 않는 타이머가 남음 → `263d6bf` |
|
||||||
|
| codex `bad3ef4b` | `263d6bf` | PASS | — |
|
||||||
|
|
||||||
|
## 1. 검증 항목
|
||||||
|
|
||||||
|
### (1) lifecycle lock 과 레지스트리·seq lock 의 순서, 교착 가능성
|
||||||
|
|
||||||
|
- **구조**: lifecycle lock(`heartbeat.py:187-209`, `<job>.heartbeat.lock`)은 레지스트리·seq lock 과 **별개의 flock** 이다. 잡는 곳은 세 군데뿐이다.
|
||||||
|
- 터미널 `stop_timer`(`:221`)
|
||||||
|
- `start_timer`(`:312`)
|
||||||
|
- 종료하는 타이머의 정리(`:473`, **non-blocking**)
|
||||||
|
- **lifecycle lock 안에서 하는 일**:
|
||||||
|
- `_stop_timer_locked` 의 drain 은 레지스트리 lock 을 잡지 않는다.
|
||||||
|
- `load_job` 은 lock 없는 읽기다(`mqtt_common.py:393-399`). `barrier_blocks` 도 파일 읽기뿐이다.
|
||||||
|
- 그 뒤 spawn 과 pidfile 기록을 한다.
|
||||||
|
- **레지스트리 lock 을 잡는 곳**: `raise_barrier`(`heartbeat.py:270`)와 `next_seq` 뿐이다. 둘 다 lifecycle lock 을 잡지 않는다.
|
||||||
|
- 터미널 publish 는 `raise_barrier`(레지스트리 lock 해제) → `stop_timer`(lifecycle) 순으로 **중첩 없이 차례로** 잡는다.
|
||||||
|
- 따라서 역순 중첩이 없고, lock 순서 교착은 불가능하다.
|
||||||
|
- **drain 중 in-flight beat**: beat 는 seq lock 만 필요하다. 그 lock 은 drain 하는 쪽이 쥐고 있지 않으므로 beat 는 끝까지 갈 수 있다.
|
||||||
|
- **F1 교정**: 종료하는 타이머는 lock 이 바쁘면 기다리지 않고 정리를 소유 연산에 맡긴다. 소유 연산(start·stop)은 모두 `_stop_timer_locked` 로 시작하며, 그 안에서 pidfile 을 지운다.
|
||||||
|
- **실험 E1 — 유휴 타이머 정지** (interval 60, 2 s 대기 뒤 `stop_timer(drain=5)`):
|
||||||
|
- **0.17 s** 에 반환했고, pidfile 이 제거되었으며, "did not drain" 경고가 없었다.
|
||||||
|
- codex 가 `2b1d590` 에서 잰 값은 2.09 s, `-9` 였다. 교착이 해소되었다.
|
||||||
|
- **실험 E4 — 와이어에 걸린 beat 가 있는 drain** (브로커 `10.255.255.1` 블랙홀, interval 1):
|
||||||
|
- `stop_timer(drain=1)` 가 **1.12 s** 에 SIGKILL 로 끝났다. 이 경우의 "did not drain" 경고 1건은 정당하다.
|
||||||
|
- 직후 `next_seq` 는 **0.001 s** 에 반환했다(seq lock 은 비어 있음). pidfile 은 제거되었다.
|
||||||
|
- drain 과 seq lock 사이에 교착이 없다.
|
||||||
|
|
||||||
|
### (2) 동시 start, barrier 뒤 start, pidfile 기록 실패
|
||||||
|
|
||||||
|
- **실험 E2 — 동시 `start_timer` 5건**(스레드 barrier 로 동시 출발):
|
||||||
|
- 오류 0, 살아남은 타이머 **정확히 1개**, pidfile 이 그 타이머를 가리킨다.
|
||||||
|
- `stop_timer` 뒤 생존 0, pidfile 없음.
|
||||||
|
- **실험 E3 — `start_timer` 와 터미널 `completed` publish 경쟁, 8라운드**(시작 간격 0–14 ms 로 변화):
|
||||||
|
- **위반 0/8**: 매 라운드 레코드 `completed`, 생존 타이머 0, pidfile 없음, events.log 에 `completed` 뒤 이벤트 0.
|
||||||
|
- 1라운드는 start 가 먼저 arm 해서 터미널이 drain 했다. 7라운드는 barrier·터미널을 보고 **arm 하지 않았다**.
|
||||||
|
- **pidfile 기록 실패 (F2)**(`:343-364`): spawn 성공 뒤 pidfile 기록이 실패하면 lock 을 쥔 채 그 `Popen` 을 kill·wait 한다. 그리고 이 시도의 부분 pidfile 만 지운 뒤 예외를 다시 던진다.
|
||||||
|
- 시험 `test_a_timer_whose_pid_cannot_be_recorded_is_killed_at_once` 가 SIGKILL 종료, pidfile 없음, lock 재사용 가능을 단정한다.
|
||||||
|
|
||||||
|
### (3) 예외 경로에서 lock 해제와 pidfile 일관성
|
||||||
|
|
||||||
|
- `lifecycle_lock` 은 `try/finally` 로 `LOCK_UN` 하고, `with open(...)` 가 fd 를 닫으므로 예외에서도 해제된다.
|
||||||
|
- non-blocking 이 바쁘면 `False` 를 내주고, 호출자는 소유 상태를 건드리지 않는다(`:473-478`).
|
||||||
|
- **spawn 전 실패**(`Popen` 오류): pidfile 을 쓰지 않고 lock 은 해제된다. 시험 `…cannot_be_spawned_leaves_nothing_behind` 가 확인한다.
|
||||||
|
- **spawn 후 실패**: 이전 소유자의 pidfile 은 이미 `_stop_timer_locked` 가 지웠다. 따라서 남은 내용은 이번 시도의 것뿐이고, pid 접두 비교로 지우는 판단이 옳다.
|
||||||
|
- P3:
|
||||||
|
- `lifecycle_lock` 은 `os.makedirs(registry_dir)` 를 한다. 그래서 잘못된 레지스트리 경로로 터미널 publish 를 하면 디렉터리가 생긴다.
|
||||||
|
- 모든 터미널 publish 가 잡마다 `<job>.heartbeat.lock` 을 남긴다(기존 `.heartbeat.barrier` 와 같은 성질). `.json` 이 아니므로 잡 목록과 #15 가드에는 영향이 없다. 레지스트리 정리 정책에서 함께 다룰 것을 권한다.
|
||||||
|
|
||||||
|
### (4) 새 회귀 시험의 결정성과 프로세스 정리
|
||||||
|
|
||||||
|
| 시험 | 판단 |
|
||||||
|
|---|---|
|
||||||
|
| `test_concurrent_start_timer_calls_leave_exactly_one_tracked_owner` | 스레드 2개로 순서를 강제하지 않는다(codex 도 지적). 위의 5건 동시 실험이 보완한다. `finally` 에서 두 pid 를 SIGKILL 한다 |
|
||||||
|
| `test_start_timer_after_the_terminal_barrier_does_not_arm` | 결정적(프로세스 없음) |
|
||||||
|
| `test_an_idle_timer_stops_gracefully_well_before_the_drain_deadline` | 2 s 대기로 TERM 핸들러 설치 뒤를 노린다. 과부하로 핸들러 설치 전에 TERM 이 가도 기본 동작으로 곧 끝나므로 `< 3 s` 단정은 유지된다. `finally` 에서 정리한다 |
|
||||||
|
| `test_a_timer_whose_pid_cannot_be_recorded_is_killed_at_once` | `builtins.open` 을 잠깐 패치하고 `finally` 에서 복구한다. spawn 된 프로세스는 `finally` 에서 kill·wait 한다 |
|
||||||
|
| `test_a_timer_that_cannot_be_spawned_leaves_nothing_behind` | 없는 python 경로를 쓴다. 두 번째 타이머는 `stop_timer`·SIGKILL 로 정리한다 |
|
||||||
|
|
||||||
|
- 실행 결과는 §2 에 있다.
|
||||||
|
|
||||||
|
### (5) 문서·수치·락스텝
|
||||||
|
|
||||||
|
- MESSAGING §2.5 heartbeat 행이 코드와 일치한다: "lifecycle lock(`<registry>/<job>.heartbeat.lock`, seq lock 과 별개) — 끝난 attempt 는 arm 하지 않음, 종료 타이머는 lock 을 기다리지 않음, pid 기록 실패 시 즉시 종료". VERSIONS 항목 1 도 같다.
|
||||||
|
- VERSIONS 품질 절 "신규 테스트 31개"(`VERSIONS.md:54`)는 `grep -c '^def test_'` 결과 31 과 같다.
|
||||||
|
- 4.5.0 락스텝 유지: `verify_framework_version` OK, `SKILL.md` 8개 4.5.0.
|
||||||
|
- `git diff --check 139bdd4...HEAD` 클린. `py_compile`(`heartbeat.py`, 시험) 통과.
|
||||||
|
|
||||||
|
## 2. 테스트 실행 결과
|
||||||
|
|
||||||
|
| 실행 | 결과 |
|
||||||
|
|---|---|
|
||||||
|
| **선택 9파일** (issue18 31, issue14, issue6 fail_closed·turn_handshake, phase1, phase2a, tier1, tier2, version) @ `263d6bf`(전후 동일) | **355 passed, 0 failed, 742.30 s**, skipped 0. 브로커 의존 AC1 22.09 s, AC3 18.92 s, pane-busy 17.93 s, 벽시계 30.12 s |
|
||||||
|
| 독립 실험 E1–E4 | 모두 기대대로(§1). 실험 타이머 잔여 0 |
|
||||||
|
| creator `31a24383` (3파일) | 100 passed |
|
||||||
|
|
||||||
|
- **누수 (실행 전후)**:
|
||||||
|
- 실제 monitor 데몬 수가 같다(3개, 실행 전후 동일 PID).
|
||||||
|
- pytest 경로의 heartbeat 타이머·구독자·원격 러너는 0 이다.
|
||||||
|
- 실제 `.mam/jobs` 추가는 0 이다.
|
||||||
|
|
||||||
|
## 3. 비차단 권고
|
||||||
|
|
||||||
|
1. (이월, T-1) 브로커 의존 시간 임계 시험의 여유를 넓힌다: AC1·AC3·AC5 의 K×N, issue14 벽시계·pane-busy, send_keys `< 11`.
|
||||||
|
2. 잡마다 남는 `.heartbeat.lock`·`.heartbeat.barrier` 파일의 정리 정책을 정한다. `lifecycle_lock` 의 `makedirs` 부작용도 함께 본다.
|
||||||
|
3. (이월) AC5 는 단일 원격 잡 시뮬레이션과 러너 라벨 단위 시험까지만 검증되었다. 루프 종단 수렴은 후속 E2E 로 남는다.
|
||||||
|
|
||||||
|
## 4. GM 참고
|
||||||
|
|
||||||
|
- **추적되지 않은 파일**: 작업 트리에 `.agents/reports/planner-reviewer-codex-01/report-bad3ef4b.md` 가 있다(codex 리포트 사본). 브리프의 "변경분" 이 이것 하나로 채워졌다. 나는 건드리지 않았다.
|
||||||
|
- **커밋 주체**: `2b1d590` 은 구현 세션(`…01VCgu9Fa9iT7TbJUSzdq4Ff`), `263d6bf` 는 creator(`…01HCGpJXhSZasJLU38ksDghe`)의 커밋이다.
|
||||||
|
- **브랜치 ref**: 11커밋 모두 로컬 `main` 에 있다(`origin/main` 보다 11 앞, 미푸시). `fix/issue-18` 은 `139bdd4` 다. 병합 전에 ref 정리가 필요하다.
|
||||||
|
|
||||||
|
## 5. 종합
|
||||||
|
|
||||||
|
- `2b1d590` 의 잡별 lifecycle lock 은 레지스트리·seq lock 과 중첩 없이 분리되어 있어 교착이 없다.
|
||||||
|
- `263d6bf` 의 non-blocking 종료 정리(F1)와 spawn 후 pid 기록 실패 시 종료(F2)가 정확하다.
|
||||||
|
- 독립 실험으로 확인했다:
|
||||||
|
- 유휴 정지 0.17 s
|
||||||
|
- 동시 start 5건에서 추적 소유자 1개
|
||||||
|
- start 와 터미널 경쟁 8라운드에서 위반 0
|
||||||
|
- 걸린 beat 가 있어도 seq lock 이 즉시 비어 있음
|
||||||
|
- 새 시험들이 이 경로들을 고정한다. 선택 스위트 355건이 모두 통과했다.
|
||||||
|
- 문서·수치(31)·4.5.0 락스텝이 정합한다.
|
||||||
|
- 남은 것은 비차단 권고뿐이며 설계 재검토는 필요 없다.
|
||||||
|
|
||||||
|
[VERDICT: PASS]
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"loop_id": "loop-20261017T145452Z-6c5aa6",
|
||||||
|
"base_sha": "26e2c43753169671b600d03274901dbe203988dd",
|
||||||
|
"head_sha": "de6f3fc81f8c33a9d2335320742dc5a47f566cda",
|
||||||
|
"tree_sha": "ffc291e6fc93822070f2b9f1d17009e83f176fcf",
|
||||||
|
"review_commit": "de6f3fc81f8c33a9d2335320742dc5a47f566cda",
|
||||||
|
"reviewer": "planner-reviewer-claude-01",
|
||||||
|
"job_id": "48d0a01c",
|
||||||
|
"verdict": "PASS",
|
||||||
|
"transport": "herdr",
|
||||||
|
"evidence": "review-tree",
|
||||||
|
"isolation": "worktree",
|
||||||
|
"verified_at": "2026-10-17T15:16:54Z"
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# 리뷰 리포트 — PR #59 3차 (Issue #31) / job 48d0a01c
|
||||||
|
|
||||||
|
- 리뷰어: planner-reviewer-claude-01 (Opus 5.5)
|
||||||
|
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261017T145452Z-6c5aa6/1`
|
||||||
|
- identity: HEAD `de6f3fc81f8c33a9d2335320742dc5a47f566cda`, tree `ffc291e6fc93822070f2b9f1d17009e83f176fcf` 일치. 트리는 clean이고 `.mam` 없음(테스트 후에도 동일).
|
||||||
|
- 범위: 26e2c43..de6f3fc 전체. 2a8d738 이후 변경은 de6f3fc 한 커밋으로, `tests/test_issue31_purge_residual.py` 의 +3/−1 뿐이다. `git diff 2a8d738 HEAD -- .agents` 는 비어 있다.
|
||||||
|
|
||||||
|
## 결론
|
||||||
|
|
||||||
|
2차의 유일한 차단(T1: macOS `/bin/sh` 의 단일 명령 exec 로 보유자 argv 에서 uuid 가 사라지던 문제)이 해결됐다. 제품 코드는 2a8d738 과 바이트 단위로 같으므로, 2차에서 이 Mac 에서 확인한 내용이 그대로 유효하다. 확인한 항목은 비-UUID 거부, 링크 경계, 유한하지 않은 startedAt, UTC lstart, exit 4 안내, 삭제 범위, 실제 `~/.claude`·`/private/tmp/claude-501` 레이아웃 정합이다. 판정은 **PASS**.
|
||||||
|
|
||||||
|
## (a) de6f3fc 검증
|
||||||
|
|
||||||
|
1. **보유자 argv 에 uuid 가 남는다.**
|
||||||
|
- 새 보유자는 `[sys.executable, "-c", "import time; time.sleep(120)", conv]` 이다(`tests/test_issue31_purge_residual.py:317`).
|
||||||
|
- Python 인터프리터는 `-c` 뒤 인자를 `sys.argv[1]` 로 받을 뿐 exec 로 자신을 바꾸지 않으므로, `ps` 의 args 에 소문자 `conv` 가 그대로 남는다.
|
||||||
|
- `subprocess.Popen` 은 자식의 exec 성공을 확인한 뒤에 반환하므로, 이어지는 `time.sleep(0.05)` 시점에는 argv 가 이미 보인다(경합 없음).
|
||||||
|
- `sys` 는 파일 머리(`:22`)에서 이미 import 되어 있다. 같은 패턴인 `test_issue10_13_purge.py` 의 `_holder()` 와도 일관된다.
|
||||||
|
2. **upper/mixed 에서 통과한다.** 검증 경로에서 `-k any_spelling` 을 5회 반복 실행해 5회 모두 `2 passed` 였다. 관련 모듈 전체 실행에서도 통과했다(아래).
|
||||||
|
3. **철자 처리 회귀를 여전히 잡는다.** `git clone` 사본에서 claude 어댑터를 한 군데씩 변형해 test 파일 전체를 돌렸다.
|
||||||
|
- `conversation_in_use` 의 argv 검색을 `{canon, uuid}` 에서 `{uuid}` 로 바꿈(소문자 정규화 제거): 2 failed (upper, mixed). 실패 지점은 정확히 새 보유자 단언 `:320`, `AssertionError: a command line naming the lowercase id` 이다. 즉 수정된 보유자가 이 회귀를 잡는 바로 그 장치다.
|
||||||
|
- `_session_files(canon)` 을 `_session_files(uuid)` 로 바꿈: 2 failed (세션 파일 경로 단언에서).
|
||||||
|
- `_canonical` 의 `.lower()` 제거: 2 failed.
|
||||||
|
- 세 변형 모두 나머지 65개는 통과했으므로, 실패가 해당 테스트에 국한된다.
|
||||||
|
4. **정리 동작이 유지된다.**
|
||||||
|
- `try/finally: named.kill(); named.wait()` 구조는 그대로다(`:318-323`).
|
||||||
|
- 변형 실행(일부러 실패시킨 경우 포함)이 끝난 뒤 `ps -axo pid=,args=` 에서 `python … import time; time.sleep(120)` 프로세스는 0개였다. argv0 가 python 인 행만 세어 자기 자신과의 매칭은 배제했다.
|
||||||
|
|
||||||
|
## (b) 전체 판정을 위한 재확인
|
||||||
|
|
||||||
|
- 제품 파일(`.agents/` 전체)은 2a8d738 과 동일하므로, 2차 리뷰(job 778fc866)의 확인 결과를 그대로 승계한다. 다시 돌린 것은 위 변형 대조와 아래 관련 모듈이다.
|
||||||
|
- 알려진 비차단 세 가지는 브리프대로 병합 차단이 아니다. 내용은 2차 리포트에 적은 그대로다.
|
||||||
|
- 링크된 `projects/<key>` 아래 jsonl 은 purge 되지 않고 residual 로만 보고된다.
|
||||||
|
- 소문자 질의는 대문자 argv 보유자를 보지 못한다.
|
||||||
|
- `purge_artifacts` 를 직접 호출하면 in-use 게이트를 거치지 않는다.
|
||||||
|
|
||||||
|
## 테스트
|
||||||
|
|
||||||
|
검증 경로에서 `env -i`(HERDR_*·WORKSPACE_ROOT·MAM_*·DELEGATE_JOB_LOGS_DIR·CLAUDE_CONFIG_DIR 미설정), 스크래치패드 basetemp, `PYTHONDONTWRITEBYTECODE=1 -p no:cacheprovider` 로 실행했다.
|
||||||
|
|
||||||
|
- 실행 모듈: `test_issue31_purge_residual, test_issue10_13_purge, test_a4_adapter_contract, test_issue42_skill_blocks, test_issue55_agent_homes, test_orc_onboard, test_uuid_target, test_tier1_unit, test_tier2_component`
|
||||||
|
- 결과: **332 passed, 1 skipped, 1 error** (624 s). 2차의 2 failed 는 사라졌다.
|
||||||
|
- 1 error 는 `test_tier2_component.py::test_comp_codex_create_and_reconcile_and_stop` 의 **teardown 에서 #55 가드**가 낸 것으로, 브리프가 밝힌 알려진 오탐이다. 판정 근거로 쓰지 않고 보고만 한다.
|
||||||
|
- 가드가 잡은 경로: `~/.gemini/antigravity-cli/conversations/0dd4a87f-6170-472b-886a-eef6e099b375.db-shm` 과 `.db-wal`.
|
||||||
|
- 읽기 전용 확인 결과: 이 DB 는 다른 곳에서 돌고 있는 실제 `agy` 프로세스(pid 66733)가 열고 있다(`lsof -t`). WAL 이 자라는 중이다(mtime Oct 18 00:06, 크기 19.6 MB). 테스트 산출물이 아니다.
|
||||||
|
- 이 테스트는 2차 실행에서는 통과했고, 이번 커밋은 tier2 와 무관한 테스트 한 줄만 바꿨다.
|
||||||
|
|
||||||
|
## 비차단 메모
|
||||||
|
|
||||||
|
- 2차 리포트의 비차단 사항(`projects/<key>` 링크 시 동작 문서화 등)은 그대로다. 병합 후 별도 이슈로 다뤄도 된다.
|
||||||
|
|
||||||
|
[VERDICT: PASS]
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# 🔍 Cross-Review Round 3 — Issue #5 Phase 2a (`6ad6f08..0e74aa1`, 신규 커밋 `cb575fa`+`0e74aa1`) — Job `49171b58`
|
||||||
|
|
||||||
|
- **Reviewer**: `planner-reviewer-claude-01` (Reviewer role)
|
||||||
|
- **대상**: 브리프 첨부 diff = `git diff 6ad6f08..HEAD` − `.agents/reports/*`(2914행, **바이트 동일**). HEAD=`0e74aa1`, working tree clean. Round 2(`cb792e91`) 대비 신규는 수정 커밋 `cb575fa`(9 files, +317/−52)와 문서 커밋 `0e74aa1`.
|
||||||
|
- **방법**: Round 2의 F-C/F-D와 Codex Round 2(`0026165b`)의 R1/R4 수정을 실제 CLI·실제 모듈로 재검증(F-C는 래퍼와 같은 `set -euo pipefail` 셸에서), Round 1 항목(F-A/F-B)의 회귀 여부 재확인, 신규 테스트 12종 독해, 전체 스위트 실행.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 결론 요약
|
||||||
|
|
||||||
|
- **Round 2 지적 전부 해소 — `[ADJUDICATION: SUSTAINED]`**. F-C: 멱등 ACK가 `TransitionResult.idempotent`로 구분되어 CLI가 rc 0을 내고, 래퍼 승인 경로가 `set -e` 아래에서 끝까지 진행된다(§1). F-D: `_operator_write` 첫 줄에서 `VALID_STATUSES` 검증이 두 모드 공통으로 복원돼 rc 1·레코드 불변이다(§2). Round 1의 F-A/F-B도 회귀 없이 유지된다(§3).
|
||||||
|
- Codex R1(관측 증거 **부재** 시 승인)·R4(감사 mirror의 비원자적 비교-후-쓰기)도 구현·테스트를 확인했다(§4). R4는 내가 Round 2 §2에서 비차단으로 언급한 창(window)을 닫는 것이라 특히 반갑다.
|
||||||
|
- **신규 결함 없음.** 자동완료 3표면·`lib.sh`·shim 무변경, 위생 클린, 전체 스위트 통과(§6). `[ESCALATE: PLANNER]` 불필요.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. F-C 재검증 — 해소
|
||||||
|
|
||||||
|
- `engine.py:83-99` `TransitionResult.idempotent`, `:105-113` `_Decision.idempotent`, `:164-167`에서 같은 terminal 재수신에 `idempotent=True`. `registry.py:329-366` `_operator_write`: `committed` → 0, `idempotent` → 0, 그 외만 `refused:` + 4.
|
||||||
|
- **셸 수준 재현(Round 2와 동일 스크립트)**: 이미 `completed`인 레코드에 `status --set completed`를 `set -euo pipefail` 아래에서 실행 → `REACHED (CAS=1)` / `REACHED (CAS=0)`, bash exit 0. Round 2에서는 exit 4로 `REACHED`가 출력되지 않았다.
|
||||||
|
- `registry.main` 반환값 직접 확인(두 모드): 멱등 `--set completed` → **rc 0**, `cancelled` after `completed` → **rc 4**(CAS=1, 진짜 거부는 여전히 non-zero; CAS=0은 레거시 덮어쓰기 유지).
|
||||||
|
- `test_fc_idempotent_finalize_*`(두 모드), `test_fc_a_genuine_conflict_is_still_reported`(rc 4 유지), `test_fc_engine_marks_an_idempotent_result`가 고정. 문서: `state-transitions.md` Rev.5 "멱등 ACK는 거부가 아니다", `phase2a-shadow.md` §2-1 CLI 종료 코드 표(0/1/4).
|
||||||
|
|
||||||
|
## 2. F-D 재검증 — 해소
|
||||||
|
|
||||||
|
`_operator_write`가 CAS 분기 **이전**에 `status not in VALID_STATUSES`면 2a 이전과 같은 문구로 rc 1. 두 모드에서 `--set bogus` → **rc 1, 레코드 불변**(직접 확인). `test_fd_invalid_status_is_rejected_in_both_modes`가 레지스트리 디렉터리 지문까지 단정.
|
||||||
|
|
||||||
|
## 3. Round 1 항목 회귀 확인 — 유지
|
||||||
|
|
||||||
|
래퍼 시퀀스 재생: `completed → update --status pending --iteration 2` rc 0 · `pending/2`; `completed → status --set error` rc 0 · `error`. 지연 flush 실험: registry=`completed`, status.json=`completed`. 두 모드 병행 특성화(X-01~X-04, fa/fb)가 스위트에 남아 있다.
|
||||||
|
|
||||||
|
## 4. Codex Round 2 (R1, R4) 확인
|
||||||
|
|
||||||
|
| # | 구현 | 검증 |
|
||||||
|
|---|---|---|
|
||||||
|
| R1 증거 부재 시 승인 | `policy.py:20-23` `ATTRIBUTION_FIELDS`; `collect_observed_identity`(`:33-66`)는 식별자를 타임라인 항목의 **`collector_attribution` 봉투**(및 비-herdr 소스 payload)에서만 읽고 네이티브 Herdr payload 안의 식별자는 **증거로 취급하지 않는다**(Phase 0의 "네이티브 payload에 MAM 필드 금지"와 정합); `decide`(`:115-135`)는 4식별자 **전부 존재+일치**와 매니페스트 `attempt_id` 존재+일치를 요구 | `finalized_candidate.json`에 봉투 추가(다른 11종은 `hold`/`reject`라 무영향, 12벡터 `test_g7` 유지). `test_r1_missing_attribution_evidence_holds`(4필드 parametrize), `*_missing_report_manifest_holds`, `*_identifiers_inside_a_native_payload_are_not_evidence`. Phase 0 `validate_watcher_scenario` 41종 통과(봉투는 item 레벨 키라 네이티브 payload 검사와 충돌 없음) |
|
||||||
|
| R4 비원자적 비교-후-쓰기 | `update_logged_status`(`mqtt_common.py:577-619`)가 per-job 전용 락 파일(`.status.json.lock`, `_file_lock`) 아래에서 읽기-비교-쓰기, 임시 파일은 `mkstemp`(공유 `.tmp` 경로 제거). 레지스트리 락과 별개이며 레지스트리 트랜잭션 밖에서 잡힌다 | `test_r4_overlapping_mirror_flushes_*`는 첫 flush의 `read_text` 직후에 경쟁 flush를 스레드로 띄워 **실제로 블록됨**(`blocked is True`)과 최신 상태 생존을 단정 — 진짜 인터리빙 테스트다. `*_each_mirror_write_uses_its_own_temp_file` |
|
||||||
|
|
||||||
|
Grok Round 2(`32e936e5`)는 PASS + 기존 비차단 잔여로, 이번 수정과 충돌 없음.
|
||||||
|
|
||||||
|
## 5. 기타 확인
|
||||||
|
|
||||||
|
- `git diff --check 6ad6f08..HEAD` 클린, `compileall lib_py` 클린, 자동완료 3표면·`lib.sh`·shim 변경 0.
|
||||||
|
- `test_issue5_phase2a_loop.py` + `phase0` + `phase1` 합계 **195 passed in 12.57s**.
|
||||||
|
- 락 규율(G-5)·writer 인벤토리(G-6)·shadow 무기록(G-4)·패키지 규칙(G-9)은 이번 수정으로 영향받지 않았고(테스트 유지), R4의 감사 락은 레지스트리 락 밖에서만 잡혀 G-5의 "락 안 블로킹 금지"와 충돌하지 않는다.
|
||||||
|
- 비차단 메모(2b 인계, 이미 문서화됨): `started`의 세션 확인(O-A), `stop_session.sh`의 무조건 `cancelled`가 `completed` 이후 거부되는 동작(O-B).
|
||||||
|
|
||||||
|
## 6. 전체 스위트
|
||||||
|
|
||||||
|
HEAD(`0e74aa1`)에서 직접 실행 (`.venv/bin/python -m pytest tests/ -q`, 백그라운드): **664 passed in 899.99s (0:14:59)**, 0 failed, 0 skipped. Round 2의 650 대비 +14 (신규 함수 12개가 parametrize로 14 케이스). 총계는 실행 결과에서 직접 읽은 값이다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 결론
|
||||||
|
|
||||||
|
Round 2에서 지적한 두 회귀(멱등 ACK의 거부 분류로 인한 래퍼 성공 경로 중단, 롤백 모드의 상태값 검증 소실)는 제시한 방향 그대로 정확히 고쳐졌고, 래퍼와 같은 셸 조건에서 실제 CLI로 확인했다. Codex의 R1/R4도 구현됐으며, 특히 R4는 감사 mirror의 마지막 경쟁 창을 닫는다. Round 1·2의 모든 항목이 회귀 없이 유지되고 새 결함은 발견되지 않았으므로 통과시킨다.
|
||||||
|
|
||||||
|
[ADJUDICATION: SUSTAINED]
|
||||||
|
|
||||||
|
[VERDICT: PASS]
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"loop_id": "loop-20261013T025803Z-c69408",
|
||||||
|
"base_sha": "42e2a7ea9bd3e2770ed8626d789193428ca3aa8b",
|
||||||
|
"head_sha": "3a30d7da894cdac26b7ec054b4e66fe9fa3dc8db",
|
||||||
|
"tree_sha": "bd43e4642f0109091f5f497b2703dcbaf8e2616b",
|
||||||
|
"review_commit": "3a30d7da894cdac26b7ec054b4e66fe9fa3dc8db",
|
||||||
|
"reviewer": "planner-reviewer-claude-01",
|
||||||
|
"job_id": "495a1373",
|
||||||
|
"verdict": "PASS",
|
||||||
|
"transport": "herdr",
|
||||||
|
"evidence": "review-tree",
|
||||||
|
"isolation": "worktree",
|
||||||
|
"verified_at": "2026-10-13T03:32:19Z"
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# 테스트 격리 수정(u1e 의 WORKSPACE_ROOT) 리뷰 — 커밋 3a30d7d (job 495a1373)
|
||||||
|
|
||||||
|
- 리뷰어: planner-reviewer-claude-01 (Reviewer)
|
||||||
|
- 리뷰 identity: loop-20261013T025803Z-c69408 / base 42e2a7ea .. head 3a30d7da (커밋 1개, 테스트 2 파일) / tree bd43e464
|
||||||
|
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261013T025803Z-c69408/1` — HEAD·tree 일치, `git status --porcelain` 0 줄, **`.mam` 없음**(시작·종료 모두). 본 체크아웃 HEAD 42e2a7e(main) 변동 없음.
|
||||||
|
- 실행 안전: 모든 실행은 `env -i`(PATH/HOME/LANG/TMPDIR 만)로 브리프의 `-u` 목록을 포함한 라우팅 변수를 비웠고, basetemp 는 scratchpad 아래(이름에 "shim" 없음). 수정 전 재현은 검증 경로를 `git clone --no-hardlinks` 한 임시 복사본에서만 했고 거기 생긴 `.mam` 은 지웠다. 검증 경로에서 돌린 것은 W1/W2 뿐이며(W1 은 체크아웃을 tmp 로 복사해 그 안에서 노드를 돌린다), 끝난 뒤 검증 경로에 `.mam` 이 없음을 확인했다. 실제 `.mam`·`.mam.env`·모니터·실제 서버·에이전트 홈 미접촉.
|
||||||
|
|
||||||
|
## 1. 원인과 수정
|
||||||
|
- 원인(1차 README 리뷰에서 재현한 그대로): `test_u1e` 는 래퍼를 `subprocess.run(["bash", WRAPPER, "submit", …, "--dry-run"], env=dict(os.environ, HOME=…, MAM_ENV_FILE=…), cwd=tmp_path)` 로 부르는데, 래퍼가 source 하는 `lib.sh` 는 `WORKSPACE_ROOT` 가 없으면 체크아웃을 기본값으로 삼고(`lib.sh:26`) `_init_herdr_isolation` 이 `$WORKSPACE_ROOT/.mam/shim/herdr` 를 쓴다(`lib.sh:150-154`). conftest 의 `pytest_configure` 는 다른 체크아웃을 가리키는 `WORKSPACE_ROOT` 만 떨어뜨리므로 pane 밖에서는 변수가 없고, 체크아웃이 기본값이 된다.
|
||||||
|
- 수정(`tests/test_issue50_dispatch.py:167-170`): env 에 `WORKSPACE_ROOT=str(tmp_path)` 추가 → shim 은 `tmp_path/.mam/shim/herdr` 에 쓰인다. `tests/test_issue17_workspace_root_isolation.py:43`: 이 노드를 W1 `NODES` 에 추가. 두 파일 `py_compile` 통과. 같은 파일의 다른 테스트는 `decide.*` 를 in-process 로 부르므로(`_register`/`_edit`/`_event` 헬퍼) shim 을 쓰지 않는다 — 아래 §2 의 파일 전체 실행이 이를 확인.
|
||||||
|
|
||||||
|
## 2. 실행 결과
|
||||||
|
|
||||||
|
| 실험 | 결과 |
|
||||||
|
|---|---|
|
||||||
|
| **수정 전 복사본**(검증 경로 clone + `test_issue50_dispatch.py` 를 base 42e2a7e 로 되돌림): u1e 단독 | 1 passed, 복사본에 **`.mam/shim/herdr` 생성** — 원인 재현 |
|
||||||
|
| **수정 전 복사본**: W1(새 NODES 포함) | **FAIL**: `AssertionError: tests wrote into the checkout they ran in: ['.mam/shim', '.mam/shim/herdr']` — W1 이 이 노드로 회귀를 잡는다(복사본 안의 tmp 복사본에만 쓰였고 복사본 자체에는 `.mam` 없음) |
|
||||||
|
| **head, 검증 경로**: `test_issue17_workspace_root_isolation.py` (W1+W2) | **2 passed**, 검증 경로 `.mam` 없음 |
|
||||||
|
| **head 복사본**(`git archive`): `test_issue50_dispatch.py` 전체 | **49 passed**, 복사본 `.mam` 없음 |
|
||||||
|
|
||||||
|
W1 이 잡는 원리: W1 은 복사본에 `WORKSPACE_ROOT=<복사본>` 을 주고 노드를 돌린다. 수정 전 u1e 는 `os.environ` 을 그대로 물려주므로 래퍼가 그 복사본에 shim 을 쓰고, 수정 후는 `tmp_path` 로 덮어써 복사본에 아무것도 남지 않는다. pane 밖(변수 없음)에서도 lib.sh 의 체크아웃 기본값 때문에 같은 결과이므로 두 환경 모두를 대표한다.
|
||||||
|
|
||||||
|
## 3. 같은 결함이 남은 테스트가 있는가
|
||||||
|
- 정적: 래퍼·`lib.sh`·`run_loop.sh`·`create_session.sh`·`reconcile.sh`·`stop/resume_session.sh` 를 subprocess 로 부르는 테스트 파일 59 개를 추려 `mam_sandbox`/`own_workspace_root`/`WORKSPACE_ROOT` 참조 여부를 봤다. 참조가 하나도 없는 7 파일과, 참조가 적어 테스트별 누락이 있을 수 있는 18 파일(W1 NODES 에 없는 것 위주)을 골랐다.
|
||||||
|
- 경험적: 그 **25 파일을 head 의 `git archive` 복사본에서 파일마다 따로** 돌리고 매번 복사본의 `.mam` 을 봤다 — `test_b7_diff_untracked`, `test_deploy_freshness`, `test_deploy_layout`, `test_issue42_skill_blocks`, `test_issue45_herdr_config`, `test_issue5_phase0_contracts`, `test_local_mqtt`, `test_issue14_delivery_ack`, `test_issue18_liveness_contract`, `test_issue6_turn_handshake`, `test_issue26_envfile`, `test_issue26_ints`, `test_issue33_budget`, `test_layout`, `test_workspace_scope`, `test_a4_adapter_contract`, `test_issue45_restore`, `test_sanitize_and_mock_errors`, `test_sanity`, `test_issue12_registry_secrets`, `test_issue21_acceptance`, `test_issue15_registry_isolation`, `test_issue20_state_isolation`, `test_issue36_prompt_transport`, `test_issue34_job_tree`: 전부 통과(skip 은 tomllib/nats-docker 류), **`.mam` 생성 0 건**.
|
||||||
|
- 이전 리뷰들에서 같은 방식으로 돌린 파일들(leak·startup_path·shim_contract·issue17/9/45, scenario4, pane_lock, issue50_dispatch_herdr 등)도 검증 경로에 `.mam` 을 남기지 않았고, `.mam/shim` 이 생긴 경우는 모두 `test_issue50_dispatch.py` 가 포함된 실행뿐이었다(u1e). 즉 이 결함은 u1e 하나였고, 수정으로 닫혔다.
|
||||||
|
|
||||||
|
## 4. 잔류물
|
||||||
|
- clone 복사본·archive 복사본·basetemp·로그는 scratchpad 아래. 검증 경로는 깨끗(`.mam` 없음, `git status` 0 줄). #55 가드 오탐은 이번 실행에서 나오지 않았다.
|
||||||
|
|
||||||
|
## 결론
|
||||||
|
원인 진단이 맞고, `WORKSPACE_ROOT=tmp_path` 가 정확한 최소 수정이며, W1 은 수정 전 트리에서 이 노드로 실패하고 수정 후 통과한다. 다른 테스트에서 같은 누출은 25 파일의 파일별 실행으로 재현되지 않았다.
|
||||||
|
|
||||||
|
[VERDICT: PASS]
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
# 🔍 Review Report — PR #37 병합 전 독립 리뷰 (job `4ab12ae2`)
|
||||||
|
|
||||||
|
- **Reviewer**: `planner-reviewer-claude-01` (role: planner,reviewer) · 2026-09-24
|
||||||
|
- **대상**: PR #37 `fix/issue-36` **@ `59be1c8`**, base `main 1debc7d` (v4.7.2). 6커밋, 11파일 `+765/−30`, `git diff --check` 클린.
|
||||||
|
- **검증 경로**: `/var/folders/.../T/mam-review/loop-20260924T080014Z-2b3df0/1`. **identity 확인**: `HEAD = 59be1c89…cb49`, `HEAD^{tree} = a009c906…fab7` — 브리프와 일치.
|
||||||
|
- **안전 규칙 준수**: 검증 경로·소스 워크트리(`multi-agent-mux-wt/pr-37`)·본 체크아웃(`main 1debc7d`) 모두 수정·커밋·checkout 없음. 실험은 scratchpad 임시 디렉터리(uuid4)에서만(래퍼는 `--dry-run` + `--agent human` + 도달 불가 브로커, 러너는 in-process 스텁), pytest 는 `AGENT_PYTHON_BIN` + `--basetemp=<검증 경로 밖>`. 실제 herdr·세션·`.mam`·데몬 조작 없음, push·merge·PR 상태 변경 없음.
|
||||||
|
|
||||||
|
## 결론 요약
|
||||||
|
|
||||||
|
**병합을 막을 결함은 없다.** 프롬프트는 러너·래퍼·lib.sh 도우미·옛 셸 러너·create 온보딩 다섯 경로 모두에서 파일로 간다(`grep -rn -- '--prompt '` 의 잔존 호출은 전부 셸 **함수** 인자이거나 문서/기본값이다). 비차단 지적 4건(H1–H4). 그중 **H1 은 재현한 실제 결함**이다: 파이썬 러너는 프롬프트 파일 **쓰기**가 실패하면(ENOSPC 등) 부분 파일을 `.mam` 에 남기고 예외를 그대로 던진다 — PR 이 셸 쪽 두 곳에서 고친 바로 그 부류가 러너에는 빠져 있다. 디스크 고갈 급의 경계 상황이라 차단으로 보지 않았지만, 릴리스 커밋이나 즉시 후속에서 세 줄로 닫을 것을 권한다.
|
||||||
|
|
||||||
|
## 1. 전달 경로의 완전성 (항목 1)
|
||||||
|
|
||||||
|
| 경로 | 이전 | 이제 | 근거 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 파이썬 러너 `_run_cmd` | `--prompt <text>` argv | `.mam/prompt-<loop_id>-XXXX.md` 에 쓰고 `--prompt-file` | `runner.py:467-502` |
|
||||||
|
| 래퍼 `submit` → `registry.py register` | `--prompt "$PROMPT"` | 파일로 왔으면 `--prompt-file` 그대로 전달, 텍스트로 왔으면 종전대로 | `multi-agent-mux-delegate-job:186-208` |
|
||||||
|
| 래퍼 loop 타입의 회차 `update` | `--prompt "$current_prompt"` | `$job_dir/prompt.md` 에 쓰고 `--prompt-file` | `:405-450` |
|
||||||
|
| `lib.sh` `delegate_submit_job` / `_delegate_submit_job_legacy` (제출 도우미 11곳의 종착) | argv | `_delegate_prompt_file` 로 `.mam/prompt-submit-XXXXXX` 에 쓰고 `--prompt-file`, 성공·실패 모두 `rm -f` | `lib.sh:2038-2046, 2049-2064, 2160-2174` |
|
||||||
|
| `lib_py/delegation/cli.py submit` | `--prompt` 필수 | `--prompt`/`--prompt-file` 상호 배타, register 로 그대로 전달 | `cli.py:29-33, 64-66` |
|
||||||
|
| 옛 셸 러너 `MAM_LOOP_PY=0` | `delegate_job_safe … --prompt "<text>"` (함수 인자) | 함수 안에서 `--prompt` 를 `.mam/prompt-shloop-XXXXXX` + `--prompt-file` 로 바꿔 래퍼 exec | `run_loop.sh:196-221`. `run_loop.sh` 의 잔존 `--prompt "…"` 11곳은 이 **함수** 호출이라 exec 을 거치지 않는다 |
|
||||||
|
| `create_session.sh --submit-job` | 주입문에 과제 본문 포함 | 과제는 `<jobs>/<id>/brief.md` 에 쓰고 pane 에는 **경로만** | `create_session.sh:625-646` |
|
||||||
|
|
||||||
|
### 1-1. 래퍼의 파일 처리 (실측, `--dry-run --agent human`)
|
||||||
|
|
||||||
|
- 상대 `--prompt-file "my prompts/p 1.md"` 는 **호출자 cwd 기준**으로 절대화된다(`:191`, `cd "$WORKDIR"` 이전) — `--workdir` 가 다른 곳이어도 찾는다. 상대 `--registry-dir "reg dir"` 는 종전대로 `--workdir` 기준이다(기존 의미 유지). 공백 경로 모두 안전.
|
||||||
|
- 등록된 `prompt` 는 **바이트 단위로 동일**(`'line1\nline2\n\n'`, 후행 개행 보존) — registry 가 파일을 직접 읽기 때문. 래퍼의 `$PROMPT` 변수(`$(<file)`)는 후행 개행을 잃지만 그것은 브리프 본문에만 쓰인다(H4).
|
||||||
|
- 래퍼는 호출자의 파일을 **지우지 않는다**(소유권은 호출자). `--prompt` 와 `--prompt-file` 동시 → 거부, 없는 파일 → `cannot read --prompt-file <절대경로>` 거부.
|
||||||
|
- 읽는 시점: register 는 래퍼의 첫 단계(`:207`)이고 래퍼는 잡이 끝날 때까지 블록하므로, 러너·lib.sh·셸 러너가 **래퍼 반환 후** 지우는 순서는 안전하다.
|
||||||
|
|
||||||
|
### 1-2. 임시 파일의 위치·권한·정리
|
||||||
|
|
||||||
|
| 생성자 | 위치 | 권한 | 정리 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 러너 | 제어 루트 `.mam/` (`self.mam_dir`) | `NamedTemporaryFile` → 0600 | `finally: os.unlink` — 성공·실패·**OSError(exec 실패)** 모두 (`:498-502`) |
|
||||||
|
| lib.sh | `${WORKSPACE_ROOT}/.mam/` | `mktemp` → 0600 | 제출 뒤 `rm -f`; **쓰기 실패 시 `rm -f` 후 return 1** (`lib.sh:2042`) |
|
||||||
|
| 셸 러너 | `$MAM_REAL_ROOT/.mam/` | `mktemp` → 0600 | 래퍼 반환 뒤 `rm -f`; 쓰기 실패 시 제거 + 원인 로그 + return 1 (`:201-205`) |
|
||||||
|
| create | `<jobs>/<id>/brief.md` (잡 디렉터리, 보존 대상) | umask | 실패 시 잡 `error` + exit 1 (`:632-637`) |
|
||||||
|
|
||||||
|
### H1 (비차단이지만 실제 결함, 재현) — 러너의 쓰기 실패는 부분 파일을 남기고 예외를 던진다
|
||||||
|
|
||||||
|
`runner.py:472-477` 의 `with NamedTemporaryFile(...) as fh: fh.write(p_text)` 는 `try` **바깥**에 있다. 파일이 만들어진 뒤 `write` 가 실패하면(ENOSPC·EIO) `finally` 의 `unlink` 에 닿지 않는다. 검증 경로의 러너를 in-process 로 띄우고 `write` 가 `OSError(28)` 을 내도록 감싼 결과:
|
||||||
|
|
||||||
|
```
|
||||||
|
RAISED: OSError [Errno 28] No space left on device ← delegate_job 밖으로 그대로 전파(트레이스백, 126 처리 아님)
|
||||||
|
leftover prompt files in .mam: ['prompt-loop-…-2ussg8e6.md'] ← 부분 파일 잔존
|
||||||
|
wrapper called: 0
|
||||||
|
```
|
||||||
|
|
||||||
|
같은 부류를 PR 은 `lib.sh:2042`(`|| { rm -f "$f"; return 1; }`)과 `run_loop.sh:201-205` 에서 고쳤고 S9 로 고정했지만, 러너에는 대응이 없고 테스트(S4/S4b)도 `subprocess.run` 의 OSError 만 다룬다. 브리프 항목 1 의 "쓰기 실패 시 부분 파일이 남지 않는지"에 대해 러너는 **아니오**다.
|
||||||
|
|
||||||
|
→ 권고: `with` 블록을 `try` 안으로 옮기고 `except OSError` 에서 `unlink` 후 `CompletedProcess(cmd, 126, "", "cannot write the prompt file: …")` 를 돌려주면 exec 실패와 같은 "실패 제출" 경로로 합쳐진다(3–4줄). `self.mam_dir.mkdir` 실패도 같은 블록에서 잡힌다.
|
||||||
|
|
||||||
|
## 2. 실패 처리 (항목 2)
|
||||||
|
|
||||||
|
- **exec 실패(OSError → rc 126)**: `_run_cmd` 가 `CompletedProcess(cmd, 126, "", "cannot start the wrapper: … (prompt N bytes, wrapper …)")` 를 돌려주므로 첫 시도·busy 재시도 모두 같은 "실패 제출" 경로로 들어간다. 재시도 실패 시 job id 가 없는 경우에도 이제 `log_error` 를 남긴다(`runner.py:525`, 이전에는 무언). `failed_submit_jobs` 처리는 바뀌지 않았다(id 가 있으면 등록, 없으면 `None` 반환 → 호출자가 `return 1`). S4/S4b 가 E2BIG·EAGAIN 으로 고정.
|
||||||
|
- **재심·중재 미등록 → rc 1**: `:1354-1357`, `:1376-1378`. 판정 잡이 등록조차 안 됐는데 "반론 기각"이나 "중재 없음"으로 흘러가던 것을 실패로 바꾼 것이라 맞다.
|
||||||
|
- **순응(compliance) 잡 `wait_for_job` 실패 → rc 1** (`:1392-1395`): 의도된 강화로 판단한다. 근거는 **정규 수정 잡과의 대칭**이다 — 일반 correction 잡도 `if not correct_jid or not self.wait_for_job(correct_jid): return 1` (`:1323-1326`) 로 끝난다. 순응 잡은 "반론이 기각됐으니 그대로 고쳐라"는 수정 잡의 변형이므로, 시간 초과·에러로 닫힌 잡을 두고 다음 리뷰 라운드로 가면 **바뀌지 않은 트리를 다시 리뷰**하게 된다. 루프가 `wait_for_job` 실패를 통과시키는 자리는 이제 self-review(`:1187`)뿐이고 그것은 판정 리포트로 따로 걸러진다. S3c 가 세 경로(comply/adjudication/arbitration)의 미등록을 고정한다.
|
||||||
|
|
||||||
|
## 3. `MAM_DIFF_MAX_BYTES`/`MAM_DIFF_MAX_LINES` (항목 3)
|
||||||
|
|
||||||
|
- `diff_collect.sh:9-11` 주석이 "증거 크기의 상한이지 전달 상한이 아니다"를 명시하고, 상한값·잘림 마커·`runner.py` 의 l15 규칙(잘린 diff 면 diff-only 원격 리뷰어 거부, 로컬 진행)은 **변경되지 않았다**(runner 의 해당 블록은 diff 밖). S5 가 "옛 사각지대"(131,072 < diff < 200,000 바이트)의 diff 가 통째로 전달됨을 고정한다.
|
||||||
|
- **H2 (비차단, 문서)**: loop SKILL.md 에는 이 두 변수도, 잘림 마커의 의미도 적혀 있지 않다(grep 0건). delegate-job SKILL.md 는 argv 상한만 말한다. "전달은 무제한, 증거는 200 KB/4,000 줄에서 잘리며 잘린 diff 는 diff-only 원격 리뷰어에게 증거가 못 된다"는 한 문단이 loop SKILL.md 에 있어야 항목 3 의 "일관 반영"이 완성된다.
|
||||||
|
|
||||||
|
## 4. `create_session.sh` (항목 4)
|
||||||
|
|
||||||
|
- 잡 등록은 종전대로 lib.sh 의 `delegate_submit_job`(파일 경유)이고, 그 뒤 `<jobs>/<id>/brief.md` 에 과제를 쓰고 pane 에는 `Task: read the complete task in <절대경로> and follow it.` 만 주입한다(`:625-646`). `$pub`(`delegate_publish_cmd` 의 `--expect-dispatch-token`·`--attempt-id`)와 `started` 기반 판정은 **손대지 않았다** — #23 계약 유지. S8 이 주입문에 brief 경로는 있고 과제 본문은 없음, `--expect-dispatch-token`·`--event started` 가 남아 있음을 단언한다.
|
||||||
|
- 경로: `${MAM_JOBS_DIR:-${WORKSPACE_ROOT:-$PWD}/.mam/jobs}` 를 절대화하므로 워크트리 creator(제어 루트 바인딩)도 자기 cwd 와 무관하게 읽을 수 있다. 래퍼가 만드는 `brief.md` 와 같은 자리다.
|
||||||
|
- 실패 시 잡 `error` + `JOB_TERMINALIZED=1` + exit 1 → 기존 트랩이 세션을 되돌린다(초기화 실패와 같은 취급).
|
||||||
|
|
||||||
|
## 5. 새 테스트 22건 (항목 5)
|
||||||
|
|
||||||
|
- **실제 경로를 지나는가**: S1 은 실제 래퍼(`bash multi-agent-mux-delegate-job submit --dry-run`)와 실제 `registry.py` 로 140,000 바이트를 등록해 **바이트 단위 동일**을 단언한다. S2/S3/S3b/S3c 는 `python -m lib_py.loop` 를 하위 프로세스로 띄우고 가짜 래퍼가 받은 인자를 검사한다. S7/S7b/S9 는 `run_loop.sh`(MAM_LOOP_PY=0)를 실제로 돈다. S8 은 mock herdr 위에서 `create_session.sh` 를 실제로 돈다. mock 으로 우회하는 것은 S4/S4b(`subprocess.run` 을 OSError 로 대체 — 커널 대신 코드 경로를 검증하는 것이 목적)뿐이다.
|
||||||
|
- **macOS**: `MAX_ARG_STRLEN` 이 없어 단일 인자 150 KB 가 통과하므로 S1b(음성 대조군)는 `linux_only` 로 **skip** 되고, S8 의 `strace` 검사는 Linux 에서만 붙는다. 즉 이 호스트에서 스위트는 **커널 상한이 아니라 코드 경로**(파일로 전달됐는가)를 검증한다 — 브리프의 우려 그대로이며, 설계상 옳다. 실제 상한 재현은 Linux CI 몫.
|
||||||
|
- **격리**: `_env()` 가 `HOME`·`WORKSPACE_ROOT`·`DELEGATE_JOB_LOGS_DIR` 를 `tmp_path` 로, 브로커를 `127.0.0.1:9` 로 고정하고 `MAM_JOBS_DIR`/`MAM_REAL_ROOT` 를 제거한다. 실제 `.mam`·홈 접촉 없음(§6 실측).
|
||||||
|
- **수정 없이는 실패하는가 — 확인함**: 검증 경로 사본에서 PR 이 바꾼 소스 8파일을 `1debc7d` 판으로 되돌리고 새 파일을 돌린 결과 **18 failed / 3 passed / 1 skipped(S1b)**. 통과 3건은 정정 전에도 성립하던 것(래퍼 실패 → rc 1 등)이다.
|
||||||
|
|
||||||
|
## 6. 검증 경로에서의 테스트 실행 (항목 5·6)
|
||||||
|
|
||||||
|
```
|
||||||
|
tests/test_issue36_prompt_transport.py tests/test_issue5_phase1_delegation.py tests/test_issue14_delivery_ack.py
|
||||||
|
tests/test_loop_cli.py tests/test_issue21_loop_b.py tests/test_issue21_acceptance.py tests/test_issue23_24_startup_path.py
|
||||||
|
tests/test_issue25_plan_reuse.py
|
||||||
|
→ 1 failed, 237 passed, 1 skipped in 422.71s (0:07:02)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **skip 1**: S1b(`linux_only`) — 설계된 것(§5).
|
||||||
|
- **failed 1: S9 — 코드 결함이 아니라 테스트의 macOS 이식성 문제(H5).** S9 는 `ulimit -f 1` 로 쓰기를 실제로 실패시킨 뒤 세 가지를 단언한다: rc≠0, stderr 에 `File too large`, 부분 파일 없음 + 원인 문구. 같은 하네스를 scratchpad 에서 직접 돌린 결과 **rc=1, 부분 파일 없음, `delegate_job_safe: cannot write the prompt file` 출력** — 즉 PR 이 고친 동작(제거 + 원인 표시)은 macOS 에서도 성립한다. 실패한 것은 두 번째 단언뿐이다: macOS 의 `/bin/bash` 3.2 는 `printf` 의 쓰기 실패에 대해 `printf: write error: File too large` 문자열을 내지 않는다(Linux bash 5 의 형식). `strerror(EFBIG)` 자체는 macOS 에서도 "File too large" 이므로 순수하게 bash 버전 차이다. S1b 처럼 `linux_only` 로 두거나, 그 단언을 빼고 원인 문구 단언만 남기면 된다.
|
||||||
|
- 기존 계약 스위트(issue5 phase1 의 registry CLI 스냅샷·help 캡처, issue14, loop_cli, issue21 loop B·acceptance, issue23/24 S7*, issue25)는 **전부 통과** — `--prompt` 하위 호환과 온보딩 계약이 유지된다(항목 4·6).
|
||||||
|
|
||||||
|
### 격리·위생 실측
|
||||||
|
|
||||||
|
| 항목 | 결과 |
|
||||||
|
|---|---|
|
||||||
|
| 실제 `.mam/jobs` / `.mam/delegate_job_logs` 전후 | **차이 0 / 차이 0** |
|
||||||
|
| 실제 `.mam` 의 `prompt-*` 잔존 | **0** |
|
||||||
|
| 검증 경로 `.mam` 의 `prompt-*` 잔존 | **0** (러너·셸 러너·lib.sh 의 정리가 실제 실행에서도 성립) |
|
||||||
|
| 검증 경로 tracked 파일 | 변화 없음(HEAD `59be1c8`), 본 체크아웃 `main 1debc7d` |
|
||||||
|
| 실행 시간 | 새 파일 22건은 34 s 안팎(되돌린 사본 기준 34.5 s); 전체 7 분은 issue14/18 계열의 벽시계 시험 몫 |
|
||||||
|
|
||||||
|
## 7. 문서 (항목 7)
|
||||||
|
|
||||||
|
- delegate-job SKILL.md `:44-48`: `--prompt-file` 의 의미와 argv 상한, "루프는 항상 파일을 쓴다" 명시 ✓. 래퍼 usage(`:112-121`)도 갱신 ✓.
|
||||||
|
- loop SKILL.md `:267`: 파이프라인에서의 종료 코드(`pipefail`/`PIPESTATUS`) 안내 ✓. 그러나 `MAM_DIFF_MAX_*` 의 새 의미는 없다(H2).
|
||||||
|
- `.claude/skills`: 본 체크아웃의 것은 git 에 추적되지 않는 로컬 심링크(`../../.agents/skills/*`)라 자동 동기화된다 — 배포 사본 정합 문제 없음.
|
||||||
|
|
||||||
|
## 8. 병합 차단 여부 (항목 8)
|
||||||
|
|
||||||
|
버전 락스텝 bump 부재(`lib.sh:32` `4.7.2`, 오케스트레이터가 4.7.3 으로 처리 예정) 외에 **병합을 막을 결함은 없다**. 다만 H1 은 릴리스 커밋에 함께 넣거나 즉시 후속으로 닫기를 권한다 — 셸 쪽과 같은 부류이고 수정이 세 줄이다.
|
||||||
|
|
||||||
|
| # | 지적 | 성격 | 근거 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| H1 | 러너의 프롬프트 파일 **쓰기** 실패 시 부분 파일 잔존 + 예외 전파(126 실패 제출로 합류하지 않음) — 재현 | 비차단(경계 상황), **후속 권고 1순위** | `runner.py:472-477` vs `:490-502`; §1 H1 |
|
||||||
|
| H2 | `MAM_DIFF_MAX_*` 의 "증거 상한, 전달 무제한" 의미가 loop SKILL.md 에 없음 | 문서 | `diff_collect.sh:9-11` 에만 있음 |
|
||||||
|
| H3 | 래퍼는 **빈** `--prompt-file` 을 받아들여 빈 프롬프트를 등록한다(실측) | 사소 | `multi-agent-mux-delegate-job:195` 의 `-n "$PROMPT" \|\| -n "$PROMPT_FILE"` |
|
||||||
|
| H4 | 래퍼의 `$PROMPT="$(<file)"` 는 후행 개행을 잃는다 — registry 의 프롬프트(파일 그대로)와 브리프 본문이 그 점에서 다를 수 있음 | 사소 | `:193` |
|
||||||
|
| H5 | S9 의 `File too large` 단언이 macOS bash 3.2 에서 실패(문자열 형식 차이); 실질 단언 2개는 통과 | 테스트 이식성 | §6 |
|
||||||
|
|
||||||
|
## 9. 교차검증 가이드
|
||||||
|
|
||||||
|
```bash
|
||||||
|
RP=/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20260924T080014Z-2b3df0/1
|
||||||
|
PY=/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.venv/bin/python
|
||||||
|
git -C "$RP" rev-parse HEAD 'HEAD^{tree}' # 59be1c8… / a009c90…
|
||||||
|
grep -rn -- '--prompt ' "$RP/.agents/skills" | grep -v '\.md:' # 래퍼 usage/기본값, cli 주석, run_loop.sh 의 함수 호출뿐
|
||||||
|
# H1 재현: 러너 in-process, NamedTemporaryFile.write 가 OSError(28) → .mam/prompt-* 잔존 + 예외 전파 (§1 H1 의 스크립트)
|
||||||
|
# S9 하네스 직접 실행(macOS): rc=1, 부분 파일 없음, "cannot write the prompt file" 출력, 단 "File too large" 문자열 없음
|
||||||
|
# 되돌린 사본: 소스 8파일을 1debc7d 판으로 → tests/test_issue36_prompt_transport.py → 18 failed / 3 passed / 1 skipped
|
||||||
|
```
|
||||||
|
|
||||||
|
[VERDICT: PASS]
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"loop_id": "loop-20261020T133532Z-a1684a",
|
||||||
|
"base_sha": "ff8b381520ae04e80c73a9e18bfad73d47e853c9",
|
||||||
|
"head_sha": "feccfd5e0356c8acf0438bc68bce7ea36ce2588e",
|
||||||
|
"tree_sha": "0f0f33cc22f5e492a0ddfccb6bac5be888c10d1a",
|
||||||
|
"review_commit": "feccfd5e0356c8acf0438bc68bce7ea36ce2588e",
|
||||||
|
"reviewer": "planner-reviewer-claude-01",
|
||||||
|
"job_id": "5bdb3a81",
|
||||||
|
"verdict": "PASS",
|
||||||
|
"transport": "herdr",
|
||||||
|
"evidence": "review-tree",
|
||||||
|
"isolation": "worktree",
|
||||||
|
"verified_at": "2026-10-20T15:51:16Z"
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
# Issue #40 리뷰 — Class A 세션 ID 자동 확정, agy 다른 작업 공간 ID 오등록 방지 (job 5bdb3a81)
|
||||||
|
|
||||||
|
- 리뷰어: planner-reviewer-claude-01
|
||||||
|
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261020T133532Z-a1684a/1`
|
||||||
|
- identity: HEAD `feccfd5e0356c8acf0438bc68bce7ea36ce2588e`, tree `0f0f33cc22f5e492a0ddfccb6bac5be888c10d1a` 가 일치한다. 작업 트리는 clean 이고 `.mam` 은 없다.
|
||||||
|
- 범위: base `ff8b381`..head 1커밋, 19개 파일. 판정은 base..head 전체에 대한 것이다.
|
||||||
|
- 기준 문서: 이슈 본문과 댓글 전체(`issue40.txt`)를 끝까지 읽고 합의 사항과 대조했다.
|
||||||
|
- 환경: macOS, Python 3.9.6.
|
||||||
|
- 실제 에이전트 홈은 `ls` 와 `lsof -p <agy pid>`(프로세스 테이블 조회)로만 봤다. 파일 자체를 연 적은 없다.
|
||||||
|
- 재현과 테스트는 모두 scratchpad 의 임시 HOME, mock herdr, git archive 사본에서 했다.
|
||||||
|
|
||||||
|
## 결론
|
||||||
|
|
||||||
|
합의된 설계(PR #41 리뷰·이슈 댓글)대로 구현됐고, 완료 조건 1-4 가 테스트로 고정됐다. 각 조건의 테스트는 base 에서 실패하고 head 에서 통과한다. 이 변경이 만든 회귀는 찾지 못했다. 남은 것은 테스트 보강 권고와 운영 위험 기록이다.
|
||||||
|
|
||||||
|
## 설계 확인 (합의 대비)
|
||||||
|
|
||||||
|
**어댑터는 사실과 판정만 제공한다.**
|
||||||
|
- `id_lifecycle`: 기본값은 `POST_DISCOVERED`. claude·grok 만 `PRE_ASSIGNED` 이다(`base.py:378-381`, `claude.py:188`, `grok.py:18`).
|
||||||
|
- `capture_active_session_id`(`base.py:383-401`): `discover()` 후보 가운데 `verify_session_uuid` 를 통과하고 다른 행이 기록하지 않은 것이 정확히 하나일 때만 `captured` 를 돌려준다. 그 밖에는 이유가 담긴 결과(`capture_result`)를 돌려준다. 읽기 전용이다.
|
||||||
|
- 설정 추가나 리팩터링은 없다.
|
||||||
|
|
||||||
|
**agy 경로 (`agy.py`)**
|
||||||
|
- `verify_artifact` discover 모드(`:97-106`): `last_conversations.json` 이 그 id 를 다른 cwd 에 매핑하면 거절한다(workspace_key 로 비교). 다른 행이 기록한 id 는 캐시가 일치해도 항상 거절한다. 이제 캐시 일치가 이 검사를 건너뛰지 못한다.
|
||||||
|
- `capture_active_session_id`(`:129-152`): 후보는 `last_conversations.json[cwd]` 하나뿐이다. 그 대화의 presence lock 이나 DB 를 그 행의 pane 프로세스 또는 그 자손이 열고 있어야 확정한다(`_owner_check`, `:154-183`, lsof/ps). 증명하지 못하면 확정하지 않는다.
|
||||||
|
- 이 근거를 실제로 확인했다. 이 Mac 에서 실행 중인 agy 3개(`--conversation <id>`)는 모두 자기 `presence/<id>.lock` 을 열고 있다(`lsof -p`).
|
||||||
|
- `_holder_pids`·`_process_parents` 를 macOS 에서 직접 확인했다. 아무도 안 연 파일은 `[]`, 연 파일은 보유 pid 를 돌려준다(0.13s). ps 표에는 자기 pid 가 들어 있다.
|
||||||
|
|
||||||
|
**reconcile drift C (agy·hermes·opencode·codex) 가 하나로 합쳐졌다** (`reconcile.sh:778-811`).
|
||||||
|
- 네 에이전트가 모두 `autopin.capture` 를 거쳐 같은 어댑터 판정을 쓴다. agy 의 `*.db` 전체 glob 은 사라졌다.
|
||||||
|
- 기존 안전장치는 그대로다: 후보가 둘 이상이면 C-ambiguous, 뷰포트가 다르면(rc 1) C-warn 으로 pin 안 함, 뷰포트 확인이 불가하면(그 밖의 rc) degraded pin.
|
||||||
|
- 다른 행이 가진 id 배제는 더 엄격해졌다. 예전에는 같은 cwd 의, 멈추지 않은, 같은 에이전트 행만 봤다. 지금은 다른 모든 행의 모든 own key 를 본다(`autopin.claimed_elsewhere`).
|
||||||
|
- 지운 hermes·opencode·codex SQL 은 각 어댑터의 `discover()` 와 같은 범위(cwd·epoch·`parent_id IS NULL`)다. codex 는 `CODEX_HOME` 을 따른다(`codex.py:65`).
|
||||||
|
|
||||||
|
**수신 측 처리기 (`lib_py/agents/autopin.py`)**
|
||||||
|
- 호출 지점:
|
||||||
|
- 래퍼: `finalize_unfinished` 뒤 `completed` 일 때(`multi-agent-mux-delegate-job:454-458`). stderr 로만 출력하므로 마지막 stdout 줄(audit-log 경로) 계약은 그대로다.
|
||||||
|
- `create --onboard` 의 completed 분기(`create_session.sh:754-756`)
|
||||||
|
- 루프 `wait_for_job` 직후의 서브프로세스(`runner.py:679, 690-705`, G9 경계를 지킴)
|
||||||
|
- 공통 헬퍼 `delegate_autopin_completed`(`lib.sh:2470-2480`)는 `|| true` 라 잡 결과와 종료 코드를 바꾸지 않는다. `set -u` 에서 쓰는 변수(`AGENT_SESSIONS_YAML`, `_disp_tok`, `DELEGATE_JOB_TOKEN`, `task_reg_dir`)는 모두 정의돼 있다.
|
||||||
|
- 중복 방지:
|
||||||
|
- 키는 `(job_id, dispatch_token, attempt_id)` 이다. 잡 디렉터리의 `autopin.lock`(flock)과 `autopin.json` 마커를 쓴다. 마커는 원자 교체로 쓴다.
|
||||||
|
- 쓰기는 `atomic_dump_yaml` 의 mutation 으로 한다. 이 모듈의 `_MUTATION` 을 `sys.modules[__name__]` 로 찾아 `python -m` 에서도 같은 객체를 쓴다. 쓸 것이 없으면 `SystemExit(0)` 으로 쓰지 않고 끝나며, `atomic_yaml.py:195` 의 finally 가 정리한다.
|
||||||
|
- 건너뛰는 잡: remote, human, completed 가 아닌 잡, 오래된 token. pre-assigned 와 이미 id 가 있는 행은 쓰기 없이 끝난다.
|
||||||
|
- 메타데이터: pin 은 `session_id_source=discovered`, `session_id_verified=true` 를 함께 기록한다. reconcile 의 `_pin_and_verify_resume` 도 같은 `pin_row` 를 쓴다. 이슈 댓글이 짚은 불일치(선택 항목)가 해결됐다.
|
||||||
|
- publish_event.py 는 바뀌지 않았다. 에이전트는 제어 평면에 쓰지 않는다(`test_call_sites_and_publisher_boundary`).
|
||||||
|
- 잠금 안에서 뷰포트 확인과 resume `--dry-run` 을 서브프로세스로 실행한다. 예전 reconcile 도 mutation 안에서 똑같이 했다. `load_state_json` 은 YAML flock 없이 SQLite 를 읽으므로 교착은 없다.
|
||||||
|
|
||||||
|
## 완료 조건별 테스트 (base 실패 / head 통과)
|
||||||
|
|
||||||
|
`tests/test_issue40_auto_pin.py`(27개)를 base 사본에 넣어 실행하면 **16 failed, 13 errors**(errors 는 base 에 없는 `agy._holder_pids` 를 패치하는 fixture 때문), **41 passed**(issue64 포함) 이다. head 에서는 issue40+issue64 **70 passed**.
|
||||||
|
|
||||||
|
| 조건 | 테스트 | base 결과 |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | `test_agy_verify_rejects_id_cached_for_another_workspace` | `True is False` |
|
||||||
|
| 1 | `test_reconcile_does_not_pin_another_workspaces_agy_conversation` | `'b4000002…' is None` — 실제로 B 의 id 를 A 행에 기록함 |
|
||||||
|
| 1 | `test_autopin_does_not_pin_…` | — |
|
||||||
|
| 1 | issue64 `:407` 단언 | `is False` 로 바뀜 |
|
||||||
|
| 2 | `test_completed_onboarding_pins_agy_without_daemon`(MAM_NO_SUBSCRIBER_DAEMON=1, B 가 더 늦게 갱신) | — |
|
||||||
|
| 2 | `test_completed_first_job_pins_opencode_codex_hermes`[3] | 실패 |
|
||||||
|
| 2 | `test_reconcile_pins_agy_owned_by_the_pane`, `test_lib_sh_helper_pins_through_the_cli`, `test_create_onboard_pins_without_daemon` | 실패 |
|
||||||
|
| 3 | `test_wrapper_and_loop_recovery_pin_once`(capture 1회, YAML mtime 불변), `test_loop_recovery_pins_when_the_wrapper_did_not` | 실패 |
|
||||||
|
| 3 | `test_ambiguous_candidates_leave_reason_without_pin`(행에 `autopin ambiguous (job amb): 2 candidates`) | 실패 |
|
||||||
|
| 4 | `test_preassigned_and_existing_ids_are_untouched`, `test_ineligible_jobs_pin_nothing`, `test_stale_dispatch_token_pins_nothing` | — |
|
||||||
|
|
||||||
|
- reconcile 테스트는 세션을 mock herdr state 에 running 으로 등록한다(`_mock_agent`). 그래서 drift A 에서 멈추지 않고 실제로 drift C 까지 간다. base 에서 B 의 id 가 기록되는 것이 그 증거다.
|
||||||
|
|
||||||
|
뮤테이션: git archive 사본에서 issue40 모듈만 돌렸다.
|
||||||
|
|
||||||
|
| 뮤테이션 | 결과 |
|
||||||
|
|---|---|
|
||||||
|
| 다른 cwd 거절 제거 | 1 failed |
|
||||||
|
| 중복 마커 무시 | 2 failed |
|
||||||
|
| 모호 판정 제거 | 1 failed |
|
||||||
|
| agy 소유 확인 제거 | 4 failed(같은 cwd 형제 교차 pin 포함) |
|
||||||
|
| 이미 id 가 있는 행 보호 제거 | 1 failed |
|
||||||
|
| remote·human 제외 제거 | 2 failed |
|
||||||
|
| 래퍼 호출 제거 | 1 failed(정적 검사만 잡음) |
|
||||||
|
| **pre-assigned 보호 제거** | **실패 0** (아래 T1) |
|
||||||
|
|
||||||
|
## 관련 테스트 묶음
|
||||||
|
|
||||||
|
- 조건: `env -i`, 임시 HOME, `XDG_DATA_HOME=<scratchpad>`, basetemp 는 scratchpad.
|
||||||
|
- 대상 18개 모듈: issue40, issue64, a4, issue10_13, issue31, issue50_dispatch, issue55, issue57, issue60_linux_fixes, issue60_opencode_v2, tier1, tier2, uuid_target, orc_onboard, issue21_phase2_loop_a, issue5_phase3_loop, issue21_worktree_prereqs, issue17_workspace_root_isolation.
|
||||||
|
- 결과: **577 passed, 1 skipped, 3 failed**, 18분.
|
||||||
|
- 실패 3개(`test_issue21_phase2_loop_a::test_w10_…[install.sh|install_mam.sh]`, `test_w11_…`)는 환경 탓이다.
|
||||||
|
- 설치 스크립트가 시스템 python3 의 PyYAML 을 찾는데, 이 Mac 의 PyYAML 은 사용자 site(`~/Library/Python/3.9/...`)에만 있다. 내가 HOME 을 임시로 바꿔서 보이지 않았다.
|
||||||
|
- base 사본에서도 같은 3개가 같은 이유로 실패하고, 설치 스크립트는 이 diff 에 없다. 판정 근거로 쓰지 않았다.
|
||||||
|
- skip 1개: PATH 에 opencode 가 없는 실바이너리 계약 테스트.
|
||||||
|
- 부작용 확인:
|
||||||
|
- 실제 `~/.gemini/antigravity-cli/conversations`·`~/.hermes`·`~/.local/share/opencode` 목록은 전후가 같았다.
|
||||||
|
- scratchpad XDG 디렉터리는 실행 뒤에도 비어 있었다.
|
||||||
|
- #55 가드 오탐은 없었다.
|
||||||
|
- 린트:
|
||||||
|
- `git diff --check` 는 깨끗하다.
|
||||||
|
- 바뀐 셸 4개(lib.sh, create_session.sh, 래퍼, reconcile.sh)가 `/bin/bash -n`(3.2)을 통과한다.
|
||||||
|
- 바뀐 Python 은 3.9 로 파싱된다. `/usr/bin/python3` 로 `lib_py.agents.autopin`·`lib_py.loop.runner` 를 import 할 수 있다.
|
||||||
|
|
||||||
|
## 비차단
|
||||||
|
|
||||||
|
- **T1. pre-assigned 보호(`autopin.capture` 의 `id_lifecycle != POST_DISCOVERED`)는 테스트로 고정되지 않았다.**
|
||||||
|
- 테스트의 claude·grok 행은 이미 id 가 있어서 `already-set` 에서 먼저 끝난다.
|
||||||
|
- id 가 비어 있는 claude 행이 생기면(예: 행을 손으로 고침), 이 보호 없이는 기본 capture 가 claude `discover()` 로 남의 transcript 를 pin 할 수 있다.
|
||||||
|
- 권고: `claude_session_id_own: None` 인 claude 행에서 `pre-assigned` 가 나오는지 확인하는 한 줄을 추가한다.
|
||||||
|
- **T2.** 래퍼 진입점은 정적 검사(`test_call_sites_…`)로만 고정됐다. 헬퍼(`lib.sh`)와 CLI, create, 루프는 실행으로 검증됐다.
|
||||||
|
- **R1. agy 의 pin 은 이제 세 가지에 기댄다**(reconcile drift C 도 같다):
|
||||||
|
- (a) lsof·ps 가 있어야 한다
|
||||||
|
- (b) 행에 pane pid 가 있어야 한다
|
||||||
|
- (c) agy 가 presence lock 을 열고 있어야 한다
|
||||||
|
- (c) 는 resume 된 agy 3개에서 확인했다. 새로 띄운 agy 의 첫 턴 직후는 실제 agy 를 띄울 수 없어 확인하지 못했다.
|
||||||
|
- lsof 가 없는 Linux 에서는 agy 가 영영 pin 되지 않는다. 예전 glob 경로는 pin 했다(오등록 위험을 감수하고). autopin 경로는 행에 `autopin unverifiable-owner … lsof is unavailable` 을 남기지만, reconcile 은 아무것도 남기지 않는다.
|
||||||
|
- 설계상 의도된 안전 쪽 선택이다. 그래도 Linux 의존성으로 문서에 한 줄 적어 두기를 권한다.
|
||||||
|
- **R2.** 중복 마커는 실패 결과도 기록한다. 래퍼 시점에 `no-candidate` 였던 잡은 루프 복구 경로에서 다시 시도되지 않는다. 다음 잡의 completed 나 reconcile 이 다시 시도한다. 합의("job/token/attempt 단위로 중복 금지")와 맞다.
|
||||||
|
- **범위 밖(보고만).** stop 의 캡처 경로는 정리하지 않았다(이슈가 "크면 하지 말고 보고만"이라고 함).
|
||||||
|
|
||||||
|
[VERDICT: PASS]
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"loop_id": "loop-20261008T234013Z-4f5d21",
|
||||||
|
"base_sha": "97c2946ee0e11eabcecafe6407755c33c0289218",
|
||||||
|
"head_sha": "a93c9bf29279f65ce0b9c9fb2c1aeea90a659420",
|
||||||
|
"tree_sha": "741c19ba4c17601768b06684d5843d218a72beb1",
|
||||||
|
"review_commit": "a93c9bf29279f65ce0b9c9fb2c1aeea90a659420",
|
||||||
|
"reviewer": "planner-reviewer-claude-01",
|
||||||
|
"job_id": "616578e7",
|
||||||
|
"verdict": "PASS",
|
||||||
|
"transport": "herdr",
|
||||||
|
"evidence": "review-tree",
|
||||||
|
"isolation": "worktree",
|
||||||
|
"verified_at": "2026-10-09T00:45:03Z"
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# 리뷰 리포트 — Job 616578e7 (loop-lock: 테스트가 체크아웃의 루프 잠금을 잡지 않게, 97c2946..a93c9bf)
|
||||||
|
|
||||||
|
- 리뷰어: planner-reviewer-claude-01 (claude)
|
||||||
|
- 리뷰 identity: loop-20261008T234013Z-4f5d21 / base 97c2946 / head a93c9bf / tree 741c19b — 검증 경로에서 `git rev-parse HEAD 'HEAD^{tree}'` 일치, 미커밋 변경 없음
|
||||||
|
- 범위: 커밋 1개(+55줄), 테스트 3개 파일: test_o3_scoped_guard.py 의 b13 두 곳에 `MAM_LOOP_MARKER=tmp_path/...`, 새 tests/test_loop_marker_isolation.py(L1), test_issue17_workspace_root_isolation.py W1 에 `.mam` 부재 단언 1줄. 제품 코드(.agents/skills)·버전·VERSIONS 변경 없음(브리프대로).
|
||||||
|
|
||||||
|
## 결론 요약
|
||||||
|
할 일 1~4 가 요구대로이고, 내 독립 조사(grep + 사본 트리 실행)로도 누락된 실행 지점이 없다. 잠금 동작을 시험하는 테스트들의 marker 경로는 바뀌지 않았다. 차단 결함 없음.
|
||||||
|
|
||||||
|
## 1. 조사(1) — 직접 grep 한 결과 (run_loop.sh 실행 지점 전수)
|
||||||
|
run_loop.sh 는 :15 `MAM_REAL_ROOT=${MAM_REAL_ROOT:-$REPO_ROOT}`, :110 `--help` 와 :66-141 인자 오류는 :181 의 marker 결정·:248 `mam_acquire_loop_lock` **이전**에 종료, loop_lock.sh:66 이 부모 디렉터리를 mkdir -p. Python `LoopRunner` 는 잠금을 잡지 않는다(runner.py:1224-1226 은 release 뿐, acquire 는 run_loop.sh:248 에만 있음).
|
||||||
|
|
||||||
|
| 실행 지점 | 분류 | 근거 |
|
||||||
|
|---|---|---|
|
||||||
|
| test_o3_scoped_guard.py:388, :472 (b13 두 테스트) | **대상 → 수정됨** | 체크아웃 run_loop.sh, `--creator nonexistent-agent --task …`, cwd=REPO_ROOT; 이제 `MAM_LOOP_MARKER=tmp_path/loop-guard-active`(:390, :470) |
|
||||||
|
| test_issue5_phase3_loop.py:166, :184 | 이미 지정 | :163 `MAM_LOOP_MARKER=tmp_path/loop-marker` |
|
||||||
|
| test_loop_cli.py:52 (`_run_loop`) | 이미 지정 | env_extra 에 `MAM_LOOP_MARKER=tmp_path/loop-guard` |
|
||||||
|
| test_o2_race_free_lock.py:203 (o2_12) | 이미 지정(잠금 경합 자체를 시험) | :202 `MAM_LOOP_MARKER=marker(tmp_path)` — 의도 유지 |
|
||||||
|
| test_issue25_plan_reuse.py:367 (p7b) | 이미 지정 | `MAM_LOOP_MARKER=tmp_path/loop.marker` |
|
||||||
|
| test_issue21_loop_b.py:239, test_issue21_worktree_prereqs.py:405 | 이미 지정 | WORKSPACE_ROOT=tmp + `MAM_LOOP_MARKER` |
|
||||||
|
| test_issue21_acceptance.py:298, test_issue36_prompt_transport.py:540, test_b7_diff_untracked.py:189, test_tier4_e2e.py:285 | 복사본 트리의 run_loop.sh(`main`/`ctl`/`base`/`tmp_path`) | 체크아웃이 아님(acceptance 는 marker 도 지정) |
|
||||||
|
| test_issue5_phase4_runtime.py:132-146 (p4_03), test_issue25:355, test_issue5_phase3:151, test_o1_rebuttal.py:72 | `--help` → :110 에서 종료 | 잠금 전 |
|
||||||
|
| test_o1_rebuttal.py:76 (`--max-rebut abc`) | 인자 오류 → 잠금 전 종료 | :66-98 |
|
||||||
|
| test_o3 Z-7/Z-14(:137, :155), test_deploy_freshness:473, test_issue5_phase0_contracts:481, test_o1:24/:166 | 소스 텍스트 읽기만 | 실행 없음 |
|
||||||
|
| LoopRunner in-process(issue18, issue21_loop_b, issue25, issue30, issue33, issue34, issue36, issue6, phase4 등) | 잠금 미획득 | 위 runner.py 근거; 해당 없음 |
|
||||||
|
|
||||||
|
경험적 확인(W1 방식): HEAD 트리 사본에서 `WORKSPACE_ROOT=<사본>` 으로 브리프 후보 15개 모듈을 **파일 전체** 단위로 실행하고 매번 `.mam` 확인 — **15개 모두 clean**(b7 20, deploy_freshness 13+18 skip, issue21_acceptance 10, issue21_loop_b 34, worktree_prereqs 35, issue25 17, issue36 23+1 skip, phase0 41, phase3 12, phase4 5, loop_cli 9, o1 10, o2 22, o3 32, tier4_e2e 5 — 모두 passed). 놓친 실행 지점 없음.
|
||||||
|
|
||||||
|
## 2. 할 일 2·3 — 수정 방식과 회귀 테스트
|
||||||
|
- 대상이 한 모듈의 두 테스트뿐이라 conftest fixture 대신 각 호출에 env 지정 — 브리프의 "검토" 요건에 맞는 최소 변경. o2 의 잠금 경합·o3 의 marker 검사(mam_sandbox 아래 marker)는 손대지 않았다.
|
||||||
|
- L1(test_loop_marker_isolation.py:36-50)은 W1 과 같은 방법(체크아웃 복사 → 7개 노드 실행 → `.mam` 부재)이며 NODES 는 위 표의 "잠금까지 가는" 7개와 정확히 일치한다. W1 에도 `.mam` 디렉터리 자체의 부재 단언이 추가돼(:71) 잠금 디렉터리 생성이 두 테스트에서 잡힌다.
|
||||||
|
- **수정 전 코드에서 실패 확인**: L1 자체는 `git ls-files` 가 필요해 `git archive` 사본에서는 못 돌리므로, L1 의 방법을 base 트리 사본 안에서 직접 재현했다 — 7개 노드를 `WORKSPACE_ROOT=<base 사본>` 으로 실행(7 passed) → 사본에 **`.mam/` 생성**(L1 의 마지막 단언이 실패하는 조건). 노드별로는 **b13 두 테스트만** 생성하고 나머지 5개는 clean — 테스트 머리말의 "The first two took the checkout's lock at 97c2946" 와 일치. HEAD 에서는 (a)(b) 모두에서 L1·W1 통과.
|
||||||
|
|
||||||
|
## 3. 할 일 4 — 실행 (검증 경로 워크트리, 워크트리 .venv Python 3.9.6, `env -i` 로 HERDR_*·MAM_*·MQTT_* 제거)
|
||||||
|
| 실행 | 결과 |
|
||||||
|
|---|---|
|
||||||
|
| (a) WORKSPACE_ROOT 없이: test_issue17_workspace_root_isolation, test_loop_marker_isolation, test_o3_scoped_guard, test_o2_race_free_lock | **57 passed / 0 failed** (45 s); 실행 뒤 워크트리에 `.mam/` **없음** |
|
||||||
|
| (b) WORKSPACE_ROOT=<워크트리 밖 센티널>: 같은 4개 파일 | **57 passed / 0 failed** (45 s); 센티널 **비어 있음**; 워크트리에 `.mam/` 없음 |
|
||||||
|
- 본 체크아웃의 실제 잠금 `.mam/loop-guard-active` mtime 전후 동일(1791502813) — 이 리뷰 중 본 체크아웃에서는 아무것도 실행하지 않았다. 실제 .mam/jobs 항목 수 불변. 브로커 bind 제약 없음.
|
||||||
|
- 이식성: Python 테스트 코드만(3.9 문법). L1 은 nested pytest + `git ls-files`, 이 Mac 에서 통과.
|
||||||
|
|
||||||
|
## 4. 비차단
|
||||||
|
- L1·W1 둘 다 노드 목록을 수동 유지한다(이전 리뷰의 같은 지적). run_loop.sh 를 잠금까지 실행하는 테스트를 새로 추가하면 L1 NODES 에도 넣어야 한다.
|
||||||
|
- L1 은 `_copy_checkout` 을 W1 모듈에서 import 하므로 두 테스트 파일이 결합돼 있다 — 작동에는 문제없고, 공용 헬퍼를 conftest 로 옮길지는 취향 문제.
|
||||||
|
- 새 관찰 없음.
|
||||||
|
|
||||||
|
[VERDICT: PASS]
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"loop_id": "loop-20261013T001819Z-131fa6",
|
||||||
|
"base_sha": "8b0b4a07dc76a4915895bdde6170dc8eed9b5f74",
|
||||||
|
"head_sha": "325b056057fcdd717d2c81017fc5ef57183cb4d0",
|
||||||
|
"tree_sha": "7905896f005159d596e3872d0ee2c3c12061f09a",
|
||||||
|
"review_commit": "325b056057fcdd717d2c81017fc5ef57183cb4d0",
|
||||||
|
"reviewer": "planner-reviewer-claude-01",
|
||||||
|
"job_id": "63919c5d",
|
||||||
|
"verdict": "PASS",
|
||||||
|
"transport": "herdr",
|
||||||
|
"evidence": "review-tree",
|
||||||
|
"isolation": "worktree",
|
||||||
|
"verified_at": "2026-10-13T00:57:46Z"
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# 테스트의 실제 herdr 서버 누출 수정 리뷰 (job 63919c5d)
|
||||||
|
|
||||||
|
- 리뷰어: planner-reviewer-claude-01 (Reviewer)
|
||||||
|
- 리뷰 identity: loop-20261013T001819Z-131fa6 / base 8b0b4a07 .. head 325b0560 (커밋 2개: 525df2b 수정, 325b056 s11 기준 이동 / 4 파일) / tree 7905896f
|
||||||
|
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261013T001819Z-131fa6/1` — HEAD·tree 가 identity 와 일치, `git status --porcelain` 0 줄(시작·종료 시), `.mam` 디렉터리도 생기지 않음. 본 체크아웃 HEAD 8b0b4a0(main) 변동 없음.
|
||||||
|
- 실행 안전: 내 pane 은 `HERDR_SOCKET_PATH`·`HERDR_PANE_ID`·`HERDR_TAB_ID`·`HERDR_WORKSPACE_ID`·`HERDR_SESSION_NAME`·`HERDR_SESSION`·`HERDR_CONFIG_PATH`·`HERDR_BIN_PATH`·`HERDR_ENV` 를 물려받고 있어, 모든 테스트·탐침은 `env -i`(PATH/HOME/LANG/TMPDIR 만)로 돌려 브리프의 `-u` 목록을 포함한 전부를 비웠다. basetemp 는 세션 scratchpad 아래(경로에 "shim" 없음). 실제 바이너리를 부른 것은 일회용 HOME 에서의 `status server`(읽기 전용, 서버 미기동)뿐이다. Python 은 본 체크아웃 `.venv`(3.9.6, paho 2.1.0). `.mam.env` 는 읽지 않았다.
|
||||||
|
|
||||||
|
## 1. 원인 판정이 맞는가
|
||||||
|
|
||||||
|
**원인 1 (조상 `*-shim` 이 mock 을 가림).** base 의 `_is_shim_path` 는 `*-shim/*` 를 `"/$1/"` 전체에 대해 매칭해 조상 디렉터리까지 걸렀고(`lib.sh` base), 생성 shim 의 `_resolve_real_herdr` 도 `[[ "$dir" != *"-shim"* ]]` 로 PATH 항목 문자열 어디든 걸렀다. basetemp `pytest-pr56-shim` 아래 mock `bin/` 은 둘 다에서 건너뛰어졌고 다음 후보인 `/opt/homebrew/bin/herdr` 가 "real" 이 된다. 수정 전 lib.sh 로 되돌린 scratch 복사본에서 L1 이 정확히 그 상태로 실패했다: `_resolve_real_herdr_path` 가 `…/pytest-x-shim/case0/bin/herdr` 대신 다음 PATH 항목의 디코이 `…/decoy/bin/herdr` 를 골랐다(`assert '/…/decoy/bin/herdr' == '/…/case0/bin/herdr'`). 판정 맞음.
|
||||||
|
|
||||||
|
**원인 2 (pane 의 `HERDR_SOCKET_PATH`).** 실제 herdr 0.9.3 바이너리를 일회용 HOME 에서 `status server` 로 읽기만 해 확인했다:
|
||||||
|
|
||||||
|
| 환경 | 바이너리가 가리킨 소켓 |
|
||||||
|
|---|---|
|
||||||
|
| `HERDR_SOCKET_PATH=/nonexistent/s.sock` + `--session t-zz` | `/nonexistent/s.sock` |
|
||||||
|
| `HERDR_SOCKET_PATH=/nonexistent/s.sock` + `HERDR_SESSION=t-zz` | `/nonexistent/s.sock` |
|
||||||
|
| `HERDR_SESSION=t-zz` 만 | `$HOME/.config/herdr/sessions/t-zz/herdr.sock` (가짜 HOME 아래) |
|
||||||
|
| `HERDR_SOCKET_PATH` 만 | `/nonexistent/s.sock` |
|
||||||
|
|
||||||
|
즉 `HERDR_SOCKET_PATH` 는 **`--session` 보다도** 우선한다. shim 이 항상 `--session <샌드박스 서버>` 를 붙여도 이 변수가 남아 있으면 실제 서버로 간다 — 원인 1 로 실제 바이너리가 선택되는 순간 샌드박스 HOME 은 아무 보호가 되지 못했다는 오케스트레이터의 실측과 일치한다. 두 원인은 독립적이고 둘 다 막아야 한다(1 만 고치면 pane 변수 누출 시 다른 경로의 실제 호출이 여전히 실제 서버로, 2 만 고치면 숨은 mock 대신 실제 바이너리가 가짜 HOME 에 서버를 띄움). 판정 맞음.
|
||||||
|
|
||||||
|
## 2. 수정 내용 검토
|
||||||
|
|
||||||
|
- `lib.sh:113-124 _is_shim_path`: 첫 `case` 에서 `*-shim/*` 만 빠지고 `.mam/shim`·`$_HERDR_SHIM_DIR_PATTERN`(`/multi-agent-herdr-shim/`)·`$_HERDR_SKILLS_BIN_PATTERN`(`/.agents/skills/.bin`) 은 그대로. `*-shim` 은 `"${1%/}"` 즉 인자 자신의 끝에만 적용. `lib.sh:130-136 _resolve_real_herdr_path` 는 PATH 항목(`_is_shim_path "$dir"`)과 후보 파일의 **canonical dirname** 둘 다 검사한다 — L1 의 mock 은 `tmp/bin/herdr` 로의 symlink 라 canonical dirname 은 `tmp/bin` 이고, 선택 결과는 PATH 항목 쪽 경로(`$dir/herdr`)다. 기존 래퍼 디렉터리 제외 여부: `.mam/shim` → 첫 case(부분 문자열, 조상 포함) 유지; `multi-agent-herdr-shim` → 패턴 상수 유지(`"/…/multi-agent-herdr-shim/…/"`); `.agents/skills/.bin` → 패턴 유지(이 저장소에는 그 디렉터리가 없다); 테스트의 `herdr-shim`(L1b)·`py-shim`·`ps-shim`(`test_issue17_item4_decisions.py:208`, `test_issue21_acceptance.py:283`, `test_issue30_stall_watch_stop.py:95`) 은 디렉터리 자신의 이름이 `*-shim` 이라 계속 제외.
|
||||||
|
- 생성 shim(`lib.sh:173-178`): `[[ "${dir%/}" != *-shim ]]`. `.mam/shim` 부분 문자열 검사는 그대로.
|
||||||
|
- `tests/conftest.py:293-298 mam_sandbox`: `HERDR_SOCKET_PATH`·`HERDR_PANE_ID`·`HERDR_TAB_ID` 제거. 기존의 `HERDR_SESSION_NAME`·`HERDR_SERVER_NAME`·`HERDR_WORKSPACE_ID`·`HERDR_CONFIG_PATH`·`XDG_CONFIG_HOME` 제거와 합쳐 pane 이 내보내는 라우팅 변수 중 실제 서버에 닿을 수 있는 것은 모두 빠진다(아래 비차단 메모의 `HERDR_SESSION` 참조).
|
||||||
|
- bash 3.2: `${1%/}`·`${dir%/}`·`case` 뿐, `bash -n` 통과. Python 3.9 컴파일 통과.
|
||||||
|
- s11 기준 커밋 525df2b: 생성 shim heredoc 이 바뀌었으므로 기준을 그 커밋으로 옮긴 것은 #45 때와 같은 방식이고, 검증 경로에서 s11 PASSED. 525df2b 는 이 브랜치의 첫 커밋으로 검증 경로에 존재한다.
|
||||||
|
|
||||||
|
## 3. 충분한가 — 다른 경로로 실제 서버에 닿는 테스트
|
||||||
|
- `mock_herdr`(`conftest.py:310`)·`mock_agents`(`:1412`) 는 `mam_sandbox` 에 의존하므로 mock 을 PATH 에 두는 테스트는 모두 샌드박스 환경(라우팅 변수 제거, HOME 교체)이다.
|
||||||
|
- `mam_sandbox` 없이 herdr 를 흉내 내는 파일은 `test_issue14_delivery_ack.py`·`test_issue18_liveness_contract.py` 둘(bash 함수 `_sks_herdr` 대체, HOME=tmp). 이 둘이 herdr **바이너리**를 한 번이라도 부르는지 보려고 호출을 기록하는 디코이 `herdr` 를 PATH 맨 앞에 두고 두 파일을 돌렸다: **43 passed, 디코이 호출 0 건**(로그 파일이 생기지 않음). 즉 이 둘은 라우팅 변수가 남아도 바이너리를 실행하지 않는다. (이 실행의 `1 error` 는 세션 teardown 의 #55 agent-home 가드가 `~/.gemini/antigravity-cli/conversations/f9bf3d55-….db-shm/.db-wal` 을 잡은 것으로, 브리프가 예고한 다른 워크스페이스의 실행 중 agy 대화 WAL/SHM 이다. 테스트 산출물이 아니므로 판정에 쓰지 않고 보고만 한다.)
|
||||||
|
- `test_issue6_fail_closed.py`·`test_issue6_turn_handshake.py` 는 `mock_herdr` 를 쓰므로 샌드박스다.
|
||||||
|
- 실제 바이너리가 아는 라우팅 변수는 `HERDR_SOCKET_PATH`(최우선)와 `HERDR_SESSION`(HOME 상대 경로)뿐임을 위 표로 확인했다. `HERDR_SESSION_NAME` 은 바이너리가 모르고 shim 만 `--session` 으로 바꾼다(어제 리뷰에서 확인). 따라서 `HERDR_SOCKET_PATH` 제거 + 가짜 HOME 이면 shim 경로든 직접 호출이든 실제 서버에는 닿지 않는다.
|
||||||
|
|
||||||
|
## 4. 테스트 결과
|
||||||
|
|
||||||
|
| 항목 | 결과 |
|
||||||
|
|---|---|
|
||||||
|
| head: `test_herdr_real_server_leak.py` + `test_issue23_24_startup_path.py` + `test_herdr_shim_contract.py` + `test_issue17_workspace_root_isolation.py` + `test_issue9_server_env.py` + `test_issue45_herdr_config.py` | **130 passed, 34 skipped**(skip 은 모두 `needs tomllib (Python 3.11+)` 류의 알려진 3.9 skip). L1·L1b·L2·s11 은 모두 PASSED 로 실행됨 |
|
||||||
|
| 수정 전 lib.sh(8b0b4a0) + 새 테스트 | **L1 실패**(디코이 선택), **L1b 통과**(자기 이름 `*-shim` 은 전후 모두 제외) |
|
||||||
|
| 수정 전 conftest(8b0b4a0) + 새 테스트 | **L2 실패**: `['HERDR_PANE_ID', 'HERDR_SOCKET_PATH', 'HERDR_TAB_ID']` 가 남음 |
|
||||||
|
| 디코이 sweep(`test_issue14`, `test_issue18`) | 43 passed, 디코이 호출 0 |
|
||||||
|
|
||||||
|
수정 전 재현에는 실제 herdr 가 실행될 수 있는 구성을 쓰지 않았다(테스트 설계대로 디코이가 PATH 에서 실제 바이너리보다 앞서고, `env -i` 로 소켓 변수도 없음).
|
||||||
|
|
||||||
|
## 5. 공유 제어 평면에 주는 영향
|
||||||
|
- 생성 shim 은 lib.sh 를 source 하는 모든 실행에서 `<워크스페이스>/.mam/shim/herdr` 로 다시 쓰이므로, 머지 뒤 실제 제어 평면의 shim 도 두 줄이 바뀐다. 바뀌는 것은 "조상 이름에 `-shim` 이 든 PATH 항목을 더 이상 건너뛰지 않음" 뿐이고, `.mam/shim` 과 자기 이름이 `*-shim` 인 디렉터리는 계속 건너뛴다. 내 pane 의 PATH 에서 `-shim` 을 포함하는 항목은 `~/.cmuxterm/cmux-cli-shims/<uuid>` 하나인데(`herdr` 없음, `amp claude codex hermes pi` 만) 전후 어느 규칙에도 해당하지 않아 영향 없다. 본 체크아웃의 `.mam/shim/herdr` 는 이 리뷰 중 건드리지 않았다(아직 base 내용).
|
||||||
|
- 비차단 메모 1: README(`README.md:102`, `README.ko.md:84`)가 설명하는 레거시 레이아웃 `/tmp/multi-agent-herdr-shim/<server>/herdr` 는 디렉터리 자신의 이름이 서버명이라, 생성 shim 의 새 규칙으로는 제외되지 않는다(lib.sh 의 `_is_shim_path` 는 패턴 상수로 계속 제외). 현재 코드는 그 레이아웃을 만들지 않고 이 호스트에도 없으므로 동작 영향은 없지만, 생성 shim 에도 `*"/multi-agent-herdr-shim/"*` 를 두거나 README 를 현재 레이아웃(`.mam/shim`)으로 고치면 깔끔하다.
|
||||||
|
- 비차단 메모 2: `mam_sandbox` 는 `HERDR_SESSION`(바이너리가 아는 변수, 내 pane 도 내보냄)을 지우지 않는다. 가짜 HOME 아래 경로로 풀리므로 실제 서버 누출은 아니지만, `lib.sh:1425 _herdr_caller_server` 와 `pane_lock.caller_server` 가 이 값을 "호출자 서버" 로 읽어 pane 안/밖에서 테스트 분기가 달라질 수 있다. 결정성을 위해 함께 지우는 것을 권한다(이 PR 의 범위 밖).
|
||||||
|
|
||||||
|
## 6. 잔류물
|
||||||
|
- scratch 복사본·basetemp·디코이·로그는 모두 세션 scratchpad 아래. 일회용 HOME `/tmp/j639-h` 제거(서버를 띄운 적 없음). 실제 `.mam`·`.mam.env`·모니터·실제 서버(multi-agent-mux, default)·`~/.config/herdr`·에이전트 홈은 건드리지 않았다.
|
||||||
|
|
||||||
|
## 결론
|
||||||
|
두 원인 판정은 코드와 실측(특히 `HERDR_SOCKET_PATH` 가 `--session` 보다 우선)으로 확인되고, 수정은 각각을 막으며 회귀 테스트는 수정 전 코드에서 정확히 그 이유로 실패한다. 기존 래퍼 디렉터리 제외는 유지되고, mock 없이 돌아가는 두 테스트 파일은 herdr 바이너리를 아예 부르지 않는다.
|
||||||
|
|
||||||
|
[VERDICT: PASS]
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
# 🔍 Review Report — PR #43 `fix/issue-26` 리뷰 (job `645adb2a`)
|
||||||
|
|
||||||
|
- **Reviewer**: `planner-reviewer-claude-01` (role: planner,reviewer) · 2026-10-01
|
||||||
|
- **대상**: PR #43 @ `b58edac`, base `main e1eb22b` (v4.8.0). 6커밋, 17파일 `+1590/−94`, `git diff --check` 클린.
|
||||||
|
- **검증 경로**: `/var/folders/.../T/mam-review/loop-20261001T131705Z-a60092/1`. **identity 확인**: `HEAD = b58edace…4893`, `HEAD^{tree} = 14b670fb…6ffa` — 브리프와 일치.
|
||||||
|
- **안전 규칙 준수**: 검증 경로·소스 워크트리·본 체크아웃(`main e1eb22b`) 모두 수정·커밋·checkout 없음. 실제 `.mam.env` 는 **값을 출력하지 않고** 형태만 셌다(`$`·`export`·인라인 `#`·따옴표 줄 수 = 모두 0). 파서 실험은 scratchpad 의 합성 파일로만. 테스트는 브리프대로 `.mam.env` 를 export 하지 않고(`MQTT_*` 미설정 확인) 돌렸다. 실제 `.mam`·데몬·세션·herdr 조작 없음, push·merge·PR 상태 변경 없음.
|
||||||
|
- **참고(이 잡의 liveness)**: 이 잡의 `started` 발행 때 브로커 `100.69.147.175:1883` 이 연결을 거부했다(`Connection refused`, 지금도 포트 닫힘). `publish_event` 는 rc 2 였지만 레코드는 `running`·`events.log` 에 `started`(토큰 포함)가 기록됐고 heartbeat 타이머도 떠 있다 — #18 계약상 로컬 증거로 충분하다. 브로커 쪽은 오케스트레이터 확인이 필요하다(아래 판정과 무관).
|
||||||
|
|
||||||
|
## 결론 요약
|
||||||
|
|
||||||
|
**병합을 막을 결함은 없다.** 두 로더는 같은 입력에서 같은 결과를 내고(합성 파일 15줄 실측), 래퍼의 eval 경로는 어떤 값도 셸 해석하지 않으며(명령 치환 주입 실험: 실행 0), bash `mam_int` 와 Python `_positive_int` 는 15가지 입력에서 결과가 같다. 로컬 브로커로 옮긴 시험들은 skip 을 fail 로 바꾸고 양쪽 로그·감사 기록까지 단언해 **오히려 강해졌다**. 비차단 관찰 5건(R1–R5).
|
||||||
|
|
||||||
|
## 1. 파서 규칙 (검토 항목 1)
|
||||||
|
|
||||||
|
`envfile.py:45-73` `parse`, `mqtt_common.py:98-101` 은 `envfile.apply` 에 위임 — Python 쪽 로더는 **하나**다. 래퍼는 같은 파일을 `--shell` 로 실행한다. 같은 합성 파일을 두 경로에 넣은 결과:
|
||||||
|
|
||||||
|
| 입력 줄 | Python 로더 | 래퍼 eval 후 |
|
||||||
|
|---|---|---|
|
||||||
|
| `BOMKEY=1` (UTF-8 BOM) | 건너뜀(invalid key, 1행 경고) | 미설정 |
|
||||||
|
| `A = spaced \r` (CRLF) | `'spaced'` | `spaced` |
|
||||||
|
| `B="q's"` | `"q's"` | 동일 |
|
||||||
|
| `C='x''` | `"x'"`(바깥 한 쌍만) | 동일 |
|
||||||
|
| `D=a=b=c` | `'a=b=c'` | 동일 |
|
||||||
|
| `E=` | `''` | `''` |
|
||||||
|
| `export F=exp` | 건너뜀(invalid key) | 미설정 |
|
||||||
|
| `G=val # trailing` | `'val # trailing'` | 동일 |
|
||||||
|
| `H=$(touch …/PWNED) \`id\` ${HOME}` | 글자 그대로 | 글자 그대로, **PWNED 파일 생성 안 됨** |
|
||||||
|
| `9BAD=x` / `noequals` | 건너뜀 + 경고 | 미설정 |
|
||||||
|
| `IFS=x` / `BASH_ENV=x` | 건너뜀(reserved) | 미설정 |
|
||||||
|
| `I="unbalanced` | `'"unbalanced'` | 동일 |
|
||||||
|
| `J= "inner"` | `'inner'` | 동일 |
|
||||||
|
|
||||||
|
**두 경로가 완전히 일치한다.** 경고에는 값이 찍히지 않는다(줄 번호·사유만). 이전 Python 로더(`strip('"').strip("'")`)는 따옴표를 여러 겹 벗기고 키 검사가 없었는데, 이제 한 쌍만 벗기고 형식 오류를 경고한다 — 문서화된 규칙과 맞다.
|
||||||
|
|
||||||
|
**R1 (비차단, 문서)**: `.mam.env.example` 은 `$HOME/...` 예시를 `/home/you/...` 로 바꿔 "셸 해석 없음" 과 **일관**되게 했지만, 머리말에 규칙 자체(셸 확장 없음, 인라인 `#` 은 값의 일부, `export` 접두어 불가, BOM 저장 시 첫 줄 무시, **이미 있는 환경변수가 우선**)를 적지 않았다. 특히 래퍼 사용자에게는 세 가지가 **동작 변경**이다 — 전에는 bash `source` 가 `export KEY=`·`$HOME` 확장·인라인 주석을 받아들였다. 실제 `.mam.env` 에는 해당 줄이 없음을 확인했다(형태만 셈).
|
||||||
|
|
||||||
|
## 2. 래퍼의 eval 경로 (검토 항목 2)
|
||||||
|
|
||||||
|
`multi-agent-mux-delegate-job:41-46`: `python -I envfile.py --shell` 의 출력을 `eval`. 출력은 `envfile.py:97-98` 의 `print(f"export {key}={shlex.quote(val)}")` 뿐이고, `key` 는 `_KEY.fullmatch` 를 통과한 것만, `val` 은 `splitlines` 로 개행이 없고 `shlex.quote` 로 감싸진다 — 주입 실험에서 `$()`·백틱·`${}` 모두 글자 그대로 남았다.
|
||||||
|
- 래퍼가 그 전에 세운 비-export 변수는 `SCRIPT_DIR`·`REPO_ROOT`·`TARGET_ENV` 셋뿐이고, 셋 다 환경으로 넘겨 파일이 덮어쓸 수 없다(`:42`). env-wins 실측: `D=fromenv` 가 파일의 `D=a=b=c` 를 이긴다.
|
||||||
|
- 실패 경로: 파일이 **없으면** rc 0·출력 없음, **읽을 수 없으면** stderr `WARNING: … cannot read (PermissionError)` 후 rc 0 — 래퍼는 설정 없이 계속한다. 래퍼의 `|| { echo "ERROR: cannot read $TARGET_ENV"; exit 1; }`(`:44`)는 **Python 실행 자체가 실패할 때**(인터프리터 없음 등)만 걸려, 메시지와 실제 원인이 어긋난다(R2, 사소).
|
||||||
|
- `-I`: `PYTHONPATH`·사용자 site 를 무시하지만 `envfile.py` 는 표준 라이브러리만 쓰고 파일 경로로 실행되므로 문제없다. 오히려 `.mam.env` 를 읽기 전 환경의 `PYTHON*` 이 파서에 끼어들 여지를 없앤다.
|
||||||
|
|
||||||
|
## 3. 우선순위 변경의 영향 (검토 항목 3)
|
||||||
|
|
||||||
|
전에는 래퍼의 `source` 가 환경을 덮어썼고 Python 로더는 환경을 지켰다 — 같은 파일이 두 경로에서 다른 결과를 냈다. 이제 둘 다 env-wins.
|
||||||
|
- **루프 러너**가 래퍼에 넘기는 env(`DELEGATE_JOB_PYTHON`, `--registry-dir` 대신 쓰는 `MAM_JOBS_DIR` 등): 전에는 파일 값에 덮일 수 있었다 → 이제 러너 값이 이긴다. **개선**.
|
||||||
|
- **워크트리 creator**: create/resume 이 넘기는 절대경로 바인딩(`AGENT_SESSIONS_YAML`·`MAM_JOBS_DIR`·…)이 이제 파일 값에 덮이지 않는다 — #21 의 의도와 맞는 **개선**.
|
||||||
|
- **원격 러너**(`remote.py`)는 원래 Python 로더라 변화 없음. **테스트**는 `conftest` 가 `MAM_ENV_FILE` 을 없는 파일로 고정해 무관.
|
||||||
|
- 동작이 나빠지는 경로: 셸에 **오래된 `MQTT_*` 를 export 해 둔 채** 파일만 고친 사용자는 이제 파일 값이 적용되지 않는다. 이 부분이 R1 의 문서 누락과 겹친다.
|
||||||
|
|
||||||
|
## 4. 정수 정규화 (검토 항목 4)
|
||||||
|
|
||||||
|
bash `mam_int`(`lib.sh:2345-2356`)와 Python `_positive_int`(`runner.py:71-82`)를 같은 15개 입력에 돌린 결과 **전부 일치**: `' 30 '→30`, `0700→700`(8진 아님), `0900→900`, `0→거부(min 1)`, `-5`·`+5`·`1e3`·`30s`·전각 `30`·빈 값 → 거부, `999999999→수용`, `1000000000→거부`(9자리 상한), `0000000000000000001→1`(앞 0 제거 후 길이 판정).
|
||||||
|
- 래퍼: 환경 노브는 `mam_int_knob`(잘못되면 경고 + 기본값, `:154-156`), 플래그는 `parse_opts` 끝의 루프(잘못되면 `ERROR … not a positive decimal integer` rc 1, `:196-200`). `MAM_INJECT_ACK_GRACE_S` 는 min 0(0 = 대기 없음 허용, 의미상 맞다), `ACK_TIMEOUT`·heartbeat·budget 은 min 1. lib.sh `SKS_*` 대기 노브는 min 0(대기 생략 허용), `delegate_wait_started` 도 0 허용(`:2379`).
|
||||||
|
- 빠진 노브: `$(( ))` 안에 환경 기본값을 직접 쓰는 자리는 남아 있지 않다(B5 정적 검사가 고정). `[ -ge ]`/`[ -le ]`/`sleep` 에만 쓰이는 노브(`MAM_READY_EMPTY_GIVEUP`·`SKS_EMPTY_GIVEUP`·`SKS_IDLE_RETRIES`·…)는 `test` 의 비교가 원래 10진수라 8진 문제가 없고, PR 이 표로 분류해 "모든 정수 노브가 분류됨" 을 시험한다(`test_b5_every_integer_env_default_is_classified`). 그 노브들에 숫자가 아닌 값이 오면 `[` 가 오류(rc 2)를 내는 기존 동작은 남는다(R3, 범위 밖).
|
||||||
|
|
||||||
|
## 5. 로컬 브로커와 이전된 시험 (검토 항목 5)
|
||||||
|
|
||||||
|
`tests/_local_mqtt.py` 는 MQTT 3.1.1 의 필요한 부분을 클라이언트가 기대하는 순서로 처리한다: CONNECT(레벨 3/4만, 그 외 CONNACK rc 1)→CONNACK, SUBSCRIBE→SUBACK(QoS 최대 1 부여) 뒤 **매칭되는 retained 전달**, PUBLISH QoS1 은 **라우팅·retained 저장 뒤에 PUBACK**(b58edac — 발행자가 ack 를 받은 시점에 브로커 상태가 이미 반영됨), 빈 payload retained 는 삭제, UNSUBSCRIBE→UNSUBACK, PINGREQ→PINGRESP, `+`/`#` 와일드카드와 `$` 토픽 예외(`topic_matches`). 미지원(QoS2·will·지속 세션)은 이 저장소가 쓰지 않는 기능이다. `127.0.0.1:0` 에 바인딩해 **포트 충돌이 없고**, 스레드는 daemon 이며 `stop()` 이 리스너·연결을 닫는다.
|
||||||
|
|
||||||
|
이전된 시험의 단언은 **약해지지 않았고 강해졌다**:
|
||||||
|
- `test_acceptance5`: 공개 브로커용 `_skip_if_broker_did_not_deliver`·`no CONNACK` skip 이 삭제되고, 대신 러너 로그의 `DISPATCH`·`published started/completed`·`DONE rc=0` 과 오케스트레이터 감사 기록의 `dispatch_verified`·`dispatch_fingerprint == sha256(token)` 을 양쪽에서 단언한다.
|
||||||
|
- `test_issue19`: "브로커 도달 불가면 skip" 이 `pytest.fail` 로 바뀌었다(로컬 브로커는 항상 떠 있으므로).
|
||||||
|
- S9/S10 의 `_live_broker()`(2·3회차 리뷰에서 내가 P2 로 지적한 공개 브로커 의존)가 `local_broker_env()` 로 대체됐다.
|
||||||
|
- `test_b19` 는 `send_keys_safe` 본문을 앞 2500자가 아니라 **함수 끝까지** 검사하게 고쳐졌다(정확성 개선).
|
||||||
|
- 정적 검사(`test_local_mqtt.py:215`)가 공개 브로커 주소의 재유입을 막는다. 시험 로그에 `hivemq`·실제 브로커 IP 흔적 0건.
|
||||||
|
|
||||||
|
## 6. 테스트 실행 (검증 방법·검토 항목 6)
|
||||||
|
|
||||||
|
```
|
||||||
|
cd <검증 경로> && AGENT_PYTHON_BIN=<pr-43 .venv> PYTHONDONTWRITEBYTECODE=1 <pr-43 .venv> -m pytest -p no:cacheprovider -q -rfEs \
|
||||||
|
--basetemp=<밖> tests/test_issue26_envfile.py tests/test_issue26_ints.py tests/test_local_mqtt.py \
|
||||||
|
tests/test_issue18_liveness_contract.py tests/test_issue33_budget.py tests/test_issue30_stall_watch.py tests/test_tier1_unit.py
|
||||||
|
(.mam.env 미export, MQTT_* 미설정)
|
||||||
|
→ 228 passed, 1 skipped in 525.44s (0:08:45) failed 0 (skip = S6, /proc)
|
||||||
|
```
|
||||||
|
|
||||||
|
- PR #41 리뷰 내내 공개 브로커 때문에 흔들리던 S9·S10·R2·B3·acceptance 계열이 **외부 브로커 없이 전부 통과**했다 — PR 의 주장 확인.
|
||||||
|
- **기준 실패 주장(검토 항목 6)**: `tests/test_issue10_13_purge.py` 를 이 PR 트리와 base(`e1eb22b` 를 `git archive` 로 풀어 놓은 사본)에서 각각 단독 실행한 결과 **둘 다 23 passed**, 실패 0. 즉 이 호스트에서 이 리뷰어의 조건으로는 "12건 기준 실패" 가 **재현되지 않는다**. 양쪽 결과가 같으므로 어느 경우든 PR 회귀는 아니다. 오케스트레이터의 전체 스위트에서만 나타난다면 시험 간 순서·상태(예: 실제 홈의 대화 파일, 이전 시험이 남긴 환경) 의존을 의심할 만하다(R4).
|
||||||
|
- 격리: 실제 `.mam/jobs` 는 이 잡 자신의 파일 외 **추가 0**. 검증 경로 tracked 파일 변화 없음(HEAD `b58edac`), 본 체크아웃 `main e1eb22b`. 실행 뒤 호스트에 남은 `stall_watch`(이 잡을 감시하는 오케스트레이터의 것)·`sleep 300`(다른 세션의 프로세스 그룹) 은 내 실행과 무관함을 부모/프로세스 그룹으로 확인했다.
|
||||||
|
|
||||||
|
## 7. 설계 경계 (검토 항목 7)
|
||||||
|
|
||||||
|
- **판정이 Python 으로 이동**: `.mam.env` 해석(키 검증·예약 변수 거부·우선순위)이 bash `source` 에서 `envfile.py` 로 옮겨졌다 — #17 항목 4 의 "`.mam.env` 파싱(#26) — bash `source` 제거" 그대로다. 래퍼에는 실행과 `eval` 만 남는다.
|
||||||
|
- **bash 에 새로 생긴 로직**: `mam_int`/`mam_int_knob` 과 `parse_opts` 의 플래그 검증. 이는 bash 산술로 들어가는 값을 그 자리에서 정규화하는 **입력 정제**이고, Python 쌍둥이(`_positive_int`)와 한 규칙으로 고정됐다(B1 시험). 판정(무엇을 할지)이 아니라 형식 변환이라 경계 위반으로 보지 않지만, 1회차 PR #41 리뷰의 M4(`MAM_JOB_TIMEOUT_S` 검증이 bash 에 있음)와 같은 부류이므로 항목 4 에서 래퍼 인자 처리가 Python 으로 갈 때 함께 옮길 목록에 둘 것을 권한다(R5).
|
||||||
|
|
||||||
|
## 8. 판정
|
||||||
|
|
||||||
|
버전 락스텝 bump 부재는 판정에서 제외한다. 병합을 막을 결함은 없다.
|
||||||
|
|
||||||
|
| # | 비차단 관찰 | 근거 |
|
||||||
|
|---|---|---|
|
||||||
|
| R1 | `.mam.env.example` 머리말에 새 파싱 규칙과 **env-wins** 가 적혀 있지 않다 — 래퍼 사용자에게 `export` 접두어·`$` 확장·인라인 주석·우선순위 4가지가 동작 변경 | `.mam.env.example:1-20`; §1·§3 |
|
||||||
|
| R2 | 래퍼의 `ERROR: cannot read $TARGET_ENV` 는 Python 실행 실패에만 걸리고, 실제 읽기 실패는 경고 후 설정 없이 진행 — 메시지가 원인과 어긋난다 | `multi-agent-mux-delegate-job:44`, `envfile.py:52-53` |
|
||||||
|
| R3 | `[ -ge ]`/`sleep` 전용 정수 노브에 숫자가 아닌 값이 오면 `[` 오류(rc 2)를 내는 기존 동작은 남는다 | PR 이 분류표로 의도적으로 범위 밖 처리 |
|
||||||
|
| R4 | "test_issue10_13_purge.py 12건 기준 실패" 가 이 리뷰어 조건(단독, PR·base 양쪽)에서는 재현되지 않음 — 전체 스위트 순서 의존 가능성 | §6 |
|
||||||
|
| R5 | `mam_int`/플래그 검증은 bash 의 입력 정제 — 항목 4 이전 목록에 추가 권고 | `lib.sh:2345-2364`, 래퍼 `:196-200` |
|
||||||
|
|
||||||
|
## 9. 교차검증 가이드
|
||||||
|
|
||||||
|
```bash
|
||||||
|
RP=/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261001T131705Z-a60092/1
|
||||||
|
PY=/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux-wt/pr-43/.venv/bin/python
|
||||||
|
git -C "$RP" rev-parse HEAD 'HEAD^{tree}' # b58edac… / 14b670f…
|
||||||
|
# 합성 .env 15줄을 envfile.apply 와 `-I envfile.py --shell` + eval 양쪽에 → 결과 동일, $(…)/`…` 미실행 (§1 표)
|
||||||
|
# mam_int vs _positive_int 15개 입력 → 전부 동일 (§4)
|
||||||
|
# base: git -C "$RP" archive e1eb22b | tar -x -C <tmp> → tests/test_issue10_13_purge.py → 23 passed (PR 도 23 passed)
|
||||||
|
```
|
||||||
|
|
||||||
|
[VERDICT: PASS]
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
# 🔍 Review Report — PR #41·#43 후속 `fix/followups-41-43` (job `76ca3b1b`)
|
||||||
|
|
||||||
|
- **Reviewer**: `planner-reviewer-claude-01` (role: planner,reviewer) · 2026-10-02
|
||||||
|
- **대상**: `0051fd6..95eabc6` (8커밋, 27파일 `+397/−88`, `git diff --check` 클린), base `main 0051fd6`.
|
||||||
|
- **검증 경로**: `/var/folders/.../T/mam-review/loop-20261001T230324Z-b511bf/1`. **identity 확인**: `HEAD = 95eabc69…d5f4f`, `HEAD^{tree} = 46399844…7998` — 브리프와 일치.
|
||||||
|
- **안전 규칙 준수**: 검증 경로·소스 워크트리(`multi-agent-mux-wt/followups`)·본 체크아웃 모두 수정·커밋·checkout 없음. 되돌린 사본·합성 파일·`ps` 대역은 scratchpad 에서만. 실제 `.mam.env` 를 읽거나 출력하지 않았고 export 하지 않았다. 실제 `.mam`·데몬·세션·herdr 조작 없음, push·merge·PR 상태 변경 없음.
|
||||||
|
|
||||||
|
## 결론 요약
|
||||||
|
|
||||||
|
**F1–F6 모두 완료 조건을 만족한다. 범위 밖 동작 변경은 없다.** 제품 코드를 바꾸는 항목(F2·F3·F4·F5)의 회귀는 제품 파일만 base 로 되돌린 사본에서 **47건 실패**한다(아래 §7). 테스트만 고치는 항목(F1·F6)은 base 제품 코드에서도 통과하는 것이 정상이며, F1 은 옛 테스트가 날짜에 따라 실패하던 원인을 `ps` 대역으로 직접 재현했다. 비차단 관찰 3건(T1–T3).
|
||||||
|
|
||||||
|
## F1 — A8 의 날짜 의존 (`tests/test_issue21_acceptance.py:258-300`)
|
||||||
|
|
||||||
|
- 제품 코드(`loop_lock.sh` 의 `mam_lstart`)는 **그대로**다. `loop_lock.sh` 의 diff 는 F4b 의 `MAM_LOCK_STEAL_RETRIES` 한 줄뿐이다.
|
||||||
|
- 테스트는 마커의 lstart 를 `" ".join(out.split())` 로 써서 제품의 `tr -s ' '` + 앞뒤 공백 제거와 같게 맞췄다.
|
||||||
|
- 날짜와 무관하게 고정하는 장치: `padded` 파라미터가 PATH 앞에 `ps` 대역을 둔다. 이 대역은 `lstart=` 질의에만 `Thu Oct 1 08:02:03 2026`(두 칸)을 돌려주고, 대역이 실제로 쓰였는지도 단언한다.
|
||||||
|
- **옛 버그 재현(직접)**: 같은 대역 아래에서 제품 `mam_lstart` 는 `Thu Oct 1 …`(한 칸), 옛 테스트 방식(`.strip()`)은 `Thu Oct 1 …`(두 칸)을 낸다 → 불일치 → 잠금이 "stale" 로 보여 두 번째 루프가 거부되지 않는다. 즉 옛 A8 이 1~9일에 실패한 원인이 정확히 이것이고, 새 `padded` 케이스는 매일 그 조건을 돈다.
|
||||||
|
- 같은 패턴의 다른 테스트: `test_o3_scoped_guard.py:_get_lstart` 는 이미 `" ".join(...split())` 로 정규화한다. `test_o2_race_free_lock.py:get_lstart` 는 정규화하지 않지만 **어디서도 호출되지 않는 죽은 헬퍼**다(T1, 지우거나 정규화 권고).
|
||||||
|
|
||||||
|
## F2 — 진입점의 한 줄 오류 (`mqtt_common.py:129-140` 외 7곳)
|
||||||
|
|
||||||
|
- 공통 헬퍼 `load_env_or_report()` 가 `EnvFileError` 를 잡아 `ERROR: <path>: cannot read (<type>)` 한 줄을 출력하고 False 를 돌려준다. 라이브러리 함수는 `sys.exit` 하지 않는다 — 진입점(`publish_event`·`registry`·`job_subscriber`·`cli`·`bench`·`heartbeat`·`remote` 의 `main`)이 `return 1` 한다. `job_subscriber` 는 `RC_ERROR`(=1)로 이름을 썼다.
|
||||||
|
- 값·파일 내용은 출력되지 않는다(예외 메시지가 경로와 예외 종류뿐).
|
||||||
|
- 회귀 `test_a1_…[7 진입점 × permission/utf8]` 14건이 base 에서 모두 실패한다.
|
||||||
|
- reconcile.sh 의 내장 구독자는 여전히 `ensure_env_loaded()` 를 직접 부른다(브리프 목록 밖, 문서화된 그대로).
|
||||||
|
|
||||||
|
## F3 — `.mam.env` 세부
|
||||||
|
|
||||||
|
- (a) BOM: `envfile.py:60-61` 이 `utf-8-sig` 로 연다. 공통 파서라 **두 로더 모두** 적용된다. 파일 맨 앞의 BOM 만 벗기므로 중간의 U+FEFF 는 그대로다.
|
||||||
|
- (b) 중복 키는 첫 값 우선: 동작은 그대로(`apply` 가 `if key not in environ`)이고 `.mam.env.example` FORMAT 절에 한 줄 추가했다.
|
||||||
|
- (c) `MESSAGING.md:369`: "명시한 파일이 없으면 오류" 를 "조용히 건너뜀(주변 탐색은 안 함), 있는데 못 읽으면 EnvFileError" 로 실제 동작에 맞췄다.
|
||||||
|
- 회귀 `test_a7_*` 3건이 base 에서 실패한다.
|
||||||
|
|
||||||
|
## F4 — 정수 노브
|
||||||
|
|
||||||
|
- (a) 러너(`runner.py:627`, `:630-643`, `:762-768`): `_int_knob` 이 `_positive_int` 규칙으로 읽고, 잘못된 값이면 노브당 한 번 경고하고 기본값을 쓴다. `MAM_CHILD_WAIT_S=abc` 의 `ValueError` 가 사라졌다.
|
||||||
|
- 최솟값의 의미도 맞다.
|
||||||
|
|
||||||
|
| 노브 | 최솟값 | 의미 |
|
||||||
|
|---|---|---|
|
||||||
|
| `MAM_WAIT_GRACE_S` | 0 | 예산만큼 정확히 대기 |
|
||||||
|
| `MAM_CHILD_WAIT_S` | 0 | 하위 잡을 한 번만 확인 |
|
||||||
|
| `MAM_JOB_TREE_MAX` | 1 | 0 이면 모든 트리가 "초과"; 대기하지 않으려면 `MAM_CHILD_WAIT_S=0` |
|
||||||
|
|
||||||
|
세 값 모두 표 `RUNNER_INT_KNOBS` 에 들어갔다.
|
||||||
|
- (b) bash 노브: `MAM_READY_EMPTY_GIVEUP`·`SKS_EMPTY_GIVEUP`(min 1), `SKS_IDLE_RETRIES`·`SKS_IDLE_RETRY_BACKOFF_S`(min 0), `MAM_LOCK_STEAL_RETRIES`(min 0), `MAM_DIFF_MAX_BYTES/LINES`(min 1), `RECONCILE_POLL_INTERVAL`(min 1 — 0 이면 바쁜 루프)가 `mam_int_knob` 을 거친다.
|
||||||
|
- `SKS_QUIESCENT_INTERVAL` 은 기본값이 소수(0.5)라 **그대로 두고 표에 사유**를 남겼다 — 브리프 지시대로다.
|
||||||
|
- `RECONCILE_POLL_INTERVAL` 은 저장소 안의 모든 사용처(테스트 `60`, 문서 15 s)가 정수라 의미가 깨지지 않는다.
|
||||||
|
- `diff_collect.sh`·`loop_lock.sh` 는 lib.sh 없이 source 되므로 규칙을 `lib_int.sh` 로 분리했다. lib.sh 도 같은 파일을 source 한다(`declare -F` 가드로 이중 정의를 막음). `deploy/remove.sh` 의 폴백 자산 목록에 `lib_int.sh` 를 추가한 것은 새 파일을 배포 제거 대상에 넣는 **필수 동반 변경**이다(범위 안). `test_issue21_loop_b.py` 의 한 줄도 L15 가 `diff_collect.sh` 를 복사해 쓰므로 필요한 동반 수정이다.
|
||||||
|
- (c) loop SKILL.md 의 Job budget 행에 `MAM_WAIT_GRACE_S`(기본 300, 0 이상, 잘못된 값은 경고 후 300)를 문서화했다.
|
||||||
|
- 회귀 `test_b1_runner_*`·`test_b5_*` 가 base 에서 실패한다.
|
||||||
|
|
||||||
|
## F5 — 테스트 스위치 제거 (`stall_watch.py`, `test_issue30_stall_watch.py`)
|
||||||
|
|
||||||
|
- `MAM_TEST_UNRELIABLE_AGENT` 두 줄을 삭제했다.
|
||||||
|
- S13 은 `-c` 진입점에서 어댑터 클래스의 `agent_status_reliable` 을 `False` 로 바꾼 뒤 **실제** `stall_watch.main` 을 돈다. 제품에는 분기가 없고, 검증하는 내용(idle 판정 끔, blocked 알림 유지)도 같다.
|
||||||
|
- 새 `test_s13_the_unreliable_case_needs_no_switch_in_the_product`(`MAM_TEST_` 문자열 부재)가 base 에서 실패한다.
|
||||||
|
|
||||||
|
## F6 — 문서·테스트 정밀도
|
||||||
|
|
||||||
|
- delegate-job SKILL.md:53 은 이제 "≤ 60 s 는 herdr wait 한정, 알림 **발행 중**에 래퍼가 사라지면 그 발행이 끝날 때까지" 라고 적는다 — 코드(`_wrapper_gone` 이 wait 직후와 `_may_prompt` 에만 있음)와 일치한다.
|
||||||
|
- "대기 중 래퍼 소멸" 테스트는 이제 **두 번째 wait 대역 안에서** `gone` 을 세우고, `ticks == [1, 2]` 와 마지막 alive 관측이 `(2, False)` 임을 단언한다. 이름 그대로를 강제한다.
|
||||||
|
- 테스트 정밀화라 base 제품 코드에서도 통과하는 것이 맞다(제품 동작은 PR #41 에서 이미 옳았다).
|
||||||
|
|
||||||
|
## 7. 테스트
|
||||||
|
|
||||||
|
### 7-1. 검증 경로에서의 실행
|
||||||
|
|
||||||
|
```
|
||||||
|
tests/test_issue26_envfile.py tests/test_issue26_ints.py tests/test_issue21_acceptance.py tests/test_issue30_stall_watch.py
|
||||||
|
tests/test_issue30_stall_watch_stop.py tests/test_tier1_unit.py tests/test_version_consistency.py tests/test_issue5_phase1_delegation.py
|
||||||
|
(.mam.env 미export, MQTT_* 미설정, followups .venv)
|
||||||
|
→ 335 passed, 1 skipped in 451.38s failed 0 (skip = S6, /proc)
|
||||||
|
```
|
||||||
|
|
||||||
|
브리프 목록 7파일에 `test_issue5_phase1_delegation.py`(registry CLI 스냅샷·help 캡처)를 더해, 진입점 변경이 CLI 계약을 깨지 않음을 확인했다. 로그에 외부 브로커 흔적 0건.
|
||||||
|
|
||||||
|
### 7-2. 수정 없이는 실패하는가 — 확인함
|
||||||
|
|
||||||
|
검증 경로 사본에서 `tests/` 밖의 바뀐 파일 전부를 `0051fd6` 판으로 되돌리고(새 `lib_int.sh` 는 삭제) 새·변경 테스트를 돌렸다:
|
||||||
|
|
||||||
|
```
|
||||||
|
47 failed, 88 passed in 110.20s
|
||||||
|
```
|
||||||
|
|
||||||
|
| 항목 | base 에서 실패한 회귀 |
|
||||||
|
|---|---|
|
||||||
|
| F2 | `test_a1_python_entry_points_report_an_unreadable_file_in_one_line` 14건(7 진입점 × 권한/UTF-8) |
|
||||||
|
| F3 | `test_a7_a_leading_bom_…`, `…_repeated_key_keeps_its_first_value…`, `…_missing_explicit_file_…` |
|
||||||
|
| F4 | `test_b1_runner_wait_grace/child_wait/job_tree_max_*`, `test_b5_*`(표·최솟값·lib.sh 없이 source 되는 두 스크립트·`_pane_quiescent`) |
|
||||||
|
| F5 | `test_s13_the_unreliable_case_needs_no_switch_in_the_product` |
|
||||||
|
| F1·F6 | 테스트만 고친 항목이라 base 제품 코드에서 통과하는 것이 정상. F1 은 §F1 의 `ps` 대역 실험으로 옛 테스트의 실패 원인을 직접 재현 |
|
||||||
|
|
||||||
|
### 7-3. 격리
|
||||||
|
|
||||||
|
실제 `.mam/jobs` 는 이 잡 자신의 파일 외 **추가 0**. 검증 경로 tracked 파일 변화 없음(HEAD `95eabc6`), 본 체크아웃 `main 0051fd6`.
|
||||||
|
|
||||||
|
## 8. 범위
|
||||||
|
|
||||||
|
바뀐 파일 27개를 모두 F1–F6 또는 그 필수 동반 변경으로 귀속할 수 있다.
|
||||||
|
- `lib_int.sh` 분리와 그에 따른 `deploy/remove.sh`·`test_issue21_loop_b.py` 의 한 줄은 F4b 의 동반 변경이다.
|
||||||
|
- #17 항목 4·#39·#40·#42 범위의 변경과 버전 bump 는 없다.
|
||||||
|
|
||||||
|
## 9. 판정
|
||||||
|
|
||||||
|
| # | 비차단 관찰 | 근거 |
|
||||||
|
|---|---|---|
|
||||||
|
| T1 | `test_o2_race_free_lock.py:get_lstart` 는 정규화하지 않는 **호출되지 않는** 헬퍼 — 지우거나 같은 정규화로 맞추면 재발 여지가 없다 | `tests/test_o2_race_free_lock.py:40-43` |
|
||||||
|
| T2 | reconcile.sh 의 내장 구독자는 `ensure_env_loaded()` 를 직접 불러 읽기 실패 시 여전히 트레이스백 | 브리프 진입점 목록 밖; 다음 정리 때 `load_env_or_report` 로 |
|
||||||
|
| T3 | (확인 완료, 조치 불필요) `deploy/install.sh` 는 트리 전체를 복사하므로(`cp -R "$REPO_URL/."`, :128) `lib_int.sh` 도 함께 배포된다. `check_assets_present`(:98-108)는 존재 확인용 목록이라 `lib.sh` 만 있어도 되지만, lib.sh 가 lib_int.sh 를 source 하므로 목록에 추가해 두면 부분 설치를 더 일찍 잡는다 | `deploy/install.sh:98-108` |
|
||||||
|
|
||||||
|
[VERDICT: PASS]
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# 리뷰 리포트 — PR #59 2차 (Issue #31) / job 778fc866
|
||||||
|
|
||||||
|
- 리뷰어: planner-reviewer-claude-01 (Opus 5.5)
|
||||||
|
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261017T135731Z-e93f0a/1`
|
||||||
|
- identity: HEAD `2a8d73825b3832376aeaf96b09548359dc3cb887`, tree `d146f1ac68bcc3485d24fb92edb87264e4d1e8cb` 일치. 트리는 clean이고 `.mam` 없음(테스트 후에도 동일).
|
||||||
|
- 범위: 26e2c43..2a8d738 누적, 중점은 1차 이후 커밋 11개(26fb1c4..2a8d738)
|
||||||
|
|
||||||
|
## 요약
|
||||||
|
|
||||||
|
1차 수정 요청 3건(차단 F1, P1, P2)은 **제품 코드에서 모두 해결됐다**. 비차단 메모(서머타임/UTC, exit 4 안내, SKILL.md 문구, 테스트의 실제 `/tmp` 나열)도 해결됐다. 샌드박스 재현과 변형(mutation) 대조로 각 수정과 회귀 테스트가 실제로 맞물리는 것도 확인했다.
|
||||||
|
|
||||||
|
그러나 **새로 추가된 테스트 `test_any_spelling_of_the_uuid_is_the_lowercase_session[upper|mixed]` 는 이 Mac 에서 항상 실패한다**(T1). 원인은 제품 코드가 아니라 테스트의 macOS 이식성 버그다. 이대로 병합하면 이 Mac 의 스위트가 빨간불이 되므로 NOT PASS 로 판정한다. 수정은 테스트 한 줄이다.
|
||||||
|
|
||||||
|
## 차단 결함
|
||||||
|
|
||||||
|
### T1. `sh -c "sleep 120" <uuid>` 보유자 프로세스가 macOS 에서 uuid 를 argv 에서 잃는다
|
||||||
|
|
||||||
|
- 위치: `tests/test_issue31_purge_residual.py:315-318`
|
||||||
|
```python
|
||||||
|
named = subprocess.Popen(["sh", "-c", "sleep 120", conv])
|
||||||
|
time.sleep(0.05)
|
||||||
|
assert adapter.conversation_in_use(other, ctx) is True, "a command line naming the lowercase id"
|
||||||
|
```
|
||||||
|
- 원인: 이 Mac 의 `/bin/sh` 는 GNU bash 3.2.57 이다. bash 는 `-c` 의 단일 단순 명령을 exec 로 바꿔 실행하므로, 실행된 프로세스의 argv 는 `sleep 120` 만 남고 `$0` 인 uuid 는 사라진다.
|
||||||
|
- 직접 확인: `/bin/sh -c 'sleep 30' MARKER-abc &` → `ps` 에 `sleep 30` 만 보인다.
|
||||||
|
- `/bin/sh -c 'sleep 30; :' MARKER-abc` → `/bin/sh -c sleep 30; : MARKER-abc` 로 uuid 가 남는다.
|
||||||
|
- 이 저장소도 같은 함정을 이미 기록해 두었다: `tests/test_issue10_13_purge.py:114` ("a shell execs its last simple command and the uuid …").
|
||||||
|
- 재현: 검증 경로의 관련 모듈 실행(아래)에서 **2 failed**. 수정하지 않은 `git clone` 사본에서 `-k any_spelling` 만 돌려도 2 failed:
|
||||||
|
`AssertionError: a command line naming the lowercase id … conversation_in_use('91151A0D-0114-4C3C-a120-aec043818d1f', …) → False`
|
||||||
|
아래 변형 대조 4종(F1/P1/P2/UTC)에서도 변형과 무관하게 같은 2개가 실패했다.
|
||||||
|
- 제품 로직은 정상이다. argv 를 유지하는 보유자(`["sh","-c","sleep 30; :", <lowercase uuid>]`)로 샌드박스에서 확인했다:
|
||||||
|
- `conversation_in_use(UPPER)` = True, `conversation_in_use(lower)` = True.
|
||||||
|
- 단일 명령 보유자로는 두 철자 모두 False.
|
||||||
|
- 제안: 보유자를 `["sh", "-c", "sleep 120; :", conv]` 나 `[sys.executable, "-c", "import time; time.sleep(120)", conv]` 처럼 uuid 가 argv 에 남는 형태로 바꾼다. 고정 `time.sleep(0.05)` 대신 짧은 폴링(예: 최대 2 s 동안 `ps` 에 보일 때까지)으로 기다리면 느린 호스트의 경합도 피할 수 있다.
|
||||||
|
|
||||||
|
## 1차 지적사항 확인
|
||||||
|
|
||||||
|
샌드박스 조건: HOME·CLAUDE_PROJECT_DIR·CLAUDE_CODE_TMPDIR·TMPDIR 를 모두 스크래치패드 아래로 두고, CLAUDE_CONFIG_DIR 은 비웠다. 인터프리터는 `/usr/bin/python3` 3.9.6. 실제 경로는 읽기만 했다.
|
||||||
|
|
||||||
|
1. **[차단] 비-UUID id — 해결.**
|
||||||
|
- 방식: `_canonical()`(`claude.py:8-19`)이 정규 UUID 만 받아 소문자로 바꾼다. `_candidates`·`purge_artifacts`·`residual_artifacts`·`conversation_in_use` 의 모든 입구가 이 함수를 거친다.
|
||||||
|
- 재현: 아래 14개 id 는 purge 결과가 모두 rc 5 이고, 아무것도 지우지 않았다. `projects/<key>/memory/MEMORY.md` 와 다른 세션 파일은 전부 남았다.
|
||||||
|
- `""`, `.`, `..`, `a/b`, `*`, `../x`
|
||||||
|
- `memory`, `MEMORY`, `subagents`, `<key>`
|
||||||
|
- uuid 앞 8자리, `<U>x`, `" <U>"`, `"<U>\n"`
|
||||||
|
- 대문자 uuid 로 purge 하면 소문자 경로 9개가 정확히 지워진다(`lower jsonl gone True`). 다른 세션·공유 파일은 모두 보존됐다.
|
||||||
|
- 변형 대조(`_canonical` 을 옛 `_plausible_id` 규칙으로 되돌림): `rejects_malformed_uuid[memory, ' ', notes, …g, '{conv} ', 'x{conv}']`, `a_non_uuid_id_reaches_nothing[uuid-c, memory, notes]`, `any_spelling` 이 실패해 **11 failed**.
|
||||||
|
2. **[P1] 링크 경계 — 해결.**
|
||||||
|
- 방식: `_inside()`(`claude.py:109-123`)는 루트와 대상 사이의 디렉터리 구성요소를 `lstat` 으로 하나씩 확인하고, 부모의 realpath 가 루트의 realpath 아래에 있는지 본다.
|
||||||
|
- 링크로 연결된 tmp `<key>`, `file-history/`, `projects/<key>` 뒤의 내용은 모두 보존되고 `residual` 로 보고된다.
|
||||||
|
- 말단 링크(`session-env/<U> → 외부`)는 링크만 지워지고 대상 파일은 남는다.
|
||||||
|
- 루트 자체가 링크인 경우(`~/.claude → realhome`, `CLAUDE_CODE_TMPDIR → realtmp`, 즉 `/tmp → /private/tmp` 같은 형태)는 정상적으로 purge 된다.
|
||||||
|
- Python 3.9 macOS 의 `shutil._use_fd_functions` 는 True 로, rmtree 가 내부 링크를 따라가지 않는다.
|
||||||
|
- 변형 대조(`_inside` → 항상 True): `never_follows_a_linked_dir_below_a_root`, `reports_a_linked_orphan_it_will_not_remove` 실패(T1 의 2개 포함 **4 failed**).
|
||||||
|
3. **[P2] 유한하지 않은 startedAt — 해결.**
|
||||||
|
- 방식: `claude.py:84-96`.
|
||||||
|
- 살아 있는 `sleep` 보유자에 대해 startedAt 을 아래 9가지 값으로 바꿔 봤다. 모두 in-use rc 4, purge rc 4 였고 jsonl 은 보존됐다.
|
||||||
|
- `NaN`, `Infinity`, `-Infinity`, `"NaN"`, `true`, `1e400`, 400자리 정수, `null`, `"1760000000000"`
|
||||||
|
- 정상 ms 값이면 live(rc 4), 1시간 전 값(pid 재사용)이면 free 다.
|
||||||
|
- 변형 대조(`isfinite` 제거): `unusable_started_at_means_live[nan]`, `live_holder…[NaN]` 실패. inf 계열은 다른 분기에서도 live 로 판정되므로 이 변형으로는 실패하지 않는 것이 맞다.
|
||||||
|
|
||||||
|
## 비차단 메모 확인
|
||||||
|
|
||||||
|
- **UTC (af8ed39)**: 실제 살아 있는 claude 4개를 읽기 전용으로 비교했다(`TZ=UTC0 LC_ALL=C ps -o lstart=` + `calendar.timegm`).
|
||||||
|
- lstart 가 세션 파일의 `procStart`(UTC)와 초 단위까지 일치했다. 예: `Sat Oct 17 13:55:10 2026`.
|
||||||
|
- startedAt − lstart 는 1.34 / 1.49 / 1.99 / 2.36 s 로, 모두 live 판정이다.
|
||||||
|
- 변형 대조(mktime·로컬 TZ 로 되돌림): `liveness_reads_lstart_as_utc` 실패.
|
||||||
|
- **exit 4 안내 (e156179)**: in-use stderr 가 두 가지를 출력한다.
|
||||||
|
- "a claude process whose session file under <cfg>/sessions/ names it" (CLAUDE_CONFIG_DIR 을 반영)
|
||||||
|
- "ps -axww | grep <uuid>"
|
||||||
|
- `stop_session.sh:292` 의 `h="$(… 2>&1 >/dev/null)" || rc=$?` 는 `/bin/bash` 3.2 + `set -euo pipefail` 에서 rc 4 와 안내 문구를 올바르게 잡고, 안내가 비었을 때도 스크립트가 계속 진행된다(직접 확인). `bash -n` 통과.
|
||||||
|
- **SKILL.md exit 4 문구**: "Records preserved … the session itself may already be down" 으로 정확해졌다.
|
||||||
|
- **테스트의 실제 /tmp (26fb1c4)**: autouse 픽스처가 `_SYSTEM_TMP` 를 테스트 디렉터리로 패치하고, `test_tests_never_reach_the_real_tmp_root` 가 하위 프로세스까지 확인한다.
|
||||||
|
- **agy 게이트**: 동작 변화는 그대로다(fail-closed). 실제 agy 로는 확인하지 않았다(브리프상 금지).
|
||||||
|
|
||||||
|
## 회귀 관점 (dd292c1 등)
|
||||||
|
|
||||||
|
- `has_artifacts` 는 링크 뒤의 후보도 포함하므로 `purge auto` 가 claude 를 고른다. 이때 purge 는 아무것도 지우지 않고 residual 만 보고한다(위 links 재현: rc 0, `purged` 에는 말단 링크 1개만 있고 `residual` 4개). 의도대로다.
|
||||||
|
- 동작이 바뀌는 지점이 하나 있다(비차단): `projects/<key>` 자체가 심볼릭 링크인 사용자는 이제 transcript jsonl 이 purge 되지 않고 residual 로만 보고된다. 반면 `discover`/`verify_artifact` 는 링크를 따라가 그 대화를 계속 찾는다. P1 요구("링크를 따라가지 않음")의 결과이고 residual 로 드러나므로 수용 가능하지만, SKILL.md 에 한 줄 적어 두면 좋다.
|
||||||
|
- 철자 비대칭(비차단): 소문자 id 로 질의하면 대문자 argv 보유자는 보지 못한다(`claude -r <UPPER>` 로 띄운 경우). claude 가 소문자로 기록하는 한 실질 영향은 없다.
|
||||||
|
- `base.in_use_hints` 추가와 `__main__` 의 in-use stderr 출력은 다른 어댑터에 기본 안내만 준다. 회귀 없음.
|
||||||
|
- `test_a4_adapter_contract` 의 claude id 를 실제 uuid 로 바꾼 것은 새 계약(정규 UUID 만 허용)에 따른 정당한 갱신이다.
|
||||||
|
- Python 3.9 파싱(`feature_version=(3,9)`)과 `/usr/bin/python3` 실행(모든 프로브)에서 문제 없었다.
|
||||||
|
|
||||||
|
## 테스트
|
||||||
|
|
||||||
|
- 검증 경로, `env -i`(HERDR_*·WORKSPACE_ROOT·MAM_*·DELEGATE_JOB_LOGS_DIR·CLAUDE_CONFIG_DIR 미설정), 스크래치패드 basetemp, `PYTHONDONTWRITEBYTECODE=1 -p no:cacheprovider`.
|
||||||
|
- 실행 모듈: `test_issue31_purge_residual, test_issue10_13_purge, test_a4_adapter_contract, test_issue42_skill_blocks, test_issue55_agent_homes, test_orc_onboard, test_uuid_target, test_tier1_unit, test_tier2_component`
|
||||||
|
- 결과: **2 failed (T1), 330 passed, 1 skipped** (630 s).
|
||||||
|
- 사본에서 `test_issue31_purge_residual.py` 를 반복 실행한 결과:
|
||||||
|
- 비변형 사본: 2 failed (T1) / 65 passed.
|
||||||
|
- P2 변형 사본: 첫 실행에서 `test_grok_residual_reports_memory_observations` 가 한 번 ERROR 였다. 이후 재실행 4회에서는 재현되지 않아 일회성으로 보고만 한다.
|
||||||
|
- 6f4f40a 의 제품 파일로 통째로 되돌린 대조는 쓸 수 없었다. autouse 픽스처가 26fb1c4 에서 추가된 `claude._SYSTEM_TMP` 를 패치하므로 67개 모두 setup ERROR 가 난다. 그래서 위의 수정별 변형 대조로 대신했다.
|
||||||
|
|
||||||
|
[VERDICT: NOT PASS]
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"loop_id": "loop-20261013T010208Z-f90dd4",
|
||||||
|
"base_sha": "325b056057fcdd717d2c81017fc5ef57183cb4d0",
|
||||||
|
"head_sha": "db7f4cab9c60ff5db009cf3fc625e9a86a465ef6",
|
||||||
|
"tree_sha": "f32cabd3a6c8d775c9223509967f1a810f0cf827",
|
||||||
|
"review_commit": "db7f4cab9c60ff5db009cf3fc625e9a86a465ef6",
|
||||||
|
"reviewer": "planner-reviewer-claude-01",
|
||||||
|
"job_id": "834fa032",
|
||||||
|
"verdict": "PASS",
|
||||||
|
"transport": "herdr",
|
||||||
|
"evidence": "review-tree",
|
||||||
|
"isolation": "worktree",
|
||||||
|
"verified_at": "2026-10-13T01:21:50Z"
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# 테스트 실제 herdr 서버 누수 수정 2차 리뷰 — 커밋 db7f4ca (job 834fa032)
|
||||||
|
|
||||||
|
- 리뷰어: planner-reviewer-claude-01 (Reviewer)
|
||||||
|
- 리뷰 identity: loop-20261013T010208Z-f90dd4 / base 325b0560 .. head db7f4cab (커밋 1개, 2 파일) / tree f32cabd3
|
||||||
|
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261013T010208Z-f90dd4/1` — HEAD·tree 일치, `git status --porcelain` 0 줄(시작·종료), `.mam` 생성 없음. 본 체크아웃 HEAD 8b0b4a0(main) 변동 없음.
|
||||||
|
- 실행 안전: 1차와 같이 모든 테스트·탐침은 `env -i`(PATH/HOME/LANG/TMPDIR 만, 필요한 변수는 명시 추가)로 돌려 브리프의 `-u` 목록 전부가 비워진다. basetemp 는 scratchpad 아래(이름에 "shim" 없음). 실제 바이너리는 일회용 HOME 에서 `status server` 읽기만(서버 미기동). Python 은 본 체크아웃 `.venv`(3.9.6). 전체 스위트는 돌리지 않았다. 범위는 이 커밋만.
|
||||||
|
|
||||||
|
## 1. 변경
|
||||||
|
- `tests/conftest.py:299`: `mam_sandbox` 가 `HERDR_SESSION` 도 지운다(주석 `:296-297`). 현재 제거 목록: `HERDR_CONFIG_PATH`, `HERDR_PANE_ID`, `HERDR_SERVER_NAME`, `HERDR_SESSION_NAME`, `HERDR_SESSION`, `HERDR_SOCKET_PATH`, `HERDR_TAB_ID`, `HERDR_WORKSPACE_ID`, `XDG_CONFIG_HOME`.
|
||||||
|
- `tests/test_herdr_real_server_leak.py:76`: L2 의 `PANE_VARS` 에 `HERDR_SESSION` 추가. 수정 전 conftest(325b056)에 새 테스트를 얹은 scratch 복사본에서 L2 는 `AssertionError: ['HERDR_SESSION']` 로 실패 → 테스트가 이 변경을 정확히 겨눈다.
|
||||||
|
- Python 3.9 컴파일 통과. 제품 코드 변경 없음.
|
||||||
|
|
||||||
|
## 2. HERDR_SESSION 을 지워서 깨지는 테스트가 있는가 (검토 관점 1)
|
||||||
|
- 소비자: `lib.sh:1425 _herdr_caller_server`, `lib_py/pane_lock.py:49 caller_server`, `lib_py/loop/watcher.py:64` — 셋 다 "있으면 호출자 서버, 없으면 `HERDR_SOCKET_PATH` 모양" 이고 `HERDR_SOCKET_PATH` 는 이미 지워지므로 샌드박스에서는 일관되게 "pane 밖" 으로 판정된다.
|
||||||
|
- 테스트 쪽 bare `HERDR_SESSION` 참조는 전부 테스트가 **스스로** 넣는 값이다(픽스처 뒤라 영향 없음): `test_issue23_24_startup_path.py:264,369,395`(S1g·watcher 케이스는 `env={...}` 로 명시), `test_issue34_job_tree.py:580`, `test_issue30_stall_watch.py:486`, `test_issue45_restore.py:42`(`SCOPE_ENV` 로 이미 `HERDR_SESSION`·`HERDR_ENV` 까지 자체 제거). 반대로 "`HERDR_SESSION` 이 없어야" 하는 테스트(`test_s2b…:188`, S2 계열 "pane 밖 호출자") 는 이제 pane 안에서 돌려도 전제가 보장된다 — 이것이 이 변경의 목적이다.
|
||||||
|
- 실측: 같은 6 파일(`test_herdr_real_server_leak`, `test_issue50_pane_lock`, `test_issue23_24_startup_path`, `test_herdr_shim_contract`, `test_issue45_herdr_config`, `test_issue9_server_env`)을 (a) 깨끗한 환경, (b) 바깥 환경에 `HERDR_SESSION=pane-real-srv` 를 넣은 환경에서 각각 돌렸다: **둘 다 139 passed, 34 skipped** (skip 은 모두 Python 3.9 의 tomllib skip). pane 안/밖 결과가 같다.
|
||||||
|
|
||||||
|
## 3. 다른 라우팅 변수가 남았는가 (검토 관점 2)
|
||||||
|
- 내 pane 이 내보내는 `HERDR_*` 는 `HERDR_BIN_PATH`, `HERDR_CONFIG_PATH`, `HERDR_ENV`, `HERDR_PANE_ID`, `HERDR_SESSION`, `HERDR_SESSION_NAME`, `HERDR_SOCKET_PATH`, `HERDR_TAB_ID`, `HERDR_WORKSPACE_ID` 아홉 개. 샌드박스가 지우지 않는 것은 `HERDR_BIN_PATH`·`HERDR_ENV` 둘뿐이다.
|
||||||
|
- 실제 바이너리(0.9.3) 에 각각을 단독으로 주고 `status server` 를 읽었다: `HERDR_ENV`, `HERDR_BIN_PATH`, `HERDR_CONFIG_PATH`, `HERDR_WORKSPACE_ID`, `HERDR_PANE_ID` 모두 소켓은 `$HOME/.config/herdr/herdr.sock`(가짜 HOME) — 서버 선택에 영향 없음. 1차에서 확인한 대로 소켓을 바꾸는 것은 `HERDR_SOCKET_PATH`(최우선)와 `HERDR_SESSION`(HOME 상대) 둘뿐이며 이제 둘 다 제거된다.
|
||||||
|
- `.agents`·`tests` 에서 `HERDR_BIN_PATH`·`HERDR_ENV` 를 읽는 코드는 없다(`test_issue45_restore.py:42` 가 자체 정리 목록에 넣어 둔 것뿐). 남은 라우팅 변수 없음.
|
||||||
|
|
||||||
|
## 4. 잔류물
|
||||||
|
- scratch 복사본·basetemp·로그는 scratchpad 아래. 일회용 HOME `/tmp/j834-h` 제거(서버 미기동). 실제 `.mam`·`.mam.env`·모니터·실제 서버·`~/.config/herdr`·에이전트 홈은 건드리지 않았다.
|
||||||
|
|
||||||
|
## 결론
|
||||||
|
`HERDR_SESSION` 제거는 호출자 서버 판정을 pane 안/밖에서 동일하게 만들고, 그 값을 쓰는 테스트는 모두 자체 설정이라 깨지지 않는다(두 환경에서 동일하게 139 passed). 실제 바이너리의 서버 선택에 관여하는 변수는 이제 모두 샌드박스에서 제거된다.
|
||||||
|
|
||||||
|
[VERDICT: PASS]
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"loop_id": "loop-20260925T072328Z-2f5853",
|
||||||
|
"base_sha": "ed293dcef4c346e0050a31cb46812bcdcf1d67cf",
|
||||||
|
"head_sha": "e80186f56064eaf93a2f32c48d31d84816894774",
|
||||||
|
"tree_sha": "3501868872d828894f031911b8ca31b01ce77a72",
|
||||||
|
"review_commit": "e80186f56064eaf93a2f32c48d31d84816894774",
|
||||||
|
"reviewer": "planner-reviewer-claude-01",
|
||||||
|
"job_id": "895b0370",
|
||||||
|
"verdict": "PASS",
|
||||||
|
"transport": "herdr",
|
||||||
|
"evidence": "review-tree",
|
||||||
|
"isolation": "worktree",
|
||||||
|
"verified_at": "2026-09-25T07:43:24Z"
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# 🔍 Review Report — PR #38 델타 리뷰 2회차 (job `895b0370`)
|
||||||
|
|
||||||
|
- **Reviewer**: `planner-reviewer-claude-01` (role: planner,reviewer) · 2026-09-25
|
||||||
|
- **대상 델타**: `ed293dc..e80186f` (2커밋, 3파일 `+73/−10`, `git diff --check` 클린), base `main 7a4a0c2` (v4.7.3).
|
||||||
|
- **검증 경로**: `/var/folders/.../T/mam-review/loop-20260925T072328Z-2f5853/1`. **identity 확인**: `HEAD = e80186f5…4774`, `HEAD^{tree} = 35018688…7a72` — 브리프와 일치.
|
||||||
|
- **1회차 내 판정**: PASS + K1~K4 (job `107d435d`). 이번에는 N1/R1(cancelled 사유)·R2(원격 마감 문구)·K1(STRAY_RULE)의 정정을 본다.
|
||||||
|
- **안전 규칙 준수**: 검증 경로·소스 워크트리·본 체크아웃(`main 7a4a0c2`) 모두 수정·커밋·checkout 없음. 실험은 in-process 러너(`env -u MAM_VERSION`, 임시 등록부·감사 로그)와 되돌린 사본에서만. 실제 herdr·세션·`.mam`·데몬 조작 없음, push·merge·PR 상태 변경 없음.
|
||||||
|
|
||||||
|
## 결론 요약
|
||||||
|
|
||||||
|
**세 정정 모두 지적을 닫고, 1회차 판정을 뒤집을 요소는 없다.** 종결 사유 12가지를 `_subjob_state` 에 넣어 분류가 설계대로임을 확인했고(감사 ndjson 경로·`.events.log` 폴백 양쪽), `get_feedback` 의 기본 동작은 그대로다. 되돌린 사본에서 T13×2·T14·T6 만 실패한다. 비차단 관찰 1건(L1).
|
||||||
|
|
||||||
|
## 1. N1/R1 — `cancelled` 사유 (항목 1)
|
||||||
|
|
||||||
|
- `registry.get_feedback(job_id, registry_dir, events=("completed","error"))` (`registry.py:316-317`): 기본값이 종전과 같아 호출자 — 래퍼의 `get-feedback` CLI 3곳(`:556`,`:565`,`:593`)과 `registry.py get-feedback` 서브커맨드(`:668`) — 는 인자 없이 부르므로 **동작 불변**. 실측: `error "first"` 뒤 `cancelled "session_busy"` 가 온 잡에서 기본 호출은 `'first'`, `events=(…,"cancelled")` 는 `'session_busy'`.
|
||||||
|
- `_subjob_state`(`runner.py:684-702`)만 `cancelled` 를 포함해 읽는다(`:691`).
|
||||||
|
- 두 경로 모두 확인: `.events.log` 에만 `cancelled` 가 있는 잡 → `NOT-DISPATCHED/target_not_found`; **감사 ndjson 에만**(`.events.log` 없음, `source_event: cancelled`) 있는 잡 → `NOT-DISPATCHED/session_busy`.
|
||||||
|
|
||||||
|
## 2. R2 — 첫 단어 판정 (항목 1)
|
||||||
|
|
||||||
|
`closed_kind = reason.split(" ", 1)[0]`(`:696`) 을 `ORCHESTRATOR_CLOSED` 와 비교한다. 원격 러너의 실제 문구는 `remote.py:352` 의 `"deadline_timeout (remote runner)"` 다. 오분류 여부를 사유 12가지로 실측:
|
||||||
|
|
||||||
|
| 상태·사유 | 분류 |
|
||||||
|
|---|---|
|
||||||
|
| `error` `deadline_timeout` (래퍼) | CLOSED-UNCONFIRMED |
|
||||||
|
| `error` `deadline_timeout (remote runner)` | **CLOSED-UNCONFIRMED** (R2 정정) |
|
||||||
|
| `error` `agent_blocked` | CLOSED-UNCONFIRMED |
|
||||||
|
| `error` 에이전트 자신의 사유(`tests failed on 3 cases`) | `error`(실패) — 첫 단어 `tests` 는 튜플 밖 |
|
||||||
|
| `error` 사유 없음 | CLOSED-UNCONFIRMED (기존 규칙) |
|
||||||
|
| `cancelled` `target_not_found` / `session_busy` / `rolled back by create_session` | **NOT-DISPATCHED** (N1 정정; `session_busy` 는 첫 단어 판정에 걸리지 않고 cancelled 분기로 간다) |
|
||||||
|
| `cancelled` 사유 없음 / `operator abort` | `cancelled`(실패 집계) |
|
||||||
|
| `completed` `all done` | completed |
|
||||||
|
|
||||||
|
첫 단어 판정이 `session_busy`·`target_not_found`·`rolled` 등을 미확인 종결로 오분류하지 않는다. 남는 이론적 여지는 에이전트가 **자기 사유를 `deadline_timeout`/`agent_blocked` 로 시작하는 문장으로 쓰는 경우**뿐인데, 이 두 단어는 오케스트레이터 예약어라 실무에서 겹치지 않는다(L1, 정보).
|
||||||
|
|
||||||
|
## 3. K1 — `STRAY_RULE` (항목 1)
|
||||||
|
|
||||||
|
`runner.py:41-42` 의 문장은 "귀속되지 않은 열린 잡은 `--parent-job` 없이 등록된 이 작업의 하위 위임일 수 있다; 그 산출물이 작업 목표에 필요하면 NOT PASS" 이고, `_subjobs_block` 은 stray 가 있을 때만 stray 줄 뒤에 붙인다(`:727-729`). SUBJOB_RULE(하위 잡이 있으면 대체 완료 입증이 없는 한 NOT PASS)과 **모순되지 않는다** — stray 는 무관한 잡일 수 있으므로 "필요하면" 이라는 조건부가 맞고, 둘 다 같은 방향(미완 → NOT PASS)이다. stray 가 없으면 프롬프트는 1회차와 바이트 동일(`if not rows and not stray: return ""` 유지).
|
||||||
|
|
||||||
|
## 4. 테스트 (항목 1·2)
|
||||||
|
|
||||||
|
### 4-1. 수정 없이는 실패하는가 — 확인함
|
||||||
|
|
||||||
|
검증 경로 사본에서 `registry.py`·`runner.py` 만 `ed293dc` 판으로 되돌리고 `-k "t13 or t14 or t6 or t5"` 를 돌렸다:
|
||||||
|
|
||||||
|
```
|
||||||
|
4 failed, 3 passed, 13 deselected in 22.39s
|
||||||
|
FAILED test_t13_…[target_not_found] ← N1
|
||||||
|
FAILED test_t13_…[session_busy] ← N1
|
||||||
|
FAILED test_t14_the_remote_runners_deadline_wording_is_closed_unconfirmed ← R2
|
||||||
|
FAILED test_t6_an_unattributed_open_job_is_shown_and_waited_for_only_on_request ← K1 (새 단언)
|
||||||
|
```
|
||||||
|
|
||||||
|
T5/T5b(`deadline_timeout` 정확 일치·늦은 쓰기 무효화)는 양쪽에서 통과 — 기존 분류가 정정으로 바뀌지 않았다. T13·T14 는 실제 `registry.py close` + `publish_event` 로 종결을 만드는 시험이라(mock 아님) 원격 러너의 실제 문구 그대로를 검증한다.
|
||||||
|
|
||||||
|
### 4-2. 검증 경로에서의 실행
|
||||||
|
|
||||||
|
```
|
||||||
|
tests/test_issue34_job_tree.py tests/test_issue5_phase1_delegation.py tests/test_issue14_delivery_ack.py
|
||||||
|
tests/test_loop_cli.py tests/test_issue25_plan_reuse.py tests/test_issue21_loop_b.py
|
||||||
|
→ 158 passed in 271.04s failed 0 · skipped 0
|
||||||
|
```
|
||||||
|
|
||||||
|
새 파일 20건 전부 통과, `registry.py` CLI 스냅샷·help 캡처(issue5 phase1) 통과 → `get_feedback` 시그니처 확장이 CLI 계약을 건드리지 않는다. 실제 `.mam/jobs` 전후 **차이 0**, 검증 경로 tracked 파일 변화 없음(HEAD `e80186f`), 본 체크아웃 `main 7a4a0c2`.
|
||||||
|
|
||||||
|
## 5. 병합 차단 여부 (항목 2)
|
||||||
|
|
||||||
|
1회차 PASS 를 뒤집을 요소는 없다. 델타는 `get_feedback` 의 기본값 보존 인자 하나, `_subjob_state` 의 두 줄, `STRAY_RULE` 한 문장, 회귀 3건이며, 정정 대상만 정확히 실패→통과로 바뀌었다. 버전 락스텝 bump 부재(`lib.sh:32` `4.7.3`, 오케스트레이터가 4.7.4 로 처리 예정) 외에 **병합을 막을 결함은 없다**. 1회차 K2(`MAM_JOB_TREE_MAX` 초과 시 대기 생략)·K3(`target_not_found` 의 rc 5)·K4(폴링마다 전체 스캔)는 이 델타 범위 밖이라 그대로 열려 있다.
|
||||||
|
|
||||||
|
| # | 비차단 관찰 | 근거 |
|
||||||
|
|---|---|---|
|
||||||
|
| L1 | 첫 단어 판정은 에이전트가 자기 사유를 `deadline_timeout`/`agent_blocked` 로 시작해 쓰면 미확인 종결로 본다 — 두 단어는 오케스트레이터 예약어라 실무 충돌은 없다 | `runner.py:696`, §2 표 |
|
||||||
|
|
||||||
|
## 6. 교차검증 가이드
|
||||||
|
|
||||||
|
```bash
|
||||||
|
RP=/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20260925T072328Z-2f5853/1
|
||||||
|
PY=/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.venv/bin/python
|
||||||
|
git -C "$RP" rev-parse HEAD 'HEAD^{tree}' # e80186f… / 3501868…
|
||||||
|
# in-process (env -u MAM_VERSION): 임시 등록부에 12가지 종결 사유를 .events.log / 감사 ndjson 으로 심고 _subjob_state 호출 (§2 표)
|
||||||
|
# 되돌린 사본: registry.py·runner.py 를 ed293dc 판으로 → -k "t13 or t14 or t6 or t5" → 4 failed / 3 passed
|
||||||
|
(cd "$RP" && AGENT_PYTHON_BIN="$PY" PYTHONDONTWRITEBYTECODE=1 "$PY" -m pytest -p no:cacheprovider -q --basetemp=/tmp/bt tests/test_issue34_job_tree.py) # 20 passed
|
||||||
|
```
|
||||||
|
|
||||||
|
[VERDICT: PASS]
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# Issue #60 리뷰 2차 — opencode 2.x 전용 지원 (job 8a485fa0)
|
||||||
|
|
||||||
|
- 리뷰어: planner-reviewer-claude-01
|
||||||
|
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261018T064723Z-79133f/1`
|
||||||
|
- identity: HEAD `cb5f439cce41a2741a6b9974a308a765e50f00ad`, tree `cfec95403cbde10a6340b9007e1246c83be461cd` — 일치, 작업 트리 clean, `.mam` 없음. base..head 4커밋(a311c23, f0d1c00, 6b4b96f, cb5f439). 판정은 base..head 전체에 대해 내린다.
|
||||||
|
- 1차(6b4b96f)에서 확인한 사항(측정 사실↔구현, 실바이너리 `session delete` 의 자식 삭제·cwd 무관·공유 행 보존, `--standalone` 수명, 1.x 가정 잔존 없음)은 cb5f439 가 건드리지 않은 부분이라 그대로 유효하다. 이번에는 cb5f439 를 중심으로 봤다.
|
||||||
|
- 실제 opencode 는 `HOME`/`TMPDIR` 를 scratchpad 로, `XDG_*` 를 unset 하고 `api --standalone`·`session delete --standalone`·`debug paths` 로만 실행했다. 모델 호출 프롬프트는 보내지 않았다. 매 실험 뒤 남은 opencode 프로세스가 없음을 확인했다(사용자 서비스 pid 86524 제외). 실제 경로는 `ls`·`grep`(읽기)으로만 봤다.
|
||||||
|
|
||||||
|
## 결론
|
||||||
|
|
||||||
|
1차 지적 5건의 수정은 모두 동작하고, 테스트가 각각을 잡는다. 하지만 **N4 수정(XDG_DATA_HOME 따르기)이 테스트 격리를 깨는 새 회귀를 만들었다(B1).** 테스트 러너 환경에 `XDG_DATA_HOME` 이 있으면 sandbox 테스트가 sandbox 밖의 opencode 데이터 디렉터리(Linux 라면 사용자의 실제 DB)를 대상으로 삼고, 2개 테스트가 실패한다. 사용자가 다음에 Linux 호환성 시험을 할 예정이고 브리프가 실제 opencode 데이터 접근을 금지하므로 차단으로 본다. 수정은 fixture 두 줄이다(아래). 설계 변경은 필요 없다.
|
||||||
|
|
||||||
|
## B1 (차단). XDG_DATA_HOME 이 설정된 러너에서 sandbox 테스트가 sandbox 밖 DB 를 본다
|
||||||
|
|
||||||
|
원인: `opencode.py:87-94` `_data_home` 은 `ctx.home_dir` 이 `expanduser("~")` 와 같으면 호출자의 `XDG_DATA_HOME` 을 쓴다. 테스트 fixture `mam_sandbox`(`tests/conftest.py:259`)와 `home`(`tests/test_issue31_purge_residual.py:85-96`)는 `HOME` 을 tmp 로 바꾸지만 `XDG_DATA_HOME` 은 지우지 않는다(`conftest.py:307` 은 `XDG_CONFIG_HOME` 만 지움). 그래서 sandbox 의 HOME 이 "자기 홈" 으로 판정되어 러너의 `XDG_DATA_HOME/opencode/opencode.db` 가 선택된다. 1차 head 는 `_run` 이 `XDG_DATA_HOME=<home>/.local/share` 로 강제했으므로 이런 일이 없었다.
|
||||||
|
|
||||||
|
재현(검증 경로, `env -i … XDG_DATA_HOME=<scratchpad>/j8a/xdg`):
|
||||||
|
```
|
||||||
|
pytest tests/test_tier2_component.py tests/test_issue60_opencode_v2.py tests/test_a4_adapter_contract.py tests/test_issue31_purge_residual.py -k opencode
|
||||||
|
FAILED tests/test_tier2_component.py::test_comp_opencode_create_and_reconcile_and_stop
|
||||||
|
assert s_after.get("opencode_session_id_own") == parent → None (drift C 가 sandbox DB 가 아니라 XDG 쪽을 봄)
|
||||||
|
FAILED tests/test_issue31_purge_residual.py::test_opencode_purge_leaves_no_rows
|
||||||
|
'<sandbox>/home/.local/share/opencode/opencode.db (session_v2.id: 1 rows)' in [] (residual 이 XDG 쪽 DB 를 스캔)
|
||||||
|
2 failed, 21 passed
|
||||||
|
```
|
||||||
|
XDG 위치에 미리 DB 를 둔 대조 실험에서도 residual 스캔과 purge 의 DB 해석이 그 DB 를 가리켰다. 이 실험에서는 sandbox 세션 id 가 무작위라 지워진 행은 없었다. 그래도 Linux 에서 `XDG_DATA_HOME` 을 둔 사용자가 스위트를 돌리면:
|
||||||
|
- reconcile 은 실제 DB 를 읽고,
|
||||||
|
- purge 는 실제 DB 에 `DELETE … WHERE id=?` 트랜잭션과 가짜 `session delete` 를 실행할 수 있다.
|
||||||
|
- #55 가드(`tests/_agent_home_guard.py:48`)는 `$HOME/.local/share/opencode` 만 감시하므로 이것을 잡지 못한다.
|
||||||
|
|
||||||
|
수정 확인: 사본에서 `conftest.py` 의 `mam_sandbox` 에 `monkeypatch.delenv("XDG_DATA_HOME", raising=False)` 를, issue31 `home` fixture 에 같은 줄을 넣고 같은 조건으로 실행하자 **22 passed**, XDG 위치에 DB 접근 흔적이 없었다. 아래 N2 의 `--version` 로그만 남았다. `_agent_home_guard` 의 감시 대상에 `XDG_DATA_HOME` 쪽 opencode 디렉터리를 넣는 것도 권한다.
|
||||||
|
|
||||||
|
## 1차 지적 수정 확인
|
||||||
|
|
||||||
|
| 지적 | 수정 | 확인 |
|
||||||
|
|---|---|---|
|
||||||
|
| codex P2: 자식 세션의 1.x 행 잔존, 부모 residual 이 빔 | `_descendants`(`opencode.py:195-217`)가 삭제 **전에** session_v2·1.x session 의 `parent_id` 를 모든 깊이로 따라가고, `_LEGACY_TABLES` 8개를 부모+자손 id 로 정리한다. residual 은 자손을 `[child session <id>]` 로 보고한다 | **실바이너리 재현**: 임시 HOME 에서 `api --standalone` 으로 P→C→G(3단) + 무관 O 를 만들고, 1.x 형 `session(id,parent_id)`·message·part·todo·session_share 를 덧붙여 어댑터 purge 를 실행. `_descendants(P)==[C,G]`, purged `['opencode session (with its child sessions): P', 'sqlite rows for session: P and 2 child session(s)']`, residual P·C·G 모두 `[]`, O 는 7건 그대로. 순환 방지(`cid != uuid and cid not in found`), 읽기 전용 URI(`quote(abspath)`) 확인 |
|
||||||
|
| codex 주의: 사용자 서비스가 DB 를 잡아 모든 purge 가 exit 4 | `conversation_in_use`(`:240-262`)가 argv 검사 후 lsof 보유자 중 `serve --service` 가 아닌 것만 in-use. lsof/ps 예외는 in-use(fail-closed) | in-use CLI 시뮬레이션(임시 HOME, 가짜 opencode): 보유자 없음 → rc 0. `… serve --service` 보유자 → rc 0. `… serve --stdio --port 0` 보유자 → rc 4. argv 에 id → rc 4. 힌트에 lsof 줄 추가됨 |
|
||||||
|
| N4: XDG_DATA_HOME 무시 | `_data_home` | 제품 동작은 의도대로다(`test_the_data_dir_follows…`). 다만 B1, N1 |
|
||||||
|
| agy: 상주 자식 | `resident_children=('opencode',)` | `stall_watch.background_children`(`:118-136`)는 상주 자식을 건너뛰되 그 자식들은 계속 순회하므로, 서버 아래 도구 프로세스는 여전히 작업으로 잡힌다. 1차에서 실측한 서버 자식 이름(`opencode serve --stdio --port 0`, argv0 basename `opencode`)과 맞음 |
|
||||||
|
| claude N1·N2 | 실바이너리 테스트가 `_bin` 을 `REAL` 로 고정. tier2 가 더 늦은 자식 세션을 시드하고 부모 고정을 단언 | PATH 에 opencode 가 없는 `env -i` 로 `test_real_opencode_2x_contract` 통과(아래 테스트 결과) |
|
||||||
|
|
||||||
|
뮤테이션(git archive 사본, `-k "opencode and not real_opencode"`, issue60·issue31·tier2):
|
||||||
|
|
||||||
|
| 변형 | 실패 |
|
||||||
|
|---|---|
|
||||||
|
| 자손 제거(`ids=[uuid]`) | 1 (`test_purge_takes_the_1x_rows_of_the_child_sessions_too`) |
|
||||||
|
| residual 의 자손 보고 제거 | 2 |
|
||||||
|
| 서비스 제외 제거 | 1 (`test_the_background_service_alone…`) |
|
||||||
|
| XDG 무시 | 1 |
|
||||||
|
| resident `()` | 1 |
|
||||||
|
| reconcile drift C `parent_id IS NULL` 제거 | 1 (tier2 — 1차 N2 해소) |
|
||||||
|
| `_LEGACY_TABLES` 에서 todo·session_share 제거 | 2 |
|
||||||
|
|
||||||
|
## 테스트 실행 (검증 경로, `env -i`, PATH 에 opencode 없음, basetemp 는 scratchpad, `XDG_DATA_HOME` 미설정)
|
||||||
|
|
||||||
|
- 관련 12개 모듈(a4, issue31, issue10_13, issue60, tier1, tier2, issue5_phase0, issue23_24, issue6, c1, issue9, issue30_stall_watch): **473 passed, 1 skipped in 1010 s**, rc 0. skip 은 `/proc` 전용 테스트(macOS). 1차의 N1 이 해소되어 PATH 없이도 실바이너리 계약 테스트가 통과한다. #55 오탐 없음. 실행 뒤 남은 opencode 프로세스 없음.
|
||||||
|
- 같은 테스트를 `XDG_DATA_HOME=<scratchpad>` 로 실행하면 2 failed — B1.
|
||||||
|
|
||||||
|
## 비차단
|
||||||
|
|
||||||
|
**N1. XDG 를 따르게 되면서 status.sh 가 어긋난다.** `status.sh:108`, `:318` 은 여전히 `{home}/.local/share/opencode` 고정이다(reconcile 의 `:917` 폴백도 같으나 `_resolve_db` 가 항상 경로를 돌려줘 닿지 않음). Linux 에서 `XDG_DATA_HOME` 을 둔 사용자라면 reconcile 은 XDG 쪽 DB 에서 id 를 고정하는데 status 는 같은 세션을 `MISSING` 으로 보인다. 표시만의 문제지만 N4 수정과 같은 범위이므로 status 도 어댑터의 `_resolve_db` 를 쓰는 편이 낫다. `auth_ok`·create_session.sh 의 auth.json 검사는 브리프대로 의도적 비변경으로 보았다.
|
||||||
|
|
||||||
|
**N2. (기존, f0d1c00 부터) `test_issue60_opencode_v2.py` 를 import 하기만 해도 실제 opencode 가 실제 HOME 으로 실행된다.** `_real_opencode()`(`:~300`)의 `--version` 호출이 모듈 import 시점에 러너 환경 그대로 돈다. 그 결과 실제 `~/.local/share/opencode/log/opencode.log` 에 `args="[\"--version\"]"` 줄이 붙는다. 읽기로 확인: 2026-10-18T06:46~06:47Z 에 여러 줄. 내 1차 리뷰 실행도 같은 줄을 남겼음을 밝혀 둔다. 데이터 손상은 아니지만 "실제 ~/.local/share/opencode 쓰기 금지" 에 걸리므로, 버전 확인도 임시 HOME/XDG 로 돌리거나 테스트 본문(임시 HOME 설정 뒤)으로 옮기기를 권한다.
|
||||||
|
|
||||||
|
**N3. 서비스 제외의 한계(의도된 설계로 보임).**
|
||||||
|
- 다른 워크스페이스의 MAM opencode 세션(`--standalone` 서버)이 하나라도 떠 있으면, 그 세션과 무관한 purge 도 여전히 exit 4 다. 안전한 쪽의 오탐이다.
|
||||||
|
- 반대로, 사용자가 서비스 모드 TUI 나 웹 UI 로 MAM 세션을 id 없이 열고 있으면 그 사용은 보이지 않는다. MAM 은 `--standalone` 으로만 띄우므로 실사용 위험은 낮다.
|
||||||
|
- Linux 에 lsof 가 없으면 DB 보유자 검사가 통째로 빠진다. base 와 같다.
|
||||||
|
|
||||||
|
## Python 3.9 / bash / Linux
|
||||||
|
|
||||||
|
- 변경 파일은 Python 3.9 로 파싱된다(`/usr/bin/python3` 3.9.6, venv 3.9.6). f-string 안의 따옴표 중첩 없음, `from urllib.parse import quote` 는 표준.
|
||||||
|
- `ps -o command= -p` 는 procps-ng 에서도 지원된다.
|
||||||
|
- resident 테스트의 `exec -a opencode` 는 bash 기능이라 Linux 에서도 동작한다.
|
||||||
|
- 셸 스크립트는 cb5f439 에서 바뀌지 않았다.
|
||||||
|
- `git diff --check` 깨끗.
|
||||||
|
|
||||||
|
## 판정
|
||||||
|
|
||||||
|
B1 은 fixture 두 줄로 고쳐지고 수정 효과도 확인했다. 그러나 Linux 시험 직전에 사용자 실제 데이터 접근 가능성을 만드는 테스트 격리 회귀이므로 차단으로 둔다. 그 밖의 수정은 모두 PASS 수준이다.
|
||||||
|
|
||||||
|
[VERDICT: NOT PASS]
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"loop_id": "loop-20261013T025132Z-60f239",
|
||||||
|
"base_sha": "ae2813773c973daedd2275cb55ad340a682c3fae",
|
||||||
|
"head_sha": "62da8056680fca939a2aff924037ed48b5f74afa",
|
||||||
|
"tree_sha": "f549246ffbe2d5e58cb7e33c212a383cd429dce5",
|
||||||
|
"review_commit": "62da8056680fca939a2aff924037ed48b5f74afa",
|
||||||
|
"reviewer": "planner-reviewer-claude-01",
|
||||||
|
"job_id": "8b2392ce",
|
||||||
|
"verdict": "PASS",
|
||||||
|
"transport": "herdr",
|
||||||
|
"evidence": "review-tree",
|
||||||
|
"isolation": "worktree",
|
||||||
|
"verified_at": "2026-10-13T02:56:58Z"
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# README.md / README.ko.md 4차 리뷰 — 커밋 62da805 (job 8b2392ce)
|
||||||
|
|
||||||
|
- 리뷰어: planner-reviewer-claude-01 (Reviewer)
|
||||||
|
- 리뷰 identity: loop-20261013T025132Z-60f239 / base ae281377 .. head 62da8056 (커밋 1개, 두 README 각 2줄) / tree f549246f
|
||||||
|
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261013T025132Z-60f239/1` — HEAD·tree 일치, `git status --porcelain` 0 줄(시작·종료). 본 체크아웃 HEAD 2ccf439(main) 변동 없음.
|
||||||
|
- 방법: 바뀐 두 문장만 코드와 대조. `generate-env.sh`·설치기는 실행하지 않았고, 테스트도 돌리지 않았다(픽스처 집계는 `pytest --co` 에 수집 전용 플러그인을 얹어 `fixturenames` 만 셌다; `env -i`, basetemp 는 scratchpad). 실제 서버·세션·`.mam`·`.mam.env`·에이전트 홈 미접촉.
|
||||||
|
|
||||||
|
## 1. 두 문장의 코드 대조
|
||||||
|
|
||||||
|
| 문장 (README.md / README.ko.md 같은 줄) | 근거 | 판정 |
|
||||||
|
|---|---|---|
|
||||||
|
| **:41** `generate-env.sh` 는 `.mam.env` 가 없을 때 `.mam.env.example` 로 만들고, `--force` 는 `.mam.env.bak` 을 남기고 덮어씀 | `deploy/generate-env.sh:54-64`: `[ -f "$DST" ] && [ "$FORCE" != "1" ]` → "no-op … (use --force to overwrite)" `exit 0`; `[ -f "$DST" ] && [ "$FORCE" = "1" ]` → `cp -p "$DST" "$DST.bak"`; 그 뒤 `cp "$SRC" "$DST"`. 머리 주석(`:5-7`)과도 일치 | ✓ |
|
||||||
|
| **:175** 모든 테스트는 임시 잡 레지스트리·감사 로그; 약 3분의 1 이 임시 HOME 샌드박스 픽스처, 그보다 적은 수가 mock herdr | 이 head 에서 수집 1901 개 중 `fixturenames` 에 `mam_sandbox` 포함 **575 (30 %)**, `mock_herdr` 포함 **186 (9 %)**(`mock_agents` 125). grok 집계(1708 중 573/196)와 모수만 다르고 비율은 같다. "약 3분의 1" 과 "그보다 적은 수" 모두 맞음. 전체 테스트의 임시 레지스트리·로그는 `conftest.py:170-181` 의 autouse 픽스처(3차에서 확인) | ✓ |
|
||||||
|
|
||||||
|
## 2. 두 언어의 일치
|
||||||
|
- 두 문장 모두 영문·한국어가 같은 조건·같은 수치를 말한다(없을 때 생성 / `--force` 는 `.bak` 남기고 덮어씀; 약 3분의 1 / 그보다 적은 수).
|
||||||
|
- 구조(head): 제목 18/18, 표 행 17/17, 코드 블록 7/7(번역 주석 블록 외 동일), 숫자 집합 동일, 백틱 토큰 차이는 `MULTI_AGENT_RULES.md` ↔ `.ko.md` 뿐.
|
||||||
|
|
||||||
|
## 결론
|
||||||
|
grok 21063f28 의 두 지적이 코드와 맞게 고쳐졌고 두 언어가 같다. 앞 라운드에서 내가 남겼던 `generate-env.sh` 메모도 이 커밋으로 해소됐다. 남은 지적 없음.
|
||||||
|
|
||||||
|
[VERDICT: PASS]
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"loop_id": "loop-20261003T230658Z-e7866a",
|
||||||
|
"base_sha": "8d26106195a0a24621f140e76389d206332f84c0",
|
||||||
|
"head_sha": "17eba0904ebbf54c1d2243ad61a6f2ba17263889",
|
||||||
|
"tree_sha": "a0f1ffede44be7c86267a56b25871e1d081feafb",
|
||||||
|
"review_commit": "17eba0904ebbf54c1d2243ad61a6f2ba17263889",
|
||||||
|
"reviewer": "planner-reviewer-claude-01",
|
||||||
|
"job_id": "95b63371",
|
||||||
|
"verdict": "PASS",
|
||||||
|
"transport": "herdr",
|
||||||
|
"evidence": "review-tree",
|
||||||
|
"isolation": "worktree",
|
||||||
|
"verified_at": "2026-10-03T23:35:18Z"
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# 리뷰 리포트 — Job 95b63371 (PR #46 2회차 델타, 8d26106..17eba09)
|
||||||
|
|
||||||
|
- 리뷰어: planner-reviewer-claude-01 (claude)
|
||||||
|
- 리뷰 identity: loop-20261003T230658Z-e7866a / base 8d26106 / head 17eba09 / tree a0f1ffe — 검증 경로에서 `git rev-parse HEAD 'HEAD^{tree}'` 일치, 미커밋 변경 없음
|
||||||
|
- 범위: 커밋 17eba09 한 개. `git diff --stat 8d26106 17eba09 -- . ':!*.md' ':!tests'` 결과가 비어 있음 → 제품 코드·버전 변경 없음(항목 4 ✓)
|
||||||
|
|
||||||
|
## 결론 요약
|
||||||
|
codex R1~R3, claude O1·O4 가 모두 닫혔다. 차단 결함 없음. 비차단 관찰 2건(§5).
|
||||||
|
|
||||||
|
## 1. 지적 사항 종결 여부
|
||||||
|
| 지적 | 수정 | 확인 |
|
||||||
|
|---|---|---|
|
||||||
|
| codex R1 (attach 대상 서버) | resume SKILL.md Case 3·Workflow 설명·When NOT 이 `herdr --session <server> agent attach <name>` 로 서버를 명시, Verification 이 행에서 SERVER 를 읽어 `--session "$SERVER"` 사용 | ✓ 아래 §2 |
|
||||||
|
| codex R2 (--dry-run 문구) | create·resume 옵션 표: "no spawn and no registry (YAML/DB) update; 앞선 검사는 돌고 macOS 에서 quarantine 속성을 지운다" | ✓ create_session.sh:196 xattr → :280 dry-run 종료, spawn() 은 :264-274 정의만, YAML 쓰기 :305·:491 은 그 뒤. resume_session.sh:169 xattr → :225 dry-run 종료, spawn :237 |
|
||||||
|
| codex R3 (삭제 본문 복귀 가드) | test_no_deleted_body_came_back 이 DATA["blocks"] 를 읽지 않고 현재 모든 블록을 removed+superseded 의 base_sha / base_fingerprint 와 비교 | ✓ §3 탐침 |
|
||||||
|
| claude O1 (create:263-266 누락) | superseded 에 class c / invocation, base_sha ceb96a8c1293 으로 기록 | ✓ base 45d1621 에서 parse_blocks 로 다시 계산한 sha·fingerprint 일치 (removed 5 + superseded 1 모두 True/True) |
|
||||||
|
| claude O4 (표 문구) | create 42 에 `MAM_KILL_ON_READY_TIMEOUT=1` 경우, stop 0 에 "--purge-conversation 에서는 no-op 아님" | ✓ create_session.sh:388-398, stop_session.sh:185 와 일치 |
|
||||||
|
|
||||||
|
## 2. R1 — 안내 명령이 실제로 격리 서버로 가는가
|
||||||
|
herdr 서버를 건드리지 않도록 `--help` 의 Logs 경로(세션 해석 결과)만으로 확인했다(`env -i` 로 상속 변수 제거):
|
||||||
|
| 호출 | 해석된 세션 디렉터리 |
|
||||||
|
|---|---|
|
||||||
|
| `herdr --help` | ~/.config/herdr/ (기본 서버) |
|
||||||
|
| `herdr --session zz-probe --help` | ~/.config/herdr/sessions/zz-probe/ |
|
||||||
|
| `herdr --session default --help` | ~/.config/herdr/ (기본 서버와 동일) |
|
||||||
|
| `HERDR_SESSION=zz-probe herdr --help` | sessions/zz-probe/ |
|
||||||
|
| `HERDR_SESSION_NAME=zz-probe herdr --help` | ~/.config/herdr/ (무시됨) |
|
||||||
|
| `herdr --session "" --help` | `error: session name cannot be empty` |
|
||||||
|
(탐침 뒤 ~/.config/herdr/sessions 에 zz-probe·default 디렉터리 생성 없음 확인.)
|
||||||
|
|
||||||
|
- 따라서 `herdr --session <row.herdr_session> agent attach` 는 그 격리 서버로 간다. 행이 `herdr_session: default` 여도 `--session default` 가 기본 서버로 해석되므로 맞다. lib.sh shim(`_real_herdr`, lib.sh:255-268)이 default 일 때 `--session` 을 빼는 것과 결과가 같다.
|
||||||
|
- resume_session.sh 가 export 하는 것은 `HERDR_SESSION_NAME` 이고 native herdr 는 이를 읽지 않으므로(위 표), 서버 명시가 필요하다는 수정 근거가 맞다.
|
||||||
|
- Verification SERVER 계산: 행에 herdr_session 없음 → `'default'` → 기본 서버(올바름). 행 자체가 없음 → python IndexError, SERVER="" → herdr 가 "session name cannot be empty" 로 즉시 실패. 검증 블록으로서는 조용히 엉뚱한 서버를 보는 것보다 낫다. 행 기록자는 create_session.sh:524-525, update_yaml_resumed.sh:140-141·155-156 에서 herdr_session 과 herdr_server 를 항상 같이 쓰므로 herdr_session 만 읽어도 된다.
|
||||||
|
|
||||||
|
## 3. R3 가드 — 견고성과 엄격성(스크래치 사본 변이 시험)
|
||||||
|
| 변이 | 새 테스트(17eba09) | 이전 테스트(8d26106) |
|
||||||
|
|---|---|---|
|
||||||
|
| 기준 | 31 passed / 1 skipped | — |
|
||||||
|
| codex 탐침: base resume Workflow 본문(45d1621 :59-118)을 목록 항목 안에 2칸 들여 다시 넣고 인벤토리에 class c 로 등록 | **1 failed** (test_no_deleted_body_came_back) | 28 passed (못 잡음) |
|
||||||
|
| 정상 편집: create 에 새로운(다른) 호출 예시 추가 | R3 가드 통과, 인벤토리 테스트만 실패(의도된 동작) | — |
|
||||||
|
| 삭제 본문 + 주석 한 줄을 앞에 붙여 다시 넣음 | R3 가드 통과(인벤토리 테스트만 실패) | — |
|
||||||
|
- fingerprint(공백 접기 후 sha256 12자)는 재들여쓰기·줄 바꿈 변화에 강하고, 정확 일치만 보므로 다른 예시를 막지 않는다. 정상 편집을 막을 정도로 엄격하지 않다.
|
||||||
|
- test_a_returned_body_is_caught_reindented_and_with_the_inventory_updated 는 가드 함수 자체를 현재 stop 블록으로 검사한다(정확 sha + 재들여쓰기 fingerprint) — 유효.
|
||||||
|
|
||||||
|
## 4. 인벤토리 재생성
|
||||||
|
8d26106 과 17eba09 의 skill_code_blocks.json 비교: blocks 33개의 (skill, class, kind) 순서가 **동일**. 바뀐 값은 resume Verification 블록(start 103)의 sha·end(124→128) 뿐이고, removed 5개는 base_fingerprint 추가만, entry_points 동일, superseded 신설. 분류 변경 없음(항목 3 ✓).
|
||||||
|
|
||||||
|
## 5. 비차단 관찰
|
||||||
|
- **N1 가드의 한계**: R3 가드는 블록 본문 전체 일치만 본다. 삭제된 본문 앞에 한 줄만 붙여도(§3 셋째 행) 인벤토리를 갱신하면 통과한다. 이번 지적(R3)의 범위인 "인벤토리 재등록으로 숨기기"는 막았으므로 차단은 아니며, 필요하면 후속에서 줄 단위 포함 비율로 넓힐 수 있다.
|
||||||
|
- **N2 문구 정밀도**: resume SKILL.md Workflow 설명의 "the native `herdr` binary does not route on environment variables" 는 정확히는 "`HERDR_SESSION_NAME` 으로는 라우팅하지 않는다"이다 — native herdr 는 `HERDR_SESSION` 을 읽는다(§2 표). 특히 herdr 패널 안에서는 `HERDR_SESSION` 이 호출자 서버로 설정되어 있어, 서버를 빼면 호출자 서버로 간다. 결론(`--session` 명시)은 그대로 옳다.
|
||||||
|
- 미반영으로 명시된 codex C2, claude O2·O3 는 후속 범위로 동의한다.
|
||||||
|
|
||||||
|
## 6. 테스트
|
||||||
|
검증 경로에서 지정 6개 파일: **132 passed / 0 failed / 1 skipped** (300 s, rc 0). skip 은 test_claude_skills_link_resolves_to_agents_skills(워크트리, 정상). test_issue42_skill_blocks.py 단독 31 passed / 1 skipped. MQTT_* unset, .mam.env 미export. 실제 .mam/jobs 항목 수 변화 +0.
|
||||||
|
|
||||||
|
[VERDICT: PASS]
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# Issue #5 Phase 2b — Round 4 교차 리뷰 (Codex Round 3 R4 후속 결함 수정)
|
||||||
|
|
||||||
|
- **Job**: a665d729 / Reviewer: planner-reviewer-claude-01
|
||||||
|
- **리뷰 대상**: 커밋 `8d7e010` "fix(issue-5): record an accepted intent, not a refused one" (기준 `9a651cc`)
|
||||||
|
- **범위 고정**: brief에 내장된 diff와 `git diff 9a651cc..HEAD -- . ':(exclude).agents/reports'`를 바이트 비교 → **동일**(15,916 B). 변경 파일 4개: `lib_py/delegation/job_subscriber.py`, `docs/contracts/issue-5/phase2b-autocomplete.md`, `docs/contracts/issue-5/task.md`, `tests/test_issue5_phase2b_autocomplete.py`.
|
||||||
|
- **동결 스냅샷**: `mam-loop-freeze.03Uqb9`의 `job_subscriber.py` md5 = `9a651cc` 시점과 동일(수정 **전**). engine/notifier/autocomplete/lib.sh는 HEAD와 동일. 즉 이 루프 자신의 구독자 데몬(pid 10863)은 수정 전 코드를 실행 중이며, 수정은 다음 루프 기동(새 동결)부터 효력을 갖는다. B-13 설계상 당연한 결과이고 diff의 결함이 아니다.
|
||||||
|
|
||||||
|
## 1. 지적 사항 대조 (Codex Round 3)
|
||||||
|
|
||||||
|
지적: `_Watcher.on_message`에서 `seen_intents[jid].add(identity)`가 단조 seq 가드보다 **앞서** 실행되어, 늦게 도착한 과거 패킷이 자기 intent를 "본 것"으로 태우고 뒤따르는 정당한 재시도가 드롭된다.
|
||||||
|
|
||||||
|
수정 후 순서(`job_subscriber.py:144-179`, 직접 확인):
|
||||||
|
1. `data`가 dict가 아니면 intent 없음으로 취급(이전 코드의 `(data or {}).get` 경로 제거)
|
||||||
|
2. intent가 있으나 dict가 아니면 **드롭+경고**
|
||||||
|
3. identity 튜플 생성 → `in seen_intents` 대조를 `try/except TypeError`로 감싸 해시 불가면 **드롭+경고**
|
||||||
|
4. 이미 본 identity면 드롭(seq 바닥을 올리지 않음)
|
||||||
|
5. seq 단조 가드
|
||||||
|
6. **가드 통과 후에만** `seen_intents[jid].add(identity)`
|
||||||
|
7. 감사 로그 `received` 기록 → 큐 투입
|
||||||
|
|
||||||
|
brief의 요구 2)·3) 모두 충족. 요구 3)의 시나리오(`last_seq=10`, seq=9 거부 → 동일 intent seq=11 수신 → `qsize()==1`)가 `test_r4b_a_stale_packet_does_not_burn_the_intent_for_its_own_retry`에 그대로 있다.
|
||||||
|
|
||||||
|
## 2. 실측 재현
|
||||||
|
|
||||||
|
### 2-1. 신규 테스트 6종 — HEAD vs 수정 전 트리
|
||||||
|
`9a651cc` 워크트리에 HEAD의 테스트 파일을 복사해 실행:
|
||||||
|
|
||||||
|
| 결과 | 테스트 |
|
||||||
|
|---|---|
|
||||||
|
| 수정 전 **FAIL** | `stale_packet_does_not_burn_the_intent` (`seen_intents == {(7,'completed','T')}` 잔존), `unhashable_intent_is_refused` (`TypeError: unhashable type: 'list'` 콜백 탈출), `non_object_intent_is_refused` |
|
||||||
|
| 수정 전 PASS(보존 가드) | `duplicate_intent_does_not_advance_the_sequence_floor`, `absent_or_null_intent`, `real_retry_is_deduplicated_by_the_real_consumer` |
|
||||||
|
|
||||||
|
task.md P2B-07의 "3종 역검증 실패 / 3종 보존" 주장과 **정확히 일치**. HEAD에서는 파일 전체 65 passed.
|
||||||
|
|
||||||
|
### 2-2. 테스트 밖 엣지 — 실제 콜백에 직접 투입 (HEAD / 수정 전)
|
||||||
|
|
||||||
|
| 입력 | 수정 전 | HEAD |
|
||||||
|
|---|---|---|
|
||||||
|
| `data`가 비어있지 않은 list / str | **`AttributeError` 콜백 탈출** | 정상 수신(intent 없음 취급) |
|
||||||
|
| intent의 `event` 필드가 dict(중첩 해시 불가) | **`TypeError` 콜백 탈출** | 드롭+경고, `last_seq` 불변 |
|
||||||
|
| 같은 wire seq QoS1 재전달(+intent) | 1건 | 1건 |
|
||||||
|
| 거부된 replay(seq 9) 뒤 바닥(1)보다 크고 9보다 작은 정상 이벤트(seq 4) | 수신 | 수신 |
|
||||||
|
| 필드가 전부 없는 intent `{}` 두 번 | 1건 수신, `(None,None,None)` 기록 | 동일 (O-1 참조) |
|
||||||
|
|
||||||
|
`data`가 비객체인 경우는 task.md가 주장하지 않은 **추가 탈출 경로**인데 이번 수정으로 함께 닫혔다(paho 네트워크 스레드에서 콜백 예외는 해당 구독자의 모든 job 수신을 끊으므로 실제 가치가 있다).
|
||||||
|
|
||||||
|
### 2-3. 종단 테스트의 전제 확인
|
||||||
|
`test_r4b_a_real_retry_...`는 도달 불가 브로커(127.0.0.1:65432)로 `notifier.flush` 2회 후 감사 로그의 `published` 항목 2건을 실제 콜백에 투입한다. `publish_event.record_outcome`이 발행 성공 여부와 무관하게 `published` 감사 항목을 기록(G-1/G-2/G-4)함을 코드로 확인했으므로 전제가 성립하며, 두 항목의 wire seq가 다르고 `notification_intent`가 같음 → 1건만 큐에 남는다.
|
||||||
|
|
||||||
|
### 2-4. 린트·정합성
|
||||||
|
- `py_compile` OK. pyflakes/ruff는 venv에 없고 저장소에 line-length 설정도 없음(pyproject/setup.cfg/ruff.toml 부재). 신규 줄 중 `job_subscriber.py:161`(102자)이 100자를 넘지만 기존 줄(98·170·227·228)도 이미 넘으므로 관례 위반 아님.
|
||||||
|
- 테스트 실행 후 `git status` clean(샌드박스에 격리됨). HEAD 수집 총계 732 = 726 + 6.
|
||||||
|
|
||||||
|
## 3. 판단
|
||||||
|
|
||||||
|
**결함 없음(blocking).** 지적된 순서 역전은 "대조는 가드 앞, 기록은 가드 뒤"로 정확히 고쳐졌고, 그 논리(중복은 시퀀스를 소비할 자격이 없고, 전달 안 된 이벤트는 본 것이 아니다)가 코드 주석·계약 문서·테스트에 일관되게 적혀 있다. 방어적 파싱 강화는 콜백 예외 탈출 경로 3가지(비객체 intent, 해시 불가 필드, 비객체 `data`)를 닫았다.
|
||||||
|
|
||||||
|
## 4. 비차단 관찰 (다음 라운드/Phase 3 참고)
|
||||||
|
|
||||||
|
- **O-1. 식별 필드가 전부 없는 intent는 "안정된 신원"이 아니다.** `{}`나 `{"foo":1}`은 identity `(None,None,None)`을 얻어 **첫 번째만 수신**되고 이후 같은 꼴은 모두 replay로 드롭된다. 정상 발행자(`notifier.intent_for`)는 세 필드를 항상 채우므로 실제 경로에서는 발생하지 않고, 발생하더라도 방향이 fail-closed(드롭)라 이중 적용은 없다. 다만 phase2b-autocomplete.md의 새 문장 "안정된 신원을 만들 수 없는 intent는 거부한다"와 코드가 정확히 맞으려면 `identity` 생성 직후 `if any(f is None for f in identity): 드롭+경고` 한 줄이면 된다. **검증**: HEAD 워크트리에 이 4줄을 적용하고 phase2b·phase1 테스트 131 passed, `{}`/`{"foo":1}`는 드롭되고 정상 intent는 1회 수신됨을 확인.
|
||||||
|
- **O-2. task.md P2B-07 문구의 개수.** "신규 회귀 6종(3종 실패 역검증, 3종 보존) + 종단 테스트"로 읽히지만 종단 테스트는 6종에 **포함**된다(`test_r4b_` 함수는 정확히 6개, 보존 3종 중 하나가 종단 테스트). "6종(그중 1종은 종단)"으로 고치면 된다.
|
||||||
|
- **O-3. 운영 노트.** 동결 스냅샷 원리상 현재 실행 중인 루프의 구독자는 수정 전 코드다. 이 라운드의 알림 재시도가 stale-first 상황을 만나면 옛 결함이 재현될 수 있으나, 재시작 후 디스크 폴백 + 멱등 ACK가 종단을 보증한다(기존 계약). 조치 불필요, 인지만.
|
||||||
|
- 이전 라운드 인계 사항(O-7 direct 잡의 `claim` 토큰 발급, `_delegate_py_bin` 앵커링, redispatch 시 shadow dispatch 갱신, `preexec_fn`)은 이번 diff 범위 밖이며 그대로 유효하다.
|
||||||
|
|
||||||
|
## 5. 전체 스위트
|
||||||
|
|
||||||
|
`.venv/bin/python -m pytest tests -q -p no:cacheprovider` (백그라운드 1회, 정상 종료): **732 passed in 802.02s (0:13:22)**, FAILED/ERROR 0건. 이전 라운드 726 → 732 (+6, 신규 `test_r4b_*` 6종). 이번에는 이전 라운드(19ac4c53)에서 있었던 비정상 종료가 재현되지 않았다.
|
||||||
|
|
||||||
|
[VERDICT: PASS]
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# 🔍 Review Report — Job aa16fa5c (branch `fix/issue-10-11-13-15` 루프 2회차, HEAD `fae70c1`)
|
||||||
|
|
||||||
|
- **Reviewer**: `planner-reviewer-claude-01` (role: planner,reviewer) · 2026-09-13
|
||||||
|
- **대상**: `git log bd3df89..HEAD` — `c82ea75`, `0282312`(release v4.4.3), `7610258`(N1), `f5a6575`(테스트 uuid4), **`fae70c1`**(codex R1/R2). 앞선 라운드에서 PASS 한 부분은 회귀 여부 중심으로, 새 커밋 3건 중 `fae70c1` 은 정밀 검토했다(`7610258`·`f5a6575` 는 앞선 라운드에서 정밀 검토했고 이번에는 회귀만 확인).
|
||||||
|
- **방법**:
|
||||||
|
- 코드 정독
|
||||||
|
- **샌드박스 재현**: 임시 HOME 을 `$TMPDIR` 아래 생성하고 실행 전 `resolve_home()` 이 그곳인지 확인했다. 대화 id 는 모두 런타임 `uuid4()`.
|
||||||
|
- 선택 테스트(실제 `.mam/jobs` 스냅샷 비교)
|
||||||
|
- 정적 검사
|
||||||
|
- 리뷰 중 45초 간격 `progress` heartbeat 발행. 코드 수정 없음.
|
||||||
|
- **안전 규칙 준수**: 실제 홈이나 등록된 orchestrator UUID 로 purge 를 실행하지 않았다. `../multi-agent-mux-b2` 는 건드리지 않았다.
|
||||||
|
|
||||||
|
## 0. 직전 루프(반복 3, HEAD `f5a6575`) 결과와 처리
|
||||||
|
|
||||||
|
| 리뷰어 | 판정 |
|
||||||
|
|---|---|
|
||||||
|
| `planner-reviewer-claude-01`(`037af8e0`) · `reviewer-agy-01`(`433a74dc`) · `reviewer-grok-01`(`1752c893`) | PASS |
|
||||||
|
| `planner-reviewer-codex-01`(`f315859f`) | **NOT PASS** — R1: agy in-use 검사가 DB 만 `lsof` 에 넘겨 presence lock 만 연 보유자를 놓침 / R2: DB 가 없는 고아 부속 파일은 purge 대상으로 해결되지 않음 |
|
||||||
|
|
||||||
|
creator 는 반론 없이 `fae70c1` 로 두 지적을 반영하고 테스트 2개를 더했다. 검증 잡(`9c3e6f9b`)에서 250 passed 를 확인했고, `VERSIONS.md` 의 신규 테스트 수가 낡았다고 스스로 기록했다(§2-(3)).
|
||||||
|
|
||||||
|
## 1. 새 커밋 정밀 검토
|
||||||
|
|
||||||
|
### (1) `7610258` `base.conversation_in_use` — ✅ (회귀 없음, 잔여 한계 R-1)
|
||||||
|
- **세션 env 검사**: `AGENT_SESSION_ENV` 5종 중 하나가 uuid 와 같으면 즉시 사용 중이다. 앞선 라운드에서 이 세션으로 실측했다(env 만으로도, env 없이 argv 만으로도 True).
|
||||||
|
- **헬퍼 사슬 제외는 첫 에이전트 CLI 조상에서 멈춘다.** `_is_agent_cli` 는 첫 옵션 이전 토큰의 basename 을 본다. 토큰 판독 사례:
|
||||||
|
- `node …/claude …`, `env X=1 claude …`, `/bin/sh …/agy …` → 에이전트 ✓
|
||||||
|
- `sh -c "…"`, `bash orc_onboard.sh --remove …`, `python -m lib_py.agents purge …` → 에이전트 아님(첫 옵션에서 판독 중단) ✓
|
||||||
|
- **오탐**: 위치 인자의 basename 이 우연히 에이전트 이름인 비-에이전트 프로세스(`vim notes/claude`, `python tools/codex`)는 에이전트로 판정된다. 다만 걷기가 거기서 멈춰도 purge 와 `orc_onboard.sh` 는 그 아래 헬퍼 사슬에 남으므로, 그런 프로세스가 둘 사이에 끼어야만 영향이 있다. 사실상 영향 없음.
|
||||||
|
- **미탐(R-1, 앞선 라운드 재현 A)**: 에이전트 이름으로 보이지 않는 조상(`python -m <agent>`, `node …/cli.js`)이 uuid 를 argv 에 갖고 있고 세션 env 도 없으면 헬퍼 사슬로 제외된다. 현재 배포(claude)는 이름과 env 양쪽으로 잡히므로 비차단이다. "알려진 헬퍼만 제외" 방식을 후속으로 권장한다.
|
||||||
|
|
||||||
|
### (2) `fae70c1` agy in-use · `has_artifacts` · 해결기 2단계 — ✅ (잔여 한계 R-a)
|
||||||
|
- **코드**:
|
||||||
|
- `AgyAgentAdapter._artifact_paths`(`agy.py:77-84`): DB, `-wal`/`-shm`/`-journal`, `brain/<uuid>`, annotation, presence lock 7경로.
|
||||||
|
- `has_artifacts`(`:86-90`): 그중 하나라도 있으면 True.
|
||||||
|
- `conversation_in_use` override(`:92-96`): 7경로 전부를 `lsof` 에 넘긴다.
|
||||||
|
- `BaseAgentAdapter.has_artifacts` 기본값(`base.py:194-200`): `uuid in path and os.path.exists(path)` — 기존 해결기의 경로 폴백과 **동일**하다. 그래서 claude·hermes·grok·codex 의 해결은 그대로이고, 공유 DB 인 opencode 는 경로에 uuid 가 없어 매치되지 않는다.
|
||||||
|
- `_resolve_purge_target`(`__main__.py:34-39`): 2단계가 `has_artifacts` 로 바뀌었다(1단계 revalidate `verify_artifact` 는 그대로).
|
||||||
|
- **샌드박스 재현(HEAD `fae70c1`, 매 케이스 새 `uuid4`)**:
|
||||||
|
|
||||||
|
| 경우 | 결과 |
|
||||||
|
|---|---|
|
||||||
|
| **R1**: 보유자가 presence lock 만 `flock` 으로 열고 argv·env 어디에도 uuid 가 없음(argv 에 uuid 없음을 확인) | `adapter.conversation_in_use` → **True**, CLI purge → **rc 4**, 존재하던 4조각 모두 보존 |
|
||||||
|
| **R2**: DB 없이 `-wal` + annotation 만 남음, `agy` 명시 | **rc 0**, agent `agy`, 2조각 purge, residual `[]` |
|
||||||
|
| R2, `auto` | **rc 0**, agent `agy`, 2조각 purge, residual `[]` |
|
||||||
|
| 아무것도 없음 | **rc 5**, agent `null`(미해결과 구분 유지) |
|
||||||
|
| 회귀: claude transcript 만 있음 | `auto` → **claude** 로 해결 |
|
||||||
|
| 회귀: opencode 공유 저장소만 있음, 무작위 uuid | `has_artifacts` False, `auto` → **rc 5** |
|
||||||
|
|
||||||
|
- **R-a (이미 보류된 항목, 이번 재현으로 증거 추가)**:
|
||||||
|
- 어댑터 `purge_artifacts` 는 여전히 **lock 을 지울지 정할 때만**, 그것도 DB·brain·annotation 을 **지운 뒤에** in-use 를 본다.
|
||||||
|
- 이 메서드를 직접 부르는 `stop_session.sh:329` 경로에서 lock 보유자가 있으면 DB·brain·annotation 을 지우고 lock 만 남긴다(재현: 3조각 삭제, lock 보존).
|
||||||
|
- CLI·`orc_onboard` 경로는 파괴 작업 **전에** 7경로 전체로 검사하므로 codex R1 의 "파괴 전 검사" 요구를 충족한다. `stop_session` 은 herdr 사망 pre-gate 를 거치므로 남는 경우는 "다른 프로세스가 같은 대화를 쥔" 드문 상황이다.
|
||||||
|
- **권장**: `purge_artifacts` 첫머리에서 `conversation_in_use` 가 참이면 빈 목록과 경고로 전체를 건너뛸 것(후속 이슈).
|
||||||
|
|
||||||
|
## 2. 검증 항목별 결과
|
||||||
|
|
||||||
|
| # | 항목 | 결과 |
|
||||||
|
|---|---|---|
|
||||||
|
| (1) | `7610258` 헬퍼 사슬·`_is_agent_cli` | ✅ §1-(1). R-1 비차단 |
|
||||||
|
| (2) | `fae70c1` agy in-use·`has_artifacts`·해결기 | ✅ §1-(2). 다른 어댑터 해결 영향 없음. R-a 비차단(보류 항목) |
|
||||||
|
| (3) | `orc_onboard.sh` 흐름 · SKILL.md · VERSIONS.md 정합 | ✅ purge 선행·exit 4(`--yes` 없음)/5(in-use, 등록 유지)·dry-run 무변경. SKILL.md 의 고아 조각·lock `lsof` 문단과 VERSIONS 항목 1·2 가 코드와 일치. **단 `VERSIONS.md:52` "신규 테스트 24개 … `test_issue10_13_purge.py` 17개" 가 낡았다** — 실제 19 + 7 = **26**(`fae70c1` 이 2개 추가). 병합 전 한 줄 정정 권장(비차단) |
|
||||||
|
| (4) | #15 경로 · `job_subscriber --registry-dir` | ✅ 무변경(`default_registry_dir()`, 운영 호출은 모두 명시) |
|
||||||
|
| (5) | 버전 락스텝 | ✅ `lib.sh:32`·`eager.py:16` = 4.4.3, `verify_framework_version` 통과, 릴리스 커밋 `0282312` 제목 그대로 |
|
||||||
|
| (6) | 테스트 · 정적 검사 | 아래 |
|
||||||
|
|
||||||
|
- **선택 실행 9파일**(브리프 지정 8 + `test_version_consistency`): **250 passed in 163.4 s, rc 0**(`test_issue10_13_purge.py` 23건 포함 — `fae70c1` 의 lock 전용 보유자·고아 부속 파일 테스트 포함).
|
||||||
|
- **#15 누수 실측**: 실행 전후 실제 `.mam/jobs` 레코드 목록이 **동일**했다(추가 0건).
|
||||||
|
- **정적 검사**:
|
||||||
|
- `py_compile`: `base.py`, `agy.py`, `__main__.py`, 테스트 파일 통과
|
||||||
|
- `bash -n orc_onboard.sh` 통과
|
||||||
|
- `git diff --check bd3df89...HEAD` 클린
|
||||||
|
- `verify_framework_version` 통과
|
||||||
|
- 작업 트리 깨끗, HEAD `fae70c1`
|
||||||
|
- purge 테스트 파일의 고정 UUID 리터럴 0건
|
||||||
|
|
||||||
|
## 3. 비차단 권고 (후속 이슈 1건으로 묶기를 권장)
|
||||||
|
|
||||||
|
1. **`VERSIONS.md:52` 테스트 수 정정**: 24/17 → 26/19. 목록에 "presence lock 만 연 보유자 거부, 고아 부속 파일로 agy 해결·purge" 추가. 병합 전 권장.
|
||||||
|
2. **R-a**: 어댑터 `purge_artifacts` 에서 in-use 이면 전체 건너뛰기(§1-(2)).
|
||||||
|
3. **R-1**: 에이전트 판정의 이름 의존 → 알려진 헬퍼만 제외.
|
||||||
|
4. **위임 래퍼의 브리프 argv 전달**: `ps` 노출이자 f5a6575 이 겪은 argv 충돌의 근본 원인. 파일이나 stdin 으로 전달 권장.
|
||||||
|
5. **R-b / R-c / R-e**: 등록 UUID 확인, claude 교차 프로젝트 탐색, 상속 `WORKSPACE_ROOT`.
|
||||||
|
|
||||||
|
## 4. GM 참고
|
||||||
|
- codex 의 NOT PASS(R1/R2)는 `fae70c1` 에서 해소되었다(위 재현). codex 가 이 HEAD 를 다시 확인하는 것이 만장일치 판정의 전제다.
|
||||||
|
- agy·grok 의 직전 PASS 는 `f5a6575` 기준이라 `fae70c1` 은 포함하지 않는다.
|
||||||
|
|
||||||
|
## 5. 종합
|
||||||
|
- `fae70c1` 은 codex R1(lock 만 연 보유자)과 R2(고아 부속 파일)를 정확한 위치에서 최소 변경으로 고쳤다. 기본 `has_artifacts` 가 기존 폴백과 같아 다른 어댑터의 해결은 바뀌지 않았다. 앞선 라운드 결과(N1·N2·#10·#11·#15·락스텝)도 회귀 없이 유지된다.
|
||||||
|
- 남은 항목은 이미 보류된 R-a·R-1 과 changelog 테스트 수 한 줄이다. 설계 재검토는 필요 없다.
|
||||||
|
|
||||||
|
[VERDICT: PASS]
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"loop_id": "loop-20261005T085016Z-c0f6eb",
|
||||||
|
"base_sha": "e8d9151226d8139c7b5688e83d035a3a747aeb2d",
|
||||||
|
"head_sha": "ab93776d6d1b8ca534c14b6be53f61dc3a0fa2a9",
|
||||||
|
"tree_sha": "f49ffdc8e95dc316f752fd81b2d55a0efddf1a12",
|
||||||
|
"review_commit": "ab93776d6d1b8ca534c14b6be53f61dc3a0fa2a9",
|
||||||
|
"reviewer": "planner-reviewer-claude-01",
|
||||||
|
"job_id": "ab18016d",
|
||||||
|
"verdict": "PASS",
|
||||||
|
"transport": "herdr",
|
||||||
|
"evidence": "review-tree",
|
||||||
|
"isolation": "worktree",
|
||||||
|
"verified_at": "2026-10-05T09:05:24Z"
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# 리뷰 리포트 — Job ab18016d (PR #48 3회차 델타, e8d9151..ab93776)
|
||||||
|
|
||||||
|
- 리뷰어: planner-reviewer-claude-01 (claude)
|
||||||
|
- 리뷰 identity: loop-20261005T085016Z-c0f6eb / base e8d9151 / head ab93776 / tree f49ffdc — 검증 경로에서 `git rev-parse HEAD 'HEAD^{tree}'` 일치, 미커밋 변경 없음
|
||||||
|
- 범위(항목 4): lib_py/herdr_config.py(+25/−2), lib_py/herdr_restore.py(+10/−2), 테스트 2개. bash·문서·버전 변경 없음. 새 판정은 모두 Python 에 있다(#17).
|
||||||
|
|
||||||
|
## 결론 요약
|
||||||
|
codex R1~R3 가 닫혔다. R1 의 "MINIMAL 무검사 사용" 판단에 동의한다. `_same_doc` 은 nan 외에는 이전 `==` 와 같거나 더 엄격하며, 그 엄격함이 오탐(불필요한 MINIMAL 폴백)을 만들지 않음을 확인했다. 차단 결함 없음.
|
||||||
|
|
||||||
|
## 1. R1~R3 종결
|
||||||
|
| 지적 (codex 2회차) | 수정 | 확인 |
|
||||||
|
|---|---|---|
|
||||||
|
| R1 `config check` 를 실행 못 하면(timeout·OSError) 병합 결과를 검사된 것으로 넘김 | herdr_config.py:245-258 — 병합 본문이면 경고 후 `continue` 로 MINIMAL 시도, MINIMAL 의 검사도 실행 못 하면 MINIMAL 을 경고와 함께 사용 | test_an_unrunnable_check_never_hands_over_an_unchecked_merge[timeout/oserror]: 스키마 오류가 든 사용자 설정이 생성 파일에 들어가지 않고 MINIMAL + "not using an unchecked merge" 경고, 원본 불변. 이전 제품 코드에서 2건 실패 |
|
||||||
|
| R2 `_shell_in_foreground` 가 0/0·False/False·""/""·-1/-1 에서 True | herdr_restore.py:44-53 — 두 값이 bool 아닌 양의 int 일 때만 비교 | test_issue45_restore_unit.py: zero·false·empty·negative·null·string·true·float 모두 닫지 않음, 정상 pid 같음 → 닫음, 다름 → 둠. 이전 코드에서 7건 실패(null 은 이전에도 통과) |
|
||||||
|
| R3 재파싱 `==` 가 nan≠nan 이라 nan 하나로 사용자 설정 전체를 버림 | herdr_config.py:140-150 `_same_doc`, :171 에서 사용 | test_a_nan_value_does_not_drop_the_other_settings[top/array/nested]: 경고 없이 설정 유지. 이전 코드에서 3건 실패 |
|
||||||
|
이전 제품 코드(e8d9151 의 두 파일)로 되돌린 스크래치 사본에서 새 테스트는 **12 failed / 55 passed**(3.11), 현재 코드에서 **67 passed**.
|
||||||
|
|
||||||
|
## 2. R1 판단 — MINIMAL 무검사 사용 vs 경로 반환 안 함
|
||||||
|
동의한다. 근거:
|
||||||
|
- 경로를 반환하지 않으면 lib.sh:241-245 가 `HERDR_CONFIG_PATH` 없이 서버를 띄운다 → herdr 는 사용자 설정(복원 기본 켜짐)을 읽어 **#45 가 확실히 재발**한다.
|
||||||
|
- MINIMAL 을 넘기면, herdr 가 그것을 받아들이는 경우(고정 2줄, 실제 herdr 0.9.1 에서 test_real_herdr_accepts_every_result 가 확인) 복원이 꺼지고, 만약 거부하더라도 herdr 는 기본값으로 돌아 결과는 "경로 없음"과 같은 복원 켜짐이다. 즉 MINIMAL 은 복원 측면에서 "경로 없음"보다 나쁜 경우가 없고, 잃는 것은 이미 경고되는 사용자 설정뿐이다.
|
||||||
|
- 검사가 **실행되어 거부**한 경우는 여전히 None(:259-262)이다. "검사 불가"와 "검사 거부"를 다르게 다루는 것이 일관적이다(거부는 herdr 가 그 파일을 버린다는 증거).
|
||||||
|
- 알 수 없는 nonzero 종료를 통과로 두는 것(:178)도 기존 근거(무해한 경고에도 nonzero)와 같고 이번 변경 범위 밖이다.
|
||||||
|
|
||||||
|
## 3. `_same_doc` 의 의미 (항목 3)
|
||||||
|
3.11 에서 이전 `==` 와 직접 비교:
|
||||||
|
| 경우 | 이전 == | _same_doc |
|
||||||
|
|---|---|---|
|
||||||
|
| nan / nan, 중첩 nan | False | **True** (의도된 변경) |
|
||||||
|
| 1 / 1.0, True / 1, 0 / False | True | **False** (더 엄격) |
|
||||||
|
| -0.0 / 0.0, inf / inf, 같은 time, 같은 문자열 | True | True |
|
||||||
|
| date / datetime | False | False |
|
||||||
|
| [1,2] / [2,1] (배열 순서) | False | False |
|
||||||
|
| dict 키 순서만 다름, 표 배열 [{…}] | True | True |
|
||||||
|
- 더 엄격해진 int/float/bool 구분은 직렬화가 타입을 보존하는 한 오탐을 만들지 않는다: dumps(:107-126)는 int→정수, float→repr(소수점·지수 유지), bool→true/false 로 쓴다. 날짜·오프셋 datetime·소수 초 local time·표 배열·inf·nan·−nan·따옴표 점 키·1e100 이 든 문서로 server_config_text 를 돌려 **설정 유지, 경고 없음**을 확인했다. 오히려 왕복에서 1 이 1.0 으로 바뀌는 것 같은 결함은 이제 잡힌다.
|
||||||
|
- 참고: `-nan` 은 `nan` 으로 쓰여 부호가 사라진다(nan 끼리 같다고 보므로 통과). herdr 설정에서 nan 부호가 의미를 가질 일은 없어 무해하다.
|
||||||
|
|
||||||
|
## 4. 테스트 실행
|
||||||
|
| 실행 | 결과 |
|
||||||
|
|---|---|
|
||||||
|
| 지정 4개 파일 (pr-48 .venv, Python 3.9, 실제 herdr 포함) | **53 passed / 34 skipped / 0 failed** (76 s). skip = tomllib 전용; test_issue45_restore 는 skip 0 |
|
||||||
|
| herdr_config + restore_unit (지정 venv311, Python 3.11.15) | **67 passed** |
|
||||||
|
| 같은 테스트, 이전 제품 코드 사본 | 12 failed / 55 passed |
|
||||||
|
격리: 실행 전후 herdr 프로세스는 기존 multi-agent-mux 서버·attach·오케스트레이터 `agent wait` 뿐, `/private/tmp/m45*` 없음, `~/.config/herdr` 목록·config.toml md5·sessions 불변. MQTT_* unset, .mam.env 미export.
|
||||||
|
|
||||||
|
## 5. 비차단 관찰
|
||||||
|
- R1 테스트는 두 검사 모두 실행 불가인 경우만 다룬다. "병합 검사는 실행 불가, MINIMAL 검사는 실행되어 통과" 경로(:256 → 다음 반복의 :258)는 코드상 자명하지만 별도 테스트는 없다.
|
||||||
|
- 2회차의 VERSIONS 4.8.5 항목 1 문장 순서 지적(N1)은 이번 델타에 포함되지 않았다(문서 변경 없음).
|
||||||
|
|
||||||
|
[VERDICT: PASS]
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
# 🔍 Review Report — PR #35 병합 전 독립 리뷰 (job `adc0ff3c`)
|
||||||
|
|
||||||
|
- **Reviewer**: `planner-reviewer-claude-01` (role: planner,reviewer) · 2026-09-23
|
||||||
|
- **대상**: PR #35 `fix/issue-23-24` **@ `f4ba205`**, base `main 1e78dae` (v4.7.1). 13커밋, 22파일 `+2197/−118`, `git diff --check` 클린.
|
||||||
|
- **검증 경로**: `/var/folders/.../T/mam-review/loop-20260923T142448Z-609954/1`. **identity 확인**: `HEAD = f4ba205c…fd2f`, `HEAD^{tree} = 88cb4f34…f181` — 브리프의 `head`·`tree` 와 일치.
|
||||||
|
- **안전 규칙 준수**: 검증 경로·소스 워크트리(`multi-agent-mux-wt/pr-35`)·본 체크아웃(`main 1e78dae`) 모두 수정·커밋·checkout 없음. 실험은 scratchpad 임시 디렉터리(uuid4 이름)에서만, pytest 는 `AGENT_PYTHON_BIN=<제어 루트 .venv>` + `--basetemp=<검증 경로 밖>` 으로 실행. 실제 herdr 서버·에이전트 세션·`.mam`·데몬 조작 없음(이 pane 의 herdr 환경변수를 **읽기만** 했다), push·merge·PR 상태 변경 없음, purge·scrub 실행 없음.
|
||||||
|
|
||||||
|
## 결론 요약
|
||||||
|
|
||||||
|
**병합을 막을 결함은 없다.** 세 핵심 규칙 — 교차 서버 판정, "이번 dispatch 의 started 만 증거", kill 결과 확인 롤백 — 을 각각 독립 실험으로 재현·확인했고, 새 테스트는 실제 `registry.py`/`publish_event.py` 를 쓰며 홈·브로커·실제 herdr 에 닿지 않는다. 비차단 지적 6건(F1–F6)을 남긴다. 그중 F1 은 **브리프의 codex 수락 키 서술과 코드가 다르다**는 점이라 오케스트레이터가 확인할 필요가 있다.
|
||||||
|
|
||||||
|
## 1. #24 시작 다이얼로그 (항목 1)
|
||||||
|
|
||||||
|
### 1-1. 어댑터 시그니처·수락 키
|
||||||
|
|
||||||
|
`python -m lib_py.agents facts <agent>` 가 실제로 내놓는 값(검증 경로에서 실행):
|
||||||
|
|
||||||
|
| 에이전트 | `MAM_STARTUP_DIALOGS` | 강한 준비 토큰 |
|
||||||
|
|---|---|---|
|
||||||
|
| claude | 기존 사다리 5개(trust Enter / `Yes, try it` Escape / `Yes, proceed` Down+Enter / `Resuming…` Enter / `Press Enter…` Enter)가 그대로 옮겨짐 | `Anthropic\|Assistant\|…\|Haiku` |
|
||||||
|
| agy | `Do you trust the contents of this project\|Yes, I trust this folder` → `Enter`, trust | `Antigravity` |
|
||||||
|
| grok | `Do you trust the contents of this directory` → `y`, trust | `Grok\|xAI\|Assistant\|❯\|>>>` |
|
||||||
|
| codex | `Do you trust…\|1\. Yes, continue` → **`Enter`**, trust · `1\. Update now\|Skip until next version` → **`2`**, notice | `Codex\|Ask Codex to do anything` |
|
||||||
|
| opencode·hermes | `[]` (다이얼로그 없음) | 있음 → 핸들러가 배너에서 즉시 반환 |
|
||||||
|
|
||||||
|
**F1 (확인 요망, 비차단)**: 브리프는 codex 수락 키를 "`1`+Enter, 업데이트 안내 `3`+Enter" 로 적었으나 코드는 **Enter 단독**(1번 항목이 미리 선택돼 있다는 주석)과 **`2`**("2 = Skip" 이라는 관찰 기록, 2026-09-22) 다. 어느 쪽이 실제 화면인지는 이 리뷰에서 재현할 수 없다(실제 codex 를 띄우는 것은 금지). PR 본문은 codex 를 포함한 4개 에이전트의 실제 herdr 시험이 사람 개입 없이 `started`(토큰 포함)→`completed` 까지 갔다고 기록하므로 **코드 쪽이 실측**으로 보이지만, 브리프 작성자의 관찰과 다르므로 병합 전에 한 번 맞춰 볼 것을 권한다. `_MAM_READY_TOKENS_CLAUDE` 상수는 제거되고 어댑터의 `strong_ready_tokens` 로 대체됐다(값 동일).
|
||||||
|
|
||||||
|
### 1-2. 준비 토큰으로 수락을 판정하는 이유
|
||||||
|
|
||||||
|
`handle_startup_dialogs`(`lib.sh:2979-`)는 시그니처가 화면에 남아 있어도 같은 인덱스에 대해 `MAM_DIALOG_RESEND_S`(기본 5 s) 안에는 재전송하지 않고(`_mam_dialog_pick` 의 cooldown), 반환 조건은 **오직 준비 토큰**이다. `--source visible` 스냅샷이 수락된 다이얼로그를 한동안 계속 보여 준다는 #24 의 관찰과 맞물려 타당하다 — 옛 코드는 매 폴링마다 Enter 를 다시 보내 이미 넘어간 TUI 에 입력을 흘렸다. `test_c1_tui_readiness.py::test_c1b` 의 단언이 `==30` 에서 `1 ≤ n ≤ 30` 으로 바뀐 것도 같은 이유이며, "한 프레임에 두 번 보내지 않는다"는 원래 가드는 그대로다.
|
||||||
|
|
||||||
|
### 1-3. `--no-auto-trust` / `MAM_AUTO_TRUST=0`
|
||||||
|
|
||||||
|
`_mam_dialog_pick` 은 `kind == "trust"` 이고 auto_trust 가 꺼져 있으면 `skip|idx|` 를 내고, 핸들러는 경고만 남긴다(cooldown 마다 1회). `notice` 는 항상 답한다. create(`create_session.sh:86-94`)와 resume(`resume_session.sh:43-57`)이 **같은 스위치·같은 env 변수(`MAM_AUTO_TRUST`)·같은 호출(`handle_startup_dialogs "$SESSION" N "$AGENT"`)** 을 쓴다. S6/R3 가 고정.
|
||||||
|
|
||||||
|
### 1-4. 자동 수락의 적용 범위(보안)
|
||||||
|
|
||||||
|
pane 은 `new-session -c "$WORKSPACE"` 로 뜨고 신뢰 창은 그 cwd 에 대한 것이므로 실질적으로 `--workspace` 에만 적용된다. 다만 시그니처 정규식은 **디렉터리 이름을 검사하지 않는다** — 화면에 어떤 경로가 적혀 있든 매치되면 수락한다(F5). 시작 직후의 신뢰 창이 다른 디렉터리를 가리킬 경로는 없어 실무 위험은 낮다.
|
||||||
|
|
||||||
|
## 2. #23 교차 서버 스코프 (항목 2)
|
||||||
|
|
||||||
|
`_herdr_caller_server` / `_herdr_call_is_cross_server`(`lib.sh:1403-1447`)를 lib.sh 에서 함수만 추출해 `env -i` 로 9가지 조합을 돌렸다(전부 기대와 일치):
|
||||||
|
|
||||||
|
| `HERDR_SOCKET_PATH` | `HERDR_SESSION` | 대상 | 판정 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `~/.config/herdr/herdr.sock` | – | `mam` | caller=`default`, **CROSS** (보고된 #23 시나리오) |
|
||||||
|
| 〃 | – | (없음) / `default` | same (앰비언트 sentinel) |
|
||||||
|
| `~/.config/herdr/sessions/mam/herdr.sock` | – | `mam` | caller=`mam`, same → **pane 배치·workspace 패킹 유지** |
|
||||||
|
| 〃 | – | `other` | CROSS |
|
||||||
|
| 〃 | `orch` | `mam` | caller=`orch`(HERDR_SESSION 우선), CROSS |
|
||||||
|
| `/other/layout/herdr.sock` / 비어 있음 | – | `mam` | caller="" → same (pane 밖: 명시 스코프 존중) |
|
||||||
|
| `…/sessions/a b/herdr.sock` | – | `mam` | caller=`a b`, CROSS (공백 안전) |
|
||||||
|
|
||||||
|
- 제거는 `mam_herdr` 안에서 **교차일 때만** `env -u HERDR_WORKSPACE_ID -u HERDR_PANE_ID -u HERDR_TAB_ID` 로 이루어지고(`lib.sh:1449-1461`), argv 배열 + `if` 로 판정하므로 `set -e` 호출자를 중단시키지 않는다(S12).
|
||||||
|
- `lib_py/loop/watcher.py::caller_server/scoped_env` 는 같은 규칙의 파이썬 쌍둥이다. 같은 5가지 입력으로 돌려 bash 와 **동일 결과**를 확인했다(같은 서버·pane 밖·`default`·`None` 대상은 ids 유지, 교차만 제거).
|
||||||
|
- **실제 레이아웃 근거**: 이 pane(herdr **0.9.1**)의 환경은 `HERDR_SOCKET_PATH=~/.config/herdr/sessions/multi-agent-mux/herdr.sock`, `HERDR_SESSION=multi-agent-mux` 였다 — 규칙이 전제하는 `sessions/<name>/herdr.sock` 형태와 일치하고, `HERDR_SESSION` 도 export 된다. 0.8.2 의 "기본 서버 pane 에는 `HERDR_SESSION` 이 없다"는 전제는 PR 의 실측 기록(코드 주석)에 의존하며 여기서 재현할 수 없다. 두 경우 모두 소켓 경로 규칙이 뒷받침하므로 어느 버전이든 판정이 성립한다.
|
||||||
|
- 인식되지 않는 레이아웃은 ""(= pane 밖) 으로 떨어져 **오늘의 동작을 유지**한다. 잘못된 제거보다 안전한 쪽이다.
|
||||||
|
|
||||||
|
## 3. #23 온보딩 전달 판정 (항목 3)
|
||||||
|
|
||||||
|
### 3-1. 공유
|
||||||
|
|
||||||
|
`delegate_publish_cmd`·`delegate_job_status`·`delegate_started_recorded`·`delegate_wait_started` 가 `lib.sh:2189-2313` 에 하나씩 있고, create 온보딩(`create_session.sh:622-680`)과 래퍼 `_wait_started`(`multi-agent-mux-delegate-job:647-668`)가 **같은 `delegate_started_recorded`** 를 부른다. 래퍼는 `MAM_JOBS_DIR="$REGISTRY_DIR"` 와 `LOGS_DIR_ABS` 를 넘겨 자기 레지스트리·감사 루트를 그대로 쓴다(`:191`,`:659`). 옛 `_disk_status != pending → 성공` 우회와 구독자 콘솔 grep 은 **삭제**됐다(diff 로 확인).
|
||||||
|
|
||||||
|
### 3-2. 증거 규칙 실측
|
||||||
|
|
||||||
|
`delegate_started_recorded` 만 추출해 가짜 `events.log`/`events.ndjson` 으로 10가지를 돌렸다(전부 기대와 일치):
|
||||||
|
|
||||||
|
| # | 상태 | 토큰 요구 | 결과 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | 파일 없음 | 있음 | NOT |
|
||||||
|
| 2 | 이전 dispatch 토큰의 `started` | 있음 | **NOT** (R1: 이전 시도의 증거 거부) |
|
||||||
|
| 3 | 〃 | 없음 | recorded (토큰 없으면 종전대로) |
|
||||||
|
| 4 | 이번 토큰의 `started` | 있음 | recorded |
|
||||||
|
| 5 | 감사 기록 `dispatch_verified:true`, **fingerprint 없음** | 있음 | **NOT** (신원 없는 과거 기록 fail closed) |
|
||||||
|
| 6 | 〃 | 없음 | recorded |
|
||||||
|
| 7 | 이전 시도의 fingerprint | 있음 | NOT |
|
||||||
|
| 8 | 이번 토큰의 fingerprint | 있음 | recorded |
|
||||||
|
| 9 | fingerprint 맞지만 `dispatch_verified:false` | 있음 | NOT |
|
||||||
|
| 10 | 깨진 줄 + `progress` 만 | 있음 | NOT (JSON 파싱, 패턴 매칭 아님) |
|
||||||
|
|
||||||
|
fingerprint 는 `sha256(token)` 으로 구독자(`job_subscriber.py:303-306`)와 **같은 해시**임을 확인했다. 레지스트리 `status` 는 어느 경로에서도 증거로 쓰이지 않는다.
|
||||||
|
|
||||||
|
### 3-3. 종료된 잡·#18 정합
|
||||||
|
|
||||||
|
- create 는 더 이상 에이전트 대신 `started` 를 발행하지 않는다(`:649-657` 주석과 코드; S7). 미확인 시 `delegate_job_status` 가 `completed|error|cancelled` 면 **덮어쓰지 않고** 경고만 한다(`:674-679`; S7m).
|
||||||
|
- 대기 시간은 `MAM_ONBOARD_ACK_S` → `MAM_INJECT_ACK_GRACE_S` → 잡의 `ack_timeout_sec`(기본 180) 순이다 — #18 의 T=180 과 같은 값이고, heartbeat 계약은 `started --heartbeat 30` 명령을 `delegate_publish_cmd` 가 래퍼와 **인자 단위로 동일**하게 만든다(S7l).
|
||||||
|
- `inject_instructions`(= `send_keys_safe`) 의 rc 4 는 세 자리 모두 "전달 미상"(`lib.sh:2746` busy 초과, `:2810` delivery unknown, `:2883` Enter 미수락)이라 `started` 대기로 판정을 넘기는 것이 맞고, rc 1(quiesce 실패)·2·3(다이얼로그 차단)은 즉시 실패로 남는다(S8/S8b).
|
||||||
|
|
||||||
|
## 4. #23 실패 처리 (항목 4)
|
||||||
|
|
||||||
|
- `cleanup_herdr_on_error`(`create_session.sh:318-346`): `kill-session` 의 rc 를 버리지 않고 받은 뒤 `has-session` 으로 재확인, 살아 있으면 행을 `running` + `last_visible_status: rollback-failed` + `delegate_job_id: null` 로 표시하고 **exit 43**. bash 3.2 에서 EXIT trap 안의 `exit 43` 이 원래 종료 코드(1)를 덮어씀을 직접 확인했다(`rc=43`). 상태값은 `atomic_yaml.py:18` 의 어휘(`running/terminated/archived/stopped`) 안이고 `last_visible_status` 는 reconcile 이 이미 쓰는 필드다.
|
||||||
|
- 온보딩 미확인: 세션 보존 + 잡 `error`(이미 종료된 잡은 제외) + 경고 2줄, `ROW_APPENDED` 뒤라 YAML 행은 유효한 `running` 이다. 실패한 잡은 `JOB_TERMINALIZED=1` 로 표시돼 트랩이 `cancelled` 를 겹쳐 발행하지 않는다.
|
||||||
|
- create SKILL.md(`:101-131`)가 이 규칙(started 기반 판정, rc 4 경고, 세션 보존, `MAM_ONBOARD_ACK_S`, exit 42/43 과 행 상태)을 정확히 서술한다.
|
||||||
|
|
||||||
|
## 5. 기존 테스트 조정 6파일 (항목 5)
|
||||||
|
|
||||||
|
| 파일 | 변경 | 판정 |
|
||||||
|
|---|---|---|
|
||||||
|
| `conftest.py` | `mam_sandbox` 에 `MAM_ONBOARD_ACK_S=3` 추가 | 타당. 샌드박스에는 `started` 를 발행할 실제 에이전트가 없어 이것이 없으면 create 테스트마다 180 s 를 소모한다. create 전용 변수라 래퍼의 `MAM_INJECT_ACK_GRACE_S` 를 단언하는 테스트와 간섭하지 않는다 |
|
||||||
|
| `test_b19_*` | `grep -q 'Yes, try it'` 가 lib.sh 에 있는지 → claude 어댑터의 `('Yes, try it', ['Escape'], 'notice')` 로 위치 이동 | 토큰 목록 두 단언은 그대로. 검증 대상이 이사한 것뿐 |
|
||||||
|
| `test_c1_tui_readiness.py` | `KEYS_COUNT==30` → `1 ≤ n ≤ 30`, `custom` 에이전트 → `claude` | §1-2 참조. cooldown 도입으로 "매 반복 1회" 가 "프레임당 최대 1회" 로 바뀌었고 원래 가드(2회 금지)는 유지·강화. `custom` 은 어댑터가 없어 다이얼로그 목록이 비므로 이 시험에는 `claude` 가 맞다 |
|
||||||
|
| `test_issue14_delivery_ack.py` | 레지스트리 상태만 `running` 으로 flip 하던 "ack" 를 **실제 `publish_event.py` + dispatch 토큰의 `started`** 로 교체 | **강화**. 옛 시험은 PR 이 없앤 "상태만 보고 성공" 우회를 통과 조건으로 쓰고 있었다. 새 시험은 로컬 `events.log` 에 토큰이 실린 줄까지 단언한다 |
|
||||||
|
| `test_issue18_liveness_contract.py` | acceptance5 의 배달 단언을 `recorded 'started'` 하나로 좁힘 | 계약상 옳다(레지스트리 상태는 증거가 아님). 다만 "브로커가 retained `completed` 만 배달" 한 경우가 이제 **실패**한다 — `_skip_if_broker_did_not_deliver` 는 그 잡의 어떤 이벤트도 못 받았을 때만 skip 하므로. 공개 브로커에서 flake 가 늘 수 있는 자리(F4) |
|
||||||
|
| `test_issue5_phase2a_loop.py` | G9 가드가 코드만 읽도록(`ast` 로 docstring **노드**만 제거, 주석은 unparse 에서 사라짐) + `g9b` 자체 검증 | 약화 아님. 호출 형태(`subprocess` argv 의 `lib.sh` 경로·`source`, 식별자)는 여전히 코드 텍스트에 있어 잡히고, `g9b` 가 "같은 줄의 코드"(R2) 네 가지까지 고정한다. `ast.unparse` 는 3.9+ — venv 3.9.6 이라 통과 |
|
||||||
|
|
||||||
|
## 7. 문서 (항목 7)
|
||||||
|
|
||||||
|
- create SKILL.md `:101-131`: started 기반 판정·rc 4 경고·세션 보존·`MAM_ONBOARD_ACK_S`·`--no-auto-trust`(codex 의 로컬 설정 로드 위험 명시)·`MAM_DIALOG_RESEND_S`·exit 42/43 과 행 상태가 코드와 일치한다. resume SKILL.md 는 에이전트 분기 `case` 를 없애고 같은 호출로 바꿨다.
|
||||||
|
- **F2 (비차단, 문서 누락)**: 감사 기록의 새 필드 `dispatch_verified`·`dispatch_fingerprint`·`attempt_id` 는 `MESSAGING.md`·delegate-job `SKILL.md` **어디에도 없다**(전수 grep 0건). 두 문서는 `events.ndjson` 을 단계 수준으로만 서술해 필드 목록 자체가 없으므로 모순은 없지만, "래퍼와 create 가 무엇을 증거로 읽는가"가 이번 PR 의 핵심이라 한 문단은 있어야 한다.
|
||||||
|
- **F3 (비차단, 문구 정밀도)**: SKILL.md 는 `MAM_ONBOARD_ACK_S` 가 래퍼의 `MAM_INJECT_ACK_GRACE_S` 와 "별개" 라고만 적었지만 코드의 우선순위는 `MAM_ONBOARD_ACK_S` → `MAM_INJECT_ACK_GRACE_S` → 잡의 `ack_timeout_sec` 이다(`create_session.sh:658`). 즉 래퍼 변수도 create 의 대기에 **2순위로 영향**을 준다.
|
||||||
|
- `.claude/skills`: 본 체크아웃의 것은 git 에 추적되지 않는 로컬 심링크(`../../.agents/skills/*`, 10개)라 자동 동기화된다. 검증 경로(워크트리)에는 존재하지 않는다 — 배포 사본 정합 문제 없음.
|
||||||
|
|
||||||
|
## 6. 테스트 (항목 6)
|
||||||
|
|
||||||
|
### 6-1. 검증 경로에서의 실행
|
||||||
|
|
||||||
|
```
|
||||||
|
cd <검증 경로> && AGENT_PYTHON_BIN=<제어 루트 .venv/bin/python> PYTHONDONTWRITEBYTECODE=1 <같은 인터프리터> -m pytest \
|
||||||
|
-p no:cacheprovider -q -rfEs --basetemp=<검증 경로 밖> \
|
||||||
|
tests/test_issue23_24_startup_path.py tests/test_issue24_resume_dialogs.py tests/test_b19_headless_reconcile_fixes.py \
|
||||||
|
tests/test_c1_tui_readiness.py tests/test_issue14_delivery_ack.py tests/test_issue18_liveness_contract.py \
|
||||||
|
tests/test_issue5_phase2a_loop.py tests/test_herdr_shim_contract.py tests/test_loop_cli.py
|
||||||
|
→ 273 passed in 700.58s (0:11:40) failed 0 · errors 0 · skipped 0
|
||||||
|
```
|
||||||
|
|
||||||
|
새 파일 2개(73건)와 조정된 6파일, 그리고 셸 shim 계약·loop CLI 까지 모두 통과. 브로커 의존 시험(issue14·issue18)도 이번 실행에서는 flake 없이 통과했다.
|
||||||
|
|
||||||
|
### 6-2. 수정 없이는 실패하는가 — 확인함
|
||||||
|
|
||||||
|
검증 경로 사본에서 PR 이 바꾼 소스 12파일(lib.sh·어댑터 5·`__main__.py`·`job_subscriber.py`·`watcher.py`·create/resume 스크립트·래퍼)만 base `1e78dae` 판으로 되돌리고 새 테스트 2파일을 돌렸다:
|
||||||
|
|
||||||
|
```
|
||||||
|
60 failed, 12 passed, 1 skipped in 275.50s
|
||||||
|
```
|
||||||
|
|
||||||
|
실패 60건은 S1(교차 서버)·S4~S6(다이얼로그)·S7/W(started 증거)·S8(rc 4)·S9/S10(롤백)·R1~R4(resume) 전 영역에 걸친다. 통과한 12건은 "같은 서버는 스코프 유지"(S2/S2b)·"shim 은 바뀌지 않음"(S11)·"호출자가 준 토큰은 덮어쓰지 않음"(S5e) 같은 **불변 조건**들이라 수정과 무관한 것이 맞다. 즉 새 테스트는 이 변경을 정확히 겨냥한다.
|
||||||
|
|
||||||
|
### 6-3. 격리·픽스처·시간
|
||||||
|
|
||||||
|
| 항목 | 결과 |
|
||||||
|
|---|---|
|
||||||
|
| 실제 `.mam/jobs` / `.mam/delegate_job_logs` 전후 | **차이 0 / 차이 0** |
|
||||||
|
| 실제 herdr 서버·홈 | `mam_sandbox` 가 `HOME`/`HOME_DIR` 를 `tmp_path` 로 바꾸고(`conftest.py:210-211`) herdr 는 mock. 새 파일에서 `hivemq`·실제 소켓 참조 0건. 이 pane 의 herdr 환경변수는 S1d 가 **읽기만** 한다 |
|
||||||
|
| 실제 생산자 사용 | S7i~S7n·W 계열은 `REGISTRY_PY`/`PUBLISH_PY`(실제 `registry.py`·`publish_event.py`)로 잡을 등록하고 `started` 를 발행한다(`test_issue23_24_startup_path.py:620-660`). "손으로 만든 픽스처가 결함을 가렸다"는 PR 의 교훈이 테스트에 반영돼 있다 |
|
||||||
|
| 검증 경로 | tracked 파일 변화 없음(HEAD `f4ba205` 유지). 내 실행이 무시되는 `.mam/` 을 만들었다(#32 리뷰 때와 같은 현상, 그대로 둠) |
|
||||||
|
| 실행 시간 | 새 파일 2개는 대체로 빠르지만 **`test_issue24_resume_dialogs.py` 4건이 각 39~43 s** 다(resume 의 `handle_startup_dialogs … 20` 타임아웃과 mock 준비 대기가 겹친다). 스위트 전체 시간에 +2.7 분을 더한다(F6) |
|
||||||
|
|
||||||
|
## 8. 병합 차단 여부 (항목 8)
|
||||||
|
|
||||||
|
버전 락스텝 bump 부재(`lib.sh:32` `4.7.1`, 오케스트레이터가 4.7.2 로 처리 예정) 외에 **병합을 막을 결함은 없다**.
|
||||||
|
|
||||||
|
| # | 비차단 지적 | 근거 / 권고 |
|
||||||
|
|---|---|---|
|
||||||
|
| F1 | **codex 수락 키가 브리프 서술과 다르다.** 코드: 신뢰 창 `Enter`(1번 미리 선택), 업데이트 안내 **`2`**(Skip, 이번만). 브리프: `1`+Enter, `3`+Enter(Skip until next version). S4 의 codex 화면 픽스처(`Update available / 1. Update now / 2. Skip / 3. Skip until next version`)를 보면 3개 항목이 실제로 있고, 코드는 그중 **2** 를 고른다 — "아무것도 바꾸지 않는" 선택이라는 주석과 일치하나 다음 시작 때 다시 뜬다. 실제 시험은 PR 본문대로 통과했으므로 동작은 성립 | 병합 전 브리프 작성자와 한 번 맞출 것; `3` 이 의도라면 어댑터 한 줄 |
|
||||||
|
| F2 | 감사 기록의 새 필드(`dispatch_verified`·`dispatch_fingerprint`·`attempt_id`)가 MESSAGING.md·delegate-job SKILL.md 에 없다 | 한 문단 추가 권고 |
|
||||||
|
| F3 | `MAM_INJECT_ACK_GRACE_S` 가 create 의 대기에도 2순위로 영향을 준다는 점이 SKILL.md 에 없다(`create_session.sh:658`) | 문구 보강 |
|
||||||
|
| F4 | acceptance5 가 "retained `completed` 만 배달" 된 경우를 이제 실패로 본다(계약상 옳음). 공개 브로커에서 flake 가 늘 수 있는 자리 | 관찰되면 skip 조건 재검토 |
|
||||||
|
| F5 | 다이얼로그 시그니처는 화면의 디렉터리 경로를 검사하지 않는다 — pane 이 `-c $WORKSPACE` 로 뜨므로 실무 위험은 낮다 | 정보 |
|
||||||
|
| F6 | `test_issue24_resume_dialogs.py` 4건이 각 ~40 s | 타임아웃을 env 로 줄일 수 있으면 개선 |
|
||||||
|
|
||||||
|
## 9. 교차검증 가이드
|
||||||
|
|
||||||
|
```bash
|
||||||
|
RP=/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20260923T142448Z-609954/1
|
||||||
|
PY=/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.venv/bin/python
|
||||||
|
git -C "$RP" rev-parse HEAD 'HEAD^{tree}' # f4ba205… / 88cb4f3…
|
||||||
|
# 교차 서버 규칙 (함수만 추출, env -i)
|
||||||
|
eval "$(sed -n '/^_herdr_caller_server() {/,/^}/p; /^_herdr_call_is_cross_server() {/,/^}/p' "$RP/.agents/skills/lib.sh")"
|
||||||
|
env -i HERDR_SOCKET_PATH=/x/.config/herdr/herdr.sock HERDR_SESSION_NAME=mam bash -c "$(declare -f _herdr_caller_server _herdr_call_is_cross_server); _herdr_call_is_cross_server && echo CROSS"
|
||||||
|
env -i HERDR_SOCKET_PATH=/x/.config/herdr/sessions/mam/herdr.sock HERDR_SESSION_NAME=mam bash -c "$(declare -f _herdr_caller_server _herdr_call_is_cross_server); _herdr_call_is_cross_server || echo same"
|
||||||
|
# 증거 규칙: 이전 토큰의 started / fingerprint 없는 감사 기록은 토큰 요구 시 거부 (§3-2 의 10 케이스)
|
||||||
|
# 수정 없이는 실패하는지 (사본에서만): 소스 12파일을 1e78dae 판으로 교체 후 새 테스트 2파일 → 60 failed / 12 passed
|
||||||
|
```
|
||||||
|
|
||||||
|
[VERDICT: PASS]
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# PR #56 4차 재리뷰 — 수정 커밋 21f0c12 (job ae9c1f28)
|
||||||
|
|
||||||
|
- 리뷰어: planner-reviewer-claude-01 (Reviewer)
|
||||||
|
- 리뷰 identity: loop-20261012T013821Z-656dfe / base efd27def .. head 21f0c12b (커밋 1개, 2 파일) / tree c2357ac0
|
||||||
|
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261012T013821Z-656dfe/1` — HEAD·tree 가 identity 와 일치, `git status --porcelain` 0 줄(시작·종료 시). 본 체크아웃 HEAD 42e5173(main) 변동 없음.
|
||||||
|
- 실행: 워크트리 venv python 3.9.6, `env -i` 로 HERDR_*/MAM_*/MQTT_*/WORKSPACE_ROOT/CLAUDE_PROJECT_DIR/HOME_DIR/CODEX_HOME 을 비우고 호스트의 긴 TMPDIR 은 그대로. `.mam.env` 는 읽지 않았다. 범위는 이 커밋만.
|
||||||
|
|
||||||
|
## 1. 전제 확인 — 'default' 스캔은 정말 HERDR_SESSION_NAME 의 서버를 보는가 (확인할 것 1)
|
||||||
|
|
||||||
|
**코드.** reconcile 의 스캔은 `subprocess.run(['herdr', ...])` 로 PATH 의 `herdr` 를 부른다(`reconcile.sh:505-510`). lib.sh 를 source 하면 `$WORKSPACE_ROOT/.mam/shim` 이 PATH 맨 앞에 들어가고(`lib.sh:144-147`), create_session.sh 는 lib.sh 를 source 한 채 `reconcile.sh --once` 를 자식으로 띄우므로(`create_session.sh:748`) 그 reconcile 의 `herdr` 는 shim 이고 `HERDR_SESSION_NAME`(= create 의 서버, 워크스페이스 슬러그)을 물려받는다. shim 은 `-L X` 를 `HERDR_SESSION_NAME=X` 로 바꾸고(`lib.sh:184-190`), 실제 바이너리를 부를 때 `HERDR_SESSION_NAME` 이 비어 있지 않고 `default` 가 아니면 `--session <그 값>` 을 앞에 붙인다(`lib.sh:270-283`; `ls` 경로의 python 도 `MAM_LS_SESSION` 으로 같은 값을 씀, `:1256-1318`). 따라서 `-L` 없는 'default' 스캔 = `herdr --session <HERDR_SESSION_NAME> ls` 이다. 전제 성립.
|
||||||
|
|
||||||
|
**실서버(herdr 0.9.3, 일회용 HOME `/tmp/ae9c-h*`, 일회용 서버 이름).**
|
||||||
|
- lib.sh 를 source 한 셸에서 `HERDR_SESSION_NAME=t-ae9c-srv` 로 shim `herdr ls -F …`(`-L` 없음) → rc 0. 그 HOME 아래 생긴 서버는 `.config/herdr/sessions/t-ae9c-srv/` 하나뿐이고 default 소켓 `.config/herdr/herdr.sock` 은 생기지 않았다 → 'default' 스캔이 환경변수의 서버로 갔다. `-L t-ae9c-srv ls` 도 rc 0(같은 서버). 서버는 `--session t-ae9c-srv server stop` 으로 내리고 HOME 제거, 잔류 프로세스 0.
|
||||||
|
- 참고로 **원 바이너리는 `HERDR_SESSION_NAME` 을 모른다**: `/opt/homebrew/bin/herdr status server` 는 `HERDR_SESSION_NAME=t-ae9c-x` 에서 default 소켓(`…/herdr.sock`)을, `HERDR_SESSION=t-ae9c-y` 와 `--session t-ae9c-z` 에서 `sessions/<이름>/herdr.sock` 을 가리켰다. 즉 전제는 shim 을 거칠 때만 성립하는데, reconcile 은 위와 같이 shim 을 거친다(그리고 lib.sh 를 source 한 셸에서는 `herdr` 자체가 함수다). 수정이 "HERDR_SESSION_NAME 이 비어 있지 않고 default 가 아닐 때" 로 조건을 건 것은 `_real_herdr` 의 조건(`:271-274`)과 정확히 같은 경계다.
|
||||||
|
|
||||||
|
## 2. 변경 자체 (`reconcile.sh:659-666`)
|
||||||
|
- drift B 에서만, `srv == 'default'` 이고 ambient 가 유효할 때 `srv` 를 ambient 로 바꾼다. 이후의 `server_opt`, `herdr_session`/`herdr_server`, `start_command`/`attach_command`/`kill_command` 가 모두 그 `srv` 를 쓰므로 행이 일관되게 "답한 서버" 로 적힌다. `srv` 는 루프 머리(`:607`)에서 매 반복 `t['server']` 로 다시 읽히므로 다음 항목으로 새지 않는다. 스캔(`:505`)·drift A(`:594-595`)·`pane_meta(name, srv)`(`:650`, 스캔한 라벨로 호출 — 같은 ambient 로 답하므로 동일 서버) 는 그대로다.
|
||||||
|
- bash 변경 없음(`bash -n` 통과), python 3.9 호환.
|
||||||
|
- `tests/data/skill_code_blocks.json`: `create_session.sh:550` 이 실제로 `raise SystemExit(4)` 임을 확인(`sed -n 550p`), `test_issue42_skill_blocks` 통과.
|
||||||
|
|
||||||
|
## 3. 부작용 검토 (확인할 것 2)
|
||||||
|
- **다음 reconcile 에서 drift A 로 오판되지 않는가.** 행의 서버가 X(ambient)로 적히면 다음 reconcile 은 그 행에서 X 를 `unique_servers` 에 넣고(`:499-502`) `-L X` 로 스캔하므로(`:505-507`) `alive_set` 에 `(name, X)` 가 들어가고(`:581`), drift A 의 비교 `(name, X)`(`:594-595`)가 맞는다 — 데몬의 환경(HERDR_SESSION_NAME 유무·다른 값)과 무관하다. 같은 create 환경에서는 'default' 스캔도 X 로 답해 `(name,'default')` 까지 더해지지만 비교에 쓰이지 않을 뿐 해가 없다. scenario4 가 바로 이 경우다: 같은 폴더 6 개 create 가 각자 `reconcile --once` 를 띄워 서로의 pane 을 보며, 6 행이 모두 살아남아야 한다 → 통과(아래).
|
||||||
|
- **기존 'default' 행·HERDR_SESSION_NAME 없는 데몬.** 조건이 ambient 가 비어 있거나 'default' 면 발동하지 않으므로(`:665`) 종전과 같은 'default' 라벨이 적힌다. 이미 'default' 로 적힌 행의 drift A 판정은 이 커밋이 건드리지 않았다.
|
||||||
|
- **같은 pane 이 'default' 스캔과 X 스캔에 두 번 보일 때.** `herdr_sessions` 에는 `(name,'default')`·`(name,X)` 두 항목이 들어오지만 drift B 는 이름 기준으로 건너뛴다: `any(_sanitize(y) == name for y in yaml_session_names)`(`:610`) 와 등록 직후의 `yaml_session_names.add(name)`(`:715`). 어느 항목이 먼저 오든(정렬된 서버 순) 첫 항목이 X 라벨로 등록되고 두 번째는 건너뛴다 → 중복 없음. 두 경로 모두 라벨은 X 로 같다(`-L X` 는 `srv=X`, 'default' 는 ambient 로 교정).
|
||||||
|
- **`HERDR_SERVER_NAME` 만 있는 경우.** `unique_servers` 는 그 값을 넣지만(`:497-498`) 라벨 교정은 `HERDR_SESSION_NAME` 만 본다. shim 의 라우팅(`_real_herdr`)도 `HERDR_SESSION_NAME` 만 보므로 그때의 'default' 스캔은 실제로 default 서버를 본다 — 라벨 'default' 가 맞다. 일관됨.
|
||||||
|
- 비차단 메모(기존 코드): `:610` 의 `(name, srv) in yaml_session_names` 는 이름 집합에 튜플을 넣어 보는 비교라 항상 거짓이고, 실제 중복 방지는 뒤의 `any(...)` 가 한다. 동작에는 영향 없음.
|
||||||
|
|
||||||
|
## 4. 테스트 (확인할 것 3)
|
||||||
|
|
||||||
|
| 항목 | 결과 |
|
||||||
|
|---|---|
|
||||||
|
| head: `test_tier4_e2e.py::test_e2e_scenario4_parallel_flock_locking` + `test_issue50_pane_lock.py` + `test_issue42_skill_blocks.py` + `test_issue9_server_env.py` + `test_b19_headless_reconcile_fixes.py` + `test_tier1_unit.py` + `test_tier2_component.py` + `test_workspace_scope.py` + `test_b4_session_created.py` | **212 passed, 1 skipped** (464 s) |
|
||||||
|
| head: `test_issue50_dispatch_herdr.py` (실서버 16 케이스, a3 = 같은 폴더 3 개 동시 create 포함) | **16 passed** (548 s) |
|
||||||
|
| 수정 전 대조군: `git archive HEAD` 복사본에 reconcile.sh 만 efd27de 로 되돌리고 scenario4 | **실패** — `ERROR: parallel-sess-3-creator-claude already running in agent-sessions.yaml`, `assert 4 == 0` (브리프의 증상 그대로 재현) |
|
||||||
|
|
||||||
|
## 5. 잔류물
|
||||||
|
- 일회용 HOME `/tmp/ae9c-h`, `/tmp/ae9c-h2`, `/tmp/ae9c-h3` 제거, 일회용 서버 `t-ae9c-srv` 정지(잔류 프로세스 0). `/tmp/mam50-*` 박스 0, 실험 herdr 서버 0. 검증 경로에는 `.mam` 조차 생기지 않았고 `git status` 깨끗. scratch 복사본·basetemp·프로브 스크립트는 세션 scratchpad 아래. 실제 `.mam`·모니터·세션·실제 서버·에이전트 홈은 건드리지 않았다(실제 서버에 대해서는 `herdr status` 읽기 한 번뿐).
|
||||||
|
|
||||||
|
## 결론
|
||||||
|
'default' 스캔이 shim 을 거쳐 HERDR_SESSION_NAME 의 서버를 본다는 전제는 코드와 실서버로 확인됐고, 행을 "답한 서버" 로 적는 교정은 drift A·중복 등록·기존 행·환경변수 없는 데몬에 부작용이 없다. create 의 자기 서버 행 인수(F2)와 맞물려 scenario4 가 이 Mac 에서 통과하며, 수정 전에는 브리프의 rc 4 로 실패한다.
|
||||||
|
|
||||||
|
[VERDICT: PASS]
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"loop_id": "loop-20261023T035928Z-2ccc02",
|
||||||
|
"base_sha": "dc886ecd6b38c01032a55a2d1c230c73362ae630",
|
||||||
|
"head_sha": "1614af5b673d7519d3a38f63e3636055592b4f14",
|
||||||
|
"tree_sha": "f1af6fdd548ee5d6f6b81e2202833edfaca98188",
|
||||||
|
"review_commit": "1614af5b673d7519d3a38f63e3636055592b4f14",
|
||||||
|
"reviewer": "planner-reviewer-claude-01",
|
||||||
|
"job_id": "af549b13",
|
||||||
|
"verdict": "PASS",
|
||||||
|
"transport": "herdr",
|
||||||
|
"evidence": "review-tree",
|
||||||
|
"isolation": "worktree",
|
||||||
|
"verified_at": "2026-10-23T05:16:58Z"
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
# PR #69 (Issue #63) 리뷰 — head 1614af5
|
||||||
|
|
||||||
|
- 리뷰어: planner-reviewer-claude-01 (macOS, bash 3.2, /usr/bin/python3 3.9.6)
|
||||||
|
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261023T035928Z-2ccc02/1`
|
||||||
|
- identity 확인: HEAD 1614af5b673d7519d3a38f63e3636055592b4f14, tree f1af6fdd548ee5d6f6b81e2202833edfaca98188, `git status --porcelain --ignored` 비어 있음, `.mam` 없음 (시작·종료 시 모두). main HEAD dc886ec 그대로.
|
||||||
|
- 범위: base dc886ec..1614af5 전체(커밋 13개).
|
||||||
|
- ede9c62 까지는 이전 리뷰(잡 8445af7f, 55f86b49)에서, 8f64174·7cac6f6 은 잡 1c6eca2b 에서 봤다. 그 결론을 다시 확인했다.
|
||||||
|
- 이번에는 1614af5 와 세 수정 사이의 정합성을 중점으로 봤다.
|
||||||
|
- 참고: 같은 head 를 맡은 잡 906427d1 은 관련 스위트 도중 검증 경로(`…a646cb/3`)가 사라져 끝내지 못했다. 그 잡에서 얻은 head 결과(issue63 27 passed, 변이 결과)는 같은 tree 의 스크래치 사본에서 나온 것이다. `diff -rq` 로 이번 검증 경로와 같음을 확인했다.
|
||||||
|
|
||||||
|
## 1. 1614af5 가 바꾼 것
|
||||||
|
|
||||||
|
- `.agents/skills/lib_py/agents/id_order.py:223`: `elif sess == me or not sess:` 를 `elif not sess:` 로 바꿨다.
|
||||||
|
- 그래서 호출자 자신의 세션 이름에 남은 죽은 owner 마커도 고아 규칙을 따른다. 이번 시도의 행이 등록되면 지우고(:225), herdr 가 정상 목록으로 "없음"이라 답하면 지우고(:228-229), 그 밖에는 남긴다.
|
||||||
|
- `_read_and_prune_markers` 의 `me` 인자를 지웠다(:183, :308).
|
||||||
|
- `blockers` 는 ede9c62 부터 `m_sess == me` 를 건너뛰지 않는다(:136-144). 그래서 남은 자기 이름 마커가 실제로 막는다.
|
||||||
|
|
||||||
|
## 2. 세 수정이 각 지적을 닫는가, 서로 어긋나지 않는가
|
||||||
|
|
||||||
|
1. **8f64174 — 조회 실패를 "없음"으로 읽던 문제**
|
||||||
|
- `herdr_session_state` (:58-86) 는 정상 파싱한 `agent list` 에 이름이 없을 때만 False 를 돌려준다. 그 밖에는 None 이다.
|
||||||
|
- prune 은 `drop = live is False` (:228) 다.
|
||||||
|
- 변이 m1(`not live`)과 m2(rc≠0 → False)가 테스트에 잡혔다(잡 1c6eca2b).
|
||||||
|
2. **7cac6f6 — 상한 0 에서 30 초 조회, server_not_running**
|
||||||
|
- 조회 시간은 `deadline - now` 다(:288). timeout≤0 이면 None 을 돌려준다(:64-65).
|
||||||
|
- 변이 m4(고정 30 s)와 m7(timeout≤0 에도 조회)이 잡혔다.
|
||||||
|
- 상한 0 테스트는 0.3 s 안에 끝나는 것을 확인한다(test :1222-1224).
|
||||||
|
3. **1614af5 — 같은 이름의 죽은 owner 마커를 확인 없이 지우던 문제**
|
||||||
|
- 옛 규칙(`sess == me` 이면 drop)을 되돌린 변이 사본에서는 두 테스트가 실패한다: `test_id_order_dead_owner_marker_blocks_while_its_unregistered_herdr_session_is_alive`, `test_id_order_real_probe_against_mock_herdr_fails_closed`.
|
||||||
|
- 커밋 메시지가 말한 "재시도의 실패 정리가 살아 있는 pane 을 죽임"도 막힌다. create 의 대기(`create_session.sh:287`)는 롤백 trap 설치(:367)보다 앞서 있어서, 대기에서 거부(exit 1)되면 `cleanup_herdr_on_error` 가 돌지 않는다.
|
||||||
|
4. **정합성**: 세 수정은 모두 `_read_and_prune_markers` 의 "죽은 owner 마커" 분기 하나를 쓰고, 조회는 `bounded_probe` 하나를 거친다. 자기 이름 마커만 다른 규칙을 쓰던 예외가 사라져서 규칙이 하나로 모였다. 어긋나는 곳은 찾지 못했다.
|
||||||
|
|
||||||
|
## 3. 흔한 사용을 600 초 대기로 막지 않는가
|
||||||
|
|
||||||
|
스크래치에서 `id_order.wait` 를 프로세스 안에서 직접 불렀다. 조회 함수를 주입하고, cap 2 s(일부 0 s)로 돌렸다.
|
||||||
|
|
||||||
|
**정상 사용 (실패 없음)**: 막지 않는다.
|
||||||
|
- create 마커가 남은 채로, 그 세션이 다음 상태일 때 같은 이름으로 resume 했다:
|
||||||
|
- stopped 이고 own id 가 기록됨
|
||||||
|
- stopped 이고 id 없음 (herdr-dead stop)
|
||||||
|
- reconcile 이 terminated 로 바꿈
|
||||||
|
- 세 경우 모두 herdr 가 **답하지 못해도**(None), cap 0 에서도 rc=0 이었다.
|
||||||
|
- `blockers` 의 `pinned_for_attempt` 와 `ended_after_attempt` (:158-171) 가 이 경우들을 걸러낸다.
|
||||||
|
- 정상 create/resume 이 끝난 뒤의 마커는 행이 running 인 동안에는 `_registered_for_attempt` (:177-180) 로 지워진다.
|
||||||
|
|
||||||
|
**실패 뒤 재시도**:
|
||||||
|
|
||||||
|
| 경우 | herdr 가 "없음" 응답 (서버 살아 있음) | herdr 무응답 (서버 꺼짐 등) |
|
||||||
|
|---|---|---|
|
||||||
|
| create X 가 행 쓰기 전에 실패(exit 42 롤백) → create X 재시도 | rc=0, 0.0 s | rc=1, cap 만큼 대기 |
|
||||||
|
| resume X 가 spawn 에서 실패(행 그대로) → resume X 재시도 | rc=0, 0.0 s | rc=1, cap 만큼 대기 |
|
||||||
|
| 위 resume 실패 → 같은 cwd 에서 create Y | rc=0, 0.0 s | rc=1, cap 만큼 대기 |
|
||||||
|
|
||||||
|
- 7cac6f6 사본으로 같은 시뮬레이션을 돌리면 무응답 열의 위 두 줄(같은 이름 재시도)이 rc=0 이다.
|
||||||
|
- 즉 1614af5 는 서버가 살아 있는 흔한 재시도는 그대로 두고, herdr 가 답하지 못하는 재시도만 막는다. 이는 아래 R1 이 같은 이름까지 넓어진 것이다.
|
||||||
|
|
||||||
|
## 4. 그 밖의 확인
|
||||||
|
|
||||||
|
브리프 항목 1·2·4·5·6·8(완료 조건 테스트, autopin 재사용, resume·stop, 지운 심볼, issue34 무관)은 1614af5 가 `session_ids.py`, `lib.sh`, 셸 스크립트, 문서를 건드리지 않으므로 이전 리뷰의 결론 그대로다.
|
||||||
|
|
||||||
|
**macOS**:
|
||||||
|
- `/usr/bin/python3` 3.9.6 으로 `lib_py.agents.id_order` import 가 성공한다(잡 1c6eca2b; 1614af5 는 문법 요소를 더하지 않는다).
|
||||||
|
- 잠금은 `fcntl.flock` 이다(:294-295).
|
||||||
|
|
||||||
|
**실제 herdr 를 건드리지 않았는가**:
|
||||||
|
- 기존 서버 두 개(pid 3407 `herdr server`, 8323 `herdr --session multi-agent-mux server`)는 스위트 전후로 같다(시작 시각 08:21).
|
||||||
|
- 스위트 중 13:22 에 `herdr --session mam-pylecturenotes server` 가 새로 보였다. 하지만 이 프로세스는 실제 HOME 과 `WORKSPACE_ROOT=/Users/godopu16/PuKi/lac/curriculum/01_python_getting_started_lecturenotes` 로 떠 있고, 사용자의 attach 클라이언트(13:27)가 붙어 있다. 테스트는 HOME 을 스크래치로 바꿔 돌므로 다른 작업 공간의 것이다.
|
||||||
|
|
||||||
|
## 5. 실행한 테스트
|
||||||
|
|
||||||
|
모두 `env -i`, 임시 HOME/XDG, `PYTHONDONTWRITEBYTECODE=1 -p no:cacheprovider --basetemp=<scratchpad>` 로 돌렸다. 테스트에서 .mam.env 를 export 하지 않았다.
|
||||||
|
|
||||||
|
| 대상 | 결과 |
|
||||||
|
|---|---|
|
||||||
|
| 검증 경로: `tests/test_issue63_same_cwd_ids.py` + 관련 19개 모듈 (issue40_auto_pin, issue64_ro_connect, issue10_13_purge, issue31_purge_residual, issue42_skill_blocks, tier1_unit, tier2_component, b4_session_created, issue24_resume_dialogs, issue30_stall_watch_stop, issue23_24_startup_path, issue45_restore, issue50_pane_lock, c1_tui_readiness, issue6_fail_closed, issue60_opencode_v2, issue60_linux_fixes, uuid_target, issue17_workspace_root_isolation) | **597 passed, 2 skipped, 0 failed** (32 분) |
|
||||||
|
| 같은 tree 스크래치 사본: issue63 단독 | 27 passed |
|
||||||
|
| base 사본 + head 테스트 | 27 errors (`ImportError: id_order`). 조건별 base 실패는 1차에서 22 failed + 1 passed 로 확인했다 |
|
||||||
|
| 변이: 1614af5 를 되돌림(자기 이름 마커를 drop) | 2 failed (위 두 테스트) |
|
||||||
|
|
||||||
|
## 6. 차단 결함
|
||||||
|
|
||||||
|
없음.
|
||||||
|
|
||||||
|
## 7. 비차단
|
||||||
|
|
||||||
|
### R1 (잡 1c6eca2b 의 R1, 범위가 넓어짐): herdr 가 답하지 못하는 고아 마커는 같은 폴더·같은 종류의 create/resume 을 매번 cap 동안 붙잡는다
|
||||||
|
|
||||||
|
- 이제 같은 이름 재시도도 포함된다(위 표).
|
||||||
|
- 서버가 꺼진 상태(server_not_running)가 대표적이다.
|
||||||
|
- 조회는 서버를 띄우지 않는다(:67).
|
||||||
|
- create/resume 은 대기를 통과해야 spawn 으로 서버를 띄운다(`create_session.sh:287`→:289, `resume_session.sh:243`→:260).
|
||||||
|
- 그래서 다른 무언가가 서버를 켜기 전까지는 기본 600 s 를 기다린 뒤 거부가 반복된다.
|
||||||
|
- 회복 경로는 있다: 메시지의 `HERDR_SESSION_NAME=<srv> herdr kill-session -t <s>` 를 shim 으로 실행하면 서버가 기동된다(잡 1c6eca2b 의 가짜 herdr 재현에서 `--session srv-down server` 1회).
|
||||||
|
- 생기는 조건은 "행 등록 전 실패나 kill"에 "서버가 꺼짐"이 겹치는 경우다. 정상 사용과 서버가 살아 있는 재시도는 막지 않는다(3절).
|
||||||
|
- 권고 (둘 중 하나):
|
||||||
|
- (a) server_not_running 만은 "없음"으로 읽는다. MAM 서버는 restore 가 꺼져 있어서 꺼진 서버 위에는 살아 있는 pane 이 없다. `lib.sh:204-207` 의 ISSUE-9 규약과도 맞는다.
|
||||||
|
- (b) 그 경우는 cap 을 기다리지 말고 바로 거부한다. 메시지는 "서버가 꺼져 있음 → 위 명령으로 정리"처럼 사실대로 쓴다.
|
||||||
|
- fail-closed 를 택한 것은 루프에서 정한 설계 선택이라 차단으로 두지 않는다.
|
||||||
|
|
||||||
|
### 그 밖의 비차단 (잡 1c6eca2b 와 같음)
|
||||||
|
|
||||||
|
- **N1**: 조회가 `MAM_NO_AUTOSTART=1` 을 쓴다는 것(:67)을 고정하는 테스트가 없다.
|
||||||
|
- **N2**: sanitize 한 이름 비교(:85)를 고정하는 테스트가 없다.
|
||||||
|
- **N3**: `flock(LOCK_EX)` 대기(:295)는 시간 제한이 없다. 다른 호출자가 앞 대기의 남은 시간만큼 더 기다릴 수 있다. 상한은 있다.
|
||||||
|
- **N4**: create·resume SKILL.md 의 exit 1 설명이 고아 마커로 거부되는 경우와 그 정리 방법을 말하지 않는다.
|
||||||
|
- **N5**: 이전 라운드의 비차단 항목이 남아 있다: resume `mam_id_order_wait` 제거가 테스트로 고정되지 않음, c6(b) 의 1.5 s 타이밍 flake, lib.sh `--herdr-server` 전달이 테스트되지 않음.
|
||||||
|
|
||||||
|
## 8. 결론
|
||||||
|
|
||||||
|
- 1614af5 는 codex 의 3차 지적(같은 이름의 죽은 owner 마커를 확인 없이 지움)을 닫는다. 8f64174·7cac6f6 과 같은 고아 규칙 하나로 모였다.
|
||||||
|
- 정상 create/resume 과 서버가 살아 있는 재시도는 막지 않는다.
|
||||||
|
- herdr 가 답하지 못할 때 cap 동안 기다리는 비용(R1)은 회복 경로가 있는 설계 선택이므로 비차단이다.
|
||||||
|
|
||||||
|
[VERDICT: PASS]
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# 리뷰 리포트 — Job b0c15e45 (PR #51 2회차 델타, 518beda..4c86107)
|
||||||
|
|
||||||
|
- 리뷰어: planner-reviewer-claude-01 (claude)
|
||||||
|
- 리뷰 identity: loop-20261007T155310Z-56d5a7 / base 518beda / head 4c86107 / tree e6f67f6 — 검증 경로에서 `git rev-parse HEAD 'HEAD^{tree}'` 일치, 미커밋 변경 없음
|
||||||
|
- 범위(항목 4): lib_py/delegation/wrapper.py `_sleep`(+6/−3, 주석 포함)과 테스트 1개 추가. 래퍼 bash·문서·버전 변경 없음.
|
||||||
|
|
||||||
|
## 결론 요약
|
||||||
|
codex R1 이 닫혔다. monotonic 은 `_sleep` 의 대기 길이에만 쓰이고, 마감·evidence 창·이벤트 시각은 벽시계 그대로라 옛 bash 정책과 같다. 새 테스트는 결정적이며 이전 코드에서 실패한다. 차단 결함 없음.
|
||||||
|
|
||||||
|
## 1. R1 종결
|
||||||
|
| 지적 (codex 1회차) | 수정 | 확인 |
|
||||||
|
|---|---|---|
|
||||||
|
| R1 `_sleep` 이 `time.time()` 으로 남은 시간을 재서, 대기 중 벽시계가 뒤로 바뀌면 대기가 늘어남(1 s→11 s, 3 s→13 s) | wrapper.py:145-156 — `end`/`left` 를 `time.monotonic()` 으로 계산 | 새 테스트 test_u_a_wait_is_not_stretched_by_a_wall_clock_step_back[1, 3] 통과. 이전 wrapper.py(518beda)를 넣은 스크래치 사본에서는 `assert 11.0 <= 1.5`, `assert 13.0 <= 3.5` 로 **2 failed** — 지적한 증상 그대로 재현 |
|
||||||
|
|
||||||
|
## 2. 벽시계 정책이 섞이지 않는가
|
||||||
|
- monotonic 이 쓰이는 곳은 `_sleep`(:150, :153) 뿐이다. 벽시계는 그대로: `_now()`(:45-46, bash `date +%s`), 마감 `start_time + timeout`(:203), broker 재관측의 남은 시간 `deadline - _now()`(:208)와 마감 도달 판정(:218), 유예 루프 조건·evidence 창·마감 판정(:226-240), 디스크 heartbeat 의 나이 `time.time() - seen`(:115, 이벤트 timestamp 가 벽시계이므로 맞다).
|
||||||
|
- 옛 bash 와 같다: `sleep 1`/`sleep "$left"` 는 경과 시간(벽시계 무관)이고, 마감·창은 `$(date +%s)`. broker 루프는 `min(deadline - _now(), 3)` 을 벽시계로 구한 뒤 그 길이만큼 monotonic 으로 잔다 — bash 의 `left` 계산 후 `sleep "$left"` 와 동일.
|
||||||
|
- 래퍼 생존 확인(0.5 s 간격)은 `_sleep` 안의 `time.sleep(min(left, 0.5))` 와 매 반복 `_check_owner` 로, 간격 자체가 monotonic 남은 시간 기준이 되었을 뿐 확인 빈도·조건은 같다.
|
||||||
|
|
||||||
|
## 3. 테스트 결정성
|
||||||
|
- `_SteppedClock` 이 모듈의 `time` 을 대체해 실제 시간·sleep 을 쓰지 않는다(0.1 s 에 끝). 첫 sleep 중 벽시계만 10 s 역행시키고 `slept` 를 정확히 누적하므로 결과는 입력으로 결정된다(현재 코드에서 slept == seconds). 3회 반복 모두 통과. 상한 `seconds + 0.5` 와 옛 결과(+10)의 차이가 커서 오탐 여지가 없다.
|
||||||
|
- 이 테스트는 벽시계가 **앞으로** 뛰는 경우는 다루지 않지만, 그 경우는 옛 bash 와 마찬가지로 마감 쪽(벽시계)이 일찍 끝나는 것이 정책이므로 대상이 아니다.
|
||||||
|
|
||||||
|
## 4. 테스트 실행
|
||||||
|
| 실행 | 결과 |
|
||||||
|
|---|---|
|
||||||
|
| 지정 2개 파일(test_issue17_item4_decisions, test_issue33_budget), pr-51 .venv Python 3.9.6 | **278 passed / 0 failed** (325 s) |
|
||||||
|
| 새 테스트, 이전 wrapper.py 사본 | 2 failed (11.0, 13.0) |
|
||||||
|
- 브리프 주의대로 모든 실행을 `env -i` 로 HERDR_*·MAM_*·MQTT_*·WORKSPACE_ROOT 를 비우고 임시 HOME(스크래치)으로 돌렸다(탐침 사본에는 WORKSPACE_ROOT 를 그 사본으로 명시). 실제 .mam·다른 워크스페이스·herdr 서버를 건드리지 않았다.
|
||||||
|
- PR 설명의 전체 스위트 실패 1건(test_issue45_restore 의 $HOME 복원, herdr 0.9.3)은 이 델타가 건드리지 않는 파일이며 main 에서도 같다는 설명과 일치한다(이번에 재실행하지 않음).
|
||||||
|
|
||||||
|
## 5. 비차단
|
||||||
|
1회차 N1~N3·N5 는 후속으로 남겨 둔 데 동의한다. 이번 델타에 새 관찰은 없다.
|
||||||
|
|
||||||
|
[VERDICT: PASS]
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
# 🔍 Review Report — Job b368210a (branch `fix/issue-12-8-7` 2회차, HEAD `ea911d7`)
|
||||||
|
|
||||||
|
- **Reviewer**: `planner-reviewer-claude-01` (role: planner,reviewer) · 2026-09-13
|
||||||
|
- **대상**: `git log 8a09e02..HEAD` 의 네 커밋. 1회차에서 PASS 한 부분은 회귀 중심으로, 새 커밋 2건은 정밀 검토했다.
|
||||||
|
- `e629cc1`: fix #12·#8·#7
|
||||||
|
- `4176d75`: release v4.4.4
|
||||||
|
- **`0fedae2`**: N1 수정 — 새 커밋
|
||||||
|
- **`ea911d7`**: 비차단 권고 반영 — 새 커밋
|
||||||
|
- **HEAD 고정**: 리뷰 시작(12:31)부터 끝까지 HEAD 는 `ea911d7`, 작업 트리는 깨끗했다. 같은 HEAD 에 대한 고아 잡 `0cf8d1c0` 의 PASS 판단을 이 잡에서 **새 증거로 다시 확인**했다(샌드박스·테스트 모두 이 잡에서 재실행).
|
||||||
|
- **방법**:
|
||||||
|
- 코드 정독
|
||||||
|
- `$TMPDIR` 샌드박스 재현(모두 끝나고 삭제)
|
||||||
|
- `scrub-secrets` 레지스트리
|
||||||
|
- `update.sh` 병합 블록 이전/이후 비교
|
||||||
|
- `git archive` 사본에서 #7 테스트 변이 검사
|
||||||
|
- 실제 레지스트리는 **읽기 전용** 집계만 했다(`scrub-secrets` 는 실제 `.mam/jobs` 에 `--dry-run` 으로도 실행하지 않음)
|
||||||
|
- 브리프 지정 테스트(실제 `.mam/jobs` 스냅샷 비교)
|
||||||
|
- 정적 검사
|
||||||
|
- 리뷰 중 45초 간격 `progress` heartbeat 발행. 코드 수정 없음.
|
||||||
|
|
||||||
|
## 0. 1회차 결과와 처리 (브리프 기준)
|
||||||
|
|
||||||
|
| 리뷰어 | 1회차(`4176d75`) 판정 |
|
||||||
|
|---|---|
|
||||||
|
| claude | **NOT PASS** — N1: `scrub-secrets` 가 키의 존재로 대상을 골라 null 비밀번호 레코드 901건을 재기록 |
|
||||||
|
| agy | PASS |
|
||||||
|
| grok | PASS |
|
||||||
|
| codex | 사용량 한도로 리뷰 불가(이번 회차 제외, 한도 복구 후 별도 리뷰 예정) |
|
||||||
|
|
||||||
|
creator 는 N1 을 `0fedae2` 로 고쳤고, 비차단 권고 1·2번(`update.sh` 병합 견고화, #7 테스트 보강)을 `ea911d7` 로 반영했다.
|
||||||
|
|
||||||
|
## 1. `0fedae2` — `scrub-secrets` 선택 조건 — ✅
|
||||||
|
|
||||||
|
### 1-1. 코드·문서
|
||||||
|
- `registry.py` `_cmd_scrub_secrets` 의 선택 조건이 `"password" in broker` → **`broker.get("password")`** 로 바뀌었다. 저장된 값이 있는 레코드만 대상이다.
|
||||||
|
- `registry_lock` 아래에서 동작하고, `--dry-run` 이면 기록하지 않으며, 기록은 `_atomic_write_record` 를 거친다.
|
||||||
|
- 서술도 코드와 일치한다.
|
||||||
|
- `registry.md:60`: "cleaned on their next write; `registry.py scrub-secrets [--dry-run]` strips …"
|
||||||
|
- `VERSIONS.md:46`: "비밀번호 값이 실제로 저장된 레코드만 … null 키는 … 대상도 보고도 아니며, 다음 쓰기 때 `_atomic_write_record` 가 정리"
|
||||||
|
- `registry.py` 는 `0fedae2` 이후 무변경이다(`git diff --stat 0fedae2..HEAD` 는 `update.sh`·#7 테스트뿐).
|
||||||
|
|
||||||
|
### 1-2. 샌드박스 재현 (이 잡에서 새로 실행)
|
||||||
|
- 레코드 네 개를 만들었다: `password: None` / `""` / 가짜 값(uuid4) / 키 없음. 모두 `host/port/tls/username`·`prompt` 를 가진다.
|
||||||
|
- 대상 안전장치: 최상위 `--registry-dir`(`registry.py:270`, 서브파서는 `--dry-run` 만 정의)에 더해 `MAM_JOBS_DIR` 도 샌드박스로 지정했다. dry-run 출력이 샌드박스 경로를 가리킬 때만 실제 실행으로 넘어가게 했다.
|
||||||
|
|
||||||
|
| 단계 | 결과 |
|
||||||
|
|---|---|
|
||||||
|
| `--dry-run` | rc 0, "would scrub **1** record(s) under `<sandbox>`: **k_real**", 네 파일 모두 바이트 동일 |
|
||||||
|
| 실제 실행 | rc 0, "scrubbed 1 record(s) under `<sandbox>`: k_real", 바뀐 파일은 `k_real` 하나 |
|
||||||
|
| 실행 후 | `k_real` 만 `password` 키 제거. `k_null`·`k_empty` 는 키 유지(다음 쓰기에 맡김), `k_absent` 무변경. 네 레코드 모두 나머지 broker 필드와 `prompt` 보존 |
|
||||||
|
|
||||||
|
### 1-3. 실제 레지스트리 (읽기 전용 집계, 명령 미실행)
|
||||||
|
910건 중 새 규칙으로 선택되는 레코드는 **0건**이다. null/빈 password 키를 가진 901건은 선택되지 않는다.
|
||||||
|
|
||||||
|
### 1-4. 회귀 테스트
|
||||||
|
`test_scrub_secrets_cli_migrates_a_registry` 가 레거시 `password: null` 레코드를 포함한다. dry-run 목록에 없고, 실제 실행 후에도 바이트 단위로 동일함을 단정한다.
|
||||||
|
|
||||||
|
## 2. `ea911d7` — `deploy/update.sh:239-257` `install_state` 병합 — ✅
|
||||||
|
|
||||||
|
**코드**
|
||||||
|
- **끝 개행 보장**(241-245): `[ -s … ] && [ -n "$(tail -c1 …)" ]`.
|
||||||
|
- 명령 치환이 끝 개행을 지우므로, 마지막 바이트가 개행이 아닐 때만 `\n` 을 붙인다. 빈 파일은 건너뛴다.
|
||||||
|
- **`=` 없는 줄 무시**(247-250): `case "$line" in *=*)`.
|
||||||
|
- **정확 키 매칭**(254): `awk -F= -v k="$key" '$1 == k …'`.
|
||||||
|
- `key="${line%%=*}"` 와 awk `$1` 은 둘 다 첫 `=` 앞부분이라 기준이 같다.
|
||||||
|
- `if !` 조건 안이라 `update.sh:8` 의 `set -euo pipefail` 아래에서도 안전하다.
|
||||||
|
- 실패 경로 `restore_on_failure` 는 무변경이다.
|
||||||
|
|
||||||
|
**샌드박스 비교**(이 잡에서 새로 실행): HEAD 와 `0fedae2` 의 블록을 그대로 추출해, 같은 입력을 `bash -euo pipefail` 로 실행했다.
|
||||||
|
- 새 파일: `axb=new`, `version=2`(끝 개행 없음)
|
||||||
|
- 이전 파일: `a.b=old`, `axb=old`, `gitignore_created=1`, `# comment`, `noequals`, `=emptykey`, `k=v=w`, `version=1`(끝 개행 없음)
|
||||||
|
|
||||||
|
| 블록 | 결과 |
|
||||||
|
|---|---|
|
||||||
|
| `0fedae2` | 두 키가 한 줄로 붙어 `version=2gitignore_created=1` 이 됨. `a.b=old` 유실(정규식 `^a.b=` 가 `axb=` 에 일치). `# comment`·`noequals` 추가 |
|
||||||
|
| `ea911d7` | `axb=new` / `version=2` / `a.b=old` / `gitignore_created=1` / `k=v=w` — 기대와 일치, rc 0 |
|
||||||
|
| `ea911d7` 추가 확인 | 두 번째 실행 결과 동일(멱등). 새 파일이 빈 경우·없는 경우 모두 `gitignore_created=1` 한 줄 |
|
||||||
|
|
||||||
|
**참고(무해)**: `awk -v` 는 값의 역슬래시 이스케이프를 해석한다. 하지만 `install_state` 키는 `install.sh:597,601` 의 `gitignore_created` 뿐이라 영향이 없다.
|
||||||
|
|
||||||
|
`test_issue8_update_state.py`(실제 install→update 사이클), `test_deploy_layout.py`, `test_deploy_freshness.py` 모두 통과했다(§5).
|
||||||
|
|
||||||
|
## 3. `ea911d7` — #7 테스트 2건이 실제 코드 경로를 검증하는가 — ✅
|
||||||
|
|
||||||
|
### 3-1. 코드 경로
|
||||||
|
**`test_idle_pane_in_a_later_workspace_is_reused`**
|
||||||
|
- 상황: w1 은 가득 찼고(4), w2 에 agent 없는 pane `w2:p_idle` 이 있다.
|
||||||
|
- `lib.sh` 가 따라가는 경로:
|
||||||
|
1. `lib.sh:764-777`: 후보마다 idle 필터 `not p.get('agent')` 를 적용한다. w1 에서는 찾지 못한다.
|
||||||
|
2. `lib.sh:812-815`: layout 이 overflow 이므로 `continue`.
|
||||||
|
3. `lib.sh:778-786`: w2 의 idle pane 을 선택하고 `break`.
|
||||||
|
- 단정: split·create 가 없고, `agent start` 대상이 `w2:p_idle` 이다.
|
||||||
|
|
||||||
|
**`test_ws_label_rename_targets_only_the_chosen_workspace`**
|
||||||
|
- 단정: rename 호출이 정확히 `["workspace","rename","w2","team-b"]` 하나다(`lib.sh:854-856`).
|
||||||
|
|
||||||
|
`lib.sh` 는 `e629cc1` 이후 무변경이다.
|
||||||
|
|
||||||
|
### 3-2. 변이 검사 (`git archive HEAD` 사본, 저장소 무변경)
|
||||||
|
- `conftest.py:22` 가 자기 위치 기준으로 `.agents/skills` 를 복사하므로, 사본의 테스트는 사본의 `lib.sh` 를 실행한다.
|
||||||
|
|
||||||
|
| 사본 | 변이 | idle 재사용 테스트 | rename 대상 테스트 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| control | 없음 | PASSED | PASSED |
|
||||||
|
| M1 | `lib.sh:771` idle 필터를 `if False:` 로(idle pane 을 찾지 못함) | **FAILED** — "an idle pane must be reused, not split around"(`w2:p1` 에서 split 발생) | PASSED |
|
||||||
|
| M2 | `lib.sh:855` rename 대상을 첫 후보(`${candidate_wss%%[[:space:]]*}`)로 | PASSED | **FAILED** — `[['workspace','rename','w1','team-b']]` ≠ 기대값(w2) |
|
||||||
|
|
||||||
|
- 각 테스트는 자기 코드 경로의 변이에서만 실패하고, 다른 테스트의 변이에는 영향받지 않는다. 두 테스트가 브리프의 "idle pane 우선"과 "rename 대상"을 **각각 독립적으로 고정**함이 확인되었다.
|
||||||
|
- 변이 실행 전후 실제 `.mam/jobs` 항목 수는 2401 로 동일했다. 사본은 끝나고 삭제했다.
|
||||||
|
|
||||||
|
## 4. 버전 락스텝 · 릴리스 커밋 — ✅
|
||||||
|
- `lib.sh:32` `MAM_VERSION="4.4.4"`, `eager.py:16` `FRAMEWORK_VERSION = "4.4.4"`, SKILL.md 8개 `version: 4.4.4`.
|
||||||
|
- `verify_framework_version()` 통과(`env -u MAM_VERSION`).
|
||||||
|
- 현재 문서(README·README.ko·BOOTSTRAP·BOOTSTRAP.ko·MESSAGING) 모두 4.4.4.
|
||||||
|
- `VERSIONS.md` 밖에 남은 `4.4.3` 은 보관된 리뷰 리포트(`.agents/reports/…`)의 이력 서술뿐이다.
|
||||||
|
- 릴리스 커밋 `4176d75` 제목은 `release(v4.4.4): bump framework and eight skills to 4.4.4 in lockstep` 그대로다.
|
||||||
|
- ⚠️ 비차단: `VERSIONS.md:51` "신규 테스트 **8개** … `test_issue7_workspace_packing.py` **3개**" 는 `ea911d7` 이후 **10개 / 5개**다(§6-1).
|
||||||
|
|
||||||
|
## 5. 테스트 · 정적 검사 (HEAD `ea911d7`)
|
||||||
|
- **브리프 지정 10파일 + `test_version_consistency` + `test_deploy_freshness`**: **275 passed in 154.9 s, rc 0**.
|
||||||
|
- 실행 시점 HEAD `ea911d7` 을 기록으로 확인했다.
|
||||||
|
- 실행 전후 실제 `.mam/jobs` 레코드 목록이 동일했다(추가 0건).
|
||||||
|
- **정적 검사**:
|
||||||
|
- `git diff --check 8a09e02...HEAD` 클린
|
||||||
|
- `bash -n deploy/update.sh` 통과
|
||||||
|
- 작업 트리 깨끗
|
||||||
|
- shellcheck 는 미설치라 실행하지 못했다(대신 §2 샌드박스 실행으로 확인).
|
||||||
|
|
||||||
|
## 6. 비차단 권고 (후속)
|
||||||
|
1. **`VERSIONS.md:51` 신규 테스트 수 정정**: 8 → 10, #7 3 → 5. 설명에 "뒤 후보의 idle pane 재사용, 선택된 워크스페이스만 rename" 추가. 병합 전 한 줄 수정을 권한다.
|
||||||
|
2. `scrub-secrets` 서브커맨드 요약 도움말 `"remove broker.password from every job record"` 은 이제 약간 과장이다. `--dry-run` 도움말은 정확하다. 고정 캡처 `help-registry.txt` 와 함께 갱신할 것.
|
||||||
|
3. `DelegationService` 를 라이브러리로 쓸 때 `ensure_env_loaded()` 선행, 명시 broker override 의 비밀번호를 env 로 제공한다는 점을 문서화.
|
||||||
|
4. 토큰 우발 노출 심층 방어(이전 트리아지 권고 유지).
|
||||||
|
|
||||||
|
## 7. GM 참고
|
||||||
|
- 이 판단은 `0cf8d1c0` 리포트와 같은 HEAD(`ea911d7`)에 대한 것이고, 결론도 같다.
|
||||||
|
- `0cf8d1c0` 에서 지적한 "리뷰 중 커밋" 문제는 이번 브리프가 `ea911d7` 을 대상으로 명시하면서 해소되었다. 병합 시에는 agy·grok 의 2회차 리포트도 `ea911d7` 을 대상으로 했는지 확인할 것.
|
||||||
|
|
||||||
|
## 8. 종합
|
||||||
|
- N1 은 최소 수정으로 해소되었다. 샌드박스, 실제 레지스트리 읽기 전용 집계(선택 0건), 회귀 테스트로 확인했다.
|
||||||
|
- `ea911d7` 의 `update.sh` 병합 보강은 세 가지 가장자리를 바르게 고쳤다. 새 #7 테스트 2건은 해당 코드 경로를 실제로 고정한다(§3-2).
|
||||||
|
- 버전 락스텝은 완전하다. 남은 것은 `VERSIONS.md:51` 테스트 수 한 줄(비차단)뿐이다. 설계 재검토는 필요 없다.
|
||||||
|
|
||||||
|
[VERDICT: PASS]
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"loop_id": "loop-20261015T123502Z-1b9df6",
|
||||||
|
"base_sha": "e32c9574644a9185813a4dc881812694d62b6204",
|
||||||
|
"head_sha": "a7cb90e63abf454ccf22f6ff887cc25393755402",
|
||||||
|
"tree_sha": "96c5c5f82cb89e4383011d52fb1dcb21d4e787e6",
|
||||||
|
"review_commit": "a7cb90e63abf454ccf22f6ff887cc25393755402",
|
||||||
|
"reviewer": "planner-reviewer-claude-01",
|
||||||
|
"job_id": "b48be8a6",
|
||||||
|
"verdict": "PASS",
|
||||||
|
"transport": "herdr",
|
||||||
|
"evidence": "review-tree",
|
||||||
|
"isolation": "worktree",
|
||||||
|
"verified_at": "2026-10-15T13:18:35Z"
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# PR #58 (fix/issue-57) 병합 전 2차 리뷰 (job b48be8a6)
|
||||||
|
|
||||||
|
- 리뷰어: planner-reviewer-claude-01 (Reviewer)
|
||||||
|
- 리뷰 identity: loop-20261015T123502Z-1b9df6 / base e32c9574 .. head a7cb90e6 (커밋 15개; 1차 이후 대응 4개: 75c0042 F1, 151af99 F2, 1c598bb R1, a7cb90e R2) / tree 96c5c5f8
|
||||||
|
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261015T123502Z-1b9df6/1` — HEAD·tree 일치, `git status --porcelain` 0 줄, `.mam` 없음(시작·종료 모두). 본 체크아웃 HEAD e32c957(main) 변동 없음.
|
||||||
|
- 환경: macOS, `/usr/bin/python3` 3.9.6(paho 없음; 이 Mac 의 PATH `python3`), 체크아웃 `.venv` 3.9.6(paho 2.1.0), PATH `bash` = `/bin/bash` 3.2.57.
|
||||||
|
- 실행 안전: 테스트·탐침은 모두 `env -i`(PATH/HOME/LANG/TMPDIR 만, 필요한 값만 명시)로 브리프의 `-u` 목록 전부를 비웠고, basetemp 는 scratchpad 아래(이름에 "shim" 없음). 수동 재현은 scratchpad 또는 `/tmp/jb48-r2`(종료 후 삭제)의 임시 워크스페이스·cwd 에서만 했다. 수정 전 대조군은 `git archive HEAD` 복사본에 제품 파일만 되돌려 만들었다. 실제 `.mam`·`.mam.env`·모니터·herdr 서버·에이전트 홈 미접촉.
|
||||||
|
|
||||||
|
## 1. 1차 지적 4건 — 재현을 다시 돌린 결과
|
||||||
|
|
||||||
|
| 지적 | 1차 재현 (63d8164) | head (a7cb90e) | 수정 전 코드에서 회귀 테스트 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **F1** paho 없는 폴백에서 control_plane·status 실패 | `AGENT_PYTHON_BIN=/usr/bin/python3` 로 `mam_control_plane_json` rc=1 `ModuleNotFoundError: paho`; `get_job_status` 예외 | rc=0, 바인딩 JSON 에 로그 루트 포함; `status.sh` 의 `get_job_status` 두 복사본 모두 `('abc12345', 'unknown')` | `test_control_plane_binding_works_without_paho`, `test_status_works_without_paho[json/text]` **실패**(paho 미존재로 control_plane·service import 실패) |
|
||||||
|
| **F2** 래퍼 `logs` 기본 경로가 cwd 상대 | `WORKSPACE_ROOT=ws`, cwd=elsewhere 에서 `logs --list` 가 잡을 못 찾음 | `logs --list` 가 ws 의 잡을 나열, `logs <id>` 에 `registered` 이벤트, `--registry-dir` 를 주면 그 레지스트리를 따름, elsewhere 에 아무것도 안 생김 | `test_logs_without_registry_dir_follows_the_workspace_from_any_cwd` **실패**(`(no logged jobs under …)`) |
|
||||||
|
| **R1** 디스크 폴백이 이후 디스패치의 terminal 을 이전 시도로 오인 | (codex 지적) | 감사 상태 채택 직전에 레코드를 다시 읽어 `(dispatch_token, attempt_id, transition_seq)` 가 처음 읽은 것과 같을 때만 채택 | `test_disk_fallback_rejects_a_terminal_that_belongs_to_a_later_dispatch` **실패**(`B's completion was attributed to A`) |
|
||||||
|
| **R2** jobs 디렉터리가 심볼릭 링크일 때 submit 과 logs 불일치 | (codex 지적) | `/tmp/jb48-r2/alias/jobs -> actual/jobs` 로 `submit --dry-run`: 출력 경로·감사 로그 모두 `actual/delegate_job_logs/<id>`; `logs --registry-dir alias/jobs` 와 `registry.py --registry-dir actual/jobs logs --list` 가 같은 잡을 찾음 | `test_a_symlinked_registry_dir_resolves_to_one_audit_log` 는 63d8164 에서는 `logroot` 부재로 ImportError 라 증거가 안 되므로, **a7cb90e^ (logroot 있음, `abspath` 규칙)** 로 다시 돌려 **실패**(`…/alias/… ≠ …/actual/delegate_job_logs`) 확인 |
|
||||||
|
|
||||||
|
## 2. 수정이 새 문제를 만들지 않았는가
|
||||||
|
|
||||||
|
**F1 (모듈 이동)**
|
||||||
|
- `lib_py/delegation/logroot.py` 는 stdlib(`os`, `typing`)만 쓴다. `mqtt_common` 이 `DEFAULT_REGISTRY_DIR`·`_workspace_mam_dir`·`default_registry_dir`·`get_logs_dir`·`sibling_logs_dir` 를 그대로 재노출하므로 기존 import 경로(`mqtt_common.get_logs_dir` 등)는 불변이다.
|
||||||
|
- `from lib_py.delegation.logroot import …` 는 패키지 `__init__` 을 거쳐 `daemon`·`service`·`registry`·`publish_event`·`publisher` 까지 로드한다. 이 Mac 의 `/usr/bin/python3` 로 직접 import 해 보니 12 개 `lib_py` 모듈이 모두 올라오고 `paho` 는 로드되지 않았다. 직접 paho 를 import 하는 곳은 `mqtt_common`·`publisher` 둘뿐이고 둘 다 `try/except ImportError` 로 감쌌으며 `from __future__ import annotations` 라 `-> mqtt.Client` 주석도 import 시 평가되지 않는다. 실제로 브로커가 필요하면 `make_client` 가 명확한 `ImportError` 를 낸다.
|
||||||
|
- eager import: `logroot` 는 `eager.CORE_MODULES` 에 없지만 목록에 있는 `mqtt_common` 이 import 시점에 로드하므로 동결 런타임에 함께 들어간다(아래 비차단 메모).
|
||||||
|
|
||||||
|
**F2**: `local -a reg=()` + `${reg[@]+"${reg[@]}"}` 를 `/bin/bash -u` 로 직접 확인 — 빈 배열이면 인자 없음, 주어지면 `--registry-dir <dir>`. 래퍼의 다른 subcommand 는 바뀌지 않았다.
|
||||||
|
|
||||||
|
**R1 (재읽기)**
|
||||||
|
- 경합: B 의 terminal 감사 상태는 재디스패치(레코드 토큰·seq 변경)가 커밋된 뒤에만 생긴다. 그러므로 감사 읽기에서 B 의 terminal 이 보였다면 레코드는 이미 그 전에 바뀌었고, 그 뒤의 재읽기가 반드시 차이를 본다. 재읽기 이후의 변경은 이미 읽은 감사 상태와 무관하다.
|
||||||
|
- 같은 시도 안에서 정상 전이(A running → A completed)가 두 읽기 사이에 끼면 seq 가 달라져 "deferred" 로 한 라운드 늦어질 뿐이고, 다음 라운드는 레코드 자체의 terminal 을 읽는다.
|
||||||
|
- 성능: 감사 상태가 terminal 이고 stale 이 아닐 때만 레코드 하나를 더 읽는다. 무시할 만하다.
|
||||||
|
|
||||||
|
**R2 (realpath)**
|
||||||
|
- 기본 경로: `WORKSPACE_ROOT=/tmp/...`(macOS 에서 `/tmp → /private/tmp`)일 때 `get_logs_dir()` 은 이제 `/private/tmp/.../.mam/delegate_job_logs` 문자열을 돌려준다. 물리적으로는 이전과 같은 디렉터리임을 확인했다. 결과를 문자열로 비교하는 곳은 `control_plane.forwarded_keys`(이제 양쪽 모두 `_abs`=realpath)와 루프 러너의 제외 목록(`runner.py` 가 `d.resolve().relative_to(root.resolve())`)뿐이라 영향이 없다.
|
||||||
|
- `forwarded_keys` 탐침: `/tmp`·`/private/tmp` 혼합 두 방향, 후행 `/`, 심볼릭 링크 alias 경유 모두 형제로 판정되어 전파하지 않는다. 비형제만 전파한다. 1차에서 비정규 입력이 "전파" 쪽으로 어긋나던 비대칭도 사라졌다.
|
||||||
|
- `from_env({"WORKSPACE_ROOT": "/tmp/..."})` 의 기본 바인딩은 `defaults()` 와 같아서, 평소 실행은 pane 에 아무것도 전파하지 않는다.
|
||||||
|
- 레지스트리 디렉터리 자체가 심볼릭 링크인 경우에만 감사 로그가 링크 대상 옆으로 간다. 이는 R2 가 의도한 동작이며, 래퍼(`pwd -P`)와 바인딩(realpath)이 이미 그렇게 보고 있었다.
|
||||||
|
|
||||||
|
## 3. 이 Mac 정합성
|
||||||
|
- 변경 py 15 개: `ast.parse(feature_version=(3,9))` 와 `/usr/bin/python3 -m py_compile` 모두 통과. 3.10+ 문법·API 없음.
|
||||||
|
- `lib.sh`·래퍼·`status.sh`: `/bin/bash -n`(3.2) 통과.
|
||||||
|
|
||||||
|
## 4. 테스트 (head, 검증 경로)
|
||||||
|
|
||||||
|
| 묶음 | 결과 |
|
||||||
|
|---|---|
|
||||||
|
| A: issue57·15·21_acceptance·21_worktree_prereqs·tier1·20·17_item4·17_workspace_root_isolation·36·14 | **475 passed, 2 skipped** |
|
||||||
|
| B: issue18·6_fail_closed·34·33·5_phase1/2a/2b/4_runtime·tier2·12·23_24·21_phase2·b7 | **493 passed** |
|
||||||
|
|
||||||
|
`5_phase4_runtime` 의 eager 모듈 검사도 통과했다. 어느 실행도 검증 경로에 `.mam` 을 남기지 않았고 #55 가드 오탐도 나오지 않았다.
|
||||||
|
|
||||||
|
## 5. 비차단 메모
|
||||||
|
- `lib_py.delegation.logroot` 를 `eager.CORE_MODULES` 에 명시하면 의도가 분명해진다. 지금도 `mqtt_common` 을 통해 로드되므로 기능 문제는 없다.
|
||||||
|
- paho 없이 `publisher` 의 브로커 경로를 직접 타면 `make_client` 의 `ImportError` 가 먼저 나는 구조다. 다른 진입점이 `mqtt.` 를 먼저 건드리면 `AttributeError: 'NoneType'` 로 보일 수 있지만, 브로커가 필요한 경로는 원래 paho 가 필수다.
|
||||||
|
|
||||||
|
## 결론
|
||||||
|
1차 지적 4건(F1·F2·R1·R2)이 모두 해소됐다. 1차 재현을 다시 돌려 확인했고, 각 회귀 테스트가 수정 전 코드에서 의도한 이유로 실패한다(R2 는 직전 부모 커밋 기준). 모듈 이동·재읽기·realpath 는 기본 경로, 바인딩·전파, 기존 import 경로에 새 문제를 만들지 않으며, 이 Mac 의 Python 3.9·bash 3.2 에서 정합하다.
|
||||||
|
|
||||||
|
[VERDICT: PASS]
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"loop_id": "loop-20261019T134017Z-d90c23",
|
||||||
|
"base_sha": "3ce5d5fdc108b0435e933a251fe1c1256b5d7973",
|
||||||
|
"head_sha": "b0eccc530149bc6e9211727e19921665c7b80492",
|
||||||
|
"tree_sha": "f5a412a3ee96465db7c4b3223d2d76e95716ddfa",
|
||||||
|
"review_commit": "b0eccc530149bc6e9211727e19921665c7b80492",
|
||||||
|
"reviewer": "planner-reviewer-claude-01",
|
||||||
|
"job_id": "b78af5ad",
|
||||||
|
"verdict": "PASS",
|
||||||
|
"transport": "herdr",
|
||||||
|
"evidence": "review-tree",
|
||||||
|
"isolation": "worktree",
|
||||||
|
"verified_at": "2026-10-19T14:59:46Z"
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# Issue #64 리뷰 — agy·codex·hermes DB 읽기 전용 열기 (job b78af5ad)
|
||||||
|
|
||||||
|
- 리뷰어: planner-reviewer-claude-01
|
||||||
|
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261019T134017Z-d90c23/1`
|
||||||
|
- identity: HEAD `b0eccc530149bc6e9211727e19921665c7b80492`, tree `f5a412a3ee96465db7c4b3223d2d76e95716ddfa` 가 일치한다. 작업 트리는 clean 이고 `.mam` 은 없다.
|
||||||
|
- 범위: base `3ce5d5f`(v4.9.7)..head 1커밋. 판정은 base..head 전체에 대한 것이다.
|
||||||
|
- 참고 자료: creator(agy) 리포트 `.mam/jobs/c721e4f5/agy-reports/report-final.md`
|
||||||
|
- 환경: macOS, Python 3.9.6, sqlite 3.51.0. 실제 에이전트 홈은 `ls`·`lsof`·`ps` 로만 봤고, sqlite 로 연 적은 없다.
|
||||||
|
|
||||||
|
## 결론
|
||||||
|
|
||||||
|
이슈 범위는 빠짐없이 바뀌었다. 조회 전용 열기는 모두 `_ro_connect` 로 바뀌었고, 쓰기와 MAM 자신의 DB 는 그대로다. 새 테스트는 base 에서 실패하고 head 에서 통과한다. 원인도 직접 재현해 확인했다. 차단 결함은 없다. 다만 새 런타임 테스트의 reconcile 단계가 실제로는 drift C 를 실행하지 않는다(N1). 또 기존부터 있던 workspace 간 오고정 문제를 새 테스트가 기대값으로 고정해 버렸다(N2). 둘 다 테스트를 보강하라는 권고다.
|
||||||
|
|
||||||
|
## 1. 원인 확인 — 직접 재현함
|
||||||
|
|
||||||
|
creator 의 결론: `reconcile.sh` drift C(agy, `:786-839`)는 고정되지 않은(unpinned) 실행 중 agy 행이 있으면 호스트 공용 `conversations/*.db` 를 전부 훑는다. 각 DB 에 `verify_session_uuid` 를 부르고, 이어서 `AgyAgentAdapter.verify_artifact` 가 `sqlite3.connect(path)` 로 연다. `last_conversations.json` 이 그 대화를 다른 workspace 의 것으로 적어 두어도 `cache_match=False` 는 `_sibling_claimed_uuids` 만 보고 거르지 않는다(`agy.py:60-72`). 그러면 macOS persistent WAL 때문에 `-wal`·`-shm` 이 남는다.
|
||||||
|
|
||||||
|
재현 방법:
|
||||||
|
- git archive 사본에 임시 테스트를 넣었다(mam_sandbox, 임시 HOME, mock herdr).
|
||||||
|
- 사이드카 없는 WAL agy DB 하나를 만들고, `last_conversations.json` 은 그 대화를 다른 workspace `ws_b` 에 매핑했다.
|
||||||
|
- ws 의 고정되지 않은 running 행을 mock herdr 에 살아 있는 pane 으로 등록했다.
|
||||||
|
- `reconcile.sh --once --emit-diff --dry-run` 을 실행했다.
|
||||||
|
|
||||||
|
| 사본 | DB 디렉터리 결과 |
|
||||||
|
|---|---|
|
||||||
|
| base | `<uuid>.db`, `.db-shm`, `.db-wal` — 사이드카가 생긴다 |
|
||||||
|
| head | `<uuid>.db` 만 — 바뀌지 않는다 |
|
||||||
|
|
||||||
|
이로써 v4.9.7 리뷰 때 #55 가드가 다른 workspace 의 agy DB 옆에서 잡은 사이드카는 MAM 의 reconcile drift C(status·create 가 부르는 경로 포함)가 만든 것으로 설명된다. 다만 그때 그 시각에 고정되지 않은 agy 행이 실제로 있었는지는 기록이 없어 확인하지 못했다.
|
||||||
|
|
||||||
|
## 2. 바뀐 곳 확인
|
||||||
|
|
||||||
|
- **공용 helper.** `_ro_connect` 가 `base.py:111-135` 로 옮겨졌다. 본문은 그대로이고, 임시 복사본 이름만 `os.path.basename(path)` 를 쓴다.
|
||||||
|
- opencode 는 base 의 것을 import 한다(`opencode._ro_connect is base._ro_connect`, 테스트로 고정).
|
||||||
|
- opencode 에서 쓰지 않게 된 `quote`·`tempfile` import 는 지워졌다. `shutil`·`sqlite3` 는 아직 쓰고 있다(`which`, purge).
|
||||||
|
- **`db_rows_naming` 기본 연결.** 기본값이 `_ro_connect` 가 됐다(`base.py:145`). 그래서 codex·opencode 의 residual 확인도 clean WAL DB 를 읽는다. base 에서는 `mode=ro` 가 Mac 에서 `unable to open` 으로 실패해 빈 결과를 냈다.
|
||||||
|
- **조회 전용 열기를 바꾼 곳:**
|
||||||
|
- agy `verify_artifact`(`agy.py:74`)
|
||||||
|
- codex `_find_rollout_via_db`(`codex.py:80`), `discover`(`:195`)
|
||||||
|
- hermes `verify_artifact`(`hermes.py:51`), `discover`(`:100`)
|
||||||
|
- status.sh hermes·codex 두 모드(`:90-91`, `:131-132`, `:290-291`, `:331-332`)
|
||||||
|
- reconcile.sh hermes drift(`:865`), codex drift(`:1048`)
|
||||||
|
- **남은 `sqlite3.connect`(grep 으로 전수 확인).** 모두 대상이 아니라는 판단이 맞다.
|
||||||
|
- 의도된 쓰기: codex·hermes·opencode purge 의 DELETE
|
||||||
|
- MAM 자신의 DB: `lib.sh:1599,2068`, `atomic_yaml.py:61`, `state.py:17`, `verify_session.py:41`(agent-sessions.db), `loop/runner.py:416`
|
||||||
|
- **import 정리.** agy 에서 `sqlite3` import 를 지웠고 다른 사용처는 없다. status.sh 의 opencode 블록은 이제 `lib_py.agents.base` 에서 import 한다.
|
||||||
|
|
||||||
|
## 3. 테스트 실행
|
||||||
|
|
||||||
|
조건: `env -i`, 임시 HOME, `XDG_DATA_HOME=<scratchpad>`, basetemp 는 scratchpad, `-p no:cacheprovider`, `PYTHONDONTWRITEBYTECODE=1`.
|
||||||
|
|
||||||
|
**새 모듈 `tests/test_issue64_ro_connect.py`**
|
||||||
|
- head: 43 passed.
|
||||||
|
- base 사본에 이 파일만 넣으면: **23 failed, 20 passed**. creator 의 수치와 같다.
|
||||||
|
- base 에서 실패한 것은 모든 clean·walonly 경우, 정적 검사, workspace 간 테스트다. 실패 메시지는 사이드카 `-shm`·`-wal` 이 생겼다는 것이다.
|
||||||
|
|
||||||
|
**관련 13개 모듈**
|
||||||
|
- 대상: issue64, issue60_linux_fixes, issue60_opencode_v2, a4, issue10_13_purge, issue31, orc_onboard, tier1, tier2, uuid_target, issue55, issue57, issue21_worktree_prereqs.
|
||||||
|
- 결과: **461 passed, 1 skipped, 실패 0**, 15분. skip 은 PATH 에 opencode 가 없는 실바이너리 계약 테스트다. #55 가드 오탐은 없었다.
|
||||||
|
|
||||||
|
**뮤테이션** (git archive 사본, issue64 모듈)
|
||||||
|
|
||||||
|
| 뮤테이션 | 결과 |
|
||||||
|
|---|---|
|
||||||
|
| agy `verify_artifact` 를 `sqlite3.connect` 로 | 5 failed(workspace 간 테스트 포함) |
|
||||||
|
| `db_rows_naming` 기본값을 옛 `mode=ro` URI 로 | 4 failed |
|
||||||
|
| hermes `discover` 를 `sqlite3.connect` 로 | 2 failed |
|
||||||
|
| reconcile hermes drift 를 `sqlite3.connect` 로 | 1 failed — 정적 검사만 잡음 |
|
||||||
|
| reconcile codex drift 를 `sqlite3.connect` 로 | 1 failed — 정적 검사만 잡음 |
|
||||||
|
|
||||||
|
**린트**
|
||||||
|
- `git diff --check` 는 깨끗하다.
|
||||||
|
- 바뀐 Python 6개 파일이 3.9 로 파싱된다.
|
||||||
|
- status.sh·reconcile.sh 는 `/bin/bash -n`(3.2)을 통과한다.
|
||||||
|
|
||||||
|
## 비차단
|
||||||
|
|
||||||
|
- **N1. 런타임 테스트의 reconcile 단계는 drift C 를 실행하지 않는다.**
|
||||||
|
- 위치: `test_status_and_reconcile_runtime_leave_sidecars_alone`(`:241-...`).
|
||||||
|
- 원인: 세 행을 mock herdr 에 등록하지 않는다. 그래서 drift A 가 먼저 `herdr gone … Marked terminated` 로 처리하고 drift C 를 건너뛴다. base 에서 같은 상황을 실행해 drift 결과로 확인했다.
|
||||||
|
- 결과: reconcile 의 hermes·codex 변경은 정적 문자열 검사로만 고정된다(위 뮤테이션 2개).
|
||||||
|
- 권고: 이슈 64 요구는 "실제 실행 또는 최소한 정적 검사"라 충족은 된다. 그래도 mock herdr state 에 세 세션을 `running` 으로 넣으면 실제 drift C 경로가 검증된다(내 재현처럼 `state["agents"][name] = {...}`). status 단계는 실제로 열린다. base 에서 hermes `state.db` 사이드카로 실패한다.
|
||||||
|
- **N2. 기존 결함: drift C(agy)가 다른 workspace 의 대화를 고정한다.**
|
||||||
|
- 내 재현에서 `last_conversations.json` 이 `ws_b` 의 것으로 적은 대화가 epoch 뒤 유일한 후보였다. 그러자 base·head 모두 ws 행에 `updated conversation id: <uuid>` 로 고정했다(dry-run 기준).
|
||||||
|
- 이 PR 이 만든 문제는 아니고 #64 범위 밖이다. 다만 새 테스트 `test_unpinned_agy_reconcile_…`(`:288`)가 `verify_session_uuid(ws_a, …, uuid_b) is True` 를 단언해 이 동작을 기대값으로 고정한다.
|
||||||
|
- 권고: 이 단언은 빼고 디렉터리 목록만 본다. 오고정은 별도 이슈로 다룬다. 예를 들어 `cache_match` 가 아니면서 lc 가 그 uuid 를 다른 cwd 에 매핑하면 거절한다.
|
||||||
|
- **N3.** `_ro_connect` 의 immutable 경쟁 창(ms 수준)은 #60 에서 남긴 것이 공용 helper 를 통해 agy·codex·hermes 로도 넓어졌다. 세 에이전트의 실제 DB 는 모두 WAL 형태다(`ls`: hermes·codex 는 사이드카 쌍이 있고, agy 대화 72개 중 7개에 사이드카가 있음). 실행 중에는 사이드카가 있으므로 `mode=ro` 갈래를 탄다.
|
||||||
|
|
||||||
|
## 관찰·공개
|
||||||
|
|
||||||
|
- **실제 codex DB 의 사이드카.** 관련 모듈 실행 전후로 `~/.codex/` 목록을 비교했더니 `state_5.sqlite-shm`·`-wal` 이 다시 나타나 있었다(mtime 23:29/23:35, 지금은 보유 프로세스 없음).
|
||||||
|
- 이 두 파일은 잡 시작 직후(23:04)에도 있었다가 사라졌던 것이다.
|
||||||
|
- 같은 시간대에 실제 `~/.codex` 를 쓰는 codex 프로세스들이 떠 있다: `codex resume`(이 루프의 리뷰어), app-server daemon, IDE 확장.
|
||||||
|
- 내 실행은 `env -i`·임시 HOME 이었고, head 코드는 사이드카를 만들지 않으며, #55 가드도 아무것도 잡지 않았다. 그래서 테스트 산출물이 아니라 실제 codex 활동으로 본다. 판정 근거로는 쓰지 않았다.
|
||||||
|
- **검증 트리에 임시 파일이 들어갔다 지워짐.** 재현 중 내 셸 명령 실수로 임시 테스트 파일(`tests/test_zz_review_agy.py`)이 검증 트리에 몇 초간 복사됐다. 그 파일로 아무것도 실행하지 않았고 즉시 지웠다. 이후 `git status --porcelain --ignored` 는 비어 있고 HEAD·tree 는 그대로다. 모든 재현은 scratchpad 사본에서 했다.
|
||||||
|
|
||||||
|
[VERDICT: PASS]
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"loop_id": "loop-20261001T140030Z-34381f",
|
||||||
|
"base_sha": "e1eb22bd59ce825e3a22e86433c30f7ffd833810",
|
||||||
|
"head_sha": "0eec795cd1b46f7a28b9a1cc87ef8577619a731f",
|
||||||
|
"tree_sha": "584eac551f880232896b898cc43156f28ae1bacb",
|
||||||
|
"review_commit": "0eec795cd1b46f7a28b9a1cc87ef8577619a731f",
|
||||||
|
"reviewer": "planner-reviewer-claude-01",
|
||||||
|
"job_id": "b9600325",
|
||||||
|
"verdict": "PASS",
|
||||||
|
"transport": "herdr",
|
||||||
|
"evidence": "review-tree",
|
||||||
|
"isolation": "worktree",
|
||||||
|
"verified_at": "2026-10-01T14:27:22Z"
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# 🔍 Review Report — PR #43 2회차 델타 (job `b9600325`)
|
||||||
|
|
||||||
|
- **Reviewer**: `planner-reviewer-claude-01` (role: planner,reviewer) · 2026-10-01
|
||||||
|
- **대상 델타**: `b58edac..0eec795` (1커밋, 7파일 `+128/−19`, `git diff --check` 클린), base `main e1eb22b` (v4.8.0).
|
||||||
|
- **검증 경로**: `/var/folders/.../T/mam-review/loop-20261001T140030Z-34381f/1`. **identity 확인**: `HEAD = 0eec795c…731f`, `HEAD^{tree} = 584eac55…bacb` — 브리프와 일치.
|
||||||
|
- **1회차 내 판정**: PASS + R1–R5 (job `645adb2a`). 브리프 지시대로 0eec795 만 본다.
|
||||||
|
- **안전 규칙 준수**: 검증 경로·소스 워크트리·본 체크아웃 모두 수정·커밋·checkout 없음. 실험은 scratchpad 의 합성 env 파일·임시 등록부·래퍼 `--dry-run`(도달 불가 브로커)에서만. 실제 `.mam.env` 를 읽거나 출력하지 않았다. 테스트는 `.mam.env` 미export·`MQTT_*` 미설정. 실제 `.mam`·데몬·세션·herdr 조작 없음, push·merge·PR 상태 변경 없음.
|
||||||
|
|
||||||
|
## 결론 요약
|
||||||
|
|
||||||
|
**네 수정(codex R1–R4)과 내 R1·R2 지적은 모두 닫혔고, 1회차 PASS 를 뒤집을 요소는 없다.** 각 수정을 독립 실험으로 확인했다. 읽기 실패 시 Python 진입점이 멈추는 설계에는 **동의**한다(§5). 비차단 관찰 2건(S1·S2).
|
||||||
|
|
||||||
|
## 1. codex R1 — 읽을 수 없는 파일은 오류 (`envfile.py:56-61`, `:104-109`)
|
||||||
|
|
||||||
|
| 입력 | `--shell` 결과 |
|
||||||
|
|---|---|
|
||||||
|
| 권한 0000 | rc **1**, stdout 없음, stderr `ERROR: …: cannot read (PermissionError)` |
|
||||||
|
| 깨진 UTF-8 | rc **1**, `cannot read (UnicodeDecodeError)` |
|
||||||
|
| 파일 없음 | rc 0, 출력 없음(종전대로 — "설정 없음" 은 정상) |
|
||||||
|
|
||||||
|
래퍼는 rc≠0 에 `ERROR: cannot load $TARGET_ENV (see the line above)` + exit 1(`multi-agent-mux-delegate-job:44`) — 1회차 내 R2(메시지가 원인과 어긋남)도 닫혔다: 이제 위 줄이 실제 원인(PermissionError/UnicodeDecodeError)이다. 값은 어디에도 찍히지 않는다.
|
||||||
|
|
||||||
|
## 2. codex R4 — NUL 이 든 줄 (`envfile.py:66-68`)
|
||||||
|
|
||||||
|
`A=1 / B=x\0y / C=3` 파일: `--shell` 이 `export A=1`·`export C=3` 만 내고 `WARNING: …:2: skipped (NUL byte)`. `parse` 가 두 로더의 공통 경로이므로 Python 로더도 같은 줄을 같은 경고로 건너뛴다 — 두 로더 일치.
|
||||||
|
|
||||||
|
## 3. codex R2 — 로케일과 무관한 ASCII 정수 (`lib.sh:2349-2351`)
|
||||||
|
|
||||||
|
C 와 en_US.UTF-8 두 로케일에서 bash `mam_int` 와 Python `_positive_int` 비교:
|
||||||
|
|
||||||
|
| 입력 | C | en_US.UTF-8 |
|
||||||
|
|---|---|---|
|
||||||
|
| `30` | 30 / 30 | 30 / 30 |
|
||||||
|
| `U+2003 30 U+2003`(em space) | 거부 / 거부 | **거부 / 거부** (수정 전 bash 는 30) |
|
||||||
|
| 아랍 숫자 `٣٠` | 거부 / 거부 | 거부 / 거부 |
|
||||||
|
| `' 30\t'` | 30 / 30 | 30 / 30 |
|
||||||
|
| 전각 `30` | 거부 / 거부 | 거부 / 거부 |
|
||||||
|
|
||||||
|
10/10 일치. 공백 집합 `$' \t\n\r\v\f'` 는 Python `strip(" \t\n\r\v\f")` 와 같다.
|
||||||
|
|
||||||
|
## 4. codex R3 — 명시한 빈 플래그 (`multi-agent-mux-delegate-job:167`, `:201`)
|
||||||
|
|
||||||
|
래퍼 `--dry-run` 실측: `--timeout ''` → rc 1 `ERROR: --timeout : not a positive decimal integer`, `--ack-timeout ''` → rc 1, `--timeout 0` → rc 1. 플래그를 주지 않으면 기본값(종전대로). 값 없이 `--timeout` 만 주면 `set -u` 의 `unbound variable` 로 rc 1 — 이 PR 이전부터의 동작이고 실패 방향이라 무해하다(S2).
|
||||||
|
|
||||||
|
## 5. 설계 질문: 읽기 실패에 Python 진입점이 멈추는 것이 맞는가
|
||||||
|
|
||||||
|
**멈추는 쪽에 동의한다.** 근거:
|
||||||
|
- `.mam.env` 가 실어 나르는 것은 브로커 주소·계정과 `MAM_DISPATCH_SECRET` 이다. 읽기에 실패한 채 "경고 후 계속" 하면 **기본 브로커(공개 `broker.hivemq.com`)로, 서명 없이** 잡 내용을 발행하게 된다 — 예제 파일의 SECURITY NOTE 가 경고하는 바로 그 상황이다. 설정 파일이 있는데 못 읽은 것은 "설정 없음" 과 다르다.
|
||||||
|
- 에이전트 세션 안의 영향(실측): 권한 0000 파일로 `publish_event --event started` 를 돌리면 rc 1 로 끝나고 레코드는 `pending`, `events.log` 는 생기지 않는다. 즉 래퍼의 `started` 대기가 **fail closed**(`agent_blocked`)로 잡을 닫는다 — 조용히 엉뚱한 브로커로 가는 것보다 보이는 실패가 낫다. heartbeat 타이머·구독자도 같은 진입점이라 같은 식으로 멈춘다.
|
||||||
|
- **S1 (비차단)**: Python 진입점에서는 `EnvFileError` 가 **트레이스백**으로 나온다(실측: Traceback 1개, 마지막 줄 `lib_py.delegation.envfile.EnvFileError: …: cannot read (PermissionError)`). 원인은 명확하지만, 에이전트 pane 에 그대로 찍히는 출력이다. `ensure_env_loaded` 를 부르는 진입점(또는 `ensure_env_loaded` 자체)이 `EnvFileError` 를 잡아 `ERROR: <msg>` 한 줄 + `sys.exit(1)` 로 끝내면 래퍼의 `--shell` 과 같은 모양이 된다.
|
||||||
|
|
||||||
|
## 6. 문서 — 내 1회차 R1 (`.mam.env.example:14-26`)
|
||||||
|
|
||||||
|
머리말에 FORMAT 절이 생겼다: 한 파서·`source` 안 함, 공백·따옴표 한 쌍 제거, **확장 없음**(`$HOME`·`${X}`·`$(cmd)`·백틱 글자 그대로 → 절대경로로 쓸 것), **줄 끝 `# ...` 은 값의 일부**, `export`·잘못된 키·`=` 없는 줄·NUL·bash 예약 변수는 줄 번호 경고 후 건너뜀, **환경변수가 파일보다 우선**, 읽기 실패는 오류. 1회차에 지적한 래퍼 사용자 동작 변경 4가지가 모두 적혔다 — 닫힘.
|
||||||
|
|
||||||
|
## 7. 1회차 내 지적의 처리
|
||||||
|
|
||||||
|
| # | 1회차 지적 | 0eec795 |
|
||||||
|
|---|---|---|
|
||||||
|
| R1 | `.mam.env.example` 에 규칙·env-wins 미기재 | **닫힘** — FORMAT 절 |
|
||||||
|
| R2 | `cannot read` 메시지가 원인과 어긋남 | **닫힘** — 실제 읽기 실패가 rc 1 + 원인 줄, 래퍼는 "see the line above" |
|
||||||
|
| R3 | `[ -ge ]` 전용 노브의 비숫자 처리 | 열림(범위 밖, PR 이 분류표로 의도 처리) |
|
||||||
|
| R4 | purge 12건 기준 실패 미재현 | 열림(델타와 무관; §8 재확인) |
|
||||||
|
| R5 | `mam_int` 를 항목 4 이전 목록에 | 열림(후속) |
|
||||||
|
|
||||||
|
## 8. 테스트 (검증 방법)
|
||||||
|
|
||||||
|
```
|
||||||
|
tests/test_issue26_envfile.py tests/test_issue26_ints.py tests/test_local_mqtt.py tests/test_issue18_liveness_contract.py tests/test_tier1_unit.py
|
||||||
|
(.mam.env 미export, MQTT_* 미설정)
|
||||||
|
→ 174 passed in 210.56s failed 0 · skipped 0
|
||||||
|
```
|
||||||
|
|
||||||
|
- 로그에 공개 브로커·실제 브로커 IP 흔적 0건 — 외부 브로커 없이 통과.
|
||||||
|
- 새 회귀(권한 0000·깨진 UTF-8 에서 래퍼와 Python 로더가 멈춤, NUL 줄을 두 로더가 같게 건너뜀, 두 로케일의 유니코드 공백·숫자 거부, 다섯 정수 플래그의 빈 값 거부)는 위 §1–§4 의 내 독립 실험과 같은 결과를 고정한다.
|
||||||
|
- 기준 실패 주장 재확인: `tests/test_issue10_13_purge.py` 를 이 head 에서 단독 실행 → **23 passed**(1회차의 PR·base 결과와 같음). 이 리뷰어 조건에서는 여전히 재현되지 않는다.
|
||||||
|
- 격리: 실제 `.mam/jobs` 는 이 잡 자신의 파일 외 추가 0, 검증 경로 tracked 파일 변화 없음(HEAD `0eec795`), 본 체크아웃 `main e1eb22b`.
|
||||||
|
|
||||||
|
## 9. 판정
|
||||||
|
|
||||||
|
0eec795 는 지적된 네 결함을 정확히 닫고(각각 실험으로 재현→해소 확인), 새 bash 판정을 만들지 않았다(정수 규칙의 문자 집합을 명시하고 플래그 존재를 기록하는 것은 기존 입력 정제의 보강). 버전 락스텝 bump 는 판정에서 제외한다.
|
||||||
|
|
||||||
|
| # | 비차단 관찰 | 근거 |
|
||||||
|
|---|---|---|
|
||||||
|
| S1 | Python 진입점에서 `EnvFileError` 가 트레이스백으로 나온다 — 한 줄 `ERROR:` + rc 1 로 잡아 주면 래퍼와 같은 모양 | `mqtt_common.py:98-104` → 각 `main` 의 `ensure_env_loaded()`; §5 실측 |
|
||||||
|
| S2 | 값 없는 `--timeout`(마지막 인자) 은 `unbound variable` rc 1 — 종전 동작이고 실패 방향 | 래퍼 `parse_opts` 의 `shift 2` |
|
||||||
|
|
||||||
|
## 10. 교차검증 가이드
|
||||||
|
|
||||||
|
```bash
|
||||||
|
RP=/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261001T140030Z-34381f/1
|
||||||
|
PY=/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux-wt/pr-43/.venv/bin/python
|
||||||
|
git -C "$RP" rev-parse HEAD 'HEAD^{tree}' # 0eec795… / 584eac5…
|
||||||
|
"$PY" -I "$RP/.agents/skills/lib_py/delegation/envfile.py" --shell <권한0000 파일> # rc 1, cannot read (PermissionError)
|
||||||
|
printf 'A=1\nB=x\0y\nC=3\n' > n.env; "$PY" -I …/envfile.py --shell n.env # export A, C 만 + NUL 경고
|
||||||
|
LC_ALL=en_US.UTF-8: mam_int $' '30$' ' 1 → 거부 (_positive_int 도 거부)
|
||||||
|
```
|
||||||
|
|
||||||
|
[VERDICT: PASS]
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# PR #58 (fix/issue-57) 병합 전 리뷰 — 이 Mac 정합성 중심 (job b9c7522d)
|
||||||
|
|
||||||
|
- 리뷰어: planner-reviewer-claude-01 (Reviewer)
|
||||||
|
- 리뷰 identity: loop-20261014T152849Z-f1a415 / base e32c9574 .. head 63d81646 (커밋 11개, 21 파일) / tree e8f162f7
|
||||||
|
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261014T152849Z-f1a415/1` — HEAD·tree 일치, `git status --porcelain` 0 줄, `.mam` 없음(시작·종료 모두). 본 체크아웃 HEAD e32c957(main) 변동 없음.
|
||||||
|
- 환경: macOS, `/usr/bin/python3` 3.9.6(paho 없음 — 이 Mac 의 PATH `python3` 도 이것), 체크아웃 `.venv` 3.9.6(paho 2.1.0), PATH `bash` = `/bin/bash` 3.2.57.
|
||||||
|
- 실행 안전: 모든 테스트·탐침은 `env -i`(PATH/HOME/LANG/TMPDIR 만, 필요한 값만 명시)로 브리프의 `-u` 목록 전부를 비웠고 basetemp 는 scratchpad 아래(이름에 "shim" 없음). 수동 재현은 scratchpad 의 임시 워크스페이스·cwd 에서만 했고, base 비교는 `git archive e32c957` 복사본으로 했다. 실제 `.mam`·`.mam.env`·모니터·herdr 서버·에이전트 홈 미접촉.
|
||||||
|
|
||||||
|
## 1. 결과 요약
|
||||||
|
|
||||||
|
| 항목 | 결과 |
|
||||||
|
|---|---|
|
||||||
|
| Python 3.9 문법(`ast.parse(feature_version=(3,9))`, 변경 py 13 개)·`py_compile` | 통과. 3.10+ 문법·API(match, `X \| None`, 내장 제네릭 주석, `removeprefix`, `tomllib`, `zip(strict=)`) 사용 없음 |
|
||||||
|
| bash 3.2 `bash -n`(lib.sh, 래퍼, worktree_prepare, reconcile, status) | 통과. 래퍼의 `LOGS_DIR_ABS=""` 전역 초기화, `var=$(…) \|\| {…}` 실패 처리, `env_add+=(…)` 모두 3.2 `set -u` 안전 |
|
||||||
|
| head 관련 모듈 A: issue57·15·20·21_acceptance·21_worktree_prereqs·tier1·17_item4·17_workspace_root_isolation·b7·36 | **477 passed, 2 skipped** |
|
||||||
|
| head 관련 모듈 B: issue14·18·6_fail_closed·34·33·5_phase1/2a/2b·tier2·12·23_24·21_phase2·**10_13_purge** | **503 passed**(알려진 실패 #28 의 `test_issue10_13_purge` 도 이 Mac 에서 통과) |
|
||||||
|
| 수정 전 대조군(제품 파일만 base 로 되돌린 복사본) + 새 `test_issue57_logs_follow_registry.py` | **15 failed, 6 errors, 1 passed** — 회귀를 실제로 잡는다 |
|
||||||
|
| 이전 라운드(ee7cef33) 차단 지적: `test_issue15…:64`, `test_tier1_unit…:512` | 두 곳 모두 `monkeypatch.delenv("MAM_JOBS_DIR")` 추가로 해소, 통과 |
|
||||||
|
|
||||||
|
## 2. 맞게 된 부분
|
||||||
|
- **규칙은 한 곳**: `mqtt_common.sibling_logs_dir`(순수) + `get_logs_dir(registry_dir)`(`DELEGATE_JOB_LOGS_DIR` > 레지스트리 형제). 쓰기·읽기 지점(register·pick·legacy/engine 미러·conflict·publish·subscriber·remote·reconcile·runner·lib.sh·래퍼)이 모두 이 함수를 거친다. lib.sh 의 bash 기본값 사본은 `delegate_logs_dir` 로 대체됐고, 해석 실패 시 감사 확인만 건너뛰고 `events.log` 증거는 유지한다.
|
||||||
|
- **macOS 경로**: `control_plane._abs` 는 `realpath(expanduser())` 라 `from_env`·행 기록이 모두 `/private/tmp`·`/private/var` 로 정규화된다. `forwarded_keys` 를 직접 탐침: 형제(정규화 후 동일) → 전파 안 함, `/tmp` 와 `/private/tmp` 가 섞인 비정규 입력·후행 `/`·비형제 → 전파. 어긋날 때는 늘 "전파" 쪽(기존 동작)이라 안전하다.
|
||||||
|
- **디스크 폴백의 재디스패치 안전성**: `job_subscriber._check_disk_fallback` 은 이제 `status.json` 의 `status` 필드를 읽고(이전엔 dict 를 비교해 아무것도 맞지 않았음), 버전 있는 레코드는 감사 `transition_seq` 가 같거나 새로울 때만 채택한다. 이전 시도의 terminal 감사 상태가 새 시도를 끝내지 못하며(`test_old_audit_terminal_does_not_complete_the_redispatched_attempt`), 손상된 seq 는 stale 로 처리한다. 버전 없는 레코드·레코드 없음은 기존 규칙.
|
||||||
|
- **래퍼**: 16 곳의 `${DELEGATE_JOB_LOGS_DIR:-$WORKDIR/delegate_job_logs}` 제거, 해석 실패 시 fail-closed(exit 1), 해석값을 export 해 자식과 주입 명령이 같은 루트를 쓴다.
|
||||||
|
- **worktree_prepare / control_plane**: 호출자가 명시한 `DELEGATE_JOB_LOGS_DIR` 만 넘기고, 유도 가능한 형제 루트는 행에만 기록한다. 기존 행 중 레지스트리를 옮겼지만 로그가 워크스페이스에 있던 것은 비형제라 계속 전파되어 resume 짝이 유지된다.
|
||||||
|
|
||||||
|
## 3. 차단 지적
|
||||||
|
|
||||||
|
**F1. 경로 헬퍼 하나 때문에 create/resume 바인딩과 status 가 paho 를 요구하게 됐다 — 이 Mac 의 문서화된 폴백에서 회귀.**
|
||||||
|
- `lib_py/control_plane.py:28` 이 `from lib_py.delegation import mqtt_common` 을 새로 하고(base 의 control_plane 은 stdlib 만), `status.sh:171,383` 이 `get_job_status` 안에서 `from lib_py.delegation.mqtt_common import sibling_logs_dir` 를 무조건 한다. `mqtt_common.py:33` 은 모듈 최상단에서 `import paho.mqtt.client` 다.
|
||||||
|
- 두 경로의 인터프리터는 `_delegate_py_bin`(`lib.sh:2111-2130`): `AGENT_PYTHON_BIN` > 활성 venv > 스킬 위쪽 가장 가까운 `.venv` > **PATH `python3`**. README 도 이 폴백을 문서화한다. 이 Mac 의 PATH `python3` 은 `/usr/bin/python3` 3.9.6 이고 paho 가 없다(install.sh 는 시스템 python3 에 PyYAML 만 요구).
|
||||||
|
- 재현(임시 워크스페이스, herdr 미호출):
|
||||||
|
```
|
||||||
|
AGENT_PYTHON_BIN=/usr/bin/python3 로 lib.sh source 후 mam_control_plane_json <wt>
|
||||||
|
base: rc=0
|
||||||
|
head: rc=1 ModuleNotFoundError: No module named 'paho'
|
||||||
|
status.sh 의 get_job_status 를 그대로 떼어 /usr/bin/python3 로 job 이 있는 행에 실행
|
||||||
|
base: ('abc12345', 'unknown')
|
||||||
|
head: ModuleNotFoundError: No module named 'paho'
|
||||||
|
```
|
||||||
|
- 영향: 그 폴백에서 `create_session.sh:232-238`·`resume_session.sh:100-106` 은 워크트리 대상이면 바인딩 계산 실패로 **exit 2**(거부), 제어 루트면 `control_plane` 없이 기록한다. 읽기 전용 `status.sh` 는 위임 잡이 걸린 행이 하나라도 있으면 heredoc 이 예외로 죽는다. 둘 다 base 에서는 paho 없이 동작했다.
|
||||||
|
- 수정(작음): `sibling_logs_dir` 를 paho 를 import 하지 않는 모듈(예: `lib_py/paths.py` 또는 새 `lib_py/delegation/logroot.py`)로 옮기고 `mqtt_common` 은 그것을 재노출, `control_plane`·`status.sh` 는 그 모듈에서 import. (또는 `mqtt_common` 의 paho import 를 지연.) 회귀 테스트로 "paho 없는 인터프리터에서 `control_plane json`·`get_job_status` 가 동작" 을 두면 좋다.
|
||||||
|
|
||||||
|
**F2. 래퍼 `logs` 의 기본 경로가 바뀌었다 (레지스트리를 옮기지 않은 평소 실행).**
|
||||||
|
- `multi-agent-mux-delegate-job` `cmd_logs` 는 이제 항상 `--registry-dir "$reg"` 를 넘기고 `reg` 의 기본은 `REGISTRY_DIR_DEFAULT="${MAM_JOBS_DIR:-.mam/jobs}"`(`:78`) — cwd 상대다. base 는 `--registry-dir` 없이 `registry.py logs` 를 불러 `get_logs_dir()` 이 `$WORKSPACE_ROOT/.mam/delegate_job_logs` 를 썼다(registry.py 자체의 기본 `default=default_registry_dir()`(`:370`) 도 `WORKSPACE_ROOT` 를 존중한다).
|
||||||
|
- 재현(임시 디렉터리, `WORKSPACE_ROOT=ws` 에 잡 하나 등록, cwd=`elsewhere`):
|
||||||
|
```
|
||||||
|
base: logs --list → 그 잡을 나열
|
||||||
|
head: logs --list → "(no logged jobs under <elsewhere>/.mam/delegate_job_logs)"
|
||||||
|
```
|
||||||
|
- 수정(작음): `--registry-dir` 가 주어졌을 때만 넘긴다(이전 라운드 구현처럼 `local reg=()` 배열 + `${reg[@]+"${reg[@]}"}`). 그러면 registry.py 의 기본(`MAM_JOBS_DIR` > `WORKSPACE_ROOT` > cwd)이 그대로 쓰여 기본 경로가 불변이다. "cwd≠워크스페이스에서 `logs`" 테스트 한 건을 권한다.
|
||||||
|
|
||||||
|
두 지적 모두 한두 줄 수정이며 설계 변경은 필요 없다.
|
||||||
|
|
||||||
|
## 4. 비차단 메모
|
||||||
|
- `docs/design/worktree-creator-model.md` 의 status 행(“`MAM_JOBS_DIR`·행 `mam_jobs_dir`·감사 로그(`DELEGATE_JOB_LOGS_DIR`·행 `delegate_job_logs_dir`)·기존 후보 순 조회”)에 새로 추가된 "레지스트리 옆 감사 로그" 후보가 빠져 있다.
|
||||||
|
- `control_plane.forwarded_keys` 는 로그 쪽 값을 정규화 없이 비교한다. 실제 흐름에서는 양쪽이 이미 realpath 라 문제없고 어긋나도 전파 쪽이라 안전하지만, 양쪽에 `_abs` 를 걸면 의도가 분명해진다.
|
||||||
|
- `cmd_logs` 의 `--registry-dir` 뒤 인자가 없으면 `set -u` 로 "unbound variable" 종료 — fail-closed 이므로 무방.
|
||||||
|
- 수정 전 대조군의 6 errors 는 base 코드가 테스트가 기대하지 않는 위치에 쓰면서 생긴 setup/teardown 오류로, head 에서는 없다.
|
||||||
|
|
||||||
|
## 5. 잔류물
|
||||||
|
- 복사본·basetemp·탐침 디렉터리는 모두 scratchpad 아래. 검증 경로는 깨끗(`.mam` 없음, `git status` 0 줄). #55 가드 오탐은 이번 실행에서 나오지 않았다.
|
||||||
|
|
||||||
|
## 결론
|
||||||
|
규칙의 일원화, 쓰기·읽기 지점, macOS 경로 정규화, 디스크 폴백의 재디스패치 안전성, bash 3.2·Python 3.9 이식성, 테스트의 회귀 탐지는 모두 맞다. 그러나 (F1) 경로 헬퍼를 paho 를 끌어오는 모듈에서 import 해 이 Mac 의 문서화된 `python3` 폴백에서 create/resume 바인딩과 status 가 깨지고, (F2) 래퍼 `logs` 의 기본 경로가 cwd 상대로 바뀌었다. 둘 다 base 대비 재현됐고 작은 수정으로 끝난다.
|
||||||
|
|
||||||
|
[VERDICT: NOT PASS]
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"loop_id": "loop-20260926T062809Z-914379",
|
||||||
|
"base_sha": "af0a2a2557768fa316203b177e1b90e9f7283a1a",
|
||||||
|
"head_sha": "9200562c914ab4f6e6d8a599cac371a07f99b25d",
|
||||||
|
"tree_sha": "c8425e2259544c3a34210858f331b0563f15325c",
|
||||||
|
"review_commit": "9200562c914ab4f6e6d8a599cac371a07f99b25d",
|
||||||
|
"reviewer": "planner-reviewer-claude-01",
|
||||||
|
"job_id": "cc418c3b",
|
||||||
|
"verdict": "PASS",
|
||||||
|
"transport": "herdr",
|
||||||
|
"evidence": "review-tree",
|
||||||
|
"isolation": "worktree",
|
||||||
|
"verified_at": "2026-09-26T07:07:06Z"
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# 🔍 Review Report — PR #41 델타 리뷰 3회차 (job `cc418c3b`)
|
||||||
|
|
||||||
|
- **Reviewer**: `planner-reviewer-claude-01` (role: planner,reviewer) · 2026-09-26
|
||||||
|
- **대상 델타**: `af0a2a2..9200562` (1커밋, 4파일 `+144/−31`, `git diff --check` 클린), base `main 8ea65bb` (v4.7.4).
|
||||||
|
- **검증 경로**: `/var/folders/.../T/mam-review/loop-20260926T062809Z-914379/1`. **identity 확인**: `HEAD = 9200562c…b25d`, `HEAD^{tree} = c8425e22…325c` — 브리프와 일치.
|
||||||
|
- **2회차 내 판정**: PASS + P1·P2 (job `2cc309ea`; 1회차 N1 은 그때 닫힘). 이번에는 grok N1(TERM 5 s 예산)·codex D1/D2 의 반영을 본다.
|
||||||
|
- **안전 규칙 준수**: 검증 경로·소스 워크트리·본 체크아웃(`main 8ea65bb`) 모두 수정·커밋·checkout 없음. 실험은 scratchpad 하네스(`stop_stall_watch` 추출, 대역은 내가 만들고 KILL 로 정리)와 되돌린 사본에서만. 실제 세션에 stall_watch 를 붙이지 않았고 실제 `.mam`·데몬·herdr 서버 조작 없음, push·merge·PR 상태 변경 없음.
|
||||||
|
|
||||||
|
## 결론 요약
|
||||||
|
|
||||||
|
**세 정정은 지적을 닫는다.** KILL 기반 stop 은 TERM 을 무시하는 대역에 대해서도 **0.00 s** 에 돌아오고(실측), prompt 앞의 마지막 게이트(`_may_prompt`: 래퍼 생존 + strict 재읽기)와 wait 직후의 `_wrapper_gone` 이 D1/D2 의 창을 닫는다. 남는 마이크로초 경합은 레지스트리 락 없이 닫을 수 없고, 그 결과(닫힌 잡에 한 줄 nudge)는 무해하므로 받아들일 수 있다. 비차단 관찰 2건(Q1·Q2).
|
||||||
|
|
||||||
|
## 1. grok N1 — KILL 로 바꾼 것 (검토 요청 1)
|
||||||
|
|
||||||
|
`multi-agent-mux-delegate-job:711-722` `stop_stall_watch`: `kill -0` → **`kill -9`** → `wait`. 하네스 실측(함수 추출):
|
||||||
|
|
||||||
|
| 대역 | 결과 |
|
||||||
|
|---|---|
|
||||||
|
| TERM 무시(`trap "" TERM; exec sleep 300`) | rc 0, **0.00 s**, 대역 사망 |
|
||||||
|
| 정상 | rc 0, 0.00 s |
|
||||||
|
| `STALL_PID` 비어 있음 | 즉시 return 0 |
|
||||||
|
| 이미 종료(rc 2) | 즉시, stderr 에 `stall watch for j1 exited rc=2:` + 로그 꼬리(보고 유지) |
|
||||||
|
|
||||||
|
**타당하다.** 감시자에 정리할 상태가 없다: 파일·락·소켓을 열어 두지 않고(레코드는 매번 열고 닫음), 유일한 부수효과는 두 하위 프로세스 — `publish_event`(알림, `--attempts 1`, 자체 timeout 120 s)와 `herdr agent prompt`(nudge, herdr 자체 `--timeout`) — 뿐이라 KILL 순간 진행 중이던 것은 각자 끝난다. TERM 핸들러도 `os._exit` 하나였으므로 KILL 로 잃는 동작이 없다. 2회차의 5 s 폴링은 래퍼의 close 시계 앞에 붙어 `deadline_timeout` 을 늦추던 부작용이 있었고(grok 지적), 그 예산이 사라졌다. KILL 은 fork-exec 창에서도 삼켜지지 않는다.
|
||||||
|
|
||||||
|
한 가지 기록(Q1): KILL 순간 비행 중이던 `publish_event` 는 래퍼가 잡을 닫은 **뒤** 에 `progress` 를 남길 수 있다. 종결 뒤 이벤트는 등록부 CAS/종단 규칙이 거르거나 한 줄 알림으로 남을 뿐이라 무해하지만, 래퍼의 디스크 liveness 스캔은 `events.log` 의 **모든** 최근 이벤트를 생존 증거로 세므로 감시자의 `stall_watch:*`/`stalled:*` 알림도 idle 유예를 연장한다(벽시계가 상한이라 무한하지는 않다). "멈춤 알림이 생존 증거가 된다"는 이 긴장은 이 델타가 만든 것이 아니며, 후속에서 스캔이 `progress`+`stall_watch:` 접두 detail 을 제외하면 사라진다.
|
||||||
|
|
||||||
|
## 2. codex D1/D2 — prompt 앞 게이트 (검토 요청 2·3)
|
||||||
|
|
||||||
|
- `_dispatch_open(strict=True)`(`stall_watch.py:266-288`): strict 에서는 읽기 오류도 "아니오". `_may_prompt`(`:296-301`) = 래퍼 생존 ∧ strict 재읽기 — `_notify` 가 **돌아온 뒤**, `prompt()` **직전** 에 호출된다(`:408-412`). 발행 rc 는 상태 판단에 쓰지 않는다(rc 0 은 "그 순간의 CAS 통과" 이지 "지금도 열려 있음" 이 아니다 — 맞는 태도).
|
||||||
|
- `_wrapper_gone`(`:290-294`)은 루프 머리(`:313`), **매 wait 직후**(`:329`, 어떤 알림·판정보다 먼저), `_may_prompt` 안 — 세 곳. D2 의 "대기 중 부모 소멸 뒤 알림·재주입 1회" 창은 wait 직후 검사로 닫힌다.
|
||||||
|
- 남는 경합: `_may_prompt` 가 참을 돌려준 뒤 `self.src.prompt()` 의 `herdr agent prompt` 가 실행되기까지의 마이크로초. 이 안에 잡이 닫히면 닫힌 잡의 pane 에 "끝났으면 completed 를 발행하고 아니면 이어서 하라" 한 줄이 들어간다. 에이전트가 그에 따라 `completed` 를 다시 발행해도 등록부가 종단 뒤 종단을 거부하므로 **상태가 바뀌지 않는다**. 레지스트리 락으로만 닫을 수 있는 창이고 비용이 이득보다 크다 — **받아들일 수 있다.**
|
||||||
|
|
||||||
|
## 3. 문서
|
||||||
|
|
||||||
|
SKILL.md `:51-54`: KILL 즉시, 래퍼 소멸 뒤 최대 wait 한 번(≤ 60 s), 소멸을 본 뒤에는 알림·nudge 없음 — 코드와 일치.
|
||||||
|
|
||||||
|
## 4. 테스트 (검토 요청 4 + 검증 방법)
|
||||||
|
|
||||||
|
### 4-1. 검증 경로에서의 실행 (브리프의 명령 그대로, pr-41 `.venv`)
|
||||||
|
|
||||||
|
```
|
||||||
|
tests/test_issue30_stall_watch_stop.py tests/test_issue30_stall_watch.py tests/test_issue14_delivery_ack.py
|
||||||
|
→ 3 failed, 67 passed, 1 skipped in 511.26s (0:08:31)
|
||||||
|
FAILED test_issue30_stall_watch.py::test_s9_the_wrapper_runs_the_watcher_for_the_jobs_life[1]
|
||||||
|
FAILED test_issue30_stall_watch.py::test_s10_remote_and_human_jobs_are_not_watched[human-…]
|
||||||
|
FAILED test_issue14_delivery_ack.py::test_wrapper_idle_timeout_grace_ignores_pane_busy_token
|
||||||
|
SKIPPED test_issue30_stall_watch.py:331 (S6, /proc)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **stop 테스트 11건(기존 7 + 새 4) 전부 통과.**
|
||||||
|
- 세 실패는 모두 **공개 브로커 의존**이다. S9[1]·S10[human] 의 실패 지점은 `t18._publish(reg, jid, "started", env).returncode == 0` 이고 stderr 는 `publish failed after 2 attempts: no CONNACK from broker`(4회) — 두 시험은 `_live_broker()` 로 일부러 공개 브로커를 쓴다(`:533-537`). 단독 재실행에서 S9[1]·pane_busy 는 통과했고 S10[human] 만 같은 `no CONNACK` 로 다시 실패했다. 같은 시각 scratch 등록부로 공개 브로커에 `started` 를 세 번 보내자 **1/3 이 21 s 뒤 rc 2** 였다 — 브로커가 이 시간대에 불안정하다는 직접 증거. 이 델타는 human/remote 분기(`start_stall_watch` 의 제외 로직)를 한 줄도 바꾸지 않았고(`git diff … | grep -c 'human\|_is_remote'` = 0), 회차마다 실패하는 케이스가 바뀐다(2회차 S9[0]→R2[False], 3회차 S9[1]→S10). 2회차 P2 그대로.
|
||||||
|
- **grok 이 인용한 idle-grace 시험의 분류(검토 요청 1 의 참고)**: 동의한다. `test_issue14` 의 `_base_env` 는 브로커를 고정하지 않아 기본값(공개 브로커)을 쓰고, PR 이 인용한 로그(`subscribe handshake timed out`, 등록 후 ~14 s 무배달 → idle 시계 늦게 시작 → 14 s 의 `completed` 가 열린 잡에 들어감)는 감시자와 무관한 구독자 핸드셰이크 지연이다. 이 시험은 PR #29·#37 리뷰 때도 base 와 동일한 코드에서 같은 방식으로 실패·재실행 통과했었고(이 호스트의 T-1 부류), KILL 로 바뀐 뒤에는 감시자가 close 시계 앞에 넣는 예산 자체가 0 이다. 다만 Q1 처럼 감시자의 알림이 디스크 liveness 로 세어지는 경로는 남아 있어, 그 시험이 감시자 켜진 채로 도는 한 "완전히 무관"은 아니다 — 시험 쪽에서 `MAM_STALL_WATCH=0` 을 주거나(감시자는 그 시험의 대상이 아니다) 스캔이 감시자 알림을 제외하면 깔끔해진다(Q2).
|
||||||
|
|
||||||
|
### 4-2. 수정 없이는 실패하는가 — 확인함
|
||||||
|
|
||||||
|
래퍼와 `stall_watch.py` 만 2회차 head(`af0a2a2`)로 되돌린 사본에서 `test_issue30_stall_watch_stop.py` → **5 failed / 6 passed (33.8 s)**: (a) `returns_at_once`(2회차 코드는 5 s 예산으로 took≈6), 그리고 새 4건(발행 중 completed / 재디스패치 / 깨진 레코드 / 대기 중 래퍼 소멸) 이 정확히 실패. 잔류 프로세스 0.
|
||||||
|
|
||||||
|
### 4-3. 결정성·잔류 (검토 요청 4)
|
||||||
|
|
||||||
|
새 4건은 in-process(`wait_once` 주입, `_notify` 는 발행 중 다른 행위자가 레코드를 바꾸는 대역, `_alive` 주입)라 브로커·herdr·시계에 의존하지 않고 3회 실행(전체·되돌린 사본·단독)에서 결과가 같았다. 세 실행 뒤 호스트에 `stall_watch --job`/`sleep 300` 잔류 **0**. 실제 `.mam/jobs` 는 이 잡 자신의 파일 외 **추가 0**, 검증 경로 tracked 파일 변화 없음(HEAD `9200562`), 본 체크아웃 `main 8ea65bb`.
|
||||||
|
|
||||||
|
## 5. 2회차 판정 항목의 처리
|
||||||
|
|
||||||
|
| # | 2회차 항목 | 이번 델타 |
|
||||||
|
|---|---|---|
|
||||||
|
| (1회차 N1) 래퍼 영구 정지 | 2회차에 닫힘 | 유지 — 이제 KILL 즉시(0.00 s 실측) |
|
||||||
|
| grok N1 | TERM 5 s 예산이 close 시계를 늦춤 | **닫힘** — 예산 제거 |
|
||||||
|
| codex D1 | 알림 발행 중 상태 변화에도 prompt | **닫힘** — `_may_prompt`(strict 재읽기 + 래퍼 생존), 테스트 3건 |
|
||||||
|
| codex D2 | 대기 중 부모 소멸 뒤 알림·재주입 | **닫힘** — wait 직후 `_wrapper_gone`, 테스트 1건, SKILL.md 문구 |
|
||||||
|
| P1 | 부모 소멸 감지 지연의 상태별 상한(정보) | 문서에 "≤ 60 s" 로 명시됨 — 종결 |
|
||||||
|
| P2 | S9/R2(이제 S10 도) 의 공개 브로커 의존 | 열림 — 이번 실행에서도 3건 |
|
||||||
|
|
||||||
|
## 6. 판정
|
||||||
|
|
||||||
|
세 정정은 지적을 닫고 부작용이 없다(KILL 로 잃는 상태 없음, 게이트가 prompt 의 유일한 경로 앞에 있음). 남는 마이크로초 경합은 결과가 무해해 받아들인다. 버전 락스텝 bump 는 판정에서 제외한다.
|
||||||
|
|
||||||
|
| # | 비차단 관찰 | 근거 |
|
||||||
|
|---|---|---|
|
||||||
|
| Q1 | 래퍼의 디스크 liveness 스캔은 `events.log` **마지막 줄의 timestamp** 만 보므로 감시자의 `progress`(`stall_watch:*`/`stalled:*`) 도 생존 증거가 된다 — 멈춤 알림이 idle 유예를 연장하는 긴장(벽시계가 상한) | 래퍼 `:810-825` 의 스캔; 델타 이전부터 |
|
||||||
|
| Q2 | `test_issue14` 의 liveness 시험들은 감시자를 끄지 않고 돈다 — 시험 대상이 아니므로 `MAM_STALL_WATCH=0` 을 주는 편이 결정적 | `_base_env` 에 `MAM_STALL_WATCH` 없음 |
|
||||||
|
|
||||||
|
## 7. 교차검증 가이드
|
||||||
|
|
||||||
|
```bash
|
||||||
|
RP=/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20260926T062809Z-914379/1
|
||||||
|
PY=/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux-wt/pr-41/.venv/bin/python
|
||||||
|
git -C "$RP" rev-parse HEAD 'HEAD^{tree}' # 9200562… / c8425e2…
|
||||||
|
# stop_stall_watch 추출 → TERM 무시 대역: rc 0, 0.00 s, 대역 사망 (§1)
|
||||||
|
# 되돌린 사본(래퍼·stall_watch.py 를 af0a2a2 판으로) → tests/test_issue30_stall_watch_stop.py → 5 failed / 6 passed
|
||||||
|
# 브로커 건강: scratch 등록부에 started 3회 발행 → 1/3 rc 2 (no CONNACK) — S9/S10 실패와 같은 원인
|
||||||
|
```
|
||||||
|
|
||||||
|
[VERDICT: PASS]
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# 리뷰 리포트 — Job cd288bf2 (Issue #17 후속 2회차: 상속 WORKSPACE_ROOT, 6b00b6e..8b47cc7)
|
||||||
|
|
||||||
|
- 리뷰어: planner-reviewer-claude-01 (claude)
|
||||||
|
- 리뷰 identity: loop-20261008T133858Z-9e7f5b / base 6b00b6e / head 8b47cc7 / tree bfe5fcb — 검증 경로(…/2)에서 `git rev-parse HEAD 'HEAD^{tree}'` 일치, 미커밋 변경 없음
|
||||||
|
- 범위: 커밋 2개(d198dd5 = 1회차, 8b47cc7 = grok·codex 지적 반영). conftest.py 의 `own_workspace_root` fixture + 테스트 파일 14개 + 새 tests/test_issue17_workspace_root_isolation.py(W1·W2). 제품 코드(.agents/skills)·버전·VERSIONS 변경 없음(브리프대로).
|
||||||
|
|
||||||
|
## 결론 요약
|
||||||
|
설계(opt-in fixture `own_workspace_root`, W1 가 모듈별 대표 노드를 체크아웃 사본에서 돌려 `.mam` 미생성을 고정, W2 가 test_orc_onboard 의 import 시점 root 전파를 고정)는 적절하고, 수정된 14개 파일·회귀 테스트·(a)(b) 실행은 모두 요구대로다. 그러나 **조사(2)가 모듈 하나를 놓쳤다**: tests/test_o2_race_free_lock.py 의 `test_o2_12_run_loop_exits_on_lock_failure` 는 HEAD 에서도 자신이 도는 체크아웃의 `.mam/shim/herdr` 를 쓴다(§3). 이 커밋의 제목("every test that sources lib.sh does it under its own WORKSPACE_ROOT")과 W1 의 전제("one test per module that sourced lib.sh … with the inherited root")에 어긋나는 같은 결함이며, 브리프가 리뷰어에게 직접 grep 으로 확인하라고 한 항목이다. 수정은 두 줄(§3)이고 재설계는 아니므로 ESCALATE 는 아니다.
|
||||||
|
|
||||||
|
## 1. 할 일 1·3·4 — 요구대로
|
||||||
|
- `_env`(test_issue17_item4_decisions.py:224-233): 1회차 그대로(제외 + tmp_path 지정). lib.sh:26 이 미설정 시 체크아웃을 유도하므로 "지정"이 맞다.
|
||||||
|
- 회귀 테스트: 1회차의 test_c_helpers_write_nothing_to_an_inherited_workspace 에 더해 W1·W2(새 파일). **수정 전 코드에서 실패 확인**:
|
||||||
|
- W1 상당: base(6b00b6e) 트리 사본 안에서 W1 의 NODES 13개를 `WORKSPACE_ROOT=<사본>` 으로 실행(24 passed) → 사본에 `.mam/shim/herdr` 생성됨(W1 의 `written == []` 단언이 실패하는 조건).
|
||||||
|
- W2 상당: base 의 test_orc_onboard 를 `WORKSPACE_ROOT=<센티널>` 로 import 한 뒤 `run_find_uuid(ws, …)` 호출 → **센티널**에 `.mam/shim/herdr` 생성, ws 에는 없음(import 시점 root 전파 재현). HEAD 의 `_scrubbed_env(root)`(test_orc_onboard.py:24-29)는 호출 시점 root 를 쓴다.
|
||||||
|
- (a)(b) 실행(검증 경로, 워크트리 .venv Python 3.9.6, `env -i` 로 HERDR_*·MAM_*·MQTT_* 제거):
|
||||||
|
|
||||||
|
| 실행 | 결과 |
|
||||||
|
|---|---|
|
||||||
|
| (a) WORKSPACE_ROOT 없이: 수정 14개 파일 + test_issue33_budget, test_issue5_phase1_delegation, test_tier1_unit | **643 passed / 0 failed** (815 s) |
|
||||||
|
| (b) WORKSPACE_ROOT=<워크트리 밖 센티널>: 수정 14개 파일 | **490 passed / 0 failed** (656 s); 실행 뒤 센티널 **비어 있음** |
|
||||||
|
| 본 체크아웃 .mam/shim/herdr mtime | 전후 동일(1791473441); 실제 .mam/jobs 항목 수 불변 |
|
||||||
|
- 이식성: Python 테스트 코드만(3.9 문법); W1 은 `git ls-files` 와 nested pytest 로 이 Mac 에서 통과.
|
||||||
|
|
||||||
|
## 2. 할 일 2 — 설계 판단
|
||||||
|
- conftest 의 `pytest_configure`(:139-148)는 **다른** 체크아웃을 가리키는 WORKSPACE_ROOT 만 지우고 이 체크아웃을 가리키면 남긴다(lib.sh 가 어차피 유도하는 값). 위험은 "이 체크아웃 자신"(에이전트 pane 에서 main 체크아웃으로 스위트를 돌릴 때 = 실제 워크스페이스)이며, 그 근본은 lib.sh:3088 이 **source 시점마다** `_init_herdr_isolation`(:143-146) 으로 `$WORKSPACE_ROOT/.mam/shim/herdr` 를 쓰는 것이다(직접 확인: `source lib.sh && find_workspace_uuid` 만으로 센티널에 shim 생성).
|
||||||
|
- `own_workspace_root` 를 autouse 로 두지 않은 이유(docstring: runner.py·mqtt_common 이 in-process 로 WORKSPACE_ROOT 를 읽음)는 타당하고, usefixtures 마크는 autouse 뒤·테스트 인자(mam_sandbox) 앞에 적용되므로 "mam_sandbox 와 테스트 자신의 지정이 이긴다"는 주장도 맞다. unittest.TestCase(test_workspace_scope)는 마크가 적용되지 않아 env 를 직접 지정한 것도 일관된다.
|
||||||
|
- test_issue21_worktree_prereqs `_mutate`(:546-547)와 test_workspace_scope(:30-32)의 명시 지정, test_orc_onboard 의 `_scrubbed_env` 도 적절.
|
||||||
|
|
||||||
|
## 3. 놓친 모듈 (차단) — tests/test_o2_race_free_lock.py:196-203
|
||||||
|
```
|
||||||
|
196 def test_o2_12_run_loop_exits_on_lock_failure(tmp_path):
|
||||||
|
200 cmd = ["bash", str(RUN_LOOP), "--creator", "dummy-agent", "--task", "test"]
|
||||||
|
201 run_env = dict(os.environ)
|
||||||
|
203 res = subprocess.run(cmd, …, cwd=str(tmp_path), env=run_env)
|
||||||
|
```
|
||||||
|
- run_loop.sh:17 이 lib.sh 를 source 하고, 이 테스트는 os.environ 을 그대로 물려주며(WORKSPACE_ROOT 미지정, cwd 만 tmp_path), 파일에 `own_workspace_root` 마크도 mam_sandbox 도 없다(:5 `import pytest` 는 이미 있음).
|
||||||
|
- 재현(W1 과 같은 방법, HEAD 트리 사본): `WORKSPACE_ROOT=<사본>` 으로 이 테스트 1개 → 사본에 `.mam/shim/herdr` **생성**; `WORKSPACE_ROOT` 미설정으로도 **생성**(lib.sh 가 체크아웃을 유도); 파일 전체(22 passed)도 생성. 같은 방법으로 돌린 내 다른 후보 11개 파일 40개 테스트(test_issue30_stall_watch_stop, test_issue36_prompt_transport, test_issue19, test_a4, test_issue21_acceptance, test_issue20, test_issue34, test_issue23_24, test_b4, test_issue15, test_issue10_13)는 모두 깨끗했다 — 놓친 것은 이 모듈뿐이다.
|
||||||
|
- 따라서 W1 의 "every module whose shells used to inherit the root" 목록(NODES, test_issue17_workspace_root_isolation.py:26-40)이 불완전하고, 커밋 8b47cc7 의 제목이 참이 아니다.
|
||||||
|
- 수정(같은 방식): test_o2_race_free_lock.py 에 `pytestmark = pytest.mark.usefixtures("own_workspace_root")` 추가(o2_13 의 mam_sandbox 는 그대로 이김), W1 NODES 에 `tests/test_o2_race_free_lock.py::test_o2_12_run_loop_exits_on_lock_failure` 추가. 그러면 W1 이 이 회귀를 앞으로 잡는다.
|
||||||
|
|
||||||
|
## 4. 비차단
|
||||||
|
- W1 은 모듈당 대표 노드 1개만 돌리므로 같은 모듈의 다른 테스트가 새로 lib.sh 를 source 하면 잡지 못한다 — 마크가 모듈 전체에 걸리므로 현재 설계에서는 문제없지만, 목록 유지가 수동이라는 한계는 적어 둔다.
|
||||||
|
- 1회차 표의 두 파일(test_b19, test_version_consistency)은 이번에 마크로 덮였다.
|
||||||
|
|
||||||
|
[VERDICT: NOT PASS]
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
### [planner-reviewer-claude-01] Issue #6 종결(Close) 승인 의견
|
||||||
|
|
||||||
|
- Job `9b3b36ba` / Reviewer / 2026-09-09
|
||||||
|
- 확인 기준: `main` HEAD `a08b3ca` (merge: refactor, 부모 2개 = `--no-ff`), 구현 커밋 `ec61511`, 계획 커밋 `7b761be`. 워킹트리 변경은 리뷰 리포트 파일 1건뿐.
|
||||||
|
|
||||||
|
## 1. 종결 조건 확인 `[실측]`
|
||||||
|
|
||||||
|
| 조건 | 결과 |
|
||||||
|
|---|---|
|
||||||
|
| 병합 상태 | `main`이 `a08b3ca`이며 `7b761be..a08b3ca`에 51 파일(+3477/−293) 반영. 신규 테스트 2모듈·`phase5-e2e.md` 존재 |
|
||||||
|
| 4-way 락스텝 | `lib.sh`·`eager.py` 4.4.1, 8개 `SKILL.md` 4.4.1, `VERSIONS.md` 개요 4.4.1 |
|
||||||
|
| 정적 검사 | `bash -n` 래퍼·`lib.sh` 통과, `git diff --check` 클린 |
|
||||||
|
| 문서 수치 | `phase5-e2e.md` §5·`task.md` P4-05가 793 collected / 0-Fail / 1478.66s로 갱신됨(이전 라운드 지적 N2 해소) |
|
||||||
|
| 검토 이력 | 10라운드 교차 검토. 결함 ①(fail-closed + CAS `deadline_timeout`), 결함 ②(사전 TUI 핸드셰이크·단일 주입), R1(working 유예), bash 3.2 호환(루프·헬퍼), 시도별 `dispatch_token`/`attempt_id` 바인딩, 디스크 폴백 토큰 검증, R2 계약 보존, 라이프사이클 토큰 고정을 실제 래퍼·브로커·herdr에서 반복 실측. 9차·10차에서 전체 스위트 793/0(단독 실행) 확인 |
|
||||||
|
|
||||||
|
## 2. 종결 후 별도 이슈로 다룰 잔여 항목 (종결을 막지 않음)
|
||||||
|
|
||||||
|
1. `phase5-e2e.md` 행 1 `e59778a0`은 구현 이전 잡(구 래퍼)으로, 표기 정정 또는 삭제.
|
||||||
|
2. `test_t1_16`의 고정 `sleep(2.8)` 1건 — 관측 기반 트리거로 전환.
|
||||||
|
3. 오케스트레이터 브리프의 완료 명령 템플릿에 `--expect-dispatch-token`/`--attempt-id` 포함(attempt 1은 디스크 폴백으로 동작하나 재위임 잡은 거절됨).
|
||||||
|
4. 리뷰 잡 벽시계 예산: 전체 스위트(≈27분)를 요구하는 잡은 `--timeout ≥ 2700`(10차 잡 `dcb79ca5`가 1800s 만료로 `error` 처리됨 — 판정은 PASS였음). 잘못 닫힌 잡은 `registry.py status --set completed`로 복구.
|
||||||
|
5. 레지스트리 정리: 비종료 실운영 레코드 2건(`29695ccf`, `d264ee71`, reviewer-opencode-01, 09-08)을 `registry.py close --request deadline_timeout`으로 닫고, pytest 세션명 레코드 누출·동시 스위트 실행이 남기는 샌드박스 고아 프로세스는 별도 이슈.
|
||||||
|
6. 계획 §4 비범위였던 O-1(리테인 재수락)은 시도별 토큰 필터로 loop 모드에서 실질 완화됨 — 별도 이슈는 "잔여 케이스 확인"으로 축소 가능.
|
||||||
|
|
||||||
|
## 3. 결론
|
||||||
|
|
||||||
|
Issue #6이 제기한 두 결함은 해소되었고, 구현·테스트·문서·버전이 `main`에 일관되게 병합되었다. 잔여 항목은 모두 후속 정리 성격이며 본 이슈의 범위나 정확성에 영향을 주지 않는다. 종결에 동의한다.
|
||||||
|
|
||||||
|
[VERDICT: PASS]
|
||||||
|
|
||||||
|
[DECISION: CLOSE]
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# Review Report — Job d05e8e15 (v4.4.0 릴리스 락스텝 갱신)
|
||||||
|
|
||||||
|
- Reviewer: planner-reviewer-claude-01
|
||||||
|
- 리뷰 범위: `b347efd..HEAD` (단일 커밋 `3874797` "release(v4.4.0)"), 21 파일 +160/−40
|
||||||
|
- 범위 고정: 브리프 내장 diff(39,725 B) vs `git diff b347efd..HEAD -- . ':(exclude).agents/reports'`(39,724 B) — 말미 개행 1바이트 외 **바이트 동일**. `.agents/reports` 하위 변경 없음.
|
||||||
|
- 검증 환경: Python 3.9.6, `.venv`, 실제 리뷰어 셸의 ambient `MAM_VERSION=4.3.0` (이 커밋이 고친 격리 결함의 실전 재현 조건)
|
||||||
|
- 참고: 현재 체크아웃 브랜치는 `release/v4.4.0`이며 `main`은 아직 `b347efd`(릴리스 커밋 미포함). VERSIONS.md "기준 브랜치: main"은 병합 후 상태를 선기술한 것으로, 병합 시점에 사실이 된다.
|
||||||
|
|
||||||
|
## 1. SemVer 판정(MINOR `v4.4.0`) 근거 재검증
|
||||||
|
|
||||||
|
작업자의 "규모는 크지만 깨는 것이 없다" 주장을 항목별로 실 트리에서 재확인했다.
|
||||||
|
|
||||||
|
| 주장 | 검증 방법 | 결과 |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| Issue #5 전 구간 삭제·rename 0건 | `git diff --name-status fc0bd96..HEAD \| grep -v '^[AM]'` | 0건 ✅ |
|
||||||
|
| `SCHEMA_VERSION = 1` 불변 | `mqtt_common.py:135` | ✅ |
|
||||||
|
| `scripts/*.py` 4종 존속 | `ls multi-agent-mux-delegate-job/scripts/` | job_subscriber/mqtt_common/publish_event/registry ✅ |
|
||||||
|
| 롤백 env 4종 실재·기본값 | `lib.sh:1972,1989,2000` (`MAM_DELEGATION_PY:-1`), `engine.py:109` (`MAM_ENGINE_CAS` 기본 "1"), `run_loop.sh:122` (`MAM_LOOP_PY:-1`, `MAM_LOOP_FREEZE:-0`) | ✅ |
|
||||||
|
| 자동 완료 기본 비활성 | `loop/autocomplete.py:29,53` — `os.environ.get("MAM_AUTO_COMPLETE","0") == "1"` | ✅ |
|
||||||
|
| 레거시 셸 루프 968줄 존치 | `wc -l run_loop.sh` = 968 (Phase 3 직전 936, Phase 4 직전 960) — VERSIONS.md의 "936줄 이식" / "968줄 존치" 두 숫자 모두 정확 | ✅ |
|
||||||
|
| `run_loop.sh:17` source, `lib.sh:32-33` 무조건 재export | 행 번호 실측 일치. `MAM_VERSION=4.3.0 bash -c 'source lib.sh; echo $MAM_VERSION'` → `4.4.0` | ✅ |
|
||||||
|
|
||||||
|
**판정 동의**: 공개 계약(스크립트 경로·CLI·와이어 스키마·YAML 레이아웃)에 제거·변경이 없고 모든 기본 동작 변경이 env 1개로 가역이므로 SemVer 2.0.0 §8의 MAJOR 요건을 충족하지 않는다. 유일한 신규 fail-closed 거부(`MAM_VERSION` 게이트)는 프로덕션 경로에서 `lib.sh`가 값을 무조건 덮어쓰므로 정상 배포된 트리에서는 발동할 수 없고, 혼재 트리에서만 발동한다. 이는 릴리스 노트 "부분 배포 금지"로 명시되어 있다. **v4.4.0 MINOR가 옳다.**
|
||||||
|
|
||||||
|
## 2. 락스텝 갱신 검증
|
||||||
|
|
||||||
|
- `lib.sh` MAM_VERSION, `eager.py` FRAMEWORK_VERSION, VERSIONS.md 현재 버전 + 매트릭스 8행, 8개 `SKILL.md` frontmatter 모두 `4.4.0` (grep 실측).
|
||||||
|
- README/BOOTSTRAP 4종 배지: **v4.1.3 / v4.2.0 → v4.4.0**. `b347efd` 시점 README 배지는 `v4.2.0`인데 `lib.sh`는 `4.3.0`이었다. 즉 배지는 두 릴리스 동안 유실 상태였고, 이번 작업이 이를 함께 회수했다(유실 관점에서 긍정). 다만 배지는 락스텝 테스트 미커버(→ O-1).
|
||||||
|
- 트리 내 잔존 `4.3.0` 문자열은 VERSIONS.md 이력, `delegate-job` README/job-protocol/registry/SKILL.md의 "구현 위치 각주 (v4.3.0)" 등 **역사적 서술**뿐이며 현재 버전 표기로 남은 것은 없다.
|
||||||
|
|
||||||
|
## 3. 테스트 변경 검증 (재현)
|
||||||
|
|
||||||
|
### 3-1. 락스텝 4번째 다리(`eager.py`) 커버리지 — 주장 사실
|
||||||
|
- `b347efd` 트리에서 `eager.py`만 4.4.0으로 올린 뒤 구 `test_version_consistency.py` 실행 → **2 passed (미검출)**.
|
||||||
|
- HEAD 트리에서 `eager.py`만 4.5.0으로 올린 뒤 실행 → `test_three_way_version_lockstep`, `test_runtime_version_gate_rejects_a_partially_bumped_tree` **2 failed (검출)**. 커밋 메시지의 "gap" 주장과 수정 효과 모두 확인.
|
||||||
|
- `_get_eager_py_framework_version`은 정적 regex 파싱(`^FRAMEWORK_VERSION\s*=\s*"(\d+\.\d+\.\d+)"`, MULTILINE)으로 `eager.py:16` 형식과 정확히 일치. 새 게이트 테스트는 `importlib.util.spec_from_file_location`으로 `sys.path` 오염 없이 로드하고 `os.environ`을 try/finally로 복원한다. `os`/`re`는 파일 상단에 이미 import되어 있다.
|
||||||
|
|
||||||
|
### 3-2. 격리 결함(ambient `MAM_VERSION`) — 주장 "4건 실패" 정확히 재현
|
||||||
|
| 트리 | conftest fixture | env | 결과 |
|
||||||
|
| :--- | :---: | :--- | :--- |
|
||||||
|
| HEAD | 제거(재현용 복제본) | `MAM_VERSION=4.3.0` | **4 failed**, 16 passed — `test_p4_01_eager_import…`, `test_p4_01_loop_runner_instantiation…`, `test_p4_02_midflight…`, `test_p3_03_runner_agent_type_resolution` |
|
||||||
|
| HEAD | 존재 | `MAM_VERSION=4.3.0` (리뷰어 셸 실값) | 20 passed |
|
||||||
|
| HEAD | 존재 | `MAM_VERSION=9.9.9` | 20 passed |
|
||||||
|
| `b347efd` | 없음 | `MAM_VERSION=4.3.0` (자기 트리와 일치) | 19 passed — "우연히 같아 잠복" 주장 확인 |
|
||||||
|
| `b347efd` | 없음 | `MAM_VERSION=9.9.9` | 5 failed (위 4건 + `test_p3_02`) — 결함이 릴리스 이전부터 존재했음 확인 |
|
||||||
|
|
||||||
|
- fixture는 session 스코프 autouse로 `os.environ.pop`, 종료 시 복원. 게이트를 직접 검사하는 테스트(`test_p4_01` monkeypatch 9.9.9, 신규 게이트 테스트, `test_mam_version_is_not_env_overridable`)는 모두 자체적으로 값을 설정하므로 영향 없음(HEAD 20/20으로 실증).
|
||||||
|
- `test_issue5_phase4_runtime.py`는 하드코딩 리터럴 제거 후 `verify_framework_version(eager.FRAMEWORK_VERSION)` — 시그니처 기본값이 `FRAMEWORK_VERSION`이므로 동작 동일하며, monkeypatch 9.9.9 불일치 검사는 그대로 유지.
|
||||||
|
|
||||||
|
### 3-3. 린트
|
||||||
|
- `py_compile` 4파일 OK, `bash -n lib.sh` OK, 변경 추가행 중 100자 초과 없음, `git status` clean. (pyflakes/ruff는 venv 미설치 — 이전 라운드와 동일.)
|
||||||
|
|
||||||
|
## 4. 문서 검증
|
||||||
|
- VERSIONS.md 4.4.0 절: 인용 파일·행 번호(`mqtt_common.py:135`, `run_loop.sh:17`, `lib.sh:32-33`), env 이름·기본값, 테스트 수(510 → 750, +240; Phase 4 시점 749 + 신규 게이트 테스트 1 = 750 수집 실측) 모두 실 트리와 일치.
|
||||||
|
- `implementation_plan.md` Phase 0/3/4 상태 열, `phase4-freeze-removal.md` 부분 배포 금지 문구, `task.md` Rev.9 / P4-01 증거 갱신 — 내용 정확.
|
||||||
|
|
||||||
|
## 5. 결함
|
||||||
|
**차단 결함 없음.** 코드 변경은 상수 2개 + 테스트 3파일이며 세 가지 주장(락스텝 갭, 4건 격리 실패, fixture 효과)이 모두 실측 재현되었다.
|
||||||
|
|
||||||
|
### 비차단 관찰
|
||||||
|
- **O-1 (권고)**: README/BOOTSTRAP 4종 배지는 이번에 두 릴리스분이 한꺼번에 회수될 만큼 락스텝 밖에 있다. `test_three_way_version_lockstep`에 5번째 다리로 추가 권고. 검증한 프로토타입(15줄, `BADGE_DOCS` + `_get_badge_version` regex `badge/version-v(\d+\.\d+\.\d+)-`): HEAD에서 3 passed, `b347efd`에 역적용 시 `README.md badge (4.2.0) != lib.sh (4.3.0)`로 정확히 실패한다.
|
||||||
|
- **O-2 (표기)**: 테스트 함수명 `test_three_way_version_lockstep`과 파일 헤더 "3-Way"가 이제 4-way(O-1 채택 시 5-way)를 검사한다. 이름 변경은 선택 사항.
|
||||||
|
- **O-3 (환경)**: `test_p3_02_run_loop_wrapper_delegates_to_python_default`는 `.venv`가 없는 git worktree에서 `No module named 'paho'`로 실패한다(리뷰 중 관측, `.venv` 심링크로 해소). 본 커밋과 무관한 기존 테스트의 실행 환경 의존이며 메인 트리에서는 통과.
|
||||||
|
- **O-4 (브랜치)**: 릴리스 커밋은 `release/v4.4.0`에만 있고 `main`은 `b347efd`이다. VERSIONS.md의 "main 병합 완료"는 이 릴리스 브랜치 병합으로 완성된다.
|
||||||
|
- 이전 라운드 핸드오버(a665d729 §4) 중 코드 변경을 요하는 항목(all-None intent 신원 가드, direct job `claim` 토큰 등)은 이번 릴리스 범위 밖이며 유효하게 잔존.
|
||||||
|
|
||||||
|
## 6. 전체 스위트
|
||||||
|
- `env -u MAM_VERSION .venv/bin/python -m pytest tests -q`: __SUITE_RESULT__
|
||||||
|
|
||||||
|
[VERDICT: PASS]
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"loop_id": "loop-20261004T044643Z-1b1440",
|
||||||
|
"base_sha": "e1ed2708d835d712f5f3a9dfd51b8f66ca1ac447",
|
||||||
|
"head_sha": "150b4937f816c6407be16fc4c37ab88e478927e7",
|
||||||
|
"tree_sha": "afce44d2e0f91720d81e8d7d484f0e1358dee680",
|
||||||
|
"review_commit": "150b4937f816c6407be16fc4c37ab88e478927e7",
|
||||||
|
"reviewer": "planner-reviewer-claude-01",
|
||||||
|
"job_id": "dcece792",
|
||||||
|
"verdict": "PASS",
|
||||||
|
"transport": "herdr",
|
||||||
|
"evidence": "review-tree",
|
||||||
|
"isolation": "worktree",
|
||||||
|
"verified_at": "2026-10-04T04:54:16Z"
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# 리뷰 리포트 — Job dcece792 (PR #47 2회차 델타, e1ed270..150b493)
|
||||||
|
|
||||||
|
- 리뷰어: planner-reviewer-claude-01 (claude)
|
||||||
|
- 리뷰 identity: loop-20261004T044643Z-1b1440 / base e1ed270 / head 150b493 / tree afce44d — 검증 경로에서 `git rev-parse HEAD 'HEAD^{tree}'` 일치, 미커밋 변경 없음
|
||||||
|
|
||||||
|
## 결론 요약
|
||||||
|
codex R1(그리고 같은 내용인 본인 1회차 관찰 N1)이 닫혔다. 차단 결함 없음.
|
||||||
|
|
||||||
|
## 1. 범위
|
||||||
|
`git diff --stat e1ed270 HEAD` 와 `git diff --stat 6af4a79 HEAD` 모두 tests/test_issue42_skill_blocks.py 한 파일(누적 +59/−3). 제품 코드·버전 변경 없음.
|
||||||
|
|
||||||
|
## 2. R1 종결
|
||||||
|
| 지적 | 수정 (tests/test_issue42_skill_blocks.py) | 확인 |
|
||||||
|
|---|---|---|
|
||||||
|
| codex R1 / claude N1: 깨진 최상위 링크가 `Path.exists()` 때문에 skip | claude_skills_problems(:240-261): 부재 = `not exists() and not is_symlink()` 일 때만 None(skip). symlink 는 `exists() and resolve() == skills_dir.resolve()` 여야 함 | 검증 경로에 `.claude/skills -> ../nowhere` 를 만들어 실제 테스트 실행 → **fail** (1회차 head 에서는 skip 이었음) |
|
||||||
|
|
||||||
|
## 3. 실제 배치 탐침 (검증 경로 안 임시 .claude/skills, 매번 삭제)
|
||||||
|
| 배치 | 결과 |
|
||||||
|
|---|---|
|
||||||
|
| 스킬별 링크 8개 + lib.sh·lib_py 링크(본 체크아웃과 동일) | pass |
|
||||||
|
| 단일 링크 → ../.agents/skills | pass |
|
||||||
|
| 깨진 최상위 링크(codex 탐침) | fail |
|
||||||
|
| 스킬 링크 하나가 깨짐 | fail ("… missing or dangling") |
|
||||||
|
| .claude/skills 가 일반 파일 | fail (각 스킬 "missing or dangling") |
|
||||||
|
| 최상위 자기 참조 링크(`skills -> skills`) | fail (RuntimeError: Symlink loop, Python 3.9.6) — 관찰 참고 |
|
||||||
|
시작 전 .claude 부재, 끝난 뒤 잔여물 없음·`git status --short --ignored` 비어 있음·HEAD 불변 확인. 본 체크아웃의 .claude/skills 는 건드리지 않았다.
|
||||||
|
|
||||||
|
## 4. 합성 배치 테스트
|
||||||
|
- test_claude_skills_layouts(:271-300)는 실제 배치 검사(test_claude_skills_link_resolves_to_agents_skills, :264-268)와 **같은 함수** claude_skills_problems 를 부른다.
|
||||||
|
- tmp_path 아래 상대 링크만 쓰고 외부 상태·시간·네트워크에 의존하지 않으므로 결정적이다. 6개 배치(absent/single/per_skill/wrong_skill/broken_top/broken_entry)가 모두 워크트리에서도 돈다.
|
||||||
|
- 실행: 검증 경로에서 `tests/test_issue42_skill_blocks.py` **37 passed / 1 skipped** (1회차 31 + 합성 6; skip 은 실제 배치 테스트, 워크트리라 정상).
|
||||||
|
|
||||||
|
## 5. 비차단 관찰
|
||||||
|
- 최상위 링크가 순환하면 :252 의 메시지용 `link.resolve()` 가 Python 3.9 에서 RuntimeError 를 던져 assert 실패 대신 예외로 끝난다. 어느 쪽이든 테스트는 실패하므로 잘못된 배치를 놓치지 않는다. 메시지를 `os.readlink(link)` 로 만들면 깔끔해진다(선택).
|
||||||
|
- 복사본 디렉터리일 때 메시지가 원인을 직접 말하지 않는 점(1회차 N2)은 그대로이며 사소하다.
|
||||||
|
|
||||||
|
[VERDICT: PASS]
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# PR #62 리뷰 — Issue #60 Linux 확인 후속 (job dd5c8c62)
|
||||||
|
|
||||||
|
- 리뷰어: planner-reviewer-claude-01
|
||||||
|
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261019T000805Z-975c4c/1`
|
||||||
|
- identity: HEAD `6a4e385a78c8a561d5cf3644336de6a352ea9271`, tree `afa2c55925f93820337b69699c670c0b0ca90980` 일치. 작업 트리 clean, `.mam` 없음. base `1d7b7da`..head 8커밋. 판정은 base..head 전체에 대한 것이다.
|
||||||
|
- 환경: macOS, `/usr/bin/python3` 3.9.6 와 `.venv` python 3.9.6, 둘 다 sqlite 3.51.0. 실제 opencode 데이터는 `ls`·`grep -c` 로만 봤다. 실제 DB 는 sqlite 로 열지 않았다.
|
||||||
|
|
||||||
|
## 결론
|
||||||
|
|
||||||
|
제품 코드에서는 차단 결함을 찾지 못했다. 다만 새 테스트 5개가 이 Mac 에서 항상 실패한다(B1). 이 리뷰의 중점이 macOS 정합성이고, 오케스트레이터의 전체 스위트도 이 Mac 에서 돈다. 그래서 NOT PASS 로 둔다. 고칠 곳은 fixture 몇 줄이고 재설계는 필요 없다.
|
||||||
|
|
||||||
|
## 차단
|
||||||
|
|
||||||
|
**B1. `test_a_query_reads_right_and_leaves_the_sidecars_alone[clean-*]` 5개가 macOS 에서 실패한다.**
|
||||||
|
- 위치: `tests/test_issue60_linux_fixes.py:153-154`(`make("clean")`)과 `:175-181`.
|
||||||
|
- 원인: macOS 시스템 SQLite 는 persistent WAL 로 동작해서, 마지막 연결을 닫아도 `-wal`·`-shm` 을 지우지 않는다. 임시 DB 에서 쓰기 연결을 닫은 뒤의 목록이 `['o.db', 'o.db-shm', 'o.db-wal']` 였다. 그래서 `_wal_writer(db, ws).close()` 뒤의 목록이 기대값 `["opencode.db"]` 와 다르고, 첫 assert(`make(state) == listing`)에서 실패한다. 제품 코드까지 가지도 않는다.
|
||||||
|
- 재현: 검증 경로에서 `env -i …` 로 `tests/test_issue60_linux_fixes.py` 를 단독 실행하면 5 failed, 40 passed. 관련 18개 모듈 실행(아래)에서도 같은 5개만 실패했다.
|
||||||
|
- 제품 코드는 맞다. 이 테스트의 `clean` 상태는 Linux 에서 닫힌 DB 의 모습(사이드카 없음)이다. git archive 사본에서 이 상태를 만들도록 close 뒤에 `-wal`·`-shm` 을 지워 보았다. 그러자 해당 서브셋 33개가 모두 통과했고, 아래 뮤테이션 6개도 모두 잡혔다.
|
||||||
|
- 권고: `clean` 분기에서 close 뒤 남은 `-wal`·`-shm` 을 지운다. close 가 이미 checkpoint 하므로 행은 본 파일에 있다. 또는 Mac 의 실제 상태인 "쓰는 쪽 없이 사이드카 둘 다"는 이미 `leftover` 가 다루므로, 그 사실을 주석으로 남긴다.
|
||||||
|
- 주의: 지금 상태의 Mac 에서는 `clean` 이 첫 assert 에서 멈춘다. 그래서 immutable 분기를 없애는 뮤테이션(M1)이 이 Mac 에서는 드러나지 않는다.
|
||||||
|
|
||||||
|
## 중점 항목 확인
|
||||||
|
|
||||||
|
**1. `_ro_connect` 세 갈래 (`opencode.py:13-36`).** 검증 경로 코드를 3.9 로 import 해 임시 DB 에서 확인했다. 사이드카를 직접 지워 Linux 와 같은 상태를 만들었다.
|
||||||
|
|
||||||
|
| 상태 | 결과 |
|
||||||
|
|---|---|
|
||||||
|
| 사이드카 없음 | 같은 상태에서 base 의 평범한 `mode=ro` 는 `unable to open database file` 로 실패한다. 그래서 base 의 `_descendants`·`db_rows_naming` 은 Mac 에서도 빈 결과였다. `_ro_connect`(immutable)는 행을 읽고 아무 파일도 만들지 않는다. |
|
||||||
|
| writer 가 살아 있음(둘 다 있음) | WAL 에만 있는 행까지 읽는다. 목록은 그대로다. |
|
||||||
|
| `-shm` 없는 `-wal` | 커밋된 행을 읽는다. 원본 두 파일의 mtime 은 그대로였고 `-shm` 도 생기지 않았다. 같은 상태에서 평범한 `mode=ro` 는 `-shm` 을 만들었다. 디렉터리가 읽기 전용(0555)이어도 읽힌다. |
|
||||||
|
| DB 없음 | `OperationalError`. 파일은 생기지 않는다. base 의 `sqlite3.connect(db)` 는 빈 DB 를 만들었으므로 개선이다. |
|
||||||
|
|
||||||
|
- 실제 Mac 의 상태: 실제 디렉터리에는 서비스가 떠 있어 `opencode.db`·`-shm`·`-wal` 이 모두 있다(`ls`). persistent WAL 이 기본인 Mac 에서는 "둘 다 있음 → mode=ro" 갈래가 대부분일 것이다. 다만 bun 이 시스템 SQLite 를 쓰는지는 확인하지 않았다.
|
||||||
|
|
||||||
|
**2. `_DB_PATHS` 캐시 (`opencode.py:39-40, 139-161`).**
|
||||||
|
- 키는 `(bin, ctx.home_dir, _data_home(ctx))` 다. `_run` 이 넘기는 HOME·XDG_DATA_HOME 과 같은 값이라, 다른 HOME 이나 XDG 에 다른 경로를 돌려주지 않는다.
|
||||||
|
- 실패한 `debug paths` 는 저장되지 않는다. 바이너리가 없으면(`bin_path` None) 캐시를 쓰지 않는다.
|
||||||
|
- 테스트 간 누출: 이 모듈은 autouse fixture 로 캐시를 비운다. 다른 모듈은 tmp_path 별 HOME 이라 키가 겹치지 않는다. 18개 모듈을 한 프로세스에서 실행했을 때 B1 외의 실패는 없었다.
|
||||||
|
|
||||||
|
**3. auth_ok (`opencode.py:317-321`, `__main__.py:120-127`, `create_session.sh:145-151`).**
|
||||||
|
- 다른 에이전트의 분기와 adapter 는 바뀌지 않았다. `auth-ok` 를 부르는 곳은 opencode 분기뿐이다.
|
||||||
|
- `python -m lib_py.agents auth-ok opencode` 를 `/usr/bin/python3` 3.9 로 실행했다. PATH 에 opencode 가 없으면 rc 1, `~/.opencode/bin` 이 있으면 rc 0, 인자가 없으면 rc 1(`unknown agent ''`).
|
||||||
|
- PYTHONPATH 는 lib.sh 가 export 한다(`lib.sh:25`). 같은 파일의 `facts`·`spawn-spec` 호출과 같은 방식이다.
|
||||||
|
- create 의 사전 검사로 충분하다. 바로 위 `command -v "$AGENT"`(`:117`)가 이미 PATH 의 바이너리를 요구한다.
|
||||||
|
|
||||||
|
**4. `input_placeholder_pattern` (`opencode.py:75-79`, `input_region.py:83-85`).**
|
||||||
|
- 홈 화면 fixture 두 개(140열, 잘린 예시가 있는 60열)는 모두 빈 입력으로 읽힌다. 1.x 캡처도 그대로 빈 입력이다. 다른 adapter 는 None 이다(테스트로 고정).
|
||||||
|
- fullmatch 라 `Ask anything… "x" and fix it` 처럼 뒤에 글이 붙은 입력은 그대로 읽힌다.
|
||||||
|
|
||||||
|
**5. stop 문구 (`stop_session.sh:242`).** 메시지만 바뀌었고 동작은 같다. 테스트 2개(exit 됨 / 살아 있어 kill)가 통과했다.
|
||||||
|
|
||||||
|
**6. Python 3.9 와 셸.**
|
||||||
|
- 바뀐 Python 5개 파일이 3.9 에서 파싱되고, 제품 모듈은 3.9 로 import 해 실행했다. 새 테스트는 `from __future__ import annotations` 를 쓴다.
|
||||||
|
- `create_session.sh`·`stop_session.sh` 가 `/bin/bash -n`(3.2)을 통과한다. `git diff --check 1d7b7da..HEAD` 는 깨끗하다.
|
||||||
|
|
||||||
|
## 테스트 실행 (검증 경로)
|
||||||
|
|
||||||
|
조건: `env -i`, 임시 HOME, `XDG_DATA_HOME=<scratchpad>`, basetemp 는 scratchpad, `-p no:cacheprovider`, `PYTHONDONTWRITEBYTECODE=1`.
|
||||||
|
|
||||||
|
대상 18개 모듈: issue60_linux_fixes, issue60_opencode_v2, a4, issue50_dispatch, issue30_stall_watch(+_stop), issue31, tier1, tier2, c1_tui_readiness, issue5_phase0/phase2b, issue24, issue6, uuid_target, issue55, issue57, issue21_worktree_prereqs.
|
||||||
|
|
||||||
|
- 결과: **5 failed(B1 의 5개뿐), 623 passed, 2 skipped**, 29분.
|
||||||
|
- skip 2개: `/proc` 전용 테스트와, PATH 에 opencode 가 없어 건너뛴 실바이너리 계약 테스트.
|
||||||
|
- 부작용 확인:
|
||||||
|
- scratchpad XDG 디렉터리는 실행 뒤에도 비어 있었다.
|
||||||
|
- 실제 `~/.local/share/opencode` 목록은 실행 전후가 같았다.
|
||||||
|
- 실제 log 의 `debug` 줄 수는 2 → 2.
|
||||||
|
- 남은 opencode 프로세스는 없었다(사용자 서비스 제외).
|
||||||
|
- #55 가드 오탐도 없었다.
|
||||||
|
|
||||||
|
뮤테이션: git archive 사본에 B1 fixture 수정을 넣고 진행했다. 대상은 issue60_linux_fixes 와 a4 이며, 실제 프로세스를 띄우는 stop·create 2개는 뺐다. 모두 잡혔다.
|
||||||
|
|
||||||
|
| 뮤테이션 | 결과 |
|
||||||
|
|---|---|
|
||||||
|
| M1 immutable 분기를 `mode=ro` 로 | 5 failed |
|
||||||
|
| M2 `wal and shm` 를 `wal` 로 | 5 failed |
|
||||||
|
| M3 캐시 사용 끔 | 1 failed |
|
||||||
|
| M4 잘린 예시 허용(`"?`) 제거 | 2 failed |
|
||||||
|
| M5 fullmatch 를 match 로 | 4 failed |
|
||||||
|
| M6 auth_ok 를 항상 True 로 | 3 failed |
|
||||||
|
|
||||||
|
## 비차단
|
||||||
|
|
||||||
|
- **N1. immutable 경쟁.** `-wal` 없음을 판단한 뒤 연결이 쓰이는 동안 writer 가 생기면 결과가 틀릴 수 있다. 임시 DB 에서 재현했다.
|
||||||
|
- (a) 판단 직후 writer 가 한 행을 커밋하면, immutable 연결은 1/2 만 본다.
|
||||||
|
- (b) 열린 immutable 커서 아래에서 writer 가 지우고 다시 쓰고 checkpoint 하면, 예외 없이 옛 행과 새 행이 섞인 20119행이 나왔다(옛 20000행, 새 30000행).
|
||||||
|
- 쿼리가 짧아(LIMIT 20, 단건 조회) 실제 창은 ms 수준이다. persistent WAL 인 Mac 에서는 이 갈래 자체가 드물다.
|
||||||
|
- 원하면 이렇게 줄일 수 있다: 쿼리 뒤 `-wal` 이 여전히 없고 본 파일의 mtime·size 가 그대로인지 확인하고, 아니면 `mode=ro` 로 한 번 다시 읽는다.
|
||||||
|
- **N2. 캐시는 프로세스 수명 동안 유지된다.** 같은 경로에서 바이너리를 업그레이드한 경우, 오래 도는 `reconcile --subscribe` 는 예전 `debug paths` 결과를 계속 쓴다. 경로 규칙이 바뀌는 업그레이드에서만 문제가 된다.
|
||||||
|
- **N3. 사전 검사가 약해졌다.** create 의 opencode 검사는 사실상 `command -v` 와 같아졌다. 깨진 바이너리도 통과하고, 실패는 이후 준비 대기 단계에서 드러난다. 2.x 에 로그인이 없다는 전제와 맞는 완화로 본다.
|
||||||
|
- **N4. 안내 문구와 같은 모양의 초안.** 사용자가 정확히 그 모양으로 친 입력(`Ask anything...`, `Ask anything… "fix the tests"`)은 빈 입력으로 읽힌다. 확인한 결과 둘 다 `''` 였다. 실사용에서 생길 가능성은 매우 낮다.
|
||||||
|
|
||||||
|
[VERDICT: NOT PASS]
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"loop_id": "loop-20260923T220355Z-76dd1d",
|
||||||
|
"base_sha": "f4ba205c9f755d817e2206c7a9cb390a3e61fd2f",
|
||||||
|
"head_sha": "05b0ea4762de3fad013e8986d45db21c4e6aa465",
|
||||||
|
"tree_sha": "8f5e8b0402017d2cc809364e60400c4c25b922f3",
|
||||||
|
"review_commit": "05b0ea4762de3fad013e8986d45db21c4e6aa465",
|
||||||
|
"reviewer": "planner-reviewer-claude-01",
|
||||||
|
"job_id": "df626811",
|
||||||
|
"verdict": "PASS",
|
||||||
|
"transport": "herdr",
|
||||||
|
"evidence": "review-tree",
|
||||||
|
"isolation": "worktree",
|
||||||
|
"verified_at": "2026-09-23T23:35:19Z"
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# 🔍 Review Report — PR #35 델타 리뷰 2회차 (job `df626811`)
|
||||||
|
|
||||||
|
- **Reviewer**: `planner-reviewer-claude-01` (role: planner,reviewer) · 2026-09-24
|
||||||
|
- **대상 델타**: `f4ba205..05b0ea4` (1커밋 `05b0ea4`, 6파일 `+76/−5`, `git diff --check` 클린), base `main 1e78dae` (v4.7.1).
|
||||||
|
- **검증 경로**: `/var/folders/.../T/mam-review/loop-20260923T220355Z-76dd1d/1`. **identity 확인**: `HEAD = 05b0ea47…a465`, `HEAD^{tree} = 8f5e8b04…22f3` — 브리프와 일치.
|
||||||
|
- **1회차 내 판정**: PASS (job `adc0ff3c`). 이번에는 codex 탐침 2건의 정정이 실제로 닫혔는지와 부작용을 본다.
|
||||||
|
- **안전 규칙 준수**: 검증 경로·소스 워크트리·본 체크아웃(`main 1e78dae`) 모두 수정·커밋·checkout 없음. 함수 추출 실험과 되돌린 사본 실행은 scratchpad 에서만, pytest 는 `AGENT_PYTHON_BIN` + `--basetemp=<검증 경로 밖>`. 실제 herdr·세션·`.mam`·데몬 조작 없음, push·merge·PR 상태 변경 없음.
|
||||||
|
|
||||||
|
## 결론 요약
|
||||||
|
|
||||||
|
**두 정정은 탐침을 닫고, 1회차 판정을 뒤집을 요소는 없다.** P2 의 `continue` 는 S5/S5b 의 쿨다운 계약을 그대로 유지하면서 같은 화면의 다음 다이얼로그를 보게 하고, P1 의 `AUTO_TRUST="${MAM_AUTO_TRUST:-1}"` 는 "플래그 > 환경 > 기본 1" 의 우선순위를 문서대로 만든다. 비차단 관찰 2건(G1·G2).
|
||||||
|
|
||||||
|
## 1. P2 — `_mam_dialog_pick` 의 `continue` (항목 1)
|
||||||
|
|
||||||
|
`lib.sh:2954-2958`. 함수만 추출해 codex 어댑터의 다이얼로그 2개(신뢰 창 idx0 → `Enter`, 업데이트 안내 idx1 → `2`)와 **두 창이 함께 있는 화면**으로 7가지 상태를 돌렸다(cooldown 5 s):
|
||||||
|
|
||||||
|
| # | 상태 | 결과 | 의미 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | 기록 없음, 자동 신뢰 on | `send\|0\|Enter` | 신뢰 창이 먼저(어댑터 순서) |
|
||||||
|
| 2 | 신뢰 창을 1 s 전에 보냄(쿨다운 중), on | **`send\|1\|2`** | **P2 정정**: 쿨다운 중인 창을 건너뛰고 안내에 답한다. 옛 코드는 여기서 `exit 1` 로 아무것도 안 했다 |
|
||||||
|
| 3 | 기록 없음, 자동 신뢰 off | `skip\|0\|` | 신뢰 창은 사람 몫, skip 기록 |
|
||||||
|
| 4 | skip 1 s 뒤, off | **`send\|1\|2`** | **P2 정정**: skip 의 쿨다운이 안내를 가리지 않는다 |
|
||||||
|
| 5 | 두 창 모두 쿨다운 중 | 없음(rc 1) | **S5 유지**: 쿨다운 안에서는 재전송 없음 |
|
||||||
|
| 6 | 두 창 모두 쿨다운 만료(+5 s) | `send\|0\|Enter` | **S5b 유지**: 만료 후 1회 재시도, 다시 신뢰 창부터 |
|
||||||
|
| 7 | off, 만료 | `skip\|0\|` | 경고가 쿨다운마다 1회 |
|
||||||
|
|
||||||
|
**키 순서·횟수**: 핸들러는 한 폴링에 하나만 고르므로(`_mam_dialog_pick` 은 첫 actionable 항목에서 종료), 두 창이 한 화면에 있으면 **1회차 폴링에 `Enter`, 다음 폴링(1 s 뒤)에 `2`** 순으로 각 1회 간다. S5f 가 `["Enter", "2"]` 를, S6c 가 `["2"]` + 경고를 그대로 고정한다. 쿨다운은 **인덱스별**(`state[idx]`)이라 서로 간섭하지 않는다.
|
||||||
|
|
||||||
|
## 2. P1 — `AUTO_TRUST="${MAM_AUTO_TRUST:-1}"` (항목 2)
|
||||||
|
|
||||||
|
`create_session.sh:72,87-88,96` 과 `resume_session.sh:33,42-43,57` 이 같은 3단 구조(초기값 ← 환경, 플래그 arm 이 덮어씀, 마지막에 `export`)다. 세 줄을 그대로 옮긴 하네스로 8가지를 돌렸다:
|
||||||
|
|
||||||
|
| 환경 `MAM_AUTO_TRUST` | 플래그 | 핸들러가 받는 값 | 판정 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 없음 | – | `1` (on) | 기본 |
|
||||||
|
| `0` | – | `0` (**off**) | **P1 정정**: 문서의 환경변수 형식이 이제 닿는다(옛 코드는 `1` 로 덮어썼다) |
|
||||||
|
| `0` | `--auto-trust` | `1` (on) | 플래그 > 환경 |
|
||||||
|
| `1` | `--no-auto-trust` | `0` (off) | 플래그 > 환경 |
|
||||||
|
| 없음 | `--no-auto-trust` | `0` (off) | |
|
||||||
|
| `yes` / `false` | – | 그대로 전달 → 핸들러 `!= "0"` 규칙으로 **on** | `_mam_dialog_pick` 도 같은 규칙(`MAM_DLG_TRUST != "0"`)이라 두 층이 일치한다 |
|
||||||
|
| `''`(빈 문자열) | – | `:-1` 이 빈 값을 미설정으로 봐 `1` (on) | 핸들러의 `${MAM_AUTO_TRUST:-1}` 와 같은 해석 |
|
||||||
|
|
||||||
|
즉 "0 만 off, 나머지는 전부 on" 이 스크립트·핸들러·picker 세 층에서 동일하다(`lib.sh:2986`, picker 의 `!= "0"`). create SKILL.md `:119-120` 이 환경변수 형식과 "플래그 우선"을 명시한다. **G1 (비차단)**: `false`/`no` 가 on 으로 해석되는 것은 일관되지만 직관에 반한다 — 문서에 "`0` 만 끈다" 를 한 마디 적어 두면 좋다. resume SKILL.md 는 `:107` 에서 `MAM_AUTO_TRUST=0` 형식은 적지만 "플래그가 환경보다 우선" 은 create 쪽에만 있다(G2, 사소).
|
||||||
|
|
||||||
|
## 3. 테스트 (항목 1·3)
|
||||||
|
|
||||||
|
### 3-1. 수정 없이는 실패하는가 — 확인함
|
||||||
|
|
||||||
|
검증 경로 사본에서 `lib.sh`·`create_session.sh`·`resume_session.sh` 세 파일만 `f4ba205` 판으로 되돌리고 새 회귀와 이웃 시험을 `-k "s6c or s5f or r3b or c3 or test_s5_ or s5b or test_s6_"` 로 돌렸다:
|
||||||
|
|
||||||
|
```
|
||||||
|
4 failed, 7 passed, 70 deselected in 153.65s
|
||||||
|
FAILED test_s6c_a_skipped_trust_dialog_does_not_hide_the_notice_below_it ← P2
|
||||||
|
FAILED test_s5f_a_dialog_inside_its_cooldown_does_not_block_the_next_one ← P2
|
||||||
|
FAILED test_r3b_…_on_resume[0-flag0-0] (환경변수만, 플래그 없음) ← P1
|
||||||
|
FAILED test_c3_…_on_create[0-flag0-0] (환경변수만, 플래그 없음) ← P1
|
||||||
|
```
|
||||||
|
|
||||||
|
실패 4건이 정확히 두 탐침의 재현 사례다. 통과한 7건 — R3b/C3 의 "플래그가 환경을 이긴다" 4사례(플래그는 옛 코드에서도 동작했다), S5·S5b(쿨다운 계약), S6(신뢰 창 skip + 안내 응답, 한 화면에 하나씩) — 은 정정 전후로 **변하지 않아야 하는** 계약이고, 실제로 변하지 않았다. 즉 `continue` 가 S5/S5b 를 깨지 않았다는 것이 되돌린 사본과 PR 트리 양쪽에서 성립한다(PR 트리 쪽은 §3-2).
|
||||||
|
|
||||||
|
### 3-2. PR 트리(검증 경로)에서의 실행
|
||||||
|
|
||||||
|
```
|
||||||
|
tests/test_issue23_24_startup_path.py tests/test_issue24_resume_dialogs.py tests/test_c1_tui_readiness.py tests/test_b19_headless_reconcile_fixes.py
|
||||||
|
→ 120 passed in 535.40s (0:08:55) failed 0 · skipped 0
|
||||||
|
같은 -k 부분집합(새 회귀 4 + 이웃 7) 단독 → 11 passed in 148.21s
|
||||||
|
```
|
||||||
|
|
||||||
|
- 다이얼로그 핸들러를 건드리는 4파일 전부 통과. `c1`·`b19` 의 "프레임당 1회"·`Yes, try it` 가드도 유지된다.
|
||||||
|
- 실제 `.mam/jobs` 전후 **차이 0**. 검증 경로 tracked 파일 변화 없음(HEAD `05b0ea4`), 본 체크아웃 `main 1e78dae`.
|
||||||
|
- 시간: 이 호스트에서 다른 스위트 2개가 동시에 돌고 있어 resume 시험 6건이 각 37~41 s 였다(1회차 F6 그대로; R3b 3사례가 더해져 +2 분).
|
||||||
|
|
||||||
|
## 4. 병합 차단 여부 (항목 3)
|
||||||
|
|
||||||
|
1회차 PASS 를 뒤집을 요소는 없다. 델타는 세 줄의 소스 변경(`lib.sh:2958` `continue`, `create_session.sh:72`·`resume_session.sh:33` 의 환경 기본값)과 문서 한 줄, 회귀 4건이며, 두 탐침의 재현 사례만 정확히 실패→통과로 바뀌었다. 버전 락스텝 bump 부재(`4.7.1`, 오케스트레이터가 4.7.2 로 처리 예정) 외에 **병합을 막을 결함은 없다**.
|
||||||
|
|
||||||
|
| # | 비차단 관찰 | 근거 |
|
||||||
|
|---|---|---|
|
||||||
|
| G1 | `MAM_AUTO_TRUST` 는 `0` 만 끈다(`false`/`no` 는 on). 세 층(스크립트·핸들러·picker)이 일치하므로 결함은 아니지만 문서에 한 마디 있으면 좋다 | `create_session.sh:72`, `lib.sh:2986`, `_mam_dialog_pick` 의 `!= "0"` |
|
||||||
|
| G2 | "플래그가 환경보다 우선" 은 create SKILL.md(`:119-120`)에만 있고 resume SKILL.md(`:107`)에는 환경변수 형식만 있다 | 사소 |
|
||||||
|
|
||||||
|
1회차 F1(codex 수락 키와 브리프 서술의 차이)은 이 델타의 범위 밖이며 그대로 열려 있다.
|
||||||
|
|
||||||
|
## 5. 교차검증 가이드
|
||||||
|
|
||||||
|
```bash
|
||||||
|
RP=/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20260923T220355Z-76dd1d/1
|
||||||
|
PY=/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.venv/bin/python
|
||||||
|
git -C "$RP" rev-parse HEAD 'HEAD^{tree}' # 05b0ea4… / 8f5e8b0…
|
||||||
|
# picker 단독: 신뢰 창 쿨다운 중에도 안내에 답하는지
|
||||||
|
eval "$(sed -n '/^_mam_dialog_pick() {/,/^}/p' "$RP/.agents/skills/lib.sh")"
|
||||||
|
D='[["Do you trust the contents of this directory|1\\. Yes, continue",["Enter"],"trust"],["1\\. Update now|Skip until next version",["2"],"notice"]]'
|
||||||
|
S=$'Do you trust the contents of this directory?\n > 1. Yes, continue\nUpdate available\n 1. Update now\n 2. Skip\n 3. Skip until next version'
|
||||||
|
_mam_dialog_pick "$S" "$D" 1 "0:1000" 1001 5 # send|1|2 (정정 전: 출력 없음)
|
||||||
|
_mam_dialog_pick "$S" "$D" 1 "0:1000 1:1000" 1002 5 || echo nothing # S5 유지
|
||||||
|
# 되돌린 사본에서 회귀가 실패하는지: lib.sh/create/resume 를 f4ba205 판으로 교체 → -k "s6c or s5f or r3b or c3 ..." → 4 failed / 7 passed
|
||||||
|
```
|
||||||
|
|
||||||
|
[VERDICT: PASS]
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# 리뷰 리포트 — Job e93cf9d4 (PR #51, refactor/issue-17-item4)
|
||||||
|
|
||||||
|
- 리뷰어: planner-reviewer-claude-01 (claude)
|
||||||
|
- 리뷰 identity: loop-20261007T142403Z-10d77f / base 7209750 / head 518beda / tree 137b4e4 — 검증 경로에서 `git rev-parse HEAD 'HEAD^{tree}'` 일치, 미커밋 변경 없음
|
||||||
|
|
||||||
|
## 결론 요약
|
||||||
|
동작 불변 계약이 지켜졌다. 옛 bash 판정(tests/data/issue17_item4_old_decisions.sh = 7209750 의 함수)과 새 wrapper.py 를 경로별로 대조해 차이를 찾지 못했고(옛 코드의 따옴표 경로 버그 하나는 새 코드에서 고쳐짐 — 개선), 지정 9개 파일이 이 macOS·Python 3.9·bash 3.2 에서 전부 통과한다. 차단 결함 없음. 비차단 관찰 5건(§8).
|
||||||
|
|
||||||
|
## 1. 동작 불변 — 경로별 대조
|
||||||
|
| 옛 bash (old_decisions.sh) | 새 Python (wrapper.py) | 판단 |
|
||||||
|
|---|---|---|
|
||||||
|
| _map_sub_rc :72-90 | sub_status :58-66 | 같음. rc 3 은 디스크 종결 상태가 이김, 그 외 코드는 timeout |
|
||||||
|
| 구독자 조기 종료 판정 `[ $sub_exit -eq 0 ] \|\| [ … -eq 3 ]` | subscriber_up :69-70 | 같음 |
|
||||||
|
| _rc_for :92-101 + 호출부의 `MAM_DELEGATE_FAIL_CLOSED=0 → exit 0` | exit_code :77-78, fail_closed :73-74 | 같음. 빈 값은 1 로 보는 `${…:-1}` 와 `or "1"` 일치 |
|
||||||
|
| cmd_wait rc 4→2 :261 | wait_rc :81-82 | 같음 |
|
||||||
|
| start_stall_watch 의 감시 여부 :30-36 | stall_watch :89-99 | 같음. 메시지도 동일 |
|
||||||
|
| finalize_unfinished 앞부분: FAIL_CLOSED=0·completed 면 반환, stall.out 꼬리 3줄, 디스크 종결이면 FINAL=디스크(completed 면 반환) :105-123 | finalize_begin :161-187 (`keep` / `done completed` / `go\|probe <final> <evidence_until> <deadline>`) | 같음 |
|
||||||
|
| broker_unavailable: 마감까지 ≤3 s 간격 재관측, 매 회 시계로 마감 계산, 마감 도달 시 wall :128-146 | finalize_end :201-216 | 같음(`_sleep` 이 0.5 s 단위로 쪼개 소유자를 확인할 뿐, 대기 총량은 같다) |
|
||||||
|
| timeout: evidence_until 는 진단 **전**에 계산, 마감까지 디스크 재관측, 디스크 heartbeat 가 창을 다시 열고, 창이 지나면 종료, 마감 도달 시 wall :149-194 | finalize_begin 이 evidence_until 계산(:181) → bash 가 진단 프로브(래퍼 :137-142) → finalize_end :217-238 | 같음. 진단 순서도 같다 |
|
||||||
|
| 라벨: sub_rc 4 → wall, wall 이고 phase≠inject 면 wall_clock_timeout, preflight/idle_wait 는 cancelled + session_busy 또는 PREFLIGHT_REASON :199-209 | :242-251 | 같음 |
|
||||||
|
| close rc 4(토큰 불일치) → 이벤트 억제, FINAL 그대로 :216-224 | :256-262 | 같음 |
|
||||||
|
| close 뒤 디스크 completed 면 completed :226-232 | :263-264 | 같음 |
|
||||||
|
| direct 만 이벤트: cancelled 또는 (sub_rc≠1 또는 inject) 이면 error(inject 는 agent_blocked) :234-245 | :266-272 | 같음. 토큰 인자도 동일 |
|
||||||
|
| registry.py close 출력 `2>&1` 을 stdout 으로, publish_event stderr 는 버림 | _run merge=True/False :190-193, 답은 fd 3 | 같음 — 래퍼가 `3>&1 1>&4` 로 stdout 을 원래 stdout 으로 보낸다(래퍼 :145-149). subprocess 기본 close_fds 로 fd 3 은 자식에 새지 않는다 |
|
||||||
|
- 비교 테스트(test_c_*)가 status×sub_rc×phase 전 조합, 토큰 불일치(CLOSE_RC=4), FAIL_CLOSED=0, remote·human·loop·discuss, 늦은 completed, heartbeat 유예가 마감까지 가는 경우, 래퍼 사망(KILL·미회수 좀비)을 옛·새 양쪽으로 돌린다.
|
||||||
|
- 직접 돌려 본 미포함 경로: registry 경로에 작은따옴표(`reg it's`)가 있을 때 옛 `_disk_status` 는 경로를 Python 소스에 끼워 넣어 `""` 를 내고(rc 3 + 디스크 completed 를 broker_unavailable 로 오판), 새 sub_status 는 `completed`. 옛 버그가 고쳐진 차이이며 계약 위반이 아니다.
|
||||||
|
|
||||||
|
## 2. bash ↔ Python 경계
|
||||||
|
- `_decide`(래퍼 :97-99)는 `"$@"` 로 인자를 그대로 넘긴다. 빈 값(`--dispatch-token ""`, `--phase ""`, `--preflight-reason ""`)은 argparse 기본값과 같다. `-` 로 시작하는 값이 들어갈 수 있는 인자는 PREFLIGHT_REASON 뿐인데 값은 `target_not_found` 하나(래퍼 :850). `${START_TIME:+--start-time "$START_TIME"}` 는 빈 START_TIME 이면 인자를 빼고 Python 이 now 를 쓴다 — 옛 `${START_TIME:-$(date +%s)}` 와 같다.
|
||||||
|
- 한 줄 출력: finalize-begin 은 stdout 에 답 한 줄만, 진단은 stderr. finalize-end 는 fd 3 으로 답(fd 3 이 닫혀 있으면 stdout).
|
||||||
|
- 래퍼에 판정이 남지 않았다: test_s_the_wrapper_carries_no_moved_decision(주석 제외 소스에 _map_sub_rc·_rc_for·FINALIZE_CLOCK·`${MAM_DELEGATE_FAIL_CLOSED`·SUB_RC 비교·라벨 문자열 없음, lib_int.sh 에 BASH_REMATCH 없음).
|
||||||
|
- knob 환경변수: 옛 bash 는 셸 변수로, 새 Python 은 환경으로 읽는다. 래퍼가 이 두 knob 을 대입하지 않고 .mam.env 는 `export` 줄로 들어오므로(래퍼 :37-45, envfile.py :116) 차이가 없다.
|
||||||
|
- Python 실패 시: 관찰 N2.
|
||||||
|
|
||||||
|
## 3. finalize-end 의 --watch-pid
|
||||||
|
- `_check_owner` 가 두 대기의 매 반복(0.5 s 단위)과 close 직전(:256), 이벤트 직전(:267)에 래퍼 생존을 본다. 사라졌으면 OwnerGone → 닫기·발행 없이 종료(:338-339).
|
||||||
|
- `_alive`(:122-137): kill(0) 실패면 죽음, 성공이면 `ps -o stat= -p` 가 `Z…` 이거나 빈 출력이면 죽음. macOS 와 Linux 모두 좀비는 `Z`(macOS `Z`, Linux `Z`/`Z+`)로 시작하고, 사라진 pid 는 빈 출력이다. ps 를 못 돌리면 kill(0) 결과를 믿는다(살아 있음) — 닫는 쪽이 아니라 기다리는 쪽이지만, 래퍼가 사라진 뒤라면 대기는 마감에서 끝나고 닫기가 일어날 수 있다(ps 가 없을 때만, 비현실적).
|
||||||
|
- 래퍼에 TERM trap 이 없으므로(trap 은 부트스트랩 EXIT 용 :916-942 뿐) TERM 이 `$(…)` 대기 중 지연되는 문제는 없다. 래퍼가 TERM/KILL 로 사라지면 고아가 된 Python 이 0.5 s 안에 알아채고 끝난다 — 옛 bash 루프가 래퍼와 함께 끝나던 것과 같은 결과. 테스트 test_c_finalize_ends_with_its_wrapper / _with_an_unreaped_wrapper / test_c_an_unreaped_wrapper_leaves_the_real_record_alone 통과.
|
||||||
|
- 정상 경로 지연: 확인마다 ps 한 번(수 ms). 대기의 총길이는 옛과 같다.
|
||||||
|
|
||||||
|
## 4. 정수 규칙 (lib_py/ints.py)
|
||||||
|
- positive_int 는 runner.py 의 옛 _positive_int 를 그대로 옮긴 것이고(diff 상 동일 본문), 옛 bash mam_int 와도 같은 규칙이다: ASCII 공백 6종만 앞뒤 허용, ASCII 숫자만(`٥` 거부, 로케일 무관), 선행 0 제거 뒤 9자리 이하, 최솟값. knob 의 경고 문구도 동일. test_c_mam_int_and_knob 이 C·en_US.UTF-8 두 로케일에서 옛·새를 비교하고 통과.
|
||||||
|
- lib_int.sh 를 lib.sh 없이 source 하는 diff_collect.sh·loop_lock.sh: `_MAM_INTS_PY` 를 BASH_SOURCE 기준으로 잡으므로 동작한다(test_issue21_loop_b l15 는 그 배치를 재현하도록 lib_py/ints.py 도 복사). 비용·실패는 N3.
|
||||||
|
|
||||||
|
## 5. 수정된 정적 테스트 4개
|
||||||
|
약해지지 않았고 오히려 강해졌다: 각 테스트가 래퍼 소스의 `_decide …` 호출 문자열을 계속 고정하면서, 옮겨진 표 자체(EXIT_CODES, rc 0~5 의 sub_status, rc 3 의 디스크 우선, SUB_RC_* 와 래퍼 상수의 일치)를 실제 함수로 검사한다. test_issue26_ints 의 NOT_ARITHMETIC 에서 두 knob 을 뺀 것은 그 knob 들이 셸에서 사라졌기 때문이며 정적 가드가 재유입을 막는다.
|
||||||
|
|
||||||
|
## 6. 성능
|
||||||
|
- `_decide` 한 번 ≈ 50 ms(5회 0.25 s), 그중 약 29 ms 는 `lib_py.delegation` 패키지 import(N1). 잡 하나당 호출은 정상 경로에서 3~5회(sub-status, exit-code, stall-watch, 미완료 시 finalize-begin/-end).
|
||||||
|
- `mam_int`/`mam_int_knob` 는 호출당 ≈ 47 ms(10회 0.47 s; 옛 bash 는 ≈ 0.1 ms). 래퍼 기동 시 knob 4~5개 + 플래그, send_keys 경로의 knob 몇 개. 모두 폴링 루프 밖(마감 계산은 1회)이라 잡당 1 s 미만이며 에이전트 턴 시간에 비해 무시할 수준.
|
||||||
|
|
||||||
|
## 7. 이식성
|
||||||
|
이 macOS·/usr/bin/python3 3.9.6·bash 3.2 에서 지정 9개 파일 통과. wrapper.py·ints.py 는 3.9 문법(`from __future__ import annotations`, typing.List), /proc 없음(ps 사용), str.isascii(3.7+).
|
||||||
|
|
||||||
|
## 8. 비차단 관찰
|
||||||
|
- **N1** wrapper.py 머리말의 "Stdlib only" 와 달리 `python -m lib_py.delegation.wrapper` 는 패키지 `lib_py/delegation/__init__.py` 를 먼저 import 해 service → mqtt_common → **paho** 를 싣는다(paho 없는 python3 에서 `ModuleNotFoundError: paho` 확인). 래퍼의 `$PY` 는 원래 paho 가 필요하므로 동작에는 문제없지만, 문서 정정이나 패키지 밖 모듈(ints.py 처럼 경로 실행)로 두면 호출당 ~29 ms 도 줄어든다.
|
||||||
|
- **N2** `_decide` 가 실패하면(모듈 import 오류 등) `exit "$(_decide exit-code …)"` 는 `exit ""` → bash 3.2 에서 rc 255("numeric argument required"), finalize 도 실패해 잡이 열린 채 남는다. 옛 코드도 같은 `$PY` 로 registry.py 를 돌렸으므로 실질 회귀는 아니지만, `exit "${rc:-1}"` 같은 기본값이 있으면 fail-closed 코드(1)가 보장된다.
|
||||||
|
- **N3** lib_int.sh 는 `$PY` 가 아니라 PATH 의 `python3 -I` 를 쓴다. python3 가 없으면 mam_int_knob 이 아무것도 출력하지 않아(옛 bash 는 항상 기본값 출력) `$(( $(date +%s) + $(mam_int_knob …) ))`(lib.sh:2783 등)가 산술 오류가 된다. MAM 은 python3 를 이미 필수로 요구하므로 현실적 위험은 낮다.
|
||||||
|
- **N4** 옛 `_disk_status` 의 작은따옴표 경로 버그(§1)가 새 코드에서 고쳐졌다 — 동작 차이지만 개선.
|
||||||
|
- **N5** 브리프·PR 설명의 "0.2 s 마다 확인"과 달리 코드는 0.5 s(`_sleep`, :145-153). 설명 정정만 필요.
|
||||||
|
|
||||||
|
## 9. 테스트
|
||||||
|
검증 경로에서 지정 9개 파일(pr-51 .venv, Python 3.9.6): **604 passed / 1 skipped / 0 failed** (1009 s, rc 0). skip = test_issue30_stall_watch.py:340 "real process trees via /proc"(Linux 전용, 기존). MQTT_* unset, .mam.env 미export. 실제 .mam/jobs 항목 수 변화 없음(1052 → 1052).
|
||||||
|
|
||||||
|
[VERDICT: PASS]
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
# 🔍 Review Report — PR #41 병합 전 독립 리뷰 + 설계 합의 (job `ed0e1492`)
|
||||||
|
|
||||||
|
- **Reviewer**: `planner-reviewer-claude-01` (role: planner,reviewer) · 2026-09-26
|
||||||
|
- **대상**: PR #41 `fix/issue-30-33` **@ `1673818`**, base `main 8ea65bb` (v4.7.4). 6커밋, 30파일 `+2047/−95`, `git diff --check` 클린.
|
||||||
|
- **검증 경로**: `/var/folders/.../T/mam-review/loop-20260926T025230Z-a30a6d/1`. **identity 확인**: `HEAD = 1673818b…e91f`, `HEAD^{tree} = 00d5e03e…9e92` — 브리프와 일치.
|
||||||
|
- **안전 규칙 준수**: 검증 경로·소스 워크트리(`multi-agent-mux-wt/pr-41`)·본 체크아웃(`main 8ea65bb`) 모두 수정·커밋·checkout 없음. 실험은 in-process 러너(`env -u MAM_VERSION`)·래퍼 `--dry-run`(`--agent human`, 도달 불가 브로커)·되돌린 사본에서만. **stall_watch 를 실제 세션에 붙이지 않았고**, 실제 herdr 서버에 위임 잡을 만들지 않았다. push·merge·PR 상태 변경 없음.
|
||||||
|
|
||||||
|
## 결론 요약
|
||||||
|
|
||||||
|
**병합을 막을 결함은 없다.** #33 의 예산 전달·우선순위와 두 시계 라벨, #30 감시자의 "알림 전용" 성질(발행하는 이벤트가 `progress` 뿐, 잡을 닫는 코드 경로 없음)을 코드·실험·테스트로 확인했다. 경계 기준으로 보면 bash 에 새 **판정** 이 두 곳 남아 있고(§4 표: 디스크 heartbeat 유예의 시계 판정, 감시 대상 제외 판정) 이는 항목 4 이전 대상이므로 비차단으로 분류한다. 비차단 관찰 7건(M1–M7). 설계 합의 4항목은 §8 에 별도로 답했다.
|
||||||
|
|
||||||
|
## 1. #33 예산 (항목 1)
|
||||||
|
|
||||||
|
| 확인 | 결과 |
|
||||||
|
|---|---|
|
||||||
|
| 래퍼 우선순위 | `--timeout` > `MAM_JOB_TIMEOUT_S` > 3600. `--dry-run` 실측으로 등록된 `timeout_sec`: 없음→3600, env 900→900, env 900 + `--timeout 120`→**120**, env `abc`→3600 + `WARNING: … ignored (not a positive integer)`, env `-7`→3600 + 경고 |
|
||||||
|
| 러너 우선순위 | `job_budget`(`runner.py:595-600`): `--job-timeout` > env(양의 정수) > 3600. in-process 실측: 기본 3600/대기 3900, env 900→900/1200, flag 120 > env 900→120/420, env `abc`·`0`→3600(**무언**, M4), `--job-timeout 0/-5/x` → exit 1 |
|
||||||
|
| 모든 위임에 전달되는가 | `contract_args()`(`:464-470`)가 **항상** `--timeout <budget>` 을 앞에 붙이고, 구현·리뷰·수정·재심·중재·순응 잡은 전부 `_dispatch → delegate_job → _run_cmd(… + contract_args())` 를 지나므로 예외가 없다(실측 `['--timeout','3600']`). 래퍼 쪽에서는 `--timeout` 플래그가 env 를 이기므로 루프의 예산이 등록된다(B2) |
|
||||||
|
| 대기 상한 | `job_wait_s = budget + MAM_WAIT_GRACE_S(300)`(`:603-609`); `wait_for_job` 기본 `max_wait` 와 `MAM_CHILD_WAIT_S` 기본값이 이를 따르고 명시 `MAM_CHILD_WAIT_S` 는 존중(`:729-732`, 로그에 출처 표기). `MAM_WAIT_GRACE_S=10` → 610 실측 |
|
||||||
|
| 원격 잡 | `remote.py` 는 `job.timeout_sec`(등록된 예산)로 명령을 죽이므로 예산이 그대로 적용된다; 라벨은 §2 |
|
||||||
|
|
||||||
|
## 2. #33 라벨 (항목 2)
|
||||||
|
|
||||||
|
- **상수**: `job_subscriber.py:53-61` `RC_COMPLETED/ERROR/NO_EVENT/BROKER_UNAVAILABLE/WALL_CLOCK = 0/1/2/3/4`, 래퍼 `:144` `SUB_RC_*` 동일값. `test_issue5_phase1_delegation.py::test_wrapper_exit_code_mapping_literals_are_frozen` 이 `SUB_RC_*` 사전을 `RC_*` 와 **사전 단위로 비교**하고, 래퍼가 숫자로 비교하는 자리가 없음·구독자가 숫자 `return` 을 하지 않음을 정규식으로 고정한다.
|
||||||
|
- **래퍼 종결 경로별 라벨**(`finalize_unfinished`, `:860-1000`):
|
||||||
|
|
||||||
|
| 경로 | 라벨 |
|
||||||
|
|---|---|
|
||||||
|
| 구독자 rc 4 | `clock=wall` → `close --request wall_clock_timeout` + `error --detail wall_clock_timeout` |
|
||||||
|
| 구독자 rc 2 | `clock=idle` → `deadline_timeout` |
|
||||||
|
| rc 2 뒤 디스크 heartbeat 유예가 `$deadline` 에 닿음 | `FINALIZE_CLOCK=wall` → `wall_clock_timeout`(R3) |
|
||||||
|
| rc 3(broker_unavailable) 재관측이 `$deadline` 에 닿음 | 같은 규칙으로 `wall` |
|
||||||
|
| inject 단계 | `agent_blocked` 유지(`phase != inject` 조건) |
|
||||||
|
| preflight/idle_wait | `cancelled`(`session_busy`/`target_not_found`) 유지 |
|
||||||
|
|
||||||
|
- 등록부 `close --request` 에 `wall_clock_timeout` 추가(`registry.py:424`), `engine.REQUEST_TO_STATUS["wall_clock_timeout"]="error"`, #34 표의 `ORCHESTRATOR_CLOSED` 에 포함 + `.rstrip(":")` 로 원격 문구 `wall_clock_timeout: agent command exceeded…` 도 첫 단어 판정을 통과(B5).
|
||||||
|
- `delegate-job wait`: rc 4→2 매핑(`:1214`)으로 하위 호환(B7, G-8 은 idle 30 s/wall 0.5 s 로 시계를 분리해 결정적).
|
||||||
|
- 벽시계로 닫힌 뒤 에이전트의 늦은 `completed`: `finalize_unfinished` 가 디스크 상태가 이미 종단이면 건너뛰고(`:875-877`), `engine.transition` 이 종단 뒤 종단을 거부하는 기존 규칙(#23 리뷰에서 확인한 "conflicting … after terminal")이 그대로다 — 이 PR 이 바꾸지 않았다.
|
||||||
|
|
||||||
|
## 3. #30 감시자 `stall_watch.py` (항목 3)
|
||||||
|
|
||||||
|
- **잡을 닫는 경로 없음**: 파일 전체에서 `--event` 는 `_notify` 의 `progress` 하나뿐(`:237`), `close`/`error`/`cancelled`/`update_status`/`transition` 호출 0건(grep). `judge()` 는 순수 함수로 `NOTIFY_BLOCKED`/`NOTIFY_IDLE_AND_NUDGE`/`None` 만 낸다. S12 가 소스 수준에서 같은 것을 고정한다. 종료 코드는 0(잡 종단)·2(herdr 불가)뿐 → **갱신 1 준수**.
|
||||||
|
- **herdr 조회 방식**: `watcher.wait_once`(`agent wait --until … --timeout`)의 서버 소유 배리어를 상태별 `until` 로 돌리고(`:274-284`), idle 뒤에는 30 s 단위로 재판정한다. 폴링·구독이 아니라 배리어 + 판정 시점의 `agent get`/`pane process-info`/`agent read`.
|
||||||
|
- **blocked**: `pane read --source detection --lines 8` 의 첫 줄(비ASCII 제거, 160자)을 붙여 `stalled:blocked <문구>` 를 에피소드당 1회 발행(`:318-324`), **키 입력 없음** — 갱신 1 의 "자동 수락 없음" 과 일치. `blocked → 다른 상태 → blocked` 면 다시 1회.
|
||||||
|
- **idle**: `MAM_STALL_IDLE_S`(600) 연속 idle + `agent_pid`(foreground 프로세스, shell 아님) 있음 + 백그라운드 자손 없음(herdr pid → OS 자손 트리에서 어댑터 `resident_children` 과 heartbeat 타이머 제외, `:118-136`) + 입력창 비어 있음(`extract_input_region == ""`) 일 때만 `stalled:idle` + 재주입 **잡당 1회**(`st.nudged`). `state_change_seq` 가 창 안에서 바뀌었으면 창을 다시 시작(R4). 입력 사실이 없는 에이전트는 보류 + 1회 알림. 재주입 문구(`NUDGE`)는 영문·ASCII·1문장이며 "끝났으면 completed/error 발행, 아니면 이어서" 라 오작동 여지가 작다.
|
||||||
|
- **원격·human 제외**: 래퍼 `start_stall_watch`(`:684-702`)가 `remote:`/`human` 이면 기동하지 않고, `MAM_STALL_WATCH=0` 으로 끌 수 있다(S9/S10).
|
||||||
|
- **herdr 실패**: `TRANSPORT_ERROR`/`AGENT_NOT_FOUND` 는 "이벤트 없음"으로 읽지 않고 1회 알림 + 백오프, `MAM_STALL_HERDR_FAIL_S`(120) 뒤 rc 2(S8).
|
||||||
|
- **수명**: 래퍼가 `wait "$sub_pid"` 직후 `stop_stall_watch`(TERM → `os._exit(0)`)로 거둔다(`:385`, `:560`). **M3**: `start_stall_watch` 와 `stop_stall_watch` 사이의 비정상 종료(`set -e` 오류, 검증 훅 실패의 `exit`, 래퍼 SIGKILL)를 덮는 EXIT trap 이 없다. 감시자는 잡 레코드가 종단이 되면 다음 반복(≤ 60 s 배리어)에서 스스로 끝나므로 잡이 닫히는 한 고아 수명은 유계이지만, 잡이 `running` 으로 남는 경우(래퍼 -9)에는 herdr 가 살아 있는 동안 계속 돈다. `.stall.out` 은 `.mam/jobs/<jid>.stall.out` 에 남는다(작고, #20 가드의 `*.json` 감시 밖).
|
||||||
|
- **실제 시험 A/B 재현성**: 실제 세션에 붙이는 것은 금지라 재현하지 않았다. mock herdr 위의 S2/S3/S7/R1/R2/R4 가 같은 신호 흐름을 실제 `python -m lib_py.stall_watch` 프로세스로 돌린다.
|
||||||
|
|
||||||
|
## 4. 경계 기준 — bash 에 들어온 판정 (항목 4)
|
||||||
|
|
||||||
|
| 위치 | 내용 | 성격 | 분류 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 래퍼 `:146-151` | `MAM_JOB_TIMEOUT_S` 양의 정수 검증·경고 | 입력 검증(기본값 결정) | 항목 4 이전 대상, 비차단 |
|
||||||
|
| `finalize_unfinished` `:902`, `:950` | 디스크 heartbeat 유예/broker 재관측 루프가 `$deadline` 에 닿았는지로 `FINALIZE_CLOCK=wall` | **시계 판정** — Python 반환값이 아니라 bash 가 결정 | 항목 4 이전 대상, 비차단(**M1**) |
|
||||||
|
| `:956-959` | `sub_rc == SUB_RC_WALL_CLOCK → clock=wall`, `phase != inject` 면 `wall_clock_timeout` | rc→라벨 **운반** + inject 예외(판정) | 운반은 기준 부합; inject 예외는 기존 구조 |
|
||||||
|
| `:684-702` | remote/human 제외, `MAM_STALL_WATCH` | 감시 여부 **판정** | 항목 4 이전 대상, 비차단(**M2**) |
|
||||||
|
| `cmd_wait` `:1214` | rc 4→2 | 호환 매핑(운반) | 부합 |
|
||||||
|
| `stop_stall_watch` | rc≠0 이면 로그 꼬리 출력 | 실행부 | 부합 |
|
||||||
|
|
||||||
|
"bash 는 라벨을 나르기만 한다"는 주장은 **구독자 rc 경로에 한해** 맞다. 유예 루프의 시계 판정(M1)과 감시 대상 판정(M2)은 bash 의 결정이다. 둘 다 항목 4(래퍼 종결 처리의 Python 이전)에서 옮길 대상이고 PR 본문이 항목 4 를 미룬다고 밝혔으므로 비차단.
|
||||||
|
|
||||||
|
## 5. 기존 테스트 조정·새 테스트 (항목 5)
|
||||||
|
|
||||||
|
- G-8: idle 0.5/wall 0.5 의 경합을 idle 30/wall 0.5 로 풀어 **어느 시계인지가 결정적**이 됐고 rc 는 `RC_WALL_CLOCK` 으로 단언 — 의도 유지.
|
||||||
|
- issue5 phase1: 스냅샷이 "숫자 return 금지·이름 비교만" 으로 **강화**됐고 `help-registry.txt` 는 `close` 선택지 갱신. issue18: `contract_args` 에 `--timeout 3600` 이 앞에 오는 것만 갱신. `conftest.py`: mock herdr 에 `force_error_code`·detection 소스·실제 `process-info` 형태·`state_change_seq`·`wait_blocks_s` 추가 — 기존 동작은 기본값으로 유지.
|
||||||
|
- 새 테스트: `_env()` 가 `HOME`·`WORKSPACE_ROOT`·로그 루트를 `tmp_path` 로, 브로커를 도달 불가로 고정; 감시자는 실제 `python -m lib_py.stall_watch` 프로세스, 래퍼도 실제 스크립트(`--agent human`), herdr 는 mock. 실제 `.mam`·홈·herdr 접촉 없음(§6 실측).
|
||||||
|
- pane 픽스처 5종: `extract_input_region` 을 5개 idle 캡처에 돌려 **전부 `''`**(S14 의 typing 변형이 비어 있지 않음을 고정). 캡처 자체가 실제 화면과 같은지는 이 리뷰에서 재현할 수 없고(실제 세션 접근 금지), PR 의 "herdr 0.9.1 캡처" 기록에 의존한다.
|
||||||
|
|
||||||
|
## 7. 문서 (항목 6)
|
||||||
|
|
||||||
|
- delegate-job SKILL.md: `MAM_JOB_TIMEOUT_S`·`MAM_STALL_IDLE_S`/`MAM_STALL_HERDR_FAIL_S`/`MAM_STALL_WATCH=0` 행(`:49-50`), 종료 코드 표에 `2 | Wall clock | error (wall_clock_timeout)` 행(`:100`) 추가 — 코드와 일치. **M5**: 그 행의 "was `deadline_timeout` before v4.7.5" 는 브리프의 병합 버전(**4.8.0**, MINOR)과 어긋난다 — 릴리스 커밋에서 문구 수정.
|
||||||
|
- loop SKILL.md: `--job-timeout` 행(`:209`)과 "하위 위임 잡" 절의 `MAM_CHILD_WAIT_S` 기본값이 "잡 예산 + 300 s" 로 갱신(`:222`) — 코드와 일치. **M6**: `MAM_WAIT_GRACE_S`(러너의 +300 을 바꾸는 손잡이, `runner.py:608`)는 어느 문서에도 없다.
|
||||||
|
- 래퍼 usage 의 `--timeout` 기본값 서술 갱신 ✓.
|
||||||
|
|
||||||
|
## 8. 설계 합의
|
||||||
|
|
||||||
|
**(A) #30 알림 전용 원칙 — 동의.** 근거: heartbeat 는 프로세스 생존만 증명하고(#18 의 절충), herdr 의 `agent_status` 도 "working 으로 보고되는 멈춤"(codex 재접속 사례)을 못 잡는다. 그런 불완전한 신호로 잡을 닫으면 #18 이 세운 "프로토콜 이벤트만이 종결 근거" 계약을 깨고, 정상적인 장기 작업(전체 스위트 2회)까지 닫게 된다. 알림은 사람·루프가 판단할 근거를 주면서 계약을 유지한다. **이 PR 은 그것을 코드로 강제한다**: `stall_watch.py` 의 유일한 발행기 `_notify` 가 `progress` 만 내고, 종결 API(`close`/`error`/`cancelled`/`transition`) 호출이 파일에 없으며, `judge()` 의 반환값이 알림 두 종류뿐이다. S12 가 소스 수준에서 이를 고정하므로 후속 커밋이 조용히 종결 경로를 넣기 어렵다. 한 가지 보강 권고: `judge()` 의 반환 타입을 열거형으로 좁혀 "닫기" 라는 값이 애초에 표현 불가능하게 두면 테스트 없이도 구조가 원칙을 말한다.
|
||||||
|
|
||||||
|
**(B) 예산 연장을 #39 로 분리 — 조건부 동의.** 러너가 래퍼를 동기 호출하는 구조에서 "래퍼가 멈춰서 묻고 루프가 정책으로 답한다" 는 원안은 래퍼 안에서 정책을 결정하거나 파일 기반 결정 채널을 추가해야 하고, `state_change_seq` 정체는 정상 장기 작업도 거부한다는 PR 의 관찰에 동의한다 — 이 PR 의 범위에서 분리한 것은 맞다. #39 방식(에이전트가 프로토콜 이벤트로 연장을 요청)의 위험: (1) 에이전트가 반복 요청해 사실상 무한 예산이 되는 것, (2) 원격 잡은 러너 쪽 `timeout_sec` 이 별도 시계라 요청이 닿아도 명령이 먼저 죽을 수 있는 것, (3) 상한이 정책이 아니라 에이전트의 자기 보고에 걸리는 것. **보완책 한 가지**: 연장 요청을 `progress --detail extend_request:<sec>` 로 받되, **결정은 받는 쪽(구독자/루프)이 `MAM_JOB_EXTEND_MAX`(기본 1)·연장 총량 상한·직전 연장 뒤 `state_change_seq` 변화 여부**로 내리고, 승인 시 등록부의 `timeout_sec` 을 CAS 로 갱신해 원격 러너도 같은 값을 읽게 한다 — 즉 요청은 에이전트가, 결정은 Python 이(경계 기준). 상한은 남긴다.
|
||||||
|
|
||||||
|
**(C) #40 트리거 — 동의(래퍼 구독자 쪽).** 제어 평면(YAML·SQLite 세션 등록부)은 오케스트레이터 소유(#15/#20/#21)이므로 에이전트 프로세스가 실행하는 `publish_event.py` 에서 세션 ID 를 캡처·고정하는 것은 소유권을 거스른다. 두 후보 중 **래퍼 구독자**가 맞다: 루프 러너의 `wait_for_job` 은 루프 밖 직접 위임(`delegate-job submit --type direct`, 워크트리 creator 의 실제 시나리오)을 지나지 않지만, 래퍼 구독자는 모든 위임(루프 안팎, `MAM_NO_SUBSCRIBER_DAEMON=1` 인 워크트리 creator 포함 — 그 경우 래퍼가 잡별 구독자를 직접 띄운다)의 `completed` 를 받는 단일 지점이기 때문이다. 루프 러너는 그 결과를 읽기만 하면 된다. **순서**: 항목 4 가 래퍼 종결 처리를 Python 으로 옮길 때 그 종결 핸들러에 `capture_active_session_id → auto_pin_if_needed` 를 끼워 넣는 것이 가장 싸다 — 항목 4 에 흡수. 항목 4 보다 먼저 해야 한다면 구독자의 `completed` 처리에 Python 훅으로 넣고 bash 는 손대지 않는다.
|
||||||
|
|
||||||
|
**(D) 항목 4 를 미룬 것 — 조건부 동의.** 이 PR 이 항목 4 까지 안았다면 라벨 분리·감시자·종결 처리 이전이 한 리뷰 왕복에 겹쳐 검증 면적이 두 배가 됐을 것이므로, bash 변경을 라벨 운반으로 최소화한 선택은 타당하다. 조건은 §4 표의 두 판정(M1·M2)이 **항목 4 의 명시적 이전 목록**에 올라가는 것이다. 항목 4 에서 Python 으로 옮겨야 할 것: (1) `finalize_unfinished` 전체 — 특히 디스크 heartbeat 유예 루프와 `$deadline` 비교로 시계를 정하는 `FINALIZE_CLOCK`(`:902`, `:950`), `sub_rc → close_req` 매핑과 inject 예외(`:956-959`); 구독자가 "어느 시계인지" 를 이미 rc 로 알고 있으므로 유예 루프 자체를 구독자(또는 그 옆의 Python 종결기)로 옮기면 bash 의 판정이 사라진다. (2) `start_stall_watch` 의 감시 여부 판정(remote/human/`MAM_STALL_WATCH`) — Python 종결기/구독자가 잡 레코드의 `transport`·`agent` 로 결정하고 bash 는 프로세스만 띄운다. (3) `MAM_JOB_TIMEOUT_S` 검증 — 등록부 `register` 가 예산 기본값을 결정하게 하면 래퍼·러너의 이중 검증(M4 의 불일치)도 사라진다. bash 에 남길 것: 감시자·구독자 프로세스 기동/회수(`stop_stall_watch`), 키 주입, 로그 꼬리 출력.
|
||||||
|
|
||||||
|
## 6. 테스트 실행과 **차단 결함 N1** (항목 5·7)
|
||||||
|
|
||||||
|
### 6-1. 검증 경로에서의 실행
|
||||||
|
|
||||||
|
```
|
||||||
|
tests/test_issue30_stall_watch.py tests/test_issue33_budget.py tests/test_tier1_unit.py tests/test_issue5_phase1_delegation.py
|
||||||
|
tests/test_issue18_liveness_contract.py tests/test_issue34_job_tree.py tests/test_issue14_delivery_ack.py tests/test_loop_cli.py
|
||||||
|
→ 3 failed, 222 passed, 48 skipped in 679.80s (0:11:19)
|
||||||
|
FAILED test_issue33_budget.py::test_b3_a_heartbeating_job_that_spends_its_budget_is_wall_clock_timeout
|
||||||
|
FAILED test_issue18_liveness_contract.py::test_send_keys_safe_is_confirmed_by_the_job_started_event
|
||||||
|
FAILED test_issue14_delivery_ack.py::test_wrapper_honours_registry_ack_over_unconfirmed_inject (TimeoutExpired 90 s)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **skip 48 = `test_issue30_stall_watch.py` 전체.** `:39` 의 모듈 수준 `pytestmark = skipif(not linux, "real process trees via /proc")` 가 순수 함수 시험(judge 표·S12·S14·`scoped_env`·추출기 R1/R1b)과 mock herdr 만 쓰는 S9/S10/S11 까지 건너뛴다. 사본에서 그 마크만 지우고 `/proc` 무관 시험을 돌리자 **29 passed** — 이 호스트에서 돌 수 있는 것들이다(M8). `background_children` 의 macOS `ps` 경로는 스위트가 안 타므로 직접 확인했다: 자식 `sleep` 감지, heartbeat 타이머 제외, `resident` 필터, 종료 후 빈 목록 — 모두 정상.
|
||||||
|
- **b3(heartbeating → wall_clock)**: 단독 재실행에서도 실패. `subscriber.out` 은 `idle timeout (3s, no events)` — 이 시험은 `broker=True` 로 **공개 브로커**를 지나 1 s heartbeat 가 3 s idle 창 안에 도착해야 벽시계(6 s)가 먼저 울린다. 이 호스트의 브로커 지연(~2 s, 스파이크 9 s — 이전 리뷰들의 T-1 부류)에서는 idle 이 먼저 울려 `deadline_timeout` 이 된다. **PR 결함이 아니라 시험 설계의 브로커 의존**(M9)이지만, 이 시험이 #33 의 동기 사례를 고정하는 유일한 자리라 결정적으로 만들 필요가 있다(로컬 events.log 로 heartbeat 를 넣거나 idle 창을 넉넉히).
|
||||||
|
- **send_keys_safe**: PR 미접촉 파일의 T-1 flake(단독 재실행 통과).
|
||||||
|
- **issue14 `test_wrapper_honours_registry_ack_over_unconfirmed_inject`: 단독 재실행은 통과했으나 합동 실행에서 래퍼가 멈춰 있었다 → N1.**
|
||||||
|
|
||||||
|
### 6-2. N1 (차단) — 래퍼가 stall 감시자를 기다리며 영구 정지, 잡은 `running` 으로 남는다
|
||||||
|
|
||||||
|
실측(모두 읽기 전용 관찰; 마지막 한 번만 **내 샌드박스** 프로세스에 TERM 을 보냈다):
|
||||||
|
|
||||||
|
| 관찰 | 값 |
|
||||||
|
|---|---|
|
||||||
|
| 합동 실행 뒤 호스트에 남은 `lib_py.stall_watch` 프로세스 | 2개 — 하나는 **내** 실행의 잡 `d494fd13`(basetemp `p41-basetemp/test_wrapper_honours_registry_0`), 하나는 **다른 리뷰어** 실행의 같은 시험(`pytest-0/test_wrapper_honours_registry_0`, 잡 `b0e6a171`, 13분 경과) |
|
||||||
|
| 두 래퍼(bash) 의 상태 | 살아 있고 `sample` 상 메인 스레드가 `__wait4` 에서 대기, **자식은 감시자 하나뿐**(구독자는 이미 종료) |
|
||||||
|
| 잡 레코드 | 둘 다 `status: running`, `timeout_sec 6` — 벽시계 6 s 가 몇 분이 지나도 닫지 못함 |
|
||||||
|
| 감시자 로그 | 내 것은 비어 있음(정상 루프), 다른 리뷰어 것은 `input box … not empty; nudge withheld` 가 2 s 간격으로 116줄 — 감시자는 정상 동작 중 |
|
||||||
|
| 내 감시자에 `kill -TERM` | 즉시 종료 → **래퍼도 즉시 종료** |
|
||||||
|
| 감시자 단독 TERM 실험(가짜 herdr, 시작 후 0.05/0.15/0.3/0.6/1/2 s) | 전부 종료(rc 143/0) — 감시자 자체는 TERM 을 받는다 |
|
||||||
|
|
||||||
|
즉 래퍼는 **감시자 프로세스가 끝나기를 기다리고**, 감시자는 **잡이 종단이 되기를 기다리며**(`stall_watch.py:270-272` 가 유일한 정상 종료 조건), 잡을 종단으로 만드는 것은 그 래퍼다 — 순환 대기. 래퍼가 어느 `wait` 에 있는지는 `sample` 이 bash 심볼을 주지 않아 못 박지 못했다(`stop_stall_watch` 의 `wait "$STALL_PID"` 가 유력하나, 그 앞의 `kill` 이 왜 닿지 않았는지는 재현하지 못했다). 그러나 원인이 무엇이든 **방어가 없다는 것**은 코드로 확정된다:
|
||||||
|
|
||||||
|
- `multi-agent-mux-delegate-job:706-719` `stop_stall_watch`: `kill` 뒤 `wait "$STALL_PID"` 가 **무한**이다. 상한도, `kill -9` 승격도 없다. TERM 이 한 번이라도 유실·무시되면 래퍼는 영원히 선다.
|
||||||
|
- `stall_watch.py:263-312` `run()`: 부모 래퍼의 생존을 보지 않는다(#18 heartbeat 타이머의 `--watch-pid` 같은 결속이 없다). 잡이 종단이 되지 않는 한(래퍼가 죽거나 멈추면 영원히) herdr 가 살아 있는 동안 계속 돈다.
|
||||||
|
- 결과는 갱신 1 의 "종결은 벽시계·무응답 경로에만" 과 정반대다: 벽시계 경로 자체가 실행되지 못해 잡이 **닫히지 않는다**. 루프 쪽은 `budget+300` 뒤 `wait_for_job` 이 포기하지만 래퍼·감시자·`running` 레코드는 남고, #34 표에는 OPEN 으로 영구히 보인다.
|
||||||
|
|
||||||
|
같은 시험이 이 PR 이전(#37·#38 리뷰의 같은 호스트)에는 한 번도 멈춘 적이 없고, 오늘 두 리뷰어의 합동 실행에서 각각 멈췄다. 단독 재실행이 통과하므로 부하·타이밍 의존이지만, 위임 래퍼의 영구 정지는 강도가 높아 **차단**으로 분류한다.
|
||||||
|
|
||||||
|
→ **닫는 방법(작음)**: (1) `stop_stall_watch` 를 유계로 — `kill`, 최대 N s(예: 5) 폴링 대기, 남으면 `kill -9`, 그 뒤 `wait`; (2) 감시자에 `--watch-pid <래퍼 pid>` 를 주고 `run()` 루프 머리에서 부모가 사라지면 rc 0 으로 끝나게(heartbeat 타이머와 같은 계약); (3) S9 에 "감시자가 TERM 을 무시해도 래퍼는 N s 안에 돌아온다" 회귀를 추가. (1)만으로도 정지는 사라지고, (2)가 고아를 없앤다.
|
||||||
|
|
||||||
|
### 6-3. 수정 없이는 실패하는가
|
||||||
|
|
||||||
|
검증 경로 사본에서 PR 이 바꾼 소스 14파일을 `8ea65bb` 판으로 되돌리고(새 모듈 `stall_watch.py` 는 그대로) 새 파일 2개를 돌린 결과 **14 failed / 6 passed / 48 skipped**: B1(env 검증)·B2(예산 전달·대기 상한)·B3·B4(원격 라벨)·B5(#34 표)·R3 가 실패 — #33 회귀는 정정을 정확히 겨냥한다. #30 파일은 Linux 전용 마크로 사본에서도 skip 이라 이 방법으로는 판정할 수 없다(새 모듈이므로 모듈 부재 = 전부 실패, 자명).
|
||||||
|
|
||||||
|
### 6-4. 격리
|
||||||
|
|
||||||
|
실제 `.mam/jobs`·`.mam/delegate_job_logs` 전후 **차이 0 / 차이 0**. 검증 경로 tracked 파일 변화 없음(HEAD `1673818`), 본 체크아웃 `main 8ea65bb`. 실제 세션·herdr 서버 접촉 없음. 단, 위 N1 의 **다른 리뷰어의** 멈춘 래퍼·감시자(pid 11266/12518, `pytest-0/...`)는 내 것이 아니라 그대로 두었다 — 오케스트레이터가 정리할 것.
|
||||||
|
|
||||||
|
## 9. 비차단 관찰
|
||||||
|
|
||||||
|
| # | 관찰 | 근거 |
|
||||||
|
|---|---|---|
|
||||||
|
| M1 | 디스크 heartbeat 유예·broker 재관측 루프의 시계 판정(`FINALIZE_CLOCK=wall`)이 bash 의 결정 | 래퍼 `:902`, `:950`; 항목 4 이전 대상 |
|
||||||
|
| M2 | 감시 여부(remote/human/`MAM_STALL_WATCH`) 판정이 bash | `:684-702`; 항목 4 이전 대상 |
|
||||||
|
| M3 | 감시자 수명이 래퍼의 정상 경로에만 묶여 있고 EXIT trap 이 없다 — N1 의 구조적 배경 | `:370-385`, `:706-719` |
|
||||||
|
| M4 | `MAM_JOB_TIMEOUT_S` 가 잘못된 값일 때 래퍼는 경고, 러너는 무언 | `runner.py:595-600` |
|
||||||
|
| M5 | delegate-job SKILL.md `:100` "before v4.7.5" — 병합 버전은 4.8.0 | 문서 |
|
||||||
|
| M6 | `MAM_WAIT_GRACE_S` 미문서 | `runner.py:608` |
|
||||||
|
| M7 | `MAM_TEST_UNRELIABLE_AGENT` 테스트 훅이 제품 코드에 있음 | `stall_watch.py:228` |
|
||||||
|
| M8 | `test_issue30_stall_watch.py:39` 모듈 마크가 `/proc` 무관 29건까지 skip | §6-1 |
|
||||||
|
| M9 | B3 heartbeating 시험이 공개 브로커 지연에 의존(이 호스트 2/2 실패) | §6-1 |
|
||||||
|
|
||||||
|
버전 락스텝 bump 부재(`lib.sh:32` `4.7.4`, 4.8.0 예정)는 브리프대로 판정에서 제외한다.
|
||||||
|
|
||||||
|
## 10. 교차검증 가이드
|
||||||
|
|
||||||
|
```bash
|
||||||
|
RP=/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20260926T025230Z-a30a6d/1
|
||||||
|
PY=/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.venv/bin/python
|
||||||
|
git -C "$RP" rev-parse HEAD 'HEAD^{tree}' # 1673818… / 00d5e03…
|
||||||
|
# N1: 합동 실행 뒤 남은 감시자와 그 부모 래퍼
|
||||||
|
ps -eo pid=,ppid=,stat=,etime=,args= | grep '[l]ib_py.stall_watch' # 부모 bash 가 __wait4 에서 대기, 잡 레코드 running
|
||||||
|
# 감시자에 TERM → 래퍼가 즉시 풀림 (자기 샌드박스 프로세스에만)
|
||||||
|
# 예산/라벨: --dry-run 으로 timeout_sec 확인, in-process 로 job_budget/job_wait_s/contract_args (§1)
|
||||||
|
# 되돌린 사본: 소스 14파일을 8ea65bb 판으로 → test_issue33_budget.py → 14 failed / 6 passed
|
||||||
|
```
|
||||||
|
|
||||||
|
[VERDICT: NOT PASS]
|
||||||
Loaded 100 of 651 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user