merge: fix/issue-10-11-13-15 (Issue #10 agy Purge Completeness, #11 Message Typo, #13 Orchestrator Purge, #15 Workspace-Anchored Registry v4.4.3)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
This commit is contained in:
GodopuandClaude Fable 5.1 committed 2026-09-13 11:22:28 +09:00
commit 8a09e022a6
37 files changed
+1332 -62

No files matched your search

@@ -0,0 +1,93 @@
# 🔍 Review Report — Job aa16fa5c (branch `fix/issue-10-11-13-15` 루프 2회차, HEAD `fae70c1`)
- **Reviewer**: `planner-reviewer-claude-01` (role: planner,reviewer) · 2026-09-13
- **대상**: `git log bd3df89..HEAD` — `c82ea75`, `0282312`(release v4.4.3), `7610258`(N1), `f5a6575`(테스트 uuid4), **`fae70c1`**(codex R1/R2). 앞선 라운드에서 PASS 한 부분은 회귀 여부 중심으로, 새 커밋 3건 중 `fae70c1` 은 정밀 검토했다(`7610258`·`f5a6575` 는 앞선 라운드에서 정밀 검토했고 이번에는 회귀만 확인).
- **방법**:
- 코드 정독
- **샌드박스 재현**: 임시 HOME 을 `$TMPDIR` 아래 생성하고 실행 전 `resolve_home()` 이 그곳인지 확인했다. 대화 id 는 모두 런타임 `uuid4()`.
- 선택 테스트(실제 `.mam/jobs` 스냅샷 비교)
- 정적 검사
- 리뷰 중 45초 간격 `progress` heartbeat 발행. 코드 수정 없음.
- **안전 규칙 준수**: 실제 홈이나 등록된 orchestrator UUID 로 purge 를 실행하지 않았다. `../multi-agent-mux-b2` 는 건드리지 않았다.
## 0. 직전 루프(반복 3, HEAD `f5a6575`) 결과와 처리
| 리뷰어 | 판정 |
|---|---|
| `planner-reviewer-claude-01`(`037af8e0`) · `reviewer-agy-01`(`433a74dc`) · `reviewer-grok-01`(`1752c893`) | PASS |
| `planner-reviewer-codex-01`(`f315859f`) | **NOT PASS** — R1: agy in-use 검사가 DB 만 `lsof` 에 넘겨 presence lock 만 연 보유자를 놓침 / R2: DB 가 없는 고아 부속 파일은 purge 대상으로 해결되지 않음 |
creator 는 반론 없이 `fae70c1` 로 두 지적을 반영하고 테스트 2개를 더했다. 검증 잡(`9c3e6f9b`)에서 250 passed 를 확인했고, `VERSIONS.md` 의 신규 테스트 수가 낡았다고 스스로 기록했다(§2-(3)).
## 1. 새 커밋 정밀 검토
### (1) `7610258` `base.conversation_in_use` — ✅ (회귀 없음, 잔여 한계 R-1)
- **세션 env 검사**: `AGENT_SESSION_ENV` 5종 중 하나가 uuid 와 같으면 즉시 사용 중이다. 앞선 라운드에서 이 세션으로 실측했다(env 만으로도, env 없이 argv 만으로도 True).
- **헬퍼 사슬 제외는 첫 에이전트 CLI 조상에서 멈춘다.** `_is_agent_cli` 는 첫 옵션 이전 토큰의 basename 을 본다. 토큰 판독 사례:
- `node …/claude …`, `env X=1 claude …`, `/bin/sh …/agy …` → 에이전트 ✓
- `sh -c "…"`, `bash orc_onboard.sh --remove …`, `python -m lib_py.agents purge …` → 에이전트 아님(첫 옵션에서 판독 중단) ✓
- **오탐**: 위치 인자의 basename 이 우연히 에이전트 이름인 비-에이전트 프로세스(`vim notes/claude`, `python tools/codex`)는 에이전트로 판정된다. 다만 걷기가 거기서 멈춰도 purge 와 `orc_onboard.sh` 는 그 아래 헬퍼 사슬에 남으므로, 그런 프로세스가 둘 사이에 끼어야만 영향이 있다. 사실상 영향 없음.
- **미탐(R-1, 앞선 라운드 재현 A)**: 에이전트 이름으로 보이지 않는 조상(`python -m <agent>`, `node …/cli.js`)이 uuid 를 argv 에 갖고 있고 세션 env 도 없으면 헬퍼 사슬로 제외된다. 현재 배포(claude)는 이름과 env 양쪽으로 잡히므로 비차단이다. "알려진 헬퍼만 제외" 방식을 후속으로 권장한다.
### (2) `fae70c1` agy in-use · `has_artifacts` · 해결기 2단계 — ✅ (잔여 한계 R-a)
- **코드**:
- `AgyAgentAdapter._artifact_paths`(`agy.py:77-84`): DB, `-wal`/`-shm`/`-journal`, `brain/<uuid>`, annotation, presence lock 7경로.
- `has_artifacts`(`:86-90`): 그중 하나라도 있으면 True.
- `conversation_in_use` override(`:92-96`): 7경로 전부를 `lsof` 에 넘긴다.
- `BaseAgentAdapter.has_artifacts` 기본값(`base.py:194-200`): `uuid in path and os.path.exists(path)` — 기존 해결기의 경로 폴백과 **동일**하다. 그래서 claude·hermes·grok·codex 의 해결은 그대로이고, 공유 DB 인 opencode 는 경로에 uuid 가 없어 매치되지 않는다.
- `_resolve_purge_target`(`__main__.py:34-39`): 2단계가 `has_artifacts` 로 바뀌었다(1단계 revalidate `verify_artifact` 는 그대로).
- **샌드박스 재현(HEAD `fae70c1`, 매 케이스 새 `uuid4`)**:
| 경우 | 결과 |
|---|---|
| **R1**: 보유자가 presence lock 만 `flock` 으로 열고 argv·env 어디에도 uuid 가 없음(argv 에 uuid 없음을 확인) | `adapter.conversation_in_use` → **True**, CLI purge → **rc 4**, 존재하던 4조각 모두 보존 |
| **R2**: DB 없이 `-wal` + annotation 만 남음, `agy` 명시 | **rc 0**, agent `agy`, 2조각 purge, residual `[]` |
| R2, `auto` | **rc 0**, agent `agy`, 2조각 purge, residual `[]` |
| 아무것도 없음 | **rc 5**, agent `null`(미해결과 구분 유지) |
| 회귀: claude transcript 만 있음 | `auto` → **claude** 로 해결 |
| 회귀: opencode 공유 저장소만 있음, 무작위 uuid | `has_artifacts` False, `auto` → **rc 5** |
- **R-a (이미 보류된 항목, 이번 재현으로 증거 추가)**:
- 어댑터 `purge_artifacts` 는 여전히 **lock 을 지울지 정할 때만**, 그것도 DB·brain·annotation 을 **지운 뒤에** in-use 를 본다.
- 이 메서드를 직접 부르는 `stop_session.sh:329` 경로에서 lock 보유자가 있으면 DB·brain·annotation 을 지우고 lock 만 남긴다(재현: 3조각 삭제, lock 보존).
- CLI·`orc_onboard` 경로는 파괴 작업 **전에** 7경로 전체로 검사하므로 codex R1 의 "파괴 전 검사" 요구를 충족한다. `stop_session` 은 herdr 사망 pre-gate 를 거치므로 남는 경우는 "다른 프로세스가 같은 대화를 쥔" 드문 상황이다.
- **권장**: `purge_artifacts` 첫머리에서 `conversation_in_use` 가 참이면 빈 목록과 경고로 전체를 건너뛸 것(후속 이슈).
## 2. 검증 항목별 결과
| # | 항목 | 결과 |
|---|---|---|
| (1) | `7610258` 헬퍼 사슬·`_is_agent_cli` | ✅ §1-(1). R-1 비차단 |
| (2) | `fae70c1` agy in-use·`has_artifacts`·해결기 | ✅ §1-(2). 다른 어댑터 해결 영향 없음. R-a 비차단(보류 항목) |
| (3) | `orc_onboard.sh` 흐름 · SKILL.md · VERSIONS.md 정합 | ✅ purge 선행·exit 4(`--yes` 없음)/5(in-use, 등록 유지)·dry-run 무변경. SKILL.md 의 고아 조각·lock `lsof` 문단과 VERSIONS 항목 1·2 가 코드와 일치. **단 `VERSIONS.md:52` "신규 테스트 24개 … `test_issue10_13_purge.py` 17개" 가 낡았다** — 실제 19 + 7 = **26**(`fae70c1` 이 2개 추가). 병합 전 한 줄 정정 권장(비차단) |
| (4) | #15 경로 · `job_subscriber --registry-dir` | ✅ 무변경(`default_registry_dir()`, 운영 호출은 모두 명시) |
| (5) | 버전 락스텝 | ✅ `lib.sh:32`·`eager.py:16` = 4.4.3, `verify_framework_version` 통과, 릴리스 커밋 `0282312` 제목 그대로 |
| (6) | 테스트 · 정적 검사 | 아래 |
- **선택 실행 9파일**(브리프 지정 8 + `test_version_consistency`): **250 passed in 163.4 s, rc 0**(`test_issue10_13_purge.py` 23건 포함 — `fae70c1` 의 lock 전용 보유자·고아 부속 파일 테스트 포함).
- **#15 누수 실측**: 실행 전후 실제 `.mam/jobs` 레코드 목록이 **동일**했다(추가 0건).
- **정적 검사**:
- `py_compile`: `base.py`, `agy.py`, `__main__.py`, 테스트 파일 통과
- `bash -n orc_onboard.sh` 통과
- `git diff --check bd3df89...HEAD` 클린
- `verify_framework_version` 통과
- 작업 트리 깨끗, HEAD `fae70c1`
- purge 테스트 파일의 고정 UUID 리터럴 0건
## 3. 비차단 권고 (후속 이슈 1건으로 묶기를 권장)
1. **`VERSIONS.md:52` 테스트 수 정정**: 24/17 → 26/19. 목록에 "presence lock 만 연 보유자 거부, 고아 부속 파일로 agy 해결·purge" 추가. 병합 전 권장.
2. **R-a**: 어댑터 `purge_artifacts` 에서 in-use 이면 전체 건너뛰기(§1-(2)).
3. **R-1**: 에이전트 판정의 이름 의존 → 알려진 헬퍼만 제외.
4. **위임 래퍼의 브리프 argv 전달**: `ps` 노출이자 f5a6575 이 겪은 argv 충돌의 근본 원인. 파일이나 stdin 으로 전달 권장.
5. **R-b / R-c / R-e**: 등록 UUID 확인, claude 교차 프로젝트 탐색, 상속 `WORKSPACE_ROOT`.
## 4. GM 참고
- codex 의 NOT PASS(R1/R2)는 `fae70c1` 에서 해소되었다(위 재현). codex 가 이 HEAD 를 다시 확인하는 것이 만장일치 판정의 전제다.
- agy·grok 의 직전 PASS 는 `f5a6575` 기준이라 `fae70c1` 은 포함하지 않는다.
## 5. 종합
- `fae70c1` 은 codex R1(lock 만 연 보유자)과 R2(고아 부속 파일)를 정확한 위치에서 최소 변경으로 고쳤다. 기본 `has_artifacts` 가 기존 폴백과 같아 다른 어댑터의 해결은 바뀌지 않았다. 앞선 라운드 결과(N1·N2·#10·#11·#15·락스텝)도 회귀 없이 유지된다.
- 남은 항목은 이미 보류된 R-a·R-1 과 changelog 테스트 수 한 줄이다. 설계 재검토는 필요 없다.
[VERDICT: PASS]
@@ -0,0 +1,44 @@
# Round 2 review — fix/issue-10-11-13-15
Job: `79204d61`. Reviewer: `planner-reviewer-codex-01`.
Base: `bd3df89`. Reviewed HEAD: `fae70c13d9a7fb9df50e1bfe759f39e35d3e1500`.
PASS. Both findings from job `f315859f` are resolved. No additional blocking defect was found in this review.
## Scope and findings
Reviewed the branch diff from `bd3df89` and all five commits (`c82ea75`, `0282312`, `7610258`, `f5a6575`, `fae70c1`). Previously reviewed, unchanged sections received regression checks; the self-session protection, randomized fixtures, and artifact-resolution changes received detailed inspection.
| Requested area | Assessment |
|---|---|
| Prior R1: held presence lock | Fixed. The agy override passes DB, sidecars, brain directory, annotation, and presence lock to the shared detector. An independent temporary-home probe with an exclusively locked presence file returned `True` while held and `False` after release. The new CLI regression also verifies refusal and preservation of artifacts. |
| Prior R2: orphan artifacts | Fixed. The agy adapter's `has_artifacts` recognizes known auxiliary paths even without a DB. Independent explicit-agy resolution succeeded with only a presence file. The new auto-resolution test purges orphan WAL, annotation, and lock files and checks empty residuals. |
| Other adapters/shared DB | The default `has_artifacts` preserves the previous `uuid in artifact_path and exists` predicate. Verification still runs before fallback, and opencode remains last. Controlled-path checks for claude/hermes/grok/codex/opencode confirmed UUID-bearing paths are accepted and an unrelated shared path is rejected. These were predicate checks, not destructive backend operations. |
| Self-session protection | Session environment values are checked before process inspection. The excluded helper chain stops at the nearest recognized agent CLI, allowing that ancestor's UUID to count. Ordinary helper ancestors remain excluded; non-helper process matches count as holders. The passing tests cover helper ancestry, agent ancestry, and all five listed session variables. |
| Onboarding flow | Purge precedes registry mutation; CLI in-use exit 4 maps to onboarding exit 5, missing confirmation returns 4, and dry-run exits before purge. The corrected `added`/`removed` wording remains intact. |
| Registry isolation | CLI/service defaults retain explicit override → existing WORKSPACE_ROOT → cwd precedence. The subscriber now uses `default_registry_dir()`. An independent parser check intercepted subscriber execution before network access and confirmed the workspace-derived path. Sandbox isolation and the adjusted R1/B-9/snapshot expectations remain consistent. |
| Version/docs | Shell and eager constants, eight frontmatters, matrix and current documentation remain 4.4.3. The release commit subject is exactly `release(v4.4.3): bump framework and eight skills to 4.4.3 in lockstep`. Remaining 4.4.2 text in checked current documents is the historical changelog heading. The skill and release notes describe the held-lock and orphan fixes. |
The process detector remains heuristic: UUID text in unrelated processes can cause a conservative refusal, as documented, and agent-name token parsing recognizes the supported command forms rather than every possible interpreter launcher. File-descriptor detection depends on working `lsof`; the current run exercised it successfully. These limits do not reopen the two reproduced findings. WORKSPACE_ROOT remains an authoritative override, so callers changing workspaces must supply the intended root or explicit directories.
## Validation
Fresh run at the reviewed HEAD: **250 passed in 178.94 seconds**.
```text
tests/test_issue10_13_purge.py
tests/test_issue15_registry_isolation.py
tests/test_orc_onboard.py
tests/test_a4_adapter_contract.py
tests/test_issue5_phase1_delegation.py
tests/test_tier1_unit.py
tests/test_loop_cli.py
tests/test_issue5_phase4_runtime.py
tests/test_version_consistency.py
```
Full-branch `git diff --check` passed. Individual `bash -n` checks passed for lib.sh, onboarding, create, and stop scripts. `verify_framework_version()` passed with MAM_VERSION read from lib.sh and the imported eager constant. Final inspection confirmed the same HEAD and a clean tracked worktree. The full repository suite was not rerun.
Only this review report was added. No source edits, commits, pushes, merges, real-home purges, registered-orchestrator removals, or operations in the excluded second worktree were performed. Independent fixtures used temporary homes and fresh UUID4 identifiers. Progress events were published during review and testing.
[VERDICT: PASS]
@@ -0,0 +1,134 @@
# Peer Review Report: Branch `fix/issue-10-11-13-15` & v4.4.3 Lockstep (Round 2 Cross-Review)
- **Reviewer**: `agy` (`herdr:reviewer-agy-01`)
- **Job ID**: `0f41ea47`
- **Target Branch**: `fix/issue-10-11-13-15` @ `fae70c1` (base `main` @ `bd3df89` v4.4.2)
- **Commits Reviewed**:
- `c82ea75` — `fix(issue-10,11,13,15): complete agy purge, orchestrator purge, workspace-anchored registry`
- `0282312` — `release(v4.4.3): bump framework and eight skills to 4.4.3 in lockstep`
- `7610258` — `fix(issue-13): refuse to purge the conversation the caller is running in`
- `f5a6575` — `test(issue-13): give each purge test fresh conversation ids`
- `fae70c1` — `fix(issue-10,13): check every agy artifact for holders and purge orphaned leftovers`
- **Review Date**: 2026-09-13 (KST)
---
## 1. Executive Summary
Branch `fix/issue-10-11-13-15` resolves issues #10, #11, #13, and #15 while integrating peer-review feedback across two review loops.
In loop 1 (round 1), codex identified two edge cases:
- **R1**: `agy` in-use check passed only the main DB file to `lsof`, missing processes holding open the presence lock (or sidecars) without the UUID in command line arguments or environment variables.
- **R2**: Orphaned leftovers (e.g. WAL, annotations, or presence lock surviving after an interrupted or pre-#10 purge where the main DB was already gone) failed to resolve as a purge target because resolution relied on DB existence.
Commit `fae70c1` directly resolves both codex findings through:
1. `AgyAgentAdapter.conversation_in_use`: Passes all conversation paths (`_artifact_paths`: DB, sidecars, brain, annotation, presence lock) to `lsof -t`.
2. `AgyAgentAdapter.has_artifacts`: Recognizes any existing artifact piece even if the primary DB has already been deleted.
3. `BaseAgentAdapter.has_artifacts`: Replaces the resolver's ad-hoc path fallback with a clean polymorphic method defaulting to `uuid in path and os.path.exists(path)`, correctly preventing `opencode` (shared DB) from falsely claiming non-opencode UUIDs.
Together with caller session recognition (`7610258`) and dynamic test fixtures (`f5a6575`), the branch is complete, robust, and verified.
All 250 targeted tests across 9 test suites passed with zero failures and zero leaks into the live `.mam/jobs` registry.
---
## 2. In-Depth Technical Verification
### (1) Codex R1 & R2 Solutions (`fae70c1`)
- **Codex R1 (`AgyAgentAdapter.conversation_in_use`)**:
```python
def _artifact_paths(self, uuid: str, ctx: DiscoveryContext) -> list:
db = self.artifact_path(uuid, ctx)
root = self._cli_root(ctx)
return ([db] + [f"{db}{suffix}" for suffix in ("-wal", "-shm", "-journal")]
+ [f"{root}/brain/{uuid}", f"{root}/annotations/{uuid}.pbtxt",
f"{root}/presence/{uuid}.lock"])
def conversation_in_use(self, uuid: str, ctx: DiscoveryContext) -> bool:
return conversation_in_use(uuid, self._artifact_paths(uuid, ctx))
```
`conversation_in_use` passes every associated file path to `lsof -t`. Even if a process holds open only `presence/<uuid>.lock` (or a `-wal` sidecar) via `flock` and carries neither the UUID in `ps` command-line arguments nor any session environment variable, `lsof` catches the active file descriptor and refuses the purge. Verified by `tests/test_issue10_13_purge.py::test_purge_cli_refuses_when_only_the_presence_lock_is_held_open`.
- **Codex R2 (`AgyAgentAdapter.has_artifacts` & Orphan Resolution)**:
```python
def has_artifacts(self, uuid: str, ctx: DiscoveryContext) -> bool:
return any(os.path.exists(p) for p in self._artifact_paths(uuid, ctx))
```
If the main DB was previously unlinked or deleted, any remaining orphaned WAL files, annotations, or presence locks are recognized by `has_artifacts`. `_resolve_purge_target` correctly identifies `agy` as the owning agent and purges the stranded artifacts completely. Verified by `tests/test_issue10_13_purge.py::test_purge_cli_resolves_agy_from_orphaned_leftovers`.
- **`BaseAgentAdapter.has_artifacts` Default Contract & Resolver Integration**:
In `lib_py/agents/base.py`:
```python
def has_artifacts(self, uuid: str, ctx: DiscoveryContext) -> bool:
path = self.artifact_path(uuid, ctx)
return uuid in path and os.path.exists(path)
```
In `lib_py/agents/__main__.py` `_resolve_purge_target`:
```python
for name, adapter, ctx in probes:
try:
if adapter.has_artifacts(uuid, ctx):
return name, adapter, ctx
except Exception:
continue
```
- **Shared Store Isolation**: `opencode` uses a shared database path (`opencode.db`) which does not embed the UUID (`uuid in path` is `False`). Thus `opencode` never falsely claims other agents' UUIDs in the fallback pass.
- **Other Adapters (`claude`, `hermes`, `grok`, `codex`)**: All embed the UUID in their artifact paths (`f".../{uuid}.jsonl"`, etc.). Their resolution behavior remains identical to the historical contract.
### (2) Caller Session Protection (`7610258`) & Dynamic Fixtures (`f5a6575`)
- **`AGENT_SESSION_ENV` Verification**:
Inspects `CLAUDE_CODE_SESSION_ID`, `ANTIGRAVITY_CONVERSATION_ID`, `HERMES_SESSION_ID`, `GROK_SESSION_ID`, and `OPENCODE_SESSION_ID`. If the caller's environment carries the target UUID, `conversation_in_use` immediately returns `True`.
- **Helper-Chain Pruning (`_is_agent_cli`)**:
Excludes the purge command's own non-agent helper processes (python interpreter, shell runners) but halts exclusion at the nearest agent CLI ancestor (`claude`, `agy`, `hermes`, `grok`, `opencode`, `codex`). If that agent CLI ancestor carries the UUID on its command line, it is treated as an active holder.
- **Dynamic Test Fixtures (`f5a6575`)**:
All purge tests in `test_issue10_13_purge.py` use dynamic `uuid4` fixtures (`conv` and `other`), preventing false "in use" detections caused by concurrent delegation wrappers whose command-line prompts carry test file diffs.
### (3) `orc_onboard.sh` Flow & Documentation Alignment
- Purge executes **before** registry mutation. A refused purge (exit code `5` due to in-use detection) leaves `.mam/agent-sessions.yaml` untouched.
- Confirmation gate (`--yes`) prevents accidental deletion (exit code `4`).
- Missing on-disk artifacts emit a warning and cleanly remove the UUID registration (exit code `0`).
- Past-tense grammatical correction maps `add -> added`, `remove -> removed`, eliminating the `removeed` typo.
- Full alignment with `multi-agent-mux-orc-onboard/SKILL.md` and `VERSIONS.md` v4.4.3.
### (4) Registry & Audit Log Resolution (#15)
- `default_registry_dir()` and `get_logs_dir()` prioritize `MAM_JOBS_DIR`/`DELEGATE_JOB_LOGS_DIR` &rarr; `$WORKSPACE_ROOT/.mam/...` &rarr; `<cwd>/.mam/...`.
- `job_subscriber.py` argument default updated to `default_registry_dir()`.
- Verified zero test jobs leaked into `.mam/jobs` during test suite runs.
### (5) Version Lockstep Completeness (`v4.4.3`)
- `MAM_VERSION="4.4.3"` in `.agents/skills/lib.sh`.
- `FRAMEWORK_VERSION = "4.4.3"` in `.agents/skills/lib_py/loop/eager.py`.
- 8 `SKILL.md` frontmatter versions: `4.4.3`.
- `VERSIONS.md`: Current release `v4.4.3`, 8-row skills matrix `4.4.3`, and changelog section updated to document codex R1/R2 and claude N1 refinements.
- Documentation badges in `README.md`, `README.ko.md`, `BOOTSTRAP.md`, `BOOTSTRAP.ko.md`, and `MESSAGING.md` header all synchronized.
- Verified no non-historical `4.4.2` references remain.
---
## 3. Test Suite Verification Results
All tests executed with active MQTT heartbeat tracking:
| Test Suite | Result | Details |
| :--- | :---: | :--- |
| `tests/test_issue10_13_purge.py` | 23/23 PASS | Includes presence lock holder test (R1) & orphaned leftover purge test (R2) |
| `tests/test_issue15_registry_isolation.py` | 7/7 PASS | Resolution hierarchy & onboarding leak prevention |
| `tests/test_orc_onboard.py` | 37/37 PASS | Onboarding CLI, remove, past tense message, ownership verification |
| `tests/test_a4_adapter_contract.py` | 33/33 PASS | Agent adapters contract compliance, 2-item purge base invariant |
| `tests/test_version_consistency.py` | 3/3 PASS | 3-way version lockstep, runtime mismatch rejection, env immutability |
| `tests/test_issue5_phase1_delegation.py` | 66/66 PASS | Delegation CLI, snapshot contracts, event publishing |
| `tests/test_tier1_unit.py` | 53/53 PASS | Unit tests, path resolution, B-9 logs directory |
| `tests/test_loop_cli.py` | 9/9 PASS | Loop CLI parsing, creator requirement, planner auto-resolution |
| `tests/test_issue5_phase4_runtime.py` | 5/5 PASS | Python core loop runtime execution & lifecycle |
| **Total Targeted Tests** | **250/250 PASS** | **All 250 tests passed cleanly (0 failures, 0 regressions)** |
### Live Registry Immutability Audit
- Pre-test and post-test inspections of `.mam/jobs` confirmed that zero test job records were written to the real repository registry.
---
## 4. Conclusion
The refinements in `fae70c1` thoroughly and elegantly address codex review findings R1 and R2 without introducing any regressions or architectural friction. All six review criteria are completely satisfied.
[VERDICT: PASS]
@@ -0,0 +1,59 @@
# 🔍 Review Report — Job 3ea06fb6 (`fix/issue-10-11-13-15` 2회차, HEAD `fae70c1`)
- **Reviewer**: `reviewer-grok-01` (role: reviewer) · 2026-09-13
- **범위**: `git log`/`git diff` **`bd3df89...HEAD`**
- **HEAD**: `fae70c1` `fix(issue-10,13): check every agy artifact for holders and purge orphaned leftovers`
- **선행**: `ba3e3ab5` PASS(`0282312`) → `4b113d10` NOT PASS(N2) → `1752c893` PASS(`f5a6575`). 이번 초점: `7610258` 재확인 + **`fae70c1` Codex R1/R2**.
- **안전**: 실제 홈 purge 없음. 테스트는 uuid4 픽스처만. 다른 worktree 미접촉. 소스 무변경.
커밋: `c82ea75` → `0282312` → `7610258` → `f5a6575` → **`fae70c1`**.
## (1) `7610258` `conversation_in_use`
- `AGENT_SESSION_ENV` 5종이 uuid 와 같으면 즉시 in-use.
- cmdline 헬퍼 사슬은 **자기부터 가장 가까운 `_is_agent_cli` 조상 직전**만 제외. 그 조상이 uuid 를 들고 있으면 holder.
- `_is_agent_cli`: 첫 `-` 옵션 이전 토큰의 basename ∈ `{claude,agy,hermes,grok,opencode,codex}`.
오탐/미탐: `python -m <agent>` 조상은 이름 매칭이 안 되어 헬퍼로 남을 수 있음(배포 claude/agy 바이너리 경로에서는 env+basename 으로 잡힘). uuid 를 **언급만** 하는 프로세스도 holder — SKILL.md 가 그렇게 문서화. 자기 세션 purge 거부는 유지.
## (2) `fae70c1` R1/R2
**R1** `AgyAgentAdapter.conversation_in_use` (`agy.py:92-96`): `_artifact_paths` 의 DB·wal/shm/journal·brain·annotation·**lock** 전부를 `conversation_in_use(..., paths)` 에 넘김. cmdline/env 없이 lock 만 `flock` 한 보유자도 `lsof` 에 잡힘. `test_purge_cli_refuses_when_only_the_presence_lock_is_held_open` 통과. CLI 는 그 결과로 exit 4, **아무것도 안 지움**.
**R2** `has_artifacts`:
- 기본 (`base.py:194-200`): `uuid in artifact_path and exists`. 공유 DB(`opencode` → `opencode.db`, 경로에 uuid 없음)는 모든 uuid 에 매치되지 않음.
- agy 오버라이드 (`agy.py:86-90`): `_artifact_paths` 중 하나라도 있으면 참 (DB 없는 WAL/annotation/lock).
- `_resolve_purge_target` 2단계: `verify_artifact` 후 `has_artifacts` (옛 `uuid in path and exists` 대체). claude/hermes/grok/codex 경로는 uuid 를 포함하므로 기존 폴백과 동등. `test_purge_cli_resolves_agy_from_orphaned_leftovers` 통과.
다른 어댑터는 `has_artifacts` 를 오버라이드하지 않음. verify 가 먼저이므로 정상 jsonl/session 파일은 1단계에서 해결.
## (3) `orc_onboard.sh` / 문서
흐름 불변: `--yes` 없으면 exit 4, Python in-use 4 → 셸 5(등록 유지), 미해결 5 → WARN 후 등록 제거, dry-run 무변경, purge 가 YAML 앞. SKILL.md·VERSIONS.md v4.4.3 항목 1–2 가 lock 전 경로 `lsof` 와 고아 부속을 반영.
## (4) #15
`MAM_JOBS_DIR` → `WORKSPACE_ROOT/.mam/…` → cwd. `job_subscriber --registry-dir` 기본값 `default_registry_dir()` (`7610258` R-d). conftest 격리 유지.
## (5) 락스텝
`lib.sh`/`eager.py`/8 SKILL.md/`VERSIONS.md` = **4.4.3**. `verify_framework_version` 통과. 릴리스 커밋 `0282312` 제목 유지. `fae70c1` 은 changelog 항목 1–2 만.
## (6) 테스트
```
.venv/bin/python -m pytest -q \
tests/test_issue10_13_purge.py tests/test_issue15_registry_isolation.py \
tests/test_orc_onboard.py tests/test_a4_adapter_contract.py \
tests/test_issue5_phase1_delegation.py tests/test_tier1_unit.py \
tests/test_loop_cli.py tests/test_issue5_phase4_runtime.py \
tests/test_version_consistency.py
```
**250 passed in 162.54s** (직전 248 + R1/R2 2건). uuid4 픽스처. `git diff --check` 클린. 전체 스위트 미실행.
## 종합
Codex R1(lock-only holder)과 R2(고아 부속)가 어댑터 단위로 닫혔고, 1회차 PASS 항목은 회귀 없음. 설계 재작업 불필요.
[VERDICT: PASS]
+4 -3
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.4.2" MAM_VERSION="4.4.3"
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}"
@@ -2036,8 +2036,9 @@ _start_watchdog_legacy() {
# NOT `python -m`: for -m, sys.path[0] is the current directory, which therefore # NOT `python -m`: for -m, sys.path[0] is the current directory, which therefore
# beats PYTHONPATH. A frozen loop whose cwd happened to contain a lib_py package # beats PYTHONPATH. A frozen loop whose cwd happened to contain a lib_py package
# would silently execute live code, losing B-13. An explicit insert of SKILL_DIR # would silently execute live code, losing B-13. An explicit insert of SKILL_DIR
# cannot be shadowed that way. cwd is deliberately left alone, because the # cannot be shadowed that way. cwd is deliberately left alone; the registry and
# registry and audit-log paths are relative to it. # audit-log paths resolve to MAM_JOBS_DIR / DELEGATE_JOB_LOGS_DIR, then to
# $WORKSPACE_ROOT/.mam/..., and only then relative to it (Issue #15).
_delegation_py() { _delegation_py() {
PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" "$(_delegate_py_bin)" -c ' PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" "$(_delegate_py_bin)" -c '
import sys import sys
+64 -2
View File
@@ -1,11 +1,48 @@
# __main__.py — CLI bridge for lib_py.agents facts and resolution (N4) # __main__.py — CLI bridge for lib_py.agents facts and resolution (N4)
import sys, json, shlex import os, sys, json, shlex
from lib_py.agents.registry import get_adapter, own_key, agent_of_row from lib_py.agents.registry import get_adapter, own_key, agent_of_row
# `purge auto` probes adapters in this order; opencode last because its
# artifact path is one shared DB that exists for every uuid.
PURGE_PROBE_ORDER = ('agy', 'claude', 'hermes', 'grok', 'codex', 'opencode')
def _resolve_purge_target(agent_hint: str, uuid: str, workspace: str):
"""(agent, adapter, ctx) whose on-disk artifact carries `uuid`, else None.
An explicit agent is trusted as long as its artifact exists. `auto` tries
each adapter's own verification first (revalidate mode: an orchestrator
conversation is not in any workspace cache), then the adapter's own
`has_artifacts` - which, for agy, also accepts orphaned sidecars, the
annotation or the presence lock left behind after the DB is gone.
"""
from lib_py.agents.base import DiscoveryContext
names = [agent_hint] if agent_hint and agent_hint != 'auto' else list(PURGE_PROBE_ORDER)
probes = []
for name in names:
adapter = get_adapter(name)
if not adapter:
continue
probes.append((name, adapter, DiscoveryContext(workspace, name, mode="revalidate")))
for name, adapter, ctx in probes:
try:
if adapter.verify_artifact(uuid, ctx):
return name, adapter, ctx
except Exception:
pass
for name, adapter, ctx in probes:
try:
if adapter.has_artifacts(uuid, ctx):
return name, adapter, ctx
except Exception:
continue
return None
def main(): def main():
if len(sys.argv) < 2: if len(sys.argv) < 2:
print("Usage: python -m lib_py.agents <facts|resolve> [args...]", file=sys.stderr) print("Usage: python -m lib_py.agents <facts|resolve|purge> [args...]", file=sys.stderr)
sys.exit(1) sys.exit(1)
cmd = sys.argv[1] cmd = sys.argv[1]
@@ -83,6 +120,31 @@ def main():
print('/exit') print('/exit')
sys.exit(0) sys.exit(0)
elif cmd == 'purge':
# purge <agent|auto> <uuid> [workspace] (Issue #13)
# Deletes a conversation's on-disk artifacts through its adapter and
# prints one JSON object: {"agent", "purged", "residual", "in_use"}.
# Exit 0 purged; 4 refused, conversation still in use (nothing
# removed); 5 no adapter has an artifact for this uuid.
agent_hint = sys.argv[2] if len(sys.argv) > 2 else 'auto'
uuid = sys.argv[3] if len(sys.argv) > 3 else ''
workspace = sys.argv[4] if len(sys.argv) > 4 and sys.argv[4] else os.getcwd()
if not uuid:
print("ERROR: purge requires <agent|auto> <uuid>", file=sys.stderr)
sys.exit(1)
target = _resolve_purge_target(agent_hint, uuid, workspace)
if not target:
print(json.dumps({"agent": None, "purged": [], "residual": [], "in_use": False}))
sys.exit(5)
name, adapter, ctx = target
if adapter.conversation_in_use(uuid, ctx):
print(json.dumps({"agent": name, "purged": [], "residual": [], "in_use": True}))
sys.exit(4)
purged = adapter.purge_artifacts(uuid, ctx)
residual = adapter.residual_artifacts(uuid, ctx)
print(json.dumps({"agent": name, "purged": purged, "residual": residual, "in_use": False}))
sys.exit(0)
else: else:
print(f"ERROR: Unknown CLI command {cmd!r}", file=sys.stderr) print(f"ERROR: Unknown CLI command {cmd!r}", file=sys.stderr)
sys.exit(1) sys.exit(1)
+64 -6
View File
@@ -1,6 +1,6 @@
import os, json, sqlite3, shutil import os, sys, json, sqlite3, shutil
from typing import Optional, Any from typing import Optional, Any
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext, conversation_in_use
class AgyAgentAdapter(BaseAgentAdapter): class AgyAgentAdapter(BaseAgentAdapter):
@property @property
@@ -71,18 +71,76 @@ class AgyAgentAdapter(BaseAgentAdapter):
return False return False
return True return True
def _cli_root(self, ctx: DiscoveryContext) -> str:
return f"{ctx.home_dir}/.gemini/antigravity-cli"
def _artifact_paths(self, uuid: str, ctx: DiscoveryContext) -> list:
"""Every on-disk piece of one conversation, DB first: the SQLite
sidecars, the brain dir, the title annotation and the presence lock."""
db = self.artifact_path(uuid, ctx)
root = self._cli_root(ctx)
return ([db] + [f"{db}{suffix}" for suffix in ("-wal", "-shm", "-journal")]
+ [f"{root}/brain/{uuid}", f"{root}/annotations/{uuid}.pbtxt",
f"{root}/presence/{uuid}.lock"])
def has_artifacts(self, uuid: str, ctx: DiscoveryContext) -> bool:
# Any piece counts, not only the DB: a purge that was interrupted (or
# the pre-#10 purge) can leave a WAL, annotation or lock behind with
# the DB already gone, and those orphans must still be reachable.
return any(os.path.exists(p) for p in self._artifact_paths(uuid, ctx))
def conversation_in_use(self, uuid: str, ctx: DiscoveryContext) -> bool:
# The holder may keep the presence lock (or a sidecar) open rather
# than the DB, and may name the uuid nowhere; the lsof leg of the
# check must see every path of the conversation.
return conversation_in_use(uuid, self._artifact_paths(uuid, ctx))
def purge_artifacts(self, uuid: str, ctx: DiscoveryContext) -> list: def purge_artifacts(self, uuid: str, ctx: DiscoveryContext) -> list:
# Issue #10: a purge must leave nothing resumable behind. Besides the
# conversation DB and the brain dir, agy keeps SQLite sidecars (-wal /
# -shm / -journal, created whenever the DB is opened - the stop path
# itself does so while capturing the id, and an unflushed WAL can still
# hold conversation content), the title annotation and a presence lock.
purged = [] purged = []
db = self.artifact_path(uuid, ctx) db = self.artifact_path(uuid, ctx)
if os.path.exists(db): for path in [db] + [f"{db}{suffix}" for suffix in ("-wal", "-shm", "-journal")]:
os.remove(db) if os.path.exists(path):
purged.append(db) os.remove(path)
brain = f"{ctx.home_dir}/.gemini/antigravity-cli/brain/{uuid}" purged.append(path)
root = self._cli_root(ctx)
brain = f"{root}/brain/{uuid}"
if os.path.isdir(brain): if os.path.isdir(brain):
shutil.rmtree(brain, ignore_errors=True) shutil.rmtree(brain, ignore_errors=True)
purged.append(brain) purged.append(brain)
annotation = f"{root}/annotations/{uuid}.pbtxt"
if os.path.exists(annotation):
os.remove(annotation)
purged.append(annotation)
# The presence lock marks a conversation some process has open; only
# remove it once nothing references the conversation any more.
lock = f"{root}/presence/{uuid}.lock"
if os.path.exists(lock):
if self.conversation_in_use(uuid, ctx):
print(f"WARN: presence lock kept, conversation {uuid} is still in use: {lock}", file=sys.stderr)
else:
os.remove(lock)
purged.append(lock)
return purged return purged
def residual_artifacts(self, uuid: str, ctx: DiscoveryContext) -> list:
"""Anything under the agy CLI tree still carrying this conversation id."""
root = self._cli_root(ctx)
if not os.path.isdir(root):
return []
found = []
for dirpath, dirnames, filenames in os.walk(root):
for name in dirnames + filenames:
if uuid in name:
found.append(os.path.join(dirpath, name))
# Do not descend into a matched directory; it is reported as a whole.
dirnames[:] = [d for d in dirnames if uuid not in d]
return sorted(found)
def spawn_spec(self, binary: str, session_uuid: str = "", use_wrapper: bool = False) -> str: def spawn_spec(self, binary: str, session_uuid: str = "", use_wrapper: bool = False) -> str:
return f"{binary} --dangerously-skip-permissions" return f"{binary} --dangerously-skip-permissions"
+97 -1
View File
@@ -1,10 +1,89 @@
# base.py — BaseAgentAdapter abstract base class, SpawnSpec, and DiscoveryContext # base.py — BaseAgentAdapter abstract base class, SpawnSpec, and DiscoveryContext
import os, json, sqlite3 import os, json, sqlite3, shutil, subprocess
from typing import Optional, Dict, Any, List from typing import Optional, Dict, Any, List
from lib_py.paths import resolve_home from lib_py.paths import resolve_home
from lib_py.verify_session import verify_session_uuid, workspace_key from lib_py.verify_session import verify_session_uuid, workspace_key
# Session-id variables the agent CLIs export to their tool shells (the list
# orc_onboard.sh's auto-detection reads).
AGENT_SESSION_ENV = ("CLAUDE_CODE_SESSION_ID", "ANTIGRAVITY_CONVERSATION_ID",
"HERMES_SESSION_ID", "GROK_SESSION_ID", "OPENCODE_SESSION_ID")
# Process basenames that are agent CLIs (orc_onboard.sh detect_nearest_agent).
AGENT_CLI_NAMES = ("claude", "agy", "hermes", "grok", "opencode", "codex")
def _is_agent_cli(command: str) -> bool:
"""An agent CLI process: some token before the first option has an agent
basename (`claude …`, `node …/claude …`, `/bin/sh …/agy …`) - the same
reading orc_onboard.sh's detect_nearest_agent applies."""
for tok in command.split():
if tok.startswith("-"):
break
if os.path.basename(tok) in AGENT_CLI_NAMES:
return True
return False
def conversation_in_use(uuid: str, paths=()) -> bool:
"""A live process still references the conversation (Issues #10, #13).
A purge must not touch a conversation that is in use, which is any of:
- the caller runs inside that conversation's own agent session: one of its
session-id variables (AGENT_SESSION_ENV) equals the uuid. An orchestrator
purging itself from its own tool shell hits exactly this, and claude
keeps no transcript open, so neither check below would see it;
- a process outside this purge's own helper chain names the uuid on its
command line (`claude -r <uuid>`, `agy --conversation <uuid>`, ...). The
helper chain - this python and the shells/scripts that launched it -
names the uuid too and is excluded, but only up to the nearest agent CLI
ancestor: that ancestor is the session the purge runs in, and if its
command line carries the uuid it is the conversation's live process;
- one of `paths` is held open (lsof, where available).
"""
for var in AGENT_SESSION_ENV:
if os.environ.get(var, "").strip() == uuid:
return True
try:
out = subprocess.run(["ps", "-axo", "pid=,ppid=,command="],
capture_output=True, text=True, timeout=10).stdout
except Exception:
out = ""
parents: Dict[int, int] = {}
commands: Dict[int, str] = {}
holders: List[int] = []
for line in out.splitlines():
parts = line.split(None, 2)
if len(parts) < 2 or not (parts[0].isdigit() and parts[1].isdigit()):
continue
pid, ppid = int(parts[0]), int(parts[1])
parents[pid] = ppid
commands[pid] = parts[2] if len(parts) > 2 else ""
if uuid in commands[pid]:
holders.append(pid)
if holders:
me = os.getpid()
helper_chain = set()
p = me
while p and p not in helper_chain:
if p != me and _is_agent_cli(commands.get(p, "")):
break
helper_chain.add(p)
p = parents.get(p, 0)
if any(h not in helper_chain for h in holders):
return True
existing = [p for p in paths if p and os.path.exists(p)]
if existing and shutil.which("lsof"):
try:
r = subprocess.run(["lsof", "-t"] + existing, capture_output=True, text=True, timeout=10)
if r.stdout.strip():
return True
except Exception:
pass
return False
class SpawnSpec: class SpawnSpec:
def __init__(self, agent_name: str, session_name: str, command: str, env: Optional[Dict[str, str]] = None): def __init__(self, agent_name: str, session_name: str, command: str, env: Optional[Dict[str, str]] = None):
self.agent_name = agent_name self.agent_name = agent_name
@@ -107,6 +186,23 @@ class BaseAgentAdapter:
def purge_artifacts(self, uuid: str, ctx: DiscoveryContext) -> list: def purge_artifacts(self, uuid: str, ctx: DiscoveryContext) -> list:
raise NotImplementedError raise NotImplementedError
def residual_artifacts(self, uuid: str, ctx: DiscoveryContext) -> list:
"""Paths still carrying `uuid` after a purge. Adapters that know their
on-disk tree override this; the default has nothing to scan."""
return []
def has_artifacts(self, uuid: str, ctx: DiscoveryContext) -> bool:
"""Anything of this conversation is on disk (a purge target exists
even when `verify_artifact` would not vouch for it). The default only
trusts an artifact path that embeds the uuid - a shared store (one DB
for every conversation) exists for any uuid and proves nothing."""
path = self.artifact_path(uuid, ctx)
return uuid in path and os.path.exists(path)
def conversation_in_use(self, uuid: str, ctx: DiscoveryContext) -> bool:
"""True while a live process still references the conversation."""
return conversation_in_use(uuid, [self.artifact_path(uuid, ctx)])
def spawn_spec(self, binary: str, session_uuid: str = "", use_wrapper: bool = False) -> str: def spawn_spec(self, binary: str, session_uuid: str = "", use_wrapper: bool = False) -> str:
raise NotImplementedError raise NotImplementedError
@@ -34,9 +34,9 @@ from typing import Any, Dict, List, Optional, Set, Tuple
from lib_py.delegation import mqtt_common from lib_py.delegation import mqtt_common
from lib_py.delegation import registry from lib_py.delegation import registry
from lib_py.delegation.mqtt_common import ( from lib_py.delegation.mqtt_common import (
DEFAULT_REGISTRY_DIR,
SCHEMA_VERSION, SCHEMA_VERSION,
broker_config_from_job, broker_config_from_job,
default_registry_dir,
load_job, load_job,
make_client, make_client,
) )
@@ -427,7 +427,7 @@ def main(argv=None) -> int:
help="expected dispatch token for CAS verification") help="expected dispatch token for CAS verification")
parser.add_argument("--attempt-id", type=int, default=None, parser.add_argument("--attempt-id", type=int, default=None,
help="minimum attempt id to accept events for") help="minimum attempt id to accept events for")
parser.add_argument("--registry-dir", default=DEFAULT_REGISTRY_DIR) parser.add_argument("--registry-dir", default=default_registry_dir())
parser.add_argument("-v", "--verbose", action="store_true") parser.add_argument("-v", "--verbose", action="store_true")
args = parser.parse_args(argv) args = parser.parse_args(argv)
@@ -137,6 +137,31 @@ DEFAULT_REGISTRY_DIR = ".mam/jobs"
DEFAULT_TOPIC_ROOT = "python/mqtt/jobs" DEFAULT_TOPIC_ROOT = "python/mqtt/jobs"
LOCK_FILENAME = ".lock" LOCK_FILENAME = ".lock"
def _workspace_mam_dir(leaf: str) -> Optional[str]:
"""`$WORKSPACE_ROOT/.mam/<leaf>` when lib.sh has told us which workspace we
serve; None otherwise."""
root = os.environ.get("WORKSPACE_ROOT")
if root and root.strip() and os.path.isdir(root):
return os.path.join(root, ".mam", leaf)
return None
def default_registry_dir() -> str:
"""Registry root, resolved at call time (Issue #15).
``MAM_JOBS_DIR`` wins; otherwise the workspace lib.sh exported as
``WORKSPACE_ROOT``; otherwise the historical cwd-relative ``.mam/jobs``.
Anchoring on the workspace is what keeps a test sandbox (or any caller
whose cwd is not the workspace) from registering jobs into whichever
``.mam/jobs`` happens to sit under the current directory - the pytest
suite used to leave hundreds of onboarding records in the real registry.
"""
env = os.environ.get("MAM_JOBS_DIR")
if env and env.strip():
return env
return _workspace_mam_dir("jobs") or DEFAULT_REGISTRY_DIR
# Persistent audit-log layout: .mam/delegate_job_logs/<job_id>/{meta,events,status}. # Persistent audit-log layout: .mam/delegate_job_logs/<job_id>/{meta,events,status}.
# This is a *separate* artifact from the registry: the registry is the live job # This is a *separate* artifact from the registry: the registry is the live job
# record (mutated in place), the audit log is an append-only history that # record (mutated in place), the audit log is an append-only history that
@@ -150,14 +175,16 @@ def get_logs_dir() -> str:
"""Audit-log root, resolved at call time (B-9). """Audit-log root, resolved at call time (B-9).
Overridable with ``DELEGATE_JOB_LOGS_DIR``; otherwise Overridable with ``DELEGATE_JOB_LOGS_DIR``; otherwise
``<cwd>/.mam/delegate_job_logs``. Resolved per call rather than at import ``$WORKSPACE_ROOT/.mam/delegate_job_logs`` (Issue #15, see
so a chdir after import cannot strand the audit trail in the old tree — ``default_registry_dir``); otherwise ``<cwd>/.mam/delegate_job_logs``.
the same reason ``DEFAULT_REGISTRY_DIR`` stays a relative string. Resolved per call rather than at import so a chdir after import cannot
strand the audit trail in the old tree — the same reason
``DEFAULT_REGISTRY_DIR`` stays a relative string.
""" """
env = os.environ.get("DELEGATE_JOB_LOGS_DIR") env = os.environ.get("DELEGATE_JOB_LOGS_DIR")
if env and env.strip(): if env and env.strip():
return env return env
return os.path.join(os.getcwd(), ".mam", "delegate_job_logs") return _workspace_mam_dir("delegate_job_logs") or os.path.join(os.getcwd(), ".mam", "delegate_job_logs")
def __getattr__(name: str): # PEP 562 (3.7+) def __getattr__(name: str): # PEP 562 (3.7+)
@@ -35,6 +35,7 @@ from lib_py.delegation.mqtt_common import (
DEFAULT_REGISTRY_DIR, DEFAULT_REGISTRY_DIR,
SCHEMA_VERSION, SCHEMA_VERSION,
broker_config_from_job, broker_config_from_job,
default_registry_dir,
events_topic_for, events_topic_for,
load_job, load_job,
next_seq, next_seq,
@@ -242,7 +243,7 @@ def main(argv=None) -> int:
parser.add_argument("--data", default=None, help="optional JSON object string") parser.add_argument("--data", default=None, help="optional JSON object string")
parser.add_argument("--retained", action="store_true", parser.add_argument("--retained", action="store_true",
help="force retain=True (auto for completed/error)") help="force retain=True (auto for completed/error)")
parser.add_argument("--registry-dir", default=DEFAULT_REGISTRY_DIR) parser.add_argument("--registry-dir", default=default_registry_dir())
parser.add_argument("--attempts", type=int, default=3) parser.add_argument("--attempts", type=int, default=3)
parser.add_argument("--finalize-report", default=None, metavar="PATH", parser.add_argument("--finalize-report", default=None, metavar="PATH",
help="atomically finalize this report and declare its " help="atomically finalize this report and declare its "
+2 -1
View File
@@ -28,6 +28,7 @@ from lib_py.delegation.mqtt_common import (
_atomic_write_record, _atomic_write_record,
_utcnow, _utcnow,
broker_config_from_env, broker_config_from_env,
default_registry_dir,
load_job, load_job,
registry_lock, registry_lock,
topic_prefix_for, topic_prefix_for,
@@ -266,7 +267,7 @@ def get_feedback(job_id: str, registry_dir: str = DEFAULT_REGISTRY_DIR) -> str:
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
def _build_parser() -> argparse.ArgumentParser: def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="multi-agent-mux-delegate-job registry CLI") parser = argparse.ArgumentParser(description="multi-agent-mux-delegate-job registry CLI")
parser.add_argument("--registry-dir", default=DEFAULT_REGISTRY_DIR) parser.add_argument("--registry-dir", default=default_registry_dir())
sub = parser.add_subparsers(dest="command", required=True) sub = parser.add_subparsers(dest="command", required=True)
p_reg = sub.add_parser("register", help="create a pending job; prints the job id") p_reg = sub.add_parser("register", help="create a pending job; prints the job id")
+1 -1
View File
@@ -18,7 +18,7 @@ logger = logging.getLogger("delegate_job.service")
class DelegationService(object): class DelegationService(object):
def __init__(self, registry_dir=None, persistent=False): def __init__(self, registry_dir=None, persistent=False):
self.registry_dir = registry_dir or mqtt_common.DEFAULT_REGISTRY_DIR self.registry_dir = registry_dir or mqtt_common.default_registry_dir()
self.persistent = persistent self.persistent = persistent
self._publisher = None self._publisher = None
+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.4.2" FRAMEWORK_VERSION = "4.4.3"
CORE_MODULES: Sequence[str] = ( CORE_MODULES: Sequence[str] = (
# Standard library modules # Standard library modules
@@ -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.4.2 version: 4.4.3
author: godopu author: godopu
license: MIT license: MIT
platforms: [linux, macos] platforms: [linux, macos]
@@ -350,7 +350,7 @@ if [ -n "$SUBMIT_JOB_PROMPT" ]; then
local_info=$(python3 -c " local_info=$(python3 -c "
import sys, json, os import sys, json, os
jid = '$DELEGATE_JOB_ID' jid = '$DELEGATE_JOB_ID'
reg_dir = os.environ.get('MAM_JOBS_DIR', '.mam/jobs') reg_dir = os.environ.get('MAM_JOBS_DIR') or (os.path.join(os.environ['WORKSPACE_ROOT'], '.mam', 'jobs') if os.environ.get('WORKSPACE_ROOT') else '.mam/jobs')
p = os.path.join(reg_dir, f'{jid}.json') p = os.path.join(reg_dir, f'{jid}.json')
if os.path.exists(p): if os.path.exists(p):
try: try:
@@ -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.4.2 version: 4.4.3
author: godopu author: godopu
license: MIT license: MIT
platforms: [linux, macos, windows] platforms: [linux, macos, windows]
@@ -109,8 +109,13 @@ Which tree wins, by execution context:
`SKILL_DIR` at the front of the import path. It deliberately does **not** use `SKILL_DIR` at the front of the import path. It deliberately does **not** use
`python -m`: for `-m` the current directory precedes `PYTHONPATH`, so a frozen `python -m`: for `-m` the current directory precedes `PYTHONPATH`, so a frozen
loop whose working directory happened to contain a `lib_py` package would have loop whose working directory happened to contain a `lib_py` package would have
silently executed live code. The working directory is left untouched, because silently executed live code. The working directory is left untouched; the
the registry and audit-log paths are relative to it. registry and audit-log paths resolve to `MAM_JOBS_DIR` / `DELEGATE_JOB_LOGS_DIR`
when set, otherwise to `$WORKSPACE_ROOT/.mam/jobs` and
`$WORKSPACE_ROOT/.mam/delegate_job_logs` (the workspace `lib.sh` exports), and
only without either to the cwd-relative `.mam/...` (Issue #15 — this is what
stops a caller whose cwd is not the workspace, such as the pytest suite, from
registering jobs into a foreign registry).
Setting `MAM_DELEGATION_PY=0` switches the shell helpers back to their preserved Setting `MAM_DELEGATION_PY=0` switches the shell helpers back to their preserved
legacy bodies with no redeploy. legacy bodies with no redeploy.
+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.4.2 version: 4.4.3
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.4.2 version: 4.4.3
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.4.2 version: 4.4.3
author: godopu author: godopu
license: MIT license: MIT
platforms: [linux, macos] platforms: [linux, macos]
@@ -26,10 +26,27 @@ Registers an orchestrator session UUID into the `orchestrator_uuids` list of `.m
### Options ### Options
- `--uuid <uuid>`: Explicitly register the specified orchestrator session UUID. - `--uuid <uuid>`: Explicitly register the specified orchestrator session UUID.
- `--remove <uuid>`: Remove the specified UUID from the `orchestrator_uuids` list. - `--remove <uuid>`: Remove the specified UUID from the `orchestrator_uuids` list.
- `--purge-conversation`: With `--remove`, also delete the conversation's on-disk artifacts through its agent adapter (Issue #13). Irreversible — requires `--yes`; refused (exit `5`, registration untouched) while a live process still references the conversation — including the session the command itself runs in: its session-id variable (`CLAUDE_CODE_SESSION_ID`, `ANTIGRAVITY_CONVERSATION_ID`, `HERMES_SESSION_ID`, `GROK_SESSION_ID`, `OPENCODE_SESSION_ID`) equals the UUID, or the nearest agent CLI ancestor names it on its command line. An orchestrator therefore cannot purge its own conversation from inside that very session. Any live process whose command line contains the UUID counts, not only a resumed session — an editor, a `grep` or a delegation wrapper whose prompt merely mentions it blocks the purge too; if it is refused, find the process with `ps -axww | grep <uuid>`.
- `--yes`: Skip the `--purge-conversation` confirmation (without it the script prints the danger notice and exits `4`).
- `--agent <name>`: Adapter to purge with (`claude|agy|hermes|grok|opencode|codex`). Default `auto`: each adapter is asked whether it owns an artifact for the UUID (`agy` → `~/.gemini/antigravity-cli/conversations/<uuid>.db` etc.). Needed because `orchestrator_uuids` stores bare UUIDs, not the agent kind.
- `--list`: Display all currently registered orchestrator UUIDs. - `--list`: Display all currently registered orchestrator UUIDs.
- `--no-autodetect`: Disable process ancestry auto-detection when `--uuid` is not provided. - `--no-autodetect`: Disable process ancestry auto-detection when `--uuid` is not provided.
- `--dry-run`: Output proposed changes without mutating the state registry. - `--dry-run`: Output proposed changes without mutating the state registry.
### Purging the orchestrator conversation
`multi-agent-mux-stop --purge-conversation` only reaches sessions that have a `herdr_sessions` row; the orchestrator never has one. To wipe an orchestrator conversation for good:
```bash
.agents/skills/multi-agent-mux-orc-onboard/scripts/orc_onboard.sh --remove <uuid> --purge-conversation --yes
# purged: ~/.gemini/antigravity-cli/conversations/<uuid>.db
# purged: ~/.gemini/antigravity-cli/brain/<uuid>
# ...
# residual: none (agent: agy)
```
The purge runs before the registry update, so a refused purge (conversation in use, exit `5`) changes nothing. If no adapter finds an artifact for the UUID the registration is still removed with a warning. For agy the adapter also removes the SQLite sidecars (`-wal`/`-shm`/`-journal`), the title annotation and the presence lock (Issue #10) and reports anything under `~/.gemini/antigravity-cli` that still carries the UUID as `WARN residual:`. Orphaned leftovers — a WAL, annotation or lock whose DB is already gone — still resolve to agy and are purged, and the in-use check asks `lsof` about every one of those paths, so a holder that keeps only the presence lock open (naming the UUID nowhere) is refused too.
## Auto-Detection Hierarchy (Rev.2) ## Auto-Detection Hierarchy (Rev.2)
When `--uuid` is omitted, `orc_onboard.sh` inspects the process ancestry tree of the nearest agent ancestor (`claude`, `agy`, `hermes`, `grok`, `opencode`, `codex`) in the following order: When `--uuid` is omitted, `orc_onboard.sh` inspects the process ancestry tree of the nearest agent ancestor (`claude`, `agy`, `hermes`, `grok`, `opencode`, `codex`) in the following order:
@@ -51,5 +68,7 @@ When `--uuid` is omitted, `orc_onboard.sh` inspects the process ancestry tree of
## Exit Statuses ## Exit Statuses
- `0`: Success (registered, removed, listed, or already present). - `0`: Success (registered, removed, listed, or already present).
- `1`: Conflict — the ID is currently owned by a `running` sub-agent session row. - `1`: Conflict — the ID is currently owned by a `running` sub-agent session row.
- `2`: Invalid arguments or malformed ID format. - `2`: Invalid arguments or malformed ID format (including `--purge-conversation` without `--remove`).
- `3`: Detection failed — ID could not be determined. - `3`: Detection failed — ID could not be determined.
- `4`: `--purge-conversation` needs `--yes` — nothing changed.
- `5`: Purge refused — the conversation is still in use by a live process; nothing changed.
@@ -13,17 +13,26 @@ MODE="add"
TARGET_UUID="" TARGET_UUID=""
AUTODETECT=true AUTODETECT=true
DRY_RUN=false DRY_RUN=false
PURGE=false
YES=false
AGENT_HINT="auto"
usage() { usage() {
cat <<'EOF' cat <<'EOF'
Usage: orc_onboard.sh [OPTIONS] Usage: orc_onboard.sh [OPTIONS]
Options: Options:
--uuid <uuid> Register specified orchestrator session UUID --uuid <uuid> Register specified orchestrator session UUID
--remove <uuid> Remove specified UUID from orchestrator_uuids list --remove <uuid> Remove specified UUID from orchestrator_uuids list
--list List all registered orchestrator UUIDs --purge-conversation With --remove: also delete the conversation's on-disk
--no-autodetect Disable process ancestry auto-detection when --uuid is omitted artifacts through its agent adapter (irreversible;
--dry-run Preview changes without updating state registry refused while a live process still references it)
--yes Skip the --purge-conversation confirmation
--agent <name> Adapter for --purge-conversation (claude|agy|hermes|
grok|opencode|codex); default: auto-detect from disk
--list List all registered orchestrator UUIDs
--no-autodetect Disable process ancestry auto-detection when --uuid is omitted
--dry-run Preview changes without updating state registry
EOF EOF
exit 2 exit 2
} }
@@ -46,6 +55,19 @@ while [ $# -gt 0 ]; do
MODE="list" MODE="list"
shift shift
;; ;;
--purge-conversation)
PURGE=true
shift
;;
--yes)
YES=true
shift
;;
--agent)
if [ $# -lt 2 ] || [ -z "$2" ]; then usage; fi
AGENT_HINT="$2"
shift 2
;;
--no-autodetect) --no-autodetect)
AUTODETECT=false AUTODETECT=false
shift shift
@@ -197,6 +219,11 @@ if ! is_valid_id "$TARGET_UUID"; then
exit 2 exit 2
fi fi
if [ "$PURGE" = true ] && [ "$MODE" != "remove" ]; then
echo "ERROR: --purge-conversation only applies together with --remove <uuid>." >&2
exit 2
fi
if [ "$MODE" = "add" ]; then if [ "$MODE" = "add" ]; then
# Check if running sub-agent session row currently owns this UUID # Check if running sub-agent session row currently owns this UUID
check_owner_rc=0 check_owner_rc=0
@@ -222,10 +249,45 @@ sys.exit(0)
fi fi
if [ "$DRY_RUN" = true ]; then if [ "$DRY_RUN" = true ]; then
echo "[dry-run] would $MODE orchestrator uuid: $TARGET_UUID" if [ "$PURGE" = true ]; then
echo "[dry-run] would remove orchestrator uuid: $TARGET_UUID and purge its conversation artifacts (agent: $AGENT_HINT)"
else
echo "[dry-run] would $MODE orchestrator uuid: $TARGET_UUID"
fi
exit 0 exit 0
fi fi
# Issue #13: the orchestrator has no herdr_sessions row, so stop_session.sh
# --purge-conversation can never reach its conversation. Purge FIRST, through
# the adapter (`lib_py.agents purge`): a refusal because the conversation is
# still in use leaves the registration untouched.
PURGE_JSON=""
if [ "$PURGE" = true ]; then
if [ "$YES" != true ]; then
echo "DANGER: --purge-conversation will DELETE orchestrator conversation $TARGET_UUID from disk." >&2
echo " This cannot be undone: no future resume of that conversation." >&2
echo " Re-run with --yes to confirm." >&2
exit 4
fi
purge_rc=0
PURGE_JSON="$("$(_delegate_py_bin)" -m lib_py.agents purge "$AGENT_HINT" "$TARGET_UUID" "$WORKSPACE_ROOT")" || purge_rc=$?
case "$purge_rc" in
0) ;;
4)
echo "ERROR: conversation $TARGET_UUID is still in use (a live process references it); nothing purged, registration unchanged." >&2
exit 5
;;
5)
echo "WARN: no on-disk conversation found for $TARGET_UUID (agent: $AGENT_HINT); nothing to purge." >&2
PURGE_JSON=""
;;
*)
echo "ERROR: purge failed (rc=$purge_rc): $PURGE_JSON" >&2
exit 1
;;
esac
fi
MAM_TARGET_UUID="$TARGET_UUID" MAM_OP_MODE="$MODE" atomic_dump_yaml "$AGENT_SESSIONS_YAML" <<'PYEOF' MAM_TARGET_UUID="$TARGET_UUID" MAM_OP_MODE="$MODE" atomic_dump_yaml "$AGENT_SESSIONS_YAML" <<'PYEOF'
import os, sys, json import os, sys, json
@@ -243,4 +305,23 @@ elif op == "remove":
d["orchestrator_uuids"] = uuids d["orchestrator_uuids"] = uuids
PYEOF PYEOF
echo "Successfully ${MODE}ed orchestrator UUID: $TARGET_UUID" case "$MODE" in
add) verb="added" ;;
remove) verb="removed" ;;
*) verb="${MODE}ed" ;;
esac
echo "Successfully $verb orchestrator UUID: $TARGET_UUID"
if [ -n "$PURGE_JSON" ]; then
printf '%s' "$PURGE_JSON" | python3 -c '
import json, sys
d = json.loads(sys.stdin.read() or "{}")
for p in d.get("purged", []):
print("purged: %s" % p)
residual = d.get("residual", [])
for p in residual:
print("WARN residual: %s" % p)
if not residual:
print("residual: none (agent: %s)" % d.get("agent"))
'
fi
@@ -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.4.2 version: 4.4.3
author: godopu author: godopu
license: MIT license: MIT
platforms: [linux, macos] platforms: [linux, macos]
@@ -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.4.2 version: 4.4.3
author: godopu author: godopu
license: MIT license: MIT
platforms: [linux, macos] platforms: [linux, macos]
+2 -2
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.4.2 version: 4.4.3
author: godopu author: godopu
license: MIT license: MIT
platforms: [linux, macos] platforms: [linux, macos]
@@ -105,7 +105,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 `~/.claude/projects/.../jsonl` (claude) or `~/.gemini/antigravity-cli/conversations/...db` + `brain/...` (agy) 5. For `purge-conversation`: deletes `~/.claude/projects/.../jsonl` (claude) 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
@@ -148,7 +148,7 @@ if [ -n "$DELEGATE_JOB_ID" ]; then
local_info=$(python3 -c " local_info=$(python3 -c "
import sys, json, os import sys, json, os
jid = '$DELEGATE_JOB_ID' jid = '$DELEGATE_JOB_ID'
reg_dir = os.environ.get('MAM_JOBS_DIR', '.mam/jobs') reg_dir = os.environ.get('MAM_JOBS_DIR') or (os.path.join(os.environ['WORKSPACE_ROOT'], '.mam', 'jobs') if os.environ.get('WORKSPACE_ROOT') else '.mam/jobs')
p = os.path.join(reg_dir, f'{jid}.json') p = os.path.join(reg_dir, f'{jid}.json')
if os.path.exists(p): if os.path.exists(p):
try: try:
+1 -1
View File
@@ -2,7 +2,7 @@
본 문서는 `herdr_agent_orchestration` 오케스트레이션 및 메시징 백플레인 워크플로우를 새로운 프로젝트에 도입하여 이식하고, 새로운 개발자/에이전트가 초기 가동을 시작할 때 수행해야 하는 환경 설정 및 구축 절차를 안내합니다. 본 문서는 `herdr_agent_orchestration` 오케스트레이션 및 메시징 백플레인 워크플로우를 새로운 프로젝트에 도입하여 이식하고, 새로운 개발자/에이전트가 초기 가동을 시작할 때 수행해야 하는 환경 설정 및 구축 절차를 안내합니다.
[![Version: v4.4.2](https://img.shields.io/badge/version-v4.4.2-blue.svg)](VERSIONS.md) [![Version: v4.4.3](https://img.shields.io/badge/version-v4.4.3-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.4.2](https://img.shields.io/badge/version-v4.4.2-blue.svg)](VERSIONS.md) [![Version: v4.4.3](https://img.shields.io/badge/version-v4.4.3-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.4.2 Applies to: MAM v4.4.3
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와 MQTT 브로커를 기반으로 구축된 고신뢰성 **다중 에이전트 오케스트레이션 및 메시징 백플레인** 프레임워크입니다. `claude`, `agy`, `hermes`, `grok`, `opencode`, `codex` 등 다중 LLM 백엔드 에이전트 전반에 걸쳐 장시간 수행되는 작업(코드 생성, 리팩토링, 보안 검토 등)을 조정, 격리 및 감사할 수 있도록 설계되었습니다. Herdr와 MQTT 브로커를 기반으로 구축된 고신뢰성 **다중 에이전트 오케스트레이션 및 메시징 백플레인** 프레임워크입니다. `claude`, `agy`, `hermes`, `grok`, `opencode`, `codex` 등 다중 LLM 백엔드 에이전트 전반에 걸쳐 장시간 수행되는 작업(코드 생성, 리팩토링, 보안 검토 등)을 조정, 격리 및 감사할 수 있도록 설계되었습니다.
[![Version: v4.4.2](https://img.shields.io/badge/version-v4.4.2-blue.svg)](VERSIONS.md) [![Version: v4.4.3](https://img.shields.io/badge/version-v4.4.3-blue.svg)](VERSIONS.md)
--- ---
+1 -1
View File
@@ -2,7 +2,7 @@
An advanced, high-reliability **Multi-Agent Orchestration & Messaging Backplane** framework built on Herdr and MQTT. It is designed to coordinate, isolate, and audit long-running agent tasks (such as code generation, refactoring, and security reviews) across multiple LLM backend clients (`claude`, `agy`, `hermes`, `grok`, `opencode`, `codex`). An advanced, high-reliability **Multi-Agent Orchestration & Messaging Backplane** framework built on Herdr and MQTT. It is designed to coordinate, isolate, and audit long-running agent tasks (such as code generation, refactoring, and security reviews) across multiple LLM backend clients (`claude`, `agy`, `hermes`, `grok`, `opencode`, `codex`).
[![Version: v4.4.2](https://img.shields.io/badge/version-v4.4.2-blue.svg)](VERSIONS.md) [![Version: v4.4.3](https://img.shields.io/badge/version-v4.4.3-blue.svg)](VERSIONS.md)
--- ---
+27 -11
View File
@@ -6,8 +6,8 @@
## 📌 현재 버전 개요 (Current Release) ## 📌 현재 버전 개요 (Current Release)
- **프레임워크 버전**: `v4.4.2` - **프레임워크 버전**: `v4.4.3`
- **최신 릴리스 일시**: 2026-09-12 (KST) - **최신 릴리스 일시**: 2026-09-13 (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 조기 종료 도입.
@@ -20,23 +20,39 @@
## 🧭 스킬 패키지 버전 매트릭스 (Skills Version Matrix) ## 🧭 스킬 패키지 버전 매트릭스 (Skills Version Matrix)
모든 8개 스킬은 YAML frontmatter 메타데이터(`author`, `version`, `platforms`, `environments`) 표준화를 통해 `v4.4.2`로 동기화되어 배포됩니다. 모든 8개 스킬은 YAML frontmatter 메타데이터(`author`, `version`, `platforms`, `environments`) 표준화를 통해 `v4.4.3`로 동기화되어 배포됩니다.
| 스킬명 | 버전 | 역할 및 주요 책임 | 상태 | | 스킬명 | 버전 | 역할 및 주요 책임 | 상태 |
| :--- | :---: | :--- | :---: | | :--- | :---: | :--- | :---: |
| **`multi-agent-mux-create`** | `4.4.2` | 에이전트 세션 신규 생성 및 Herdr 컨테이너 격리 스폰 | ✅ 배포 | | **`multi-agent-mux-create`** | `4.4.3` | 에이전트 세션 신규 생성 및 Herdr 컨테이너 격리 스폰 | ✅ 배포 |
| **`multi-agent-mux-stop`** | `4.4.2` | 대화 UUID 원자적 캡처 및 세션 안전 종료 (Graceful Stop) | ✅ 배포 | | **`multi-agent-mux-stop`** | `4.4.3` | 대화 UUID 원자적 캡처 및 세션 안전 종료 (Graceful Stop) | ✅ 배포 |
| **`multi-agent-mux-resume`** | `4.4.2` | 온디스크 대화 컨텍스트 기반 Tier-1 초고속 세션 복원 | ✅ 배포 | | **`multi-agent-mux-resume`** | `4.4.3` | 온디스크 대화 컨텍스트 기반 Tier-1 초고속 세션 복원 | ✅ 배포 |
| **`multi-agent-mux-status`** | `4.4.2` | 실시간 Herdr 세션 및 레지스트리 드리프트 스냅샷 조회 | ✅ 배포 | | **`multi-agent-mux-status`** | `4.4.3` | 실시간 Herdr 세션 및 레지스트리 드리프트 스냅샷 조회 | ✅ 배포 |
| **`multi-agent-mux-monitor`** | `4.4.2` | YAML ↔ 런타임 상태 간 자율 조정자 (Reconciler Loop) | ✅ 배포 | | **`multi-agent-mux-monitor`** | `4.4.3` | YAML ↔ 런타임 상태 간 자율 조정자 (Reconciler Loop) | ✅ 배포 |
| **`multi-agent-mux-delegate-job`** | `4.4.2` | MQTT 이벤트 채널 기반 비동기 단위 작업 위임 | ✅ 배포 | | **`multi-agent-mux-delegate-job`** | `4.4.3` | MQTT 이벤트 채널 기반 비동기 단위 작업 위임 | ✅ 배포 |
| **`multi-agent-mux-loop`** | `4.4.2` | Planner-Creator-Reviewer 3자 자율 계획·실행·피어리뷰 루프 | ✅ 배포 | | **`multi-agent-mux-loop`** | `4.4.3` | Planner-Creator-Reviewer 3자 자율 계획·실행·피어리뷰 루프 | ✅ 배포 |
| **`multi-agent-mux-orc-onboard`** | `4.4.2` | 오케스트레이터 UUID 격리 등록 및 서브 세션 오염 방지 | ✅ 배포 | | **`multi-agent-mux-orc-onboard`** | `4.4.3` | 오케스트레이터 UUID 격리 등록 및 서브 세션 오염 방지 | ✅ 배포 |
--- ---
## 📋 버전별 상세 변경 내역 (Changelog) ## 📋 버전별 상세 변경 내역 (Changelog)
### 🚀 `v4.4.3` — Issue #10·#13 대화 purge 완결, Issue #11 메시지 정정, Issue #15 레지스트리 워크스페이스 고정 (2026-09-13)
> **패치 마일스톤 (PATCH Release)**: agy 대화 purge 가 resume 가능한 부속 파일을 남기던 문제(Issue #10), herdr 세션 행이 없는 오케스트레이터 대화를 어떤 스킬로도 지울 수 없던 문제(Issue #13), `orc_onboard.sh --remove` 의 "removeed" 오타(Issue #11), 호출자 cwd 를 따라가던 잡 레지스트리·감사 로그 때문에 pytest 스위트가 실제 `.mam/jobs` 에 테스트 레코드를 남기던 문제(Issue #15)를 해결했다. 새 서브커맨드·옵션은 모두 추가 기능이다. 레지스트리·감사 로그의 **기본** 경로는 `WORKSPACE_ROOT` 가 있으면 cwd 대신 그 워크스페이스를 따르며, 명시적 `--registry-dir` 와 `MAM_JOBS_DIR`/`DELEGATE_JOB_LOGS_DIR` 는 그대로 우선한다.
#### 🌟 주요 개선 사항
1. **agy purge 부속 파일 완결 (Issue #10, `lib_py/agents/adapters/agy.py`)**: `purge_artifacts` 가 대화 DB·`brain/<uuid>` 에 더해 SQLite 부속 파일(`-wal`/`-shm`/`-journal` — DB 를 열 때마다 생기고 stop 경로의 ID 캡처도 이를 만들며, 체크포인트되지 않은 WAL 에는 대화 내용이 남을 수 있다), 제목 annotation(`annotations/<uuid>.pbtxt`), presence lock(`presence/<uuid>.lock`)까지 지운다. presence lock 은 `conversation_in_use` 가 거짓일 때만 지우고, 사용 중이면 남긴 채 경고한다. agy 의 `conversation_in_use` 는 DB 만이 아니라 부속 파일·annotation·presence lock 전부를 `lsof` 점유 검사에 넘기므로, uuid 를 어디에도 적지 않은 채 lock 만 열어 둔 보유자도 잡는다. DB 가 이미 사라지고 WAL·annotation·lock 만 남은 고아 상태도 `has_artifacts` 가 인식해 purge 대상이 된다. 새 `residual_artifacts()` 는 `~/.gemini/antigravity-cli` 아래에 그 uuid 를 이름에 가진 경로가 남아 있으면 보고한다. db+brain 만 있는 경우 2건을 돌려주는 어댑터 계약은 유지.
2. **오케스트레이터 대화 purge CLI (Issue #13, `python -m lib_py.agents purge <agent|auto> <uuid> [workspace]`)**: `orchestrator_uuids` 는 uuid 만 저장하므로 디스크에서 어댑터를 판별한다. `auto` 는 `agy → claude → hermes → grok → codex → opencode` 순으로(opencode 는 모든 uuid 에 대해 존재하는 공유 DB 라 마지막) 각 어댑터의 `verify_artifact`(revalidate 모드 — 오케스트레이터 대화는 워크스페이스 캐시에 없음)를 먼저, 그다음 uuid 가 들어간 artifact 경로의 존재를 확인한다. 살아 있는 프로세스가 그 uuid 를 참조하면 거부한다(`base.conversation_in_use`: ① 호출 환경의 세션 id — `CLAUDE_CODE_SESSION_ID`·`ANTIGRAVITY_CONVERSATION_ID`·`HERMES_SESSION_ID`·`GROK_SESSION_ID`·`OPENCODE_SESSION_ID` — 가 그 uuid 이면 사용 중, 즉 오케스트레이터가 자기 세션 안에서 자기 대화를 purge 하는 경우를 막는다(claude 는 transcript 를 열어 두지 않아 아래 두 검사로는 보이지 않는다). ② 프로세스 명령줄 검사에서 purge 자신의 헬퍼 사슬(python·셸·스크립트)만 빼되 **가장 가까운 에이전트 CLI 조상에서 멈추고**, 그 조상의 명령줄에 uuid 가 있으면 보유자로 센다. ③ `lsof` 로 artifact 점유 확인 — 어댑터가 넘긴 모든 경로(agy 는 DB·부속 파일·annotation·presence lock)). 결과는 JSON 한 줄(`agent`·`purged`·`residual`·`in_use`)이며 종료 코드는 0 purge 완료 / 4 사용 중이라 거부(아무것도 삭제하지 않음) / 5 해당 uuid 의 artifact 없음.
3. **`orc_onboard.sh --remove <uuid> --purge-conversation --yes [--agent <name>]` (Issue #13)**: 위 purge 를 **레지스트리 갱신 전에** 실행하므로 거부되면 등록이 바뀌지 않는다. `--yes` 가 없으면 경고 후 exit 4, 대화가 사용 중이면 exit 5(등록 유지), 디스크에 대화가 없으면 경고 후 등록만 해제, `--dry-run` 은 예정 작업만 출력, `--purge-conversation` 은 `--remove` 와 함께일 때만 허용(exit 2). 성공 시 purge 된 경로와 잔여(residual) 검사 결과를 출력한다.
4. **`orc_onboard.sh` 성공 메시지 과거형 (Issue #11)**: `add`/`remove` 를 `added`/`removed` 로 명시 활용해 "Successfully removeed" 를 없앴다.
5. **잡 레지스트리·감사 로그의 워크스페이스 고정 (Issue #15, `mqtt_common.default_registry_dir`·`get_logs_dir`)**: 기본 경로가 `MAM_JOBS_DIR`/`DELEGATE_JOB_LOGS_DIR` → `$WORKSPACE_ROOT/.mam/jobs`·`$WORKSPACE_ROOT/.mam/delegate_job_logs` → cwd 기준 `.mam/…` 순으로 정해진다. `registry.py`·`publish_event.py`·`service.py` 와 `create_session.sh`·`stop_session.sh` 가 같은 순서를 쓴다. 저장소 루트에서 돈 pytest 스위트(기본 온보딩 잡을 등록하는 `create_session.sh`)가 호출자 cwd 를 따라 실제 `.mam/jobs` 에 테스트 레코드 593건을 남기던 문제를 해소했고, 샌드박스 픽스처(`tests/conftest.py`)는 두 환경변수를 모두 고정한다.
#### ✅ 품질 검증
- **신규 테스트 24개**: `tests/test_issue10_13_purge.py` 17개(agy 부속 파일·annotation·lock 삭제, 사용 중 lock 보존, 존재한 것만 보고, 자기 계보를 뺀 in-use 판정, 비-에이전트 헬퍼 사슬만 제외하고 에이전트 CLI 조상은 검사, purge CLI 의 auto 판별·사용 중 거부·자기 에이전트 세션 안에서 거부·세션 id 환경변수 5종으로 거부·미해결 uuid, `orc_onboard` purge 의 `--yes` 요구·삭제 후 등록 해제·사용 중 거부·오케스트레이터 자기 세션에서 거부·artifact 없음에도 등록 해제·`--remove` 필수·dry-run 무변경), `tests/test_issue15_registry_isolation.py` 7개(`MAM_JOBS_DIR` 우선, `WORKSPACE_ROOT` 추종, cwd 폴백, 로그 경로 동일 순서, CLI 기본값의 프로세스별 해석, 명시 env 로 온보딩 잡 샌드박스 유지, `WORKSPACE_ROOT` 만으로도 샌드박스 유지).
- **기존 테스트 조정**: O-13(`tests/test_orc_onboard.py`)이 정정된 메시지를 단정하고, R1 헬퍼·B-9 로그 경로 테스트와 `publish_event` 스냅샷(`tests/test_issue5_phase1_delegation.py`·`tests/test_tier1_unit.py`)이 워크스페이스 고정 계약에 맞게 조정됨.
- **실측 (커밋 `c82ea75`·위임 브리프 기록)**: 전체 스위트 839/839 통과, 스위트 실행 전후 실제 `.mam/jobs` 레코드 881건 불변.
### 🚀 `v4.4.2` — Issue #9 herdr 서버 환경 격리 및 Issue #14 프롬프트 전달 확인 보강 (2026-09-12) ### 🚀 `v4.4.2` — Issue #9 herdr 서버 환경 격리 및 Issue #14 프롬프트 전달 확인 보강 (2026-09-12)
> **패치 마일스톤 (PATCH Release)**: 에이전트 런타임(예: Claude Code 오케스트레이터) 안에서 herdr 서버가 처음 기동될 때 세션 식별 마커가 서버와 이후 모든 에이전트 pane 에 상속되던 문제(Issue #9)와, `working` 을 보고하지 않는 에이전트(herdr 0.9.0 의 codex)에게 정상 전달된 프롬프트를 `send_keys_safe` 가 실패로 오판해 잡을 `agent_blocked` 로 닫고 에이전트의 `started`/`completed` 를 거부하던 문제(Issue #14)를 해결했다. 신규 환경변수는 모두 기본값이 있는 추가 설정이며 공개 계약·종료 코드 변경은 없다. > **패치 마일스톤 (PATCH Release)**: 에이전트 런타임(예: Claude Code 오케스트레이터) 안에서 herdr 서버가 처음 기동될 때 세션 식별 마커가 서버와 이후 모든 에이전트 pane 에 상속되던 문제(Issue #9)와, `working` 을 보고하지 않는 에이전트(herdr 0.9.0 의 codex)에게 정상 전달된 프롬프트를 `send_keys_safe` 가 실패로 오판해 잡을 `agent_blocked` 로 닫고 에이전트의 `started`/`completed` 를 거부하던 문제(Issue #14)를 해결했다. 신규 환경변수는 모두 기본값이 있는 추가 설정이며 공개 계약·종료 코드 변경은 없다.
+6
View File
@@ -42,6 +42,12 @@ def mam_sandbox(tmp_path, monkeypatch):
monkeypatch.setenv("LOCAL_BIN", str(tmp_path / ".local" / "bin")) monkeypatch.setenv("LOCAL_BIN", str(tmp_path / ".local" / "bin"))
monkeypatch.setenv("AGENT_SESSIONS_YAML", str(yaml_path)) monkeypatch.setenv("AGENT_SESSIONS_YAML", str(yaml_path))
monkeypatch.setenv("WORKSPACE_ROOT", str(tmp_path)) monkeypatch.setenv("WORKSPACE_ROOT", str(tmp_path))
# Issue #15: the job registry and its audit log must land in the sandbox
# too, whatever the cwd of the script under test - not in the repo's own
# .mam/jobs. Explicit here even though the defaults now follow
# WORKSPACE_ROOT, so a test that unsets WORKSPACE_ROOT still stays inside.
monkeypatch.setenv("MAM_JOBS_DIR", str(mam_dir / "jobs"))
monkeypatch.setenv("DELEGATE_JOB_LOGS_DIR", str(mam_dir / "delegate_job_logs"))
# D6 (issue #5 phase 1): point the delegation env loader at a file that does not # D6 (issue #5 phase 1): point the delegation env loader at a file that does not
# exist, so importing or running delegation code inside a sandbox never injects the # exist, so importing or running delegation code inside a sandbox never injects the
# real .mam.env into this process's os.environ. # real .mam.env into this process's os.environ.
+433
View File
@@ -0,0 +1,433 @@
#!/usr/bin/env python3
"""Issues #10 and #13 — purging a conversation must leave nothing resumable
behind, and an orchestrator conversation (no herdr_sessions row) must be
purgeable through orc_onboard.sh.
#10: the agy adapter deleted only the conversation DB and the brain dir; the
SQLite sidecars (-wal/-shm/-journal), the title annotation and the presence
lock survived. The lock is removed only once nothing references the
conversation any more.
#13: `orc_onboard.sh --remove <uuid> --purge-conversation --yes` resolves the
adapter from what is on disk (orchestrator_uuids stores bare UUIDs), purges
through it, and refuses while a live process still names the UUID -
including the orchestrator's own session running the command.
Every test gets fresh conversation ids (the `conv` / `other` fixtures):
conversation_in_use scans every process on the host, so a fixed id in this
file is named by anything that carries the file's text on its command line.
"""
from __future__ import annotations
import json
import os
import sqlite3
import subprocess
import sys
import time
import uuid as uuidlib
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parent.parent
SKILLS = REPO_ROOT / ".agents" / "skills"
ONBOARD_SH = SKILLS / "multi-agent-mux-orc-onboard" / "scripts" / "orc_onboard.sh"
sys.path.insert(0, str(SKILLS))
from lib_py.agents.registry import get_adapter # noqa: E402
from lib_py.agents.base import DiscoveryContext, conversation_in_use # noqa: E402
# The session-id variables an agent CLI exports to its tool shells. Scrubbed
# from every subprocess env below so the caller's own session never leaks in.
SESSION_ENV = ("CLAUDE_CODE_SESSION_ID", "ANTIGRAVITY_CONVERSATION_ID",
"HERMES_SESSION_ID", "GROK_SESSION_ID", "OPENCODE_SESSION_ID")
def _processes_naming(ident: str) -> list:
out = subprocess.run(["ps", "-axo", "pid=,command="], capture_output=True, text=True, timeout=10).stdout
return [line.strip()[:120] for line in out.splitlines() if ident in line]
def _fresh_id() -> str:
"""A conversation id no other process can already be naming. With fixed
ids, the delegation wrapper of a review job - whose --prompt carried this
file's diff - named them, and five tests read "in use" during review."""
ident = str(uuidlib.uuid4())
named = _processes_naming(ident)
assert not named, f"fresh id {ident} is already named by a live process: {named}"
return ident
@pytest.fixture
def conv() -> str:
return _fresh_id()
@pytest.fixture
def other() -> str:
return _fresh_id()
def _clean_env(extra=None) -> dict:
env = dict(os.environ)
for k in SESSION_ENV + ("MAM_ORCHESTRATOR_UUIDS",):
env.pop(k, None)
env["PYTHONPATH"] = f"{SKILLS}:{env.get('PYTHONPATH', '')}"
if extra:
env.update(extra)
return env
def _agy_tree(home: Path, uuid: str) -> dict:
root = home / ".gemini" / "antigravity-cli"
paths = {
"db": root / "conversations" / f"{uuid}.db",
"wal": root / "conversations" / f"{uuid}.db-wal",
"shm": root / "conversations" / f"{uuid}.db-shm",
"brain": root / "brain" / uuid,
"annotation": root / "annotations" / f"{uuid}.pbtxt",
"lock": root / "presence" / f"{uuid}.lock",
}
for key, p in paths.items():
if key == "brain":
p.mkdir(parents=True, exist_ok=True)
(p / "note.txt").write_text("brain")
elif key == "db":
# A real conversation DB: the adapter's verify_artifact opens it
# with sqlite3, and sqlite discards a garbage -wal next to a
# garbage main file, which would hide the sidecar from the purge.
p.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(p)
conn.execute("CREATE TABLE steps (id INTEGER)")
conn.execute("INSERT INTO steps VALUES (1)")
conn.commit()
conn.close()
else:
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text("x" if key != "lock" else "")
return paths
def _holder(uuid: str) -> subprocess.Popen:
"""A live process whose command line names the conversation, the way a
resumed `agy --conversation <uuid>` or `claude -r <uuid>` does. (Not
`sh -c 'sleep …'`: a shell execs its last simple command and the uuid
would vanish from the process list.)"""
proc = subprocess.Popen([sys.executable, "-c", "import sys, time; time.sleep(60)", "--conversation", uuid])
time.sleep(0.3)
return proc
def _stop(proc: subprocess.Popen) -> None:
proc.kill()
proc.wait(timeout=10)
def _fake_agent_cli(tmp_path: Path, name: str) -> Path:
"""An executable named like an agent CLI. `<name> <flag> <uuid> -- cmd…`
runs cmd as its child (not exec: this process stays in the tree with the
uuid on its command line) - the agent session a skill runs under."""
bindir = tmp_path / "agent-bin"
bindir.mkdir(exist_ok=True)
cli = bindir / name
cli.write_text('#!/bin/sh\nshift 3\n"$@"\nexit $?\n')
cli.chmod(0o755)
return cli
def _run_onboard(args, sandbox: Path, extra_env=None) -> subprocess.CompletedProcess:
env = _clean_env()
env.update({
"AGENT_SESSIONS_YAML": str(sandbox / ".mam" / "agent-sessions.yaml"),
"YAML_PATH": str(sandbox / ".mam" / "agent-sessions.yaml"),
"HOME_DIR": str(sandbox),
"AGENT_PYTHON_BIN": sys.executable,
"DELEGATE_JOB_PYTHON": sys.executable,
})
if extra_env:
env.update(extra_env)
return subprocess.run(["bash", str(ONBOARD_SH)] + list(args), capture_output=True, text=True,
env=env, cwd=str(sandbox), timeout=120)
def _registered(sandbox: Path, uuid: str) -> bool:
return uuid in _run_onboard(["--list"], sandbox).stdout
# ---------------------------------------------------------------------------
# #10 — agy adapter
# ---------------------------------------------------------------------------
def test_agy_purge_removes_sidecars_annotation_and_lock(tmp_path, conv):
paths = _agy_tree(tmp_path, conv)
adapter = get_adapter("agy")
ctx = DiscoveryContext(workspace=str(tmp_path), agent_name="agy", home_dir=str(tmp_path))
purged = adapter.purge_artifacts(conv, ctx)
assert len(purged) == 6, purged
for key, p in paths.items():
assert not p.exists(), f"{key} survived the purge: {p}"
assert adapter.residual_artifacts(conv, ctx) == []
def test_agy_purge_keeps_presence_lock_while_in_use(tmp_path, conv):
paths = _agy_tree(tmp_path, conv)
adapter = get_adapter("agy")
ctx = DiscoveryContext(workspace=str(tmp_path), agent_name="agy", home_dir=str(tmp_path))
holder = _holder(conv)
try:
purged = adapter.purge_artifacts(conv, ctx)
finally:
_stop(holder)
assert paths["lock"].exists(), "presence lock must survive while a process references the conversation"
assert str(paths["lock"]) not in purged
for key in ("db", "wal", "shm", "brain", "annotation"):
assert not paths[key].exists(), key
assert adapter.residual_artifacts(conv, ctx) == [str(paths["lock"])]
def test_agy_purge_reports_only_what_existed(tmp_path, conv):
"""The historical 2-item contract (db + brain) still holds when no sidecar
exists (test_a4_adapter_contract pins len == 2)."""
root = tmp_path / ".gemini" / "antigravity-cli"
(root / "conversations").mkdir(parents=True)
(root / "conversations" / f"{conv}.db").write_text("db")
(root / "brain" / conv).mkdir(parents=True)
adapter = get_adapter("agy")
ctx = DiscoveryContext(workspace=str(tmp_path), agent_name="agy", home_dir=str(tmp_path))
assert len(adapter.purge_artifacts(conv, ctx)) == 2
def test_conversation_in_use_ignores_own_lineage(other):
"""The purge command names the uuid on its own command line (and its
parent shell does); only *other* processes count as holders."""
assert conversation_in_use(other) is False
holder = _holder(other)
try:
assert conversation_in_use(other) is True
finally:
_stop(holder)
assert conversation_in_use(other) is False
def test_conversation_in_use_excludes_only_the_non_agent_helper_chain(tmp_path, other):
"""The purge's own helper chain names the uuid too - the python argv and
the script that launched it - and must not count. The exclusion stops at
the nearest agent CLI ancestor, though: that process is the session the
purge runs in, and when it names the uuid the conversation is live."""
probe = ("import sys; sys.path.insert(0, %r); "
"from lib_py.agents.base import conversation_in_use; "
"print(conversation_in_use(sys.argv[1]))") % str(SKILLS)
helper = tmp_path / "helper.sh"
helper.write_text('#!/bin/sh\n"$@"\nexit $?\n')
helper.chmod(0o755)
alone = subprocess.run([str(helper), sys.executable, "-c", probe, other],
capture_output=True, text=True, env=_clean_env(), timeout=60)
assert alone.stdout.strip() == "False", alone.stdout + alone.stderr
cli = _fake_agent_cli(tmp_path, "agy")
under_agent = subprocess.run([str(cli), "--conversation", other, "--",
str(helper), sys.executable, "-c", probe, other],
capture_output=True, text=True, env=_clean_env(), timeout=60)
assert under_agent.stdout.strip() == "True", under_agent.stdout + under_agent.stderr
# ---------------------------------------------------------------------------
# #13 — lib_py.agents purge CLI
# ---------------------------------------------------------------------------
def _purge_cli(agent: str, uuid: str, home: Path, extra_env=None, prefix=()) -> subprocess.CompletedProcess:
env = _clean_env(extra_env)
env["HOME_DIR"] = str(home)
cmd = list(prefix) + [sys.executable, "-m", "lib_py.agents", "purge", agent, uuid, str(home)]
return subprocess.run(cmd, capture_output=True, text=True, env=env, cwd=str(home), timeout=60)
def test_purge_cli_auto_resolves_agy_from_disk(tmp_path, conv):
paths = _agy_tree(tmp_path, conv)
res = _purge_cli("auto", conv, tmp_path)
assert res.returncode == 0, res.stderr
out = json.loads(res.stdout)
assert out["agent"] == "agy"
assert len(out["purged"]) == 6 and out["residual"] == [] and out["in_use"] is False
assert not any(p.exists() for p in paths.values())
def test_purge_cli_refuses_while_in_use(tmp_path, conv):
paths = _agy_tree(tmp_path, conv)
holder = _holder(conv)
try:
res = _purge_cli("agy", conv, tmp_path)
finally:
_stop(holder)
assert res.returncode == 4, res.stdout + res.stderr
assert json.loads(res.stdout)["in_use"] is True
assert all(p.exists() for p in paths.values()), "a refused purge must remove nothing"
def test_purge_cli_refuses_inside_the_agent_session_that_owns_the_uuid(tmp_path, conv):
"""An orchestrator purging its own conversation from its own session: the
purge runs as a descendant of the agent CLI whose command line names the
uuid. That ancestor is the conversation's live process, not part of the
purge's helper chain, so the purge must be refused and delete nothing."""
paths = _agy_tree(tmp_path, conv)
cli = _fake_agent_cli(tmp_path, "claude")
res = _purge_cli("agy", conv, tmp_path, prefix=(str(cli), "--session-id", conv, "--"))
assert res.returncode == 4, res.stdout + res.stderr
assert json.loads(res.stdout)["in_use"] is True
assert all(p.exists() for p in paths.values()), "a refused purge must remove nothing"
@pytest.mark.parametrize("var", SESSION_ENV)
def test_purge_cli_refuses_from_the_sessions_own_shell(tmp_path, var, conv):
"""The session a tool shell belongs to is in its environment even when no
process carries the uuid in argv and nothing holds the artifact open
(claude keeps no transcript open): a purge run from there is refused."""
paths = _agy_tree(tmp_path, conv)
res = _purge_cli("agy", conv, tmp_path, extra_env={var: conv})
assert res.returncode == 4, res.stdout + res.stderr
assert json.loads(res.stdout)["in_use"] is True
assert all(p.exists() for p in paths.values()), "a refused purge must remove nothing"
def test_purge_cli_unresolved_uuid(tmp_path, other):
res = _purge_cli("auto", other, tmp_path)
assert res.returncode == 5, res.stdout + res.stderr
assert json.loads(res.stdout)["agent"] is None
def _lock_holder(lock: Path) -> subprocess.Popen:
"""A process that holds the presence lock open (flock) but names the
conversation nowhere - not on its command line (the path travels through
the environment) and not in a session variable."""
code = ("import fcntl, os, time; f = open(os.environ['MAM_TEST_LOCK'], 'a'); "
"fcntl.flock(f, fcntl.LOCK_EX); time.sleep(60)")
proc = subprocess.Popen([sys.executable, "-c", code], env=dict(os.environ, MAM_TEST_LOCK=str(lock)))
time.sleep(0.5)
return proc
def test_purge_cli_refuses_when_only_the_presence_lock_is_held_open(tmp_path, conv):
"""codex R1: the in-use check must also ask lsof about the presence lock
and the sidecars, not just the DB - a holder can keep the lock open with
the uuid on no command line and in no session variable."""
paths = _agy_tree(tmp_path, conv)
adapter = get_adapter("agy")
ctx = DiscoveryContext(workspace=str(tmp_path), agent_name="agy", home_dir=str(tmp_path))
holder = _lock_holder(paths["lock"])
try:
assert adapter.conversation_in_use(conv, ctx) is True
res = _purge_cli("agy", conv, tmp_path)
finally:
_stop(holder)
assert res.returncode == 4, res.stdout + res.stderr
assert json.loads(res.stdout)["in_use"] is True
assert all(p.exists() for p in paths.values()), "a refused purge must remove nothing"
assert adapter.conversation_in_use(conv, ctx) is False
def test_purge_cli_resolves_agy_from_orphaned_leftovers(tmp_path, conv):
"""codex R2: after the DB is gone (an interrupted or pre-#10 purge) the
WAL, annotation and lock must still resolve to agy and be purged, instead
of 'no artifact' leaving them behind forever."""
root = tmp_path / ".gemini" / "antigravity-cli"
leftovers = [root / "conversations" / f"{conv}.db-wal",
root / "annotations" / f"{conv}.pbtxt",
root / "presence" / f"{conv}.lock"]
for p in leftovers:
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text("x")
res = _purge_cli("auto", conv, tmp_path)
assert res.returncode == 0, res.stdout + res.stderr
out = json.loads(res.stdout)
assert out["agent"] == "agy"
assert sorted(out["purged"]) == sorted(str(p) for p in leftovers)
assert out["residual"] == []
assert not any(p.exists() for p in leftovers)
# ---------------------------------------------------------------------------
# #13 — orc_onboard.sh --remove --purge-conversation
# ---------------------------------------------------------------------------
def test_orc_remove_purge_requires_yes(mam_sandbox, conv):
paths = _agy_tree(mam_sandbox, conv)
assert _run_onboard(["--uuid", conv], mam_sandbox).returncode == 0
res = _run_onboard(["--remove", conv, "--purge-conversation"], mam_sandbox)
assert res.returncode == 4, res.stdout + res.stderr
assert "Re-run with --yes" in res.stderr
assert all(p.exists() for p in paths.values())
assert _registered(mam_sandbox, conv), "registration must be untouched without --yes"
def test_orc_remove_purge_deletes_and_unregisters(mam_sandbox, conv):
paths = _agy_tree(mam_sandbox, conv)
assert _run_onboard(["--uuid", conv], mam_sandbox).returncode == 0
res = _run_onboard(["--remove", conv, "--purge-conversation", "--yes"], mam_sandbox)
assert res.returncode == 0, res.stdout + res.stderr
assert "Successfully removed orchestrator UUID" in res.stdout
assert res.stdout.count("purged: ") == 6, res.stdout
assert "residual: none (agent: agy)" in res.stdout
assert not any(p.exists() for p in paths.values())
assert not _registered(mam_sandbox, conv)
def test_orc_remove_purge_refused_while_in_use(mam_sandbox, conv):
paths = _agy_tree(mam_sandbox, conv)
assert _run_onboard(["--uuid", conv], mam_sandbox).returncode == 0
holder = _holder(conv)
try:
res = _run_onboard(["--remove", conv, "--purge-conversation", "--yes"], mam_sandbox)
finally:
_stop(holder)
assert res.returncode == 5, res.stdout + res.stderr
assert "still in use" in res.stderr
assert all(p.exists() for p in paths.values())
assert _registered(mam_sandbox, conv), "a refused purge must leave the registration in place"
def test_orc_remove_purge_refused_from_the_orchestrators_own_session(mam_sandbox, conv):
"""The case the skill documents: the orchestrator removes and purges its
own UUID from inside its own session (its tool shell carries the session
id). The purge must be refused (exit 5) and the registration kept."""
paths = _agy_tree(mam_sandbox, conv)
assert _run_onboard(["--uuid", conv], mam_sandbox).returncode == 0
res = _run_onboard(["--remove", conv, "--purge-conversation", "--yes"], mam_sandbox,
extra_env={"CLAUDE_CODE_SESSION_ID": conv})
assert res.returncode == 5, res.stdout + res.stderr
assert "still in use" in res.stderr
assert all(p.exists() for p in paths.values())
assert _registered(mam_sandbox, conv), "a refused purge must leave the registration in place"
def test_orc_remove_purge_without_artifacts_still_unregisters(mam_sandbox, conv):
assert _run_onboard(["--uuid", conv], mam_sandbox).returncode == 0
res = _run_onboard(["--remove", conv, "--purge-conversation", "--yes"], mam_sandbox)
assert res.returncode == 0, res.stdout + res.stderr
assert "nothing to purge" in res.stderr
assert not _registered(mam_sandbox, conv)
def test_orc_purge_flag_needs_remove(mam_sandbox, conv):
res = _run_onboard(["--uuid", conv, "--purge-conversation", "--yes"], mam_sandbox)
assert res.returncode == 2
assert not _registered(mam_sandbox, conv)
def test_orc_dry_run_purge_changes_nothing(mam_sandbox, conv):
paths = _agy_tree(mam_sandbox, conv)
assert _run_onboard(["--uuid", conv], mam_sandbox).returncode == 0
res = _run_onboard(["--remove", conv, "--purge-conversation", "--yes", "--dry-run"], mam_sandbox)
assert res.returncode == 0
assert "purge its conversation artifacts" in res.stdout
assert all(p.exists() for p in paths.values())
assert _registered(mam_sandbox, conv)
+120
View File
@@ -0,0 +1,120 @@
#!/usr/bin/env python3
"""Issue #15 — the job registry and its audit log follow the workspace, not
the caller's cwd.
`DEFAULT_REGISTRY_DIR` (".mam/jobs") and the audit-log root were resolved
relative to the current directory, so `create_session.sh`'s default onboarding
job — registered through `lib_py.delegation.cli submit` without a
`--registry-dir` — landed in whatever `.mam/jobs` sat under the cwd. Run from
the repo root, the pytest suite left hundreds of `running` test records in the
real registry. The defaults now honour `MAM_JOBS_DIR` / `DELEGATE_JOB_LOGS_DIR`,
then `$WORKSPACE_ROOT/.mam/...`, and only then the cwd.
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
SKILLS = REPO_ROOT / ".agents" / "skills"
REAL_JOBS = REPO_ROOT / ".mam" / "jobs"
REAL_LOGS = REPO_ROOT / ".mam" / "delegate_job_logs"
sys.path.insert(0, str(SKILLS))
from lib_py.delegation import mqtt_common # noqa: E402
def _listing(d: Path) -> set:
return set(os.listdir(d)) if d.is_dir() else set()
# ---------------------------------------------------------------------------
# resolution order
# ---------------------------------------------------------------------------
def test_registry_dir_env_wins(monkeypatch, tmp_path):
monkeypatch.setenv("MAM_JOBS_DIR", str(tmp_path / "explicit"))
monkeypatch.setenv("WORKSPACE_ROOT", str(tmp_path))
assert mqtt_common.default_registry_dir() == str(tmp_path / "explicit")
def test_registry_dir_follows_workspace_root(monkeypatch, tmp_path):
monkeypatch.delenv("MAM_JOBS_DIR", raising=False)
monkeypatch.setenv("WORKSPACE_ROOT", str(tmp_path))
assert mqtt_common.default_registry_dir() == str(tmp_path / ".mam" / "jobs")
def test_registry_dir_falls_back_to_cwd_relative(monkeypatch, tmp_path):
monkeypatch.delenv("MAM_JOBS_DIR", raising=False)
monkeypatch.delenv("WORKSPACE_ROOT", raising=False)
assert mqtt_common.default_registry_dir() == ".mam/jobs"
# A WORKSPACE_ROOT that is not a directory is ignored, not trusted.
monkeypatch.setenv("WORKSPACE_ROOT", str(tmp_path / "does-not-exist"))
assert mqtt_common.default_registry_dir() == ".mam/jobs"
def test_logs_dir_follows_the_same_order(monkeypatch, tmp_path):
monkeypatch.setenv("DELEGATE_JOB_LOGS_DIR", str(tmp_path / "logs"))
monkeypatch.setenv("WORKSPACE_ROOT", str(tmp_path))
assert mqtt_common.get_logs_dir() == str(tmp_path / "logs")
monkeypatch.delenv("DELEGATE_JOB_LOGS_DIR")
assert mqtt_common.get_logs_dir() == str(tmp_path / ".mam" / "delegate_job_logs")
monkeypatch.delenv("WORKSPACE_ROOT")
assert mqtt_common.get_logs_dir() == os.path.join(os.getcwd(), ".mam", "delegate_job_logs")
def test_cli_defaults_are_resolved_per_process(monkeypatch, tmp_path):
"""registry.py / publish_event.py build their argparse defaults at call
time, so a process started with WORKSPACE_ROOT set registers there."""
monkeypatch.delenv("MAM_JOBS_DIR", raising=False)
env = dict(os.environ, WORKSPACE_ROOT=str(tmp_path), PYTHONPATH=f"{SKILLS}:{os.environ.get('PYTHONPATH', '')}")
env.pop("MAM_JOBS_DIR", None)
res = subprocess.run(
[sys.executable, "-m", "lib_py.delegation.cli", "submit", "--prompt", "issue 15", "--agent", "claude-code",
"--agent-session", "herdr:issue15-probe"],
capture_output=True, text=True, env=env, cwd=str(REPO_ROOT), timeout=60,
)
assert res.returncode == 0, res.stderr
records = list((tmp_path / ".mam" / "jobs").glob("*.json"))
assert len(records) == 1, records
assert json.loads(records[0].read_text())["agent_session"] == "herdr:issue15-probe"
# ---------------------------------------------------------------------------
# the leak itself: create_session.sh's onboarding job from the repo root cwd
# ---------------------------------------------------------------------------
def _create_session_from_repo_root(sandbox: Path, session: str) -> subprocess.CompletedProcess:
script = sandbox / ".agents" / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh"
# cwd deliberately the repo root — the exact shape of the leaking tests.
return subprocess.run(
["bash", str(script), "--workspace", str(sandbox), "--agent", "claude", "--role", "Creator",
"--session", session],
capture_output=True, text=True, cwd=str(REPO_ROOT), timeout=180,
)
def _assert_registered_in_sandbox_only(sandbox: Path, session: str, before_jobs: set, before_logs: set, res):
assert res.returncode == 0, res.stderr + res.stdout
assert _listing(REAL_JOBS) == before_jobs, "onboarding job leaked into the repo's .mam/jobs"
assert _listing(REAL_LOGS) == before_logs, "audit log leaked into the repo's .mam/delegate_job_logs"
records = [json.loads(p.read_text()) for p in (sandbox / ".mam" / "jobs").glob("*.json")]
assert any(r.get("agent_session") == f"herdr:{session}" for r in records), records
def test_onboarding_job_stays_in_sandbox_with_explicit_env(mam_sandbox, mock_herdr, mock_agents):
before_jobs, before_logs = _listing(REAL_JOBS), _listing(REAL_LOGS)
res = _create_session_from_repo_root(mam_sandbox, "issue15-explicit-creator-claude")
_assert_registered_in_sandbox_only(mam_sandbox, "issue15-explicit-creator-claude", before_jobs, before_logs, res)
def test_onboarding_job_follows_workspace_root_alone(mam_sandbox, mock_herdr, mock_agents, monkeypatch):
"""Without the explicit MAM_JOBS_DIR / DELEGATE_JOB_LOGS_DIR overrides the
WORKSPACE_ROOT that lib.sh exports must be enough to keep the record out
of the repo's registry."""
monkeypatch.delenv("MAM_JOBS_DIR", raising=False)
monkeypatch.delenv("DELEGATE_JOB_LOGS_DIR", raising=False)
before_jobs, before_logs = _listing(REAL_JOBS), _listing(REAL_LOGS)
res = _create_session_from_repo_root(mam_sandbox, "issue15-wsroot-creator-claude")
_assert_registered_in_sandbox_only(mam_sandbox, "issue15-wsroot-creator-claude", before_jobs, before_logs, res)
+12 -3
View File
@@ -314,7 +314,9 @@ def test_publish_event_cli_contract_snapshot(mam_sandbox):
# Defaults that shell and wrapper callers depend on. # Defaults that shell and wrapper callers depend on.
src = Path(publish_event.__file__).read_text(encoding="utf-8") src = Path(publish_event.__file__).read_text(encoding="utf-8")
assert '"--attempts", type=int, default=3' in src assert '"--attempts", type=int, default=3' in src
assert '"--registry-dir", default=DEFAULT_REGISTRY_DIR' in src # Issue #15: resolved per process (MAM_JOBS_DIR, then WORKSPACE_ROOT, then
# the cwd-relative DEFAULT_REGISTRY_DIR) instead of the bare constant.
assert '"--registry-dir", default=default_registry_dir()' in src
assert '"--event", default="progress", choices=VALID_EVENTS' in src assert '"--event", default="progress", choices=VALID_EVENTS' in src
@@ -1063,8 +1065,15 @@ def test_r1_shell_helper_ignores_a_lib_py_in_the_working_directory(mam_sandbox,
assert res.returncode == 0, res.stderr assert res.returncode == 0, res.stderr
job_id = res.stdout.strip() job_id = res.stdout.strip()
assert re.fullmatch(r"[0-9a-f]+", job_id), repr(res.stdout) assert re.fullmatch(r"[0-9a-f]+", job_id), repr(res.stdout)
assert (decoy / ".mam" / "jobs" / (job_id + ".json")).exists(), ( # Issue #15: the registry follows MAM_JOBS_DIR / WORKSPACE_ROOT (both point
"the real cli should have registered the job in the cwd registry" # at the sandbox here), no longer the cwd — the decoy workspace's own
# .mam/jobs must stay empty.
registry = Path(env["MAM_JOBS_DIR"])
assert (registry / (job_id + ".json")).exists(), (
"the real cli should have registered the job in the sandbox registry"
)
assert not (decoy / ".mam" / "jobs" / (job_id + ".json")).exists(), (
"the job must not be registered relative to the cwd"
) )
+2
View File
@@ -242,6 +242,8 @@ def test_o13_onboard_remove_uuid(mam_sandbox):
res = run_onboard(["--remove", orc_uuid], env=env, cwd=str(mam_sandbox)) res = run_onboard(["--remove", orc_uuid], env=env, cwd=str(mam_sandbox))
assert res.returncode == 0 assert res.returncode == 0
# Issue #11: "removeed" was the naive ${MODE}ed conjugation.
assert "Successfully removed orchestrator UUID" in res.stdout, res.stdout
res_list = run_onboard(["--list"], env=env, cwd=str(mam_sandbox)) res_list = run_onboard(["--list"], env=env, cwd=str(mam_sandbox))
assert orc_uuid not in res_list.stdout assert orc_uuid not in res_list.stdout
+3
View File
@@ -493,6 +493,9 @@ def test_b9_logs_dir_follows_cwd_changes(mam_sandbox, tmp_path, monkeypatch):
"""B-9: the audit-log root must be resolved per call, not frozen at import.""" """B-9: the audit-log root must be resolved per call, not frozen at import."""
mq = get_mqtt_common(mam_sandbox) mq = get_mqtt_common(mam_sandbox)
monkeypatch.delenv("DELEGATE_JOB_LOGS_DIR", raising=False) monkeypatch.delenv("DELEGATE_JOB_LOGS_DIR", raising=False)
# Issue #15: with WORKSPACE_ROOT set the root is anchored on the workspace
# instead; the cwd only decides when neither override is present.
monkeypatch.delenv("WORKSPACE_ROOT", raising=False)
a = tmp_path / "a"; b = tmp_path / "b" a = tmp_path / "a"; b = tmp_path / "b"
a.mkdir(); b.mkdir() a.mkdir(); b.mkdir()