fix(agents): 같은 폴더의 같은 종류 에이전트가 서로의 대화 ID 를 쓰지 않는다 (Issue #63) #69

Merged
Godopu merged 14 commits from fix/issue-63 into main 2026-10-23 06:46:13 +00:00
34 changed files with 2655 additions and 124 deletions

No files matched your search

@@ -0,0 +1,14 @@
{
"loop_id": "loop-20261023T035928Z-2ccc02",
"base_sha": "dc886ecd6b38c01032a55a2d1c230c73362ae630",
"head_sha": "1614af5b673d7519d3a38f63e3636055592b4f14",
"tree_sha": "f1af6fdd548ee5d6f6b81e2202833edfaca98188",
"review_commit": "1614af5b673d7519d3a38f63e3636055592b4f14",
"reviewer": "planner-reviewer-claude-01",
"job_id": "af549b13",
"verdict": "PASS",
"transport": "herdr",
"evidence": "review-tree",
"isolation": "worktree",
"verified_at": "2026-10-23T05:16:58Z"
}
@@ -0,0 +1,114 @@
# PR #69 (Issue #63) 리뷰 — head 1614af5
- 리뷰어: planner-reviewer-claude-01 (macOS, bash 3.2, /usr/bin/python3 3.9.6)
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261023T035928Z-2ccc02/1`
- identity 확인: HEAD 1614af5b673d7519d3a38f63e3636055592b4f14, tree f1af6fdd548ee5d6f6b81e2202833edfaca98188, `git status --porcelain --ignored` 비어 있음, `.mam` 없음 (시작·종료 시 모두). main HEAD dc886ec 그대로.
- 범위: base dc886ec..1614af5 전체(커밋 13개).
- ede9c62 까지는 이전 리뷰(잡 8445af7f, 55f86b49)에서, 8f64174·7cac6f6 은 잡 1c6eca2b 에서 봤다. 그 결론을 다시 확인했다.
- 이번에는 1614af5 와 세 수정 사이의 정합성을 중점으로 봤다.
- 참고: 같은 head 를 맡은 잡 906427d1 은 관련 스위트 도중 검증 경로(`…a646cb/3`)가 사라져 끝내지 못했다. 그 잡에서 얻은 head 결과(issue63 27 passed, 변이 결과)는 같은 tree 의 스크래치 사본에서 나온 것이다. `diff -rq` 로 이번 검증 경로와 같음을 확인했다.
## 1. 1614af5 가 바꾼 것
- `.agents/skills/lib_py/agents/id_order.py:223`: `elif sess == me or not sess:` 를 `elif not sess:` 로 바꿨다.
- 그래서 호출자 자신의 세션 이름에 남은 죽은 owner 마커도 고아 규칙을 따른다. 이번 시도의 행이 등록되면 지우고(:225), herdr 가 정상 목록으로 "없음"이라 답하면 지우고(:228-229), 그 밖에는 남긴다.
- `_read_and_prune_markers` 의 `me` 인자를 지웠다(:183, :308).
- `blockers` 는 ede9c62 부터 `m_sess == me` 를 건너뛰지 않는다(:136-144). 그래서 남은 자기 이름 마커가 실제로 막는다.
## 2. 세 수정이 각 지적을 닫는가, 서로 어긋나지 않는가
1. **8f64174 — 조회 실패를 "없음"으로 읽던 문제**
- `herdr_session_state` (:58-86) 는 정상 파싱한 `agent list` 에 이름이 없을 때만 False 를 돌려준다. 그 밖에는 None 이다.
- prune 은 `drop = live is False` (:228) 다.
- 변이 m1(`not live`)과 m2(rc≠0 → False)가 테스트에 잡혔다(잡 1c6eca2b).
2. **7cac6f6 — 상한 0 에서 30 초 조회, server_not_running**
- 조회 시간은 `deadline - now` 다(:288). timeout≤0 이면 None 을 돌려준다(:64-65).
- 변이 m4(고정 30 s)와 m7(timeout≤0 에도 조회)이 잡혔다.
- 상한 0 테스트는 0.3 s 안에 끝나는 것을 확인한다(test :1222-1224).
3. **1614af5 — 같은 이름의 죽은 owner 마커를 확인 없이 지우던 문제**
- 옛 규칙(`sess == me` 이면 drop)을 되돌린 변이 사본에서는 두 테스트가 실패한다: `test_id_order_dead_owner_marker_blocks_while_its_unregistered_herdr_session_is_alive`, `test_id_order_real_probe_against_mock_herdr_fails_closed`.
- 커밋 메시지가 말한 "재시도의 실패 정리가 살아 있는 pane 을 죽임"도 막힌다. create 의 대기(`create_session.sh:287`)는 롤백 trap 설치(:367)보다 앞서 있어서, 대기에서 거부(exit 1)되면 `cleanup_herdr_on_error` 가 돌지 않는다.
4. **정합성**: 세 수정은 모두 `_read_and_prune_markers` 의 "죽은 owner 마커" 분기 하나를 쓰고, 조회는 `bounded_probe` 하나를 거친다. 자기 이름 마커만 다른 규칙을 쓰던 예외가 사라져서 규칙이 하나로 모였다. 어긋나는 곳은 찾지 못했다.
## 3. 흔한 사용을 600 초 대기로 막지 않는가
스크래치에서 `id_order.wait` 를 프로세스 안에서 직접 불렀다. 조회 함수를 주입하고, cap 2 s(일부 0 s)로 돌렸다.
**정상 사용 (실패 없음)**: 막지 않는다.
- create 마커가 남은 채로, 그 세션이 다음 상태일 때 같은 이름으로 resume 했다:
- stopped 이고 own id 가 기록됨
- stopped 이고 id 없음 (herdr-dead stop)
- reconcile 이 terminated 로 바꿈
- 세 경우 모두 herdr 가 **답하지 못해도**(None), cap 0 에서도 rc=0 이었다.
- `blockers` 의 `pinned_for_attempt` 와 `ended_after_attempt` (:158-171) 가 이 경우들을 걸러낸다.
- 정상 create/resume 이 끝난 뒤의 마커는 행이 running 인 동안에는 `_registered_for_attempt` (:177-180) 로 지워진다.
**실패 뒤 재시도**:
| 경우 | herdr 가 "없음" 응답 (서버 살아 있음) | herdr 무응답 (서버 꺼짐 등) |
|---|---|---|
| create X 가 행 쓰기 전에 실패(exit 42 롤백) → create X 재시도 | rc=0, 0.0 s | rc=1, cap 만큼 대기 |
| resume X 가 spawn 에서 실패(행 그대로) → resume X 재시도 | rc=0, 0.0 s | rc=1, cap 만큼 대기 |
| 위 resume 실패 → 같은 cwd 에서 create Y | rc=0, 0.0 s | rc=1, cap 만큼 대기 |
- 7cac6f6 사본으로 같은 시뮬레이션을 돌리면 무응답 열의 위 두 줄(같은 이름 재시도)이 rc=0 이다.
- 즉 1614af5 는 서버가 살아 있는 흔한 재시도는 그대로 두고, herdr 가 답하지 못하는 재시도만 막는다. 이는 아래 R1 이 같은 이름까지 넓어진 것이다.
## 4. 그 밖의 확인
브리프 항목 1·2·4·5·6·8(완료 조건 테스트, autopin 재사용, resume·stop, 지운 심볼, issue34 무관)은 1614af5 가 `session_ids.py`, `lib.sh`, 셸 스크립트, 문서를 건드리지 않으므로 이전 리뷰의 결론 그대로다.
**macOS**:
- `/usr/bin/python3` 3.9.6 으로 `lib_py.agents.id_order` import 가 성공한다(잡 1c6eca2b; 1614af5 는 문법 요소를 더하지 않는다).
- 잠금은 `fcntl.flock` 이다(:294-295).
**실제 herdr 를 건드리지 않았는가**:
- 기존 서버 두 개(pid 3407 `herdr server`, 8323 `herdr --session multi-agent-mux server`)는 스위트 전후로 같다(시작 시각 08:21).
- 스위트 중 13:22 에 `herdr --session mam-pylecturenotes server` 가 새로 보였다. 하지만 이 프로세스는 실제 HOME 과 `WORKSPACE_ROOT=/Users/godopu16/PuKi/lac/curriculum/01_python_getting_started_lecturenotes` 로 떠 있고, 사용자의 attach 클라이언트(13:27)가 붙어 있다. 테스트는 HOME 을 스크래치로 바꿔 돌므로 다른 작업 공간의 것이다.
## 5. 실행한 테스트
모두 `env -i`, 임시 HOME/XDG, `PYTHONDONTWRITEBYTECODE=1 -p no:cacheprovider --basetemp=<scratchpad>` 로 돌렸다. 테스트에서 .mam.env 를 export 하지 않았다.
| 대상 | 결과 |
|---|---|
| 검증 경로: `tests/test_issue63_same_cwd_ids.py` + 관련 19개 모듈 (issue40_auto_pin, issue64_ro_connect, issue10_13_purge, issue31_purge_residual, issue42_skill_blocks, tier1_unit, tier2_component, b4_session_created, issue24_resume_dialogs, issue30_stall_watch_stop, issue23_24_startup_path, issue45_restore, issue50_pane_lock, c1_tui_readiness, issue6_fail_closed, issue60_opencode_v2, issue60_linux_fixes, uuid_target, issue17_workspace_root_isolation) | **597 passed, 2 skipped, 0 failed** (32 분) |
| 같은 tree 스크래치 사본: issue63 단독 | 27 passed |
| base 사본 + head 테스트 | 27 errors (`ImportError: id_order`). 조건별 base 실패는 1차에서 22 failed + 1 passed 로 확인했다 |
| 변이: 1614af5 를 되돌림(자기 이름 마커를 drop) | 2 failed (위 두 테스트) |
## 6. 차단 결함
없음.
## 7. 비차단
### R1 (잡 1c6eca2b 의 R1, 범위가 넓어짐): herdr 가 답하지 못하는 고아 마커는 같은 폴더·같은 종류의 create/resume 을 매번 cap 동안 붙잡는다
- 이제 같은 이름 재시도도 포함된다(위 표).
- 서버가 꺼진 상태(server_not_running)가 대표적이다.
- 조회는 서버를 띄우지 않는다(:67).
- create/resume 은 대기를 통과해야 spawn 으로 서버를 띄운다(`create_session.sh:287`→:289, `resume_session.sh:243`→:260).
- 그래서 다른 무언가가 서버를 켜기 전까지는 기본 600 s 를 기다린 뒤 거부가 반복된다.
- 회복 경로는 있다: 메시지의 `HERDR_SESSION_NAME=<srv> herdr kill-session -t <s>` 를 shim 으로 실행하면 서버가 기동된다(잡 1c6eca2b 의 가짜 herdr 재현에서 `--session srv-down server` 1회).
- 생기는 조건은 "행 등록 전 실패나 kill"에 "서버가 꺼짐"이 겹치는 경우다. 정상 사용과 서버가 살아 있는 재시도는 막지 않는다(3절).
- 권고 (둘 중 하나):
- (a) server_not_running 만은 "없음"으로 읽는다. MAM 서버는 restore 가 꺼져 있어서 꺼진 서버 위에는 살아 있는 pane 이 없다. `lib.sh:204-207` 의 ISSUE-9 규약과도 맞는다.
- (b) 그 경우는 cap 을 기다리지 말고 바로 거부한다. 메시지는 "서버가 꺼져 있음 → 위 명령으로 정리"처럼 사실대로 쓴다.
- fail-closed 를 택한 것은 루프에서 정한 설계 선택이라 차단으로 두지 않는다.
### 그 밖의 비차단 (잡 1c6eca2b 와 같음)
- **N1**: 조회가 `MAM_NO_AUTOSTART=1` 을 쓴다는 것(:67)을 고정하는 테스트가 없다.
- **N2**: sanitize 한 이름 비교(:85)를 고정하는 테스트가 없다.
- **N3**: `flock(LOCK_EX)` 대기(:295)는 시간 제한이 없다. 다른 호출자가 앞 대기의 남은 시간만큼 더 기다릴 수 있다. 상한은 있다.
- **N4**: create·resume SKILL.md 의 exit 1 설명이 고아 마커로 거부되는 경우와 그 정리 방법을 말하지 않는다.
- **N5**: 이전 라운드의 비차단 항목이 남아 있다: resume `mam_id_order_wait` 제거가 테스트로 고정되지 않음, c6(b) 의 1.5 s 타이밍 flake, lib.sh `--herdr-server` 전달이 테스트되지 않음.
## 8. 결론
- 1614af5 는 codex 의 3차 지적(같은 이름의 죽은 owner 마커를 확인 없이 지움)을 닫는다. 8f64174·7cac6f6 과 같은 고아 규칙 하나로 모였다.
- 정상 create/resume 과 서버가 살아 있는 재시도는 막지 않는다.
- herdr 가 답하지 못할 때 cap 동안 기다리는 비용(R1)은 회복 경로가 있는 설계 선택이므로 비차단이다.
[VERDICT: PASS]
@@ -0,0 +1,14 @@
{
"loop_id": "loop-20261023T035928Z-2ccc02",
"base_sha": "dc886ecd6b38c01032a55a2d1c230c73362ae630",
"head_sha": "1614af5b673d7519d3a38f63e3636055592b4f14",
"tree_sha": "f1af6fdd548ee5d6f6b81e2202833edfaca98188",
"review_commit": "1614af5b673d7519d3a38f63e3636055592b4f14",
"reviewer": "planner-reviewer-codex-01",
"job_id": "12669b52",
"verdict": "PASS",
"transport": "herdr",
"evidence": "review-tree",
"isolation": "worktree",
"verified_at": "2026-10-23T05:16:58Z"
}
@@ -0,0 +1,21 @@
# Reviewer report — PR #69 / Issue #63
Reviewed the required snapshot at `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261023T035928Z-2ccc02/1`, head `1614af5b673d7519d3a38f63e3636055592b4f14`, against base `dc886ecd6b38c01032a55a2d1c230c73362ae630`. The snapshot matches the expected tree `f1af6fdd548ee5d6f6b81e2202833edfaca98188` and is clean. I read Issue #63 and PR #69. The latest delta is limited to `id_order.py` and its regression tests.
## Previously blocking finding is closed
At the previous head (`7cac6f6`), `_read_and_prune_markers` dropped a dead-owner marker when its session name matched the caller, without checking herdr. The new code removes that exception: `.agents/skills/lib_py/agents/id_order.py:219-230` now keeps a dead-owner marker until its row is registered for that attempt or herdr returns an authoritative `False`; live and unknown answers both retain the orphan. `blockers()` includes a marker without a row even when its name is the caller's (`id_order.py:145-148`), so a retry is refused rather than replacing its marker.
The tests now cover same-name retries with a live owner, a dead owner and live herdr session, unknown herdr state, and a confirmed-gone session (`tests/test_issue63_same_cwd_ids.py:1117-1148`). The real-probe integration test also retries using the orphan's own name and checks that the marker is retained (`tests/test_issue63_same_cwd_ids.py:1287-1290`) and then safely replaced once the session is confirmed gone (`1312-1315`). The test fixture uses a mocked herdr and in-process probes; it does not contact a real herdr server.
## Verification
- `PYTHONDONTWRITEBYTECODE=1 .../python -m pytest -p no:cacheprovider --basetemp=/tmp/mam-review-12669b52 tests/test_issue63_same_cwd_ids.py` — **27 passed** on macOS, Python 3.9.6 (230.59 s). This includes the orphan liveness, unknown-result, zero-wait-cap, and same-name retry cases.
- Changed shell scripts checked with `/bin/bash -n` — passed.
- `git diff --check dc886ecd6b38c01032a55a2d1c230c73362ae630..HEAD` — passed.
- The earlier review covered the broader unchanged stop/purge/resume implementation and adjacent compatibility checks. This re-review did not repeat the base checkout or all adjacent test groups because the new delta is confined to dead-owner marker pruning and its regression tests.
- No real agent-home databases or real herdr sessions were accessed.
The latest change fixes the remaining blocking same-name orphan path. The earlier probe-failure and wait-cap fixes remain covered by the passing Issue #63 tests. I found no blocking issue in this revision.
[VERDICT: PASS]
@@ -0,0 +1,14 @@
{
"loop_id": "loop-20261023T035928Z-2ccc02",
"base_sha": "dc886ecd6b38c01032a55a2d1c230c73362ae630",
"head_sha": "1614af5b673d7519d3a38f63e3636055592b4f14",
"tree_sha": "f1af6fdd548ee5d6f6b81e2202833edfaca98188",
"review_commit": "1614af5b673d7519d3a38f63e3636055592b4f14",
"reviewer": "reviewer-agy-01",
"job_id": "cae1ea73",
"verdict": "PASS",
"transport": "herdr",
"evidence": "review-tree",
"isolation": "worktree",
"verified_at": "2026-10-23T05:16:58Z"
}
@@ -0,0 +1,74 @@
# PR #69 (Issue #63) Round 3 Code Review Report — `agy` (`reviewer-agy-01`)
- **Job ID**: `cae1ea73`
- **Branch / Range**: `fix/issue-63` (`dc886ecd6b38c01032a55a2d1c230c73362ae630..1614af5b673d7519d3a38f63e3636055592b4f14`, 13 commits)
- **Tree**: `f1af6fdd548ee5d6f6b81e2202833edfaca98188`
- **Verification Worktree**: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261023T035928Z-2ccc02/1`
- **Environment**: macOS (`Darwin arm64`), `/bin/bash 3.2.57(1)-release`, `/usr/bin/python3 3.9.6`, `.venv/bin/python 3.9.6`
- **Summary**: **PASS** (`P0: 0`, `P1: 0`, `P2: 0`)
---
## 1. 2·3차 리뷰 후속 커밋 (`ede9c62`, `8f64174`, `7cac6f6`, `1614af5`) 집중 검증 결과
### 1.1 권위 있는 "세션 없음" 응답에서만 고아 마커 삭제 (`8f64174`, `id_order.py:58-90,225-227`)
- `herdr_session_state(server, session, timeout)` (`id_order.py:58-90`)는 `MAM_NO_AUTOSTART="1"` 및 `HERDR_SESSION_NAME=server or "default"` 환경에서 `source "$1" && _herdr agent list`를 실행합니다.
- `res.returncode == 0`이고 `json.loads(res.stdout)["result"]["agents"]`가 리스트이며, 그 안에 `session` 및 `sanitize_herdr_agent_name(session)`이 모두 없을 때에만 `False`를 반환합니다 (`id_order.py:74-83`).
- `_read_and_prune_markers` (`id_order.py:225-227`)는 `drop = live is False`로 판정하여, 조회 실패·타임아웃·파싱 실패(`live is None`) 시에는 마커를 삭제하지 않고 `_orphan = "unknown"`으로 유지합니다.
### 1.2 `server_not_running` 불확실 처리 및 `MAM_ID_ORDER_WAIT_S=0` 무프로브 보장 (`7cac6f6`, `id_order.py:58-90,280-291,330-346`)
- `server_not_running`을 포함한 모든 비정상 종료(`res.returncode != 0`)에서 `herdr_session_state`가 `None`을 반환하므로 서버 다운 상태에서도 고아 마커가 삭제되지 않고 fail-closed로 차단됩니다.
- `bounded_probe` (`id_order.py:285-290`)가 `deadline - time.monotonic()`을 `timeout`으로 전달하고, `herdr_session_state` 첫 줄(`if timeout <= 0: return None`, `id_order.py:64-65`)에서 즉시 반환하므로:
- 다수의 죽은 마커가 있더라도 전체 프로브 시간이 `MAM_ID_ORDER_WAIT_S` 상한 안에 엄격히 묶입니다 (`test_id_order_herdr_probes_stay_within_the_wait_cap`).
- `MAM_ID_ORDER_WAIT_S=0`(`cap == 0`)일 때는 herdr 서브프로세스를 전혀 호출하지 않고 즉시 거부하며 `(MAM_ID_ORDER_WAIT_S=0 leaves no time to ask herdr)` 안내를 출력합니다 (`id_order.py:336-338`).
- `answers: Dict[str, bool]` (`id_order.py:283,287-289,333`)가 이전 폴링에서 확인된 확정 응답을 보존하여 마지막 폴링 시점에 예산이 소진되더라도 `"its herdr session is alive"` 메시지를 정확히 유지합니다.
### 1.3 동일 세션 이름(`sess == me`)의 죽은 owner 마커에 동일한 고아 규칙 적용 (`1614af5`, `id_order.py:180-235,305`)
- `_read_and_prune_markers`에서 `sess == me`일 때 무조건 삭제하던 분기를 제거하여(`id_order.py:217-227`), 이전 `create`/`resume`이 pane을 띄운 직후 죽어 고아 세션을 남긴 상태에서 **같은 세션 이름**으로 재시도하더라도:
- 고아 herdr 세션이 살아 있거나 확인 불가(`True`/`None`)이면 마커를 유지하고 차단합니다 (`blockers()`는 `m_sess == me`를 건너뛰지 않음, `id_order.py:130-145`).
- herdr가 해당 세션이 사라졌음을 확인(`False`)하면 죽은 마커를 삭제하고 새 시도의 마커로 교체(`owner_pid == my_pid`)하여 정상 진입합니다 (`test_id_order_dead_owner_marker_blocks_while_its_unregistered_herdr_session_is_alive`, `test_id_order_real_probe_against_mock_herdr_fails_closed`).
### 1.4 세 수정 간 정합성 및 일반 사용(정상 create/resume, 재시도) 600초 대기 미발생 검증
- **정상 `create` 후 진행 중**: 첫 세션이 YAML 행을 `status: "running"` 및 `herdr_session_epoch > prev_epoch`로 등록하면 `_registered_for_attempt` (`id_order.py:174-177`)가 `True`를 반환해 herdr 프로브 없이 마커가 즉시 삭제되고, 이후에는 YAML 행의 `own_id` 기록 여부에 따라 대기가 결정됩니다.
- **정상 `stop` 후 후속 `create`/`resume`**: 세션이 정상 중지되면 `own_id`가 기록되거나(`pinned_for_attempt == True`) `stopped_at_epoch > max(m_at, prev_end_epoch)`(`ended_after_attempt == True`)가 성립하므로 `blockers()` (`id_order.py:150-163`)가 이를 차단 목록에서 즉시 제외하여 600초 대기가 발생하지 않습니다.
- **`create` 실패·롤백(`exit 42`) 후 재시도**: `create_session.sh`의 `cleanup_herdr_on_error` 트랩(`create_session.sh:337-368`)이 실행 중인 워크스페이스 herdr 서버에서 세션을 즉시 종료하므로, 재시도 시 첫 폴링의 `_herdr agent list`가 빈 에이전트 목록(`False`)을 반환해 죽은 마커를 즉시 정리하고 대기 없이 통과합니다.
---
## 2. 전체 체크리스트 (1~8) 점검 요약
| # | 점검 항목 | 결과 | 근거 파일 및 라인 |
|---|---|---|---|
| 1 | 완료 조건 1~8 및 테스트 커버리지 | **PASS** | `session_ids.py:116-297`, `id_order.py:1-382`, `tests/test_issue63_same_cwd_ids.py` (총 27개 테스트) |
| 2 | `#40` `autopin.capture` 재사용 (판정 단일화) | **PASS** | `session_ids.py:19,176,272` (`from lib_py.agents.autopin import OWN_KEYS, capture, claimed_elsewhere`), `lib.sh` 구 `capture_conversation_id` 제거 |
| 3 | `id_order.py` 잠금·만료·비차단 조건·`MAM_ID_ORDER_WAIT_S` | **PASS** | `fcntl.flock` (`id_order.py:293`), `wait_s` (`id_order.py:36-41`), `herdr_session_state` (`id_order.py:58-83`), `_read_and_prune_markers` (`id_order.py:180-235`) |
| 4 | `resume`: `--drop-stale-id`, 사전 거부(exit 1), 이미 실행 중 분기, `SKILL.md` | **PASS** | `resume_session.sh:132-170,243,270-276`, `update_yaml_resumed.sh:21,66-197`, `multi-agent-mux-resume/SKILL.md:59-83,165-171` |
| 5 | `stop`: kill 전 ID 판정, 죽은 herdr 미기록+이유 기록, purge 재검증 | **PASS** | `stop_session.sh:104-179,233-286,308-358`, `multi-agent-mux-stop/SKILL.md:38-55,98-115` |
| 6 | 삭제된 심볼 잔존 참조 없음 (`capture_conversation_id`, `CAPTURE_ID`) | **PASS** | `git grep` 결과 코드·문서·테스트 내 잔존 참조 0건 |
| 7 | macOS 호환성 (`/bin/bash 3.2.57`, `/usr/bin/python3 3.9.6`) | **PASS** | 수정된 셸 6개 `/bin/bash -n` 통과, Python 3.9 AST 파싱 및 모듈 import(`PY39_IMPORT_OK`) 통과 |
| 8 | `test_issue34_job_tree.py::test_t5b_a_*[False]` 관련성 여부 | **PASS** | 본 브랜치는 루프·잡 트리(`lib_py/loop`, `delegate-job`)를 전혀 수정하지 않으며, 해당 테스트는 본 변경과 무관함 |
---
## 3. 실행한 검증 명령 및 결과
1. **정적 검사 (`git diff --check`, `/bin/bash -n`, `/usr/bin/python3` 3.9.6 import)**:
- Exit `0` (`PY39_IMPORT_OK`).
2. **Issue #63 `id_order` 및 이식성 테스트 + 실제 `herdr` 호출 감시(Spy) 검증**:
- `pytest tests/test_issue63_same_cwd_ids.py -k "test_id_order or test_c6_id_order_markers or test_portability" -v`
- 결과: **`8 passed`**, `SPY_LOG` 비어 있음 (`0` calls).
3. **Herdr 격리 및 Skill 블록 인벤토리 정합성 테스트**:
- `pytest tests/test_herdr_real_server_leak.py tests/test_issue42_skill_blocks.py -v`
- 결과: **`40 passed, 1 skipped in 1.52s`**.
4. **Negative Regression 검증**:
- Base(`dc886ec`): `test_issue63_same_cwd_ids.py` 12개 실패 확인.
- `ede9c62` 대비: `test_id_order_real_probe_against_mock_herdr_fails_closed`가 `protocol_mismatch`에서 `assert 0 == 1`로 실패하고 `HEAD`에서 통과함을 확인.
- `7cac6f6`(`HEAD~1`) 대비: `1614af5`의 동일 세션 이름 고아 마커 검증(`assert wait_same() == 1`)이 `HEAD~1`에서 `assert 0 == 1`로 실패하고 `1614af5`(`HEAD`)에서 통과함을 확인.
---
## 4. 발견 사항
- 차단 결함(P0/P1) 및 비차단 결함(P2) 없음.
[VERDICT: PASS]
@@ -0,0 +1,14 @@
{
"loop_id": "loop-20261023T035928Z-2ccc02",
"base_sha": "dc886ecd6b38c01032a55a2d1c230c73362ae630",
"head_sha": "1614af5b673d7519d3a38f63e3636055592b4f14",
"tree_sha": "f1af6fdd548ee5d6f6b81e2202833edfaca98188",
"review_commit": "1614af5b673d7519d3a38f63e3636055592b4f14",
"reviewer": "reviewer-grok-01",
"job_id": "dee00bc0",
"verdict": "PASS",
"transport": "herdr",
"evidence": "review-tree",
"isolation": "worktree",
"verified_at": "2026-10-23T05:16:58Z"
}
@@ -0,0 +1,102 @@
# Review dee00bc0 — PR #69 / Issue #63, fourth Mac pass
PASS. Head `1614af5` keeps a dead-owner marker whose session name is the caller's own until herdr returns a parsed agent list that omits that name, or until the row is registered for the attempt. A live owner of that name is kept. An empty session name is still dropped. A failed lookup, `server_not_running` included, and a wait cap of 0 leave the marker in place. A normal create or resume whose `has-session` already sees the name returns before this wait. A retry whose probe says the session is gone returns on that probe.
Reviewed only the snapshot `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261023T035928Z-2ccc02/1` (`loop-20261023T035928Z-2ccc02`). Head `1614af5b673d7519d3a38f63e3636055592b4f14`, tree `f1af6fdd548ee5d6f6b81e2202833edfaca98188`. Base `dc886ecd6b38c01032a55a2d1c230c73362ae630` (main, v4.9.9) is an ancestor and is the control-plane HEAD. Thirteen commits, 14 files, +2234/−102. Against the previous review head `7cac6f6` the delta is 2 files, +42/−16: `.agents/skills/lib_py/agents/id_order.py` and `tests/test_issue63_same_cwd_ids.py`. No uncommitted changes. `git diff --check` is clean. Snapshot porcelain is empty and the snapshot has no `.mam`. Control-plane porcelain is empty. No product file was edited. The source worktree `mam-wt-pr69` was not run.
The new commit is `1614af5` `fix(id_order): a dead-owner marker on the caller's own session follows the orphan rules`. It closes the residual recorded on `7cac6f6`, where a dead owner of the waiter's own session name was dropped before herdr was asked. `8f64174` and `7cac6f6` stay underneath it. HEAD still prunes only on an authoritative gone, still treats `server_not_running` as unknown, and still asks herdr nothing at cap 0. The same-name case now uses that branch.
Host tools: control-plane venv Python 3.9.6, sqlite 3.51.0, `/bin/bash` 3.2.57(1)-release. The real agent databases were not opened.
## Same-name orphan
`_read_and_prune_markers` no longer takes the waiter session name (`id_order.py:183-186`, call at `308-309`). A dead owner's marker (`id_order.py:217-229`):
- A missing owner pid, or an owner pid equal to this waiter, drops the marker (`219-220`).
- A live owner keeps it, including a live attempt on the caller's session (`221-222`).
- An empty session name drops it (`223-224`).
- A row registered for the attempt drops it without a probe. Registered means status `running` and `herdr_session_epoch` greater than the marker's `prev_epoch` (`177-180`, `225-226`).
- Otherwise the probe runs. `drop` is true only when it returns false. True and `None` keep the marker, and `_orphan` is `"alive"` or `"unknown"` (`227-229`). A marker left by an earlier attempt on the caller's own session name takes this path.
`herdr_session_state` (`id_order.py:58-86`) is unchanged in this commit. False is only a successful, parsed `_herdr agent list` whose agents omit the session name and `sanitize_herdr_agent_name` of that name (`84-86`). A non-zero exit, a timeout, an `OSError`, or JSON that has no `result.agents` list returns `None` (`75-83`). `timeout <= 0` returns `None` before any subprocess (`64-65`). Measured on this snapshot: `herdr_session_state(..., 0)` and `herdr_session_state(..., -1)` are both `None`. `wait_s({})` is 600, `wait_s` of `"0"` is 0, and `wait_s` of `"abc"` is 600 (`36-41`).
The child environment sets `MAM_NO_AUTOSTART=1` and `HERDR_SESSION_NAME` to the marker's server, or `default` when that field is empty (`67`). The shim skips autostart when that variable is `1` (`lib.sh:203-208`). `bounded_probe` passes the time left on the deadline (`287-291`). The call sits inside `fcntl.flock(LOCK_EX)`. macOS has no `flock(1)`. The ordering tests passed with `fcntl`.
`blockers` includes a kept marker of the caller's own session when that row is absent (`133-147`). The waiter then holds until the marker is pruned or the cap expires (`325-347`). On cap expiry the message uses the last definite answer from this wait (`333-344`). An unknown orphan at cap 0 adds `MAM_ID_ORDER_WAIT_S=0 leaves no time to ask herdr`.
## Ordinary create, resume, and retry
Create calls `has-session` and exits 3 when the name is already present (`create_session.sh:168-170`). The id-order wait is later (`287`). Resume sets `HERDR_ALIVE` from `has-session` (`resume_session.sh:125-128`) and, when the session is alive, updates the row and exits (`158-169`) before the wait (`243`). The mock `has-session` exits 0 when the agent name is in its state (`conftest.py:1345-1355`). A retry that herdr still lists under that name therefore stops at `has-session` and does not enter the 600-second cap.
The wait itself returns 0 on the first lock hold when `blockers` is empty (`310-323`). That is a create or resume with no live same-cwd marker and no running row that still lacks an id. The default 600 is the cap for a marker that stays, and the function does not sleep it out on an empty blocker list.
When the caller has already passed `has-session` and a dead-owner marker of the same name remains:
- A parsed list that omits the name removes the marker and returns 0 on that pass (`228-229`, then `310-323`). The real-probe test does this with cap 2 on the marker's own session name and checks that the replacement marker's owner pid is the waiter (`test_issue63_same_cwd_ids.py:1315-1317`).
- A parsed list that contains the name keeps the dead owner's pid and returns 1 inside the cap (`1290-1292`). The unit test at `wait_s_val=0` with an injected `pane_state` prints `its herdr session is alive` and `kill-session -t same-agy` (`1133-1140`).
- `None`, which covers a failed lookup, `server_not_running`, and a real cap of 0, keeps the marker and the waiter uses the cap. That is the same rule `7cac6f6` already applies to every other orphan. The real-probe cap-0 assertion is on the other session name in the same test (`1306-1310`); the same-name branch calls the same `herdr_session_state`.
The head suite finished in 1301.73 s, in line with the previous pass on `7cac6f6` (1263.90 s). A common create or resume that now slept out 600 seconds would have stretched that run. It did not.
## Tests for this commit
`no_herdr_probe` (`test_issue63_same_cwd_ids.py:32-36`) makes an in-process `herdr_session_state` return false unless the test passes `pane_state`. The shell tests start a child, and that child runs the real function.
`test_id_order_dead_owner_marker_blocks_while_its_unregistered_herdr_session_is_alive` (`1047`) still covers another session's orphan: alive keeps and prints the kill line, unknown keeps, a registered running row drops the marker with an empty probe list, and a false answer admits the next session. The same-name block (`1120-1148`) keeps a live owner's pid, keeps a dead owner's pid while the injected probe returns true or `None`, and on false replaces the owner pid with the waiter's pid.
`test_id_order_real_probe_against_mock_herdr_fails_closed` (`1250`) sources the sandbox `lib.sh` and calls `mam_id_order_wait`. A listed agent returns 1 for both the other session name and the marker's own name, and the dead owner's pid stays (`1287-1292`). `protocol_mismatch` and `server_not_running` return 1 with `herdr could not tell whether its session is gone` (`1294-1304`). The mock exits 1 for `force_error_code` before the subcommand (`conftest.py:486-488`). Cap 0 returns 1 with `MAM_ID_ORDER_WAIT_S=0 leaves no time to ask herdr` and keeps the marker (`1306-1310`). An unlisted agent returns 0 for the other name, and a following wait on the marker's own name returns 0 with a new owner pid (`1312-1317`). The mock `agent list` prints `{"result": {"agents": [...]}}` with each agent's `name` (`conftest.py:794-806`). A parsed list that contains the name is what prints `its herdr session is alive`.
`mock_herdr` prepends its bin (`conftest.py:1411`). These shell tests passed in the head run below. The unlisted own-name case returning 0, and the listed own-name case returning 1, are the evidence the probe reached the mock.
`test_id_order_create_waits_for_orphan_herdr_session_of_a_killed_create` (`1160`) and `test_id_order_herdr_probes_stay_within_the_wait_cap` (`1207`) are unchanged by this commit and passed. The create test uses a different session name and cap 3. The cap test's cap 0 finishes with every budget `<= 0` and both markers still on disk.
## Rest of base..head
The twelve commits through `7cac6f6` are the tree reviewed in job 737239ab. This commit does not change `session_ids.py`, `lib.sh`, the stop, resume, or create scripts, or the skill exit tables. `session_ids.py` still uses `#40`'s `capture` and `claimed_elsewhere`. Stop still decides the id before kill. Resume still resolves the id and refuses a claimed id before spawn. `git grep` for `capture_conversation_id` and `CAPTURE_ID`, excluding `.agents/reports` and `IMPROVEMENTS.md`, finds no caller. The diff names no loop or job-tree file, so the author's one-off `tests/test_issue34_job_tree.py::test_t5b_a_*[False]` failure is outside this change. `/bin/bash -n` accepts the snapshot `lib.sh`. Python 3.9.6 imports `id_order`. The annotations sit behind `from __future__ import annotations`.
## Results
Pytest used the control-plane venv, `PYTHONDONTWRITEBYTECODE=1`, `-p no:cacheprovider`, and `--basetemp` `/tmp/mamdee-dee00bc0-pytest` (head) and `/tmp/mamdee-dee00bc0-base-pytest` (base). Neither path contains `mam-review` or `shim`. Inherited `HERDR_*`, `MAM_*`, `MQTT_*`, `SKS_*`, `DELEGATE_JOB_LOGS_DIR`, `HOME_DIR`, `CLAUDE_*`, `CODEX_HOME`, `PYTHONPATH`, `WORKSPACE_ROOT`, `MAM_ENV_FILE`, `MAM_JOBS_DIR`, `AGENT_SESSIONS_YAML`, the four `XDG_*` homes, and `AGENT_PYTHON_BIN` were dropped.
Head, cwd the snapshot: `tests/test_issue63_same_cwd_ids.py`, `test_issue40_auto_pin.py`, `test_issue64_ro_connect.py`, `test_issue10_13_purge.py`, `test_issue31_purge_residual.py`, `test_issue42_skill_blocks.py`, `test_tier1_unit.py`, `test_tier2_component.py`, `test_uuid_target.py`, `test_issue24_resume_dialogs.py`. **359 passed, 1 skipped** in 1301.73 s. Exit 0. The log closed at 2026-10-23 14:09:03. `.claude/skills` is absent in this snapshot, and `test_claude_skills_link_resolves_to_agents_skills` skips in that case (`test_issue42_skill_blocks.py:266`). That file is outside this delta. The same-name assertions are inside the two existing probe tests, so the pass count matches the previous head.
Base, this turn: a throwaway `git clone --shared` checked out at `dc886ecd6b38c01032a55a2d1c230c73362ae630`, with only `tests/test_issue63_same_cwd_ids.py` copied from head. `id_order.py` is absent there. **27 errors** in 0.11 s. Exit 1. Each error is `ImportError: cannot import name 'id_order' from 'lib_py.agents'` at setup of `no_herdr_probe`, including `test_id_order_dead_owner_marker_blocks_while_its_unregistered_herdr_session_is_alive`, `test_id_order_real_probe_against_mock_herdr_fails_closed`, and `test_c4`. The snapshot was not checked out. Snapshot porcelain stayed empty.
The behavioral base result for this same base, on the test file from before that fixture imported `id_order`, remains the one recorded by job ba3eeb7b: **22 failed, 1 passed** in 181.15 s. The pass was `test_c4_pinned_post_discovered_and_preassigned_agents_unchanged`. On that run the second stop row received A's id, purge printed `purged:`, the unpinned resume carried A's id, and cap 0 still spawned. Those tests passed on this head inside the 359.
1. Stop order A→B and B→A, including herdr already dead, records only a verified own id. `test_c1_stop_same_cwd_agy_sessions_records_only_verified_owner` passed on head.
2. Purge of B leaves A's files. `test_c2_purge_never_deletes_another_rows_conversation` passed on head.
3. Resume of an unpinned B stays off A's conversation, and the already-running branch keeps the epoch. `test_c3_resume_unpinned_second_agy_row_starts_fresh_and_running_branch_keeps_epoch` passed on head.
4. A row that already has an own id, and claude/grok, stay on the previous path. `test_c4_pinned_post_discovered_and_preassigned_agents_unchanged` passed on head, along with `test_issue40_auto_pin.py`, `test_issue64_ro_connect.py`, and `test_issue10_13_purge.py`.
5. A same-cwd create waits while another post-discovered row of the same agent has no id. `test_c6_create_waits_or_refuses_while_same_cwd_post_discovered_row_has_no_id` passed on head. The orphan tests cover a dead owner, including one on the caller's own session name, whose herdr session is alive, unknown, or gone.
6. Resume refuses before spawn when the resolved id belongs to another row. `test_c7_resume_refuses_when_recorded_id_is_claimed_by_another_row` passed on head.
## Blocking defects
None.
## Non-blocking
- While herdr keeps failing the lookup, the orphan marker stays, and every later create or resume that reaches the wait — a retry of the marker's own session name included — waits out `MAM_ID_ORDER_WAIT_S` again (`id_order.py:75-76`, `227-229`). The wait is bounded. Create and resume still return at `has-session` when that command already sees the name (`create_session.sh:168-170`, `resume_session.sh:158-169`). The cap message names `herdr kill-session`.
- The probe holds the id-order lock until it returns. The budget is the time left on the deadline, and at most 30 seconds (`id_order.py:73`, `287-309`).
- An empty `herdr_server` is probed on the `default` server (`id_order.py:67`). Create and resume export `HERDR_SESSION_NAME` before they write a new marker (`create_session.sh:185-186`, `resume_session.sh:111-114`). The cap line prints `default` for an empty server and leaves the server and session unquoted (`id_order.py:335-344`).
- A successful agent list that is briefly empty while a pane is still registering would prune the marker. The tests cover a list that already contains the name, and a list that does not.
- One remaining same-type row with herdr dead still uses `find_workspace_uuid` (`session_ids.py:274-275`).
- A running row with an empty own id and a missing pane pid blocks until the cap (`id_order.py:128-131`). The unit test asserts that return of 1 (`test_issue63_same_cwd_ids.py:1150-1154`).
- A marker whose owner pid has been reused by an unrelated live process stays until that process exits (`id_order.py:221-222`).
- With herdr alive, a recorded id that fails revalidation is still returned (`session_ids.py:240-241`).
- An unparsable `MAM_ID_ORDER_WAIT_S` becomes 600 (`id_order.py:36-41`).
- `stop_session.sh:220` uses `eval "$(mam_session_ids stop ...)" || true`. A normal decision returns 0. A crashed helper leaves the captured fields empty and the stop continues.
- `--no-onboard` waits out the cap. Presence-lock id discovery is not in this PR. Both are the deferrals named by the issue and the PR body.
- `resolve_session_id.sh:4-5` still describes an empty line and exit 0. The script calls `mam_session_ids` under `set -euo pipefail` (line 10). The resume skill exit table documents exit 1 when another row claims the id.
- Read-only connect residuals from #64 (immutable race, `mode=ro` sidecar rewrite, purge DELETE staying read-write) are outside this diff. `_ro_connect` is unchanged.
## Host check
No process has a first command token of `opencode` or a path ending in `/opencode`. `lsof` on `~/.local/share/opencode/opencode.db` exited 1. That database was not opened. Its mtime is 1792641027 and its size is 36712448. `opencode.log` is absent. `~/.config/opencode/service.json` mtime is 1792246838 and its size is 64. `~/.local/state/opencode/service.json` is absent. The herdr config mtime is 1784456394. `~/.local/state/mam` mtime is 1791810241. `~/.local/state/mam/id-order` is absent. These stats match the check taken before the suite.
Eight herdr processes were running immediately before the suite. After the suite log closed at 2026-10-23 14:09:03, the 14:09:37 check found seven. Two pids from the before-set were gone: one `/opt/homebrew/bin/herdr` whose session argument was `multi-agent-mux` (about 40 seconds old at suite start) and one whose session argument was `mam-pylecturenotes` (about 3 seconds old at suite start). The new process is `/opt/homebrew/bin/herdr` with `--session multi-agent-mux`, elapsed time about 54 seconds at 14:09:37, so it started near 14:08:43, during the last half-minute of the suite. No herdr command line contained the review basetemp, the snapshot path, or the test session names `probe-ws` and `orphan-ws`. This review did not start or stop a herdr server.
After this report file was written, the same check was repeated at 2026-10-23 14:11:38. Snapshot porcelain is empty and the snapshot has no `.mam`. Control-plane porcelain is empty at `dc886ecd6b38c01032a55a2d1c230c73362ae630`. No process has a first command token of `opencode` or a path ending in `/opencode`, and `lsof` still reports no database holder. The database mtime is still 1792641027 and its size is still 36712448. `opencode.log` is still absent. The config service file mtime is still 1792246838. The state service file is still absent. The herdr config mtime is still 1784456394. `~/.local/state/mam` mtime is still 1791810241, and `id-order` is still absent. Seven herdr processes were present. The pid set differs from the 14:09:37 set by one homebrew herdr process whose arguments include `--session multi-agent-mux`; its elapsed time was about 54 seconds, so that process had turned over again. No herdr command line contained the review basetemp, the snapshot path, or `probe-ws` or `orphan-ws`. This review did not start or stop it.
[VERDICT: PASS]
+25 -12
View File
@@ -29,7 +29,7 @@ export WORKSPACE_ROOT
# Framework semantic version. Single runtime source of truth; kept in lockstep # 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. # 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. # NOTE: unlike other MAM_* variables this one is intentionally NOT env-overridable.
MAM_VERSION="4.9.9" MAM_VERSION="4.10.0"
export MAM_VERSION export MAM_VERSION
AGENT_SESSIONS_YAML="${AGENT_SESSIONS_YAML:-$WORKSPACE_ROOT/.mam/agent-sessions.yaml}" AGENT_SESSIONS_YAML="${AGENT_SESSIONS_YAML:-$WORKSPACE_ROOT/.mam/agent-sessions.yaml}"
@@ -2024,18 +2024,31 @@ PYEOF
} }
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# capture_conversation_id <agent> <workdir> [session_name] # mam_session_ids <stop|resume> [args...]
# # Issue #63: stop/purge/resume conversation-id decision (lib_py.agents.session_ids).
# Thin wrapper over find_workspace_uuid: resolves THIS workspace's conversation
# id (claude jsonl sessionId / agy db uuid) and prints it on stdout (empty line
# if none). find_workspace_uuid is already a workspace-scoped, 2-tier, race-free
# resolver (per-row own id -> workspace-scoped disk scan),
# so recording its result into the row before kill guarantees tier-1 on the next
# resume. Pass session_name to prefer that specific row's recorded id. Always exits 0.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
capture_conversation_id() { mam_session_ids() {
local agent="$1" workdir="$2" session_name="${3:-}" env_python "$AGENT_SESSIONS_YAML" "$@" <<'PYEOF'
find_workspace_uuid "$workdir" "$agent" "$session_name" import sys
from lib_py.agents.session_ids import main
sys.exit(main(sys.argv[1:]))
PYEOF
}
# ---------------------------------------------------------------------------
# mam_id_order_wait <workspace> <agent> <session> [uuid]
# Issue #63: same-cwd creation/resume ordering for post-discovered agents.
# ---------------------------------------------------------------------------
mam_id_order_wait() {
local workspace="$1" agent="$2" session_name="$3" uuid="${4:-}"
local abs; abs="$(mam_abs_workspace "$workspace")"
env_python "$AGENT_SESSIONS_YAML" wait --cwd "$abs" --agent "$agent" \
--session "$session_name" --owner-pid "$$" --uuid "$uuid" \
--herdr-server "${HERDR_SESSION_NAME:-}" <<'PYEOF'
import sys
from lib_py.agents.id_order import main
sys.exit(main(sys.argv[1:]))
PYEOF
} }
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+382
View File
@@ -0,0 +1,382 @@
"""Same-cwd creation/resume ordering for post-discovered agents (Issue #63).
While a post-discovered agent (agy, codex, hermes, opencode) in a workspace is
still being created (marker held by a live create/resume process) or is running
without a recorded conversation id (live pane pid), a second create or resume of
the same agent type in that workspace waits until the first records its id, up
to MAM_ID_ORDER_WAIT_S (default 600 s; 0 = do not wait). A marker whose owner
died before the row was registered keeps blocking until herdr answers that its
session is gone (the pane may still discover the workspace id).
"""
from __future__ import annotations
import argparse
import fcntl
import hashlib
import json
import os
import subprocess
import sys
import tempfile
import time
from typing import Any, Callable, Dict, List, Mapping, Optional
from lib_py import ints
from lib_py.agents.autopin import claimed_elsewhere
from lib_py.agents.base import POST_DISCOVERED
from lib_py.agents.registry import agent_of_row, get_adapter
from lib_py.agents.sanitize import sanitize_herdr_agent_name
from lib_py.agents.session_ids import canonical_cwd, claiming_rows, own_id
from lib_py.state import load_state_json
DEFAULT_WAIT_S = 600
PROBE_TIMEOUT_S = 30.0
def wait_s(env: Mapping[str, str] = os.environ) -> int:
raw = env.get("MAM_ID_ORDER_WAIT_S")
if raw is None:
return DEFAULT_WAIT_S
n = ints.positive_int(raw, 0)
return DEFAULT_WAIT_S if n is None else n
def pid_alive(pid: int) -> bool:
if not isinstance(pid, int) or pid <= 0:
return False
try:
os.kill(pid, 0)
return True
except ProcessLookupError:
return False
except PermissionError:
return True
except OSError:
return False
def herdr_session_state(server: str, session: str, timeout: float) -> Optional[bool]:
"""Is `session` live on herdr server `server`: True, False, or None when
herdr gave no authoritative answer. False only from a successful, parsed
`agent list` without the name; a failed (server down included), timed-out
or unparseable lookup, or no time left, is None. Never starts a server
(MAM_NO_AUTOSTART)."""
if timeout <= 0:
return None
skills_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
env = dict(os.environ, MAM_NO_AUTOSTART="1", HERDR_SESSION_NAME=server or "default")
script = 'source "$1" && _herdr agent list'
try:
res = subprocess.run(
["bash", "-c", script, "_", os.path.join(skills_dir, "lib.sh")],
env=env, stdin=subprocess.DEVNULL, capture_output=True, text=True,
timeout=min(timeout, PROBE_TIMEOUT_S),
)
except (OSError, subprocess.SubprocessError):
return None
if res.returncode != 0:
return None
try:
agents = json.loads(res.stdout)["result"]["agents"]
except (ValueError, KeyError, TypeError):
return None
if not isinstance(agents, list):
return None
names = {session, sanitize_herdr_agent_name(session)}
return any(isinstance(a, dict) and a.get("name") in names for a in agents)
def order_dir(cwd: str, agent: str, env: Mapping[str, str] = os.environ,
home: Optional[str] = None) -> str:
h = home or env.get("HOME") or env.get("HOME_DIR") or os.path.expanduser("~")
real_cwd = canonical_cwd(cwd or ".")
digest = hashlib.sha256(real_cwd.encode("utf-8")).hexdigest()[:16]
return os.path.join(h, ".local", "state", "mam", "id-order", f"{digest}-{agent}")
def _parse_pos_int(val: Any) -> Optional[int]:
if isinstance(val, bool):
return None
if isinstance(val, int):
return val if val > 0 else None
s = str(val or "").strip()
return int(s) if s.isdigit() and int(s) > 0 else None
def blockers(state: Dict[str, Any], cwd: str, agent: str, me: str,
markers: List[Dict[str, Any]],
is_alive: Callable[[int], bool] = pid_alive) -> List[str]:
target_cwd = canonical_cwd(cwd)
found: List[str] = []
rows_by_name: Dict[str, Dict[str, Any]] = {}
for s in state.get("herdr_sessions") or []:
if not isinstance(s, dict):
continue
rname = str(s.get("name") or "")
if rname:
rows_by_name[rname] = s
if not rname or rname == me:
continue
s_cwd = (s.get("pane") or {}).get("cwd") or ""
if not s_cwd or canonical_cwd(s_cwd) != target_cwd:
continue
if agent_of_row(s) != agent or s.get("status") != "running":
continue
if own_id(s, agent):
continue
pane_pid = _parse_pos_int((s.get("pane") or {}).get("pid"))
if pane_pid is None or is_alive(pane_pid):
if rname not in found:
found.append(rname)
for m in markers or []:
if not isinstance(m, dict):
continue
m_sess = str(m.get("session") or "")
if not m_sess or m_sess in found:
continue
m_cwd = str(m.get("cwd") or "")
if m_cwd and canonical_cwd(m_cwd) != target_cwd:
continue
m_agent = str(m.get("agent") or "")
if m_agent and m_agent != agent:
continue
row = rows_by_name.get(m_sess)
if row is None:
found.append(m_sess)
continue
status = str(row.get("status") or "")
cur_own = own_id(row, agent)
row_epoch = int(row.get("herdr_session_epoch") or 0)
end_epoch = int(row.get("stopped_at_epoch") or row.get("terminated_at_epoch") or 0)
m_at = int(m.get("at") or 0)
prev_own = str(m.get("prev_own_id") or "")
prev_epoch = int(m.get("prev_epoch") or 0)
prev_end_epoch = int(m.get("prev_end_epoch") or 0)
pinned_for_attempt = bool(cur_own) and (
cur_own != prev_own
or (status == "running" and row_epoch > prev_epoch)
)
if pinned_for_attempt:
continue
if status == "running":
found.append(m_sess)
else:
ended_after_attempt = (
end_epoch > max(m_at, prev_end_epoch)
and (prev_epoch == 0 or row_epoch > prev_epoch)
)
if not ended_after_attempt:
found.append(m_sess)
return found
def _registered_for_attempt(m: Dict[str, Any], row: Optional[Dict[str, Any]]) -> bool:
if not isinstance(row, dict) or row.get("status") != "running":
return False
return int(row.get("herdr_session_epoch") or 0) > int(m.get("prev_epoch") or 0)
def _read_and_prune_markers(dpath: str, my_pid: int, state: Dict[str, Any],
is_alive: Callable[[int], bool] = pid_alive,
pane_state: Callable[[str, str], Optional[bool]] = (
lambda server, session: None),
) -> List[Dict[str, Any]]:
"""Markers that still count. A live owner keeps its marker (another live
attempt on the caller's session included). A dead owner's marker goes once
its row is registered for the attempt (the row decides from then on) or
herdr answers that its session is gone; until then it is kept as an orphan,
one an earlier attempt left on the caller's session included."""
rows_by_name = {
str(s.get("name") or ""): s
for s in (state.get("herdr_sessions") or []) if isinstance(s, dict)
}
out: List[Dict[str, Any]] = []
try:
names = sorted(os.listdir(dpath))
except OSError:
return out
for fname in names:
if not fname.endswith(".json") or fname.startswith("."):
continue
fpath = os.path.join(dpath, fname)
try:
with open(fpath, encoding="utf-8") as f:
data = json.load(f)
except (OSError, ValueError):
try:
os.remove(fpath)
except OSError:
pass
continue
if not isinstance(data, dict):
continue
owner_pid = _parse_pos_int(data.get("owner_pid"))
sess = str(data.get("session") or "")
if owner_pid is None or owner_pid == my_pid:
drop = True
elif is_alive(owner_pid):
drop = False
elif not sess:
drop = True
elif _registered_for_attempt(data, rows_by_name.get(sess)):
drop = True
else:
live = pane_state(str(data.get("herdr_server") or ""), sess)
drop = live is False
data["_orphan"] = "alive" if live else "unknown"
if drop:
try:
os.remove(fpath)
except OSError:
pass
continue
out.append(data)
return out
def write_marker(dpath: str, session: str, owner_pid: int, cwd: str, agent: str,
prev_row: Optional[Dict[str, Any]] = None, herdr_server: str = "") -> str:
os.makedirs(dpath, exist_ok=True)
marker_path = os.path.join(dpath, f"{session}.json")
payload: Dict[str, Any] = {
"session": session,
"owner_pid": int(owner_pid),
"cwd": canonical_cwd(cwd),
"agent": agent,
"herdr_server": herdr_server,
"at": int(time.time()),
}
if isinstance(prev_row, dict):
payload["prev_own_id"] = own_id(prev_row, agent)
payload["prev_epoch"] = int(prev_row.get("herdr_session_epoch") or 0)
payload["prev_end_epoch"] = int(
prev_row.get("stopped_at_epoch") or prev_row.get("terminated_at_epoch") or 0
)
fd, tmp = tempfile.mkstemp(dir=dpath, prefix=".marker.", suffix=".tmp")
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(payload, f)
os.replace(tmp, marker_path)
return marker_path
def wait(cwd: str, agent: str, session: str, owner_pid: int,
uuid: str = "", yaml_path: Optional[str] = None,
home: Optional[str] = None, wait_s_val: Optional[int] = None,
poll: float = 0.25,
is_alive: Callable[[int], bool] = pid_alive,
herdr_server: str = "",
pane_state: Optional[Callable[[str, str, float], Optional[bool]]] = None) -> int:
adapter = get_adapter(agent)
if adapter is None or adapter.id_lifecycle != POST_DISCOVERED:
return 0
cap = wait_s(os.environ) if wait_s_val is None else int(wait_s_val)
dpath = order_dir(cwd, agent, os.environ, home=home)
os.makedirs(dpath, exist_ok=True)
lock_file = os.path.join(dpath, "lock")
deadline = time.monotonic() + cap
announced = False
row_ref = {"name": session}
probe = pane_state or herdr_session_state
answers: Dict[str, bool] = {} # last definite answer per session, for the message
def bounded_probe(server: str, sess: str) -> Optional[bool]:
live = probe(server, sess, deadline - time.monotonic())
if live is not None:
answers[sess] = live
return live
while True:
with open(lock_file, "a", encoding="utf-8") as lf:
fcntl.flock(lf, fcntl.LOCK_EX)
state = load_state_json(yaml_path)
if uuid:
claimed = set(claimed_elsewhere(row_ref, state))
if uuid in claimed:
others = ", ".join(claiming_rows(row_ref, uuid, state))
print(
f"ERROR: {session}'s recorded id {uuid} is also recorded by {others}; "
f"refusing to resume on a conversation another session claims (Issue #63).",
file=sys.stderr, flush=True,
)
return 1
markers = _read_and_prune_markers(dpath, owner_pid, state,
is_alive=is_alive, pane_state=bounded_probe)
blks = blockers(state, cwd, agent, session, markers, is_alive=is_alive)
if not blks:
prev_row = next(
(
s for s in (state.get("herdr_sessions") or [])
if isinstance(s, dict) and s.get("name") == session
),
None,
)
already_recorded = bool(uuid) and own_id(prev_row, agent) == uuid
if not already_recorded and owner_pid and owner_pid > 0:
write_marker(dpath, session, owner_pid, cwd, agent, prev_row=prev_row,
herdr_server=herdr_server)
return 0
names = ", ".join(blks)
if time.monotonic() >= deadline:
print(
f"ERROR: {names} in {cwd} still has no recorded {agent} id after {cap}s; "
f"not starting {session} (Issue #63). Wait for its first turn to finish, "
f"or stop it with multi-agent-mux-stop.",
file=sys.stderr, flush=True,
)
for m in markers:
if m.get("_orphan") and m.get("session") in blks:
srv = m.get("herdr_server") or "default"
alive = m["_orphan"] == "alive" or answers.get(m["session"]) is True
seen = ("its herdr session is alive" if alive
else "herdr could not tell whether its session is gone")
if not alive and cap == 0:
seen += " (MAM_ID_ORDER_WAIT_S=0 leaves no time to ask herdr)"
print(
f"ERROR: {m['session']}'s create/resume exited before recording it, "
f"and {seen}; close it with: "
f"HERDR_SESSION_NAME={srv} herdr kill-session -t {m['session']}",
file=sys.stderr, flush=True,
)
return 1
if not announced:
print(
f"id-order: waiting for {names} ({agent}, no recorded id yet) in {cwd} "
f"- up to {cap}s (MAM_ID_ORDER_WAIT_S)",
file=sys.stderr, flush=True,
)
announced = True
remaining = deadline - time.monotonic()
if remaining > 0:
time.sleep(min(poll, remaining))
def main(argv: Optional[List[str]] = None) -> int:
p = argparse.ArgumentParser(prog="python -m lib_py.agents.id_order")
sub = p.add_subparsers(dest="cmd", required=True)
s_wait = sub.add_parser("wait")
s_wait.add_argument("--cwd", required=True)
s_wait.add_argument("--agent", required=True)
s_wait.add_argument("--session", required=True)
s_wait.add_argument("--owner-pid", type=int, default=0)
s_wait.add_argument("--uuid", default="")
s_wait.add_argument("--herdr-server", default="")
a = p.parse_args(argv)
return wait(
cwd=a.cwd,
agent=a.agent,
session=a.session,
owner_pid=a.owner_pid,
uuid=a.uuid or "",
herdr_server=a.herdr_server or "",
)
if __name__ == "__main__":
sys.exit(main())
+351
View File
@@ -0,0 +1,351 @@
"""Stop, purge, and resume conversation-id decisions (Issue #63).
Post-discovered agents (agy, codex, hermes, opencode) write their conversation
id to disk only after their first turn, and agy's workspace cache holds one id
per cwd. Stop, purge, and resume therefore use the row's own recorded id first
(never falling through to a workspace guess when a recorded id is present), and
otherwise use the #40 active-session capture when herdr is alive. Pre-assigned
agents (claude, grok) keep find_workspace_uuid with other rows' claimed ids
excluded.
"""
from __future__ import annotations
import argparse
import os
import shlex
import sys
from typing import Any, Dict, List, Optional
from lib_py.agents.autopin import OWN_KEYS, capture, claimed_elsewhere
from lib_py.agents.base import DiscoveryContext, POST_DISCOVERED
from lib_py.agents.registry import agent_of_row, get_adapter
from lib_py.paths import resolve_home
from lib_py.state import load_state_json
from lib_py.verify_session import verify_session_uuid
from lib_py.workspace_uuid import find_workspace_uuid
def canonical_cwd(path: str) -> str:
if not path:
return ""
try:
return os.path.realpath(path)
except Exception:
return os.path.abspath(path)
def own_id(row: Optional[Dict[str, Any]], agent: str) -> str:
adapter = get_adapter(agent)
if not adapter or not isinstance(row, dict):
return ""
val = row.get(adapter.own_key)
return str(val).strip() if val else ""
def claiming_rows(row: Dict[str, Any], uuid: str, state: Dict[str, Any]) -> List[str]:
if not uuid:
return []
me = row.get("name")
holders: List[str] = []
for other in state.get("herdr_sessions") or []:
if other is row or (me and other.get("name") == me):
continue
if any(other.get(k) == uuid for k in OWN_KEYS):
other_name = str(other.get("name") or "<unnamed>")
if other_name not in holders:
holders.append(other_name)
return holders
def same_type_rows(state: Dict[str, Any], cwd: str, agent: str) -> List[Dict[str, Any]]:
if not cwd or not agent:
return []
target_cwd = canonical_cwd(cwd)
out: List[Dict[str, Any]] = []
for s in state.get("herdr_sessions") or []:
if not isinstance(s, dict):
continue
s_cwd = (s.get("pane") or {}).get("cwd") or ""
if s_cwd and canonical_cwd(s_cwd) == target_cwd and agent_of_row(s) == agent:
out.append(s)
return out
def row_for_capture(row: Optional[Dict[str, Any]], pane_pid: Optional[int],
cwd: str = "", name: str = "", agent: str = "") -> Dict[str, Any]:
copy = dict(row) if isinstance(row, dict) else {}
if name and not copy.get("name"):
copy["name"] = name
if agent and not copy.get("agent"):
copy["agent"] = agent
pane = dict(copy.get("pane") or {})
if cwd and not pane.get("cwd"):
pane["cwd"] = cwd
if isinstance(pane_pid, int) and pane_pid > 0:
pane["pid"] = pane_pid
copy["pane"] = pane
return copy
def _find_row(state: Dict[str, Any], name: str) -> Optional[Dict[str, Any]]:
if not name:
return None
for s in state.get("herdr_sessions") or []:
if isinstance(s, dict) and s.get("name") == name:
return s
return None
def _parse_pid(raw: Any) -> Optional[int]:
if isinstance(raw, int):
return raw if raw > 0 else None
s = str(raw or "").strip()
return int(s) if s.isdigit() and int(s) > 0 else None
def decide_stop(state: Dict[str, Any], name: str, agent: str, cwd: str,
herdr_alive: bool, pane_pid: Optional[int] = None,
purge: bool = False, home: Optional[str] = None,
claude_dir: Optional[str] = None) -> Dict[str, str]:
home_resolved = resolve_home(home)
claude_resolved = claude_dir or os.environ.get("CLAUDE_PROJECT_DIR") or f"{home_resolved}/.claude/projects"
row = _find_row(state, name)
row_ref = row if row is not None else ({"name": name} if name else {})
claimed = set(claimed_elsewhere(row_ref, state))
adapter = get_adapter(agent)
own = own_id(row, agent)
if own:
if own in claimed:
others = ", ".join(claiming_rows(row_ref, own, state))
return {
"captured": "",
"status": "sibling-claimed",
"purge_uuid": "",
"note": f"{own} is also recorded by {others}; registry needs fixing",
}
if adapter and adapter.id_lifecycle == POST_DISCOVERED:
if verify_session_uuid(cwd, agent, own, row, home_dir=home_resolved,
claude_dir=claude_resolved, mode="revalidate"):
return {
"captured": own,
"status": "already-set",
"purge_uuid": own if purge else "",
"note": "",
}
ctx = DiscoveryContext(workspace=cwd, agent_name=agent, home_dir=home_resolved,
claude_dir=claude_resolved, row=row, mode="revalidate")
can_purge = bool(purge and adapter.has_artifacts(own, ctx))
return {
"captured": "",
"status": "unverified-own",
"purge_uuid": own if can_purge else "",
"note": f"recorded id {own} no longer verifies; kept as is",
}
if adapter is None or adapter.id_lifecycle != POST_DISCOVERED:
found = find_workspace_uuid(cwd, agent, name, state, home_resolved, claude_resolved,
exclude_ids=claimed) if cwd else ""
if found and found in claimed:
others = ", ".join(claiming_rows(row_ref, found, state))
return {
"captured": "",
"status": "sibling-claimed",
"purge_uuid": "",
"note": f"{found} is also recorded by {others}; registry needs fixing",
}
return {
"captured": found,
"status": "resolved" if found else "no-candidate",
"purge_uuid": found if purge else "",
"note": "" if found else "nothing on disk yet",
}
if herdr_alive:
cap_row = row_for_capture(row, _parse_pid(pane_pid), cwd=cwd, name=name, agent=agent)
res = capture(cap_row, agent, state, home_dir=home_resolved)
if res.get("status") == "captured" and res.get("session_id"):
u = str(res["session_id"])
if u in claimed:
others = ", ".join(claiming_rows(row_ref, u, state))
return {
"captured": "",
"status": "sibling-claimed",
"purge_uuid": "",
"note": f"{u} is also recorded by {others}; registry needs fixing",
}
return {
"captured": u,
"status": "captured",
"purge_uuid": u if purge else "",
"note": "",
}
st = str(res.get("status") or "no-candidate")
reason = str(res.get("reason") or "")
return {
"captured": "",
"status": st,
"purge_uuid": "",
"note": f"{st}: {reason}" if reason else st,
}
return {
"captured": "",
"status": "herdr-dead",
"purge_uuid": "",
"note": "herdr session was already gone, so the pane that owns the conversation could not be checked; no id recorded",
}
def decide_resume(state: Dict[str, Any], name: str, agent: str, workspace: str,
herdr_alive: bool = False, pane_pid: Optional[int] = None,
home: Optional[str] = None,
claude_dir: Optional[str] = None) -> Dict[str, Any]:
home_resolved = resolve_home(home)
claude_resolved = claude_dir or os.environ.get("CLAUDE_PROJECT_DIR") or f"{home_resolved}/.claude/projects"
row = _find_row(state, name)
adapter = get_adapter(agent)
if row is None and not name and workspace:
same = same_type_rows(state, workspace, agent)
if len(same) == 1:
row = same[0]
name = str(row.get("name") or "")
elif adapter is None or adapter.id_lifecycle != POST_DISCOVERED:
creator = next(
(s for s in same if str(s.get("name") or "").endswith(f"-creator-{agent}")),
None,
)
if creator is not None:
row = creator
name = str(row.get("name") or "")
row_ref = row if row is not None else ({"name": name} if name else {})
claimed = set(claimed_elsewhere(row_ref, state))
own = own_id(row, agent)
if own:
if own in claimed:
others = ", ".join(claiming_rows(row_ref, own, state))
return {
"uuid": "",
"drop_stale_id": False,
"notes": [],
"error": (f"ERROR: {name}'s recorded id {own} is also recorded by {others}; "
"refusing to resume on a conversation another session claims (Issue #63). "
"One of these rows holds the wrong id - fix the registry before resuming."),
}
if adapter and adapter.id_lifecycle == POST_DISCOVERED:
if verify_session_uuid(workspace, agent, own, row, home_dir=home_resolved,
claude_dir=claude_resolved, mode="revalidate"):
return {"uuid": own, "drop_stale_id": False, "notes": [], "error": None}
if herdr_alive:
return {"uuid": own, "drop_stale_id": False, "notes": [], "error": None}
note = (f"NOTE: {name}'s recorded {agent} id {own} no longer verifies "
"(its conversation is missing or empty); starting a new conversation (Issue #63)")
return {"uuid": "", "drop_stale_id": True, "notes": [note], "error": None}
if adapter is None or adapter.id_lifecycle != POST_DISCOVERED:
u = find_workspace_uuid(workspace, agent, name, state, home_resolved, claude_resolved,
exclude_ids=claimed)
if u and u in claimed:
others = ", ".join(claiming_rows(row_ref, u, state))
return {
"uuid": "",
"drop_stale_id": False,
"notes": [],
"error": (f"ERROR: {name or agent}'s resolved id {u} is also recorded by {others}; "
"refusing to resume on a conversation another session claims (Issue #63)."),
}
return {"uuid": u, "drop_stale_id": False, "notes": [], "error": None}
if herdr_alive:
cap_row = row_for_capture(row, _parse_pid(pane_pid), cwd=workspace, name=name, agent=agent)
res = capture(cap_row, agent, state, home_dir=home_resolved)
u = str(res["session_id"]) if res.get("status") == "captured" and res.get("session_id") else ""
else:
same = same_type_rows(state, workspace, agent)
n = len(same)
if name and all(r.get("name") != name for r in same):
n += 1
if n >= 2:
label = name or agent
note = (f"NOTE: {label} has no recorded {agent} id and {n} {agent} sessions share "
f"{workspace}; the cache cannot tell them apart, so it starts a new conversation (Issue #63)")
return {"uuid": "", "drop_stale_id": False, "notes": [note], "error": None}
u = find_workspace_uuid(workspace, agent, name, state, home_resolved, claude_resolved,
exclude_ids=claimed)
if u and u in claimed:
others = ", ".join(claiming_rows(row_ref, u, state))
return {
"uuid": "",
"drop_stale_id": False,
"notes": [],
"error": (f"ERROR: {name or agent}'s resolved id {u} is also recorded by {others}; "
"refusing to resume on a conversation another session claims (Issue #63)."),
}
return {"uuid": u, "drop_stale_id": False, "notes": [], "error": None}
def main(argv: Optional[List[str]] = None) -> int:
p = argparse.ArgumentParser(prog="python -m lib_py.agents.session_ids")
sub = p.add_subparsers(dest="cmd", required=True)
s_stop = sub.add_parser("stop")
s_stop.add_argument("--session", required=True)
s_stop.add_argument("--agent", required=True)
s_stop.add_argument("--cwd", default="")
s_stop.add_argument("--herdr-alive", default="0")
s_stop.add_argument("--pane-pid", default="")
s_stop.add_argument("--purge", default="0")
s_res = sub.add_parser("resume")
s_res.add_argument("--session", default="")
s_res.add_argument("--agent", required=True)
s_res.add_argument("--workspace", required=True)
s_res.add_argument("--herdr-alive", default="0")
s_res.add_argument("--pane-pid", default="")
s_res.add_argument("--shell", action="store_true")
a = p.parse_args(argv)
state = load_state_json()
q = shlex.quote
if a.cmd == "stop":
res = decide_stop(
state=state,
name=a.session,
agent=a.agent,
cwd=a.cwd,
herdr_alive=(a.herdr_alive == "1"),
pane_pid=_parse_pid(a.pane_pid),
purge=(a.purge == "1"),
)
print(f"CAPTURED_UUID={q(res['captured'])}")
print(f"CAPTURE_STATUS={q(res['status'])}")
print(f"PURGE_UUID={q(res['purge_uuid'])}")
print(f"STOP_ID_NOTE={q(res['note'])}")
return 0
res = decide_resume(
state=state,
name=a.session,
agent=a.agent,
workspace=a.workspace,
herdr_alive=(a.herdr_alive == "1"),
pane_pid=_parse_pid(a.pane_pid),
)
for note in res.get("notes") or []:
print(note, file=sys.stderr)
if res.get("error"):
print(res["error"], file=sys.stderr)
return 1
if a.shell:
print(f"RESUME_UUID={q(res['uuid'])}")
print(f"DROP_STALE_ID={'1' if res.get('drop_stale_id') else '0'}")
else:
print(res["uuid"])
return 0
if __name__ == "__main__":
sys.exit(main())
+1 -1
View File
@@ -13,7 +13,7 @@ import sys
from typing import List, Sequence from typing import List, Sequence
# Framework version locked with framework constants # Framework version locked with framework constants
FRAMEWORK_VERSION = "4.9.9" FRAMEWORK_VERSION = "4.10.0"
CORE_MODULES: Sequence[str] = ( CORE_MODULES: Sequence[str] = (
# Standard library modules # Standard library modules
+34 -23
View File
@@ -1,7 +1,7 @@
# workspace_uuid.py — workspace UUID discovery logic # workspace_uuid.py — workspace UUID discovery logic
# Extracted from lib.sh find_workspace_uuid PYEOF block # Extracted from lib.sh find_workspace_uuid PYEOF block
import os, sys, json import os, json
from lib_py.verify_session import verify_session_uuid, mam_orchestrator_uuids, mam_row_own_uuid from lib_py.verify_session import verify_session_uuid, mam_orchestrator_uuids, mam_row_own_uuid
OWN_KEY = { OWN_KEY = {
@@ -15,17 +15,10 @@ OWN_KEY = {
from lib_py.paths import resolve_home from lib_py.paths import resolve_home
def find_workspace_uuid_main(): def find_workspace_uuid(ws, agent, target='', state=None, home=None, claude_dir=None, exclude_ids=()):
ws = os.environ['WS_ABS'] d = state if isinstance(state, dict) else {}
agent = os.environ['AGENT'] home = resolve_home(home)
home = resolve_home() claude_project_dir = claude_dir or os.environ.get('CLAUDE_PROJECT_DIR', f"{home}/.claude/projects")
claude_project_dir = os.environ.get('CLAUDE_PROJECT_DIR', f"{home}/.claude/projects")
target = os.environ.get('TARGET_SESSION', '')
try:
d = json.loads(os.environ.get('MAM_STATE_JSON', '{}'))
except Exception:
d = {}
running_ids = set() running_ids = set()
for s_item in d.get('herdr_sessions', []): for s_item in d.get('herdr_sessions', []):
@@ -38,6 +31,10 @@ def find_workspace_uuid_main():
running_ids.add(val) running_ids.add(val)
orchestrator_ids = set(mam_orchestrator_uuids()) orchestrator_ids = set(mam_orchestrator_uuids())
if "MAM_ORCHESTRATOR_UUIDS" not in os.environ:
raw_orc = d.get('orchestrator_uuids')
if isinstance(raw_orc, list) and all(isinstance(x, str) and x.strip() for x in raw_orc):
orchestrator_ids |= set(raw_orc)
if target: if target:
for s_item in d.get('herdr_sessions', []): for s_item in d.get('herdr_sessions', []):
if s_item.get('name') == target: if s_item.get('name') == target:
@@ -45,11 +42,7 @@ def find_workspace_uuid_main():
if own: if own:
orchestrator_ids.discard(own) orchestrator_ids.discard(own)
def emit(u): excluded = running_ids | orchestrator_ids | {u for u in (exclude_ids or ()) if u}
if u in running_ids or u in orchestrator_ids:
return
print(u)
sys.exit(0)
# Fetch all sessions matching this workspace # Fetch all sessions matching this workspace
sessions = [] sessions = []
@@ -63,15 +56,17 @@ def find_workspace_uuid_main():
if s.get('name') != target: if s.get('name') != target:
continue continue
cand = s.get(OWN_KEY.get(agent, ''), None) cand = s.get(OWN_KEY.get(agent, ''), None)
if cand and verify_session_uuid(ws, agent, cand, s, mode="revalidate"): if cand and verify_session_uuid(ws, agent, cand, s, home_dir=home, claude_dir=claude_project_dir, mode="revalidate"):
emit(cand) if cand not in excluded:
return cand
for s in sessions: for s in sessions:
name = s.get('name', '') name = s.get('name', '')
if name.endswith(f"-creator-{agent}"): if name.endswith(f"-creator-{agent}"):
cand = s.get(OWN_KEY.get(agent, '')) cand = s.get(OWN_KEY.get(agent, ''))
if cand and verify_session_uuid(ws, agent, cand, s, mode="revalidate"): if cand and verify_session_uuid(ws, agent, cand, s, home_dir=home, claude_dir=claude_project_dir, mode="revalidate"):
emit(cand) if cand not in excluded:
return cand
from lib_py.agents.registry import get_adapter from lib_py.agents.registry import get_adapter
from lib_py.agents.base import DiscoveryContext from lib_py.agents.base import DiscoveryContext
@@ -80,9 +75,25 @@ def find_workspace_uuid_main():
if adapter: if adapter:
ctx = DiscoveryContext(workspace=ws, agent_name=agent, home_dir=home, claude_dir=claude_project_dir) ctx = DiscoveryContext(workspace=ws, agent_name=agent, home_dir=home, claude_dir=claude_project_dir)
for cand in adapter.discover(ctx): for cand in adapter.discover(ctx):
emit(cand) if cand and cand not in excluded:
return cand
print('') return ''
def find_workspace_uuid_main():
ws = os.environ['WS_ABS']
agent = os.environ['AGENT']
home = resolve_home()
claude_project_dir = os.environ.get('CLAUDE_PROJECT_DIR', f"{home}/.claude/projects")
target = os.environ.get('TARGET_SESSION', '')
try:
d = json.loads(os.environ.get('MAM_STATE_JSON', '{}'))
except Exception:
d = {}
print(find_workspace_uuid(ws, agent, target, d, home, claude_project_dir))
if __name__ == '__main__': if __name__ == '__main__':
find_workspace_uuid_main() find_workspace_uuid_main()
@@ -1,7 +1,7 @@
--- ---
name: multi-agent-mux-create 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." 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.9.9 version: 4.10.0
author: godopu author: godopu
license: MIT license: MIT
platforms: [linux, macos] platforms: [linux, macos]
@@ -183,7 +183,7 @@ bash .agents/skills/multi-agent-mux-create/scripts/create_session.sh --workspace
| Exit | Meaning | | Exit | Meaning |
|---|---| |---|---|
| 0 | Created; or `--dry-run` | | 0 | Created; or `--dry-run` |
| 1 | Pre-flight failure, nothing was spawned: workspace not a directory, herdr not installed, agent CLI not in `PATH`, agent not authenticated. After spawn: the task brief could not be written or the instruction injection failed (the session is rolled back) | | 1 | Pre-flight failure, nothing was spawned: workspace not a directory, herdr not installed, agent CLI not in `PATH`, agent not authenticated, or a same-cwd session of the same Class A agent did not record its conversation ID within `MAM_ID_ORDER_WAIT_S` (default 600). After spawn: the task brief could not be written or the instruction injection failed (the session is rolled back) |
| 2 | Bad arguments; control-plane binding refused for a worktree; whitespace in a control-plane path that must be forwarded | | 2 | Bad arguments; control-plane binding refused for a worktree; whitespace in a control-plane path that must be forwarded |
| 3 | A herdr session of that name already exists (`multi-agent-mux-resume`, or `multi-agent-mux-stop` first) | | 3 | A herdr session of that name already exists (`multi-agent-mux-resume`, or `multi-agent-mux-stop` first) |
| 4 | The YAML already has a `running` row of that name. The YAML write raises it after spawn, and the herdr session is rolled back. A row the monitor registered for this very pane while it was being created is replaced instead | | 4 | The YAML already has a `running` row of that name. The YAML write raises it after spawn, and the herdr session is rolled back. A row the monitor registered for this very pane while it was being created is replaced instead |
@@ -283,10 +283,10 @@ if [ "$DRY_RUN" = "1" ]; then
echo "[dry-run] would spawn: herdr session '$SESSION_NAME' in $WORKSPACE (agent=$AGENT, herdr_session=${HERDR_SESSION_NAME:-default}, herdr_workspace=${MAM_WS_LABEL})" echo "[dry-run] would spawn: herdr session '$SESSION_NAME' in $WORKSPACE (agent=$AGENT, herdr_session=${HERDR_SESSION_NAME:-default}, herdr_workspace=${MAM_WS_LABEL})"
exit 0 exit 0
fi fi
# Issue #63: a same-cwd session of this post-discovered agent records its id first (lib.sh mam_id_order_wait).
mam_id_order_wait "$WORKSPACE" "$AGENT" "$SESSION_NAME" || exit 1
HERDR_EPOCH=$(date +%s) HERDR_EPOCH=$(date +%s)
NOW_ISO=$(date -u +'%Y-%m-%dT%H:%M:%SZ') NOW_ISO=$(date -u +'%Y-%m-%dT%H:%M:%SZ')
spawn spawn
# _mark_row_rolled_back <status> <note> # _mark_row_rolled_back <status> <note>
@@ -1,7 +1,7 @@
--- ---
name: multi-agent-mux-delegate-job 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." 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.9.9 version: 4.10.0
author: godopu author: godopu
license: MIT license: MIT
platforms: [linux, macos, windows] platforms: [linux, macos, windows]
+1 -1
View File
@@ -1,7 +1,7 @@
--- ---
name: multi-agent-mux-loop 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." 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.9.9 version: 4.10.0
author: godopu author: godopu
license: MIT license: MIT
platforms: [linux, macos] platforms: [linux, macos]
@@ -1,7 +1,7 @@
--- ---
name: multi-agent-mux-monitor 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." 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.9.9 version: 4.10.0
author: godopu author: godopu
license: MIT license: MIT
platforms: [linux, macos] platforms: [linux, macos]
@@ -1,7 +1,7 @@
--- ---
name: multi-agent-mux-orc-onboard 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." description: "Register current or specified orchestrator session UUID into agent-sessions.yaml orchestrator_uuids list to prevent sub-agent discovery capture."
version: 4.9.9 version: 4.10.0
author: godopu author: godopu
license: MIT license: MIT
platforms: [linux, macos] platforms: [linux, macos]
+11 -11
View File
@@ -1,7 +1,7 @@
--- ---
name: multi-agent-mux-resume 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." 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.9.9 version: 4.10.0
author: godopu author: godopu
license: MIT license: MIT
platforms: [linux, macos] platforms: [linux, macos]
@@ -36,10 +36,10 @@ its row is `status: stopped` with `resumable: true` and the conversation id
already recorded in `claude_session_id_own` / `agy_conversation_id_own`. This is the already recorded in `claude_session_id_own` / `agy_conversation_id_own`. This is the
ideal resume path: ideal resume path:
- **tier-1, race-free**: because the stop command wrote the id into the row at stop - **tier-1, race-free**: when the row has its own id recorded, `session_ids.py`
time, `resolve_session_id.sh` resolves it via `find_workspace_uuid` tier-1 (the resolves it directly from the per-row own id (after checking no other row
per-row own id) — no reliance on the mtime-based disk scan, so a concurrent claims it and revalidating on disk) — no reliance on the mtime-based disk scan,
session in another workspace can never shadow it. so a concurrent session in the same or another workspace can never shadow it.
- On resume, `update_yaml_resumed.sh` transitions `stopped → running` and **clears - On resume, `update_yaml_resumed.sh` transitions `stopped → running` and **clears
the stop metadata** (`stopped_at`, `stopped_at_epoch`, `stop_reason`, `resumable`) the stop metadata** (`stopped_at`, `stopped_at_epoch`, `stop_reason`, `resumable`)
along with the usual `terminated_at*` / `termination_mode` / `archived_at`, so the along with the usual `terminated_at*` / `termination_mode` / `archived_at`, so the
@@ -49,10 +49,10 @@ ideal resume path:
`agent-sessions.yaml` and on-disk discovery are used to resolve the UUID in this order: `agent-sessions.yaml` and on-disk discovery are used to resolve the UUID in this order:
1. **`herdr_sessions[]` row's per-row own id** (`claude_session_id_own` / `agy_conversation_id_own` / `hermes_conversation_id_own` / `grok_session_id_own` / `opencode_session_id_own` / `codex_session_id_own`) — explicitly saved by `multi-agent-mux-stop` right before teardown (tier-1, race-free). 1. **`herdr_sessions[]` row's per-row own id** (`claude_session_id_own` / `agy_conversation_id_own` / `hermes_conversation_id_own` / `grok_session_id_own` / `opencode_session_id_own` / `codex_session_id_own`) — verified against disk and refused (exit 1) if another row also records it (tier-1, race-free).
2. **Workspace-scoped on-disk scan** (adapter `discover()`) 2. **Without an own id**: Class A (`agy`, `hermes`, `opencode`, `codex`) uses `#40` pane capture (`autopin.capture`) when herdr is alive, starts a fresh conversation (`FRESH_SPAWN=1` + `NOTE:` on stderr) when herdr is dead and 2+ same-type rows share the cwd, or scans the workspace when it is the only row; Class B (`claude`, `grok`) runs the workspace scan (`find_workspace_uuid`, skipping ids claimed by other rows).
If both are empty → for Class A agents (`agy`, `hermes`, `opencode`, `codex`), `resume_session.sh` automatically falls back to `FRESH_SPAWN=1` (`spawn-spec`). For Class B agents (`claude`, `grok`), fall back to `multi-agent-mux-create`. If the resolved id is empty → for Class A agents (`agy`, `hermes`, `opencode`, `codex`), `resume_session.sh` uses `FRESH_SPAWN=1` (`spawn-spec`). For Class B agents (`claude`, `grok`), fall back to `multi-agent-mux-create`.
## Workflow ## Workflow
@@ -60,7 +60,7 @@ If both are empty → for Class A agents (`agy`, `hermes`, `opencode`, `codex`),
bash .agents/skills/multi-agent-mux-resume/scripts/resume_session.sh --workspace <path> --agent <agent> --session <name> bash .agents/skills/multi-agent-mux-resume/scripts/resume_session.sh --workspace <path> --agent <agent> --session <name>
``` ```
`resume_session.sh` does the whole flow: original-cwd check, control-plane binding, session-id resolution (`resolve_session_id.sh`), the herdr-alive check, the agent command (adapter `resume-spec`, or `spawn-spec` for a Class A fresh spawn), spawn (just before it, the session's own empty shell left by a server restart is closed: same session id, no agent, nothing running; `lib_py/herdr_restore.py`), start-up dialogs, and the YAML update (`update_yaml_resumed.sh`, which also publishes a `progress "resumed"` event when the row has a `delegate_job_id`). It never attaches; attaching is a separate step that names the server, because the server the script exports does not reach the calling shell and the native `herdr` binary does not route on environment variables: `herdr --session <server> agent attach <name>` (`<server>` = the `--herdr-session` you passed, or the row's `herdr_session`). `resume_session.sh` does the whole flow: original-cwd check, control-plane binding, the session-id decision (`lib_py/agents/session_ids.py`), the herdr-alive check, the agent command (adapter `resume-spec`, or `spawn-spec` for a Class A fresh spawn), spawn (just before it, the session's own empty shell left by a server restart is closed: same session id, no agent, nothing running; `lib_py/herdr_restore.py`), start-up dialogs, and the YAML update (`update_yaml_resumed.sh`, which also publishes a `progress "resumed"` event when the row has a `delegate_job_id`). It never attaches; attaching is a separate step that names the server, because the server the script exports does not reach the calling shell and the native `herdr` binary does not route on environment variables: `herdr --session <server> agent attach <name>` (`<server>` = the `--herdr-session` you passed, or the row's `herdr_session`).
| Option | Meaning | | Option | Meaning |
|---|---| |---|---|
@@ -75,11 +75,11 @@ bash .agents/skills/multi-agent-mux-resume/scripts/resume_session.sh --workspace
| Exit | Meaning | | Exit | Meaning |
|---|---| |---|---|
| 0 | Resumed; or already running, so only the row was updated (no attach); or `--dry-run` | | 0 | Resumed; or already running, so only the row was updated (no attach); or `--dry-run` |
| 1 | From the checks before spawn, nothing was changed: no saved session for claude/grok (use `multi-agent-mux-create` first), agent binary missing or not executable. **The YAML update after spawn (`update_yaml_resumed.sh`) also exits 1** (YAML not found) | | 1 | From the checks before spawn, nothing was changed: no saved session for claude/grok (use `multi-agent-mux-create` first), the row's id is also claimed by another row, a same-cwd Class A session did not record its id within `MAM_ID_ORDER_WAIT_S` (default 600), or agent binary missing/not executable. **The YAML update after spawn (`update_yaml_resumed.sh`) also exits 1** (YAML not found) |
| 2 | From the checks before spawn, nothing was changed: bad arguments, `--workspace` differs from the original cwd or it no longer exists, control-plane binding refused, whitespace in the control-plane path. **The YAML update after spawn also exits 2** (bad arguments) | | 2 | From the checks before spawn, nothing was changed: bad arguments, `--workspace` differs from the original cwd or it no longer exists, control-plane binding refused, whitespace in the control-plane path. **The YAML update after spawn also exits 2** (bad arguments) |
| other | A later step failed (herdr spawn, start-up dialogs, YAML update) and its code is passed through. resume does not roll back | | other | A later step failed (herdr spawn, start-up dialogs, YAML update) and its code is passed through. resume does not roll back |
A non-zero exit after the spawn can leave the herdr session running while the row is still `stopped`. Before retrying or creating a new session, check with `multi-agent-mux-status`. The `ERROR:` line on stderr tells the two apart: the pre-spawn checks' messages are in `resume_session.sh` (lines 50-223). A non-zero exit after the spawn can leave the herdr session running while the row is still `stopped`. Before retrying or creating a new session, check with `multi-agent-mux-status`. The `ERROR:` line on stderr tells the two apart: the pre-spawn checks' messages are in `resume_session.sh` (lines 50-243).
## Original cwd only (Issue #21 phase 2) ## Original cwd only (Issue #21 phase 2)
@@ -4,9 +4,9 @@
# bash resolve_session_id.sh --workspace <path> --agent <claude|agy|hermes|grok|opencode|codex> # bash resolve_session_id.sh --workspace <path> --agent <claude|agy|hermes|grok|opencode|codex>
# 출력: stdout 으로 UUID 한 줄 (없으면 빈 줄 + exit 0) # 출력: stdout 으로 UUID 한 줄 (없으면 빈 줄 + exit 0)
# #
# P0-C: 전역 agent_identities 를 즉시 반환하지 않는다. lib.sh::find_workspace_uuid # P0-C / Issue #63: 전역 agent_identities 를 즉시 반환하지 않는다. lib.sh::mam_session_ids
# 가 워크스페이스 격리된 해결 경로(per-row own id -> 디스크 스캔 -> cwd 일치하는 # 가 워크스페이스 격리된 해결 경로(per-row own id -> #40 active capture / 디스크 스캔)만 사용.
# cache)만 사용. 다른 워크스페이스의 UUID 를 절대 반환하지 않음. # 다른 워크스페이스나 다른 행이 기록한 UUID 를 절대 반환하지 않음.
set -euo pipefail set -euo pipefail
source "$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/lib.sh" source "$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/lib.sh"
@@ -22,12 +22,16 @@ EOF
WORKSPACE="" WORKSPACE=""
AGENT="" AGENT=""
SESSION_NAME="" SESSION_NAME=""
HERDR_ALIVE="0"
PANE_PID=""
while [ $# -gt 0 ]; do while [ $# -gt 0 ]; do
case "$1" in case "$1" in
--workspace) WORKSPACE="$2"; shift 2 ;; --workspace) WORKSPACE="$2"; shift 2 ;;
--agent) AGENT="$2"; shift 2 ;; --agent) AGENT="$2"; shift 2 ;;
--session) SESSION_NAME="$2"; shift 2 ;; --session) SESSION_NAME="$2"; shift 2 ;;
--herdr-alive) HERDR_ALIVE="$2"; shift 2 ;;
--pane-pid) PANE_PID="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;; -h|--help) usage; exit 0 ;;
*) echo "ERROR: unknown arg: $1" >&2; exit 2 ;; *) echo "ERROR: unknown arg: $1" >&2; exit 2 ;;
esac esac
@@ -40,4 +44,6 @@ case "$AGENT" in
*) echo "ERROR: --agent must be claude, agy, hermes, grok, opencode, or codex" >&2; exit 2 ;; *) echo "ERROR: --agent must be claude, agy, hermes, grok, opencode, or codex" >&2; exit 2 ;;
esac esac
find_workspace_uuid "$WORKSPACE" "$AGENT" "$SESSION_NAME" mam_session_ids resume --session "$SESSION_NAME" --agent "$AGENT" \
--workspace "$(mam_abs_workspace "$WORKSPACE")" \
--herdr-alive "$HERDR_ALIVE" --pane-pid "$PANE_PID"
@@ -107,9 +107,33 @@ if [ "$cp_rc" -ne 0 ] || [ -z "$CP_JSON" ]; then
CP_JSON="" CP_JSON=""
fi fi
# 1. Resolve the session id if [ -n "$HERDR_SERVER_OPT" ]; then
UUID=$(bash "$(dirname "${BASH_SOURCE[0]}")/resolve_session_id.sh" \ export HERDR_SESSION_NAME="$HERDR_SERVER_OPT"
--workspace "$WORKSPACE" --agent "$AGENT" --session "$SESSION_NAME") else
HERDR_SESSION_NAME="$(resolve_herdr_session "$SESSION_NAME" "$WORKSPACE")"
export HERDR_SESSION_NAME
fi
if [ -n "$HERDR_WORKSPACE_OPT" ]; then
export MAM_WS_LABEL="$HERDR_WORKSPACE_OPT"
else
export MAM_WS_LABEL="$(resolve_herdr_workspace "$SESSION_NAME" "$WORKSPACE")"
fi
HERDR_ALIVE=0
PANE_PID_NOW=""
if herdr has-session -t "$SESSION_NAME" 2>/dev/null; then
HERDR_ALIVE=1
PANE_PID_NOW=$(herdr list-panes -t "$SESSION_NAME" -F '#{pane_pid}' 2>/dev/null | head -1 || true)
fi
# 1. Resolve the session id (Issue #63: own id first, then #40 capture when
# herdr is alive, or fresh spawn when 2+ same-type rows share the cwd)
RESUME_OUT=$(mam_session_ids resume --shell \
--workspace "$(mam_abs_workspace "$WORKSPACE")" --agent "$AGENT" --session "$SESSION_NAME" \
--herdr-alive "$HERDR_ALIVE" --pane-pid "$PANE_PID_NOW")
eval "$RESUME_OUT"
UUID="$RESUME_UUID"
# ISSUE-3: agy/hermes/opencode (Class A) are spawned without a UUID - their # ISSUE-3: agy/hermes/opencode (Class A) are spawned without a UUID - their
# own conversation id is only assigned by the agent itself on its first turn. # own conversation id is only assigned by the agent itself on its first turn.
@@ -130,21 +154,8 @@ if [ -z "$UUID" ]; then
esac esac
fi fi
if [ -n "$HERDR_SERVER_OPT" ]; then
export HERDR_SESSION_NAME="$HERDR_SERVER_OPT"
else
HERDR_SESSION_NAME="$(resolve_herdr_session "$SESSION_NAME" "$WORKSPACE")"
export HERDR_SESSION_NAME
fi
if [ -n "$HERDR_WORKSPACE_OPT" ]; then
export MAM_WS_LABEL="$HERDR_WORKSPACE_OPT"
else
export MAM_WS_LABEL="$(resolve_herdr_workspace "$SESSION_NAME" "$WORKSPACE")"
fi
# 2. If herdr is alive, print warning or attach. # 2. If herdr is alive, print warning or attach.
if herdr has-session -t "$SESSION_NAME" 2>/dev/null; then if [ "$HERDR_ALIVE" = "1" ]; then
if [ "${DRY_RUN:-0}" = "1" ]; then if [ "${DRY_RUN:-0}" = "1" ]; then
echo "[dry-run] herdr '$SESSION_NAME' already running — nothing to validate" echo "[dry-run] herdr '$SESSION_NAME' already running — nothing to validate"
exit 0 exit 0
@@ -153,7 +164,7 @@ if herdr has-session -t "$SESSION_NAME" 2>/dev/null; then
# Just update YAML to make sure it's set to running # Just update YAML to make sure it's set to running
CONTROL_PLANE_JSON="$CP_JSON" bash "$(dirname "${BASH_SOURCE[0]}")/update_yaml_resumed.sh" \ CONTROL_PLANE_JSON="$CP_JSON" bash "$(dirname "${BASH_SOURCE[0]}")/update_yaml_resumed.sh" \
--session "$SESSION_NAME" --uuid "$UUID" --agent "$AGENT" --workspace "$WORKSPACE" \ --session "$SESSION_NAME" --uuid "$UUID" --agent "$AGENT" --workspace "$WORKSPACE" \
--herdr-session "$HERDR_SESSION_NAME" \ --herdr-session "$HERDR_SESSION_NAME" --already-running \
${HERDR_WORKSPACE_OPT:+--herdr-workspace "$HERDR_WORKSPACE_OPT"} ${HERDR_WORKSPACE_OPT:+--herdr-workspace "$HERDR_WORKSPACE_OPT"}
exit 0 exit 0
fi fi
@@ -227,6 +238,10 @@ if [ "${DRY_RUN:-0}" = "1" ]; then
exit 0 exit 0
fi fi
# Issue #63: wait if another same-cwd session of this post-discovered agent
# is still being created or has not recorded its id yet (lib.sh mam_id_order_wait).
mam_id_order_wait "$WORKSPACE" "$AGENT" "$SESSION_NAME" "$UUID" || exit 1
if [ "$AGENT" = "opencode" ]; then if [ "$AGENT" = "opencode" ]; then
if [ -z "${OPENCODE_PERMISSION:-}" ]; then if [ -z "${OPENCODE_PERMISSION:-}" ]; then
export OPENCODE_PERMISSION='{"*":"allow"}' export OPENCODE_PERMISSION='{"*":"allow"}'
@@ -252,9 +267,12 @@ handle_startup_dialogs "$SESSION_NAME" 20 "$AGENT"
sleep 2 sleep 2
# 5. Update agent-sessions.yaml: status running, last_visible_status # 5. Update agent-sessions.yaml: status running, last_visible_status
DROP_FLAG=""
[ "${DROP_STALE_ID:-0}" = "1" ] && DROP_FLAG="--drop-stale-id"
CONTROL_PLANE_JSON="$CP_JSON" bash "$(dirname "${BASH_SOURCE[0]}")/update_yaml_resumed.sh" \ CONTROL_PLANE_JSON="$CP_JSON" bash "$(dirname "${BASH_SOURCE[0]}")/update_yaml_resumed.sh" \
--session "$SESSION_NAME" --uuid "$UUID" --agent "$AGENT" --workspace "$WORKSPACE" \ --session "$SESSION_NAME" --uuid "$UUID" --agent "$AGENT" --workspace "$WORKSPACE" \
--herdr-session "$HERDR_SESSION_NAME" --cmd-full "$CMD_FULL" \ --herdr-session "$HERDR_SESSION_NAME" --cmd-full "$CMD_FULL" \
${DROP_FLAG:+$DROP_FLAG} \
${HERDR_WORKSPACE_OPT:+--herdr-workspace "$HERDR_WORKSPACE_OPT"} ${HERDR_WORKSPACE_OPT:+--herdr-workspace "$HERDR_WORKSPACE_OPT"}
echo "Successfully resumed $SESSION_NAME ($AGENT)" echo "Successfully resumed $SESSION_NAME ($AGENT)"
@@ -27,6 +27,8 @@ ROLE=""
HERDR_SERVER_OPT="" HERDR_SERVER_OPT=""
HERDR_WORKSPACE_OPT="" HERDR_WORKSPACE_OPT=""
CMD_FULL_OPT="" CMD_FULL_OPT=""
ALREADY_RUNNING=0
DROP_STALE_ID=0
while [ $# -gt 0 ]; do while [ $# -gt 0 ]; do
case "$1" in case "$1" in
@@ -38,6 +40,8 @@ while [ $# -gt 0 ]; do
--herdr-session|--herdr-server) HERDR_SERVER_OPT="$2"; shift 2 ;; --herdr-session|--herdr-server) HERDR_SERVER_OPT="$2"; shift 2 ;;
--herdr-workspace) HERDR_WORKSPACE_OPT="$2"; shift 2 ;; --herdr-workspace) HERDR_WORKSPACE_OPT="$2"; shift 2 ;;
--cmd-full) CMD_FULL_OPT="$2"; shift 2 ;; --cmd-full) CMD_FULL_OPT="$2"; shift 2 ;;
--already-running) ALREADY_RUNNING=1; shift ;;
--drop-stale-id) DROP_STALE_ID=1; shift ;;
-h|--help) usage; exit 0 ;; -h|--help) usage; exit 0 ;;
*) echo "ERROR: unknown arg: $1" >&2; exit 2 ;; *) echo "ERROR: unknown arg: $1" >&2; exit 2 ;;
esac esac
@@ -110,6 +114,7 @@ atomic_dump_yaml "$AGENT_SESSIONS_YAML" \
NOW_EPOCH="$NOW_EPOCH" TARGET_WORKSPACE="${WORKSPACE:-$WORKSPACE_ROOT}" ROLE="$ROLE" \ NOW_EPOCH="$NOW_EPOCH" TARGET_WORKSPACE="${WORKSPACE:-$WORKSPACE_ROOT}" ROLE="$ROLE" \
PANE_PID="$PANE_PID" CHILD_PID="$CHILD_PID" HERDR_SERVER_OPT_EXPLICIT="${HERDR_SERVER_OPT_EXPLICIT:-0}" \ PANE_PID="$PANE_PID" CHILD_PID="$CHILD_PID" HERDR_SERVER_OPT_EXPLICIT="${HERDR_SERVER_OPT_EXPLICIT:-0}" \
MAM_WS_LABEL="$MAM_WS_LABEL" MAM_WS_LABEL_EXPLICIT="${MAM_WS_LABEL_EXPLICIT:-0}" \ MAM_WS_LABEL="$MAM_WS_LABEL" MAM_WS_LABEL_EXPLICIT="${MAM_WS_LABEL_EXPLICIT:-0}" \
ALREADY_RUNNING="$ALREADY_RUNNING" DROP_STALE_ID="$DROP_STALE_ID" \
CMD_FULL_OPT="$CMD_FULL_OPT" <<'PYEOF' CMD_FULL_OPT="$CMD_FULL_OPT" <<'PYEOF'
name = os.environ['SESSION_NAME'] name = os.environ['SESSION_NAME']
uuid = os.environ['UUID'] uuid = os.environ['UUID']
@@ -173,7 +178,17 @@ if _cp_raw and not isinstance(target.get('control_plane'), dict):
target['control_plane'] = {k: _cp[k] for k in ('workspace_root', 'agent_sessions_yaml', 'mam_jobs_dir', 'delegate_job_logs_dir')} target['control_plane'] = {k: _cp[k] for k in ('workspace_root', 'agent_sessions_yaml', 'mam_jobs_dir', 'delegate_job_logs_dir')}
except Exception: except Exception:
pass pass
if not uuid: stale_dropped = ''
if not uuid and os.environ.get('DROP_STALE_ID') == '1':
from lib_py.agents.autopin import claimed_elsewhere
from lib_py.agents.registry import get_adapter
_adapter = get_adapter(agent)
_own_key = _adapter.own_key if _adapter else ''
_old = (target.get(_own_key) or '').strip() if _own_key else ''
if _old and _old not in set(claimed_elsewhere(target, d)):
target[_own_key] = None
stale_dropped = _old
if not uuid and os.environ.get('ALREADY_RUNNING') != '1':
# ISSUE-3 (challenge c4b0a075): a Class A fresh-spawn resume re-arms # ISSUE-3 (challenge c4b0a075): a Class A fresh-spawn resume re-arms
# discovery from scratch - the resumed agent has not spoken yet, so any # discovery from scratch - the resumed agent has not spoken yet, so any
# on-disk conversation older than THIS resume must not be auto-pinned to # on-disk conversation older than THIS resume must not be auto-pinned to
@@ -196,6 +211,10 @@ target.pop('stop_reason', None)
target.pop('resumable', None) target.pop('resumable', None)
if uuid: if uuid:
target['last_visible_status'] = f'resumed conversation {uuid} at {now}' target['last_visible_status'] = f'resumed conversation {uuid} at {now}'
elif os.environ.get('ALREADY_RUNNING') == '1':
target['last_visible_status'] = f'already running; no id recorded at {now}'
elif stale_dropped:
target['last_visible_status'] = f'resumed (fresh spawn, recorded id {stale_dropped} no longer verified) at {now}'
else: else:
# ISSUE-3 Class A fresh-spawn resume: no conversation id existed yet. # ISSUE-3 Class A fresh-spawn resume: no conversation id existed yet.
target['last_visible_status'] = f'resumed (fresh spawn, no prior turn) at {now}' target['last_visible_status'] = f'resumed (fresh spawn, no prior turn) at {now}'
@@ -1,7 +1,7 @@
--- ---
name: multi-agent-mux-status 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." 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.9.9 version: 4.10.0
author: godopu author: godopu
license: MIT license: MIT
platforms: [linux, macos] platforms: [linux, macos]
+3 -3
View File
@@ -1,7 +1,7 @@
--- ---
name: multi-agent-mux-stop 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." 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.9.9 version: 4.10.0
author: godopu author: godopu
license: MIT license: MIT
platforms: [linux, macos] platforms: [linux, macos]
@@ -31,7 +31,7 @@ The stop command is always **graceful by default**:
1. Sends exit keys to the agent TUI (`/exit` for Claude, `/exit` for Agy) and waits 3 seconds. 1. Sends exit keys to the agent TUI (`/exit` for Claude, `/exit` for Agy) and waits 3 seconds.
2. If still alive, issues `herdr kill-session` (SIGTERM) and waits 5 seconds. 2. If still alive, issues `herdr kill-session` (SIGTERM) and waits 5 seconds.
3. If still alive, kills the pane PID via SIGKILL (`kill -9`) as a last resort. 3. If still alive, kills the pane PID via SIGKILL (`kill -9`) as a last resort.
4. Auto-captures the conversation ID into the row (`claude_session_id_own`/`agy_conversation_id_own`) before killing, ensuring the next resume uses a race-free tier-1 lookup. 4. Auto-captures the conversation ID into the row (`claude_session_id_own`/`agy_conversation_id_own`) before killing (for Class A agents, only an ID verified as this pane's via `#40` capture is recorded, and nothing is recorded if the herdr session was already dead), ensuring the next resume uses a race-free tier-1 lookup.
## Pre-flight ## Pre-flight
@@ -93,7 +93,7 @@ The script:
2. If `delegate_job_id` is set, automatically publishes a `progress --detail "terminating"` event to the multi-agent-mux-delegate-job registry 2. If `delegate_job_id` is set, automatically publishes a `progress --detail "terminating"` event to the multi-agent-mux-delegate-job registry
3. Captures the `last_visible_status` from `herdr capture-pane` (so we have a final TUI snapshot for audit) 3. Captures the `last_visible_status` from `herdr capture-pane` (so we have a final TUI snapshot for audit)
4. Attempts graceful exit keys → SIGTERM kill-session → SIGKILL fallback 4. Attempts graceful exit keys → SIGTERM kill-session → SIGKILL fallback
5. For `purge-conversation`: deletes, for claude, every path the session owns — `projects/<key>/<uuid>.jsonl` and `<uuid>/`, `file-history/<uuid>/`, `session-env/<uuid>/`, `jobs/<uuid[:8]>/` (when its `state.json` names the session), stale `sessions/<pid>.json` (+ that pid's `.key`) and `<tmp>/claude-<uid>/*/<uuid>/`, then prints anything still carrying the UUID as `WARN residual:`; shared files such as `history.jsonl` are not rewritten (Issue #31). Or, for agy, `~/.gemini/antigravity-cli/conversations/<uuid>.db` with its `-wal`/`-shm`/`-journal` sidecars, `brain/<uuid>/`, `annotations/<uuid>.pbtxt` and `presence/<uuid>.lock` (the lock only once no live process references the conversation — Issue #10) 5. For `purge-conversation` (purging only the row's own ID or an ID verified for this pane, and never an ID another row records — Issue #63): deletes, for claude, every path the session owns — `projects/<key>/<uuid>.jsonl` and `<uuid>/`, `file-history/<uuid>/`, `session-env/<uuid>/`, `jobs/<uuid[:8]>/` (when its `state.json` names the session), stale `sessions/<pid>.json` (+ that pid's `.key`) and `<tmp>/claude-<uid>/*/<uuid>/`, then prints anything still carrying the UUID as `WARN residual:`; shared files such as `history.jsonl` are not rewritten (Issue #31). Or, for agy, `~/.gemini/antigravity-cli/conversations/<uuid>.db` with its `-wal`/`-shm`/`-journal` sidecars, `brain/<uuid>/`, `annotations/<uuid>.pbtxt` and `presence/<uuid>.lock` (the lock only once no live process references the conversation — Issue #10)
6. Updates the YAML entry and SQLite database atomically 6. Updates the YAML entry and SQLite database atomically
7. If `delegate_job_id` is set, publishes a `completed` event to the multi-agent-mux-delegate-job registry 7. If `delegate_job_id` is set, publishes a `completed` event to the multi-agent-mux-delegate-job registry
@@ -5,8 +5,8 @@
# [--reason <reason>] [--purge-conversation] [--yes] # [--reason <reason>] [--purge-conversation] [--yes]
# #
# 동작: 항상 graceful stop 입니다. send-keys 로 정상 종료를 유도하고 # 동작: 항상 graceful stop 입니다. send-keys 로 정상 종료를 유도하고
# (미종료 시 SIGTERM → SIGKILL 폴백), kill 직전에 이 워크스페이스의 # (미종료 시 SIGTERM → SIGKILL 폴백), kill 직전에 이 세션(pane)의 검증된
# conversation id 를 row 에 확정 기록해 다음 resume 이 tier-1(race-free) # conversation id 만 row 에 확정 기록해 다음 resume 이 tier-1(race-free)
# 으로 복원되게 합니다. status 는 running -> stopped 로 전이합니다. # 으로 복원되게 합니다. status 는 running -> stopped 로 전이합니다.
# 멱등: 이미 stopped 면 no-op + exit 0. # 멱등: 이미 stopped 면 no-op + exit 0.
# #
@@ -67,7 +67,6 @@ HERDR_SERVER_OPT=""
HERDR_WORKSPACE_OPT="" HERDR_WORKSPACE_OPT=""
PURGE=0 PURGE=0
YES=0 YES=0
CAPTURE_ID=1
GRACEFUL=1 GRACEFUL=1
REASON="manual_stop" REASON="manual_stop"
STOP_MODE=1 STOP_MODE=1
@@ -198,12 +197,6 @@ if [ "$PURGE" = "1" ] && [ "$YES" != "1" ]; then
exit 3 exit 3
fi fi
# purge 대상 UUID 를 워크스페이스 격리해서 해결 (P0-C — 전역 참조 금지)
PURGE_UUID=""
if [ "$PURGE" = "1" ] && [ -n "$TARGET_CWD" ]; then
PURGE_UUID=$(find_workspace_uuid "$TARGET_CWD" "$AGENT" "$SESSION_NAME" || true)
fi
NOW_ISO=$(date -u +'%Y-%m-%dT%H:%M:%SZ') NOW_ISO=$(date -u +'%Y-%m-%dT%H:%M:%SZ')
NOW_EPOCH=$(date +%s) NOW_EPOCH=$(date +%s)
@@ -215,17 +208,19 @@ if herdr has-session -t "$SESSION_NAME" 2>/dev/null; then
LAST_STATUS=$(herdr capture-pane -t "$SESSION_NAME" -p -S -10 2>/dev/null | tr '\n' ' ' | head -c 500 || true) LAST_STATUS=$(herdr capture-pane -t "$SESSION_NAME" -p -S -10 2>/dev/null | tr '\n' ' ' | head -c 500 || true)
fi fi
# 캡처: kill 직전에 conversation id 를 해결 (process/jsonl 이 아직 살아있을 때). # Issue #63: before the kill, decide the id this stop records and a purge
# find_workspace_uuid 가 tier-1(row) -> tier-2(workspace-scoped disk scan) # deletes (lib_py.agents.session_ids): the row's own id; else, for an agent
# 를 알아서 시도하므로 herdr 생사와 무관하게 동작. # that writes its id after its first turn, only what the #40 capture proves is
CAPTURED_UUID="" # this pane's (nothing once herdr is dead - the reason goes on the row); claude
if [ "$CAPTURE_ID" = "1" ] && [ -n "$TARGET_CWD" ]; then # and grok keep find_workspace_uuid. An id another row records is never used.
CAPTURED_UUID=$(capture_conversation_id "$AGENT" "$TARGET_CWD" "$SESSION_NAME" || true) CAPTURED_UUID="" CAPTURE_STATUS="" PURGE_UUID="" STOP_ID_NOTE=""
if [ -n "$CAPTURED_UUID" ]; then if [ -n "$TARGET_CWD" ]; then
echo "captured conversation id: $CAPTURED_UUID" PANE_PID_NOW=""
else [ "$HERDR_ALIVE" = "1" ] && PANE_PID_NOW=$(herdr list-panes -t "$SESSION_NAME" -F '#{pane_pid}' 2>/dev/null | head -1 || true)
echo "WARN: no conversation id resolved before stop (nothing on disk yet)" eval "$(mam_session_ids stop --session "$SESSION_NAME" --agent "$AGENT" --cwd "$TARGET_CWD" \
fi --herdr-alive "$HERDR_ALIVE" --pane-pid "$PANE_PID_NOW" --purge "$PURGE")" || true
if [ -n "$CAPTURED_UUID" ]; then echo "captured conversation id: $CAPTURED_UUID"
else echo "WARN: no conversation id recorded before stop${STOP_ID_NOTE:+ - $STOP_ID_NOTE}"; fi
fi fi
delegate_publish_event "$DELEGATE_JOB_ID" progress "terminating" "$DELEGATE_JOB_TOKEN" "$DELEGATE_JOB_ATTEMPT" delegate_publish_event "$DELEGATE_JOB_ID" progress "terminating" "$DELEGATE_JOB_TOKEN" "$DELEGATE_JOB_ATTEMPT"
@@ -304,7 +299,7 @@ atomic_dump_yaml "$AGENT_SESSIONS_YAML" \
SESSION_NAME="$SESSION_NAME" AGENT="$AGENT" PURGE="$PURGE" \ SESSION_NAME="$SESSION_NAME" AGENT="$AGENT" PURGE="$PURGE" \
NOW_ISO="$NOW_ISO" NOW_EPOCH="$NOW_EPOCH" LAST_STATUS="$LAST_STATUS" \ NOW_ISO="$NOW_ISO" NOW_EPOCH="$NOW_EPOCH" LAST_STATUS="$LAST_STATUS" \
PURGE_UUID="$PURGE_UUID" TARGET_CWD="$TARGET_CWD" \ PURGE_UUID="$PURGE_UUID" TARGET_CWD="$TARGET_CWD" \
REASON="$REASON" CAPTURED_UUID="$CAPTURED_UUID" <<'PYEOF' REASON="$REASON" CAPTURED_UUID="$CAPTURED_UUID" CAPTURE_STATUS="$CAPTURE_STATUS" STOP_ID_NOTE="$STOP_ID_NOTE" <<'PYEOF'
import shutil import shutil
name = os.environ['SESSION_NAME'] name = os.environ['SESSION_NAME']
agent = os.environ['AGENT'] agent = os.environ['AGENT']
@@ -326,6 +321,19 @@ if target is None:
print(f"ERROR: disappeared during script: {name}", flush=True) print(f"ERROR: disappeared during script: {name}", flush=True)
raise SystemExit(1) raise SystemExit(1)
from lib_py.agents.autopin import claimed_elsewhere, pin_row
capture_status = os.environ.get('CAPTURE_STATUS', '').strip()
note = os.environ.get('STOP_ID_NOTE', '').strip()
claimed = set(claimed_elsewhere(target, d))
if captured and captured in claimed:
print(f"WARN: refusing to record {captured} on {name}; another row already claims it", flush=True)
if not note:
note = f"{captured} is already claimed by another row"
captured = ''
if purge_uuid and purge_uuid in claimed:
print(f"WARN: refusing to purge {purge_uuid} for {name}; another row claims it", flush=True)
purge_uuid = ''
if not purge: if not purge:
target['status'] = 'stopped' target['status'] = 'stopped'
target['stopped_at'] = now target['stopped_at'] = now
@@ -350,7 +358,11 @@ if captured and not purge:
target['opencode_session_id_own'] = captured target['opencode_session_id_own'] = captured
elif agent == 'codex': elif agent == 'codex':
target['codex_session_id_own'] = captured target['codex_session_id_own'] = captured
if capture_status == 'captured':
pin_row(target, agent, captured)
target['resumable'] = True target['resumable'] = True
elif not purge and note:
target['last_visible_status'] = f"stop: {note}"
if purge and purge_uuid: if purge and purge_uuid:
from lib_py.agents.registry import get_adapter from lib_py.agents.registry import get_adapter
+1 -1
View File
@@ -2,7 +2,7 @@
본 문서는 `herdr_agent_orchestration` 오케스트레이션 및 메시징 백플레인 워크플로우를 새로운 프로젝트에 도입하여 이식하고, 새로운 개발자/에이전트가 초기 가동을 시작할 때 수행해야 하는 환경 설정 및 구축 절차를 안내합니다. 본 문서는 `herdr_agent_orchestration` 오케스트레이션 및 메시징 백플레인 워크플로우를 새로운 프로젝트에 도입하여 이식하고, 새로운 개발자/에이전트가 초기 가동을 시작할 때 수행해야 하는 환경 설정 및 구축 절차를 안내합니다.
[![Version: v4.9.9](https://img.shields.io/badge/version-v4.9.9-blue.svg)](VERSIONS.md) [![Version: v4.10.0](https://img.shields.io/badge/version-v4.10.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. 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.9.9](https://img.shields.io/badge/version-v4.9.9-blue.svg)](VERSIONS.md) [![Version: v4.10.0](https://img.shields.io/badge/version-v4.10.0-blue.svg)](VERSIONS.md)
A new agent can follow the steps in this guide sequentially to establish a stable and reliable initial environment. 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 # Messaging System Technical Analysis & Architecture Report
Applies to: MAM v4.9.9 Applies to: MAM v4.10.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. 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.
+1 -1
View File
@@ -2,7 +2,7 @@
[herdr](https://herdr.dev)와 MQTT 위에 만든 **다중 에이전트 오케스트레이션·메시징 백플레인**입니다. 장시간 실행되는 코딩 에이전트(`claude`, `agy`, `hermes`, `grok`, `opencode`, `codex`)를 격리된 herdr pane 에서 돌리고, 작업을 추적되는 잡으로 위임하며, 모든 리뷰어가 통과시킬 때까지 planner / creator / reviewer 루프를 돌립니다. [herdr](https://herdr.dev)와 MQTT 위에 만든 **다중 에이전트 오케스트레이션·메시징 백플레인**입니다. 장시간 실행되는 코딩 에이전트(`claude`, `agy`, `hermes`, `grok`, `opencode`, `codex`)를 격리된 herdr pane 에서 돌리고, 작업을 추적되는 잡으로 위임하며, 모든 리뷰어가 통과시킬 때까지 planner / creator / reviewer 루프를 돌립니다.
[![Version: v4.9.9](https://img.shields.io/badge/version-v4.9.9-blue.svg)](VERSIONS.md) [![Version: v4.10.0](https://img.shields.io/badge/version-v4.10.0-blue.svg)](VERSIONS.md)
--- ---
+1 -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. 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.9.9](https://img.shields.io/badge/version-v4.9.9-blue.svg)](VERSIONS.md) [![Version: v4.10.0](https://img.shields.io/badge/version-v4.10.0-blue.svg)](VERSIONS.md)
--- ---
+43 -11
View File
@@ -6,8 +6,8 @@
## 📌 현재 버전 개요 (Current Release) ## 📌 현재 버전 개요 (Current Release)
- **프레임워크 버전**: `v4.9.9` - **프레임워크 버전**: `v4.10.0`
- **최신 릴리스 일시**: 2026-10-21 (KST) - **최신 릴리스 일시**: 2026-10-23 (KST)
- **기준 브랜치**: `main` - **기준 브랜치**: `main`
- **핵심 아키텍처**: - **핵심 아키텍처**:
- **Terminal 상태 단방향 불변성 (`engine.py`, Issue #6 Phase 1)**: `completed`, `error`, `cancelled` 터미널 상태 진입 후 후속 이벤트나 업데이트에 의한 덮어쓰기를 엄격히 차단(Terminal Immutability). `registry.py close` 서브커맨드 및 fail-closed 조기 종료 도입. - **Terminal 상태 단방향 불변성 (`engine.py`, Issue #6 Phase 1)**: `completed`, `error`, `cancelled` 터미널 상태 진입 후 후속 이벤트나 업데이트에 의한 덮어쓰기를 엄격히 차단(Terminal Immutability). `registry.py close` 서브커맨드 및 fail-closed 조기 종료 도입.
@@ -24,23 +24,55 @@
## 🧭 스킬 패키지 버전 매트릭스 (Skills Version Matrix) ## 🧭 스킬 패키지 버전 매트릭스 (Skills Version Matrix)
모든 8개 스킬은 YAML frontmatter 메타데이터(`author`, `version`, `platforms`, `environments`) 표준화를 통해 `v4.9.9`로 동기화되어 배포됩니다. 모든 8개 스킬은 YAML frontmatter 메타데이터(`author`, `version`, `platforms`, `environments`) 표준화를 통해 `v4.10.0`로 동기화되어 배포됩니다.
| 스킬명 | 버전 | 역할 및 주요 책임 | 상태 | | 스킬명 | 버전 | 역할 및 주요 책임 | 상태 |
| :--- | :---: | :--- | :---: | | :--- | :---: | :--- | :---: |
| **`multi-agent-mux-create`** | `4.9.9` | 에이전트 세션 신규 생성 및 Herdr 컨테이너 격리 스폰 | ✅ 배포 | | **`multi-agent-mux-create`** | `4.10.0` | 에이전트 세션 신규 생성 및 Herdr 컨테이너 격리 스폰 | ✅ 배포 |
| **`multi-agent-mux-stop`** | `4.9.9` | 대화 UUID 원자적 캡처 및 세션 안전 종료 (Graceful Stop) | ✅ 배포 | | **`multi-agent-mux-stop`** | `4.10.0` | 대화 UUID 원자적 캡처 및 세션 안전 종료 (Graceful Stop) | ✅ 배포 |
| **`multi-agent-mux-resume`** | `4.9.9` | 온디스크 대화 컨텍스트 기반 Tier-1 초고속 세션 복원 | ✅ 배포 | | **`multi-agent-mux-resume`** | `4.10.0` | 온디스크 대화 컨텍스트 기반 Tier-1 초고속 세션 복원 | ✅ 배포 |
| **`multi-agent-mux-status`** | `4.9.9` | 실시간 Herdr 세션 및 레지스트리 드리프트 스냅샷 조회 | ✅ 배포 | | **`multi-agent-mux-status`** | `4.10.0` | 실시간 Herdr 세션 및 레지스트리 드리프트 스냅샷 조회 | ✅ 배포 |
| **`multi-agent-mux-monitor`** | `4.9.9` | YAML ↔ 런타임 상태 간 자율 조정자 (Reconciler Loop) | ✅ 배포 | | **`multi-agent-mux-monitor`** | `4.10.0` | YAML ↔ 런타임 상태 간 자율 조정자 (Reconciler Loop) | ✅ 배포 |
| **`multi-agent-mux-delegate-job`** | `4.9.9` | MQTT 이벤트 채널 기반 비동기 단위 작업 위임 | ✅ 배포 | | **`multi-agent-mux-delegate-job`** | `4.10.0` | MQTT 이벤트 채널 기반 비동기 단위 작업 위임 | ✅ 배포 |
| **`multi-agent-mux-loop`** | `4.9.9` | Planner-Creator-Reviewer 3자 자율 계획·실행·피어리뷰 루프 | ✅ 배포 | | **`multi-agent-mux-loop`** | `4.10.0` | Planner-Creator-Reviewer 3자 자율 계획·실행·피어리뷰 루프 | ✅ 배포 |
| **`multi-agent-mux-orc-onboard`** | `4.9.9` | 오케스트레이터 UUID 격리 등록 및 서브 세션 오염 방지 | ✅ 배포 | | **`multi-agent-mux-orc-onboard`** | `4.10.0` | 오케스트레이터 UUID 격리 등록 및 서브 세션 오염 방지 | ✅ 배포 |
--- ---
## 📋 버전별 상세 변경 내역 (Changelog) ## 📋 버전별 상세 변경 내역 (Changelog)
### 🚀 `v4.10.0` — 같은 폴더의 같은 종류 에이전트가 서로의 대화 ID 를 쓰지 않는다 (Issue #63) (2026-10-23)
> **마이너 (MINOR Release)**: 같은 폴더에 ID 가 나중에 생기는 같은 종류 에이전트(agy·codex·hermes·opencode)가 둘 있으면, stop·purge·resume 이 `find_workspace_uuid` 의 추측으로 다른 행의 대화 ID 를 기록하거나 지우거나 이어받을 수 있었다. 이제 세 경로가 #40 의 판정을 같이 쓰고, 같은 폴더의 생성 순서를 정하는 새 대기 동작(`MAM_ID_ORDER_WAIT_S`)과 resume 의 새 거부(exit 1)가 추가되어 MINOR 로 올린다. PR #69.
#### 🌟 주요 개선 사항
1. **ID 판정 (`lib_py/agents/session_ids.py`, 새 파일)**: stop·purge·resume 이 같은 판정을 쓴다. 행의 own id 가 우선이고, 없으면 #40 의 `autopin.capture` 를 쓴다. 다른 행이 기록한 ID 는 쓰지도 지우지도 않는다. claude·grok 은 그대로다.
2. **stop·purge**:
- stop 은 프로세스를 끄기 전에 ID 를 정한다. herdr 가 이미 죽은 행은 확인할 수 없으므로 ID 를 기록하지 않고 이유를 남긴다.
- purge 는 own id 이거나 확인을 통과한 ID 만 지우고, 레지스트리를 쓰는 시점에 다른 행이 기록했는지 다시 확인한다.
3. **resume**:
- own id 가 없고 같은 폴더에 같은 종류 행이 둘 이상이면 추측하지 않고 새 대화로 띄운다.
- 해결한 ID 를 다른 행이 이미 기록했으면 띄우기 전에 거부한다(exit 1).
- 기록된 ID 가 더 이상 유효하지 않으면 새 대화로 띄우고 그 ID 를 지운다(`update_yaml_resumed.sh --drop-stale-id`).
4. **생성 순서 (`lib_py/agents/id_order.py`, 새 파일)**:
- 같은 폴더에서 같은 종류 에이전트가 생성 중이거나 ID 없이 실행 중이면, 새 create·resume 은 그 ID 가 기록될 때까지 기다린다. 폴더·에이전트 단위 잠금(`fcntl`)과 "생성 중" 표시를 쓰고, 폴더는 실제 경로로 비교한다.
- 상한은 `MAM_ID_ORDER_WAIT_S`(기본 600 s, 0 이면 기다리지 않음)다. 넘으면 아무것도 띄우지 않고 기다린 세션 이름을 알리며 거부한다(exit 1).
- create·resume 이 띄운 뒤 행을 등록하기 전에 죽어 "생성 중" 표시만 남으면, 그 herdr 세션이 살아 있는 동안 계속 막는다. herdr 가 세션이 없다고 확실히 답하거나 그 시도의 행이 등록되면 푼다. 조회 실패는 "모름"으로 보고 막으며, 조회도 대기 상한 안에서만 한다(상한 0 이면 묻지 않음).
5. **정리**: `find_workspace_uuid()` 가 ID 를 반환하고 다른 행의 ID 를 후보에서 뺀다(`exclude_ids`). 쓰이지 않게 된 `capture_conversation_id`(lib.sh)와 `CAPTURE_ID`(stop)를 지웠다.
#### ✅ 품질 검증
- **회귀**: `tests/test_issue63_same_cwd_ids.py`(27개). 완료 조건의 회귀 테스트는 base(v4.9.9)에서 실패하고 head 에서 통과한다.
- **리뷰 (`--all-reviewer`, 작성자 PR 의 리뷰 전용 루프, 수정은 claude creator)**: codex 가 고아 "생성 중" 표시 처리에서 차단 결함을 회차마다 찾았고 ede9c62·8f64174·7cac6f6·1614af5 에서 고쳤다. 마지막 루프 1회차(@1614af5) **4/4 PASS**. 리포트는 `.agents/reports/<session>/` 에 있다.
- **전체 스위트 (macOS, Python 3.9.6, @1614af5)**: 2097 passed, 56 skipped, 1 failed.
- 실패는 `tests/test_issue50_dispatch_herdr.py::test_c11_…` 하나다. 이 브랜치는 위임 코드를 건드리지 않고, 단독으로 세 번 돌리면 매번 통과했다(시간 의존).
- 실제 `~/.claude`·`/private/tmp/claude-<uid>`·herdr 서버 pane 목록·opencode 의 data·state·config 에 이 실행이 남긴 것은 없었다.
- **작성자 확인 (Linux)**: 전체 스위트와 실제 agy 두 개 시험(같은 폴더 동시 create·stop·동시 resume·B purge 뒤 A 의 대화 파일 보존).
- **알려진 비차단**:
- herdr 조회가 계속 실패하면 고아 표시가 남아 이후 create·resume 이 매번 상한까지 기다린다. 거부 메시지가 `herdr kill-session` 명령을 알려 준다.
- 등록 중에 herdr 목록이 잠깐 비어 있으면 표시가 풀릴 수 있다.
- `--no-onboard` 로 만든 세션은 첫 ID 가 기록될 때까지 다음 create 가 상한까지 기다린다(#68).
- presence lock 으로 ID 를 찾는 것과, MAM 밖에서 사람이 같은 폴더에 띄운 에이전트는 범위 밖이다.
### 🚀 `v4.9.9` — completed 를 확인한 쪽이 세션 ID 를 등록하고, agy 는 다른 작업 공간의 대화를 받지 않는다 (Issue #40) (2026-10-21) ### 🚀 `v4.9.9` — completed 를 확인한 쪽이 세션 ID 를 등록하고, agy 는 다른 작업 공간의 대화를 받지 않는다 (Issue #40) (2026-10-21)
> **패치 (PATCH Release)**: 세션 ID 가 생성 뒤에야 생기는 에이전트(agy·opencode·codex·hermes)는 지금까지 모니터 데몬의 reconcile 이 찾아 등록했다. 데몬이 없는 워크트리 creator 는 등록되지 않았고, agy 의 drift C 는 호스트 공용 대화 폴더의 모든 `*.db` 를 후보로 보아 다른 작업 공간의 대화 ID 를 등록할 수 있었다. 이제 잡의 completed 를 확인한 쪽이 같은 판정으로 한 번 등록하고, reconcile 도 그 판정을 쓴다. > **패치 (PATCH Release)**: 세션 ID 가 생성 뒤에야 생기는 에이전트(agy·opencode·codex·hermes)는 지금까지 모니터 데몬의 reconcile 이 찾아 등록했다. 데몬이 없는 워크트리 creator 는 등록되지 않았고, agy 의 drift C 는 호스트 공용 대화 폴더의 모든 `*.db` 를 후보로 보아 다른 작업 공간의 대화 ID 를 등록할 수 있었다. 이제 잡의 completed 를 확인한 쪽이 같은 판정으로 한 번 등록하고, reconcile 도 그 판정을 쓴다.
+5 -5
View File
@@ -433,7 +433,7 @@
"class": "c", "class": "c",
"kind": "diagram", "kind": "diagram",
"evidence": [ "evidence": [
"multi-agent-mux-stop/scripts/stop_session.sh:184-198" "multi-agent-mux-stop/scripts/stop_session.sh:183-197"
], ],
"mismatch": null, "mismatch": null,
"note": "state machine" "note": "state machine"
@@ -503,9 +503,9 @@
"base_sha": "a7ed3e902cae", "base_sha": "a7ed3e902cae",
"class": "a", "class": "a",
"evidence": [ "evidence": [
"multi-agent-mux-stop/scripts/stop_session.sh:99", "multi-agent-mux-stop/scripts/stop_session.sh:98",
"multi-agent-mux-stop/scripts/stop_session.sh:140-143", "multi-agent-mux-stop/scripts/stop_session.sh:139-142",
"multi-agent-mux-stop/scripts/stop_session.sh:184-188" "multi-agent-mux-stop/scripts/stop_session.sh:183-187"
], ],
"mismatch": [ "mismatch": [
"T1: the doc offered --force-no-yaml, which no script accepts" "T1: the doc offered --force-no-yaml, which no script accepts"
@@ -599,7 +599,7 @@
"other" "other"
], ],
"passthrough": { "passthrough": {
"1": "multi-agent-mux-stop/scripts/stop_session.sh:327" "1": "multi-agent-mux-stop/scripts/stop_session.sh:322"
} }
}, },
"multi-agent-mux-create/scripts/create_session.sh": { "multi-agent-mux-create/scripts/create_session.sh": {
File diff suppressed because it is too large. Load diff