merge: fix/issue-78 (multi-agent-mux-reset skill; registrations refuse during a reset; v4.14.0)

This commit was merged in pull request #82.
This commit is contained in:
Godopu committed 2026-10-30 02:43:30 +00:00
commit 12e140322d
42 files changed
+4529 -60

No files matched your search

@@ -0,0 +1,72 @@
# PR #82 (Issue #78) 리뷰 - 새 루프 3라운드 (b49314c) - planner-reviewer-claude-01
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261029T184637Z-6f4228/3`
- HEAD `b49314c3d039027d3f9478cacecfda587fdc8abc`, tree `d49ff60bbf89437e997f7da21e2fae8df570456b`, base `a3364e8`.
- 리뷰 시작과 끝에서 확인한 것: HEAD·tree 가 [리뷰 identity] 와 같고, `git status --porcelain --ignored` 가 비어 있고, `.mam` 이 없다.
- 브리프는 이전 라운드(4b82e70c)와 identity(head·tree·검증 경로)와 커밋 수만 다르다. diff 는 잘려 있어 스냅샷을 직접 봤다.
- 2라운드(568385c, 내가 PASS) 뒤 새 커밋은 `b49314c` 하나다. 6개 파일, +213/-74.
- `lib_py/delegation/registry.py`, `lib_py/reset.py`, `lib_py/update.py`, `SKILL.md`, `reset.sh`, `tests/test_issue78_reset.py`
- 커밋 메시지에 따르면 codex 4라운드의 R1(다른 reset 의 fence 를 덮어씀, 내 N8 과 같은 지점)과 R2(없는 행 레지스트리를 fence 하지 않음)를 고친 것이다.
- 그 밖의 파일은 바뀌지 않았다. 이전 리포트(57b76c9a, ee7ae43d, c15c85f2, 4b82e70c)의 확인이 그대로 유효하다.
## 이번 변경
| 확인 | 근거(파일:줄) | 결과 |
|---|---|---|
| R1: 다른 live reset 의 fence 를 덮어쓰지 않는가 | `reset.py:492-519` `fence_registries`: registry lock 안에서 `reset_marker(d)` 를 읽고(`registry.py:97-117`), `live`·`unknown` 이면서 pid 가 자기 것이 아니면 `FenceBusy` 를 던진다(`reset.py:510-514`). `stale` 마커만 교체한다. | 덮어쓰지 않는다. 확인과 쓰기가 같은 lock 안이라 두 reset 이 동시에 같은 레지스트리를 잡을 수 없다. 2라운드에서 내가 재현한 N8(B 가 A 의 마커를 덮어쓰고 지움)은 B 가 거부되는 것으로 막힌다. |
| 거부 시 "nothing was changed" 가 맞는가 | `reset.sh:150-164`: `fenced <path>` 줄은 `FENCED` 에 넣고, `refused <msg>` 줄은 메시지로 쓴 뒤 exit 1. EXIT trap(`reset.sh:118-125`)이 `FENCED` 중 pid 가 `$$` 인 마커만 지운다. | 거부 전에 fence 한 자기 레지스트리는 trap 이 지운다. 다른 reset 의 마커는 pid 가 달라 남는다. 새 테스트가 B 의 loop marker·reset marker 도 남지 않음을 본다. |
| 지울 때 경합 | `reset.sh:118-125` 의 확인과 `rm` 은 lock 밖이다. | 내 마커가 live 인 동안 다른 fence 는 그것을 교체하지 못하므로(R1), 확인과 rm 사이에 남의 마커로 바뀌지 않는다. 문제없다. |
| 교착·양쪽 거부 | 디렉터리마다 짧게 lock 을 잡는다. | A 가 D1→D2, B 가 D2→D1 순서로 잡으면 둘 다 FenceBusy 로 거부될 수 있다. 교착은 없고 둘 다 아무것도 바꾸지 않은 채 끝나므로 다시 실행하면 된다. 비차단. |
| `reset_refusal` 리팩터 | `registry.py:120-135` | 판정이 2라운드와 같다. absent·stale 이면 통과하고, sessions 필터, owner pid, unknown 이면 거부, live 이면 거부. 바뀐 것은 owner 확인이 ps 뒤로 간 것뿐이다. reset 자신의 create 에서만 ps 를 두 번 더 부른다. 마커가 없는 일반 등록은 여전히 ps 를 부르지 않는다. |
| R2: 없는 레지스트리도 fence 하는가 | `update.py:150-163` `jobs_dirs` 가 존재 여부와 무관하게 (dir, addressed_only) 를 realpath 로 한 번씩 낸다. `reset.py:507-508` 이 각 dir 을 `os.makedirs` 한 뒤 fence 한다. | 등록도 `registry_lock` 이 `mkdir(parents=True)` 로 디렉터리를 만든다(`mqtt_common.py:359`). 그러므로 없는 레지스트리를 만들고 fence 하는 것이 맞다. 만들 수 없으면 OSError → `refused ...` → reset 거부(fail-closed). |
| `open_jobs` 동작이 바뀌었나 | `update.py:166-185`: `jobs_dirs` 로 열거한 뒤 `_is_jobs_dir` 가 False 면 건너뛴다. | 이전과 같은 디렉터리 집합·순서·중복 제거다(전에는 `_is_jobs_dir` 를 중복 제거 전에 봤지만, 없는 경로끼리 realpath 가 같아도 결과는 같다). `update.sh` 의 precheck 도 이 함수를 쓰므로 `test_issue72_update.py` 를 함께 돌렸다(아래, 통과). |
| SKILL.md 대조 | `SKILL.md:26`의 "created when absent", "다른 작업 공간의 live reset 이 이미 fence 한 공유 레지스트리면 아무것도 바꾸지 않고 거부", "다른 reset 의 마커를 교체·삭제하지 않음" | `reset.py:507-519`, `reset.sh:118-125,150-164` 와 맞다. SKILL.md 에만 있고 코드로 강제되지 않는 규칙은 새로 생기지 않았다. |
## 실행한 테스트 (이 Mac, Python 3.9.6, /bin/bash 3.2)
명령: `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 <본 체크아웃 .venv/bin/python> -m pytest -p no:cacheprovider -q --basetemp=$TMPDIR/j18bt.*/…`
- 리뷰 트리에는 .venv 가 없어 본 체크아웃의 .venv 인터프리터를 썼다. `&` 없이 띄웠다(SIGINT 상속 문제 회피).
- 처음에 파일 이름을 잘못 넣어(`test_issue55_update.py`, 없는 파일) A 묶음이 0건으로 끝났다. 올바른 `test_issue72_update.py` 로 다시 돌린 결과가 아래다. 다시 돌린 A 는 B 와 일부 시간이 겹쳤지만 모두 통과했다.
| 대상 | 결과 |
|---|---|
| A: `test_issue78_reset.py` + `test_issue78_hints_codex.py` + `test_issue42_skill_blocks.py` + `test_issue72_update.py` (시그널 테스트, 새 `[row-absent]`, `test_other_reset_cannot_take_a_live_fence[False/True]` 포함) | **134 passed, 1 skipped** (819 s) |
| B: delegation 8개 파일 (`test_issue5_phase1_delegation.py` `test_issue12_registry_secrets.py` `test_issue14_delivery_ack.py` `test_issue18_liveness_contract.py` `test_issue34_job_tree.py` `test_issue50_onboard.py` `test_tier1_unit.py` `test_issue6_fail_closed.py`) | **249 passed** (635 s) |
`git archive` 사본 mutation (모두 대상 테스트가 실패한다):
| mutation | 실패한 테스트 |
|---|---|
| m1 fence 의 FenceBusy 검사 제거(남의 live 마커를 덮어씀) | `test_other_reset_cannot_take_a_live_fence[False]`, `[True]` (2 failed) |
| m3 없는 행 레지스트리를 건너뜀 | `test_registry_cli_is_refused_after_the_target_recheck[row-absent]`, `test_fence_waits_for_a_registration_and_covers_every_registry_read` (2 failed) |
| m4 stale 마커도 busy 로 봄 | `test_fence_waits_…` (1 failed, stale 교체 검증) |
| m5 reset.sh 가 `fenced ` 접두어를 벗기지 않음(종료 시 마커가 안 지워짐) | `test_registry_cli_is_refused_after_the_target_recheck[canonical]` (1 failed) |
추가 probe (임시 디렉터리, `lib_py.reset fence` 직접 실행):
- terminated 행의 `control_plane.mam_jobs_dir` 가 지워진(쓸 수 있는) 경로를 가리키면: rc 0. 그 경로(`gone-ws/.mam/jobs`, 부모 포함)가 **새로 만들어지고**, reset 이 끝난 뒤에도 빈 디렉터리로 남는다.
- 만들 수 없는 경로(`/nonexistent-root/...`)면: rc 1, `refused the job registries cannot be fenced against new registrations ([Errno 30] Read-only file system: '/nonexistent-root')`. 먼저 fence 한 자기 `.mam/jobs` 는 출력되어 trap 이 지운다.
프로세스: 테스트 앞뒤로 `reconcile.sh`(본 체크아웃 모니터 3714 하나), mock herdr, 구독자 목록이 같다. 바뀐 것은 오케스트레이터의 실제 `herdr agent wait planner-reviewer-claude-01`(70490 → 82377)뿐이며 테스트와 무관하다. 내 probe 의 holder 는 스크립트 안에서 정리했다.
## 발견 사항
### 차단
없음.
### 비차단
- **N11. fence 가 모든 행(stopped·terminated 포함)의 `control_plane.mam_jobs_dir` 를 만들고, 만들 수 없으면 reset 전체를 거부한다.**
- `jobs_dirs`(`update.py:150-163`)는 상태와 무관하게 모든 행을 본다. `fence_registries`(`reset.py:508`)는 각 경로를 `makedirs` 한다.
- 지워진 작업 공간을 가리키는 오래된 행이 있으면, 그 디렉터리 트리가 다시 생기고 남는다(probe 로 확인).
- 만들 수 없는 경로를 가리키는 행이 하나라도 있으면 reset 이 거부된다. 메시지가 어느 행 때문인지 말하지 않는다. 사용자가 그 행을 고치거나 지워야 한다.
- 안전한 쪽(fail-closed)이라 차단으로 보지 않는다. 다만 이 디렉터리에 등록할 수 없는 경우(만들 수 없음)는 등록도 생길 수 없으므로, 거부 대신 건너뛰어도 안전하다. 또 다른 control plane 레지스트리는 대상 행(running·control root)의 것만 fence 해도 대상 보호에는 충분하다.
- **N12. 두 reset 이 서로 다른 순서로 공유 레지스트리를 잡으면 둘 다 거부될 수 있다.** 교착은 없고, 둘 다 아무것도 바꾸지 않는다. 다시 실행하면 된다.
- 2라운드의 N8 은 해소됐다. N9(sessions 필터의 `herdr:` 접두어 정확 비교)는 그대로다. N10(죽은 reset 의 마커)은 이제 어느 작업 공간의 다음 fence 든 stale 마커를 교체하므로 범위가 줄었다.
- 이전 리포트의 N1, N2, N4, N5, N6 은 그대로다.
- 코드 양: reset.py 934줄, reset.sh 349줄. 이번 변경은 `jobs_dirs` 추출로 `open_jobs` 와 fence 의 열거를 하나로 합쳐, 오히려 중복을 줄였다. 불필요한 코드는 찾지 못했다.
## 사용자 확인 필요
- N11: fence 와 open-job 검사가 stopped·terminated 행의 control plane 레지스트리까지 보고 만드는 것이 의도인지. 아니면 만들 수 없는 경로는 건너뛰거나 대상 행으로 좁힐지.
- 수동 stop 이 pre-assigned 행의 id 를 폴더 전체에서 추정하는 동작(이전 리포트 3번)을 별도 이슈로 다룰지.
- N6: agy 에도 폴더 소유 확인이 필요한지.
- 병합 뒤 main 에 `.claude/skills/multi-agent-mux-reset` 링크를 손으로 만들어야 한다.
[VERDICT: PASS]
@@ -0,0 +1,163 @@
# PR #82 / Issue #78 — b49314c 리뷰: PASS
잡 `035792f2` 리뷰 결과다. 이전 리뷰의 **공유 live fence 덮어쓰기(R1)**와 **미생성 control_plane registry 누락(R2)**는 모두 해소됐다. 원래 실패를 일으켰던 공개 등록 CLI 탐침으로 확인했고, 이번 검토에서 차단 결함을 찾지 못했다.
## Identity·검토 범위
- Loop `loop-20261029T184637Z-6f4228`, round 3.
- Base `a3364e82e1f9e9c6ac504eef377e8dbefba02b8d`.
- Head `b49314c3d039027d3f9478cacecfda587fdc8abc`.
- Tree `d49ff60bbf89437e997f7da21e2fae8df570456b`.
- 검증 경로 `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261029T184637Z-6f4228/3`.
- 기준은 읽기 전용 `tea issues 78 --comments`, `tea pulls 82`, `INSTRUCTION.md`이다. 누적 22파일과 직전 `568385c..HEAD`의 6파일 변경을 실제 snapshot에서 확인했다. 이전에 대조한 누적 reset/adapter/배포 코드와 이번 변경을 함께 검토했으며, truncated summary만으로 판단하지 않았다.
- 파일:줄은 검증 경로 기준이다. creator 작업 트리에서 실행·수정하거나 커밋/브랜치 변경을 하지 않았다.
## 이전 차단 사항의 판정
### R1 — 공유 live fence 덮어쓰기: 해소
- `.agents/skills/lib_py/delegation/registry.py:97`의 reset_marker가 absent/stale/live/unknown 판정을 공유한다.
- `.agents/skills/lib_py/reset.py:507`–`:518`은 각 registry_lock 안에서 기존 marker를 확인한다. 다른 PID의 live 또는 unknown owner이면 FenceBusy를 발생시키고 기존 marker를 교체하지 않는다.
- `reset.sh:154`–`:163`은 fence가 성공한 경로와 거부 사유를 구분한다. EXIT cleanup(:117–124)은 이미 얻은 자신의 marker만 해제한다.
- 체크인 테스트 `test_other_reset_cannot_take_a_live_fence[False/True]`는 B에 대상이 없는 경우/있는 경우를 모두 검증한다. A의 fence bytes 유지, B의 행·대화 보존, B marker cleanup, B 종료 후 외부 등록 거부 및 A 정상 완료를 확인한다.
**별도 재현:** [probe_shared_reset.py](evidence/probe_shared_reset.py), [shared.log](evidence/shared.log).
임시 A의 stop을 마지막 job 검사 뒤 gate에서 대기시킨 채, 같은 MAM_JOBS_DIR를 쓰는 독립된 임시 B에서 원본 reset.sh --yes를 실행했다. B rc=1, `fenced by a reset of A` 사유, 공유 marker bytes 동일, A는 계속 살아 있다. 이어 외부 public registry.py register도 rc=1로 거부돼 잡이 생기지 않았고 A는 rc=0으로 정상 완료했다. 이전 탐침에서 B가 rc0으로 marker를 제거하던 동작이 사라졌다.
추가로 ps 판정 불가를 주입한 탐침에서 신규 register/API와 새 fence 획득 모두 거부됐고 기존 foreign marker bytes가 보존됐다([unknown.log](evidence/unknown.log)). stale marker 교체 및 registry lock barrier는 체크인 테스트로 확인했다.
### R2 — 아직 없는 행 지정 registry가 fence에서 빠짐: 해소
- `.agents/skills/lib_py/update.py:150`–`:163`의 jobs_dirs는 존재 여부와 무관하게 canonical·MAM_JOBS_DIR·행의 control_plane.mam_jobs_dir를 realpath 기준으로 중복 제거하고 addressed_only 범위를 반환한다.
- `reset.py:507`–`:509`가 같은 jobs_dirs를 사용해 없는 디렉터리도 만들고 lock/fence를 획득한다.
- `update.py:166`–`:184`의 open_jobs도 같은 열거 함수를 사용한다. 읽기 검사는 없는 디렉터리를 계속 건너뛰고, 기존 unreadable-directory/record 거부 및 타 제어 평면의 addressed-only 의미를 보존한다.
**별도 재현:** [probe_absent_row_registry.py](evidence/probe_absent_row_registry.py), [absent.log](evidence/absent.log).
reset 시작 전에 target 행에 아직 없는 `<임시 WS>/elsewhere/jobs`를 control_plane.mam_jobs_dir로 기록하고, MAM_JOBS_DIR는 canonical 그대로 뒀다. 마지막 target 검사 뒤 같은 public CLI로 그 경로에 등록을 시도했다. 이번에는 rc=1, `a reset of ... is in progress`로 거부돼 새 잡이 없고 reset은 rc=0으로 끝났다. 중간 행 변경이나 raw 잡 JSON 쓰기를 사용하지 않았다.
체크인 `test_registry_cli_is_refused_after_the_target_recheck[row-absent]`와 fence의 absent 경로 생성·stale 교체·realpath dedupe 테스트도 통과했다. update의 open/terminal/dry-run, unreadable jobs directory, 타 control-plane 대상 필터 회귀 3건도 통과했다.
위 두 탐침의 `old_transcript_exists=false`는 외부 등록이 거부된 뒤 사용자가 승인한 정상 reset이 완료된 결과다. pending 잡을 허용한 상태로 대화를 지우던 이전 실패와 구분된다.
### 이전 라운드 보호의 유지
- canonical/alias/custom 공개 등록 거부는 체크인 매개변수 테스트로 재검증했다.
- `reset.py:522`의 target_id_owned는 post-discovered ID의 cwd 소유권을 기존 verify_session_uuid(revalidate)로 확인한다. `reset.sh:283`에서 false이면 purge 대신 skip한다.
- 이전 foreign Codex 탐침 재실행 결과는 **A 검증 false / B 검증 true / 외부 파일 보존 / reset rc4 / purge 명령 없음**이다([foreign.log](evidence/foreign.log)). own/foreign 체크인 대조도 통과했다.
- strict spec 필드·타입·agent·workspace·중복 이름 검사, row agent/server 비교, pre-assigned ID 없는 행 거부, captured old ID 저장, created/done 미재purge, 시그널·중간 실패·rc4·rc5 재실행 보호가 유지됐다.
## 제안 1–6 및 SKILL.md 주장 대조
| 기준/문서 줄 | 실행 코드·테스트 근거 | 판정 |
|---|---|---|
| 제안1, SKILL:3·23 — root running, worktree/non-running/orchestrator 제외, metadata 유지 | reset.py:105–178 select_targets; reset.sh:306–317 create 인자; 선택/realpath/제외 행/2-target 정상 E2E | 충족 |
| 제안2, SKILL:25 — 열린 잡·loop·update·자신·invalid agent/role·ID/spec 사전 거부 | reset.py:424–473; reset.sh:38–40·98–174; 각 거부·동시 admission 테스트 | 충족 |
| 제안3, SKILL:17–18·24 — parser·확인·dry-run 무변경 | reset.sh:27–79·177–203; 비TTY rc3, dry-run .mam 파일무변경 | 충족. TTY 해석은 아래 사용자 확인 사항 |
| 제안4, SKILL:27 — 첫 stop 전에 원자 spec, 재개 | reset.py:183–272 mkstemp/fsync/replace·strict schema; :275–353 record_old_id/classify_entry; reset.sh:203·228–230·337 | 충족. 5-state, 같은 ID resume, 중단 후 재실행 |
| 제안4, SKILL:28 — 순차 stop → recorded/owned ID → purge → create/onboard | reset.sh:217–323, reset.py:522–537; 기존 stop/create 재사용 | 충족, 병렬 create 없음 |
| 제안5, SKILL:29 — changed/open job/no ID/unverified/in-use skip, 외부 holder 미종료 | reset.sh:233–302, reset.py SKIP_RESULTS; skip·파일/행 보존·rerun 테스트 | 충족 |
| 제안6, SKILL:33–39 — 결과 표와 exit0/1/2/3/4/5, INT/TERM/HUP | reset.py:599–683 build_table, reset.sh trap:126–134·결과:325–349 | 충족. 원본 Mac signal 테스트 사용 |
| SKILL:26·47 — update admission → loop/reset marker → release; run_loop/update/다른 reset 거부; configured registry/alias·owner onboarding·stale 표식 | reset.sh:98–174·317; registry.py:97–135·232 이후; reset.py:492–519; canonical/alias/custom/absent·shared live fence·barrier·owner/stale | 충족. 이번 R1/R2 해소 |
| 함께 고칠 것, SKILL:44 — lsof PID/명령줄·기존 ps 안내·서비스 제외·외부 미종료 | agents/base.py:89–115·385–403, agy in_use_paths, opencode _is_shared_service/_holder_counts | 충족. lsof 부재·timeout·holder·서비스 제외, 실제 Mac lsof 탐침 |
| 함께 고칠 것 — Codex capability UUID leaf만 삭제 | adapters/codex.py:134–179, plausible ID/leaf/symlink 처리, delete 성공·실패 및 타 UUID 보존 | 충족 |
| SKILL:43 — 비가역·먼저 dry-run | 도움말/동의 및 dry-run 분리 | 운영 권고. 과거 dry-run 실행 이력을 강제하는 규칙은 아님 |
| SKILL:45 — Grok shared memory 보존 | 기존 Grok의 세션별 제거 유지, purge/adapter 회귀 | 충족. 메모리 삭제 옵션 추가 없음 |
| SKILL:46 — AGY edit hook | hooks/loop_delegation_guard.sh:51과 reset.sh:110이 같은 loop marker 사용 | 코드 일치 |
| SKILL:48 — 같은 종류 post-discovered 순차 ID 대기 | create의 기존 MAM_ID_ORDER_WAIT_S/adapter ordering, #63 회귀 | 코드 일치 |
명령 예시, 옵션/동작 표, exit 표, safety notes를 코드와 대조했다. 순서·사전 거부·원자 저장·잠금·정리는 스크립트에 있고 테스트로 고정된다. 이번 검토에서 SKILL에만 있고 실행 코드에 빠진 기계적 규칙을 찾지 못했다. 수기 잡 파일 쓰기는 등록 프로토콜 밖이므로 문서대로 target 재검사 때 이미 보이는 기록만 검출한다. 이 문구를 임의 파일 쓰기의 원자적 admission 보장으로 확대하지 않았다.
## 일반 registration·remote·update 영향과 단순화
- marker 없는 registry에서 register_job 10회 + remote mirror/replay 2회 동안 subprocess.run을 금지한 탐침이 통과했다. **일반 경로의 ps/subprocess 호출은 0회**다([fast.log](evidence/fast.log)).
- live fence에서 remote.mirror_job은 ResetInProgressError를 처리해 None을 반환하고 잡 JSON/brief 디렉터리를 만들지 않는다. 기존 registry stdout/CLI/import/환경 계약과 remote finished/newer attempt 재전송도 통과했다.
- 기존과 달리 live marker의 owner 예외·타 session 범위 판단 전에 reset_marker가 liveness를 조회한다. 따라서 marker가 존재하는 registry에서는 최대 두 ps 호출이 있을 수 있다. reset이 없는 보통 등록의 비용 증가와 구분해야 하며 차단 사유는 아니다.
- addressed-only 표식이 있는 타 control-plane registry에서 covered session은 거부하고 unrelated session은 등록되는 별도 탐침도 통과했다([unknown.log](evidence/unknown.log)).
- 이전 위험한 중복이었던 registry 경로 열거가 jobs_dirs 한 함수로 합쳐졌다. reset은 없는 경로에 보호를 설치하고 update는 읽기만 하는 차이는 각 호출부에 남아 적절하다. 순서/필터를 바꾸는 불필요한 재설계는 아니다.
- reset.py 934줄/reset.sh 349줄이라는 양 자체를 결함으로 세지 않았다. 기존 stop/create·verify_session_uuid·open_jobs 재사용이 유지된다. reset.py:54–100의 read-only state 로더는 실제 SQLite -shm까지 바꾸지 않는 dry-run 요구 때문에 복사본을 읽는다. 환경 JSON만 읽는 update._state(:375)로 단순 치환할 수 없으며 추가 추상화는 승인 조건이 아니다.
## 패키징·버전
install.sh의 일반 .agents 복사(:367)와 reset 도움말(:673), remove.sh:96 fallback, gitea-ci.yml:45 ShellCheck 대상, 두 README:60, code-block inventory 및 10-skill layout을 확인했다. detached snapshot에 실제 .claude/skills가 없어 관련 1건은 skipped이며 합성 layout 검사는 통과했다. SKILL version과 lib.sh MAM_VERSION은 4.13.0으로 일치한다. test_version_consistency 목록과 VERSIONS 매트릭스는 브리프대로 릴리스 때 오케스트레이터가 넣을 사항으로 확인했고 누락 결함으로 세지 않았다.
## Mac 실행·정적 검사
Darwin arm64, /bin/bash 3.2.57, 지정 제어 루트 venv Python 3.9.6. 검증 tree에는 venv가 없어 브리프 상단의 명시적 인터프리터를 썼다. 모든 pytest cwd는 검증 경로다. 전체 스위트는 실행하지 않았다.
공통 명령:
```bash
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 -u AGENT_SESSIONS_YAML \
PYTHONDONTWRITEBYTECODE=1 \
/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.venv/bin/python \
-m pytest -p no:cacheprovider --basetemp=<TMPDIR/아래 이름> -q <선택>
```
| basetemp 이름 (TMPDIR 아래) | 선택 파일/옵션 | 결과 |
|---|---|---|
| mam-eval-035792f2-main | tests/test_issue78_reset.py tests/test_issue78_hints_codex.py tests/test_issue42_skill_blocks.py | **84 passed, 1 skipped, 1 session-teardown error / 700.03s**. [main.log](evidence/main.log) |
| mam-eval-035792f2-purge | tests/test_issue10_13_purge.py tests/test_issue31_purge_residual.py tests/test_a4_adapter_contract.py tests/test_issue63_same_cwd_ids.py | **150 passed / 282.73s**. [purge.log](evidence/purge.log) |
| mam-eval-035792f2-contracts | 아래 9 node, 등록 node 양 mode 포함 | **10 passed / 16.38s**. [contracts.log](evidence/contracts.log) |
| mam-eval-035792f2-shared | -s -p probe_shared_reset tests/test_issue78_reset.py | **1 passed / 33.96s**. [shared.log](evidence/shared.log) |
| mam-eval-035792f2-absent | -s -p probe_absent_row_registry tests/test_issue78_reset.py | **1 passed / 32.64s**. [absent.log](evidence/absent.log) |
| mam-eval-035792f2-foreign | -s -p probe_foreign_id tests/test_issue78_reset.py | **1 passed / 20.60s**. [foreign.log](evidence/foreign.log) |
| mam-eval-035792f2-fast | -s -p probe_registry_paths tests/test_issue78_reset.py | **2 passed / 0.19s**. [fast.log](evidence/fast.log) |
| mam-eval-035792f2-native | -s -p probe_native_holders tests/test_issue78_reset.py | **2 passed / 0.28s**. [native.log](evidence/native.log) |
| mam-eval-035792f2-unknown | -s -p probe_unknown_fence tests/test_issue78_reset.py | **2 passed / 0.16s**. [unknown.log](evidence/unknown.log) |
일반 선택 회귀는 **244 passed, 1 skipped, 1 session-teardown error**, 별도 외부 탐침은 **9 passed**다. 같은 사례를 반복 실행해 합산하지 않았다.
종료 오류 분석용 재실행(중복 합산 안 함):
- `mam-eval-035792f2-layout-rerun`: `tests/test_issue42_skill_blocks.py::test_claude_skills_layouts[broken_entry-False]` — **1 passed / 0.04s**.
- `mam-eval-035792f2-agy-rerun`: `tests/test_issue78_hints_codex.py::test_in_use_hints_list_lsof_holders[agy]`, `tests/test_issue78_reset.py::test_post_discovered_without_recorded_id_8b_and_exit_5` — **2 passed / 64.39s**.
contracts의 node 목록:
```text
tests/test_issue5_phase1_delegation.py::test_c05_submit_job_stdout_is_exactly_the_job_id
tests/test_issue5_phase1_delegation.py::test_registry_cli_contract_snapshot
tests/test_issue5_phase1_delegation.py::test_delegation_package_imports_no_sibling_lib_py_modules
tests/test_issue5_phase1_delegation.py::test_importing_delegation_does_not_mutate_environment
tests/test_issue18_liveness_contract.py::test_remote_runner_ignores_a_resent_dispatch_of_a_finished_attempt
tests/test_issue18_liveness_contract.py::test_remote_runner_never_rolls_a_newer_attempt_back
tests/test_issue72_update.py::test_open_jobs_refuse_and_terminal_or_dry_run_do_not
tests/test_issue72_update.py::test_a_jobs_directory_that_cannot_be_listed_refuses
tests/test_issue72_update.py::test_other_control_planes_count_only_jobs_addressed_to_this_registry
```
외부 플러그인은 명령 앞에 `PYTHONPATH=<evidence 절대 디렉터리>`를 더하고 `-s -p <probe 모듈> tests/test_issue78_reset.py`로 선택한다. probe 파일은 collection을 해당 탐침으로 바꾸며 snapshot의 fixtures/helpers를 그대로 쓴다. shared 탐침은 이전 실패 때의 B rc0 기대만 이번 보호 정책인 rc1/동일 marker bytes 기대로 바꿨고, 외부 register·실제 reset 동작은 유지했다. 제품 코드/체크인 테스트는 수정하지 않았다.
변경 Python 11파일 ast.parse(feature_version=(3,9)), lib.sh/reset.sh/delegate wrapper 각각 /bin/bash -n, git diff --check가 통과했다([static.json](evidence/static.json)). 새 reset 경로에 mapfile/declare -A/flock(1)/setsid(1)/proc, GNU 전용 sed -i/stat -c/readlink -f 의존을 추가하지 않았다. 빈 배열·trap은 native 동작 테스트로 확인했다. 실제 Mac lsof가 자신이 만든 holder PID를 찾았고, timeout 주입 시 10초 상한과 빈 추가 힌트를 확인했다. ShellCheck는 설치되어 있지 않아 실행하지 않았다.
## 세션 종료 guard 오류와 판정 한계
main 실행은 기능 단언 **84 passed, 1 skipped**였지만 세션 종료의 기존 agent_home_leak_guard가 **1 error**를 냈다. 따라서 이 명령의 exit code는 **1**이며, 오류 없는 전체 스위트 통과라고 보고하지 않는다([main.log](evidence/main.log)).
감시기는 실제 AGY 홈에서 `637fea1e-9288-4a72-857e-7d23e04bb12d.db-wal`과 `.db-shm`의 새 디렉터리 항목을 발견했다. 기존 conftest.py:1582–1598도 병행 실제 agent가 새 파일을 만들면 false positive가 가능하다고 설명한다. 오류는 마지막 레이아웃 사례에 붙었지만 그 사례는 tmp_path의 symlink layout만 만든다.
- DB를 SQLite로 열거나 수정하지 않고 lsof의 PID와 ps의 실행 파일·경과 시간만 확인했다. 보유자는 실제 `agy` PID **66608**(약 11시간 실행), **91891**(약 34분 실행)으로, 11분 40초의 main 실행보다 먼저 시작한 외부 프로세스다([ambient-holder.json](evidence/ambient-holder.json)).
- main basetemp 안에는 해당 UUID의 artifact가 없었다([ambient-basetemp.json](evidence/ambient-basetemp.json)). AGY reset 시험은 UA 상수와 임시 HOME을 쓰며, hints 시험은 임시 home/HOME_DIR/ctx와 mock lsof를 쓴다.
- 마지막 레이아웃 node 단독 재실행은 **1 passed / 0.04s**, 종료 guard도 통과했다([layout-rerun.log](evidence/layout-rerun.log)).
- AGY hints와 post-discovered stop/purge/create/재실행 경로의 선택 재실행: **2 passed / 64.39s, 종료 guard 통과**([agy-rerun.log](evidence/agy-rerun.log)). 재실행 수는 일반 회귀 합계에 중복 합산하지 않았다.
위 프로세스·fixture·재실행 증거로 이 종료 오류는 **외부 AGY 동시 활동에 따른 환경 감시 false positive로 판단한다(추론)**. 테스트 누수라고 단정하거나 실제 홈 파일을 제거하는 방식으로 감시기를 통과시키지 않았다. 제품의 이번 변경에 의한 기능 실패는 없어 PASS지만, main 명령 자체의 종료 오류는 그대로 증거와 함께 남긴다.
## 발견 사항·사용자 확인 필요
- **차단 사항 없음.** 이전 R1/R2는 해소됐으며 재계획이 필요하지 않다.
- 비차단: marker가 남아 있는 경로의 liveness 조회 비용은 위 설명과 같다. 추가 작업을 승인 조건으로 삼지 않는다.
- 순차 create, Grok 메모리 옵션 없음, stopped 초기 대상 제외는 작성자의 선택대로 검토했다.
- 이슈의 “--yes 없이는 실행하지 않는다”와 TTY 명시적 y/yes 동의 방식의 차이는 이전 판정처럼 사용자 확인 사항이다. 동의를 받는다는 의미로 검토했으며 flag 자체 강제를 요구한 것으로 보아 차단하지 않았다.
## 격리·종료 확인
실제 작업 공간에 reset/dry-run/purge를 실행하지 않았다. 파괴적 동작은 임시 HOME·자체 .mam·mock herdr 안에서만 수행했다. 실제 .mam.env 값 출력/export, 실제 herdr 서버/설정 변경, SQLite로 실제 에이전트 홈 DB 열기, 실제 opencode 서비스 제어를 하지 않았다. guard 오류 분석의 실제 홈 확인은 위 PID/실행 파일·나이 메타데이터뿐이며 DB 내용은 읽지 않았다. pkill/killall/패턴 kill을 쓰거나 실제 모니터에 신호를 보내지 않았다. native holder 탐침은 자신이 만든 Popen만 회수했다.
이번 basetemp에 속한 테스트 잔류 프로세스는 0개, 실제 모니터는 전후 PID 3714로 같았다([process-audit.json](evidence/process-audit.json)). 시작·종료 HEAD/tree는 위 identity와 일치했고 git status는 빈 출력이다. 소스/브랜치/이슈·PR 상태 변경, push/merge는 없었다. 제어 평면 쓰기는 승인된 lifecycle 이벤트와 리포트·증거뿐이다.
[VERDICT: PASS]
@@ -0,0 +1,14 @@
{
"loop_id": "loop-20261030T002908Z-7d0414",
"base_sha": "a3364e82e1f9e9c6ac504eef377e8dbefba02b8d",
"head_sha": "b49314c3d039027d3f9478cacecfda587fdc8abc",
"tree_sha": "d49ff60bbf89437e997f7da21e2fae8df570456b",
"review_commit": "b49314c3d039027d3f9478cacecfda587fdc8abc",
"reviewer": "reviewer-agy-01",
"job_id": "8eb66b73",
"verdict": "PASS",
"transport": "herdr",
"evidence": "review-tree",
"isolation": "worktree",
"verified_at": "2026-10-30T00:44:15Z"
}
@@ -0,0 +1,107 @@
# Code Review Report — PR #82 (Issue #78) — Loop 3 Iteration 1 (`@b49314c3`)
- **Job ID**: `8eb66b73`
- **Reviewer**: `agy` (`herdr:reviewer-agy-01`)
- **Loop ID**: `loop-20261030T002908Z-7d0414`
- **Review Snapshot**: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261030T002908Z-7d0414/1`
- **Commit Range**: `a3364e82e1f9e9c6ac504eef377e8dbefba02b8d..b49314c3d039027d3f9478cacecfda587fdc8abc` (11 commits)
- **Tree**: `d49ff60bbf89437e997f7da21e2fae8df570456b`
---
## 1. 확인한 것 (파일:줄)
### 1.1 `568385c7` 이후 `b49314c3` 변경분 검증 (R1: 다른 live reset의 fence 침범 금지, R2: 아직 존재하지 않는 행 레지스트리 fence)
- **`.agents/skills/lib_py/delegation/registry.py:97-136`**:
- `reset_marker(registry_dir)`(`97-117`)가 `<registry_dir>/.reset-in-progress` 마커 상태를 `"absent"`(파일 없음), `"stale"`(`pid` 숫자 아님 / 프로세스 종료 / `lstart` 불일치), `"live"`(동일 `pid` 및 `lstart`), `"unknown"`(파일 읽기 불가 `OSError`/`ValueError` 또는 현재 프로세스에 대한 `ps` 실패)으로 판별.
- 일반 잡 등록 경로(reset 미실행 시)에서는 `open(marker)`가 즉시 `FileNotFoundError`를 일으켜 `"absent"`로 반환되므로 **`ps` 서브프로세스 호출이 전혀 발생하지 않음**(성능 영향 없음).
- `reset_refusal(registry_dir, agent_session)`(`120-136`)은 `with registry_lock(registry_dir):`(`150`) 내부에서 호출되며, `"absent"`/`"stale"`이거나 `sessions=`에 해당 세션이 없거나 `MAM_RESET_OWNER_PID == fields["pid"]`(reset 자신의 `create_session.sh` 온보딩 잡)인 경우 통과시키고, `"unknown"` 및 `"live"`는 거부 사유를 반환.
- **`.agents/skills/lib_py/update.py:188-238`**:
- `jobs_dirs(workspace, state, env)`(`188-202`)가 `<ws>/.mam/jobs`, `MAM_JOBS_DIR` (`addressed_only=False`), 그리고 각 행의 `control_plane.mam_jobs_dir` (`addressed_only=True`)를 **디렉터리 존재 여부와 무관하게** `_real(c)` 기준으로 중복 제거하여 반환.
- `open_jobs(workspace, state, env)`(`205-238`)와 `fence_registries`(`reset.py:492-521`)가 동일한 `jobs_dirs()` 열거 함수를 공유하여 검사 대상과 fence 대상이 정확히 일치.
- **`.agents/skills/lib_py/reset.py:488-521, 887-900` 및 `.agents/skills/multi-agent-mux-reset/scripts/reset.sh:117-125, 150-175`**:
- `fence_registries`가 `jobs_dirs`의 각 디렉터리에 대해 `os.makedirs(d, exist_ok=True)`를 수행한 뒤(존재하지 않던 `control_plane.mam_jobs_dir`도 생성하여 fence), `with registry_lock(d):` 아래에서 `reset_marker(d)`를 먼저 확인.
- 이미 다른 live 또는 unknown reset(`held in ("live", "unknown") and fields.get("pid") != me`)이 fence를 보유하고 있으면 마커를 덮어쓰지 않고 `FenceBusy`를 발생시켜 거부(`refused ...`).
- 자신이 새로 쓴 디렉터리만 `fenced <realpath>`로 출력하고 `reset.sh`의 `FENCED` 배열에 기록하며, `_release_reset_lock`(`reset.sh:118-125`)은 `FENCED`에 기록된 경로 중 `pid == $$`인 마커만 제거하므로 다른 워크스페이스의 live reset이 잡은 공유 레지스트리 fence를 절대 뺏거나 지우지 않음.
### 1.2 제안 1–6 및 "함께 고칠 것" 대조표
| 항목 | 명세 요구사항 (Issue #78) | 구현 위치 (`파일:줄`) | 테스트 커버리지 | 상태 |
|---|---|---|---|---|
| 제안 1 | `multi-agent-mux-reset` 스킬 신설 (`SKILL.md`, `scripts/reset.sh`, `lib_py/reset.py`) | `.agents/skills/multi-agent-mux-reset/SKILL.md:1-48`, `scripts/reset.sh:1-349`, `.agents/skills/lib_py/reset.py:1-934` | `tests/test_issue78_reset.py`, `tests/test_issue42_skill_blocks.py` | 충족 |
| 제안 2 | 대상 선택 (`status: running` + `realpath(pane.cwd) == realpath(WS)` 만 포함; `stopped`/`terminated`, 다른 폴더/worktree, `orchestrator_uuids` 제외) 및 필터(`--session`, `--role`, `--agent`, `--all`) | `lib_py/reset.py:157-249` (`select_targets`), `reset.sh:29-68` | `test_select_targets_and_plan_cli` | 충족 |
| 제안 3 | 사전 확인 및 잠금(update admission -> `loop-guard-active` & `reset-in-progress` -> job registry fence -> open job / 호출자 자신 / 미기록 claude·grok ID 거부, `[y/N]` 프롬프트 및 `--yes`/`--dry-run`) | `reset.sh:81-208`, `lib.sh:198-214` (`mam_reset_admit`), `lib_py/reset.py:488-589`, `lib_py/delegation/registry.py:97-160`, `lib_py/delegation/remote.py:114-116` | `test_no_yes_non_tty_exits_3_without_touching_locks`, `test_dry_run_changes_nothing_in_mam_tree`, `test_precheck_refusals_change_nothing`, `test_registry_cli_is_refused_after_the_target_recheck`, `test_other_reset_cannot_take_a_live_fence` | 충족 |
| 제안 4 | `.mam/reset-spec.json` 원자적 기록(`0600`) 및 중단/시그널/실패(`exit 4`, `exit 5`) 후 재실행 시 이미 재생성된 에이전트 재-purge 방지 | `lib_py/reset.py:252-449` (`write_spec`, `read_spec`, `entries`), `reset.sh:210-348` | `test_spec_file_write_read_and_validation`, `test_entries_5_states_and_resumed_row_9b`, `test_mid_run_failure_and_rerun_resumes_from_spec`, `test_signal_interrupts_reset_preserves_spec_and_holds_lock_until_child_exits` | 충족 |
| 제안 5 | 에이전트별 순차 실행: ⓪ 대상 재확인(`target-check`) -> ① `stop_session.sh --reason reset` (post-discovered ID 포착) -> ② ID 일치 및 폴더 소유 검증(`id-owned`) -> ③ `stop_session.sh --reason reset --purge-conversation --yes` -> ④ `create_session.sh` | `reset.sh:218-323`, `lib_py/reset.py:645-736` (`target_check`, `record_id`, `target_id_owned`) | `test_full_reset_success_spec_ordering_table_and_loop_block`, `test_post_discovered_without_recorded_id_8b_and_exit_5`, `test_post_discovered_id_is_purged_only_when_it_verifies_for_its_folder` | 충족 |
| 제안 6 | 종료 코드 표(`0`, `1`, `2`, `3`, `4`, `5`) 및 결과 요약 테이블 출력 | `SKILL.md:31-40`, `reset.sh:325-348`, `lib_py/reset.py:739-782` | `test_exit_table_agrees_with_script`, `test_purge_refused_exit_4_preserves_stopped_row_and_rerun_completes`, `test_post_discovered_uncapturable_id_8b_ii` | 충족 |
| 함께 고칠 것 1 | `in_use_hints`에서 `lsof` holder PID·명령어 표시 (`opencode` 백그라운드 서비스 제외) | `.agents/skills/lib_py/agents/base.py:268-310`, `adapters/agy.py:136-138`, `adapters/opencode.py:259-273` | `tests/test_issue78_hints_codex.py::test_in_use_hints_*`, `test_stop_purge_refusal_prints_holders` | 충족 |
| 함께 고칠 것 2 | `codex` `purge_artifacts` 및 `residual_artifacts`에 `~/.codex/tui-thread-reference-capabilities/<uuid>` 포함 | `.agents/skills/lib_py/agents/adapters/codex.py:420-467` | `tests/test_issue78_hints_codex.py::test_codex_purge_removes_tui_thread_capability`, `tests/test_a4_adapter_contract.py::test_codex_purge_artifacts` | 충족 |
| 패키징·문서 | `deploy/install.sh`, `deploy/remove.sh`, `deploy/gitea-ci.yml`, `lib_py/update.py`, `README.md`, `README.ko.md`, `docs/SKILLS_SYNC.json` 반영 | `deploy/install.sh:23,121,164`, `deploy/remove.sh:12`, `deploy/gitea-ci.yml:69`, `lib_py/update.py:38`, `README.md:107`, `README.ko.md:107` | `tests/test_issue42_skill_blocks.py`, `tests/test_deploy_layout.py` | 충족 |
### 1.3 `INSTRUCTION.md` 기준 `SKILL.md` 줄 단위 대조
- `.agents/skills/multi-agent-mux-reset/SKILL.md:1-48`의 모든 문장을 `reset.sh` 및 `lib_py/reset.py`와 줄 단위로 대조함:
- 진입점 단일 호출(`bash .agents/skills/multi-agent-mux-reset/scripts/reset.sh ...`), 옵션(`--yes`, `--dry-run`, `--session`, `--role`, `--agent`, `--all`), 대상 선택 규칙, 확인 프롬프트, 사전 확인 거부 조건, 잠금·동시성 제어(`update` admission lock, `loop-guard-active`, `reset-in-progress`, job registry `.reset-in-progress` fence, `MAM_RESET_OWNER_PID`), `.mam/reset-spec.json` 재실행 복구, 순차 실행 단계(⓪~③ 및 `create_session.sh`), 건너뜀(`exit 4`) 및 중단(`exit 5`), 종료 코드(`0`~`5`), 플레이스홀더(`0.0.0-dev`) 모두 스크립트/파이썬 코드와 1:1로 일치하며 **SKILL.md에만 있고 코드로 강제되지 않는 규칙은 없음**.
### 1.4 macOS 이식성 확인
- `/bin/bash` 3.2 호환성: `reset.sh` 및 `lib.sh`에 `mapfile`, `readarray`, `declare -A`, `sed -i`, `stat -c`, `readlink -f` 미사용. `set -uo pipefail` 아래 빈 배열 확장 시 `${FENCED[@]+"${FENCED[@]}"}` 패턴(`reset.sh:120`)을 사용하여 bash 3.2 `nounset`에서도 안전함.
- Python 3.9.6 호환성: `lib_py/reset.py`, `lib_py/delegation/registry.py`, `lib_py/update.py` 모두 `Optional[...]` / `Tuple[...]` / `List[...]`(`typing`)만 사용하며 Python 3.10+ 전용 `X | Y` 타입 유니온이나 `match` 문을 사용하지 않음.
---
## 2. 실행한 테스트와 결과 (명령·passed/failed)
검증 경로 `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261030T002908Z-7d0414/1`에서 아래 명령을 실행함:
1. **셸 문법 검사**:
```bash
cd /var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261030T002908Z-7d0414/1 && \
/bin/bash -n .agents/skills/multi-agent-mux-reset/scripts/reset.sh .agents/skills/lib.sh deploy/install.sh deploy/remove.sh
```
- 결과: **exit 0**
2. **Issue #78 및 Skill Blocks 테스트 스위트**:
```bash
cd /var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261030T002908Z-7d0414/1 && \
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=/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-pytest-8eb66b73 \
-v tests/test_issue78_reset.py tests/test_issue78_hints_codex.py tests/test_issue42_skill_blocks.py
```
- 결과: **84 passed, 1 skipped** in `699.23s` (exit 0)
- `b49314c3` 신규/보강 케이스 모두 통과:
- `test_registry_cli_is_refused_after_the_target_recheck[canonical/alias/custom/row-absent]` (`4 passed`)
- `test_fence_waits_for_a_registration_and_covers_every_registry_read` (`PASSED`)
- `test_other_reset_cannot_take_a_live_fence[False/True]` (`2 passed`)
3. **Deploy Layout / Adapter Contract / Purge 관련 스위트**:
```bash
cd /var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261030T002908Z-7d0414/1 && \
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=/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-pytest-8eb66b73-2 \
-v tests/test_deploy_layout.py tests/test_a4_adapter_contract.py tests/test_issue10_13_purge.py tests/test_issue31_purge_residual.py
```
- 결과: **128 passed** in `71.65s` (exit 0)
4. **작업 트리 상태 및 잔여 프로세스 확인**:
- 임시 `--basetemp` 디렉터리 정리 완료.
- `git status --short`: 빈 출력(깨끗함).
- `ps -ef | grep -E 'reconcile\.sh|reset\.sh'`: 본 체크아웃의 기존 모니터 데몬(`PID 3714`) 1개만 유지되고 테스트 잔여 프로세스 없음.
---
## 3. 발견 사항
### 차단 (Blocking)
- 없음.
### 비차단 (Non-Blocking)
- 코드 양 및 중복: `lib_py/reset.py:139-145`의 `_real()` 헬퍼와 `lib_py/update.py:140-144`의 `_real()` 헬퍼가 동일한 역할(`os.path.realpath(os.path.abspath(p))`)을 수행하므로 추후 리팩터링 시 공통 유틸로 합칠 수 있음(동작·안전성에는 영향 없음).
---
## 4. 사용자 확인 필요
- 이슈 #78 본문의 "정할 것" 3가지에 대해 작성자가 선택한 설계(① 모든 에이전트를 멈추기만 하고 새로 띄우지 않는 옵션 없이 순서대로 `create_session.sh`까지 수행, ② `grok` 메모리 관찰 기록 보존 옵션 없이 기본 purge 수행, ③ 이미 `stopped`인 행은 기본 대상에서 제외)가 의도와 맞는지 최종 확인 바랍니다.
[VERDICT: PASS]
@@ -0,0 +1,72 @@
# PR #82 (Issue #78) 리뷰
검증 경로 `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261029T184637Z-6f4228/3`. 테스트 뒤 HEAD `b49314c3d039027d3f9478cacecfda587fdc8abc`, tree `d49ff60bbf89437e997f7da21e2fae8df570456b`, porcelain 0. base `a3364e82e1f9e9c6ac504eef377e8dbefba02b8d` 의 조상이다. 스냅샷에 `.venv` 가 없어 제어 평면 Python 3.9.6 으로, 작업 디렉터리는 스냅샷으로 실행했다. 실제 작업 공간에서 `reset.sh`, `stop_session.sh --purge-conversation`, `--dry-run` 은 실행하지 않았다. 체크아웃 모니터(pid 3714, `reconcile.sh --subscribe --idle-timeout 0`)는 그대로 두고 신호를 보내지 않았다. 제품 파일은 수정하지 않았다.
이번 스냅샷의 추가분 `b49314c` 은 이전 head `568385c` 이후 6개 파일(+213/−74)이다. 두 작업 공간이 같은 잡 디렉터리를 동시에 reset 할 때 나중 fence 가 앞 마커를 지우던 겹침, 그리고 아직 없는 행 레지스트리를 fence 하지 않던 구멍을 막는지 코드를 확인했다. 브리프의 diff 는 잘려 있어 스냅샷을 직접 보았다. 브리프의 줄 수 812/286 은 현재 `lib_py/reset.py` 934줄, `reset.sh` 349줄과 다르다.
## 확인한 것
### 다른 살아있는 reset 의 펜스는 덮어쓰지 않는다
`fence_registries` (`reset.py:492-519`) 는 `jobs_dirs` 가 주는 디렉터리마다 `registry_lock` 안에서 `reset_marker` 를 본다. 상태가 `live` 또는 `unknown` 이고 pid 가 이 reset 이 아니면 `FenceBusy` 를 내고 그 파일은 그대로 둔다 (`reset.py:510-514`). 없거나 `stale` 이거나 pid 가 자기 것이면 자기 마커로 바꾼다 (`:515-518`).
`reset_marker` (`registry.py:97-116`):
- 파일이 없으면 `absent`. `ps` 를 부르지 않는다 (`:105-106`).
- 읽기 실패는 `unknown` (`:107-108`).
- pid 가 숫자가 아니면 `stale` (`:109-111`).
- 이 프로세스의 lstart 를 얻지 못하면 `unknown` (`:112-113`).
- 홀더가 죽었거나 lstart 가 다르면 `stale` (`:114-116`).
- 맞으면 `live` (`:117`).
`reset.sh` 는 `fenced <경로>` 만 정리 목록에 넣고, `refused ` 줄은 거절 이유로 찍은 뒤 exit 1 한다 (`reset.sh:152-163`). 정리는 pid 가 이 셸일 때만 마커를 지운다 (`reset.sh:120-121`). 다른 reset 의 마커는 목록에 없고, 지워지지도 않는다. fence 는 spec 쓰기와 purge 보다 앞이다 (`reset.sh:150-168`). 거절된 reset 은 대화를 지우지 않는다.
### 아직 없는 행 레지스트리도 막는다
`jobs_dirs` (`update.py:150-163`) 는 `.mam/jobs`, `MAM_JOBS_DIR`, 행의 `control_plane.mam_jobs_dir` 를 디렉터리가 아직 없어도 물리 경로당 한 번 돌려준다. 앞의 둘은 모든 등록을 거부하고, 행이 가리키는 다른 제어 평면은 이 레지스트리의 세션만 거부한다. fence 는 그 경로를 만든 다음 마커를 쓴다 (`reset.py:507-518`). `open_jobs` 는 같은 목록을 쓰되, 디렉터리가 아니면 건너뛴다 (`update.py:174-176`). 잡 목록은 `*.json` 만 보므로 마커 파일은 잡이 아니다 (`update.py:116-120`).
등록 거부는 그대로다. 마커가 없고 stale 이면 통과한다. `sessions` 에 없는 세션은 통과한다. `MAM_RESET_OWNER_PID` 가 마커 pid 이면 통과한다. `unknown` 과 `live` 는 거부한다 (`registry.py:120-135`). 호출은 레지스트리 잠금 안이다 (`registry.py:219-222`). 마커가 없을 때 일반 등록의 추가 비용은 그 파일을 한 번 열다 실패하는 것이다. `ps` 는 마커가 있을 때만 돈다.
### R2 와 이전 안전장치는 유지된다
post-discovered purge 는 `target_id_owned` 가 `verify_session_uuid(..., mode="revalidate")` 를 통과할 때만 한다 (`reset.py:522-537`). codex `verify_artifact` 는 rollout cwd 가 행의 폴더와 같을 때만 참이다 (`adapters/codex.py:99-130`). create 는 codex source 를 `pending-discovery` 로 둔다 (`create_session.sh:620-625`).
| SKILL.md | 내용 | 코드 |
|---|---|---|
| :26 | 공유 레지스트리가 다른 작업 공간의 살아있는 reset 에 막혀 있으면 아무것도 바꾸지 않고 거부한다. 그 마커를 바꾸거나 지우지 않는다 | `reset.py:510-514`, `reset.sh:120-121`, `:160-163` |
| :26 | 아직 없는 행 레지스트리도 만들어 막는다. 다른 제어 평면은 이 작업 공간의 세션만 | `update.py:150-163`, `reset.py:507-518` |
| :26 | 어떤 경로로 열어도 거부. 예외는 reset 자신의 create 온보딩 | 마커가 디렉터리 안에 있음. create 의 `MAM_RESET_OWNER_PID=$$` |
| :47 | 죽인 reset 의 마커는 홀더가 죽으면 무시 | `registry.py:114-116`. fence 는 그 stale 마커를 자기 것으로 바꾼다 (`reset.py:510-518`) |
제안 1–6, 대상 선택, spec, 잠금 순서, lsof, codex capability, 패키징은 이번 여섯 파일 diff 가 지우지 않았고, 같은 테스트 묶음이 다시 통과했다. submit 이 `.mam/loop-guard-active` 자체를 보지 않는 것은 리뷰 루프에 대한 기존 한계다. reset 은 작업 공간 마커와 레지스트리 마커로 등록을 거부한다.
## 실행한 테스트
스냅샷에 `.venv` 가 없다. cwd 는 검증 경로, 인터프리터는 `/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.venv/bin/python` (3.9.6). `HERDR_SOCKET_PATH`, `HERDR_PANE_ID`, `HERDR_TAB_ID`, `HERDR_WORKSPACE_ID`, `HERDR_SESSION_NAME`, `HERDR_SESSION`, `WORKSPACE_ROOT`, `MAM_ENV_FILE`, `MAM_JOBS_DIR`, `DELEGATE_JOB_LOGS_DIR` 는 해제했다. `PYTHONDONTWRITEBYTECODE=1`, `-p no:cacheprovider`, `--basetemp=/tmp/mam-i78-30c01a28` (`mam-review` 와 `shim` 없음), `-q --tb=line`.
파일: `tests/test_issue78_reset.py`, `tests/test_issue78_hints_codex.py`, `tests/test_issue42_skill_blocks.py`, `tests/test_issue10_13_purge.py`, `tests/test_issue31_purge_residual.py`, `tests/test_issue63_same_cwd_ids.py`.
결과: **201 passed, 1 skipped in 994.63s**. 로그의 종료 줄은 `EXIT:0`. 세션 가드 오류는 없었다. 건너뛴 항목은 `tests/test_issue42_skill_blocks.py:263-266` 이다. 스냅샷에 `.claude/skills` 가 없다.
이번 커밋의 테스트도 통과했다. `test_registry_cli_is_refused_after_the_target_recheck` (`:1793`, canonical/alias/custom/row-absent), `test_fence_waits_for_a_registration_and_covers_every_registry_read` (`:1920`, 없는 디렉터리와 stale 마커), `test_other_reset_cannot_take_a_live_fence` (`:2031`, 대상 있음/없음), `test_post_discovered_id_is_purged_only_when_it_verifies_for_its_folder` (`:1984`, own/foreign).
전체 스위트, `tests/test_issue34_job_tree.py`, `tests/test_issue50_dispatch_herdr.py` 는 돌리지 않았다. 테스트 뒤에 이 basetemp 의 pytest 프로세스는 남지 않았다. 테스트 뒤 HEAD·tree·porcelain 은 위와 같다.
## 발견 사항
차단: 없음. 한 reset 은 다른 살아있는 reset 의 마커를 바꾸거나 지우지 않고, 그 경우 purge 전에 거부한다. 아직 없는 행 레지스트리는 만들어 막는다. 마커가 없을 때 일반 등록은 `ps` 를 부르지 않는다.
비차단:
- 죽은 reset 의 stale 마커는 다음 fence 가 자기 마커로 바꾼다 (`reset.py:510-518`, 테스트 `:1936-1937`). SKILL.md:26 의 "다른 reset 의 마커를 바꾸지 않는다" 는 바로 앞의 살아있는 공유 펜스에 대한 문장이고, 죽은 홀더는 SKILL.md:47 과 같이 무시된다.
- `ps` 가 이 프로세스의 lstart 를 주지 못하면 마커는 `unknown` 이다. 등록은 거부되고 (`registry.py:131-133`), 다른 reset 도 그 펜스를 가져가지 않는다 (`reset.py:511`).
- fence 가 만들었어도 종료 시 지우는 것은 `.reset-in-progress` 뿐이다 (`reset.sh:120-121`). 행이 가리키던 빈 디렉터리와 `.lock` 은 남는다.
- `MAM_RESET_OWNER_PID` 는 herdr `new-session` 의 pane env 로 넘어가지 않는다. create 가 herdr 서버를 새로 띄우면 `lib.sh:276-290` 의 deny 목록에 이 변수가 없다. 기본 상주 경로는 `new-session` 이다.
- 원격 `handle_dispatch` 는 reset 거부로 `mirror_job` 이 `None` 이면 0 으로 돌아간다 (`remote.py:307-310`).
- `--dry-run` reset 의 파이썬은 `_delegate_py_bin` 과 다를 수 있다 (`reset.sh:51-58`). `update.sh` 의 거절 문구는 reset 이 loop 마커를 잡고 있어도 review loop 라고 말한다. 줄 수는 934/349 이다.
## 사용자 확인 필요
create 와 resume 은 `mam_reset_admit` 을 부르지 않는다. 결정 변경은 필요하지 않다. reset 중에 새로 만든 행은 spec 에 없고, spec 에 있는 행은 purge 전에 agent·서버·열린 잡·ID 소유를 다시 본다.
[VERDICT: PASS]
+18 -1
View File
@@ -29,7 +29,7 @@ export WORKSPACE_ROOT
# Framework semantic version. Single runtime source of truth; kept in lockstep
# with VERSIONS.md and the 8 SKILL.md frontmatters by tests/test_version_consistency.py.
# NOTE: unlike other MAM_* variables this one is intentionally NOT env-overridable.
MAM_VERSION="4.13.0"
MAM_VERSION="4.14.0"
export MAM_VERSION
AGENT_SESSIONS_YAML="${AGENT_SESSIONS_YAML:-$WORKSPACE_ROOT/.mam/agent-sessions.yaml}"
@@ -3348,6 +3348,23 @@ mam_update_admit_release() {
eval "exec ${1:-7}>&-"
}
# Issue #78: a reset (multi-agent-mux-reset) publishes .mam/reset-in-progress
# under the exclusive admission lock before its precheck. Delegate submit checks
# it while it holds that workspace's admission, so a job is either registered
# before the reset's precheck (which refuses on it) or not at all.
# mam_reset_admit [workspace]: rc 0 no live reset, 1 refused (stderr).
mam_reset_admit() {
local root="${1:-$WORKSPACE_ROOT}" marker st
marker="$root/.mam/reset-in-progress"
[ -f "$marker" ] || return 0
# shellcheck disable=SC1091
declare -F _mam_lock_holder_state >/dev/null || source "$SKILL_DIR/multi-agent-mux-loop/scripts/loop_lock.sh"
st="$(_mam_lock_holder_state "$marker")"
[ "$st" != "stale" ] || return 0
echo "ERROR: a reset of $root is in progress (marker $marker, holder $st); its agents are being purged and recreated; try again when it has finished" >&2
return 1
}
# mam_update_admit_at <cwd>: what create and resume take for an agent they
# start in <cwd> - mam_update_admit for the control root (fd 7) and for every
# other installed workspace at or above <cwd> (fds 20 and up), whose update
+4 -1
View File
@@ -197,11 +197,14 @@ class AgyAgentAdapter(BaseAgentAdapter):
# the DB already gone, and those orphans must still be reachable.
return any(os.path.exists(p) for p in self._artifact_paths(uuid, ctx))
def in_use_paths(self, uuid: str, ctx: DiscoveryContext) -> list:
return self._artifact_paths(uuid, ctx)
def conversation_in_use(self, uuid: str, ctx: DiscoveryContext) -> bool:
# The holder may keep the presence lock (or a sidecar) open rather
# than the DB, and may name the uuid nowhere; the lsof leg of the
# check must see every path of the conversation.
return conversation_in_use(uuid, self._artifact_paths(uuid, ctx))
return conversation_in_use(uuid, self.in_use_paths(uuid, ctx))
def purge_artifacts(self, uuid: str, ctx: DiscoveryContext) -> list:
# Issue #10: a purge must leave nothing resumable behind. Besides the
+23 -2
View File
@@ -1,6 +1,6 @@
import os, json, glob, sqlite3, subprocess
import os, json, glob, shutil, sqlite3, subprocess
from typing import Optional, Any, List
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext, _ro_connect, scan_names, db_rows_naming
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext, _plausible_id, _ro_connect, scan_names, db_rows_naming
from lib_py.verify_session import workspace_key
class CodexAgentAdapter(BaseAgentAdapter):
@@ -131,6 +131,22 @@ class CodexAgentAdapter(BaseAgentAdapter):
except Exception:
return False
def _purge_tui_thread_capability(self, uuid: str, ctx: DiscoveryContext, purged: List[str]) -> None:
if not _plausible_id(uuid):
return
cap = os.path.join(self._codex_home(ctx), "tui-thread-reference-capabilities", uuid)
if not os.path.lexists(cap):
return
try:
if os.path.isdir(cap) and not os.path.islink(cap):
shutil.rmtree(cap)
else:
os.unlink(cap)
if not os.path.lexists(cap):
purged.append(cap)
except Exception:
pass
def purge_artifacts(self, uuid: str, ctx: DiscoveryContext) -> List[str]:
purged = []
path = self._find_rollout(uuid, ctx)
@@ -140,12 +156,16 @@ class CodexAgentAdapter(BaseAgentAdapter):
if res.returncode == 0:
if path:
purged.append(path)
self._purge_tui_thread_capability(uuid, ctx, purged)
return purged
except Exception:
pass
if path and os.path.exists(path):
try:
os.remove(path)
purged.append(path)
except Exception:
pass
db = self._state_db(ctx)
if db and os.path.exists(db):
try:
@@ -156,6 +176,7 @@ class CodexAgentAdapter(BaseAgentAdapter):
purged.append(f"state db row for thread: {uuid}")
except Exception:
pass
self._purge_tui_thread_capability(uuid, ctx, purged)
return purged
def residual_artifacts(self, uuid: str, ctx: DiscoveryContext) -> List[str]:
@@ -13,6 +13,11 @@ _LEGACY_TABLES = (("message", "session_id"), ("messages", "session_id"), ("part"
# (binary, HOME, XDG_DATA_HOME) -> the db `debug paths` printed for them.
_DB_PATHS = {}
def _is_shared_service(cmd: str) -> bool:
"""The per-user opencode background service (`serve --service`)."""
return "serve --service" in (cmd or "")
class OpenCodeAgentAdapter(BaseAgentAdapter):
@property
def name(self) -> str:
@@ -250,6 +255,13 @@ class OpenCodeAgentAdapter(BaseAgentAdapter):
found += rows if i == uuid else [f"{r} [child session {i}]" for r in rows]
return found
def in_use_paths(self, uuid: str, ctx: DiscoveryContext) -> list:
db = self._resolve_db(ctx)
return [db] if db else []
def _holder_counts(self, cmd: str) -> bool:
return not _is_shared_service(cmd)
def conversation_in_use(self, uuid: str, ctx: DiscoveryContext) -> bool:
"""A process naming the session, or an opencode process holding the
shared database. The per-user background service (`serve --service`)
@@ -270,13 +282,14 @@ class OpenCodeAgentAdapter(BaseAgentAdapter):
timeout=10).stdout
except Exception:
return True
if cmd.strip() and "serve --service" not in cmd:
if cmd.strip() and not _is_shared_service(cmd):
return True
return False
def in_use_hints(self, uuid: str, ctx: DiscoveryContext) -> list:
db = self._resolve_db(ctx)
return super().in_use_hints(uuid, ctx) + [
f"another opencode session holding {self._resolve_db(ctx)}: lsof {self._resolve_db(ctx)}"]
f"another opencode session holding {db}: lsof {db}"]
# opencode 2.x: the default agent is build (`--agent` is gone from the
# TUI), and --standalone keeps the server a child of the pane's TUI
+42 -3
View File
@@ -1,7 +1,7 @@
# base.py — BaseAgentAdapter abstract base class, SpawnSpec, and DiscoveryContext
import os, json, sqlite3, shutil, subprocess, tempfile
from typing import Optional, Dict, Any, List
from typing import Optional, Dict, Any, List, Tuple
from urllib.parse import quote
from lib_py.paths import resolve_home
from lib_py.verify_session import verify_session_uuid, workspace_key
@@ -86,6 +86,34 @@ def conversation_in_use(uuid: str, paths=()) -> bool:
return False
def file_holders(paths=()) -> List[Tuple[int, str]]:
"""(pid, command_line) for each process holding one of `paths` open via lsof (#78).
Informational only: returns [] when lsof is absent or fails."""
existing = [p for p in paths if p and os.path.exists(p)]
if not existing or not shutil.which("lsof"):
return []
try:
r = subprocess.run(["lsof", "-t"] + existing, capture_output=True, text=True, timeout=10)
except Exception:
return []
pids: List[int] = []
for tok in (r.stdout or "").split():
if tok.isdigit():
pid = int(tok)
if pid not in pids:
pids.append(pid)
out: List[Tuple[int, str]] = []
for pid in pids:
try:
cmd = subprocess.run(["ps", "-o", "command=", "-p", str(pid)],
capture_output=True, text=True, timeout=10).stdout.strip()
except Exception:
continue
if cmd:
out.append((pid, cmd))
return out
def _plausible_id(uuid: str) -> bool:
"""An id safe to build a path from and to search for: not empty, not
made of dots alone ("." names the parent dir itself), no "/", ".." or "*"."""
@@ -354,14 +382,25 @@ class BaseAgentAdapter:
path = self.artifact_path(uuid, ctx)
return uuid in path and os.path.exists(path)
def in_use_paths(self, uuid: str, ctx: DiscoveryContext) -> list:
"""On-disk paths checked with lsof for conversation_in_use and in_use_hints (#78)."""
return [self.artifact_path(uuid, ctx)]
def _holder_counts(self, cmd: str) -> bool:
return True
def conversation_in_use(self, uuid: str, ctx: DiscoveryContext) -> bool:
"""True while a live process still references the conversation."""
return conversation_in_use(uuid, [self.artifact_path(uuid, ctx)])
return conversation_in_use(uuid, self.in_use_paths(uuid, ctx))
def in_use_hints(self, uuid: str, ctx: DiscoveryContext) -> list:
"""Where to look for what holds the conversation, shown when a purge
is refused because it is in use."""
return [f"any process with it on its command line (an editor, a grep, ...): ps -axww | grep {uuid}"]
hints = [f"any process with it on its command line (an editor, a grep, ...): ps -axww | grep {uuid}"]
for pid, cmd in file_holders(self.in_use_paths(uuid, ctx)):
if self._holder_counts(cmd):
hints.append(f"held open by pid {pid}: {cmd} (not stopped by MAM; end it yourself if it is safe)")
return hints
def spawn_spec(self, binary: str, session_uuid: str = "", use_wrapper: bool = False) -> str:
raise NotImplementedError
@@ -16,6 +16,8 @@ from __future__ import annotations
import argparse
import json
import logging
import os
import subprocess
import sys
import uuid
from pathlib import Path
@@ -65,6 +67,74 @@ def liveness_window(heartbeat_sec: int, heartbeat_misses: int) -> int:
return max(1, int(heartbeat_sec)) * max(1, int(heartbeat_misses))
# Issue #78: a reset of a workspace purges its agents' conversations, so no job
# may be registered for them while it runs. The reset writes RESET_MARKER into
# every registry its open-job check reads (its own .mam/jobs and MAM_JOBS_DIR,
# and a row's other control_plane.mam_jobs_dir), each under that registry's
# lock and before the check: a registration reads it under the same lock, so it
# is either written before the check or refused - by whatever path it names
# the directory. The marker carries the reset's pid/lstart (loop_lock.sh's
# format), its workspace, and for another control plane's registry the
# sessions it covers. The reset's own create (the onboarding job of an agent
# it recreates) passes the reset's pid in MAM_RESET_OWNER_PID.
RESET_MARKER = ".reset-in-progress"
RESET_OWNER_ENV = "MAM_RESET_OWNER_PID"
class ResetInProgressError(RuntimeError):
pass
def _lstart(pid: str) -> str:
try:
out = subprocess.run(["ps", "-p", pid, "-o", "lstart="], capture_output=True, text=True,
timeout=10).stdout
except (OSError, subprocess.SubprocessError):
return ""
return " ".join(out.split())
def reset_marker(registry_dir: str) -> Tuple[str, Dict[str, str]]:
"""(holder state, fields) of the registry's RESET_MARKER: "absent", or as
loop_lock.sh decides - "stale" (dead or reused pid), "live", "unknown" (ps
fails on this process; a marker that cannot be read is unknown too)."""
marker = os.path.join(registry_dir, RESET_MARKER)
try:
with open(marker, encoding="utf-8") as fh:
fields = dict(line.split("=", 1) for line in fh.read().splitlines() if "=" in line)
except FileNotFoundError:
return "absent", {}
except (OSError, ValueError) as exc:
return "unknown", {"error": str(exc)}
pid = fields.get("pid", "")
if not pid.isdigit():
return "stale", fields
if not _lstart(str(os.getpid())):
return "unknown", fields
cur = _lstart(pid)
if not cur or (fields.get("lstart") and cur != fields["lstart"]):
return "stale", fields
return "live", fields
def reset_refusal(registry_dir: str, agent_session: str) -> Optional[str]:
"""Why registering a job for ``agent_session`` in ``registry_dir`` is
refused, or None: a live (or undecidable) reset fenced this registry, for
that session, and the caller is not its own create."""
state, fields = reset_marker(registry_dir)
if state in ("absent", "stale"):
return None
if "sessions" in fields and agent_session not in fields["sessions"].split(","):
return None
if fields.get("pid") and os.environ.get(RESET_OWNER_ENV) == fields["pid"]:
return None
if state == "unknown":
return "cannot tell whether the reset fencing %s is live (%s)" % (
os.path.join(registry_dir, RESET_MARKER), fields.get("error") or "ps unavailable?")
return ("a reset of %s is in progress (pid %s); its agents are being purged and recreated;"
" register the job when it has finished" % (fields.get("workspace", "?"), fields["pid"]))
def register_job(
prompt: str,
agent: str = "claude-code",
@@ -160,6 +230,9 @@ def register_job(
if dry_run:
record["dry_run"] = True
with registry_lock(registry_dir):
refusal = reset_refusal(registry_dir, agent_session)
if refusal:
raise ResetInProgressError(refusal)
if mqtt_common._job_path(job_id, registry_dir).exists():
if not supersede_older_attempt:
raise FileExistsError(f"job already exists: {job_id}")
@@ -556,6 +629,7 @@ def main(argv: Optional[List[str]] = None) -> int:
if args.parent_job and not mqtt_common._job_path(args.parent_job, rd).exists():
print(f"unknown parent job {args.parent_job}", file=sys.stderr)
return 1
try:
job_id = register_job(
prompt=_prompt_from_args(parser, args),
agent=args.agent,
@@ -581,6 +655,9 @@ def main(argv: Optional[List[str]] = None) -> int:
parent_job=args.parent_job,
dry_run=args.dry_run,
)
except ResetInProgressError as exc:
print("ERROR: %s" % exc, file=sys.stderr)
return 1
print(job_id)
return 0
@@ -221,6 +221,9 @@ def mirror_job(d: Dict[str, Any], registry_dir: str, session: str) -> Optional[s
except FileExistsError as exc: # lost the race to a newer (or equal) attempt under the lock
logger.warning("refusing dispatch for %s: %s", jid, exc)
return None
except registry.ResetInProgressError as exc: # Issue #78: this workspace is being reset
logger.warning("refusing dispatch for %s: %s", jid, exc)
return None
brief_dir = os.path.join(registry_dir, jid)
os.makedirs(brief_dir, exist_ok=True)
with open(os.path.join(brief_dir, "brief.md"), "w", encoding="utf-8") as fh:
+1 -1
View File
@@ -13,7 +13,7 @@ import sys
from typing import List, Sequence
# Framework version locked with framework constants
FRAMEWORK_VERSION = "4.13.0"
FRAMEWORK_VERSION = "4.14.0"
CORE_MODULES: Sequence[str] = (
# Standard library modules
+934
View File
@@ -0,0 +1,934 @@
"""Decisions behind the multi-agent-mux-reset skill (Issue #78).
reset.sh keeps the shell sequence (arg parsing, terminal check, update
admission, loop lock, sequential stop/create invocations, exit codes) and
delegates target selection, prechecks, atomic spec-file persistence, 5-state
progress classification, and result-table formatting to this module.
Standard library only, Python 3.9 compatible.
"""
from __future__ import annotations
import argparse
import datetime
import json
import os
import shlex
import shutil
import sqlite3
import subprocess
import sys
import tempfile
from typing import Dict, Iterator, List, Mapping, Optional, Tuple
from lib_py.agents.base import AGENT_SESSION_ENV, POST_DISCOVERED
from lib_py.agents.registry import agent_of_row, get_adapter, own_key
from lib_py.delegation.mqtt_common import registry_lock
from lib_py.delegation.registry import RESET_MARKER, reset_marker
from lib_py.update import jobs_dirs, open_jobs, row_server
from lib_py.verify_session import verify_session_uuid
SPEC_VERSION = 1
SPEC_REL = os.path.join(".mam", "reset-spec.json")
class SpecError(Exception):
pass
def _real(p: str) -> str:
return os.path.realpath(p) if p else ""
def spec_path(workspace: str) -> str:
return os.path.join(_real(workspace), SPEC_REL)
def _rows_by_name(state: dict) -> Dict[str, dict]:
return {
str(s.get("name")): s
for s in (state.get("herdr_sessions") or [])
if isinstance(s, dict) and s.get("name")
}
def _state(workspace: str = "") -> dict:
raw = os.environ.get("MAM_STATE_JSON")
if raw is not None:
return json.loads(raw or "{}") or {}
# Only --dry-run gets here (reset.sh passes MAM_STATE_JSON everywhere
# else): it does not source lib.sh, and reads a copy of the DB because a
# read on macOS rewrites -shm, so the dry run changes nothing under .mam.
yaml_path = os.environ.get("AGENT_SESSIONS_YAML") or (
os.path.join(_real(workspace), ".mam", "agent-sessions.yaml") if workspace else ""
)
if not yaml_path:
return {}
db_path = os.path.splitext(yaml_path)[0] + ".db"
if os.path.exists(db_path):
tmp_dir = tempfile.mkdtemp(prefix="mam-reset-ro.")
try:
tmp_db = os.path.join(tmp_dir, "agent-sessions.db")
shutil.copyfile(db_path, tmp_db)
for suf in ("-wal", "-shm"):
if os.path.exists(db_path + suf):
shutil.copyfile(db_path + suf, tmp_db + suf)
conn = sqlite3.connect(tmp_db, timeout=60.0)
try:
d: dict = {}
row = conn.execute("SELECT data FROM state WHERE id=1").fetchone()
if row:
d = json.loads(row[0])
cursor = conn.execute("SELECT data FROM sessions")
d["herdr_sessions"] = [json.loads(r[0]) for r in cursor.fetchall()]
return d
finally:
conn.close()
except Exception as exc:
raise ValueError("the registry %s cannot be read (%s); fix it first" % (yaml_path, exc))
finally:
shutil.rmtree(tmp_dir, ignore_errors=True)
if os.path.exists(yaml_path):
try:
import yaml
with open(yaml_path, encoding="utf-8") as fh:
data = yaml.safe_load(fh)
if data is not None and not isinstance(data, dict):
raise ValueError("top-level YAML is not a mapping")
return data or {}
except Exception as exc:
raise ValueError("the registry %s cannot be read (%s); fix it first" % (yaml_path, exc))
return {}
# --------------------------------------------------------------------------- D2: target selection
def select_targets(workspace: str, state: dict) -> dict:
"""Select running control-root sessions and classify non-targets.
Returns {"targets": [...], "skipped": [{"name", "reason"}],
"orchestrators": [...], "errors": [...]}.
"""
ws_real = _real(workspace)
targets: List[dict] = []
skipped: List[dict] = []
errors: List[str] = []
for row in state.get("herdr_sessions") or []:
if not isinstance(row, dict):
continue
name = str(row.get("name") or "")
status = str(row.get("status") or "")
if status != "running":
skipped.append({"name": name, "reason": "status %s" % (status or "unknown")})
continue
cwd = str((row.get("pane") or {}).get("cwd") or "")
cwd_real = _real(cwd) if cwd else ""
if cwd_real != ws_real:
skipped.append({
"name": name,
"reason": "pane.cwd %s is not the control root (a worktree creator?)" % (cwd or "<empty>"),
})
continue
agent = agent_of_row(row)
role = str(row.get("role") or "").strip()
if not agent:
errors.append(
"running session %s has no recognisable agent type (agent=%r, pane.cmd=%r); "
"fix its registry row first"
% (name, row.get("agent"), (row.get("pane") or {}).get("cmd"))
)
continue
if not role:
errors.append(
"running session %s (%s) has an empty role; fix its registry row first"
% (name, agent)
)
continue
key = own_key(agent)
raw_id = row.get(key)
old_id = str(raw_id).strip() if isinstance(raw_id, str) and raw_id.strip() else None
if old_id is None and get_adapter(agent).id_lifecycle != POST_DISCOVERED:
# Its id is given at spawn, so none on the row means none was ever
# pinned (the monitor defers an ambiguous one); stop would fill it
# with a folder-wide guess that the purge would then delete.
errors.append(
"running session %s (%s) has no recorded conversation id; record it (monitor) "
"or stop/purge it by hand first" % (name, agent)
)
continue
targets.append({
"name": name,
"agent": agent,
"role": role,
"herdr_server": str(row_server(row)),
"herdr_workspace": str(row.get("herdr_workspace") or ""),
"old_id": old_id,
"old_created_at": str(row.get("herdr_session_created_at") or ""),
})
orchestrators = [str(u) for u in (state.get("orchestrator_uuids") or []) if u]
return {
"targets": targets,
"skipped": skipped,
"orchestrators": orchestrators,
"errors": errors,
}
# --------------------------------------------------------------------------- D5: spec file
def write_spec(workspace: str, agents: List[dict]) -> str:
"""Atomically write .mam/reset-spec.json (mkstemp -> fsync -> replace -> dir fsync)."""
ws_real = _real(workspace)
mam_dir = os.path.join(ws_real, ".mam")
os.makedirs(mam_dir, exist_ok=True)
path = os.path.join(mam_dir, "reset-spec.json")
now_iso = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
doc = {
"version": SPEC_VERSION,
"workspace": ws_real,
"written_at": now_iso,
"agents": agents,
}
payload = (json.dumps(doc, indent=2) + "\n").encode("utf-8")
fd, tmp_path = tempfile.mkstemp(prefix=".reset-spec.", suffix=".tmp", dir=mam_dir)
try:
with os.fdopen(fd, "wb") as fh:
fh.write(payload)
fh.flush()
os.fsync(fh.fileno())
os.replace(tmp_path, path)
except BaseException:
try:
os.unlink(tmp_path)
except OSError:
pass
raise
try:
dir_fd = os.open(mam_dir, os.O_RDONLY)
try:
os.fsync(dir_fd)
finally:
os.close(dir_fd)
except OSError:
pass
return path
def read_spec(workspace: str) -> dict:
"""Read and validate .mam/reset-spec.json; raise SpecError if invalid."""
ws_real = _real(workspace)
path = spec_path(ws_real)
try:
with open(path, encoding="utf-8") as fh:
doc = json.load(fh)
except (OSError, ValueError) as exc:
raise SpecError("spec file %s cannot be read (%s); check it, then remove it by hand" % (path, exc))
if not isinstance(doc, dict) or doc.get("version") != SPEC_VERSION:
raise SpecError(
"spec file %s has unsupported version %r; check it, then remove it by hand"
% (path, doc.get("version") if isinstance(doc, dict) else None)
)
spec_ws = _real(str(doc.get("workspace") or ""))
if spec_ws != ws_real:
raise SpecError(
"spec file %s is for workspace %r, not %r; check it, then remove it by hand"
% (path, doc.get("workspace"), ws_real)
)
agents = doc.get("agents")
if not isinstance(agents, list):
raise SpecError("spec file %s has no agents list; check it, then remove it by hand" % path)
names = set()
for item in agents:
if not isinstance(item, dict):
raise SpecError("spec file %s has a non-object agent entry; check it, then remove it by hand" % path)
name = item.get("name")
agent = item.get("agent")
role = item.get("role")
server = item.get("herdr_server")
old_id = item.get("old_id")
if not (
all(isinstance(v, str) and v for v in (name, agent, role, server))
and isinstance(item.get("herdr_workspace"), str)
and "old_id" in item
and (old_id is None or (isinstance(old_id, str) and old_id))
and isinstance(item.get("old_created_at"), str)
):
raise SpecError("spec file %s has an incomplete agent entry; check it, then remove it by hand" % path)
if name in names:
raise SpecError(
"spec file %s lists %s twice; check it, then remove it by hand" % (path, name)
)
names.add(name)
adapter = get_adapter(agent)
if adapter is None or adapter.name != agent or not own_key(agent):
raise SpecError(
"spec file %s has unknown agent %r; check it, then remove it by hand" % (path, agent)
)
return doc
def record_old_id(workspace: str, state: dict, session_name: str) -> Optional[str]:
"""Persist a newly captured conversation id onto an entry with old_id=None.
Called after step 1 (normal stop) captures a POST_DISCOVERED session's id
onto the original instance's registry row, so a subsequent resume + rerun
cannot mistake the original conversation for a new one (review F1).
Reads the id from `state` (never from command-line args).
"""
ws_real = _real(workspace)
spec = read_spec(ws_real)
row = _rows_by_name(state).get(session_name)
if row is None:
return None
changed = False
recorded: Optional[str] = None
for entry in spec["agents"]:
if str(entry.get("name") or "") != session_name:
continue
raw_old = entry.get("old_id")
old_id = str(raw_old).strip() if isinstance(raw_old, str) and raw_old.strip() else None
if old_id:
return old_id
# Only a post-discovered id is captured by this pane's pid (#40); a
# pre-assigned agent's is a folder-wide guess, never recorded.
if get_adapter(entry["agent"]).id_lifecycle != POST_DISCOVERED:
return None
cur_created_at = str(row.get("herdr_session_created_at") or "")
old_created_at = str(entry.get("old_created_at") or "")
if cur_created_at != old_created_at:
return None
key = own_key(entry["agent"])
raw_cur = row.get(key)
cur_id = str(raw_cur).strip() if isinstance(raw_cur, str) and raw_cur.strip() else None
if cur_id:
entry["old_id"] = cur_id
recorded = cur_id
changed = True
if changed:
write_spec(ws_real, spec["agents"])
return recorded
def new_running_rows(workspace: str, state: dict, spec_agents: List[dict]) -> List[str]:
"""Names of running control-root rows not present in spec_agents."""
known = {str(a.get("name") or "") for a in spec_agents}
sel = select_targets(workspace, state)
return [t["name"] for t in sel["targets"] if t["name"] not in known]
# --------------------------------------------------------------------------- D6: progress classification
def classify_entry(entry: dict, row: Optional[dict]) -> Tuple[str, Optional[str], bool]:
"""Classify a spec entry against its current registry row (matched by name only).
Returns (r_state, new_id, same_instance):
1. row is None -> ("purged", None, False)
2. r.herdr_session_created_at == old_created_at -> ("pending", None, True)
3. not same_instance and r[id] == old_id (non-empty) -> ("pending", cur_id, False)
4. not same_instance and r[id] is empty -> ("created", None, False)
5. not same_instance and r[id] is non-empty and != old_id -> ("done", cur_id, False)
"""
if row is None:
return "purged", None, False
key = own_key(entry["agent"])
cur_created_at = str(row.get("herdr_session_created_at") or "")
old_created_at = str(entry.get("old_created_at") or "")
raw_cur = row.get(key)
cur_id = str(raw_cur).strip() if isinstance(raw_cur, str) and raw_cur.strip() else None
raw_old = entry.get("old_id")
old_id = str(raw_old).strip() if isinstance(raw_old, str) and raw_old.strip() else None
if cur_created_at == old_created_at:
return "pending", None, True
if cur_id and old_id and cur_id == old_id:
return "pending", cur_id, False
if not cur_id:
return "created", None, False
return "done", cur_id, False
# --------------------------------------------------------------------------- D4: precheck
def _ancestor_pids(start_pid: Optional[int] = None) -> set:
pid = os.getpid() if start_pid is None else start_pid
try:
res = subprocess.run(
["ps", "-eo", "pid=,ppid="],
stdin=subprocess.DEVNULL,
capture_output=True,
text=True,
timeout=5,
)
except (OSError, subprocess.SubprocessError):
return set()
if res.returncode != 0:
return set()
ppid_of: Dict[int, int] = {}
for line in (res.stdout or "").splitlines():
parts = line.split()
if len(parts) == 2 and parts[0].isdigit() and parts[1].isdigit():
ppid_of[int(parts[0])] = int(parts[1])
ancestors = set()
cur = pid
while cur and cur > 1 and cur not in ancestors:
ancestors.add(cur)
cur = ppid_of.get(cur, 0)
return ancestors
def _caller_blockers(
targets: List[dict],
rows_by_name: Mapping[str, dict],
env: Mapping[str, str],
ancestors: Optional[set] = None,
) -> List[str]:
env_ids = {
str(env.get(k) or "").strip()
for k in AGENT_SESSION_ENV
if str(env.get(k) or "").strip()
}
anc = _ancestor_pids() if ancestors is None else ancestors
out: List[str] = []
for t in targets:
name = t["name"]
row = rows_by_name.get(name)
r_state, _, _ = classify_entry(t, row)
if r_state != "pending":
continue
old_id = t.get("old_id")
key = own_key(t["agent"])
raw_cur = (row or {}).get(key)
cur_id = str(raw_cur).strip() if isinstance(raw_cur, str) and raw_cur.strip() else None
matched = False
if env_ids and ((old_id and old_id in env_ids) or (cur_id and cur_id in env_ids)):
matched = True
elif row is not None and anc:
pane_pid = (row.get("pane") or {}).get("pid")
child_pid = row.get("child_pid")
for p in (pane_pid, child_pid):
if isinstance(p, int) and p > 1 and p in anc:
matched = True
break
if matched:
out.append("you are %s, one of the targets; run the reset from the orchestrator" % name)
return out
def precheck(
workspace: str,
state: dict,
env: Mapping[str, str],
ancestors: Optional[set] = None,
) -> List[str]:
"""Return refusal lines for open jobs, unreadable spec, un-recreatable targets, or caller==target."""
ws_real = _real(workspace)
blockers: List[str] = []
spath = spec_path(ws_real)
targets: List[dict] = []
if os.path.lexists(spath):
try:
spec = read_spec(ws_real)
targets = spec["agents"]
except SpecError as exc:
blockers.append(str(exc))
else:
sel = select_targets(ws_real, state)
blockers.extend(sel["errors"])
targets = sel["targets"]
for j in open_jobs(ws_real, state, env):
if j.get("dir"):
blockers.append(
"jobs directory %s cannot be listed; fix its permissions, then try again"
% j["path"]
)
elif j.get("unreadable"):
blockers.append(
"job record %s cannot be read; check it, then move or delete it by hand"
% j["path"]
)
else:
blockers.append(
"job %s (%s, %s) is open; wait for it to end or close it: python "
".agents/skills/multi-agent-mux-delegate-job/scripts/registry.py"
" --registry-dir %s close --job %s --request cancelled"
% (
j["job_id"],
j["agent_session"] or "-",
j["status"],
j["registry_dir"],
j["job_id"],
)
)
blockers.extend(_caller_blockers(targets, _rows_by_name(state), env, ancestors=ancestors))
return blockers
def target_open_jobs(workspace: str, state: dict, env: Mapping[str, str], session_name: str) -> List[str]:
"""Open (or unreadable) job records addressed to one target, re-read just
before its stop: a job registered after the precheck is never cancelled."""
out: List[str] = []
for j in open_jobs(_real(workspace), state, env):
if j.get("unreadable"):
out.append("job record %s cannot be read" % j["path"])
elif j["agent_session"] == "herdr:%s" % session_name:
out.append("job %s (%s) is open" % (j["job_id"], j["status"]))
return out
class FenceBusy(Exception):
pass
def fence_registries(workspace: str, state: dict, env: Mapping[str, str]) -> Iterator[str]:
"""Write RESET_MARKER into every registry open_jobs reads (jobs_dirs,
created when absent - a registration would create it too), each under its
registry lock (a registration that read no marker has finished writing),
and yield its real path as it is written. FenceBusy when one is already
fenced by another live (or undecidable) reset - registries may be shared
between workspaces; its marker is left as it is. This workspace's own
registries refuse every registration; another control plane's only those
addressed to this registry's rows. The marker copies .mam/reset-in-progress."""
with open(os.path.join(workspace, ".mam", "reset-in-progress"), encoding="utf-8") as fh:
base = "".join(l + "\n" for l in fh.read().splitlines() if l.split("=", 1)[0] in ("pid", "lstart", "started"))
me = dict(l.split("=", 1) for l in base.splitlines()).get("pid", "")
base += "workspace=%s\n" % workspace
rows = state.get("herdr_sessions") or []
sessions = ",".join("herdr:%s" % s["name"] for s in rows if s.get("name"))
for d, addressed_only in jobs_dirs(workspace, state, env):
os.makedirs(d, exist_ok=True)
with registry_lock(d):
held, fields = reset_marker(d)
if held in ("live", "unknown") and fields.get("pid") != me:
raise FenceBusy(
"the job registry %s is fenced by a reset of %s (pid %s, holder %s); wait for it to end"
% (d, fields.get("workspace", "?"), fields.get("pid", "?"), held))
fd, tmp = tempfile.mkstemp(dir=d, prefix=RESET_MARKER + ".")
with os.fdopen(fd, "w", encoding="utf-8") as fh:
fh.write(base + ("sessions=%s\n" % sessions if addressed_only else ""))
os.replace(tmp, os.path.join(d, RESET_MARKER))
yield _real(d)
def target_id_owned(state: dict, session_name: str) -> bool:
"""The row's recorded post-discovered id still verifies (revalidate) as a
conversation of the row's cwd - the check stop makes before it keeps such
an id. stop's own --purge-conversation also removes an id that no longer
verifies (a hand-run orphan cleanup); the reset purges only an owned one.
stop looks a pre-assigned id up in the cwd itself, so those pass."""
row = _rows_by_name(state).get(session_name)
agent = agent_of_row(row) if row else None
if not agent:
return False
if get_adapter(agent).id_lifecycle != POST_DISCOVERED:
return True
raw = row.get(own_key(agent))
own = raw.strip() if isinstance(raw, str) else ""
cwd = str((row.get("pane") or {}).get("cwd") or "")
return bool(own and cwd and verify_session_uuid(cwd, agent, own, row, mode="revalidate"))
# --------------------------------------------------------------------------- D1: entries shell lines
def format_entry_line(entry: dict, row: Optional[dict]) -> str:
r_state, _, _ = classify_entry(entry, row)
key = own_key(entry["agent"])
raw_cur = (row or {}).get(key)
row_id = str(raw_cur).strip() if isinstance(raw_cur, str) and raw_cur.strip() else ""
pairs = [
("R_STATE", r_state),
("R_NAME", str(entry.get("name") or "")),
("R_AGENT", str(entry.get("agent") or "")),
("R_ROLE", str(entry.get("role") or "")),
("R_SERVER", str(entry.get("herdr_server") or "")),
("R_LABEL", str(entry.get("herdr_workspace") or "")),
("R_OLD_ID", str(entry.get("old_id") or "")),
("R_ROW_ID", row_id),
("R_ROW_AGENT", (agent_of_row(row) or "") if row else ""),
("R_ROW_SERVER", str(row_server(row)) if row else ""),
]
return " ".join("%s=%s" % (k, shlex.quote(v)) for k, v in pairs)
# --------------------------------------------------------------------------- D8: result table
SKIP_RESULTS = {
"no_id": "skipped: no conversation id recorded",
"purge_refused": "skipped: purge refused",
"open_job": "skipped: job open",
"id_unverified": "skipped: id not verified",
"changed": "skipped: row changed",
}
def _parse_skipped(items: Optional[List[str]]) -> Dict[str, str]:
out: Dict[str, str] = {}
for raw in items or []:
for token in raw.split(","):
token = token.strip()
if not token:
continue
if ":" in token:
name, kind = token.split(":", 1)
out[name] = kind
else:
out[token] = "purge_refused"
return out
def _parse_failed(raw: Optional[str]) -> Optional[Tuple[str, str, str]]:
if not raw:
return None
parts = raw.split(":", 2)
if len(parts) == 3:
return parts[0], parts[1], parts[2]
if len(parts) == 2:
return parts[0], parts[1], "1"
return parts[0], "stop", "1"
def build_table(
agents: List[dict],
state: dict,
skipped_items: Optional[List[str]] = None,
failed_spec: Optional[str] = None,
) -> Tuple[str, int]:
"""Format the D8 result table and compute the D9 exit code (0, 1, 4, or 5)."""
rows_by_name = _rows_by_name(state)
skipped_map = _parse_skipped(skipped_items)
failed = _parse_failed(failed_spec)
failed_name = failed[0] if failed else None
after_failed = False
headers = ("NAME", "AGENT", "OLD_ID", "NEW_ID", "RECORDED", "NEW≠OLD", "RESULT")
table_rows: List[Tuple[str, str, str, str, str, str, str]] = []
has_skip_or_incomplete = bool(skipped_map)
has_created = False
for entry in agents:
name = str(entry.get("name") or "")
agent = str(entry.get("agent") or "")
raw_old = entry.get("old_id")
old_id = str(raw_old).strip() if isinstance(raw_old, str) and raw_old.strip() else None
old_col = old_id if old_id else "-"
row = rows_by_name.get(name)
r_state, new_id, _ = classify_entry(entry, row)
new_col = new_id if new_id else "-"
recorded_col = "yes" if new_id else "no"
if not new_id:
diff_col = "-"
elif not old_id or new_id != old_id:
diff_col = "yes"
else:
diff_col = "no"
if failed and name == failed_name:
result_col = "failed at %s (rc %s)" % (failed[1], failed[2])
after_failed = True
has_skip_or_incomplete = True
elif after_failed and r_state in ("pending", "purged"):
result_col = "not reached"
has_skip_or_incomplete = True
elif name in skipped_map:
result_col = SKIP_RESULTS.get(skipped_map[name], SKIP_RESULTS["purge_refused"])
has_skip_or_incomplete = True
elif r_state == "done":
result_col = "done"
elif r_state == "created":
result_col = "created; id not recorded yet"
has_created = True
elif r_state == "purged":
result_col = "purged"
has_skip_or_incomplete = True
else:
result_col = "pending"
has_skip_or_incomplete = True
table_rows.append((name, agent, old_col, new_col, recorded_col, diff_col, result_col))
widths = [len(h) for h in headers]
for r in table_rows:
for i in range(len(headers) - 1):
if len(r[i]) > widths[i]:
widths[i] = len(r[i])
def _fmt(cols: Tuple[str, ...]) -> str:
parts = [cols[i].ljust(widths[i]) for i in range(len(cols) - 1)]
parts.append(cols[-1])
return " ".join(parts)
lines = [_fmt(headers)]
for r in table_rows:
lines.append(_fmt(r))
if failed:
rc = 1
elif has_skip_or_incomplete:
rc = 4
elif has_created:
rc = 5
else:
rc = 0
return "\n".join(lines), rc
# --------------------------------------------------------------------------- D10: dry-run
def format_dry_run(
workspace: str,
state: dict,
env: Mapping[str, str],
extra_refusals: Optional[List[str]] = None,
) -> Tuple[str, int]:
ws_real = _real(workspace)
lines: List[str] = []
refusals: List[str] = list(extra_refusals or [])
refusals.extend(precheck(ws_real, state, env))
rows_by_name = _rows_by_name(state)
sel = select_targets(ws_real, state)
spath = spec_path(ws_real)
action_map = {
"pending": "stop + purge + create",
"purged": "create",
"created": "none (waiting for id)",
"done": "none (done)",
}
if os.path.lexists(spath):
try:
spec = read_spec(ws_real)
except SpecError:
spec = None
if spec is not None:
lines.append("spec file: %s" % spath)
for e in spec["agents"]:
r_state, _, _ = classify_entry(e, rows_by_name.get(e["name"]))
lines.append(
"target: %s agent=%s role=%s herdr_server=%s herdr_workspace=%s "
"old_id=%s old_created_at=%s state=%s action=%s"
% (
e["name"],
e["agent"],
e["role"],
e["herdr_server"],
e.get("herdr_workspace") or "",
e.get("old_id") or "-",
e.get("old_created_at") or "-",
r_state,
action_map.get(r_state, r_state),
)
)
extra_running = new_running_rows(ws_real, state, spec["agents"])
if extra_running:
lines.append(
"NOTE: running control-root session(s) not in reset-spec.json "
"(not targeted by this rerun): %s" % ", ".join(extra_running)
)
else:
if sel["targets"]:
for t in sel["targets"]:
lines.append(
"target: %s agent=%s role=%s herdr_server=%s herdr_workspace=%s "
"old_id=%s old_created_at=%s"
% (
t["name"],
t["agent"],
t["role"],
t["herdr_server"],
t["herdr_workspace"],
t["old_id"] or "-",
t["old_created_at"] or "-",
)
)
else:
lines.append("targets: (none)")
for sk in sel["skipped"]:
lines.append("skip: %s (%s)" % (sk["name"], sk["reason"]))
for u in sel["orchestrators"]:
lines.append("orchestrator %s (not a row; never reset)" % u)
if refusals:
lines.append("would refuse:")
for r in refusals:
lines.append(" - %s" % r)
return "\n".join(lines), 1
return "\n".join(lines), 0
# --------------------------------------------------------------------------- CLI
def main(argv: Optional[List[str]] = None) -> int:
p = argparse.ArgumentParser(prog="lib_py.reset")
sub = p.add_subparsers(dest="cmd", required=True)
s = sub.add_parser("plan")
s.add_argument("--workspace", required=True)
s = sub.add_parser("precheck")
s.add_argument("--workspace", required=True)
s = sub.add_parser("write-spec")
s.add_argument("--workspace", required=True)
s = sub.add_parser("entries")
s.add_argument("--workspace", required=True)
s.add_argument("--session", default="")
s.add_argument("--warn-new", action="store_true")
s = sub.add_parser("open-jobs")
s.add_argument("--workspace", required=True)
s.add_argument("--session", required=True)
s = sub.add_parser("fence")
s.add_argument("--workspace", required=True)
s = sub.add_parser("id-owned")
s.add_argument("--workspace", required=True)
s.add_argument("--session", required=True)
s = sub.add_parser("record-id")
s.add_argument("--workspace", required=True)
s.add_argument("--session", required=True)
s = sub.add_parser("table")
s.add_argument("--workspace", required=True)
s.add_argument("--skipped", action="append", default=[])
s.add_argument("--failed", default="")
s = sub.add_parser("dry-run")
s.add_argument("--workspace", required=True)
s.add_argument("--refusal", action="append", default=[])
a = p.parse_args(argv)
ws = _real(a.workspace)
if a.cmd == "write-spec":
try:
raw = json.load(sys.stdin)
except ValueError as exc:
print("ERROR: invalid targets JSON on stdin (%s)" % exc, file=sys.stderr)
return 1
agents = raw.get("targets") if isinstance(raw, dict) else raw
if not isinstance(agents, list):
print("ERROR: targets JSON must be a list", file=sys.stderr)
return 1
try:
write_spec(ws, agents)
except OSError as exc:
print("ERROR: cannot write %s (%s)" % (spec_path(ws), exc), file=sys.stderr)
return 1
return 0
try:
state = _state(ws)
except ValueError as exc:
if a.cmd == "dry-run":
refs = list(a.refusal or []) + [str(exc)]
out, rc = format_dry_run(ws, {}, os.environ, extra_refusals=refs)
print(out)
return rc
if a.cmd == "precheck":
print("registry: cannot parse MAM_STATE_JSON (%s)" % exc)
return 1
print("ERROR: cannot parse MAM_STATE_JSON (%s)" % exc, file=sys.stderr)
return 1
if a.cmd == "plan":
sel = select_targets(ws, state)
print(json.dumps(sel))
return 1 if sel["errors"] else 0
if a.cmd == "precheck":
lines = precheck(ws, state, os.environ)
for line in lines:
print(line)
return 1 if lines else 0
if a.cmd == "entries":
try:
spec = read_spec(ws)
except SpecError as exc:
print("ERROR: %s" % exc, file=sys.stderr)
return 1
if a.warn_new:
extra = new_running_rows(ws, state, spec["agents"])
if extra:
print(
"NOTE: running control-root session(s) not in %s (not targeted by this rerun): %s"
% (spec_path(ws), ", ".join(extra)),
file=sys.stderr,
)
rows_by_name = _rows_by_name(state)
for e in spec["agents"]:
if a.session and e.get("name") != a.session:
continue
print(format_entry_line(e, rows_by_name.get(e["name"])))
return 0
if a.cmd == "open-jobs":
lines = target_open_jobs(ws, state, os.environ, a.session)
for line in lines:
print(line)
return 1 if lines else 0
if a.cmd == "fence":
try:
for d in fence_registries(ws, state, os.environ):
print("fenced %s" % d, flush=True)
except FenceBusy as exc:
print("refused %s" % exc)
return 1
except OSError as exc:
print("refused the job registries cannot be fenced against new registrations (%s)" % exc)
return 1
return 0
if a.cmd == "id-owned":
return 0 if target_id_owned(state, a.session) else 1
if a.cmd == "record-id":
try:
record_old_id(ws, state, a.session)
except (SpecError, OSError) as exc:
print("ERROR: %s" % exc, file=sys.stderr)
return 1
return 0
if a.cmd == "table":
try:
spec = read_spec(ws)
except SpecError as exc:
print("ERROR: %s" % exc, file=sys.stderr)
return 1
out, rc = build_table(
spec["agents"],
state,
skipped_items=a.skipped,
failed_spec=a.failed or None,
)
print(out)
return rc
if a.cmd == "dry-run":
out, rc = format_dry_run(ws, state, os.environ, extra_refusals=a.refusal)
print(out)
return rc
return 2
if __name__ == "__main__":
sys.exit(main())
+23 -17
View File
@@ -147,27 +147,33 @@ def _is_open(rec: dict) -> bool:
return rec.get("status") not in TERMINAL_STATUSES and rec.get("dry_run") is not True
def open_jobs(workspace: str, state: dict, env: Mapping[str, str]) -> List[dict]:
"""Every open record (not completed/error/cancelled, not dry_run) in this
workspace's .mam/jobs and MAM_JOBS_DIR; in another row's
control_plane.mam_jobs_dir only those addressed to a row of this registry.
A record, or a directory, that cannot be read is open (path only)."""
own, seen = [], set()
for c in (os.path.join(workspace, ".mam", "jobs"), env.get("MAM_JOBS_DIR") or ""):
if c and _is_jobs_dir(c) and _real(c) not in seen:
def jobs_dirs(workspace: str, state: dict, env: Mapping[str, str]) -> List[Tuple[str, bool]]:
"""(dir, addressed_only) per registry an open-job check reads, once per
real path, whether or not it exists yet: this workspace's .mam/jobs and
MAM_JOBS_DIR, then another row's control_plane.mam_jobs_dir, where only
records addressed to a row of this registry count (addressed_only)."""
rows = state.get("herdr_sessions") or []
cands = [(c, False) for c in (os.path.join(workspace, ".mam", "jobs"), env.get("MAM_JOBS_DIR") or "")]
cands += [((s.get("control_plane") or {}).get("mam_jobs_dir") or "", True) for s in rows]
out, seen = [], set()
for c, addressed_only in cands:
if c and _real(c) not in seen:
seen.add(_real(c))
own.append(c)
out.append((c, addressed_only))
return out
def open_jobs(workspace: str, state: dict, env: Mapping[str, str]) -> List[dict]:
"""Every open record (not completed/error/cancelled, not dry_run) in the
registries jobs_dirs names; in another row's control-plane registry only
those addressed to a row of this registry. A record, or a directory, that
cannot be read is open (path only)."""
rows = state.get("herdr_sessions") or []
names = {"herdr:%s" % s.get("name") for s in rows if s.get("name")}
other = []
for s in rows:
c = (s.get("control_plane") or {}).get("mam_jobs_dir") or ""
if c and _is_jobs_dir(c) and _real(c) not in seen:
seen.add(_real(c))
other.append(c)
out = []
for dirs, addressed_only in ((own, False), (other, True)):
for d in dirs:
for d, addressed_only in jobs_dirs(workspace, state, env):
if not _is_jobs_dir(d):
continue
for path, rec in _job_records(d):
if rec is None:
out.append({"path": path, "unreadable": True, "dir": path == d})
@@ -1,7 +1,7 @@
---
name: multi-agent-mux-create
description: "Create a new agent session (claude, antigravity/agy) in a dedicated herdr session for context-preserving long-running work. Always creates a herdr session — never backgrounds with nohup/disown. Writes the new session to .mam/agent-sessions.yaml. Use when you want to start a fresh agent (no prior UUID) for a new project workspace."
version: 4.13.0
version: 4.14.0
author: godopu
license: MIT
platforms: [linux, macos]
@@ -1,7 +1,7 @@
---
name: multi-agent-mux-delegate-job
description: "Delegate a unit of work to any autonomous agent (claude-code, hermes, agy, grok-build, codex, or a human) and observe it asynchronously over an MQTT event channel. Supported roles include orchestrator, worker, and reviewer."
version: 4.13.0
version: 4.14.0
author: godopu
license: MIT
platforms: [linux, macos, windows]
@@ -306,7 +306,8 @@ cmd_submit() {
# its row's, or with no row the live agent's on the server delivery uses)
# runs; each is held, fd 7 then 20.., until the record is written (lib.sh
# mam_update_admit). A dry-run record is never dispatched and is not
# counted by the update.
# counted by the update. Issue #78: likewise while a reset of one runs
# (mam_reset_admit, checked under the same admission).
local admit_fds=() target_ws tws fd=20
if [[ "$DRY_RUN" != "1" ]]; then
target_ws="$(MAM_STATE_JSON="$(load_state_json)" PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" "$PY" -m lib_py.update \
@@ -315,9 +316,11 @@ cmd_submit() {
--herdr "$(_resolve_real_herdr_path || true)")" \
|| { echo "ERROR: cannot resolve the workspace of $AGENT_SESSION; nothing was registered" >&2; exit 1; }
mam_update_admit "$WORKSPACE_ROOT" || exit 1
mam_reset_admit "$WORKSPACE_ROOT" || exit 1
while IFS= read -r tws; do
[[ -n "$tws" ]] || continue
mam_update_admit "$tws" "$fd" || exit 1
mam_reset_admit "$tws" || exit 1
admit_fds+=("$fd"); fd=$((fd + 1))
done <<< "$target_ws"
fi
@@ -141,8 +141,9 @@ $PY scripts/registry.py status --job <id> --set completed # set status
$PY scripts/registry.py pick --agent-session herdr:claude # claim → running
```
Exit codes: `0` ok, `1` not found / bad status, `3` (`pick`) no pending job for
that session.
Exit codes: `0` ok, `1` not found / bad status / (`register`) a reset fenced
this registry (its `.reset-in-progress` names a live reset, Issue #78), `3` (`pick`) no
pending job for that session.
---
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: multi-agent-mux-loop
description: "Run an autonomous planning-execution-review loop using multiple agents (Planner, Creator, Reviewers) in the workspace. Automatically orchestrates plan discussion, code changes, and peer reviews until a unanimous PASS is achieved or the maximum iteration limit is reached."
version: 4.13.0
version: 4.14.0
author: godopu
license: MIT
platforms: [linux, macos]
@@ -1,7 +1,7 @@
---
name: multi-agent-mux-monitor
description: "Run a long-lived reconciler that watches .mam/agent-sessions.yaml against the actual herdr/agent runtime state and reconciles them. Use when you want live visibility into which agent sessions are running, which are dead, which have stale YAML entries, and which have new session ids that haven't been recorded yet. Runs as a persistent loop (`reconcile.sh --subscribe`) that keeps going until it times out, idles out, or is interrupted."
version: 4.13.0
version: 4.14.0
author: godopu
license: MIT
platforms: [linux, macos]
@@ -1,7 +1,7 @@
---
name: multi-agent-mux-orc-onboard
description: "Register current or specified orchestrator session UUID into agent-sessions.yaml orchestrator_uuids list to prevent sub-agent discovery capture."
version: 4.13.0
version: 4.14.0
author: godopu
license: MIT
platforms: [linux, macos]
@@ -0,0 +1,48 @@
---
name: multi-agent-mux-reset
description: "Purge the control root's running resident agents and recreate each with the same name, agent, role, herdr server and workspace label, one at a time, with onboarding. Persists .mam/reset-spec.json before the first purge so an interrupted or skipped run resumes where it left off."
version: 4.14.0
author: godopu
license: MIT
platforms: [linux, macos]
environments: [terminal, herdr]
metadata:
hermes:
tags: [reset, purge, recreate, lifecycle, onboarding]
related_skills: [multi-agent-mux-stop, multi-agent-mux-create, multi-agent-mux-status]
---
# Multi-Agent Reset
```bash
bash .agents/skills/multi-agent-mux-reset/scripts/reset.sh --yes [--dry-run]
```
| Item | Behaviour |
|---|---|
| Targets | Every registry row with `status: running` and `realpath(pane.cwd) == realpath(WS)`. Rows with any other status, worktree rows, and `orchestrator_uuids` are skipped and listed with their reason. |
| Confirmation | `[y/N]` prompt; without a terminal `-y`/`--yes` is required (exits `3` otherwise). `--dry-run` inspects without `--yes` and changes nothing. |
| Prechecks | Refuses before any change when an open job exists (prints the `registry.py ... close --request cancelled` command), when `.mam/loop-guard-active` or `.mam/update-in-progress` is held, when a target row lacks a valid agent or role, when a running `claude`/`grok` target has no recorded conversation id (the monitor had not pinned one; record it, or stop/purge it by hand first), when `.mam/reset-spec.json` is invalid, or when the caller itself is one of the targets. |
| Lock & concurrency | Takes the update admission lock exclusively, acquires `.mam/loop-guard-active` and publishes `.mam/reset-in-progress`, then releases the admission lock and runs the prechecks. `run_loop.sh`, `update.sh` and another reset refuse on the loop marker. The reset then writes `.reset-in-progress` into every job registry its open-job check reads (`.mam/jobs`, `MAM_JOBS_DIR`, and a row's other `control_plane.mam_jobs_dir`; created when absent), each under that registry's lock, before the check. It refuses, changing nothing, when a registry shared with another workspace is already fenced by that workspace's live reset; it never replaces or removes another reset's marker. A registration (`multi-agent-mux-delegate-job submit`, `registry.py register`, the registry API) into a fenced registry - by any path to that directory - refuses; in another control plane's registry only for this workspace's sessions. The onboarding jobs of the reset's own `create_session.sh` are let through. |
| Spec file & rerun | Writes `.mam/reset-spec.json` atomically before the first stop/purge and removes it only when all targets reach `done`. Rerunning resumes from the spec file without re-purging already recreated agents (`created` or `done`). |
| Sequence per target | Sequential (never parallel): ⓪ re-checks that its row still has the spec's agent and herdr server and that no job addressed to it is open, ① normal `stop_session.sh --reason reset` (a post-discovered agent's id is captured onto the row while herdr is live; `status: stopped`), ② checks that the row's id is the one recorded before the stop and, for a post-discovered agent, that it still verifies as a conversation of the row's folder, ③ `stop_session.sh --reason reset --purge-conversation --yes` (using the row's recorded ID, never a CLI UUID), then `create_session.sh` with the original name, agent, role, `--herdr-session`, and `--herdr-workspace` (onboarding enabled by default). |
| When skipped (exit 4) | The target is skipped and the reset continues to the next agent when step ⓪ finds a changed row (`row changed`) or an open job (`job open`) - nothing was changed for it -, or when step ② finds no id (`no conversation id recorded`) or an id other than the recorded one or one that does not verify for the row's folder (`id not verified`), or step ③ exits `4` (`purge refused`, in-use gate) - it is stopped but resumable (its row keeps its id) and its conversation files are untouched. Rerun once the cause is gone, or resume it. |
| Exit | Meaning |
|---|---|
| 0 | All targeted agents reached `done` and `.mam/reset-spec.json` was removed; or nothing to reset, cancelled at prompt, or `--dry-run` with no blockers |
| 1 | Refused by precheck (nothing changed), corrupt/mismatched `.mam/reset-spec.json`, or a `stop`/`create` step failed (`.mam/reset-spec.json` is kept so rerunning resumes) |
| 2 | Invalid arguments |
| 3 | Not a terminal and `-y`/`--yes` was not passed; nothing was changed |
| 4 | Completed the pass, but one or more agents were skipped (see *When skipped*); `.mam/reset-spec.json` is kept for rerun |
| 5 | No agent was skipped or failed, but one or more newly created post-discovered agents do not have their new conversation ID recorded yet; `.mam/reset-spec.json` is kept so rerunning re-checks without re-purging |
| other | 130 / 143 / 129 when interrupted by SIGINT / SIGTERM / SIGHUP (the in-flight stop/create ends first, nothing further runs, `.mam/reset-spec.json` is kept); otherwise, under `set -e`, an unexpected internal step failure passes its nonzero exit code through |
## Safety Notes
- **Irreversible**: purged conversations cannot be recovered. Confirm with `--dry-run` first.
- **No external process termination**: processes holding conversation files open (reported via `lsof` in `in_use_hints`) are never signalled by MAM; end them yourself when safe, then rerun.
- **Shared stores preserved**: Grok's shared `memory-v2.db` is left intact during purge; only session rows and per-session files are removed.
- **Orchestrator hook during reset**: while `.mam/loop-guard-active` is held by the reset, the AGY orchestrator's direct file-edit hook (`loop_delegation_guard.sh`) blocks direct edits until the reset finishes.
- **Job submissions during a reset**: `multi-agent-mux-delegate-job submit` and `registry.py register` refuse with `a reset of <WS> is in progress` until the reset ends (a marker left by a killed reset is stale and ignored); submit again afterwards. A record written into `.mam/jobs` by hand for a target after the precheck makes that target `job open` (skipped, not cancelled) if it is there before that target's stop.
- **Same-type post-discovered ordering**: when resetting multiple post-discovered agents of the same type, the second `create_session.sh` waits up to `MAM_ID_ORDER_WAIT_S` for the first agent's ID to be recorded; if it times out, rerun the reset once the ID is recorded.
+349
View File
@@ -0,0 +1,349 @@
#!/usr/bin/env bash
# reset.sh — multi-agent-mux-reset (Issue #78): purge the control root's
# running resident agents and recreate each with the same name, agent, role,
# herdr server and workspace label, one at a time, with onboarding.
# Usage:
# bash .agents/skills/multi-agent-mux-reset/scripts/reset.sh [-y|--yes] [--dry-run]
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SKILL_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
WS="$(cd -P "$SCRIPT_DIR/../../../.." && pwd -P)"
export WORKSPACE_ROOT="$WS"
export AGENT_SESSIONS_YAML="${AGENT_SESSIONS_YAML:-$WS/.mam/agent-sessions.yaml}"
export PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}"
usage() {
cat <<'USAGE'
Usage: bash .agents/skills/multi-agent-mux-reset/scripts/reset.sh [-y|--yes] [--dry-run]
Options:
-y, --yes Confirm purging and recreating the control root's running agents
--dry-run Inspect targets, skipped rows, spec progress and blockers without changing anything
-h, --help Show this help
USAGE
}
YES=0
DRY_RUN=0
while [ $# -gt 0 ]; do
case "$1" in
-y|--yes) YES=1; shift ;;
--dry-run) DRY_RUN=1; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "ERROR: unknown arg: $1" >&2; usage >&2; exit 2 ;;
esac
done
if [ "$DRY_RUN" != "1" ] && [ "$YES" != "1" ] && [ ! -t 0 ]; then
echo "ERROR: not a terminal; run with -y/--yes (or --dry-run to inspect)" >&2
exit 3
fi
MAM="$WS/.mam"
MARKER="${MAM_LOOP_MARKER:-$MAM/loop-guard-active}"
RESET_MARKER="$MAM/reset-in-progress"
SPEC_FILE="$MAM/reset-spec.json"
# shellcheck disable=SC1091
source "$SKILL_DIR/multi-agent-mux-loop/scripts/loop_lock.sh"
if [ "$DRY_RUN" = "1" ]; then
if [ -x "$WS/.venv/bin/python" ]; then
PY="$WS/.venv/bin/python"
elif command -v python3 >/dev/null 2>&1; then
PY="python3"
else
PY="python"
fi
DRY_ARGS=()
if [ -f "$MAM/update-in-progress" ]; then
upd_st="$(_mam_lock_holder_state "$MAM/update-in-progress")"
case "$upd_st" in
stale) echo "NOTE: stale update marker $MAM/update-in-progress (its update is gone); not a reason to refuse" ;;
*) DRY_ARGS+=(--refusal "an update of $WS is in progress (pid $(_mam_lock_field "$MAM/update-in-progress" pid), holder $upd_st); wait for it to finish") ;;
esac
fi
if [ -f "$MARKER" ]; then
loop_st="$(_mam_lock_holder_state "$MARKER")"
case "$loop_st" in
stale) echo "NOTE: stale loop marker $MARKER (its loop is gone); not a reason to refuse" ;;
*) DRY_ARGS+=(--refusal "a review loop or another reset is running (pid $(_mam_lock_field "$MARKER" pid), holder $loop_st); wait for it to end") ;;
esac
fi
dry_rc=0
"$PY" -m lib_py.reset dry-run --workspace "$WS" ${DRY_ARGS[@]+"${DRY_ARGS[@]}"} || dry_rc=$?
if [ "$dry_rc" -eq 0 ]; then
exit 0
fi
exit 1
fi
# shellcheck disable=SC1091
source "$WS/.agents/skills/lib.sh"
PY="$(_delegate_py_bin)"
_refuse_locked() {
exec 7>&-
echo "ERROR: reset refused; nothing was changed:" >&2
echo " - $1" >&2
exit 1
}
# The update admission lock is taken exclusively, as update.sh does, while the
# reset publishes its markers: a delegate submit admitted earlier has written
# its job record (the precheck below sees it), and one admitted later sees
# .mam/reset-in-progress and refuses. Any other registration (registry.py
# register, the delegation API) is fenced below.
mkdir -p "$MAM"
exec 7>>"$MAM/update-admission.lock"
case "$("$PY" -m lib_py.pane_lock acquire --fd 7 --wait-env MAM_UPDATE_ADMISSION_WAIT_S)" in
locked) ;;
timeout) _refuse_locked "a create, resume, loop, delegate submit, stop or orc-onboard kept the admission lock past MAM_UPDATE_ADMISSION_WAIT_S; try again" ;;
*) _refuse_locked "the admission lock $MAM/update-admission.lock could not be decided (lib_py.pane_lock)" ;;
esac
if [ -f "$MAM/update-in-progress" ]; then
upd_st="$(_mam_lock_holder_state "$MAM/update-in-progress")"
if [ "$upd_st" != "stale" ]; then
_refuse_locked "an update of $WS is in progress (pid $(_mam_lock_field "$MAM/update-in-progress" pid), holder $upd_st); wait for it to finish"
fi
fi
_acquire_rc=0
mam_acquire_loop_lock "$MARKER" || _acquire_rc=$?
case "$_acquire_rc" in
0) ;;
3) _refuse_locked "a review loop or another reset is running (pid $(_mam_lock_field "$MARKER" "pid" 2>/dev/null || echo "unknown")); wait for it to end" ;;
*) _refuse_locked "cannot determine whether a review loop or another reset is running (ps unavailable?)" ;;
esac
FENCED=()
_release_reset_lock() {
local d
for d in ${FENCED[@]+"${FENCED[@]}"}; do
[ "$(_mam_lock_field "$d/.reset-in-progress" pid 2>/dev/null || true)" != "$$" ] || rm -f "$d/.reset-in-progress"
done
mam_release_loop_lock "$RESET_MARKER" || true
mam_release_loop_lock "$MARKER" || true
}
_on_reset_signal() {
local sig_rc="$1"
_release_reset_lock
exit "$sig_rc"
}
trap _release_reset_lock EXIT
trap '_on_reset_signal 130' INT
trap '_on_reset_signal 143' TERM
trap '_on_reset_signal 129' HUP
if ! mam_acquire_loop_lock "$RESET_MARKER"; then
_refuse_locked "cannot publish $RESET_MARKER"
fi
exec 7>&-
STATE_JSON=""
if ! STATE_JSON="$(load_state_json strict)"; then
echo "ERROR: reset refused; nothing was changed:" >&2
echo " - the registry $AGENT_SESSIONS_YAML cannot be read; fix it first" >&2
exit 1
fi
# Every registry the precheck reads gets the reset marker under its own lock
# (lib_py.reset fence): a registration that read none is written by then, any
# later one is refused, by whatever path it names that registry.
fence_out=""
fence_rc=0
fence_out="$(MAM_STATE_JSON="$STATE_JSON" "$PY" -m lib_py.reset fence --workspace "$WS")" || fence_rc=$?
fence_refusal=""
while IFS= read -r line; do
case "$line" in
"fenced "*) FENCED+=("${line#fenced }") ;;
"refused "*) fence_refusal="${line#refused }" ;;
esac
done <<< "$fence_out"
if [ "$fence_rc" -ne 0 ]; then
echo "ERROR: reset refused; nothing was changed:" >&2
echo " - ${fence_refusal:-the job registries could not be fenced against new registrations (lib_py.reset fence rc $fence_rc)}" >&2
exit 1
fi
pre_out=""
pre_rc=0
pre_out="$(MAM_STATE_JSON="$STATE_JSON" "$PY" -m lib_py.reset precheck --workspace "$WS")" || pre_rc=$?
if [ "$pre_rc" -ne 0 ]; then
echo "ERROR: reset refused; nothing was changed:" >&2
while IFS= read -r line; do
[ -n "$line" ] && echo " - $line" >&2
done <<< "${pre_out:-lib_py.reset precheck failed (rc $pre_rc)}"
exit 1
fi
if [ -f "$SPEC_FILE" ]; then
MAM_STATE_JSON="$STATE_JSON" "$PY" -m lib_py.reset dry-run --workspace "$WS" || exit 1
if [ "$YES" != "1" ]; then
ans=""
read -r -p "Resume reset from $SPEC_FILE? [y/N]: " ans || ans=""
case "$ans" in
y|Y|yes|Yes|YES) ;;
*) echo "Reset cancelled; nothing was changed"; exit 0 ;;
esac
fi
else
PLAN_JSON="$(MAM_STATE_JSON="$STATE_JSON" "$PY" -m lib_py.reset plan --workspace "$WS")" || exit 1
TARGET_COUNT="$(printf '%s' "$PLAN_JSON" | "$PY" -c 'import json,sys; print(len(json.load(sys.stdin).get("targets", [])))')"
MAM_STATE_JSON="$STATE_JSON" "$PY" -m lib_py.reset dry-run --workspace "$WS" || exit 1
if [ "$TARGET_COUNT" = "0" ]; then
echo "nothing to reset"
exit 0
fi
if [ "$YES" != "1" ]; then
ans=""
read -r -p "Purge and recreate $TARGET_COUNT agents? [y/N]: " ans || ans=""
case "$ans" in
y|Y|yes|Yes|YES) ;;
*) echo "Reset cancelled; nothing was changed"; exit 0 ;;
esac
fi
printf '%s' "$PLAN_JSON" | "$PY" -m lib_py.reset write-spec --workspace "$WS" || exit 1
fi
ENTRIES_OUT="$(MAM_STATE_JSON="$STATE_JSON" "$PY" -m lib_py.reset entries --workspace "$WS" --warn-new)" || exit 1
ENTRY_NAMES=()
while IFS= read -r line; do
[ -n "$line" ] || continue
eval "$line"
ENTRY_NAMES+=("$R_NAME")
done <<< "$ENTRIES_OUT"
SKIPPED_ARGS=()
FAILED_SPEC=""
for target_name in ${ENTRY_NAMES[@]+"${ENTRY_NAMES[@]}"}; do
if ! STATE_JSON="$(load_state_json strict)"; then
FAILED_SPEC="$target_name:stop:1"
break
fi
entry_line="$(MAM_STATE_JSON="$STATE_JSON" "$PY" -m lib_py.reset entries --workspace "$WS" --session "$target_name")" || {
FAILED_SPEC="$target_name:stop:1"
break
}
eval "$entry_line"
case "$R_STATE" in
done|created)
continue
;;
pending)
if [ "$R_ROW_AGENT" != "$R_AGENT" ] || [ "$R_ROW_SERVER" != "$R_SERVER" ]; then
echo "skipped $R_NAME: its row ($R_ROW_AGENT on herdr server $R_ROW_SERVER) no longer matches $SPEC_FILE ($R_AGENT on $R_SERVER); nothing was changed for it; check the row, then rerun the reset"
SKIPPED_ARGS+=(--skipped "$R_NAME:changed")
continue
fi
jobs_out=""
jobs_out="$(MAM_STATE_JSON="$STATE_JSON" "$PY" -m lib_py.reset open-jobs --workspace "$WS" --session "$R_NAME")" || {
echo "skipped $R_NAME: a job was registered for it after the precheck (${jobs_out//$'\n'/; }); nothing was changed for it; rerun the reset once the job has ended"
SKIPPED_ARGS+=(--skipped "$R_NAME:open_job")
continue
}
STOP_CMD=(bash "$SKILL_DIR/multi-agent-mux-stop/scripts/stop_session.sh" --session "$R_NAME" --agent "$R_AGENT")
if [ -n "$R_SERVER" ]; then
STOP_CMD+=(--herdr-session "$R_SERVER")
fi
STOP_CMD+=(--reason reset)
echo "reset [$R_NAME]: ${STOP_CMD[*]}"
stop_rc=0
"${STOP_CMD[@]}" || stop_rc=$?
if [ "$stop_rc" -ne 0 ]; then
FAILED_SPEC="$R_NAME:stop:$stop_rc"
break
fi
if ! STATE_JSON="$(load_state_json strict)"; then
FAILED_SPEC="$R_NAME:stop:1"
break
fi
if [ -z "$R_OLD_ID" ]; then
MAM_STATE_JSON="$STATE_JSON" "$PY" -m lib_py.reset record-id --workspace "$WS" --session "$R_NAME" || {
FAILED_SPEC="$R_NAME:stop:1"
break
}
fi
entry_line="$(MAM_STATE_JSON="$STATE_JSON" "$PY" -m lib_py.reset entries --workspace "$WS" --session "$R_NAME")" || {
FAILED_SPEC="$R_NAME:stop:1"
break
}
eval "$entry_line"
if [ -z "$R_ROW_ID" ]; then
echo "skipped $R_NAME: no conversation id is recorded, so a purge would drop the row and leave the conversation on disk; resume it, or purge it by hand (stop_session.sh --session $R_NAME --purge-conversation --yes) and rerun the reset to recreate it"
SKIPPED_ARGS+=(--skipped "$R_NAME:no_id")
continue
fi
if [ "$R_ROW_ID" != "$R_OLD_ID" ]; then
echo "skipped $R_NAME: the stop left id $R_ROW_ID on its row, not the one recorded before the reset (${R_OLD_ID:-none}); it may be another conversation's, so nothing is purged; check it, purge it by hand if it is this agent's, and rerun the reset"
SKIPPED_ARGS+=(--skipped "$R_NAME:id_unverified")
continue
fi
if ! MAM_STATE_JSON="$STATE_JSON" "$PY" -m lib_py.reset id-owned --workspace "$WS" --session "$R_NAME"; then
echo "skipped $R_NAME: its recorded id $R_ROW_ID does not verify as a conversation of its folder, so it may be another folder's and nothing is purged; check it, purge it by hand if it is this agent's, and rerun the reset"
SKIPPED_ARGS+=(--skipped "$R_NAME:id_unverified")
continue
fi
echo "reset [$R_NAME]: ${STOP_CMD[*]} --purge-conversation --yes"
purge_rc=0
"${STOP_CMD[@]}" --purge-conversation --yes || purge_rc=$?
if [ "$purge_rc" -eq 4 ]; then
echo "skipped $R_NAME: its purge was refused (stop exit 4); the row (now stopped, id recorded) and the conversation are kept; the processes above hold it - they are not killed; rerun the reset once they end"
SKIPPED_ARGS+=(--skipped "$R_NAME:purge_refused")
continue
fi
if [ "$purge_rc" -ne 0 ]; then
FAILED_SPEC="$R_NAME:stop:$purge_rc"
break
fi
R_STATE="purged"
;;
esac
if [ "$R_STATE" = "purged" ]; then
CREATE_CMD=(bash "$SKILL_DIR/multi-agent-mux-create/scripts/create_session.sh" --workspace "$WS" --agent "$R_AGENT" --role "$R_ROLE" --session "$R_NAME")
if [ -n "$R_SERVER" ]; then
CREATE_CMD+=(--herdr-session "$R_SERVER")
fi
if [ -n "$R_LABEL" ]; then
CREATE_CMD+=(--herdr-workspace "$R_LABEL")
fi
echo "reset [$R_NAME]: ${CREATE_CMD[*]}"
create_rc=0
# Its onboarding job is the one registration the reset marker lets through.
MAM_RESET_OWNER_PID="$$" "${CREATE_CMD[@]}" || create_rc=$?
if [ "$create_rc" -ne 0 ]; then
FAILED_SPEC="$R_NAME:create:$create_rc"
break
fi
fi
done
STATE_JSON="$(load_state_json strict)" || exit 1
TABLE_ARGS=(--workspace "$WS")
if [ ${#SKIPPED_ARGS[@]} -gt 0 ]; then
TABLE_ARGS+=("${SKIPPED_ARGS[@]}")
fi
if [ -n "$FAILED_SPEC" ]; then
TABLE_ARGS+=(--failed "$FAILED_SPEC")
fi
table_rc=0
MAM_STATE_JSON="$STATE_JSON" "$PY" -m lib_py.reset table "${TABLE_ARGS[@]}" || table_rc=$?
case "$table_rc" in
0)
rm -f "$SPEC_FILE"
exit 0
;;
4)
exit 4
;;
5)
exit 5
;;
*)
exit 1
;;
esac
@@ -1,7 +1,7 @@
---
name: multi-agent-mux-resume
description: "Resume an existing agent (claude, antigravity/agy) conversation by UUID into a herdr session. Reads .mam/agent-sessions.yaml for the saved session/conversation id, spawns (or reuses) a herdr session of the matching name, and runs `claude -r <id>` or `agy --conversation <id>` inside. Use when you want to reattach to a previous session's context, or revive a session whose herdr died but the agent's conversation is still on disk."
version: 4.13.0
version: 4.14.0
author: godopu
license: MIT
platforms: [linux, macos]
@@ -1,7 +1,7 @@
---
name: multi-agent-mux-status
description: "Read-only instant snapshot of all agent herdr sessions — name, YAML status, herdr alive, pane cmd/cwd, resume UUID on disk, and any drift. No mutation. Reuses reconcile.sh --dry-run for the diff logic. Use when you want to know 'what's running RIGHT NOW' without spinning up the monitor loop."
version: 4.13.0
version: 4.14.0
author: godopu
license: MIT
platforms: [linux, macos]
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: multi-agent-mux-stop
description: "Stop an agent herdr session (claude, antigravity/agy) and update .mam/agent-sessions.yaml. Default stops gracefully and marks status=stopped with conversation preserved for resume. Does NOT delete on-disk conversation artifacts (jsonl/db) — those are preserved unless --purge-conversation is passed. Use when ending a work session, switching to a different one, or cleaning up before a fresh start."
version: 4.13.0
version: 4.14.0
author: godopu
license: MIT
platforms: [linux, macos]
@@ -1,7 +1,7 @@
---
name: multi-agent-mux-update
description: "Update an installed MAM workspace in place to the latest version. Refuses while a session, an open job, a review loop or an unknown monitor is live; stops the monitor daemon and starts it again afterwards; rolls everything back if the install fails; converts the registry with the newly installed code. Use instead of the removed .mam_deploy/update.sh."
version: 4.13.0
version: 4.14.0
author: godopu
license: MIT
platforms: [linux, macos]
+1 -1
View File
@@ -2,7 +2,7 @@
본 문서는 `herdr_agent_orchestration` 오케스트레이션 및 메시징 백플레인 워크플로우를 새로운 프로젝트에 도입하여 이식하고, 새로운 개발자/에이전트가 초기 가동을 시작할 때 수행해야 하는 환경 설정 및 구축 절차를 안내합니다.
[![Version: v4.13.0](https://img.shields.io/badge/version-v4.13.0-blue.svg)](VERSIONS.md)
[![Version: v4.14.0](https://img.shields.io/badge/version-v4.14.0-blue.svg)](VERSIONS.md)
새로운 에이전트는 이 안내서에 기술된 절차를 순차적으로 실행하여 초기 환경을 안정적으로 설정할 수 있습니다.
+1 -1
View File
@@ -2,7 +2,7 @@
This document guides you through the setup and initialization procedures required to adopt the `herdr_agent_orchestration` orchestration and messaging backplane workflow in a new project, enabling a new developer or agent to get up and running quickly.
[![Version: v4.13.0](https://img.shields.io/badge/version-v4.13.0-blue.svg)](VERSIONS.md)
[![Version: v4.14.0](https://img.shields.io/badge/version-v4.14.0-blue.svg)](VERSIONS.md)
A new agent can follow the steps in this guide sequentially to establish a stable and reliable initial environment.
+1 -1
View File
@@ -1,6 +1,6 @@
# Messaging System Technical Analysis & Architecture Report
Applies to: MAM v4.13.0
Applies to: MAM v4.14.0
This report provides a comprehensive, deep-dive analysis of the messaging system implemented in the `multi-agent-mux-delegate-job` skill. It covers the MQTT broker architecture, event protocols, job lifecycles, codebase internals, cross-system integration, and a list of known limitations along with production recommendations.
+2 -1
View File
@@ -2,7 +2,7 @@
[herdr](https://herdr.dev)와 MQTT 위에 만든 **다중 에이전트 오케스트레이션·메시징 백플레인**입니다. 장시간 실행되는 코딩 에이전트(`claude`, `agy`, `hermes`, `grok`, `opencode`, `codex`)를 격리된 herdr pane 에서 돌리고, 작업을 추적되는 잡으로 위임하며, 모든 리뷰어가 통과시킬 때까지 planner / creator / reviewer 루프를 돌립니다.
[![Version: v4.13.0](https://img.shields.io/badge/version-v4.13.0-blue.svg)](VERSIONS.md)
[![Version: v4.14.0](https://img.shields.io/badge/version-v4.14.0-blue.svg)](VERSIONS.md)
---
@@ -57,6 +57,7 @@ bash deploy/install.sh
| **`multi-agent-mux-loop`** | 계획·구현·리뷰: 선택적인 planner 와 creator, 그다음 선택한 리뷰어(`--reviewer A,B`, 또는 `--all-reviewer` 로 creator 를 뺀 실행 중인 리뷰어 전원)가 모두 `PASS` 를 낼 때까지(또는 `--max-loop` 까지). 둘 다 없으면 creator 가 스스로 리뷰합니다. 루프 밖에서 끝난 작업은 `--review-only` 로 리뷰합니다. 통과한 리포트는 `.agents/reports/<session>/` 으로 옮겨 보관합니다. |
| **`multi-agent-mux-orc-onboard`** | 오케스트레이터 자신의 세션 ID 를 등록해, 세션 탐색이 그것을 하위 에이전트로 착각하지 않게 합니다. |
| **`multi-agent-mux-update`** | 설치된 작업 공간을 제자리에서 업데이트합니다. 실행 중인 세션·열린 잡·루프가 있으면 거부하고, 모니터를 멈췄다가 원래대로 되돌리며, 설치가 실패하면 되돌리고, 새 코드로 레지스트리를 변환합니다. |
| **`multi-agent-mux-reset`** | 제어 루트의 실행 중인 상주 에이전트를 하나씩 정리(purge)하고 같은 이름·에이전트·역할·herdr 서버·작업 공간 라벨로 다시 만들며 온보딩합니다(`.mam/reset-spec.json` 으로 중단된 지점부터 재개). |
---
+2 -1
View File
@@ -2,7 +2,7 @@
A **Multi-Agent Orchestration & Messaging Backplane** built on [herdr](https://herdr.dev) and MQTT. It runs long-lived coding agents (`claude`, `agy`, `hermes`, `grok`, `opencode`, `codex`) in isolated herdr panes, delegates work to them as tracked jobs, and drives planner / creator / reviewer loops until every reviewer passes the change.
[![Version: v4.13.0](https://img.shields.io/badge/version-v4.13.0-blue.svg)](VERSIONS.md)
[![Version: v4.14.0](https://img.shields.io/badge/version-v4.14.0-blue.svg)](VERSIONS.md)
---
@@ -57,6 +57,7 @@ Every skill lives under `.agents/skills/` with a `SKILL.md` that documents its o
| **`multi-agent-mux-loop`** | Plan, implement, review: an optional planner and a creator, then the selected reviewers (`--reviewer A,B`, or `--all-reviewer` for every running reviewer except the creator) until all of them `PASS` or `--max-loop` is reached. With neither option the creator reviews its own work. `--review-only` reviews work finished outside the loop. Passing reports are promoted to `.agents/reports/<session>/`. |
| **`multi-agent-mux-orc-onboard`** | Registers the orchestrator's own session id, so session discovery never mistakes it for a sub-agent. |
| **`multi-agent-mux-update`** | Updates an installed workspace in place: refuses while sessions, open jobs or a loop are live; stops and restores the monitor; rolls back on a failed install; converts the registry with the new code. |
| **`multi-agent-mux-reset`** | Purges the control root's running resident agents and recreates each with the same name, agent, role, herdr server and workspace label, one at a time, with onboarding (`.mam/reset-spec.json` for resumable reruns). |
---
+43 -13
View File
@@ -6,8 +6,8 @@
## 📌 현재 버전 개요 (Current Release)
- **프레임워크 버전**: `v4.13.0`
- **최신 릴리스 일시**: 2026-10-29 (KST)
- **프레임워크 버전**: `v4.14.0`
- **최신 릴리스 일시**: 2026-10-30 (KST)
- **기준 브랜치**: `main`
- **핵심 아키텍처**:
- **Terminal 상태 단방향 불변성 (`engine.py`, Issue #6 Phase 1)**: `completed`, `error`, `cancelled` 터미널 상태 진입 후 후속 이벤트나 업데이트에 의한 덮어쓰기를 엄격히 차단(Terminal Immutability). `registry.py close` 서브커맨드 및 fail-closed 조기 종료 도입.
@@ -18,30 +18,60 @@
- **워크트리 운영 모델 2단계 (Issue #21 항목 4·5·7)**: 워크트리 준비·해체 헬퍼(변경 전 판정, untracked 포함, 검증된 단일 `--force`), 리뷰 identity(라운드별 스냅샷·stale 재리뷰·승격 직전 재확인·`identity.json`), 수용 사례 A1–A8.
- **잡 예산·라벨·멈춤 알림 (Issue #33·#30)**: 위임 잡 예산은 `--job-timeout`/`MAM_JOB_TIMEOUT_S`, 벽시계 초과는 `wall_clock_timeout`(무응답 `deadline_timeout` 과 구분), `lib_py/stall_watch.py` 가 `blocked`/`idle` 을 `progress stalled:*` 로 알리기만 한다(종결 근거는 #18 의 프로토콜 이벤트뿐).
- **설정 파일 로더 하나 (Issue #26)**: `.mam.env` 는 `lib_py/delegation/envfile.py` 한 곳이 읽는다(셸 해석 없음, 환경변수 우선, 읽을 수 없는 파일은 오류). bash 산술에 들어가는 정수 설정값은 `mam_int` 로 10진수.
- **4-Way 버전 락스텝**: `lib.sh`(`MAM_VERSION`) ↔ `lib_py/loop/eager.py`(`FRAMEWORK_VERSION`) ↔ `VERSIONS.md` ↔ 9개 `SKILL.md`.
- **4-Way 버전 락스텝**: `lib.sh`(`MAM_VERSION`) ↔ `lib_py/loop/eager.py`(`FRAMEWORK_VERSION`) ↔ `VERSIONS.md` ↔ 10개 `SKILL.md`.
---
## 🧭 스킬 패키지 버전 매트릭스 (Skills Version Matrix)
모든 9개 스킬은 YAML frontmatter 메타데이터(`author`, `version`, `platforms`, `environments`) 표준화를 통해 `v4.13.0`로 동기화되어 배포됩니다.
모든 10개 스킬은 YAML frontmatter 메타데이터(`author`, `version`, `platforms`, `environments`) 표준화를 통해 `v4.14.0`로 동기화되어 배포됩니다.
| 스킬명 | 버전 | 역할 및 주요 책임 | 상태 |
| :--- | :---: | :--- | :---: |
| **`multi-agent-mux-create`** | `4.13.0` | 에이전트 세션 신규 생성 및 Herdr 컨테이너 격리 스폰 | ✅ 배포 |
| **`multi-agent-mux-stop`** | `4.13.0` | 대화 UUID 원자적 캡처 및 세션 안전 종료 (Graceful Stop) | ✅ 배포 |
| **`multi-agent-mux-resume`** | `4.13.0` | 온디스크 대화 컨텍스트 기반 Tier-1 초고속 세션 복원 | ✅ 배포 |
| **`multi-agent-mux-status`** | `4.13.0` | 실시간 Herdr 세션 및 레지스트리 드리프트 스냅샷 조회 | ✅ 배포 |
| **`multi-agent-mux-monitor`** | `4.13.0` | YAML ↔ 런타임 상태 간 자율 조정자 (Reconciler Loop) | ✅ 배포 |
| **`multi-agent-mux-delegate-job`** | `4.13.0` | MQTT 이벤트 채널 기반 비동기 단위 작업 위임 | ✅ 배포 |
| **`multi-agent-mux-loop`** | `4.13.0` | Planner-Creator-Reviewer 3자 자율 계획·실행·피어리뷰 루프 | ✅ 배포 |
| **`multi-agent-mux-orc-onboard`** | `4.13.0` | 오케스트레이터 UUID 격리 등록 및 서브 세션 오염 방지 | ✅ 배포 |
| **`multi-agent-mux-update`** | `4.13.0` | 설치된 작업 공간의 제자리 업데이트·되돌리기와 업데이트 중 입장 차단 | ✅ 배포 |
| **`multi-agent-mux-create`** | `4.14.0` | 에이전트 세션 신규 생성 및 Herdr 컨테이너 격리 스폰 | ✅ 배포 |
| **`multi-agent-mux-stop`** | `4.14.0` | 대화 UUID 원자적 캡처 및 세션 안전 종료 (Graceful Stop) | ✅ 배포 |
| **`multi-agent-mux-resume`** | `4.14.0` | 온디스크 대화 컨텍스트 기반 Tier-1 초고속 세션 복원 | ✅ 배포 |
| **`multi-agent-mux-status`** | `4.14.0` | 실시간 Herdr 세션 및 레지스트리 드리프트 스냅샷 조회 | ✅ 배포 |
| **`multi-agent-mux-monitor`** | `4.14.0` | YAML ↔ 런타임 상태 간 자율 조정자 (Reconciler Loop) | ✅ 배포 |
| **`multi-agent-mux-delegate-job`** | `4.14.0` | MQTT 이벤트 채널 기반 비동기 단위 작업 위임 | ✅ 배포 |
| **`multi-agent-mux-loop`** | `4.14.0` | Planner-Creator-Reviewer 3자 자율 계획·실행·피어리뷰 루프 | ✅ 배포 |
| **`multi-agent-mux-orc-onboard`** | `4.14.0` | 오케스트레이터 UUID 격리 등록 및 서브 세션 오염 방지 | ✅ 배포 |
| **`multi-agent-mux-update`** | `4.14.0` | 설치된 작업 공간의 제자리 업데이트·되돌리기와 업데이트 중 입장 차단 | ✅ 배포 |
| **`multi-agent-mux-reset`** | `4.14.0` | 제어 루트의 상주 에이전트를 하나씩 purge 하고 같은 사양으로 다시 만들기 | ✅ 배포 |
---
## 📋 버전별 상세 변경 내역 (Changelog)
### 🚀 `v4.14.0` — reset 스킬로 상주 에이전트의 대화를 지우고 같은 사양으로 다시 만든다 (Issue #78) (2026-10-30)
> **마이너 (MINOR Release)**: 제어 루트의 running 에이전트를 하나씩 stop·purge 하고 같은 이름·에이전트·역할·herdr 서버·작업 공간 라벨로 다시 만드는 새 `multi-agent-mux-reset` 스킬을 더했다. reset 중에는 잡 등록이 거부된다. purge 거부 안내가 대화 파일을 쥔 프로세스를 보여 주고, codex purge 가 `tui-thread-reference-capabilities/<uuid>` 도 지운다. 새 스킬과 기존 명령의 동작(reset 중 등록 거부)이 늘어 MINOR 로 올린다. PR #82.
#### ⚠️ 호환성
- 새 스킬 `multi-agent-mux-reset`(`scripts/reset.sh --yes [--dry-run]`). 터미널이 아니면 `--yes` 가 필요하다(없으면 exit 3). 종료 코드는 0·1·2·3·4(건너뛴 에이전트 있음)·5(새 대화 ID 미기록), 신호로 끊기면 130·143·129.
- reset 이 도는 동안 그 작업 공간이 읽는 잡 레지스트리(`.mam/jobs`, `MAM_JOBS_DIR`, 행의 `control_plane.mam_jobs_dir`)에 `.reset-in-progress` 를 두고, 그 레지스트리로의 등록(`delegate-job submit`, `registry.py register`, 등록 API)은 거부된다. reset 자신의 create 온보딩만 통과한다. 표식이 없으면 등록 경로는 전과 같다.
- purge 가 사용 중이라 거부될 때(exit 4) `in_use_hints` 가 `lsof` 로 찾은 프로세스(pid, 명령줄)를 함께 출력한다. 그 프로세스는 종료하지 않는다.
- codex purge 가 `tui-thread-reference-capabilities/<uuid>` 도 지운다.
- `.claude/skills/multi-agent-mux-reset` 링크는 추적되지 않으므로 설치본이 아닌 체크아웃에서는 손으로 만든다.
#### 🌟 주요 개선 사항
1. **reset 스킬 (`multi-agent-mux-reset/scripts/reset.sh`, `lib_py/reset.py`)**:
- 대상: `status: running` 이고 `pane.cwd` 가 제어 루트인 행. 다른 상태·워크트리 행·오케스트레이터는 이유와 함께 건너뛴다.
- 사전 확인(무엇도 바꾸기 전): 열린 잡, live 루프·업데이트 표식, 행의 agent·role 누락, 대화 ID 가 없는 claude·grok, 깨진 사양 파일, 호출자 자신이 대상.
- 잠금: 업데이트 입장 잠금을 배타로 잡고 `.mam/loop-guard-active` 와 `.mam/reset-in-progress` 를 건 뒤 놓는다. 이어 읽는 레지스트리마다 그 잠금 아래에서 표식을 둔다. 다른 작업 공간의 live reset 이 같은 레지스트리를 쥐고 있으면 아무것도 바꾸지 않고 거부한다.
- 순서: 사양 파일(`.mam/reset-spec.json`)을 원자적으로 쓴 뒤 에이전트마다 ⓪ 행·열린 잡 재확인 ① stop ② 기록된 ID 와 같은지, ID 가 나중에 생기는 에이전트는 그 폴더의 대화로 검증되는지 ③ purge ④ create(온보딩). 중간에 멈추면 다시 실행해 사양 파일에서 이어 가고, 이미 다시 만든 에이전트는 다시 purge 하지 않는다.
- 결과 표: 에이전트마다 새 대화 ID 가 기록됐는지, 이전 ID 와 다른지.
2. **등록 경계 (`lib_py/delegation/registry.py`, `remote.py`, `lib.sh` `mam_reset_admit`)**: `register_job` 이 레지스트리 잠금 아래에서 reset 표식을 읽고 거부한다. 원격 runner 는 거부를 dispatch 거부로 처리한다.
3. **purge 안내와 codex (`lib_py/agents/base.py`, `adapters/{agy,codex,opencode}.py`)**: `in_use_hints` 의 `lsof` holder, codex 의 capability 디렉터리 삭제.
4. **패키징·문서**: `deploy/install.sh`·`remove.sh`·`gitea-ci.yml`, README 두 개, 스킬 코드 블록 인벤토리, 버전 매트릭스와 `test_version_consistency.py` 의 스킬 목록(10개).
#### ✅ 품질 검증
- **회귀**: `tests/test_issue78_reset.py`, `tests/test_issue78_hints_codex.py`. 신호(SIGINT·SIGTERM·SIGHUP) 중단, 재실행, 공개 등록 CLI 경합, 다른 폴더의 codex 대화 보호, 레지스트리 표식을 포함한다.
- **리뷰 (`--all-reviewer`, 리뷰 전용 루프)**: 첫 루프(@f90a705) 1회차 4명 모두 NOT PASS, 2회차 3/4 PASS. codex 가 `registry.py register` 로 reset 입장 경계를 우회하는 경합과, ID 문자열 일치만으로 다른 폴더의 codex 대화를 지우는 경우를 재현했다. creator 의 수정 잡이 예산을 넘겨 루프가 멈췄고, 수정은 548c685 로 커밋됐다. 두 번째 루프(@548c685) 1·2회차 3/4 PASS, codex 의 지적으로 레지스트리 표식을 물리 경로 기준으로 모든 레지스트리에 두고(568385c), 다른 live reset 의 표식을 가져가지 않게 했다(b49314c). 3회차(@b49314c) claude·grok·codex PASS, agy 는 프롬프트 전달 실패로 따로 리뷰해 PASS — **4/4 PASS @b49314c**. 리포트는 `.agents/reports/<session>/` 에 있다.
- **전체 스위트 (macOS, Python 3.9.6, @b49314c)**: 2336 passed, 56 skipped, 2 failed (115분). 실패 둘은 SIGINT·SIGHUP 중단 테스트로, 스위트를 `nohup … &` 로 띄워 두 신호가 무시된 채 상속된 탓이다. 그렇게 띄우지 않고 다시 돌린 신호 테스트 3개는 모두 통과했다. 실제 `~/.claude`·`/private/tmp/claude-<uid>`·herdr 서버 pane 목록·opencode 의 data·state·config·본 체크아웃의 `.mam` 에 이 실행이 남긴 것은 없었고, 본 체크아웃의 모니터는 그대로 떠 있었다.
- **작성자 확인**: Linux(@f90a705 이전) 2330 passed, 2 failed(#80 의 flake). creator 의 macOS 실행(@548c685) 2333 passed, 56 skipped, 실패 0.
- **알려진 비차단**: 레지스트리 표식은 stopped·terminated 행의 `mam_jobs_dir` 까지 만들고, 만들 수 없는 경로가 있으면 reset 이 거부된다(어느 행인지 말하지 않는다). 표식을 위해 만든 빈 디렉터리와 `.lock` 은 남는다. 두 reset 이 공유 레지스트리를 다른 순서로 잡으면 둘 다 거부될 수 있다(아무것도 바뀌지 않음). 터미널에서는 `--yes` 없이 `[y/N]` 에 y 로 답해도 진행한다.
### 🚀 `v4.13.0` — worktree creator 도 기본으로 온보딩하고, create 의 --no-onboard 는 ID 가 나중에 생기는 에이전트에 경고한다 (Issue #68) (2026-10-29)
> **마이너 (MINOR Release)**: `worktree_prepare.sh --creator-agent` 가 온보딩 없이 creator 를 만들어, agy·codex·hermes·opencode 는 첫 대화 전까지 레지스트리에 대화 ID 가 없었다. 이제 worktree creator 도 기본으로 온보딩한다. create 의 `--no-onboard` 는 남기고, ID 가 첫 턴 뒤에야 생기는 에이전트에 쓰면 경고한다. 공개 스크립트의 기본 동작이 바뀌어 MINOR 로 올린다. PR #79.
+1
View File
@@ -42,6 +42,7 @@ jobs:
shellcheck deploy/install_mam.sh
shellcheck deploy/generate-env.sh
shellcheck .agents/skills/multi-agent-mux-update/scripts/*.sh
shellcheck .agents/skills/multi-agent-mux-reset/scripts/*.sh
shellcheck deploy/remove.sh
echo "✅ ShellCheck completed successfully."
+3
View File
@@ -668,5 +668,8 @@ cat <<EOF
3. Update installed skills in the future:
$ bash .agents/skills/multi-agent-mux-update/scripts/update.sh
4. Reset running control-root agents with fresh conversations:
$ bash .agents/skills/multi-agent-mux-reset/scripts/reset.sh --dry-run
--------------------------------------------------------------------------------
EOF
+1
View File
@@ -93,6 +93,7 @@ else
".agents/skills/multi-agent-mux-status"
".agents/skills/multi-agent-mux-stop"
".agents/skills/multi-agent-mux-update"
".agents/skills/multi-agent-mux-reset"
".agents/hooks"
".agents/hooks.json"
".agents/MULTI_AGENT_RULES.md"
+27
View File
@@ -477,6 +477,20 @@
],
"mismatch": null,
"note": "entry point call (Issue #72)"
},
{
"skill": "multi-agent-mux-reset",
"start": 17,
"end": 19,
"lang": "bash",
"sha": "3e9ea3c85bb0",
"class": "c",
"kind": "invocation",
"evidence": [
"multi-agent-mux-reset/scripts/reset.sh:6"
],
"mismatch": null,
"note": "entry point call (Issue #78)"
}
],
"removed": [
@@ -635,6 +649,19 @@
"passthrough": {
"4": "multi-agent-mux-create/scripts/create_session.sh:554"
}
},
"multi-agent-mux-reset/scripts/reset.sh": {
"skill": "multi-agent-mux-reset",
"exit_codes": [
"0",
"1",
"2",
"3",
"4",
"5",
"other"
],
"passthrough": {}
}
},
"prose_mismatches": [
+1 -1
View File
@@ -116,7 +116,7 @@ def test_inventory_matches_skill_md(skill):
def test_inventory_covers_nine_skills():
assert len(SKILL_NAMES) == 9 # Issue #72: + multi-agent-mux-update
assert len(SKILL_NAMES) == 10 # Issue #78: + multi-agent-mux-reset
assert {b["skill"] for b in DATA["blocks"]} == set(SKILL_NAMES)
+338
View File
@@ -0,0 +1,338 @@
"""Issue #78: in_use_hints lists lsof holders, and codex purge removes
tui-thread-reference-capabilities/<uuid>.
"""
from __future__ import annotations
import json
import os
import sqlite3
import subprocess
import sys
import uuid as uuidlib
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parent.parent
SKILLS = REPO_ROOT / ".agents" / "skills"
if str(SKILLS) not in sys.path:
sys.path.insert(0, str(SKILLS))
from lib_py.agents.base import DiscoveryContext # noqa: E402
from lib_py.agents.registry import get_adapter # noqa: E402
pytestmark = pytest.mark.usefixtures("own_workspace_root")
def _clean_env(extra: dict | None = None) -> dict:
env = {
k: v
for k, v in os.environ.items()
if not k.startswith("XDG_")
and not k.startswith("HERDR_")
and k
not in (
"CLAUDE_CODE_SESSION_ID",
"ANTIGRAVITY_CONVERSATION_ID",
"HERMES_SESSION_ID",
"GROK_SESSION_ID",
"OPENCODE_SESSION_ID",
"CLAUDE_CONFIG_DIR",
"CLAUDE_PROJECT_DIR",
"CODEX_HOME",
)
}
env["PYTHONPATH"] = str(SKILLS)
if extra:
env.update(extra)
return env
def _write_fake_lsof(bindir: Path, mapping: dict[str, list[int]]) -> Path:
"""Fake lsof that prints mapped PIDs when any argument matches a key substring."""
bindir.mkdir(parents=True, exist_ok=True)
cases = []
for needle, pids in mapping.items():
echoes = "\n".join(f" echo {p}" for p in pids)
cases.append(f" *{needle}*)\n{echoes}\n found=1 ;;")
body = "\n".join(cases)
exe = bindir / "lsof"
exe.write_text(
"#!/bin/sh\n"
"found=0\n"
'for a in "$@"; do\n'
' case "$a" in\n'
f"{body}\n"
" esac\n"
"done\n"
'[ "$found" = "1" ] && exit 0\n'
"exit 1\n",
encoding="utf-8",
)
exe.chmod(0o755)
return exe
def _materialize_artifact(agent: str, conv: str, ctx: DiscoveryContext) -> str:
adapter = get_adapter(agent)
if agent == "agy":
# Hold the presence lock so agy's in_use_paths (_artifact_paths) is exercised too.
paths = adapter._artifact_paths(conv, ctx)
target = paths[-1]
else:
target = adapter.artifact_path(conv, ctx)
Path(target).parent.mkdir(parents=True, exist_ok=True)
Path(target).write_text("{}\n", encoding="utf-8")
return target
@pytest.mark.parametrize("agent", ["claude", "agy", "codex", "grok", "hermes"])
def test_in_use_hints_list_lsof_holders(tmp_path, monkeypatch, agent):
home = tmp_path / "home"
ws = tmp_path / "ws"
bindir = tmp_path / "bin"
no_lsof_bin = tmp_path / "no-lsof-bin"
for d in (home, ws, bindir, no_lsof_bin):
d.mkdir(parents=True, exist_ok=True)
# Populate no_lsof_bin with symlinks to basic tools (ps) but not lsof.
for tool in ("ps", "sh", "bash"):
for sysdir in ("/bin", "/usr/bin"):
cand = Path(sysdir) / tool
if cand.exists() and not (no_lsof_bin / tool).exists():
(no_lsof_bin / tool).symlink_to(cand)
conv = str(uuidlib.uuid4())
monkeypatch.setenv("HOME", str(home))
monkeypatch.setenv("HOME_DIR", str(home))
monkeypatch.setenv("CODEX_HOME", str(home / ".codex"))
ctx = DiscoveryContext(workspace=str(ws), agent_name=agent, home_dir=str(home))
target = _materialize_artifact(agent, conv, ctx)
adapter = get_adapter(agent)
holder = subprocess.Popen(["sleep", "300"])
try:
_write_fake_lsof(bindir, {conv: [holder.pid]})
env = _clean_env({
"HOME": str(home),
"HOME_DIR": str(home),
"CODEX_HOME": str(home / ".codex"),
"PATH": f"{bindir}:{os.environ.get('PATH', '')}",
})
res = subprocess.run(
[sys.executable, "-m", "lib_py.agents", "in-use", agent, conv, str(ws)],
capture_output=True,
text=True,
env=env,
timeout=30,
)
assert res.returncode == 4, res.stdout + res.stderr
assert json.loads(res.stdout) == {"agent": agent, "in_use": True}
assert f"ps -axww | grep {conv}" in res.stderr
assert f"held open by pid {holder.pid}:" in res.stderr
assert "sleep 300" in res.stderr
assert "not stopped by MAM" in res.stderr
monkeypatch.setenv("PATH", env["PATH"])
hints = adapter.in_use_hints(conv, ctx)
if agent == "claude":
assert hints[0].startswith("a claude process whose session file under ")
assert f"ps -axww | grep {conv}" in hints[1]
else:
assert f"ps -axww | grep {conv}" in hints[0]
assert any(f"held open by pid {holder.pid}:" in h and "sleep 300" in h for h in hints)
assert target in adapter.in_use_paths(conv, ctx)
finally:
holder.kill()
holder.wait()
def test_in_use_hints_no_lsof_unchanged(tmp_path, monkeypatch):
"""§5.1 regression control: without lsof on PATH, hints match pre-#78 lines."""
home = tmp_path / "home"
ws = tmp_path / "ws"
no_lsof_bin = tmp_path / "no-lsof-bin"
for d in (home, ws, no_lsof_bin):
d.mkdir(parents=True, exist_ok=True)
monkeypatch.setenv("HOME", str(home))
monkeypatch.setenv("HOME_DIR", str(home))
monkeypatch.setenv("CODEX_HOME", str(home / ".codex"))
monkeypatch.setenv("PATH", str(no_lsof_bin))
for agent in ("claude", "agy", "codex", "grok", "hermes"):
conv = str(uuidlib.uuid4())
ctx = DiscoveryContext(workspace=str(ws), agent_name=agent, home_dir=str(home))
_materialize_artifact(agent, conv, ctx)
adapter = get_adapter(agent)
hints = adapter.in_use_hints(conv, ctx)
assert not any("held open by pid" in h for h in hints)
assert any(f"ps -axww | grep {conv}" in h for h in hints)
def test_in_use_hints_opencode_skip_service(tmp_path, monkeypatch):
home = tmp_path / "home"
ws = tmp_path / "ws"
bindir = tmp_path / "bin"
for d in (home, ws, bindir):
d.mkdir(parents=True, exist_ok=True)
db = home / ".local" / "share" / "opencode" / "opencode.db"
db.parent.mkdir(parents=True, exist_ok=True)
db.write_text("", encoding="utf-8")
adapter = get_adapter("opencode")
monkeypatch.setattr(adapter, "_bin", lambda ctx: None)
monkeypatch.setenv("HOME", str(home))
monkeypatch.setenv("HOME_DIR", str(home))
ctx = DiscoveryContext(workspace=str(ws), agent_name="opencode", home_dir=str(home))
service_proc = subprocess.Popen(
[sys.executable, "-c", "import time; time.sleep(300)", "serve", "--service"]
)
holder_proc = subprocess.Popen(["sleep", "300"])
try:
_write_fake_lsof(bindir, {"opencode.db": [service_proc.pid, holder_proc.pid]})
monkeypatch.setenv("PATH", f"{bindir}:{os.environ.get('PATH', '')}")
hints = adapter.in_use_hints("ses_test78", ctx)
assert hints[0] == "any process with it on its command line (an editor, a grep, ...): ps -axww | grep ses_test78"
assert any(f"held open by pid {holder_proc.pid}:" in h and "sleep 300" in h for h in hints)
assert not any(f"pid {service_proc.pid}" in h or "serve --service" in h for h in hints)
assert hints[-1] == f"another opencode session holding {db}: lsof {db}"
finally:
for p in (service_proc, holder_proc):
p.kill()
p.wait()
def test_stop_purge_refusal_prints_holders(mam_sandbox, mock_herdr, monkeypatch):
conv = str(uuidlib.uuid4())
name = "issue78-reviewer-claude"
ws = mam_sandbox
ctx = DiscoveryContext(workspace=str(ws), agent_name="claude", home_dir=str(mam_sandbox))
transcript = Path(get_adapter("claude").artifact_path(conv, ctx))
transcript.parent.mkdir(parents=True, exist_ok=True)
transcript.write_text(json.dumps({"sessionId": conv}) + "\n", encoding="utf-8")
lib = mam_sandbox / ".agents" / "skills" / "lib.sh"
yaml_path = mam_sandbox / ".mam" / "agent-sessions.yaml"
mutation = (
f"d['herdr_sessions'] = [{{'name': {name!r}, 'status': 'running', 'agent': 'claude', "
f"'role': 'reviewer', 'claude_session_id_own': {conv!r}, "
f"'pane': {{'cwd': {os.path.realpath(str(ws))!r}}}}}]\n"
)
res_init = subprocess.run(
["bash", "-c", f'source "{lib}" && atomic_dump_yaml "{yaml_path}"'],
input=mutation,
capture_output=True,
text=True,
env=_clean_env({
"HOME": str(mam_sandbox),
"HOME_DIR": str(mam_sandbox),
"WORKSPACE_ROOT": str(mam_sandbox),
"AGENT_SESSIONS_YAML": str(yaml_path),
}),
)
assert res_init.returncode == 0, res_init.stdout + res_init.stderr
holder = subprocess.Popen(["sleep", "300"])
try:
bindir = mam_sandbox / ".local" / "bin"
_write_fake_lsof(bindir, {conv: [holder.pid]})
stop_sh = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-stop" / "scripts" / "stop_session.sh"
env = _clean_env({
"HOME": str(mam_sandbox),
"HOME_DIR": str(mam_sandbox),
"CLAUDE_PROJECT_DIR": str(mam_sandbox / ".claude" / "projects"),
"WORKSPACE_ROOT": str(mam_sandbox),
"AGENT_SESSIONS_YAML": str(yaml_path),
"PATH": f"{bindir}:{os.environ.get('PATH', '')}",
})
res = subprocess.run(
["bash", str(stop_sh), "--session", name, "--agent", "claude", "--purge-conversation", "--yes"],
capture_output=True,
text=True,
cwd=str(mam_sandbox),
env=env,
timeout=60,
)
assert res.returncode == 4, res.stdout + res.stderr
assert f"held open by pid {holder.pid}:" in res.stderr
assert "sleep 300" in res.stderr
assert transcript.exists()
assert name in yaml_path.read_text(encoding="utf-8")
finally:
holder.kill()
holder.wait()
@pytest.mark.parametrize("delete_ok", [True, False], ids=["delete_ok", "delete_fails"])
def test_codex_purge_removes_tui_thread_capability(tmp_path, monkeypatch, delete_ok):
home = tmp_path / "home"
codex_home = tmp_path / "codex-home"
ws = tmp_path / "ws"
bindir = tmp_path / "bin"
for d in (home, codex_home, ws, bindir):
d.mkdir(parents=True, exist_ok=True)
conv = str(uuidlib.uuid4())
other = str(uuidlib.uuid4())
rollout = codex_home / "sessions" / "2026" / "10" / "28" / f"rollout-2026-10-28T00-00-00-{conv}.jsonl"
rollout.parent.mkdir(parents=True, exist_ok=True)
rollout.write_text(json.dumps({"payload": {"id": conv, "cwd": str(ws)}}) + "\n", encoding="utf-8")
cap_dir = codex_home / "tui-thread-reference-capabilities"
cap_dir.mkdir(parents=True, exist_ok=True)
cap_mine = cap_dir / conv
cap_other = cap_dir / other
cap_mine.write_text("cap-mine", encoding="utf-8")
cap_other.write_text("cap-other", encoding="utf-8")
state_db = codex_home / "state_5.sqlite"
conn = sqlite3.connect(str(state_db))
conn.execute("CREATE TABLE threads (id TEXT PRIMARY KEY, rollout_path TEXT, cwd TEXT)")
conn.execute("INSERT INTO threads VALUES (?, ?, ?)", (conv, str(rollout), str(ws)))
conn.execute("INSERT INTO threads VALUES (?, ?, ?)", (other, "", str(ws)))
conn.commit()
conn.close()
fake_codex = bindir / "codex"
if delete_ok:
# Simulate `codex delete <uuid> --force` removing rollout + DB row, leaving tui-thread-reference-capabilities.
fake_codex.write_text(
f"#!{sys.executable}\n"
"import os, sqlite3, sys\n"
"if len(sys.argv) >= 3 and sys.argv[1] == 'delete':\n"
f" p = {str(rollout)!r}\n"
" if os.path.exists(p):\n"
" os.remove(p)\n"
f" conn = sqlite3.connect({str(state_db)!r})\n"
" conn.execute('DELETE FROM threads WHERE id=?', (sys.argv[2],))\n"
" conn.commit()\n"
" conn.close()\n"
" sys.exit(0)\n"
"sys.exit(1)\n",
encoding="utf-8",
)
else:
fake_codex.write_text("#!/bin/sh\nexit 1\n", encoding="utf-8")
fake_codex.chmod(0o755)
monkeypatch.setenv("HOME", str(home))
monkeypatch.setenv("HOME_DIR", str(home))
monkeypatch.setenv("CODEX_HOME", str(codex_home))
monkeypatch.setenv("PATH", f"{bindir}:{os.environ.get('PATH', '')}")
adapter = get_adapter("codex")
ctx = DiscoveryContext(workspace=str(ws), agent_name="codex", home_dir=str(home))
assert str(cap_mine) in adapter.residual_artifacts(conv, ctx)
purged = adapter.purge_artifacts(conv, ctx)
assert str(cap_mine) in purged
assert not cap_mine.exists()
assert cap_other.exists()
assert not rollout.exists()
assert adapter.residual_artifacts(conv, ctx) == []
assert str(cap_other) in adapter.residual_artifacts(other, ctx)
File diff suppressed because it is too large. Load diff
+3 -2
View File
@@ -28,6 +28,7 @@ SKILL_NAMES = [
"multi-agent-mux-loop",
"multi-agent-mux-orc-onboard",
"multi-agent-mux-update",
"multi-agent-mux-reset",
]
@@ -76,7 +77,7 @@ def _get_skill_md_frontmatter_version(skill_name: str):
def test_three_way_version_lockstep():
"""T-4: lib.sh(MAM_VERSION) == VERSIONS.md(Current + Matrix 9 items) == 9x SKILL.md frontmatters."""
"""T-4: lib.sh(MAM_VERSION) == VERSIONS.md(Current + Matrix 10 items) == 10x SKILL.md frontmatters."""
# 1. lib.sh runtime constant
lib_ver = _get_lib_sh_mam_version()
assert lib_ver != "", "MAM_VERSION in lib.sh is empty"
@@ -89,7 +90,7 @@ def test_three_way_version_lockstep():
assert skill in vmd_matrix, f"Skill {skill} missing from VERSIONS.md matrix"
assert vmd_matrix[skill] == lib_ver, f"VERSIONS.md matrix for {skill} ({vmd_matrix[skill]}) != lib.sh ({lib_ver})"
# 3. 9x SKILL.md files
# 3. 10x SKILL.md files
for skill in SKILL_NAMES:
fm_ver = _get_skill_md_frontmatter_version(skill)
assert fm_ver == lib_ver, f"{skill}/SKILL.md version ({fm_ver}) != lib.sh ({lib_ver})"