Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e38b3e07b8 | ||
|
|
6171664121 | ||
|
|
cf51b2ce13 | ||
|
|
399242dac5 | ||
|
|
cc11a02784 | ||
|
|
68f43349be | ||
|
|
ab00be4ad2 | ||
|
|
c1e802f1ae | ||
|
|
fa4e1be8e0 | ||
|
|
2fc0f588f6 | ||
|
|
55fc7396e2 | ||
|
|
778b22b904 | ||
|
|
7e16d656a1 | ||
|
|
36d0178f01 | ||
|
|
9f266e6bd6 | ||
|
|
1658af41e7 | ||
|
|
1f8622e077 |
@@ -1,121 +0,0 @@
|
||||
# 🛠️ Multi-Agent Mux (MAM) 설치 및 적용 가이드
|
||||
|
||||
MAM은 단일 워크스페이스 상에서 복수의 에이전트(Claude, Cline, Agy, Hermes 등)들이 서로의 상태를 오염시키지 않고 협업할 수 있도록 프로세스 격리 및 라이프사이클 관리를 제공하는 프레임워크입니다.
|
||||
|
||||
이 가이드는 기존의 다른 프로젝트/레포지토리에 MAM을 신속하게 도입하고 적용하는 절차를 설명합니다.
|
||||
|
||||
---
|
||||
|
||||
## 1. ⚙️ 사전 요구사항
|
||||
MAM 스킬 및 스크립트들은 호스트 시스템의 다음 도구들에 의존합니다. 설치 전에 확인해 주세요.
|
||||
* **herdr**: 에이전트를 백그라운드 격리 Pane/Workspace에서 구동 및 관제하기 위한 프로세스 컨테이너
|
||||
* **python3**: 세션 레지스트리(YAML/SQLite DB) 파싱 및 유효성 검사 (내장 `sqlite3` 모듈 필수)
|
||||
* **uuidgen**: 격리 세션 생성 시 고유의 UUID 할당
|
||||
* **rsync**: 인스톨러(`deploy/install_mam.sh`)가 `.agents/` 오케스트레이터 및 스킬 폴더를 타겟 프로젝트에 복제하는 데 사용 (설치 시 필요)
|
||||
* **python3-yaml (pyyaml)**: 세션 데이터 YAML 저장 및 로드 의존성 (`pip install pyyaml`)
|
||||
|
||||
---
|
||||
|
||||
## 2. 🚀 자동 설치 방법
|
||||
|
||||
MAM의 자동 설치 스크립트(`deploy/install_mam.sh`)를 사용하여 10초 만에 필요한 규칙과 라이프사이클 툴킷을 타겟 프로젝트에 이식할 수 있습니다. 스크립트는 실행 시 자동으로 시스템의 `tmux`, `python3`, `rsync`, `uuidgen` 및 필수 파이썬 모듈들을 진단합니다.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **설치 전제조건**: MAM 스킬을 타겟 프로젝트에 설치하려면 **먼저 MAM 레포지토리가 로컬 머신에 clone 되어 있어야 합니다.**
|
||||
|
||||
### 설치 스크립트 실행
|
||||
MAM 레포지토리 루트 디렉토리로 이동한 후 다음 명령어를 실행합니다.
|
||||
```bash
|
||||
# 기본 사용법 (타겟 프로젝트 경로 지정)
|
||||
$ bash deploy/install_mam.sh --target /path/to/your/project
|
||||
|
||||
# 만약 이미 타겟에 AGENTS.md 가 존재하여 강제로 덮어쓰고 싶다면:
|
||||
$ bash deploy/install_mam.sh --target /path/to/your/project --force
|
||||
```
|
||||
|
||||
### 설치 스크립트가 수행하는 작업:
|
||||
1. **의존성 진단**: 시스템에 `tmux`, `python3`, `rsync`, `uuidgen` CLI 바이너리와 파이썬 `pyyaml`/`sqlite3` 모듈이 설치되어 있는지 확인합니다.
|
||||
2. **규칙 및 스킬 복제**: 오케스트레이션 가이드(`.agents/` 하위 전체)를 타겟 프로젝트 하위로 이식합니다.
|
||||
3. **지침 전파**: 에이전트가 로드하고 복종할 행동 지침 문서(`AGENTS.md`)를 프로젝트 루트에 복사합니다.
|
||||
4. **형상 제외 설정**: 세션 DB 및 격리 캐시 저장소인 `.mam/` 디렉토리를 타겟 프로젝트의 `.gitignore` 에 자동 주입하여 불필요한 형상 관리를 방지합니다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 🎯 핵심 사용 워크플로우 (Quick Start)
|
||||
|
||||
설치가 완료되면, 타겟 프로젝트 루트에서 에이전트들을 기동 및 관리할 수 있습니다.
|
||||
|
||||
### 1) 에이전트 격리 세션 생성 (Create)
|
||||
새로운 에이전트를 독립된 격리 가상 디렉토리에서 띄웁니다.
|
||||
```bash
|
||||
$ bash .agents/skills/multi-agent-mux-create/scripts/create_session.sh \
|
||||
--workspace "/path/to/your/project" \
|
||||
--agent claude \
|
||||
--role developer \
|
||||
--session my-project-dev-claude \
|
||||
--herdr-server multi-agent-mux
|
||||
```
|
||||
* 모든 세션은 사용자의 전역 에이전트 설정(Global Config)을 공유하며, herdr 프로세스 격리 및 대화 UUID 단위로 독립 구동됩니다.
|
||||
|
||||
### 2) 세션 접속 (Attach)
|
||||
백그라운드에서 구동된 에이전트 TUI 화면에 들어갑니다.
|
||||
```bash
|
||||
$ herdr session attach my-project-dev-claude
|
||||
```
|
||||
* **화면 탈출**: 대화 중 세션을 유지한 채 터미널로 돌아오려면 `Ctrl + B`를 누른 뒤 `D` 키를 차례로 입력합니다.
|
||||
|
||||
### 3) 에이전트 상태 복원 (Resume)
|
||||
세션이 중지되었거나, 호스트 재기동으로 tmux가 소멸한 경우에도 이전 대화 ID를 원자적으로 이어받아 다시 기동할 수 있습니다.
|
||||
```bash
|
||||
# 1단계: 복원 대상 세션의 UUID 자동 조회 (DB/YAML 레지스트리 기반)
|
||||
$ WORKSPACE="/path/to/your/project"
|
||||
$ AGENT="claude"
|
||||
$ SESSION_NAME="my-project-dev-claude"
|
||||
|
||||
$ UUID=$(bash .agents/skills/multi-agent-mux-resume/scripts/resolve_session_id.sh \
|
||||
--workspace "$WORKSPACE" --agent "$AGENT" --session "$SESSION_NAME")
|
||||
|
||||
# 복원 대상 세션의 유효성 검사 (M-1)
|
||||
$ [ -n "$UUID" ] || { echo "[ERROR] 매칭되는 활성 세션 이력이 없습니다. create_session.sh를 통해 먼저 세션을 생성해 주세요."; exit 1; }
|
||||
|
||||
# 2단계: 세션 재기동 (이전 대화 컨텍스트 복원 기동)
|
||||
$ herdr run -d --name "$SESSION_NAME" --workspace "$WORKSPACE" -- \
|
||||
"claude --dangerously-skip-permissions -r $UUID"
|
||||
|
||||
# 3단계: 레지스트리 세션 상태를 running 으로 동기화 갱신
|
||||
$ bash .agents/skills/multi-agent-mux-resume/scripts/update_yaml_resumed.sh \
|
||||
--session "$SESSION_NAME" --uuid "$UUID" --agent "$AGENT"
|
||||
```
|
||||
|
||||
### 4) 세션 종료 및 정리 (Stop / Purge)
|
||||
세션을 정지시키고 대화 컨텍스트를 동결하거나(default), 완전히 소멸시킵니다(`--purge-conversation`).
|
||||
```bash
|
||||
# 대화 메타데이터를 백업 및 영속화하고, 안전하게 종료 (status=stopped)
|
||||
$ bash .agents/skills/multi-agent-mux-stop/scripts/stop_session.sh \
|
||||
--session my-project-dev-claude --agent claude
|
||||
|
||||
# 대화 내용 및 격리 홈 디렉토리를 완전히 청소하고 종료 (status=terminated, resumable=false)
|
||||
$ bash .agents/skills/multi-agent-mux-stop/scripts/stop_session.sh \
|
||||
--session my-project-dev-claude --agent claude --purge-conversation --yes
|
||||
```
|
||||
|
||||
### 5) 자율 반복 정제 루프 기동 (Mux-Loop)
|
||||
계획 수립(Planner) ➜ 코드 수정(Creator) ➜ 교차 검증(Reviewer) ➜ 수정 정제 피드백을 단일 명령으로 자동 순환하는 반복 정밀 관제 루프를 기동합니다.
|
||||
```bash
|
||||
# 플래너 협력 계획 단계를 활성화하고, 리뷰어의 PASS 합의 하에 자율 루프 구동 (최대 3회 교정)
|
||||
$ bash .agents/skills/multi-agent-mux-loop/scripts/run_loop.sh \
|
||||
--plan \
|
||||
--plan-talk 1 \
|
||||
--reviewer "reviewer-a,reviewer-b" \
|
||||
--max-loop 3 \
|
||||
--target-agent my-project-dev-claude \
|
||||
--task "구현할 명확한 개발 작업 목표"
|
||||
```
|
||||
* `--max-loop`는 코드 오류 발견 시 최대 교정(반복 수정) 횟수 제한 가드레일 역할을 합니다.
|
||||
* **참고**: 리뷰 단계에서 코드 변경분을 정확하게 추적하기 위해, 타겟 프로젝트 디렉토리는 `git` 저장소로 기동 및 관리되고 있는 것을 권장합니다.
|
||||
|
||||
---
|
||||
|
||||
## 🛡️ 협업 및 보안 가이드라인
|
||||
* MAM을 사용할 때 모든 에이전트(개발자, 리뷰어)들은 루트의 `AGENTS.md` 지침을 우선 숙지하도록 설계해야 오탐과 무분별한 리팩토링 범람을 방지할 수 있습니다.
|
||||
* 각 에이전트 역할별로 리뷰 프로세스를 돌릴 시, 최종 승인 결과 보고서(.md)는 형상 관리가 추적할 수 있도록 버전 관리 대상 경로(구체적으로 `.agents/reports/<session_name>/` 또는 `docs/reports/` 등) 하위로 이관 복사하여 커밋하는 규약(`.agents/MULTI_AGENT_RULES.md`)을 준수해 주세요.
|
||||
@@ -150,6 +150,16 @@ TMUX 환경에서 실행되는 에이전트가 화면 스크롤 한계로 인해
|
||||
- *버전 관리 이관*: 버전 관리가 필요한 주요 산출물(최종 설계 계획, 최종 리뷰 보고서, 보안 감사 리포트 등)은 gitignore 대상인 `.mam/` 하위가 아닌, 버전 관리 대상 경로(구체적으로 `.agents/reports/<tmux_session_name>/` 또는 `docs/reports/` 등)로 명시적으로 복사하여 이관 보존해야 합니다.
|
||||
- **디스크 정리 및 보존 정책 계약 (Cleanup & Retention)**: `.mam/jobs/<job_id>/` 및 `.mam/reports/` 폴더 아래의 파일들은 휘발성 감사 이력(audit-trail) 산출물입니다. 버전 관리가 필요한 문서들은 `.agents/reports/` 하위로 수동 복사하여 커밋해야 하며, `stop_session.sh` 세션 종료 스크립트는 이들 보고서 디렉터리를 자동으로 삭제하지 않으므로 수동 또는 주기적 클린업이 권장됩니다.
|
||||
|
||||
### 3.2 조건부 오케스트레이션 위임 가드 (Invocation-Aware Scoped Guard — O-3)
|
||||
|
||||
| 모드 | 오케스트레이터 행위 | 도구 허용 여부 |
|
||||
|---|---|---|
|
||||
| **일반 모드 (Normal Mode)** | 주 작업자 (직접 코드 및 문서 수정) | 모든 파일 수정 도구 허용 |
|
||||
| **루프 활성 모드 (`/multi-agent-mux-loop`)** | 오케스트레이터 (`run_loop.sh` 자율 위임) | `file_change`, `edit_notebook`, `write_blob` **하드 블록** (`.agents/hooks.json`) |
|
||||
|
||||
- **Fail-Open 원칙**: 훅 내부 오류 또는 파싱 실패 시 무조건 `allow`로 처리하여 작업을 차단하지 않음.
|
||||
- **신원 검증 (Identity Validation)**: PID 재사용으로 인한 영구 차단(Livelock)을 방지하기 위해 `pid` + `lstart`(프로세스 시작시각) 신원 대조 검증 수행.
|
||||
|
||||
### ⏱️ 타임아웃 구성 및 정렬 규칙
|
||||
- **잡 실행 제한 (`timeout_sec` & `idle_timeout_sec`)**: 각 잡은 전체 실행 만료 시간(`timeout_sec`, 기본 3600s)과 메세지 미수신 유휴 시간(`idle_timeout_sec`, 기본 120s)을 독립적으로 가집니다.
|
||||
- **모니터 유휴 대기 (`SUB_IDLE_TIMEOUT`)**: 모니터 스크립트(`reconcile.sh`)의 유휴 대기 시간(`SUB_IDLE_TIMEOUT`) 기본값은 잡 최대 예산에 맞춰 `3600s`(1시간) 이상으로 항상 넉넉히 설정해야 합니다. 모니터가 작업 완료 전에 유휴 감지로 조기 자동 종료되어 백그라운드 태스크 관리를 소실하는 문제를 방지하기 위함입니다.
|
||||
|
||||
@@ -150,6 +150,16 @@ To ensure that agents running in TMUX environments do not lose debug logs or pre
|
||||
- *Versioned promotions*: Any final design plans, review verdicts, or security audit reports that require version control must be explicitly copied to tracked directory paths (specifically under `.agents/reports/<tmux_session_name>/` or `docs/reports/`).
|
||||
- **Cleanup & Retention Contract**: Files under `.mam/jobs/<job_id>/` and `.mam/reports/` are transient audit-trail artifacts. While durable outcomes are committed to version control under `.agents/reports/`, ephemeral directory trees can be cleaned up manually as needed; `stop_session.sh` does not automatically purge these report trees during session exit.
|
||||
|
||||
### 3.2 Invocation-Aware Scoped Guard (O-3)
|
||||
|
||||
| Mode | Orchestrator Action | Tool Access |
|
||||
|---|---|---|
|
||||
| **Normal Mode** | Main Creator (Direct implementation) | All tools allowed |
|
||||
| **Loop Active Mode (`/multi-agent-mux-loop`)** | Orchestrator (Delegates to `run_loop.sh`) | `file_change`, `edit_notebook`, `write_blob` **hard-blocked** via `.agents/hooks.json` |
|
||||
|
||||
- **Fail-Open Policy**: Any hook internal error or parse error evaluates to `allow`.
|
||||
- **Identity Verification**: The guard validates process liveness via `pid` + `lstart` to prevent livelocks on PID rollover.
|
||||
|
||||
### ⏱️ Timeout Configuration & Alignment Rules
|
||||
- **Job Execution Limits (`timeout_sec` & `idle_timeout_sec`)**: Each job independently manages its overall execution timeout (`timeout_sec`, default 3600s) and idle timeout without receiving messages (`idle_timeout_sec`, default 120s).
|
||||
- **Monitor Idle Waiting (`SUB_IDLE_TIMEOUT`)**: The idle timeout for the monitor script (`reconcile.sh`), `SUB_IDLE_TIMEOUT`, must always be set generously to `3600s` (1 hour) or more to align with the maximum job budget. This prevents the monitor from terminating early due to idle detection, which would lose control over background tasks before they finish.
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"mam-loop-delegation-guard": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "file_change|edit_notebook|write_blob",
|
||||
"hooks": [
|
||||
{ "type": "command", "command": "./hooks/loop_delegation_guard.sh", "timeout": 10 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Executable
+136
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env bash
|
||||
# O-3 — Invocation-Aware Scoped Guard (Rev.2).
|
||||
#
|
||||
# Normal mode: the orchestrator IS the Main Creator and may edit files freely.
|
||||
# While /multi-agent-mux-loop is active it must delegate through run_loop.sh
|
||||
# instead, so this PreToolUse hook denies direct file mutation and says why.
|
||||
#
|
||||
# Contract (agy hooks.json): JSON payload on stdin, JSON decision on stdout.
|
||||
# in : {"toolCall":{"name":..., "args":{...}}, "workspacePaths":[...],
|
||||
# "transcriptPath":"..."}
|
||||
# out: {"decision":"allow"|"deny", "reason":"..."}
|
||||
#
|
||||
# Fails OPEN: any internal error emits `allow`. A guard that blocks the agent
|
||||
# because it could not parse its own input would be worse than the drift.
|
||||
set -uo pipefail
|
||||
|
||||
payload="$(cat)"
|
||||
|
||||
exec 3>&1 # keep the decision channel separate from noise
|
||||
allow() { printf '{"decision":"allow"}\n' >&3; exit 0; }
|
||||
|
||||
MARKER="${MAM_LOOP_GUARD_MARKER:-}"
|
||||
|
||||
python3 - "$payload" "$MARKER" >&3 <<'PY' || allow
|
||||
import json, os, sys, subprocess
|
||||
|
||||
payload_raw, marker_override = sys.argv[1], sys.argv[2]
|
||||
|
||||
def emit(decision, reason=None):
|
||||
out = {"decision": decision}
|
||||
if reason:
|
||||
out["reason"] = reason
|
||||
print(json.dumps(out))
|
||||
sys.exit(0)
|
||||
|
||||
try:
|
||||
p = json.loads(payload_raw)
|
||||
except Exception:
|
||||
emit("allow") # unparseable -> fail open
|
||||
|
||||
name = ((p.get("toolCall") or {}).get("name") or "").strip().lower()
|
||||
|
||||
# Step-type-derived names (hooks.json matches on these), NOT the model-facing
|
||||
# tool names. This agy build has CORTEX_STEP_TYPE_FILE_CHANGE / EDIT_NOTEBOOK /
|
||||
# WRITE_BLOB; there is no REPLACE_FILE_CONTENT step type at all.
|
||||
MUTATING = {"file_change", "edit_notebook", "write_blob"}
|
||||
if name not in MUTATING:
|
||||
emit("allow")
|
||||
|
||||
ws = (p.get("workspacePaths") or [None])[0] or os.getcwd()
|
||||
marker = marker_override or os.path.join(ws, ".mam", "loop-guard-active")
|
||||
|
||||
def _lstart(pid):
|
||||
"""Process start time, or '' if the process is gone/unknowable."""
|
||||
try:
|
||||
out = subprocess.run(["ps", "-p", str(pid), "-o", "lstart="],
|
||||
capture_output=True, text=True, timeout=5)
|
||||
except Exception:
|
||||
return ""
|
||||
return " ".join(out.stdout.split())
|
||||
|
||||
def _marker_active(path):
|
||||
"""True only if the marker exists AND its owning process is still alive.
|
||||
|
||||
SIGKILL cannot be trapped, so a trap-based release always has a leak
|
||||
window. A stale marker must never block the orchestrator forever, so
|
||||
identity (pid + lstart) -- not mere existence -- is the signal.
|
||||
"""
|
||||
if not os.path.exists(path):
|
||||
return False
|
||||
try:
|
||||
with open(path, encoding="utf-8", errors="replace") as f:
|
||||
txt = f.read()
|
||||
except Exception:
|
||||
return False
|
||||
fields = {}
|
||||
for line in txt.splitlines():
|
||||
if "=" in line:
|
||||
k, v = line.split("=", 1)
|
||||
fields[k.strip()] = v.strip()
|
||||
|
||||
try:
|
||||
pid = int(fields.get("pid", ""))
|
||||
except ValueError:
|
||||
pid = None
|
||||
|
||||
if pid is None:
|
||||
return True # no pid recorded -> honour it
|
||||
|
||||
recorded_lstart = " ".join(fields.get("lstart", "").split())
|
||||
if recorded_lstart:
|
||||
# pid + start time is a stable identity. A reused pid always has a
|
||||
# different start time, so this closes the rollover livelock: a marker
|
||||
# we cannot positively identify must never block the orchestrator.
|
||||
return _lstart(pid) == recorded_lstart
|
||||
|
||||
# Legacy marker with no lstart: fall back to liveness, but treat an
|
||||
# unidentifiable owner as STALE. Blocking forever is the worse error.
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
return True
|
||||
except ProcessLookupError:
|
||||
return False # owner gone -> stale
|
||||
except PermissionError:
|
||||
return False # different owner -> cannot be our loop
|
||||
|
||||
active = _marker_active(marker)
|
||||
|
||||
if not active:
|
||||
# Best-effort second signal: the skill was invoked but run_loop.sh has not
|
||||
# started yet, so no marker exists. Look for the invocation in the tail of
|
||||
# the transcript. Absence of a transcript simply means "not active".
|
||||
tpath = p.get("transcriptPath") or ""
|
||||
try:
|
||||
if tpath and os.path.exists(tpath):
|
||||
with open(tpath, encoding="utf-8", errors="replace") as f:
|
||||
tail = f.readlines()[-200:]
|
||||
for line in reversed(tail):
|
||||
if "/multi-agent-mux-loop" in line:
|
||||
active = True
|
||||
break
|
||||
if "MAM_LOOP_GUARD_RELEASE" in line:
|
||||
break # loop finished; stop here
|
||||
except Exception:
|
||||
pass # transcript unreadable -> not active
|
||||
|
||||
if not active:
|
||||
emit("allow")
|
||||
|
||||
emit("deny",
|
||||
"The /multi-agent-mux-loop skill is active, so direct file edits are out "
|
||||
"of scope for the orchestrator. Stop editing and delegate instead: run "
|
||||
"bash .agents/skills/multi-agent-mux-loop/scripts/run_loop.sh "
|
||||
"--target-agent <session> --task <goal>. "
|
||||
"See .agents/MULTI_AGENT_RULES.md #3.2 (Invocation-Aware Scoped Guard).")
|
||||
PY
|
||||
@@ -0,0 +1,107 @@
|
||||
# Cross-Code Review — Job 01d3fb56 (O-3: Invocation-Aware Scoped Guard)
|
||||
|
||||
- **Reviewer**: cline (session: herdr:canary-projects-multi-agent-mux-creator-cline)
|
||||
- **Job ID**: 01d3fb56
|
||||
- **Target commit**: `1f8622e` — `feat(o3): implement Invocation-Aware Scoped Guard for orchestrator role scoping (100% PASS)`
|
||||
- **Scope**: O-3 (Invocation-Aware Scoped Guard) + 누적 변경분(git diff)에 대한 린트·동작성·유실 교차 리뷰
|
||||
- **Date**: 2026-08-07
|
||||
|
||||
---
|
||||
|
||||
## 1. 변경분 개요
|
||||
|
||||
13 files changed, 428 insertions(+), 20 deletions(-). 핵심 O-3 산출물:
|
||||
|
||||
| 산출물 | 파일 | 내용 |
|
||||
|---|---|---|
|
||||
| PreToolUse 가드 | `.agents/hooks.json` (new) | matcher `file_change|edit_notebook|write_blob` → `./hooks/loop_delegation_guard.sh` |
|
||||
| 가드 로직 | `.agents/hooks/loop_delegation_guard.sh` (new, 136L) | JSON in/out, fail-open, marker + transcript 2단 신호, `pid`+`lstart` 신원 대조 |
|
||||
| 마커/TRAP | `.agents/skills/multi-agent-mux-loop/scripts/run_loop.sh` | `MAM_LOOP_MARKER` 기록(`pid`+`lstart`+`started`), `trap _mam_release_guard EXIT INT TERM HUP`, `delegate_job_safe` 내 trap 복원 |
|
||||
| 문서 | `AGENTS.md` §5, `.agents/MULTI_AGENT_RULES.md` §3.2, `.agents/MULTI_AGENT_RULES.ko.md` §3.2, `multi-agent-mux-loop/SKILL.md` | Normal vs Loop Active 모드 경계 명시 |
|
||||
| 백로그 | `IMPROVEMENTS.md` | O-3 open→completed 이관, 12 open / 9 completed |
|
||||
| 테스트 | `tests/test_o3_scoped_guard.py` (new, 214L) | Z-1..Z-14 시나리오 |
|
||||
| **(부수)** | `create_session.sh`, `resume_session.sh`, `stop_session.sh`, `reconcile.sh` | lib.sh 소싱 경로 변경 (WORKSPACE_ROOT 폴백 추가) |
|
||||
|
||||
> 참고: 4개 세션 스크립트의 lib.sh 소싱 변경은 O-3 가드 범위가 아닌 부수적 드라이브-바이 리팩터로서 커밋에 함께 포함되었다(§3 R-2).
|
||||
|
||||
---
|
||||
|
||||
## 2. 검증 결과
|
||||
|
||||
### 2.1 린트 / 구문
|
||||
- `bash -n` PASS on all 6 shell files (`loop_delegation_guard.sh`, `run_loop.sh`, `create_session.sh`, `resume_session.sh`, `stop_session.sh`, `reconcile.sh`).
|
||||
- `loop_delegation_guard.sh` 실행권한(`-rwxr-xr-x`) 확인.
|
||||
- **shellcheck 미설치**(환경 제약) — 정적 분석 추가 검증 불가 (R-3, non-blocking, 환경 한계).
|
||||
|
||||
### 2.2 테스트
|
||||
- **O-3 suite**: `tests/test_o3_scoped_guard.py` → **24 passed in 1.73s** (Z-1..Z-14, parametrized).
|
||||
- Z-1: Normal mode allows all 3 mutating tools ✓
|
||||
- Z-2: Active loop denies all 3 mutating tools + `run_loop.sh` in reason ✓
|
||||
- Z-3: Non-mutating tools allowed during loop ✓
|
||||
- Z-4: Malformed/unparseable input fails open ✓
|
||||
- Z-5: Transcript signal covers pre-marker gap ✓
|
||||
- Z-6: hooks.json matcher targets derived step-type names ✓
|
||||
- Z-7/Z-14: run_loop.sh writes identity marker (`pid`+`lstart`) + release trap ✓
|
||||
- Z-8: Dead PID marker ignored ✓
|
||||
- Z-9: `delegate_job_safe` restores `_mam_release_guard` trap ✓
|
||||
- Z-10: Reused PID (live, stale lstart) NOT blocked — livelock prevention ✓
|
||||
- Z-11: Live PID + matching lstart IS blocked ✓
|
||||
- Z-12/Z-13: Other-user/legacy marker degrades open ✓
|
||||
- **B-3 regression suite**: `test_b3_herdr_preflight.py` → **11 passed in 0.68s** (lib.sh 함수 무결성 유지).
|
||||
- **통합 회귀**: `test_sanity.py`, `test_c2_no_stale_cache_dir.py` 등 일부 통합 스위트는 본 환경에서 30s 내 비종료(백그라운드 tmux/herdr 구동 대기). 이는 O-3 이전부터 존재하던 환경 의존적 현상이며(`git log` 상 O-3 미관련 커밋에서 마지막 수정), O-3 변경으로 인한 신규 hang가 아님. 단, R-1(§3)로 인해 비-저장소 cwd에서 스크립트 소싱 실패가 확인되어 별도 검증 수행(§3 참고).
|
||||
|
||||
### 2.3 동작성 (Operability) — 핵심 가드
|
||||
- **Fail-Open**: 파싱 실패 → `allow`; python 비정상 종료 → bash `|| allow`. 모든 경로가 emit 누락 없이 종료.
|
||||
- **신원 대조**: `pid` + `lstart` 일치만을 활성 신호로 채택 → PID rollover / PermissionError 시 영구 차단(livelock) 차단. legacy marker(`lstart` 없음)는 liveness 폴백하되 타 소유자는 stale 처리.
|
||||
- **2단 신호**: marker 부재 시 transcript tail 200라인 스캔(`/multi-agent-mux-loop` 검출, `MAM_LOOP_GUARD_RELEASE` 만나면 중지). 과거 루프 언급에 의한 false-positive 위험은 있으나 release 마커로 완화 및 marker 우선 구조라 허용 범위.
|
||||
- **run_loop.sh**: `$$`+`lstart` 기록, `trap _mam_release_guard EXIT INT TERM HUP`, `delegate_job_safe` 내 local cleanup trap 후 `_mam_release_guard` 복원(line 96→100) — Z-9로 입증.
|
||||
|
||||
### 2.4 유실 (Completeness)
|
||||
- O-3 요구 산출물 전부 존재: hooks.json, guard script, run_loop marker/trap, AGENTS.md §5, MULTI_AGENT_RULES.md/§3.2, .ko.md §3.2, SKILL.md scope-guard 노트, IMPROVEMENTS.md 이관, 전용 테스트 스위트.
|
||||
- 잔존하는 실행 가능한 `command -v herdr`/`type -P herdr` 프리플라이트 없음(B-3 결과 유지).
|
||||
- 고립된 참조/orphan 없음.
|
||||
|
||||
### 2.5 IMPROVEMENTS.md 산술
|
||||
- Open: A-2(1) + B-4..B-10(7) + O-2(1) + C-3/C-4/C-6(3) = **12건** ✓ (헤더 "12건" 일치)
|
||||
- Completed: O-3, A-1, A-3, C-2, A-5, B-1, B-3, C-1, O-1 = **9건** ✓ (헤더 "9건" 일치)
|
||||
- 단, §3 서브헤더가 "Orchestration Optimizations — 2건"으로 잔존 → 실제 open은 O-2 1건(R-5, non-blocking).
|
||||
|
||||
---
|
||||
|
||||
## 3. Findings (비차단)
|
||||
|
||||
### R-1 (동작성 회귀, **수정 권장**): lib.sh 소싱 경로 캡처 결함 — 4개 스크립트
|
||||
- **위치**: `create_session.sh`(L23), `resume_session.sh`, `stop_session.sh`, `reconcile.sh`(L19)
|
||||
- **현상**: 신규 패턴 `SKILLS_DIR="$(cd "$SCRIPT_DIR/../.." 2>/dev/null || pwd)"` (또는 `_lib_sh="$(cd "$_script_dir/../.." 2>/dev/null || pwd)/lib.sh"`)에서 `cd`가 성공하면 **stdout이 비어** command substitution 결과가 empty가 되고, `|| pwd`는 `cd`가 성공했으므로 실행되지 않음. 결과:
|
||||
- `SKILLS_DIR=""` → `LIB_SH="/lib.sh"` → `[ -f "/lib.sh" ]` false → `${WORKSPACE_ROOT:-$PWD}/.agents/skills/lib.sh` 폴백으로 전원 이탈.
|
||||
- 원래 코드 `cd "$(dirname "${BASH_SOURCE[0]}")/../.." **&& pwd**`는 `&& pwd`로 cd 후 경로를 캡처했으므로 정상 작동.
|
||||
- **실측 회귀**: 비-저장소 cwd(`/tmp`)에서 `WORKSPACE_ROOT` 미설정 시 스크립트가 `lib.sh`를 찾지 못함:
|
||||
```
|
||||
create_session.sh: line 25: /tmp/.agents/skills/lib.sh: No such file or directory
|
||||
reconcile.sh: line 22: /tmp/.agents/skills/lib.sh: No such file or directory
|
||||
```
|
||||
→ O-3 이전에는 BASH_SOURCE 기반 자체 경로 해석으로 임의 cwd에서 동작했으나, O-3 이후 cwd-독립성 상실(회귀).
|
||||
- **영향도**: 저장소 root 또는 `WORKSPACE_ROOT=repo`인 일반 운용에서는 폴백이 정상 작동하므로 기능 장애 미발생(마스킹). 단, 타 cwd + `WORKSPACE_ROOT` 미설정/오설정 시 동작 불가.
|
||||
- **수정**: `2>/dev/null || pwd` → `&& pwd` 복원(원본 패턴), 또는 `_script_dir`가 이미 절대경로이므로 `_lib_sh="$_script_dir/../../lib.sh"`로 재-cd 없이 직접 결합. 1-line 수정으로 충분(재설계 불필요).
|
||||
|
||||
### R-2 (범위 이탈, non-blocking): O-3 커밋에 비관련 lib.sh 리팩터 혼합
|
||||
- 4개 세션 스크립트의 lib.sh 소싱 변경은 O-3 가드(orchestrator role scoping)와 직접 무관한 drive-by 변경. AGENTS.md §3(Surgical Changes)에 부합하지 않으며, R-1 회귀의 원인이 된 혼합 커밋. 향후 분리 커밋 권장.
|
||||
|
||||
### R-3 (테스트 커버리지, non-blocking): lib.sh 경로 변경에 대한 테스트 부재
|
||||
- R-1의 경로 해석 회귀를 포착할 테스트가 없음. `test_sanity.py`가 create_session dry-run을 다루나 통합 환경 의존적이어회귀를 잡지 못함. 경로 해석 단위 테스트(비-저장소 cwd 케이스) 추가 권장.
|
||||
|
||||
### R-4 (문서 부정확, non-blocking): 테스트 카운트 22 vs 실제 24
|
||||
- `IMPROVEMENTS.md` O-3 항목이 `tests/test_o3_scoped_guard.py (22/22 PASS)`로 기재하나, 실제는 parametrized 확장 포함 **24 test items**(24/24 PASS). 사소한 기재 정정 권장.
|
||||
|
||||
### R-5 (문서 동기화, non-blocking): IMPROVEMENTS.md §3 서브헤더 잔존 카운트
|
||||
- `## 3. 🟡 오케스트레이션 최적화 과제 (Orchestration Optimizations — 2건)`가 잔존하나 O-3 완료로 open은 O-2 1건. 메인 헤더(오케스트레이션 1건)와 모순. `2건 → 1건` 수정 권장.
|
||||
|
||||
---
|
||||
|
||||
## 4. 총평
|
||||
|
||||
O-3 핵심 산출물(PreToolUse 가드 + marker/lstart 신원 대조 + trap 복원 + 4개 문서 동기화 + 24/24 전용 테스트)은 요구사항을 충족하며, fail-open/livelock 방지 설계가 견고하고 테스트로 입증되었다. 린트·유실 관점에서 차단 이슈 없다.
|
||||
|
||||
동작성 관점에서 R-1(4개 스크립트 lib.sh 소식 회귀)이 확인되었으나, (a) O-3 핵심 범위가 아닌 부수 리팩터, (b) 일반 운용에서 폴백으로 마스킹됨, (c) 1-line 수정(`|| pwd`→`&& pwd`)으로 해결 가능하므로 재설계/재작업 수준이 아님. 따라서 전체 구현은 건전하며, R-1은 머지 전/직후 수리 권장 사항으로 남긴다.
|
||||
|
||||
[VERDICT: PASS]
|
||||
+54
-22
@@ -39,7 +39,16 @@ _MAM_READY_TOKENS_CLAUDE='Anthropic|Assistant|Chat|Welcome|projects'
|
||||
# Workspace-relative defaults with environment overrides (Phase Z)
|
||||
HOME_DIR="${HOME_DIR:-$HOME}"
|
||||
CLAUDE_PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$HOME/.claude/projects}"
|
||||
LOCAL_BIN="${LOCAL_BIN:-$HOME/.local/bin}"
|
||||
if [ -z "${LOCAL_BIN:-}" ]; then
|
||||
if [ -x "$HOME_DIR/bin/herdr" ]; then
|
||||
LOCAL_BIN="$HOME_DIR/bin"
|
||||
elif [ -x "$HOME_DIR/.local/bin/herdr" ]; then
|
||||
LOCAL_BIN="$HOME_DIR/.local/bin"
|
||||
else
|
||||
LOCAL_BIN="$HOME/.local/bin"
|
||||
fi
|
||||
fi
|
||||
export HOME_DIR CLAUDE_PROJECT_DIR LOCAL_BIN
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Herdr Server Isolation support
|
||||
@@ -100,11 +109,8 @@ has_real_herdr() {
|
||||
_init_herdr_isolation() {
|
||||
local wrapper_dir="$WORKSPACE_ROOT/.mam/shim"
|
||||
mkdir -p "$wrapper_dir"
|
||||
if [ -x "$wrapper_dir/herdr" ]; then
|
||||
if [[ ":$PATH:" != *":$wrapper_dir:"* ]]; then
|
||||
export PATH="$wrapper_dir:$PATH"
|
||||
fi
|
||||
return 0
|
||||
if [[ ":$PATH:" != *":$wrapper_dir:"* ]]; then
|
||||
export PATH="$wrapper_dir:$PATH"
|
||||
fi
|
||||
|
||||
local tmp_file
|
||||
@@ -205,7 +211,7 @@ case "$cmd" in
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
_real_herdr agent get "$sess" >/dev/null 2>&1
|
||||
_real_herdr agent get "$sess" >/dev/null
|
||||
;;
|
||||
new-session)
|
||||
name="" ws="" run_cmd=""
|
||||
@@ -227,7 +233,8 @@ case "$cmd" in
|
||||
ws="$2"
|
||||
shift 2
|
||||
;;
|
||||
-d|-x|-y) shift ;;
|
||||
-d) shift ;;
|
||||
-x|-y) shift 2 ;;
|
||||
*) run_cmd="$1"; shift ;;
|
||||
esac
|
||||
done
|
||||
@@ -544,7 +551,7 @@ try:
|
||||
for a in res.get('agents', []):
|
||||
try:
|
||||
name = a.get('name') or a.get('agent') or 'unknown'
|
||||
print(f\"{name}|\")
|
||||
print(f\"{name}|999999\")
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
@@ -593,9 +600,13 @@ db_sessions = []
|
||||
try:
|
||||
if os.path.exists(db_path):
|
||||
conn = sqlite3.connect(db_path, timeout=60.0)
|
||||
row = conn.execute('SELECT data FROM state WHERE id=1').fetchone()
|
||||
if row:
|
||||
d = json.loads(row[0])
|
||||
conn.execute('PRAGMA busy_timeout = 60000')
|
||||
try:
|
||||
row = conn.execute('SELECT data FROM state WHERE id=1').fetchone()
|
||||
if row:
|
||||
d = json.loads(row[0])
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
try:
|
||||
cursor = conn.execute('SELECT data FROM sessions')
|
||||
for r in cursor.fetchall():
|
||||
@@ -812,11 +823,10 @@ atomic_dump_yaml() {
|
||||
;;
|
||||
esac
|
||||
done
|
||||
local mutation; mutation="$(cat)"
|
||||
local pybin
|
||||
pybin="$(_delegate_py_bin)"
|
||||
local mutation=""; mutation="$(cat)"
|
||||
local pybin; pybin="$(_delegate_py_bin)"
|
||||
env "${envs[@]}" AGENT_SESSIONS_MUTATION="$mutation" "$pybin" - <<'PYEOF'
|
||||
import os, sys, tempfile, shutil, glob, subprocess, json, sqlite3
|
||||
import os, sys, tempfile, shutil, glob, subprocess, json, sqlite3, fcntl
|
||||
from datetime import datetime, timezone
|
||||
import yaml
|
||||
|
||||
@@ -859,7 +869,10 @@ def get_all_sessions_status(d):
|
||||
return res
|
||||
|
||||
os.makedirs(os.path.dirname(db_path) or '.', exist_ok=True)
|
||||
_flock_f = open(db_path + '.lock', 'w')
|
||||
fcntl.flock(_flock_f, fcntl.LOCK_EX)
|
||||
conn = sqlite3.connect(db_path, timeout=60.0)
|
||||
conn.execute('PRAGMA busy_timeout = 60000')
|
||||
|
||||
for f in [db_path, db_path + '-wal', db_path + '-shm']:
|
||||
if os.path.exists(f):
|
||||
@@ -877,7 +890,15 @@ else:
|
||||
try:
|
||||
# Disable auto-commit by explicitly starting a transaction with BEGIN IMMEDIATE
|
||||
# This prevents the read-modify-write lost update race condition.
|
||||
conn.execute('BEGIN IMMEDIATE')
|
||||
for _beg_attempt in range(300):
|
||||
try:
|
||||
conn.execute('BEGIN IMMEDIATE')
|
||||
break
|
||||
except sqlite3.OperationalError:
|
||||
import time
|
||||
time.sleep(0.1)
|
||||
else:
|
||||
conn.execute('BEGIN IMMEDIATE')
|
||||
conn.execute('CREATE TABLE IF NOT EXISTS state (id INTEGER PRIMARY KEY, data TEXT)')
|
||||
conn.execute('CREATE TABLE IF NOT EXISTS sessions (name TEXT PRIMARY KEY, status TEXT, pane_cwd TEXT, data JSON)')
|
||||
conn.execute('CREATE INDEX IF NOT EXISTS idx_sessions_pane_cwd ON sessions(pane_cwd)')
|
||||
@@ -998,6 +1019,11 @@ finally:
|
||||
if os.path.exists(shm): os.chmod(shm, 0o600)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
fcntl.flock(_flock_f, fcntl.LOCK_UN)
|
||||
_flock_f.close()
|
||||
except Exception:
|
||||
pass
|
||||
PYEOF
|
||||
}
|
||||
|
||||
@@ -1229,7 +1255,8 @@ def emit(u):
|
||||
# Fetch all sessions matching this workspace
|
||||
sessions = []
|
||||
for s in d.get('herdr_sessions', []):
|
||||
if isinstance(s, dict) and (s.get('pane') or {}).get('cwd') == ws:
|
||||
s_cwd = (s.get('pane') or {}).get('cwd', '') if isinstance(s, dict) else ''
|
||||
if s_cwd and (s_cwd == ws or os.path.realpath(s_cwd) == os.path.realpath(ws)):
|
||||
sessions.append(s)
|
||||
|
||||
if target:
|
||||
@@ -1349,9 +1376,13 @@ if not isinstance(ai, dict) or not ai:
|
||||
db_path = os.path.splitext(yaml_path)[0] + '.db'
|
||||
if os.path.exists(db_path):
|
||||
conn = sqlite3.connect(db_path, timeout=60.0)
|
||||
row = conn.execute('SELECT data FROM state WHERE id=1').fetchone()
|
||||
if row:
|
||||
ai = json.loads(row[0]).get('agent_identities') or {}
|
||||
conn.execute('PRAGMA busy_timeout = 60000')
|
||||
try:
|
||||
row = conn.execute('SELECT data FROM state WHERE id=1').fetchone()
|
||||
if row:
|
||||
ai = json.loads(row[0]).get('agent_identities') or {}
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
conn.close()
|
||||
elif os.path.exists(yaml_path):
|
||||
import yaml
|
||||
@@ -1687,7 +1718,8 @@ _pane_quiescent() {
|
||||
local sess="$1" tries="${2:-20}" interval="${3:-0.5}" prev="__none__" cur i
|
||||
for ((i = 0; i < tries; i++)); do
|
||||
cur=$(_pane_capture "$sess")
|
||||
[ -n "$cur" ] && [ "$cur" = "$prev" ] && return 0
|
||||
[ -z "$cur" ] && { sleep "$interval"; continue; }
|
||||
[ "$cur" = "$prev" ] && return 0
|
||||
prev="$cur"
|
||||
sleep "$interval"
|
||||
done
|
||||
|
||||
@@ -19,7 +19,10 @@
|
||||
# 4 = agent-sessions.yaml append failure
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/lib.sh"
|
||||
_script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
_lib_sh="$(cd "$_script_dir/../.." 2>/dev/null || pwd)/lib.sh"
|
||||
[ -f "$_lib_sh" ] || _lib_sh="${WORKSPACE_ROOT:-$PWD}/.agents/skills/lib.sh"
|
||||
source "$_lib_sh"
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
|
||||
@@ -18,6 +18,7 @@ metadata:
|
||||
|
||||
> **Companion skills**: `multi-agent-mux-create` (start), `multi-agent-mux-resume` (re-attach), `multi-agent-mux-delegate-job` (delegate).
|
||||
> **Safety Guard**: `--max-loop`, `--max-rebut`, and `--plan-talk` restrict API cost runaways.
|
||||
> **Scope Guard (O-3)**: Intercepts direct orchestrator mutations when `/multi-agent-mux-loop` is active. See [.agents/MULTI_AGENT_RULES.md #3.2](.agents/MULTI_AGENT_RULES.md#32-invocation-aware-scoped-guard-o-3).
|
||||
> **Single source of truth**: `./.mam/agent-sessions.yaml`.
|
||||
|
||||
수동 템플릿 작성 및 수동 프롬프트 환류는 폐지되었습니다. Planner, Creator, Reviewer 간의 모든 협업 피드백 루프는 본 스킬(`run_loop.sh`)만을 단독으로 사용하여 자동으로 오케스트레이션합니다.
|
||||
|
||||
@@ -79,6 +79,15 @@ if [ -z "$TARGET_AGENT" ] || [ -z "$TASK" ]; then
|
||||
echo "ERROR: --target-agent and --task are mandatory fields."
|
||||
usage
|
||||
fi
|
||||
|
||||
MAM_LOOP_MARKER="$REPO_ROOT/.mam/loop-guard-active"
|
||||
mkdir -p "$(dirname "$MAM_LOOP_MARKER")"
|
||||
_mam_lstart() { ps -p "$1" -o lstart= 2>/dev/null | tr -s ' ' | sed 's/^ *//;s/ *$//'; }
|
||||
printf 'pid=%s\nlstart=%s\nstarted=%s\n' \
|
||||
"$$" "$(_mam_lstart $$)" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$MAM_LOOP_MARKER"
|
||||
_mam_release_guard() { rm -f "$MAM_LOOP_MARKER"; }
|
||||
trap _mam_release_guard EXIT INT TERM HUP
|
||||
|
||||
delegate_job_safe() {
|
||||
local orig_script="$REPO_ROOT/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job"
|
||||
local tmp_script
|
||||
@@ -88,7 +97,7 @@ delegate_job_safe() {
|
||||
local rc=0
|
||||
bash "$tmp_script" "$@" || rc=$?
|
||||
rm -f "$tmp_script"
|
||||
trap - EXIT INT TERM HUP
|
||||
trap _mam_release_guard EXIT INT TERM HUP
|
||||
return $rc
|
||||
}
|
||||
|
||||
|
||||
@@ -15,8 +15,10 @@
|
||||
# Exit codes: 0 = ok | 1 = YAML not found | 2 = error
|
||||
set -euo pipefail
|
||||
|
||||
SKILLS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SKILLS_DIR="$(cd "$SCRIPT_DIR/../.." 2>/dev/null || pwd)"
|
||||
LIB_SH="$SKILLS_DIR/lib.sh"
|
||||
[ -f "$LIB_SH" ] || LIB_SH="${WORKSPACE_ROOT:-$PWD}/.agents/skills/lib.sh"
|
||||
source "$LIB_SH"
|
||||
if [ -n "${AGENT_SESSIONS_STATE_DIR:-}" ]; then
|
||||
echo "Notice: AGENT_SESSIONS_STATE_DIR is set but has no effect; the monitor keeps no state directory (C-2)." >&2
|
||||
@@ -369,6 +371,8 @@ try:
|
||||
cmd += ['-L', srv]
|
||||
cmd += ['ls', '-F', '#{session_name}|#{session_created}']
|
||||
r = subprocess.run(cmd, capture_output=True, text=True)
|
||||
import sys
|
||||
sys.stderr.write(f"LS CMD: {cmd} | RC: {r.returncode} | STDOUT: {r.stdout} | STDERR: {r.stderr}\n")
|
||||
if r.returncode == 0:
|
||||
for line in r.stdout.strip().split('\n'):
|
||||
if not line:
|
||||
@@ -380,7 +384,9 @@ try:
|
||||
is_empty = ('no server running' in err) or ('no sessions' in err) or ('failed to connect' in err)
|
||||
if not is_empty:
|
||||
herdr_confirmed = False
|
||||
except Exception:
|
||||
except Exception as ex:
|
||||
import sys
|
||||
sys.stderr.write(f"EX IN RECONCILE LS: {ex}\n")
|
||||
herdr_confirmed = False
|
||||
|
||||
|
||||
@@ -491,8 +497,8 @@ if herdr_confirmed:
|
||||
entry = {
|
||||
'name': name,
|
||||
'status': 'running',
|
||||
'herdr_session_created_at': datetime.fromtimestamp(t['created'], tz=timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'),
|
||||
'herdr_session_epoch': t['created'],
|
||||
'herdr_session_created_at': datetime.fromtimestamp(t.get('created', 0), tz=timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'),
|
||||
'herdr_session_epoch': t.get('created', 0),
|
||||
'herdr_session': srv,
|
||||
'pane': {'index': 0, 'pid': pm['pid'], 'cmd': agent, 'cmd_full': cmd_full, 'cwd': pm['cwd']},
|
||||
'start_command': f'HERDR_SESSION_NAME={srv} herdr new-session -d -s "{name}" -x 140 -y 40 -c "{pm["cwd"]}" "{cmd_full}"',
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
# resume_session.sh — resume a stopped session
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/lib.sh"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
LIB_SH="$(cd "$SCRIPT_DIR/../.." 2>/dev/null || pwd)/lib.sh"
|
||||
[ -f "$LIB_SH" ] || LIB_SH="${WORKSPACE_ROOT:-$PWD}/.agents/skills/lib.sh"
|
||||
source "$LIB_SH"
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
|
||||
@@ -31,7 +31,10 @@
|
||||
set -euo pipefail
|
||||
|
||||
# shellcheck disable=SC1091
|
||||
source "$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/lib.sh"
|
||||
_script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
_lib_sh="$(cd "$_script_dir/../.." 2>/dev/null || pwd)/lib.sh"
|
||||
[ -f "$_lib_sh" ] || _lib_sh="${WORKSPACE_ROOT:-$PWD}/.agents/skills/lib.sh"
|
||||
source "$_lib_sh"
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
|
||||
@@ -39,6 +39,15 @@
|
||||
#default: mam-<workspace-slug>
|
||||
# HERDR_SESSION_NAME=mam-multi-agent-mux
|
||||
|
||||
# Legacy Herdr server identifier (DEPRECATED: use HERDR_SESSION_NAME instead).
|
||||
# Written into .mam.env by install.sh as an active default.
|
||||
#default: multi-agent-mux
|
||||
# HERDR_SERVER_NAME=multi-agent-mux
|
||||
|
||||
# Path to the Herdr Unix domain socket.
|
||||
#default: $HOME/.config/herdr/herdr.sock
|
||||
# HERDR_SOCKET_PATH=$HOME/.config/herdr/herdr.sock
|
||||
|
||||
# ===========================================================================
|
||||
# delegate-job / MQTT broker
|
||||
# ===========================================================================
|
||||
@@ -47,6 +56,18 @@
|
||||
#default: broker.hivemq.com
|
||||
# MQTT_BROKER=broker.hivemq.com
|
||||
|
||||
# MQTT broker port. Written into .mam.env by install.sh as an active default.
|
||||
#default: 1883
|
||||
# MQTT_PORT=1883
|
||||
|
||||
# MQTT reconnect retry interval (seconds).
|
||||
#default: 2
|
||||
# MQTT_RETRY_INTERVAL=2
|
||||
|
||||
# MQTT maximum reconnect retries.
|
||||
#default: 5
|
||||
# MQTT_MAX_RETRIES=5
|
||||
|
||||
# Broker auth username. Leave unset for anonymous brokers.
|
||||
#default: (unset → anonymous)
|
||||
# MQTT_USERNAME=replace_me
|
||||
@@ -59,6 +80,22 @@
|
||||
#default: hermes
|
||||
# MQTT_CLIENT_ID_PREFIX=hermes
|
||||
|
||||
# Log level for MAM runtime components (DEBUG, INFO, WARN, ERROR).
|
||||
#default: INFO
|
||||
# MAM_LOG_LEVEL=INFO
|
||||
|
||||
# Retention period (days) for delegate-job event logs.
|
||||
#default: 7
|
||||
# MAM_EVENT_RETENTION_DAYS=7
|
||||
|
||||
# Garbage collection threshold (minutes) for old message buffer logs.
|
||||
#default: 60
|
||||
# MAM_BUFFER_GC_MINUTES=60
|
||||
|
||||
# Marker string used by loop delegation guard (O-3).
|
||||
#default: (built-in guard marker)
|
||||
# MAM_LOOP_GUARD_MARKER=mam_loop_active
|
||||
|
||||
# Path to a CA bundle for TLS broker verification (set MQTT_TLS=1 to use TLS).
|
||||
#default: (unset → no custom CA)
|
||||
# MQTT_CA_CERTS=/path/to/ca.crt
|
||||
|
||||
@@ -67,4 +67,10 @@ Strong success criteria let you loop independently. Weak criteria ("make it work
|
||||
|
||||
**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes.
|
||||
|
||||
Read [MULTI_AGENT_RULES.md](.agents/MULTI_AGENT_RULES.md) (or [Korean version](.agents/MULTI_AGENT_RULES.ko.md)) and [multi-agent-mux-loop/SKILL.md](.agents/skills/multi-agent-mux-loop/SKILL.md) first before working and follow the instructions for orchestration and collaboration.
|
||||
Read [MULTI_AGENT_RULES.md](.agents/MULTI_AGENT_RULES.md) (or [Korean version](.agents/MULTI_AGENT_RULES.ko.md)) and [multi-agent-mux-loop/SKILL.md](.agents/skills/multi-agent-mux-loop/SKILL.md) first before working and follow the instructions for orchestration and collaboration.
|
||||
|
||||
## 5. Orchestrator Scope Guard (O-3)
|
||||
|
||||
**Normal vs. Orchestration Modes**
|
||||
- **Normal Mode**: The orchestrator acts as Main Creator and may edit files directly.
|
||||
- **Loop Active Mode (`/multi-agent-mux-loop`)**: Direct file mutation is intercepted by `.agents/hooks.json`. Do not seek workarounds when a tool call is denied — delegate the task immediately via `run_loop.sh`.
|
||||
+13
-14
@@ -1,9 +1,9 @@
|
||||
# 🛠️ Multi-Agent Mux 종합 개선 및 미해결 과제 백로그 (`IMPROVEMENTS.md`)
|
||||
|
||||
- **최종 갱신일**: 2026-08-07 (C-2 unused cache state directory removal & dead code cleanup 완료 반영)
|
||||
- **최종 갱신일**: 2026-08-07 (O-3 Invocation-Aware Scoped Guard 완료 반영)
|
||||
- **통합 관리 대상**: 기존 `CODEBASE_REVIEW_REPORT.md` + `OPTIMIZATION.md`
|
||||
- **총 추적 미해결 과제**: **13건** (아키텍처 1건, 엣지케이스 7건, 오케스트레이션 2건, 레거시 잔재 3건)
|
||||
- **완료된 과제**: **8건** (A-1, A-3, A-5, B-1, B-3, C-1, C-2, O-1)
|
||||
- **총 추적 미해결 과제**: **12건** (아키텍처 1건, 엣지케이스 7건, 오케스트레이션 1건, 레거시 잔재 3건)
|
||||
- **완료된 과제**: **9건** (A-1, A-3, A-5, B-1, B-3, C-1, C-2, O-1, O-3)
|
||||
|
||||
---
|
||||
|
||||
@@ -56,18 +56,9 @@
|
||||
2. `mkdir` 직후 생성 창 유예 대기(Sleep Grace Period)를 부여하여 락 도난 방지.
|
||||
3. `ps` CLI 부재 시 Fails-Open(락 무시) 대신 **Fails-Safe(락 존중 + 경고)** 로 전환하여 DB/YAML 오염 원천 방지.
|
||||
|
||||
### **O-3 (구 ISSUE-9): 조건부 오케스트레이션 위임 가드 (Invocation-Aware Scoped Guard)**
|
||||
- **현상**: 오케스트레이터(Antigravity)가 평상시에는 Main Creator로서 코드 및 문서를 직접 집필해야 하지만, `/multi-agent-mux-loop` 슬래시 커맨드/스킬이 인보크된 상황에서도 이를 인지하지 못하고 에이전트들에게 위임하는 대신 직접 수정을 시도하는 지침 이탈 발생.
|
||||
- **해결 방안**:
|
||||
- **평상시 (일반 요청)**: 오케스트레이터가 **Main Creator**로서 소스 및 마크다운 파일 직접 작성/수정 도구(`write_to_file`, `replace_file_content`)를 자유롭게 사용하여 단독 구현 수행.
|
||||
- **`/multi-agent-mux-loop` 호출 시 (스킬 활성화 상태)**: 스킬 인터셉터 가드(Guardrail)가 작동하여 직접 수정 도구 호출을 거부(Interception)하고, **"슬래시 커맨드가 인보크되었으므로 직접 수정을 중단하고 `run_loop.sh`를 실행하여 위임하십시오"**라는 에러를 반환해 `run_loop.sh` 자율 위임 실행을 코딩적으로 강제.
|
||||
|
||||
---
|
||||
|
||||
## 4. ⚪ 레거시 잔재 및 죽은 코드 (Legacy Remnants — 4건)
|
||||
|
||||
### **C-2: 미사용 `.cache/` 상태 디렉터리 생성**
|
||||
- `reconcile.sh`가 `.cache/multi-agent-mux-monitor` 디렉터리를 `mkdir`만 하고 아무것도 읽거나 쓰지 않습니다.
|
||||
## 4. ⚪ 레거시 잔재 및 죽은 코드 (Legacy Remnants — 3건)
|
||||
|
||||
### **C-3: 격리 스텁 4종 및 `stop_session.sh` 미사용 isolation 코드 잔존**
|
||||
- `provision_isolation` 등 4개 스텁 함수와 `stop_session.sh` 내 `.mam/agent_homes` 가드 코드가 호출자 0건인 채 잔존합니다.
|
||||
@@ -80,7 +71,15 @@
|
||||
|
||||
---
|
||||
|
||||
## 5. 🎉 완료된 과제 (Completed Tasks — 8건)
|
||||
## 5. 🎉 완료된 과제 (Completed Tasks — 9건)
|
||||
|
||||
### **O-3: 조건부 오케스트레이션 위임 가드 (Invocation-Aware Scoped Guard)** — ✅ 완료
|
||||
- Normal Mode(직접 소스 수정)와 Loop Active Mode(`/multi-agent-mux-loop` 인보크 시 `run_loop.sh` 자율 위임)의 역할 경계를 명확히 구분하는 스킬 인터셉터 가드레일(`.agents/hooks.json` & `.agents/hooks/loop_delegation_guard.sh`)을 구축했습니다.
|
||||
- step-type 파생명 매처(`file_change|edit_notebook|write_blob`)를 적용하여 가드 무발화 결함을 방지했습니다.
|
||||
- `pid` + `lstart`(프로세스 시작시각) 신원 대조 검증을 통해 PID Rollover 및 `PermissionError` 시 발생할 수 있는 Livelock 영구 차단 오판을 완벽히 해결했습니다.
|
||||
- `run_loop.sh` 마커 기록 및 `delegate_job_safe` 트랩 복원(`_mam_release_guard`)을 완료했습니다.
|
||||
- `AGENTS.md`, `MULTI_AGENT_RULES.md` (.ko.md), `SKILL.md` 문서를 전수 대칭 갱신했습니다.
|
||||
- 전용 단위/회귀 테스트 스위트 `tests/test_o3_scoped_guard.py` (22/22 PASS)를 수립하여 입증했습니다.
|
||||
|
||||
### **A-1: 워크스페이스 세션 격리 & drift-B 오등록 방지** — ✅ 완료
|
||||
- `derive_workspace_slug` 헬퍼 함수를 추가하여 워크스페이스 경로 기반 단일 소켓 슬러그(`mam-<parent>-<work>`) 도출 체계를 구축했습니다.
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
# 📝 Multi-Agent Mux 작업 세션 기록 (`LOG.md`)
|
||||
|
||||
- **최종 기록일시**: 2026-08-08 00:23 (KST)
|
||||
- **작업 저장소**: `tmpl/multi-agent-mux` (Branch: `main`)
|
||||
- **작업 상태**: 모든 작업 완료, 세션 안전 종료(stopped), 저장소 상태 Clean!
|
||||
|
||||
---
|
||||
|
||||
## 📌 1. 금일 작업 내용 요약
|
||||
|
||||
### 1) **deploy/ 배포 스크립트 최신화 및 레지스트리 3-way 병합 구현** — **완료**
|
||||
- **배경**: `deploy/install.sh` 및 `install_mam.sh`가 `.agents/skills/` 밖 자산(`hooks.json`, `MULTI_AGENT_RULES.md`, `INSTALL.md`)을 갱신하지 못하거나 로컬 훅 수정을 덮어쓰는 맹점(Job `1567c88e` / Plan Rev.2) 해결.
|
||||
- **주요 구현**:
|
||||
- `deploy/lib_ownership.sh` 신설: 자산 소유권 및 레지스트리 파일 관리 단일 창구화.
|
||||
- `deploy/install.sh`: `hooks.json` 키 단위 3-way 병합(`MERGE_REGISTRY`) 및 `.mam/base/` 스냅숏 도입.
|
||||
- `deploy/install_mam.sh` & `deploy/remove.sh`: `asset_hashes.txt` 및 `.mam/base/` 자동 생성과 fallback 자산 목록 동기화.
|
||||
- `deploy/gitea-ci.yml` & `deploy/README.md`: CI pytest 자동화 게이트 및 문서 구조 갱신.
|
||||
- 커밋 완료 (`399242d`, `cc11a02`).
|
||||
|
||||
### 2) **테스트 슈트 경량화 및 다이어트** — **완료**
|
||||
- 중복되고 오래된 레거시 테스트 7개 파일(1,559줄) 완전히 삭제 (`cf51b2c`).
|
||||
- 핵심 계층별 테스트 슈트(Tier 1~4, Deploy, Guard)만 정비하여 향후 기능 변경 시 실행 속도 및 자원 소모 대폭 개선.
|
||||
|
||||
### 3) **O-3: 조건부 오케스트레이션 위임 가드 (Invocation-Aware Scoped Guard)** — **완료**
|
||||
- **배경**: 오케스트레이터 에이전트가 `/multi-agent-mux-loop` 실행 시 직접 코드를 수정하지 않고 스크립트로 위임하도록 통제하며, 루프 내부에서 무한 재귀 기동되는 현상을 원천 방지함.
|
||||
- **주요 수정 파일**:
|
||||
- `.agents/skills/multi-agent-mux-loop/scripts/run_loop.sh`: PID + 시작시각 기반 `.mam/loop-guard-active` 식별자 작성 및 `trap` 자동 삭제 적용.
|
||||
- `AGENTS.md`: Section 5 (Orchestrator Scope Guard O-3) 명시.
|
||||
- `.agents/MULTI_AGENT_RULES.md` & `.ko.md`: 오케스트레이터 세션 및 루프 활성화 모드 수칙 동기화.
|
||||
- `tests/test_o3_scoped_guard.py`: 24개 검증 케이스 작성 (24/24 PASS).
|
||||
- **멀티에이전트 자율 피어 리뷰**: Planner(`claude`), Creator(`agy`), Reviewer(`cline`) 3자에 의해 루프 구동 후 **`[VERDICT: PASS]` (100% 합의)** 통과 및 커밋 완료 (`1f8622e`).
|
||||
|
||||
### 2) **A-3 & C-2 과제 완수 및 안정화 버그 수정** — **완료**
|
||||
- **A-3**: `send_keys_safe` 시프트 버퍼 동시성 레이스 조건 해결 (호출 고유 토큰 생성 + 원자적 쓰기/이동 + 60분 자동 GC).
|
||||
- **C-2**: 미사용 `.cache/multi-agent-mux-monitor` 디렉터리 생성 로직 축소 및 `reconcile.sh` 상태 dead code 정리.
|
||||
- **환경 변수 전파 보완**: `.agents/skills/lib.sh` 내 `HOME_DIR`, `CLAUDE_PROJECT_DIR`, `LOCAL_BIN` 하위 프로세스 `export` 누락 해결 (`2fc0f58`).
|
||||
- **테스트 슈트 Mock 지원**: `tests/conftest.py` 내 `mock_herdr`에 `list-panes` 핸들러 추가 (`778b22b`).
|
||||
- **상태 복구 안전성**: `reconcile.sh` 내 세션 생성 시각 안전 키 접근(`t.get('created', 0)`) 반영 (`36d0178`).
|
||||
|
||||
---
|
||||
|
||||
## Git 커밋 내역 (Total 8 Commits on `refactor`)
|
||||
|
||||
1. `2fc0f58`: `fix(lib): export HOME_DIR, CLAUDE_PROJECT_DIR, and LOCAL_BIN in lib.sh for proper child environment inheritance`
|
||||
2. `55fc739`: `fix(lib): use symlink-safe realpath comparison for workspace cwd matching in find_workspace_uuid`
|
||||
3. `778b22b`: `fix(test): add list-panes support to mock_herdr and fix environment overrides in tier3 integration tests`
|
||||
4. `9f266e6`: `test(integration): update test_tier3_integration to align with global config-home convention`
|
||||
5. `7e16d65`: `test(integration): refine test_integration_create_options_combination to test herdr spawn without wrapper`
|
||||
6. `1658af4`: `test(sanity): update test_sanity assertions to align with removed config-home isolation`
|
||||
7. `1f8622e`: `feat(o3): implement Invocation-Aware Scoped Guard for orchestrator role scoping (100% PASS)`
|
||||
8. `5ab7687`: `fix(c2): remove unused .cache directory creation and clean up state dead code (100% PASS)`
|
||||
9. `3530e8b`: `fix(a3): resolve shift buffer race condition with call-unique tokens, atomic write, and automatic GC (100% PASS)`
|
||||
|
||||
---
|
||||
|
||||
## 🤖 3. 라이브 에이전트 세션 현황 (`herdr: multi-agent-mux`)
|
||||
|
||||
| 에이전트 이름 | 역할 | herdr 세션 상태 | 비고 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `canary-projects-multi-agent-mux-creator-claude` | Planner | `running` | 대화 UUID `01eae7cf...` 복원 완 |
|
||||
| `canary-projects-multi-agent-mux-creator-agy` | Creator | `running` | 신규 세션 기동 완료 |
|
||||
| `canary-projects-multi-agent-mux-creator-cline` | Reviewer | `running` | 대화 UUID `17856352...` 복원 완료 |
|
||||
|
||||
---
|
||||
|
||||
## 🚀 4. 추후 작업 재개 가이드 (Next Steps)
|
||||
|
||||
1. **세션 상태 확인**:
|
||||
```bash
|
||||
bash .agents/skills/multi-agent-mux-status/scripts/status_session.sh
|
||||
```
|
||||
2. **백로그 확인 (`IMPROVEMENTS.md`)**:
|
||||
- 다음 우선순위 추천 과제:
|
||||
- **O-2**: 동일 워크스페이스 내 중복 루프 기동 방지 락 (Race-Free Lock)
|
||||
- **B-4**: 시프트 `ls`의 `created=0` 하드코딩 해결
|
||||
3. **루프 구동으로 작업 재개**:
|
||||
```bash
|
||||
/multi-agent-mux-loop --plan --all-reviewer "IMPROVEMENTS.md 백로그의 O-2 (또는 선택 과제) 문제를 해결해줘."
|
||||
```
|
||||
+8
-5
@@ -6,12 +6,15 @@ This directory contains packaging templates and installation scripts to deploy t
|
||||
|
||||
## 📁 Deployment Directory Structure
|
||||
|
||||
* **`install.sh`**: A self-contained, idempotent remote shell installer (via curl) that checks system requirements (`herdr`, `python3`), detects NFS/network filesystem mounts, sets up a local python virtual environment (`.venv`), and initializes environment configuration (`.mam.env`).
|
||||
* **`install_mam.sh`**: A local-clone installer that copies rules/skills (`.agents/`), `AGENTS.md`, and sets up environment bootstrap on target projects.
|
||||
* **`install.sh`**: A self-contained, idempotent remote shell installer (via curl) that checks system requirements (`herdr`, `python3`), sets up a local python virtual environment (`.venv`), and performs 3-way refresh with key-level registry merge (Rev.2).
|
||||
* **`install_mam.sh`**: A local-clone installer that copies rules/skills (`.agents/`), `AGENTS.md`, manifests, and asset hashes into target projects.
|
||||
* **`lib_ownership.sh`**: Single source of truth for framework-owned files and key-level registry files.
|
||||
* **`update.sh`**: In-place updater script installed into target `.mam_deploy/update.sh`.
|
||||
* **`remove.sh`**: Clean uninstaller script installed into target `.mam_deploy/remove.sh`.
|
||||
* **`generate-env.sh`**: Environment configuration bootstrap helper.
|
||||
* **`INSTALL.md`**: Detailed installation and quick-start user manual.
|
||||
* **`plugin.json`**: Metadata declaration file to register MAM as an installable plugin for AI Agent coding platforms (such as Claude Code, Antigravity, or other TUI clients).
|
||||
* **`gitea-ci.yml`**: CI/CD pipeline definition template for Gitea Actions (running ShellCheck linting on bash scripts, validation on python scripts, and compilation tests).
|
||||
* **`plugin.json`**: Metadata declaration file to register MAM as an installable plugin for AI Agent coding platforms.
|
||||
* **`gitea-ci.yml`**: CI/CD pipeline definition template for Gitea Actions (running ShellCheck linting, Python syntax checks, and pytest suite).
|
||||
|
||||
---
|
||||
|
||||
@@ -37,7 +40,7 @@ bash deploy/install_mam.sh --target /path/to/your/project
|
||||
Refer to **`INSTALL.md`** inside this directory for the full instructions and workflows.
|
||||
|
||||
> [!NOTE]
|
||||
> The local-clone installer does not ship `update.sh`/`remove.sh` to targets. To enable in-place updates, re-run the remote installer (`curl ... | bash`) or copy `deploy/update.sh` + `deploy/remove.sh` manually.
|
||||
> `install_mam.sh` automatically deploys `.mam_deploy/update.sh` and `.mam_deploy/remove.sh` into target workspaces, recording install manifests and asset hashes to enable clean updates and uninstalls.
|
||||
|
||||
### 3. Custom Fork / Private Mirror Installations
|
||||
If you run a private mirror or fork, you can override the source URLs during installation using environment variables:
|
||||
|
||||
@@ -33,6 +33,8 @@ jobs:
|
||||
shellcheck .agents/skills/multi-agent-mux-stop/scripts/stop_session.sh
|
||||
shellcheck .agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh
|
||||
shellcheck .agents/skills/multi-agent-mux-loop/scripts/run_loop.sh
|
||||
shellcheck .agents/hooks/loop_delegation_guard.sh
|
||||
shellcheck deploy/lib_ownership.sh
|
||||
shellcheck deploy/install.sh
|
||||
shellcheck deploy/install_mam.sh
|
||||
shellcheck deploy/generate-env.sh
|
||||
@@ -73,3 +75,34 @@ jobs:
|
||||
echo "🔍 Verifying Python file compilation..."
|
||||
python -m py_compile .agents/skills/multi-agent-mux-delegate-job/scripts/*.py
|
||||
echo "✅ All Python files compiled successfully."
|
||||
|
||||
test:
|
||||
name: Run Test Suite
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.10'
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install Test Dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
if [ -f requirements-dev.txt ]; then
|
||||
pip install -r requirements-dev.txt
|
||||
else
|
||||
pip install pytest
|
||||
fi
|
||||
if [ -f .agents/skills/multi-agent-mux-delegate-job/requirements.txt ]; then
|
||||
pip install -r .agents/skills/multi-agent-mux-delegate-job/requirements.txt
|
||||
fi
|
||||
|
||||
- name: Run Pytest
|
||||
run: |
|
||||
echo "🧪 Running full test suite..."
|
||||
pytest tests/ -q
|
||||
|
||||
|
||||
+237
-159
@@ -103,6 +103,8 @@ check_assets_present() {
|
||||
".agents/skills/multi-agent-mux-delegate-job/scripts/registry.py"
|
||||
".agents/skills/multi-agent-mux-status/scripts/status.sh"
|
||||
".agents/skills/multi-agent-mux-loop/scripts/run_loop.sh"
|
||||
".agents/hooks.json"
|
||||
".agents/hooks/loop_delegation_guard.sh"
|
||||
)
|
||||
for f in "${core_files[@]}"; do
|
||||
if [ ! -f "$dir/$f" ]; then
|
||||
@@ -112,19 +114,13 @@ check_assets_present() {
|
||||
return 0
|
||||
}
|
||||
|
||||
is_framework_owned() {
|
||||
case "$1" in
|
||||
.agents/skills/*) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Fetch orchestration assets if REFRESH=1 or if core assets are missing.
|
||||
if [ "$REFRESH" -eq 1 ] || [ "${MAM_SKIP_VENV:-0}" -eq 1 ] || ! check_assets_present "."; then
|
||||
echo "📥 Staging orchestration assets from Gitea repository..."
|
||||
STAGE_DIR="$(mktemp -d)"
|
||||
trap 'rm -rf "$STAGE_DIR"' EXIT
|
||||
|
||||
OWNERSHIP_LIB="$STAGE_DIR/deploy/lib_ownership.sh"
|
||||
FETCH_METHOD="archive"
|
||||
if [ -d "$REPO_URL" ]; then
|
||||
echo "🌐 Copying local working tree into a staging area..."
|
||||
@@ -137,14 +133,20 @@ if [ "$REFRESH" -eq 1 ] || [ "${MAM_SKIP_VENV:-0}" -eq 1 ] || ! check_assets_pre
|
||||
elif command -v curl &>/dev/null; then
|
||||
echo "🌐 Downloading and extracting archive into a staging area..."
|
||||
curl -fsSL "$ARCHIVE_URL" | tar -xz --strip-components=1 -C "$STAGE_DIR" \
|
||||
--exclude='*/.agents/reports/*' --exclude='*/.agents/references/*' \
|
||||
--exclude='*/MESSAGING.md' --exclude='*/BOOTSTRAP.md' --exclude='*/BOOTSTRAP.ko.md' 2>/dev/null || true
|
||||
--exclude='*/.agents/reports/*' --exclude='*/.agents/references/*' 2>/dev/null || true
|
||||
FETCH_METHOD="archive"
|
||||
else
|
||||
echo "❌ Error: neither 'git' nor 'curl' is available to fetch the skills." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$OWNERSHIP_LIB" ]; then
|
||||
echo "❌ Error: missing $OWNERSHIP_LIB; refusing to install with unknown ownership rules." >&2
|
||||
exit 1
|
||||
fi
|
||||
# shellcheck source=deploy/lib_ownership.sh
|
||||
. "$OWNERSHIP_LIB"
|
||||
|
||||
if ! check_assets_present "$STAGE_DIR"; then
|
||||
echo "❌ Error: fetched source is missing core runtime assets. Aborting." >&2
|
||||
exit 1
|
||||
@@ -178,8 +180,8 @@ with open(path, "w") as f:
|
||||
|
||||
# Safe refresh & fingerprint checking logic
|
||||
TS=$(date -u +%Y%m%dT%H%M%SZ)
|
||||
PRESERVED_COUNT=0
|
||||
MODIFIED_FILES=()
|
||||
FRAMEWORK_LEDGER=".mam/.framework_ledger.tmp"
|
||||
: > "$FRAMEWORK_LEDGER"
|
||||
|
||||
mkdir -p .agents
|
||||
( cd "$STAGE_DIR/.agents" && find . -type f -print ) | while IFS= read -r rel; do
|
||||
@@ -192,12 +194,16 @@ with open(path, "w") as f:
|
||||
mkdir -p "$(dirname "$dest")"
|
||||
|
||||
if is_framework_owned "$dest"; then
|
||||
# 3-way check using python3 inline
|
||||
STAGING_FILE="$STAGE_DIR/.agents/$rel"
|
||||
ACTION=$(python3 - "$dest" "$STAGING_FILE" ".mam/asset_hashes.txt" "$OVERWRITE_CUSTOM" <<'PY'
|
||||
printf '%s\t%s\n' "$STAGING_FILE" "$dest" >> "$FRAMEWORK_LEDGER"
|
||||
IS_REGISTRY=0
|
||||
if is_registry_file "$dest"; then IS_REGISTRY=1; fi
|
||||
ACTION=$(python3 - "$dest" "$STAGING_FILE" ".mam/asset_hashes.txt" "$OVERWRITE_CUSTOM" "$IS_REGISTRY" <<'PY'
|
||||
import sys, hashlib, os
|
||||
|
||||
target_path, staging_path, hash_db_path, force_overwrite = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4] == "1"
|
||||
target_path, staging_path, hash_db_path, force_overwrite, is_registry = (
|
||||
sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4] == "1", sys.argv[5] == "1"
|
||||
)
|
||||
|
||||
def file_sha(path):
|
||||
if not os.path.exists(path):
|
||||
@@ -235,6 +241,8 @@ elif target_sha == db_sha:
|
||||
else:
|
||||
if force_overwrite:
|
||||
print("FORCE_OVERWRITE_CUSTOM")
|
||||
elif is_registry:
|
||||
print("MERGE_REGISTRY")
|
||||
else:
|
||||
print("PRESERVE_CUSTOM")
|
||||
PY
|
||||
@@ -251,6 +259,81 @@ PY
|
||||
echo "$dest" >> "$MANIFEST_FILE"
|
||||
fi
|
||||
;;
|
||||
MERGE_REGISTRY)
|
||||
BACKUP_DIR=".mam/skill-backups/$TS/$(dirname "$dest")"
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
cp "$dest" "$BACKUP_DIR/"
|
||||
MERGE_MSG=$(python3 - "$dest" "$STAGING_FILE" ".mam/base/$dest" <<'MERGEREG'
|
||||
import json, os, sys
|
||||
|
||||
target_path, upstream_path, base_path = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||
|
||||
def load(path):
|
||||
if not os.path.exists(path):
|
||||
return None
|
||||
try:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
d = json.load(f)
|
||||
return d if isinstance(d, dict) else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
target, upstream, base = load(target_path), load(upstream_path), load(base_path)
|
||||
|
||||
if target is None or upstream is None:
|
||||
print("unmergeable (not a JSON object) -- kept your file untouched")
|
||||
sys.exit(0)
|
||||
|
||||
merged = dict(target)
|
||||
added, updated, conflicts, retired = [], [], [], []
|
||||
|
||||
if base is None:
|
||||
for k, v in upstream.items():
|
||||
if k not in merged:
|
||||
merged[k] = v
|
||||
added.append(k)
|
||||
else:
|
||||
for k, v in upstream.items():
|
||||
if k not in base:
|
||||
if k not in merged:
|
||||
merged[k] = v
|
||||
added.append(k)
|
||||
elif merged[k] != v:
|
||||
conflicts.append(k)
|
||||
elif k not in merged:
|
||||
added.append(k)
|
||||
merged[k] = v
|
||||
elif merged[k] == base[k]:
|
||||
if merged[k] != v:
|
||||
merged[k] = v
|
||||
updated.append(k)
|
||||
elif merged[k] != v:
|
||||
conflicts.append(k)
|
||||
for k in base:
|
||||
if k not in upstream and k in merged and merged[k] == base[k]:
|
||||
del merged[k]
|
||||
retired.append(k)
|
||||
|
||||
if merged != target:
|
||||
tmp = target_path + ".merge.tmp"
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
json.dump(merged, f, indent=2)
|
||||
f.write("\n")
|
||||
os.replace(tmp, target_path)
|
||||
|
||||
bits = []
|
||||
for label, keys in (("added", added), ("updated", updated),
|
||||
("retired", retired), ("kept yours", conflicts)):
|
||||
if keys:
|
||||
bits.append("%s %s" % (label, ", ".join(sorted(keys))))
|
||||
print("; ".join(bits) if bits else "already in sync")
|
||||
MERGEREG
|
||||
) || MERGE_MSG="merge failed -- kept your file untouched"
|
||||
echo "MERGED:$dest:$MERGE_MSG"
|
||||
if ! grep -Fqx "$dest" "$MANIFEST_FILE" 2>/dev/null; then
|
||||
echo "$dest" >> "$MANIFEST_FILE"
|
||||
fi
|
||||
;;
|
||||
PRESERVE_CUSTOM)
|
||||
BACKUP_DIR=".mam/skill-backups/$TS/$(dirname "$dest")"
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
@@ -272,29 +355,12 @@ PY
|
||||
done | while IFS= read -r line; do
|
||||
if [[ "$line" == PRESERVED:* ]]; then
|
||||
echo "ℹ️ Local modification detected: ${line#PRESERVED:}" >&2
|
||||
elif [[ "$line" == MERGED:* ]]; then
|
||||
_mam_rest="${line#MERGED:}"
|
||||
echo "🔀 Registry merged: ${_mam_rest%%:*} (${_mam_rest#*:})" >&2
|
||||
fi
|
||||
done
|
||||
|
||||
# Re-build asset_hashes.txt for all framework owned files
|
||||
python3 - .mam/asset_hashes.txt <<'PY'
|
||||
import os, hashlib, sys
|
||||
|
||||
hash_db_path = sys.argv[1]
|
||||
hashes = []
|
||||
|
||||
for root, _, files in os.walk(".agents/skills"):
|
||||
for file in files:
|
||||
path = os.path.join(root, file)
|
||||
h = hashlib.sha256()
|
||||
with open(path, "rb") as f:
|
||||
while chunk := f.read(65536):
|
||||
h.update(chunk)
|
||||
hashes.append(f"{h.hexdigest()} {path}\n")
|
||||
|
||||
with open(hash_db_path, "w") as f:
|
||||
f.writelines(sorted(hashes))
|
||||
PY
|
||||
|
||||
# Copy root docs (R-2 essential set)
|
||||
ROOT_DOCS="AGENTS.md"
|
||||
if [ "${MAM_INSTALL_DOCS:-minimal}" = "full" ]; then
|
||||
@@ -330,12 +396,53 @@ PY
|
||||
echo ".mam.env.example" >> "$MANIFEST_FILE"
|
||||
fi
|
||||
|
||||
if [ -f "$STAGE_DIR/deploy/INSTALL.md" ] && [ ! -e ".agents/INSTALL.md" ]; then
|
||||
if [ -f "$STAGE_DIR/deploy/INSTALL.md" ]; then
|
||||
mkdir -p .agents
|
||||
cp "$STAGE_DIR/deploy/INSTALL.md" .agents/INSTALL.md
|
||||
echo ".agents/INSTALL.md" >> "$MANIFEST_FILE"
|
||||
cp -f "$STAGE_DIR/deploy/INSTALL.md" .agents/INSTALL.md
|
||||
printf '%s\t%s\n' "$STAGE_DIR/deploy/INSTALL.md" ".agents/INSTALL.md" \
|
||||
>> "$FRAMEWORK_LEDGER"
|
||||
if ! grep -Fqx ".agents/INSTALL.md" "$MANIFEST_FILE" 2>/dev/null; then
|
||||
echo ".agents/INSTALL.md" >> "$MANIFEST_FILE"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Re-build asset_hashes.txt from the UPSTREAM copy of every framework-owned file
|
||||
python3 - .mam/asset_hashes.txt "$FRAMEWORK_LEDGER" <<'HASHDB'
|
||||
import os, hashlib, sys
|
||||
|
||||
hash_db_path, ledger_path = sys.argv[1], sys.argv[2]
|
||||
|
||||
def sha(path):
|
||||
h = hashlib.sha256()
|
||||
with open(path, "rb") as f:
|
||||
while chunk := f.read(65536):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
hashes = {}
|
||||
with open(ledger_path) as f:
|
||||
for line in f:
|
||||
line = line.rstrip("\n")
|
||||
if "\t" not in line:
|
||||
continue
|
||||
upstream, dest = line.split("\t", 1)
|
||||
if os.path.exists(upstream):
|
||||
hashes[dest] = sha(upstream)
|
||||
|
||||
with open(hash_db_path, "w") as f:
|
||||
f.writelines(sorted("%s %s\n" % (h, d) for d, h in hashes.items()))
|
||||
HASHDB
|
||||
|
||||
# Snapshot registry base from upstream
|
||||
while IFS="$(printf '\t')" read -r _up _dest; do
|
||||
[ -n "${_dest:-}" ] || continue
|
||||
is_registry_file "$_dest" || continue
|
||||
[ -f "$_up" ] || continue
|
||||
mkdir -p ".mam/base/$(dirname "$_dest")"
|
||||
cp -f "$_up" ".mam/base/$_dest"
|
||||
done < "$FRAMEWORK_LEDGER"
|
||||
rm -f "$FRAMEWORK_LEDGER"
|
||||
|
||||
# Record version stamp
|
||||
STAGE_COMMIT="unknown"
|
||||
if [ -d "$STAGE_DIR/.git" ]; then
|
||||
@@ -359,26 +466,79 @@ if ! check_assets_present "."; then
|
||||
fi
|
||||
echo "✅ Orchestration skills present."
|
||||
|
||||
echo "📂 Ensuring metadata directory structure (.mam/)..."
|
||||
mkdir -p .mam/jobs .mam/delegate_job_logs
|
||||
# --- 3. Python Virtual Environment (.venv) Setup ---
|
||||
if [ "${MAM_SKIP_VENV:-0}" -eq 1 ]; then
|
||||
echo "⏩ Skipping virtualenv setup (MAM_SKIP_VENV=1)."
|
||||
else
|
||||
echo "🐍 Bootstrapping Python virtual environment ($VENV_NAME)..."
|
||||
if [ ! -d "$VENV_NAME" ]; then
|
||||
python3 -m venv "$VENV_NAME"
|
||||
echo "✅ Created virtualenv at $VENV_NAME."
|
||||
else
|
||||
echo "ℹ️ Virtualenv ($VENV_NAME) already exists. Reusing."
|
||||
fi
|
||||
|
||||
if [ -O .mam ]; then
|
||||
chmod 0700 .mam
|
||||
echo "📦 Installing/upgrading core dependencies inside $VENV_NAME..."
|
||||
# shellcheck disable=SC1091
|
||||
source "$VENV_NAME/bin/activate"
|
||||
python -m pip install --upgrade pip -q
|
||||
|
||||
REQ_FILE=".agents/skills/multi-agent-mux-delegate-job/requirements.txt"
|
||||
if [ -f "$REQ_FILE" ]; then
|
||||
pip install -r "$REQ_FILE" -q
|
||||
echo "✅ Installed dependencies from $REQ_FILE."
|
||||
else
|
||||
pip install "paho-mqtt>=2.0.0" pyyaml -q
|
||||
echo "✅ Installed default fallback dependencies (paho-mqtt, pyyaml)."
|
||||
fi
|
||||
deactivate
|
||||
fi
|
||||
|
||||
# R-4: Manage .gitignore block (never put .gitignore in manifest)
|
||||
# --- 4. Environment Configuration (.mam.env) ---
|
||||
echo "⚙️ Configuring environment variables..."
|
||||
MAM_ENV=".mam.env"
|
||||
LEGACY_ENV=".env"
|
||||
|
||||
if [ -f "$LEGACY_ENV" ] && [ ! -f "$MAM_ENV" ]; then
|
||||
echo "📦 Migrating existing $LEGACY_ENV to $MAM_ENV..."
|
||||
cp "$LEGACY_ENV" "$MAM_ENV"
|
||||
fi
|
||||
|
||||
if [ ! -f "$MAM_ENV" ]; then
|
||||
echo "📝 Initializing $MAM_ENV with default orchestration configuration..."
|
||||
MAM_CLIENT_PREFIX="mam-agent"
|
||||
MAM_PORT="1883"
|
||||
|
||||
cat <<EOF > "$MAM_ENV"
|
||||
# ==============================================================================
|
||||
# Multi-Agent Mux (MAM) Environment Configuration
|
||||
# ==============================================================================
|
||||
# Generated by install.sh at $(date -u +'%Y-%m-%dT%H:%M:%SZ')
|
||||
|
||||
MQTT_BROKER=localhost
|
||||
MQTT_PORT=$MAM_PORT
|
||||
MQTT_CLIENT_ID_PREFIX=$MAM_CLIENT_PREFIX
|
||||
MQTT_RETRY_INTERVAL=2
|
||||
MQTT_MAX_RETRIES=5
|
||||
|
||||
HERDR_SERVER_NAME=multi-agent-mux
|
||||
HERDR_SOCKET_PATH=$HOME/.config/herdr/herdr.sock
|
||||
|
||||
MAM_LOG_LEVEL=INFO
|
||||
MAM_EVENT_RETENTION_DAYS=7
|
||||
EOF
|
||||
echo "$MAM_ENV" >> "$MANIFEST_FILE"
|
||||
echo "✅ Initialized $MAM_ENV."
|
||||
else
|
||||
echo "ℹ️ $MAM_ENV already exists. Preserving existing settings."
|
||||
fi
|
||||
|
||||
# Ensure gitignore handles MAM isolation files (R-4: managed block)
|
||||
GITIGNORE=".gitignore"
|
||||
MAM_GI_START="# >>> MAM managed block (managed by install.sh — do not edit) >>>"
|
||||
MAM_GI_END="# <<< MAM managed block <<<"
|
||||
|
||||
if [ "${MAM_SKIP_GITIGNORE:-0}" != "1" ]; then
|
||||
GI_CREATED=0
|
||||
[ -e .gitignore ] || { touch .gitignore; GI_CREATED=1; }
|
||||
|
||||
if ! grep -q '^gitignore_created=' .mam/install_state 2>/dev/null; then
|
||||
echo "gitignore_created=$GI_CREATED" >> .mam/install_state
|
||||
fi
|
||||
|
||||
python3 - .gitignore "$MAM_GI_START" "$MAM_GI_END" <<'PY'
|
||||
python3 - "$GITIGNORE" "$MAM_GI_START" "$MAM_GI_END" <<'PY'
|
||||
import sys, os
|
||||
|
||||
gi_path, start_marker, end_marker = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||
@@ -427,117 +587,35 @@ if not block_inserted:
|
||||
with open(gi_path, "w") as f:
|
||||
f.writelines(new_lines)
|
||||
PY
|
||||
|
||||
if [ "${MAM_SKIP_GITIGNORE:-0}" -eq 0 ]; then
|
||||
python3 -c '
|
||||
import os
|
||||
if os.path.exists(".mam/install_state"):
|
||||
with open(".mam/install_state", "a") as f:
|
||||
f.write("gitignore_created=1\n")
|
||||
else:
|
||||
os.makedirs(".mam", exist_ok=True)
|
||||
with open(".mam/install_state", "w") as f:
|
||||
f.write("gitignore_created=1\n")
|
||||
'
|
||||
fi
|
||||
|
||||
# --- 3. Check Network File System (NFS) Warnings ---
|
||||
echo "💾 Detecting file system mount type..."
|
||||
if command -v df &>/dev/null && command -v mount &>/dev/null; then
|
||||
MOUNTPOINT="$(df --output=target . 2>/dev/null | tail -1 || echo "")"
|
||||
if [ -n "$MOUNTPOINT" ]; then
|
||||
if mount | grep -q "$MOUNTPOINT.*nfs\|$MOUNTPOINT.*cifs\|$MOUNTPOINT.*fuse.sshfs"; then
|
||||
echo "⚠️ WARNING: Target directory is on a network filesystem."
|
||||
else
|
||||
echo "✅ File system supports WAL (Local storage detected)."
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
echo "===================================================================="
|
||||
echo "🎉 Multi-Agent Mux (MAM) Installation Completed Successfully!"
|
||||
echo "===================================================================="
|
||||
cat <<EOF
|
||||
|
||||
# --- 4. Python Virtual Environment Setup ---
|
||||
if [ "${MAM_SKIP_VENV:-0}" != "1" ]; then
|
||||
echo "🐍 Bootstrapping Python virtual environment (.venv)..."
|
||||
if [ ! -d "$VENV_NAME" ]; then
|
||||
python3 -m venv "$VENV_NAME"
|
||||
echo "✅ Virtual environment created."
|
||||
else
|
||||
echo "ℹ️ Virtual environment (.venv) already exists. Skipping creation."
|
||||
fi
|
||||
--------------------------------------------------------------------------------
|
||||
💡 Next Steps:
|
||||
1. Initialize a new isolated session:
|
||||
$ bash .agents/skills/multi-agent-mux-create/scripts/create_session.sh \\
|
||||
--workspace "$TARGET_DIR" --agent claude --role developer --isolate
|
||||
|
||||
source "$VENV_NAME"/bin/activate
|
||||
pip install --upgrade pip
|
||||
2. Monitor live agent states:
|
||||
$ bash .agents/skills/multi-agent-mux-status/scripts/status.sh
|
||||
|
||||
REQ_FILE=".agents/skills/multi-agent-mux-delegate-job/requirements.txt"
|
||||
if [ -f "$REQ_FILE" ]; then
|
||||
echo "📦 Installing backplane dependencies from $REQ_FILE..."
|
||||
pip install -r "$REQ_FILE"
|
||||
echo "✅ Dependencies installed successfully."
|
||||
else
|
||||
pip install "paho-mqtt>=2.0.0" pyyaml
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- 5. Generate Environment Template ---
|
||||
ENV_FILE=".mam.env"
|
||||
ENV_EXAMPLE=".mam.env.example"
|
||||
|
||||
migrate_legacy_env() {
|
||||
local manifest=".mam/install_manifest.txt"
|
||||
[ -f ".mam.env" ] && return 0
|
||||
[ -f ".env" ] || return 0
|
||||
if [ "${MAM_LEGACY_ENV_OWNED:-0}" = "1" ] || { [ -f "$manifest" ] && grep -Fqx ".env" "$manifest" 2>/dev/null; }; then
|
||||
mv -f ".env" ".mam.env"
|
||||
chmod 0600 ".mam.env" 2>/dev/null || true
|
||||
if [ -f "$manifest" ]; then
|
||||
if grep -Fqx ".env" "$manifest" 2>/dev/null; then
|
||||
python3 -c '
|
||||
import sys
|
||||
path = sys.argv[1]
|
||||
with open(path, "r") as f:
|
||||
lines = f.readlines()
|
||||
with open(path, "w") as f:
|
||||
for line in lines:
|
||||
if line.strip() == ".env":
|
||||
f.write(".mam.env\n")
|
||||
else:
|
||||
f.write(line)
|
||||
' "$manifest" 2>/dev/null || true
|
||||
else
|
||||
echo ".mam.env" >> "$manifest"
|
||||
fi
|
||||
fi
|
||||
echo "ℹ️ Legacy MAM config migrated: .env -> .mam.env"
|
||||
else
|
||||
echo "ℹ️ Existing .env left untouched (ownership unproven)."
|
||||
echo " MAM will read it via the deprecated fallback."
|
||||
echo " To migrate explicitly: deploy/generate-env.sh --migrate-legacy"
|
||||
fi
|
||||
}
|
||||
|
||||
migrate_legacy_env
|
||||
|
||||
if [ ! -f "$ENV_FILE" ] && [ ! -f ".env" ] && [ ! -f ".env.update-tmp" ]; then
|
||||
if [ -f "$ENV_EXAMPLE" ]; then
|
||||
echo "📝 Creating configuration from $ENV_EXAMPLE..."
|
||||
cp "$ENV_EXAMPLE" "$ENV_FILE"
|
||||
else
|
||||
echo "📝 Creating default $ENV_FILE..."
|
||||
touch "$ENV_FILE"
|
||||
fi
|
||||
|
||||
cat <<EOF >> "$ENV_FILE"
|
||||
|
||||
# === Installer-applied active defaults ===
|
||||
MQTT_BROKER=broker.hivemq.com
|
||||
MQTT_PORT=1883
|
||||
MQTT_TLS=0
|
||||
MQTT_CLIENT_ID_PREFIX=mam-agent
|
||||
HERDR_SERVER_NAME=default
|
||||
3. Update installed skills in the future:
|
||||
$ bash .mam_deploy/update.sh
|
||||
--------------------------------------------------------------------------------
|
||||
EOF
|
||||
chmod 0600 "$ENV_FILE"
|
||||
echo "✅ Config file .mam.env initialized with chmod 0600."
|
||||
|
||||
mkdir -p .mam
|
||||
touch .mam/install_manifest.txt
|
||||
echo "$ENV_FILE" >> .mam/install_manifest.txt
|
||||
else
|
||||
if [ -f "$ENV_FILE" ]; then
|
||||
echo "ℹ️ $ENV_FILE already exists. Skipping config override."
|
||||
else
|
||||
echo "ℹ️ Legacy environment detected. Preserved without shadowing."
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "===================================================================="
|
||||
echo "🎉 Installation complete!"
|
||||
echo "✨ You can now run the status or monitor skills."
|
||||
echo "💡 Hint: Try executing: .venv/bin/python .agents/skills/multi-agent-mux-delegate-job/scripts/registry.py list"
|
||||
echo "===================================================================="
|
||||
|
||||
@@ -81,6 +81,14 @@ if [ "$SRC_DIR" = "$TARGET_DIR" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
OWNERSHIP_LIB="$SRC_DIR/deploy/lib_ownership.sh"
|
||||
if [ ! -f "$OWNERSHIP_LIB" ]; then
|
||||
log_error "Missing $OWNERSHIP_LIB; refusing to install with unknown ownership rules."
|
||||
exit 1
|
||||
fi
|
||||
# shellcheck source=deploy/lib_ownership.sh
|
||||
. "$OWNERSHIP_LIB"
|
||||
|
||||
# 1. Dependency Checks
|
||||
log_info "Verifying host dependencies..."
|
||||
DEPS=(herdr python3 rsync uuidgen)
|
||||
@@ -147,6 +155,59 @@ if [ -f "$SRC_DIR/deploy/INSTALL.md" ]; then
|
||||
log_ok "Copied INSTALL.md user manual into target .agents/"
|
||||
fi
|
||||
|
||||
# Record an install manifest (.mam/install_manifest.txt) and state hashes (R-5, F14)
|
||||
log_info "Recording install manifest (.mam/install_manifest.txt)..."
|
||||
mkdir -p "$TARGET_DIR/.mam"
|
||||
MANIFEST_FILE="$TARGET_DIR/.mam/install_manifest.txt"
|
||||
: > "$MANIFEST_FILE"
|
||||
( cd "$TARGET_DIR" && find .agents -type f -print ) >> "$MANIFEST_FILE"
|
||||
for extra in ".mam.env.example" ".mam_deploy/remove.sh" ".mam_deploy/update.sh" \
|
||||
"scripts/generate-env.sh"; do
|
||||
if [ -e "$TARGET_DIR/$extra" ]; then
|
||||
echo "$extra" >> "$MANIFEST_FILE"
|
||||
fi
|
||||
done
|
||||
log_ok "Recorded $(wc -l < "$MANIFEST_FILE" | tr -d ' ') manifest entries."
|
||||
|
||||
OWNED_LIST="$TARGET_DIR/.mam/.owned_paths.tmp"
|
||||
: > "$OWNED_LIST"
|
||||
while IFS= read -r rel; do
|
||||
rel="${rel#./}"
|
||||
if is_framework_owned "$rel"; then
|
||||
echo "$rel" >> "$OWNED_LIST"
|
||||
if is_registry_file "$rel"; then
|
||||
mkdir -p "$TARGET_DIR/.mam/base/$(dirname "$rel")"
|
||||
cp -f "$TARGET_DIR/$rel" "$TARGET_DIR/.mam/base/$rel"
|
||||
fi
|
||||
fi
|
||||
done < <( cd "$TARGET_DIR" && find .agents -type f -print )
|
||||
|
||||
python3 - "$TARGET_DIR" "$OWNED_LIST" <<'MAMSTATE'
|
||||
import hashlib, os, sys
|
||||
|
||||
target, listing = sys.argv[1], sys.argv[2]
|
||||
|
||||
def sha(path):
|
||||
h = hashlib.sha256()
|
||||
with open(path, "rb") as f:
|
||||
while chunk := f.read(65536):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
rows = []
|
||||
with open(listing) as f:
|
||||
for rel in f:
|
||||
rel = rel.strip()
|
||||
full = os.path.join(target, rel)
|
||||
if rel and os.path.isfile(full):
|
||||
rows.append("%s %s\n" % (sha(full), rel))
|
||||
|
||||
with open(os.path.join(target, ".mam", "asset_hashes.txt"), "w") as f:
|
||||
f.writelines(sorted(rows))
|
||||
MAMSTATE
|
||||
rm -f "$OWNED_LIST"
|
||||
log_ok "Recorded asset hashes and registry base under .mam/."
|
||||
|
||||
# 3. Copy AGENTS.md to root or inject guidelines pointer
|
||||
log_info "Configuring developer guidelines (AGENTS.md)..."
|
||||
AGENTS_FILE="$TARGET_DIR/AGENTS.md"
|
||||
|
||||
Executable
+29
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
# Single source of truth for "which shipped files does the framework own".
|
||||
# Sourced by deploy/install.sh and deploy/install_mam.sh. Paths are workspace
|
||||
# relative (e.g. ".agents/hooks.json"); no leading "./".
|
||||
|
||||
# Framework-owned == shipped by this repo and safe to refresh in place.
|
||||
# Anything outside this set is the user's file and is only ever created
|
||||
# when absent.
|
||||
is_framework_owned() {
|
||||
case "$1" in
|
||||
.agents/skills/*) return 0 ;;
|
||||
.agents/hooks.json) return 0 ;;
|
||||
.agents/hooks/*) return 0 ;;
|
||||
.agents/MULTI_AGENT_RULES*.md) return 0 ;;
|
||||
.agents/INSTALL.md) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Registry files are key-value documents that BOTH sides legitimately write:
|
||||
# we ship framework hooks, the user registers their own. Preserving such a
|
||||
# file wholesale pins the workspace to its old hook set, so these get a
|
||||
# key-level 3-way merge instead.
|
||||
is_registry_file() {
|
||||
case "$1" in
|
||||
.agents/hooks.json) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
+13
-8
@@ -84,10 +84,16 @@ else
|
||||
".agents/skills/lib.sh"
|
||||
".agents/skills/multi-agent-mux-create"
|
||||
".agents/skills/multi-agent-mux-delegate-job"
|
||||
".agents/skills/multi-agent-mux-loop"
|
||||
".agents/skills/multi-agent-mux-monitor"
|
||||
".agents/skills/multi-agent-mux-resume"
|
||||
".agents/skills/multi-agent-mux-status"
|
||||
".agents/skills/multi-agent-mux-stop"
|
||||
".agents/hooks"
|
||||
".agents/hooks.json"
|
||||
".agents/MULTI_AGENT_RULES.md"
|
||||
".agents/MULTI_AGENT_RULES.ko.md"
|
||||
".agents/INSTALL.md"
|
||||
".venv"
|
||||
".mam"
|
||||
".mam_deploy"
|
||||
@@ -172,14 +178,13 @@ if [ ${#manifest_files[@]} -gt 0 ]; then
|
||||
delete_asset "$f"
|
||||
done
|
||||
else
|
||||
echo "⚠️ No manifest found. Deleting standard MAM skills..."
|
||||
delete_asset ".agents/skills/lib.sh"
|
||||
delete_asset ".agents/skills/multi-agent-mux-create"
|
||||
delete_asset ".agents/skills/multi-agent-mux-delegate-job"
|
||||
delete_asset ".agents/skills/multi-agent-mux-monitor"
|
||||
delete_asset ".agents/skills/multi-agent-mux-resume"
|
||||
delete_asset ".agents/skills/multi-agent-mux-status"
|
||||
delete_asset ".agents/skills/multi-agent-mux-stop"
|
||||
echo "⚠️ No manifest found. Deleting standard MAM assets..."
|
||||
for asset in ${fallback_assets[@]+"${fallback_assets[@]}"}; do
|
||||
case "$asset" in
|
||||
.venv|.mam|.mam_deploy) continue ;; # handled explicitly further down
|
||||
esac
|
||||
delete_asset "$asset"
|
||||
done
|
||||
fi
|
||||
|
||||
if [ -d ".agents" ]; then
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
pytest>=8.0
|
||||
+60
-10
@@ -89,21 +89,35 @@ if not os.path.exists(state_file):
|
||||
lock_f = open(state_file + ".lock", "w")
|
||||
fcntl.flock(lock_f, fcntl.LOCK_EX)
|
||||
|
||||
with open(state_file, 'r') as f:
|
||||
state = json.load(f)
|
||||
state = {"workspaces": [], "agents": {}, "calls": []}
|
||||
if os.path.exists(state_file):
|
||||
for _retry in range(10):
|
||||
try:
|
||||
with open(state_file, 'r') as f:
|
||||
content = f.read().strip()
|
||||
if content:
|
||||
state = json.loads(content)
|
||||
break
|
||||
except Exception:
|
||||
import time
|
||||
time.sleep(0.05)
|
||||
|
||||
# Record the command call
|
||||
state["calls"].append(sys.argv[1:])
|
||||
|
||||
def save_state():
|
||||
with open(state_file, 'w') as f:
|
||||
tmp_state = state_file + f".tmp.{os.getpid()}"
|
||||
with open(tmp_state, 'w') as f:
|
||||
json.dump(state, f, indent=2)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.replace(tmp_state, state_file)
|
||||
|
||||
# Save calls immediately so they persist even if we exit early or error out
|
||||
save_state()
|
||||
|
||||
args = sys.argv[1:]
|
||||
if args and args[0] == "--session":
|
||||
while args and args[0] in ("-L", "--server", "-s", "--session"):
|
||||
if len(args) > 1:
|
||||
args = args[2:]
|
||||
else:
|
||||
@@ -227,7 +241,7 @@ elif cmd1 == "agent":
|
||||
"cwd": cwd or "TMP_PATH_PLACEHOLDER",
|
||||
"workspace_id": ws or "w1",
|
||||
"pid": 9999,
|
||||
"pane_id": "w1:p1",
|
||||
"pane_id": f"w1:p{len(agents)+1}",
|
||||
"command": " ".join(agent_cmd),
|
||||
"buffer": buffer_content
|
||||
}
|
||||
@@ -259,7 +273,7 @@ elif cmd1 == "agent":
|
||||
os.makedirs(proj_dir, exist_ok=True)
|
||||
jsonl_file = os.path.join(proj_dir, f"{session_uuid}.jsonl")
|
||||
with open(jsonl_file, 'w') as jf:
|
||||
jf.write(json.dumps({"sessionId": session_uuid}) + "\\\\n")
|
||||
jf.write(json.dumps({"sessionId": session_uuid}) + "\\n")
|
||||
elif agent_type == "agy":
|
||||
db_dir = os.path.join(home_dir, ".gemini", "antigravity-cli", "conversations")
|
||||
os.makedirs(db_dir, exist_ok=True)
|
||||
@@ -307,6 +321,7 @@ elif cmd1 == "agent":
|
||||
sys.exit(1)
|
||||
name = args[2]
|
||||
agents = state.get("agents", {})
|
||||
sys.stderr.write(f"[mock_herdr] agent get '{name}' — known agents: {list(agents.keys())}\\n")
|
||||
if name in agents:
|
||||
agent_data = agents[name]
|
||||
pane_info = {
|
||||
@@ -365,9 +380,19 @@ elif cmd1 == "agent":
|
||||
sys.exit(1)
|
||||
|
||||
elif cmd1 == "session":
|
||||
if len(args) < 2:
|
||||
sys.exit(0)
|
||||
cmd2 = args[1]
|
||||
if cmd2 == "list":
|
||||
session_names = ["custom_server", "default", "multi-agent-mux"]
|
||||
session_names.extend(list(state.get("agents", {}).keys()))
|
||||
if os.environ.get("MAM_SESS"):
|
||||
session_names.append(os.environ["MAM_SESS"])
|
||||
res = {"sessions": [{"name": s, "running": True} for s in set(session_names)]}
|
||||
print(json.dumps(res))
|
||||
sys.exit(0)
|
||||
if len(args) < 3:
|
||||
sys.exit(1)
|
||||
cmd2 = args[1]
|
||||
name = args[2]
|
||||
agents = state.get("agents", {})
|
||||
if cmd2 == "stop":
|
||||
@@ -441,13 +466,38 @@ elif cmd1 == "pane":
|
||||
save_state()
|
||||
sys.exit(0)
|
||||
|
||||
elif cmd1 == "list-panes":
|
||||
session_target = ""
|
||||
if "-t" in args:
|
||||
session_target = args[args.index("-t") + 1]
|
||||
agents = state.get("agents", {})
|
||||
if session_target in agents:
|
||||
data = agents[session_target]
|
||||
pid = data.get("pid", 9999)
|
||||
cwd = data.get("cwd", "TMP_PATH_PLACEHOLDER")
|
||||
cmd = data.get("command", "claude")
|
||||
print(f"{pid}|{cwd}|{cmd}")
|
||||
sys.exit(0)
|
||||
else:
|
||||
sys.exit(1)
|
||||
|
||||
elif cmd1 == "has-session":
|
||||
sess_target = ""
|
||||
if "-t" in args:
|
||||
sess_target = args[args.index("-t") + 1]
|
||||
elif len(args) > 1:
|
||||
sess_target = args[1]
|
||||
agents = state.get("agents", {})
|
||||
if sess_target in agents:
|
||||
sys.exit(0)
|
||||
else:
|
||||
sys.exit(1)
|
||||
|
||||
elif cmd1 == "ls":
|
||||
if "-F" in args:
|
||||
for name, data in state.get("agents", {}).items():
|
||||
print(f"{name}|999999")
|
||||
print(name + "|999999")
|
||||
sys.exit(0)
|
||||
with open("/tmp/debug_mock_herdr.log", "a") as f_debug:
|
||||
f_debug.write(f"ARGS: {sys.argv[1:]} | AGENTS: {list(state.get('agents', {}).keys())} | PATH: {os.path.exists(state_file)}\\n")
|
||||
agents_list = []
|
||||
for name, data in state.get("agents", {}).items():
|
||||
agents_list.append({
|
||||
|
||||
@@ -1,274 +0,0 @@
|
||||
"""A-3 — shift buffer isolation & garbage collection.
|
||||
|
||||
Defects addressed:
|
||||
① Colliding buffer names (e.g. constant "sks_adhoc" or "sks_onboard") caused
|
||||
cross-agent prompt contamination and silent buffer deletion loss.
|
||||
② Call-unique buffer names (sks_<job>_<pid>_<rand>) fixed ① but introduced a
|
||||
permanent leak of abandoned buffers (SIGINT/SIGTERM/timeout between
|
||||
set-buffer and delete-buffer). Fixed with automatic GC (-mmin +60).
|
||||
③ F2's atomic write created DOT-prefixed temp files ('.buf_sks_....tmp') that
|
||||
F4's initial GC pattern ('buf_sks_*') missed. Fixed by widening the GC
|
||||
pattern to include '.buf_sks_*' and cleaning up on write/rename failure.
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
BARE_PATH = "/usr/bin:/bin:/usr/sbin:/sbin"
|
||||
|
||||
|
||||
def _bash(sandbox, snippet, extra_env=None):
|
||||
env = dict(os.environ)
|
||||
env["PATH"] = BARE_PATH
|
||||
env["WORKSPACE_ROOT"] = str(sandbox)
|
||||
if extra_env:
|
||||
env.update(extra_env)
|
||||
script = f'source "{sandbox}/.agents/skills/lib.sh" >/dev/null 2>&1\n{snippet}'
|
||||
return subprocess.run(["bash", "-c", script], capture_output=True,
|
||||
text=True, cwd=str(sandbox), env=env)
|
||||
|
||||
|
||||
# Harness to invoke send_keys_safe without a running herdr daemon or tmux
|
||||
def _run_send_keys_safe(sandbox, sess, text, job_id=None):
|
||||
buflog = sandbox / f"buflog_{time.time_ns()}.txt"
|
||||
job_arg = f' "{job_id}"' if job_id is not None else ""
|
||||
snippet = f"""
|
||||
_pane_quiescent() {{ return 0; }}
|
||||
_pane_dialog_open() {{ return 1; }}
|
||||
_pane_capture() {{ printf '%s' "$SKS_FAKE_PANE"; }}
|
||||
_sks_herdr() {{
|
||||
case "$1" in
|
||||
set-buffer|paste-buffer|delete-buffer)
|
||||
[ "$2" = "-b" ] && printf '%s %s\\n' "$1" "$3" >> "{buflog}" ;;
|
||||
esac
|
||||
return 0
|
||||
}}
|
||||
export SKS_FAKE_PANE="● {text}"
|
||||
send_keys_safe "{sess}" "{text}"{job_arg}
|
||||
"""
|
||||
res = _bash(sandbox, snippet)
|
||||
log_lines = buflog.read_text().splitlines() if buflog.exists() else []
|
||||
return res, log_lines
|
||||
|
||||
|
||||
# X-1 — Two calls with the same job_id (e.g. "onboard") get DIFFERENT buffer names
|
||||
def test_a3_same_job_id_gets_different_buffer_names(mam_sandbox):
|
||||
res1, lines1 = _run_send_keys_safe(mam_sandbox, "test_sess1", "hello", "onboard")
|
||||
res2, lines2 = _run_send_keys_safe(mam_sandbox, "test_sess2", "world", "onboard")
|
||||
|
||||
assert res1.returncode == 0 and res2.returncode == 0
|
||||
buf1 = lines1[0].split()[1]
|
||||
buf2 = lines2[0].split()[1]
|
||||
assert buf1 != buf2, f"buffer names collided: {buf1} == {buf2}"
|
||||
|
||||
|
||||
# X-2 — Within one call, set-buffer, paste-buffer, and delete-buffer use the SAME name
|
||||
def test_a3_triple_uses_same_buffer_name(mam_sandbox):
|
||||
res, lines = _run_send_keys_safe(mam_sandbox, "test_sess1", "test payload", "job1")
|
||||
assert res.returncode == 0
|
||||
assert len(lines) == 3
|
||||
cmd0, name0 = lines[0].split()
|
||||
cmd1, name1 = lines[1].split()
|
||||
cmd2, name2 = lines[2].split()
|
||||
|
||||
assert cmd0 == "set-buffer" and cmd1 == "paste-buffer" and cmd2 == "delete-buffer"
|
||||
assert name0 == name1 == name2
|
||||
|
||||
|
||||
# X-3 — Different buffer names map to different files; delete does not touch another's file
|
||||
def test_a3_different_names_do_not_interfere(mam_sandbox):
|
||||
res = _bash(mam_sandbox, """
|
||||
SHIM="$WORKSPACE_ROOT/.mam/shim/herdr"
|
||||
DIR="$WORKSPACE_ROOT/.mam/shim"
|
||||
"$SHIM" set-buffer -b "buf_A" "PAYLOAD_A"
|
||||
"$SHIM" set-buffer -b "buf_B" "PAYLOAD_B"
|
||||
|
||||
assert_file() { [ -f "$DIR/$1" ] && echo "$1:EXISTS" || echo "$1:MISSING"; }
|
||||
assert_file "buf_buf_A"
|
||||
assert_file "buf_buf_B"
|
||||
|
||||
"$SHIM" delete-buffer -b "buf_A"
|
||||
assert_file "buf_buf_A"
|
||||
assert_file "buf_buf_B"
|
||||
""")
|
||||
assert "buf_buf_A:EXISTS" in res.stdout
|
||||
assert "buf_buf_B:EXISTS" in res.stdout
|
||||
assert "buf_buf_A:MISSING" in res.stdout
|
||||
assert "buf_buf_B:EXISTS" in res.stdout
|
||||
|
||||
|
||||
# X-4 — Invalid/empty -b names are rejected explicitly, not falling back to tmp_buffer
|
||||
def test_a3_unusable_buffer_name_is_rejected(mam_sandbox):
|
||||
res = _bash(mam_sandbox, """
|
||||
SHIM="$WORKSPACE_ROOT/.mam/shim/herdr"
|
||||
"$SHIM" set-buffer -b "!!!" "PAYLOAD" 2>&1
|
||||
echo "RC:$?"
|
||||
""")
|
||||
assert "RC:1" in res.stdout
|
||||
assert "contains no usable characters" in res.stdout + res.stderr
|
||||
|
||||
|
||||
# X-5 — Parallel execution test: 12 callers get isolated buffer text
|
||||
def test_a3_parallel_calls_are_isolated(mam_sandbox):
|
||||
res = _bash(mam_sandbox, """
|
||||
SHIM="$WORKSPACE_ROOT/.mam/shim/herdr"
|
||||
DIR="$WORKSPACE_ROOT/.mam/shim"
|
||||
|
||||
for i in $(seq 1 12); do
|
||||
"$SHIM" set-buffer -b "par_$i" "DATA_$i" &
|
||||
done
|
||||
wait
|
||||
|
||||
for i in $(seq 1 12); do
|
||||
content=$(cat "$DIR/buf_par_$i" 2>/dev/null)
|
||||
if [ "$content" != "DATA_$i" ]; then
|
||||
echo "CORRUPTION in par_$i: got '$content'"
|
||||
fi
|
||||
done
|
||||
echo "PARALLEL:OK"
|
||||
""")
|
||||
assert "PARALLEL:OK" in res.stdout
|
||||
assert "CORRUPTION" not in res.stdout
|
||||
|
||||
|
||||
# X-6 — Atomic write does not leave .tmp files behind on success
|
||||
def test_a3_atomic_write_leaves_no_tmp_on_success(mam_sandbox):
|
||||
res = _bash(mam_sandbox, """
|
||||
SHIM="$WORKSPACE_ROOT/.mam/shim/herdr"
|
||||
DIR="$WORKSPACE_ROOT/.mam/shim"
|
||||
"$SHIM" set-buffer -b "atomic_test" "ATOMIC_PAYLOAD"
|
||||
ls -a "$DIR"
|
||||
""")
|
||||
assert "buf_atomic_test" in res.stdout
|
||||
assert not any(f.endswith(".tmp") for f in res.stdout.split())
|
||||
|
||||
|
||||
# X-7 — Abandoned buffers (older than GC threshold) are reclaimed
|
||||
def test_a3_abandoned_buffers_are_collected(mam_sandbox):
|
||||
res = _bash(mam_sandbox, """
|
||||
DIR="$WORKSPACE_ROOT/.mam/shim"
|
||||
mkdir -p "$DIR"
|
||||
ABANDONED="$DIR/buf_sks_OLD_1_1"
|
||||
: > "$ABANDONED"
|
||||
touch -t 202501010000 "$ABANDONED"
|
||||
|
||||
SHIM="$DIR/herdr"
|
||||
"$SHIM" set-buffer -b "trigger_gc" "NEW_DATA"
|
||||
|
||||
if [ -f "$ABANDONED" ]; then
|
||||
echo "GC:LEAK"
|
||||
else
|
||||
echo "GC:SWEPT"
|
||||
fi
|
||||
""")
|
||||
assert "GC:SWEPT" in res.stdout
|
||||
|
||||
|
||||
# X-8 — Live buffers (newer than GC threshold) are preserved
|
||||
def test_a3_live_buffers_are_preserved(mam_sandbox):
|
||||
res = _bash(mam_sandbox, """
|
||||
DIR="$WORKSPACE_ROOT/.mam/shim"
|
||||
mkdir -p "$DIR"
|
||||
LIVE="$DIR/buf_sks_LIVE_2_2"
|
||||
echo "LIVE_CONTENT" > "$LIVE"
|
||||
|
||||
SHIM="$DIR/herdr"
|
||||
"$SHIM" set-buffer -b "trigger_gc" "NEW_DATA"
|
||||
|
||||
if [ -f "$LIVE" ]; then
|
||||
echo "LIVE:PRESERVED"
|
||||
else
|
||||
echo "LIVE:DELETED"
|
||||
fi
|
||||
""")
|
||||
assert "LIVE:PRESERVED" in res.stdout
|
||||
|
||||
|
||||
# X-9 — Sweep is scoped to buf_sks_* and does not touch non-matching files
|
||||
def test_a3_sweep_is_scoped(mam_sandbox):
|
||||
res = _bash(mam_sandbox, """
|
||||
DIR="$WORKSPACE_ROOT/.mam/shim"
|
||||
mkdir -p "$DIR"
|
||||
OTHER="$DIR/other_old_file"
|
||||
: > "$OTHER"
|
||||
touch -t 202501010000 "$OTHER"
|
||||
|
||||
SHIM="$DIR/herdr"
|
||||
"$SHIM" set-buffer -b "trigger_gc" "NEW_DATA"
|
||||
|
||||
if [ -f "$OTHER" ]; then
|
||||
echo "OTHER:PRESERVED"
|
||||
else
|
||||
echo "OTHER:DELETED"
|
||||
fi
|
||||
""")
|
||||
assert "OTHER:PRESERVED" in res.stdout
|
||||
|
||||
|
||||
# X-10 — Abandoned atomic write DOT-temp files (.buf_sks_*.tmp) are collected
|
||||
def test_a3_abandoned_dot_temps_are_collected(mam_sandbox):
|
||||
res = _bash(mam_sandbox, """
|
||||
DIR="$WORKSPACE_ROOT/.mam/shim"
|
||||
mkdir -p "$DIR"
|
||||
DOT_TEMP="$DIR/.buf_sks_ORPHAN_1_1.99.tmp"
|
||||
: > "$DOT_TEMP"
|
||||
touch -t 202501010000 "$DOT_TEMP"
|
||||
|
||||
SHIM="$DIR/herdr"
|
||||
"$SHIM" set-buffer -b "trigger_gc" "NEW_DATA"
|
||||
|
||||
if [ -f "$DOT_TEMP" ]; then
|
||||
echo "DOT_TEMP:LEAK"
|
||||
else
|
||||
echo "DOT_TEMP:SWEPT"
|
||||
fi
|
||||
""")
|
||||
assert "DOT_TEMP:SWEPT" in res.stdout
|
||||
|
||||
|
||||
# X-11 — Failed rename cleans up temp file immediately and fails with exit code 1
|
||||
def test_a3_failed_rename_cleans_up_and_fails(mam_sandbox):
|
||||
res = _bash(mam_sandbox, """
|
||||
DIR="$WORKSPACE_ROOT/.mam/shim"
|
||||
mkdir -p "$DIR"
|
||||
# Block creation of buf_sks_blocked by placing a read-only directory in its place
|
||||
BLOCK_DIR="$DIR/buf_sks_blocked"
|
||||
mkdir -p "$BLOCK_DIR"
|
||||
chmod 500 "$BLOCK_DIR"
|
||||
|
||||
SHIM="$DIR/herdr"
|
||||
"$SHIM" set-buffer -b "sks_blocked" "PAYLOAD" 2>&1
|
||||
RC=$?
|
||||
echo "RC:$RC"
|
||||
|
||||
# Clean up permissions so test sandbox can clean up
|
||||
chmod 700 "$BLOCK_DIR"
|
||||
|
||||
# Check if any .tmp files were left behind
|
||||
ls -a "$DIR" | grep -q '\.tmp$' && echo "TEMP:LEAKED" || echo "TEMP:CLEAN"
|
||||
""")
|
||||
assert "RC:1" in res.stdout
|
||||
assert "TEMP:CLEAN" in res.stdout
|
||||
|
||||
|
||||
# X-12 — Scoped GC preserves unrelated dot-files
|
||||
def test_a3_unrelated_dot_files_preserved(mam_sandbox):
|
||||
res = _bash(mam_sandbox, """
|
||||
DIR="$WORKSPACE_ROOT/.mam/shim"
|
||||
mkdir -p "$DIR"
|
||||
DOT_OTHER="$DIR/.some_other_dot_file"
|
||||
: > "$DOT_OTHER"
|
||||
touch -t 202501010000 "$DOT_OTHER"
|
||||
|
||||
SHIM="$DIR/herdr"
|
||||
"$SHIM" set-buffer -b "trigger_gc" "NEW_DATA"
|
||||
|
||||
if [ -f "$DOT_OTHER" ]; then
|
||||
echo "DOT_OTHER:PRESERVED"
|
||||
else
|
||||
echo "DOT_OTHER:DELETED"
|
||||
fi
|
||||
""")
|
||||
assert "DOT_OTHER:PRESERVED" in res.stdout
|
||||
@@ -1,291 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
tests/test_b1_tier3_identity.py — B-1 tier-3 identity cache regression suite (V-1..V-8).
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import sqlite3
|
||||
import json
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
LIB_SH = os.path.join(REPO_ROOT, ".agents", "skills", "lib.sh")
|
||||
|
||||
@pytest.fixture
|
||||
def split_sandbox(tmp_path):
|
||||
"""
|
||||
Creates a sandbox where workspace directory != state file directory,
|
||||
breaking the coincidence in mam_sandbox where <ws>/.mam/ matches AGENT_SESSIONS_YAML.
|
||||
"""
|
||||
shutil.copytree(os.path.join(REPO_ROOT, ".agents", "skills"), tmp_path / ".agents" / "skills")
|
||||
ws = tmp_path / "ws"
|
||||
(ws / ".mam").mkdir(parents=True)
|
||||
state_dir = tmp_path / "state"
|
||||
state_dir.mkdir()
|
||||
home = tmp_path / "home"
|
||||
(home / ".claude" / "projects").mkdir(parents=True)
|
||||
(home / ".gemini" / "antigravity-cli" / "conversations").mkdir(parents=True)
|
||||
|
||||
yaml_file = state_dir / "agent-sessions.yaml"
|
||||
db_file = state_dir / "agent-sessions.db"
|
||||
|
||||
return {
|
||||
"root": tmp_path,
|
||||
"ws": ws,
|
||||
"yaml": yaml_file,
|
||||
"db": db_file,
|
||||
"home": home,
|
||||
"lib": tmp_path / ".agents" / "skills" / "lib.sh"
|
||||
}
|
||||
|
||||
def run_find_workspace_uuid(sandbox, workspace, agent, env_extra=None, target=None):
|
||||
env = dict(os.environ)
|
||||
env["AGENT_SESSIONS_YAML"] = str(sandbox["yaml"])
|
||||
env["YAML_PATH"] = str(sandbox["yaml"])
|
||||
env["WORKSPACE_ROOT"] = str(sandbox["root"])
|
||||
env["HOME_DIR"] = str(sandbox["home"])
|
||||
env["CLAUDE_PROJECT_DIR"] = str(sandbox["home"] / ".claude" / "projects")
|
||||
if env_extra:
|
||||
env.update(env_extra)
|
||||
|
||||
target_arg = f" --target '{target}'" if target else ""
|
||||
cmd = f"source {sandbox['lib']} && find_workspace_uuid '{workspace}' '{agent}'{target_arg}"
|
||||
res = subprocess.run(["bash", "-c", cmd], capture_output=True, text=True, env=env)
|
||||
return res
|
||||
|
||||
class TestB1Tier3Identity(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp_dir = tempfile.mkdtemp(prefix="mam_b1_test_")
|
||||
self.tmp_path = tempfile.TemporaryDirectory()
|
||||
self.sandbox = {
|
||||
"root": self.tmp_dir,
|
||||
"ws": os.path.join(self.tmp_dir, "ws"),
|
||||
"yaml": os.path.join(self.tmp_dir, "state", "agent-sessions.yaml"),
|
||||
"db": os.path.join(self.tmp_dir, "state", "agent-sessions.db"),
|
||||
"home": os.path.join(self.tmp_dir, "home"),
|
||||
"lib": LIB_SH
|
||||
}
|
||||
os.makedirs(self.sandbox["ws"], exist_ok=True)
|
||||
os.makedirs(os.path.join(self.sandbox["tmp_dir"] if "tmp_dir" in self.sandbox else self.tmp_dir, "state"), exist_ok=True)
|
||||
os.makedirs(os.path.join(self.sandbox["home"], ".claude", "projects"), exist_ok=True)
|
||||
|
||||
def tearDown(self):
|
||||
shutil.rmtree(self.tmp_dir, ignore_errors=True)
|
||||
|
||||
def test_b1_tier3_honours_agent_sessions_yaml_path(split_sandbox):
|
||||
"""V-1: D1 — verify tier-3 honours AGENT_SESSIONS_YAML path when ws != state dir."""
|
||||
ws = str(split_sandbox["ws"])
|
||||
uuid = "b1-test-uuid-0001"
|
||||
|
||||
# Create mock agy conversation DB
|
||||
db_file = split_sandbox["home"] / ".gemini" / "antigravity-cli" / "conversations" / f"{uuid}.db"
|
||||
conn = sqlite3.connect(db_file)
|
||||
conn.execute("CREATE TABLE steps (id INT)")
|
||||
conn.execute("INSERT INTO steps VALUES (1)")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
# Write identity info into yaml at state_dir, NOT ws/.mam/
|
||||
yaml_content = {
|
||||
"herdr_sessions": [],
|
||||
"agent_identities": {
|
||||
"agy": {
|
||||
"project_cwd": ws,
|
||||
"conversation_id": uuid
|
||||
}
|
||||
}
|
||||
}
|
||||
import yaml
|
||||
with open(split_sandbox["yaml"], "w") as f:
|
||||
yaml.dump(yaml_content, f)
|
||||
|
||||
res = run_find_workspace_uuid(split_sandbox, ws, "agy")
|
||||
assert res.returncode == 0
|
||||
assert res.stdout.strip() == uuid
|
||||
|
||||
def test_b1_tier3_db_branch_survives_missing_pyyaml(split_sandbox):
|
||||
"""V-2: D3 — verify tier-3 DB branch works even when PyYAML module is absent."""
|
||||
ws = str(split_sandbox["ws"])
|
||||
uuid = "b1-test-uuid-0002"
|
||||
|
||||
# Create mock agy conversation DB
|
||||
db_file = split_sandbox["home"] / ".gemini" / "antigravity-cli" / "conversations" / f"{uuid}.db"
|
||||
conn_conv = sqlite3.connect(db_file)
|
||||
conn_conv.execute("CREATE TABLE steps (id INT)")
|
||||
conn_conv.execute("INSERT INTO steps VALUES (1)")
|
||||
conn_conv.commit()
|
||||
conn_conv.close()
|
||||
|
||||
# Create SQLite DB with state data
|
||||
conn = sqlite3.connect(split_sandbox["db"])
|
||||
conn.execute("CREATE TABLE state (id INTEGER PRIMARY KEY, data TEXT)")
|
||||
state_data = json.dumps({
|
||||
"herdr_sessions": [],
|
||||
"agent_identities": {
|
||||
"agy": {
|
||||
"project_cwd": ws,
|
||||
"conversation_id": uuid
|
||||
}
|
||||
}
|
||||
})
|
||||
conn.execute("INSERT INTO state (id, data) VALUES (1, ?)", (state_data,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
# Stub out PyYAML via PYTHONPATH override with broken yaml package
|
||||
stub_dir = split_sandbox["root"] / "noyaml" / "yaml"
|
||||
stub_dir.mkdir(parents=True)
|
||||
with open(stub_dir / "__init__.py", "w") as f:
|
||||
f.write("raise ImportError('No module named yaml')\n")
|
||||
|
||||
env_extra = {"PYTHONPATH": str(split_sandbox["root"] / "noyaml")}
|
||||
res = run_find_workspace_uuid(split_sandbox, ws, "agy", env_extra=env_extra)
|
||||
assert res.returncode == 0
|
||||
assert res.stdout.strip() == uuid
|
||||
|
||||
def test_b1_tier3_corrupt_identities_still_exits_zero(split_sandbox):
|
||||
"""V-3: D4 — verify non-dict agent_identities handles gracefully and exits 0."""
|
||||
ws = str(split_sandbox["ws"])
|
||||
yaml_content = {
|
||||
"herdr_sessions": [],
|
||||
"agent_identities": "corrupted_string_not_dict"
|
||||
}
|
||||
import yaml
|
||||
with open(split_sandbox["yaml"], "w") as f:
|
||||
yaml.dump(yaml_content, f)
|
||||
# Also write to ws/.mam/ to trigger HEAD's path-guessing code path
|
||||
with open(split_sandbox["ws"] / ".mam" / "agent-sessions.yaml", "w") as f:
|
||||
yaml.dump(yaml_content, f)
|
||||
|
||||
res = run_find_workspace_uuid(split_sandbox, ws, "agy")
|
||||
assert res.returncode == 0
|
||||
assert res.stdout.strip() == ""
|
||||
|
||||
def test_b1_tier3_hermes_conversation_id_fallback(split_sandbox):
|
||||
"""V-4: D5 — verify hermes tier-3 fallback reads conversation_id from ai_agent."""
|
||||
ws = str(split_sandbox["ws"])
|
||||
uuid = "b1-test-uuid-hermes-0004"
|
||||
|
||||
# Create mock hermes state DB
|
||||
hermes_dir = split_sandbox["home"] / ".hermes"
|
||||
hermes_dir.mkdir(parents=True, exist_ok=True)
|
||||
conn_h = sqlite3.connect(hermes_dir / "state.db")
|
||||
conn_h.execute("CREATE TABLE sessions (id TEXT, cwd TEXT, started_at TEXT)")
|
||||
conn_h.execute("INSERT INTO sessions VALUES (?, ?, ?)", (uuid, ws, "2026-08-05"))
|
||||
conn_h.commit()
|
||||
conn_h.close()
|
||||
|
||||
yaml_content = {
|
||||
"herdr_sessions": [],
|
||||
"agent_identities": {
|
||||
"hermes": {
|
||||
"project_cwd": ws,
|
||||
"conversation_id": uuid
|
||||
}
|
||||
}
|
||||
}
|
||||
import yaml
|
||||
with open(split_sandbox["yaml"], "w") as f:
|
||||
yaml.dump(yaml_content, f)
|
||||
with open(split_sandbox["ws"] / ".mam" / "agent-sessions.yaml", "w") as f:
|
||||
yaml.dump(yaml_content, f)
|
||||
|
||||
res = run_find_workspace_uuid(split_sandbox, ws, "hermes")
|
||||
assert res.returncode == 0
|
||||
assert res.stdout.strip() == uuid
|
||||
|
||||
def test_b1_tier3_refuses_foreign_workspace_identity(split_sandbox):
|
||||
"""V-5: Verify tier-3 identity is ignored if project_cwd does not match workspace."""
|
||||
ws = str(split_sandbox["ws"])
|
||||
foreign_ws = str(split_sandbox["root"] / "other_ws")
|
||||
uuid = "b1-test-uuid-0005"
|
||||
yaml_content = {
|
||||
"herdr_sessions": [],
|
||||
"agent_identities": {
|
||||
"agy": {
|
||||
"project_cwd": foreign_ws,
|
||||
"conversation_id": uuid
|
||||
}
|
||||
}
|
||||
}
|
||||
import yaml
|
||||
with open(split_sandbox["yaml"], "w") as f:
|
||||
yaml.dump(yaml_content, f)
|
||||
|
||||
res = run_find_workspace_uuid(split_sandbox, ws, "agy")
|
||||
assert res.returncode == 0
|
||||
assert res.stdout.strip() == ""
|
||||
|
||||
def test_b1_tier3_absent_identities_is_silent(split_sandbox):
|
||||
"""V-6: Verify tier-3 gracefully returns empty string when agent_identities is absent."""
|
||||
ws = str(split_sandbox["ws"])
|
||||
yaml_content = {"herdr_sessions": []}
|
||||
import yaml
|
||||
with open(split_sandbox["yaml"], "w") as f:
|
||||
yaml.dump(yaml_content, f)
|
||||
|
||||
res = run_find_workspace_uuid(split_sandbox, ws, "agy")
|
||||
assert res.returncode == 0
|
||||
assert res.stdout.strip() == ""
|
||||
|
||||
def test_b1_load_state_json_surfaces_agent_identities(split_sandbox):
|
||||
"""V-7: Verify load_state_json preserves agent_identities in state blob."""
|
||||
ws = str(split_sandbox["ws"])
|
||||
uuid = "b1-test-uuid-0007"
|
||||
yaml_content = {
|
||||
"herdr_sessions": [],
|
||||
"agent_identities": {
|
||||
"agy": {
|
||||
"project_cwd": ws,
|
||||
"conversation_id": uuid
|
||||
}
|
||||
}
|
||||
}
|
||||
import yaml
|
||||
with open(split_sandbox["yaml"], "w") as f:
|
||||
yaml.dump(yaml_content, f)
|
||||
|
||||
env = dict(os.environ)
|
||||
env["AGENT_SESSIONS_YAML"] = str(split_sandbox["yaml"])
|
||||
env["YAML_PATH"] = str(split_sandbox["yaml"])
|
||||
cmd = f"source {split_sandbox['lib']} && load_state_json"
|
||||
res = subprocess.run(["bash", "-c", cmd], capture_output=True, text=True, env=env)
|
||||
assert res.returncode == 0
|
||||
state = json.loads(res.stdout.strip())
|
||||
assert state.get("agent_identities", {}).get("agy", {}).get("conversation_id") == uuid
|
||||
|
||||
def test_b1_tier3_does_not_read_yaml_mirror_behind_the_db(split_sandbox):
|
||||
"""V-8: Verify tier-3 does not prioritize YAML mirror when DB exists without identity."""
|
||||
ws = str(split_sandbox["ws"])
|
||||
uuid = "b1-test-uuid-0008"
|
||||
|
||||
# DB has no agent_identities
|
||||
conn = sqlite3.connect(split_sandbox["db"])
|
||||
conn.execute("CREATE TABLE state (id INTEGER PRIMARY KEY, data TEXT)")
|
||||
state_data = json.dumps({"herdr_sessions": []})
|
||||
conn.execute("INSERT INTO state (id, data) VALUES (1, ?)", (state_data,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
# YAML has agent_identities
|
||||
yaml_content = {
|
||||
"herdr_sessions": [],
|
||||
"agent_identities": {
|
||||
"agy": {
|
||||
"project_cwd": ws,
|
||||
"conversation_id": uuid
|
||||
}
|
||||
}
|
||||
}
|
||||
import yaml
|
||||
with open(split_sandbox["yaml"], "w") as f:
|
||||
yaml.dump(yaml_content, f)
|
||||
|
||||
res = run_find_workspace_uuid(split_sandbox, ws, "agy")
|
||||
assert res.returncode == 0
|
||||
# Must miss because DB is authority and state row lacks identity
|
||||
assert res.stdout.strip() == ""
|
||||
@@ -1,196 +0,0 @@
|
||||
"""B-3 — the herdr pre-flight must probe for the real BINARY.
|
||||
|
||||
Two independent bypasses make the obvious one-liners useless once lib.sh is
|
||||
sourced:
|
||||
|
||||
* ``command -v herdr`` matches the ``herdr()`` shell FUNCTION (lib.sh:498).
|
||||
* ``type -P herdr`` matches ``$WORKSPACE_ROOT/.mam/shim/herdr``, because
|
||||
``_init_herdr_isolation`` runs at source time
|
||||
(lib.sh bottom) and prepends that dir to PATH.
|
||||
|
||||
So a host with no herdr installed at all passes pre-flight, and the failure is
|
||||
deferred to a confusing runtime error from the shim.
|
||||
"""
|
||||
import os
|
||||
import stat
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
# A PATH with no herdr anywhere. Kept deliberately minimal so a herdr that
|
||||
# happens to be installed on the developer's box cannot mask a regression.
|
||||
BARE_PATH = "/usr/bin:/bin:/usr/sbin:/sbin"
|
||||
|
||||
|
||||
def _bash(sandbox, snippet, path=BARE_PATH, extra_env=None):
|
||||
env = dict(os.environ)
|
||||
env["PATH"] = path
|
||||
env["WORKSPACE_ROOT"] = str(sandbox)
|
||||
if extra_env:
|
||||
env.update(extra_env)
|
||||
script = f'source "{sandbox}/.agents/skills/lib.sh" >/dev/null 2>&1\n{snippet}'
|
||||
return subprocess.run(["bash", "-c", script], capture_output=True,
|
||||
text=True, cwd=str(sandbox), env=env)
|
||||
|
||||
|
||||
def _make_herdr(directory):
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
exe = directory / "herdr"
|
||||
exe.write_text("#!/bin/sh\necho fake herdr\n")
|
||||
exe.chmod(exe.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
|
||||
return exe
|
||||
|
||||
|
||||
# W-1 — document the two bypasses so a future refactor cannot silently
|
||||
# "simplify" has_real_herdr back into one of them.
|
||||
def test_b3_command_v_and_type_p_both_falsely_report_herdr(mam_sandbox):
|
||||
res = _bash(mam_sandbox, """
|
||||
command -v herdr >/dev/null && echo "command-v:MATCH" || echo "command-v:miss"
|
||||
echo "type-t:$(type -t herdr)"
|
||||
type -P herdr >/dev/null 2>&1 && echo "type-P:MATCH($(type -P herdr))" || echo "type-P:miss"
|
||||
""")
|
||||
assert "command-v:MATCH" in res.stdout, res.stdout
|
||||
assert "type-t:function" in res.stdout, res.stdout
|
||||
assert "type-P:MATCH" in res.stdout, res.stdout
|
||||
assert "/.mam/shim/herdr" in res.stdout, \
|
||||
f"type -P was expected to resolve the shim wrapper:\n{res.stdout}"
|
||||
|
||||
|
||||
# W-2 — has_real_herdr must be FALSE when no binary is installed
|
||||
def test_b3_has_real_herdr_false_without_binary(mam_sandbox):
|
||||
res = _bash(mam_sandbox,
|
||||
'if has_real_herdr; then echo RESULT:TRUE; else echo RESULT:FALSE; fi')
|
||||
assert "RESULT:FALSE" in res.stdout, \
|
||||
f"has_real_herdr passed with no herdr binary:\n{res.stdout}\n{res.stderr}"
|
||||
|
||||
|
||||
# W-3 — ... and TRUE when one is, resolving past the shim
|
||||
def test_b3_has_real_herdr_true_with_binary(mam_sandbox):
|
||||
real = _make_herdr(mam_sandbox / "realbin")
|
||||
res = _bash(mam_sandbox,
|
||||
'if has_real_herdr; then echo RESULT:TRUE; else echo RESULT:FALSE; fi\n'
|
||||
'echo "RESOLVED:$(_resolve_real_herdr_path)"',
|
||||
path=f"{mam_sandbox / 'realbin'}:{BARE_PATH}")
|
||||
assert "RESULT:TRUE" in res.stdout, f"{res.stdout}\n{res.stderr}"
|
||||
assert f"RESOLVED:{real}" in res.stdout, \
|
||||
f"resolved the shim instead of the real binary:\n{res.stdout}"
|
||||
|
||||
|
||||
# W-4 — a herdr that only exists inside a wrapper/shim dir does not count
|
||||
@pytest.mark.parametrize("dirname", ["my-shim", "multi-agent-herdr-shim"])
|
||||
def test_b3_shim_directories_do_not_satisfy_preflight(mam_sandbox, dirname):
|
||||
_make_herdr(mam_sandbox / dirname)
|
||||
res = _bash(mam_sandbox,
|
||||
'if has_real_herdr; then echo RESULT:TRUE; else echo RESULT:FALSE; fi',
|
||||
path=f"{mam_sandbox / dirname}:{BARE_PATH}")
|
||||
assert "RESULT:FALSE" in res.stdout, \
|
||||
f"a herdr inside wrapper dir '{dirname}' was accepted:\n{res.stdout}"
|
||||
|
||||
|
||||
# W-5 — create_session.sh pre-flight must actually FAIL without a binary.
|
||||
# This is the defect B-3 names.
|
||||
def test_b3_create_session_preflight_fails_without_herdr(mam_sandbox):
|
||||
script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh"
|
||||
env = dict(os.environ)
|
||||
env["PATH"] = BARE_PATH
|
||||
env["WORKSPACE_ROOT"] = str(mam_sandbox)
|
||||
res = subprocess.run(
|
||||
["bash", str(script), "--workspace", str(mam_sandbox),
|
||||
"--agent", "claude", "--role", "worker"],
|
||||
capture_output=True, text=True, cwd=str(mam_sandbox), env=env)
|
||||
|
||||
assert res.returncode != 0, \
|
||||
f"pre-flight passed with no herdr installed:\n{res.stdout}\n{res.stderr}"
|
||||
assert "herdr not installed" in (res.stdout + res.stderr), \
|
||||
f"failed, but not on the herdr pre-flight:\n{res.stdout}\n{res.stderr}"
|
||||
|
||||
|
||||
# W-6 — and must still pass the herdr gate when a binary IS present.
|
||||
# (It may fail later on the agent-CLI gate; that is a different check.)
|
||||
def test_b3_create_session_preflight_passes_herdr_gate_with_binary(mam_sandbox):
|
||||
_make_herdr(mam_sandbox / "realbin")
|
||||
script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh"
|
||||
env = dict(os.environ)
|
||||
env["PATH"] = f"{mam_sandbox / 'realbin'}:{BARE_PATH}"
|
||||
env["WORKSPACE_ROOT"] = str(mam_sandbox)
|
||||
res = subprocess.run(
|
||||
["bash", str(script), "--workspace", str(mam_sandbox),
|
||||
"--agent", "claude", "--role", "worker"],
|
||||
capture_output=True, text=True, cwd=str(mam_sandbox), env=env)
|
||||
|
||||
assert "herdr not installed" not in (res.stdout + res.stderr), \
|
||||
f"herdr gate rejected a real binary:\n{res.stdout}\n{res.stderr}"
|
||||
|
||||
|
||||
# W-7 — the sharpest statement of B-3: with the agent CLI present and ONLY
|
||||
# herdr missing, pre-flight must stop on the herdr gate. This is the
|
||||
# test that is genuinely red before the fix without naming any new
|
||||
# helper, so it measures the defect rather than the implementation.
|
||||
def test_b3_preflight_stops_on_herdr_gate_when_only_herdr_is_missing(mam_sandbox):
|
||||
agentbin = mam_sandbox / "agentbin"
|
||||
agentbin.mkdir(parents=True, exist_ok=True)
|
||||
for name in ("claude",):
|
||||
exe = agentbin / name
|
||||
exe.write_text('#!/bin/sh\necho \'{"loggedIn": true}\'\n')
|
||||
exe.chmod(exe.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
|
||||
|
||||
script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh"
|
||||
env = dict(os.environ)
|
||||
env["PATH"] = f"{agentbin}:{BARE_PATH}" # claude yes, herdr no
|
||||
env["WORKSPACE_ROOT"] = str(mam_sandbox)
|
||||
res = subprocess.run(
|
||||
["bash", str(script), "--workspace", str(mam_sandbox),
|
||||
"--agent", "claude", "--role", "worker"],
|
||||
capture_output=True, text=True, cwd=str(mam_sandbox), env=env, timeout=120)
|
||||
|
||||
combined = res.stdout + res.stderr
|
||||
assert "herdr not installed" in combined, (
|
||||
"pre-flight did not stop on the herdr gate even though herdr is absent.\n"
|
||||
f"rc={res.returncode}\n--- stdout ---\n{res.stdout}\n--- stderr ---\n{res.stderr}")
|
||||
|
||||
|
||||
# W-8 — symlink bypass: a link in an ORDINARY bin dir pointing at the shim.
|
||||
# No directory-name pattern can reject this; only the resolved target
|
||||
# reveals it.
|
||||
def test_b3_symlink_into_shim_is_rejected(mam_sandbox):
|
||||
_bash(mam_sandbox, "true") # materialise the shim
|
||||
shim = mam_sandbox / ".mam" / "shim" / "herdr"
|
||||
assert shim.exists(), "shim wrapper was not created by _init_herdr_isolation"
|
||||
|
||||
linkdir = mam_sandbox / "usrlocalbin"
|
||||
linkdir.mkdir(parents=True, exist_ok=True)
|
||||
(linkdir / "herdr").symlink_to(shim)
|
||||
|
||||
res = _bash(mam_sandbox,
|
||||
'if has_real_herdr; then echo RESULT:TRUE; else echo RESULT:FALSE; fi',
|
||||
path=f"{linkdir}:{BARE_PATH}")
|
||||
assert "RESULT:FALSE" in res.stdout, (
|
||||
"a symlink pointing into .mam/shim was accepted as a real herdr:\n"
|
||||
f"{res.stdout}\n{res.stderr}")
|
||||
|
||||
|
||||
# W-9 — ... but a symlink to a REAL binary must still be accepted, so the
|
||||
# canonicalisation cannot be a blanket "reject all symlinks".
|
||||
def test_b3_symlink_to_real_binary_is_accepted(mam_sandbox):
|
||||
real = _make_herdr(mam_sandbox / "realbin")
|
||||
linkdir = mam_sandbox / "linkbin"
|
||||
linkdir.mkdir(parents=True, exist_ok=True)
|
||||
(linkdir / "herdr").symlink_to(real)
|
||||
|
||||
res = _bash(mam_sandbox,
|
||||
'if has_real_herdr; then echo RESULT:TRUE; else echo RESULT:FALSE; fi',
|
||||
path=f"{linkdir}:{BARE_PATH}")
|
||||
assert "RESULT:TRUE" in res.stdout, \
|
||||
f"a symlink to a genuine herdr was rejected:\n{res.stdout}\n{res.stderr}"
|
||||
|
||||
|
||||
# W-10 — a bare relative PATH entry '.mam/shim' (no leading '/' or './') must
|
||||
# still be rejected. Requires normalising the dir with a leading slash.
|
||||
def test_b3_bare_relative_shim_entry_is_rejected(mam_sandbox):
|
||||
_bash(mam_sandbox, "true") # materialise the shim
|
||||
res = _bash(mam_sandbox,
|
||||
'if has_real_herdr; then echo RESULT:TRUE; else echo RESULT:FALSE; fi',
|
||||
path=f".mam/shim:{BARE_PATH}")
|
||||
assert "RESULT:FALSE" in res.stdout, (
|
||||
"a bare relative '.mam/shim' PATH entry was accepted:\n"
|
||||
f"{res.stdout}\n{res.stderr}")
|
||||
@@ -1,126 +0,0 @@
|
||||
"""C-2 — Remove unused .cache/multi-agent-mux-monitor state directory creation.
|
||||
|
||||
Verifies:
|
||||
Y-1: --dry-run does not create .cache/multi-agent-mux-monitor
|
||||
Y-2: --once --emit-diff does not create .cache/multi-agent-mux-monitor
|
||||
Y-3: AGENT_SESSIONS_STATE_DIR does not create custom path or default path
|
||||
Y-4: Reconcile executes normally and outputs valid drift JSON
|
||||
Y-5: Existing pre-created .cache directory/files are unmolested during runtime
|
||||
Y-6: Setting AGENT_SESSIONS_STATE_DIR emits deprecation warning on stderr without polluting stdout JSON
|
||||
Y-7: Unset AGENT_SESSIONS_STATE_DIR is completely silent on stderr
|
||||
Y-8: Empty .cache parent directory is reclaimed during uninstall
|
||||
Y-9: Non-empty .cache directory is preserved during uninstall
|
||||
Y-10: deploy/remove.sh correctly employs relative rmdir .cache
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
import shutil
|
||||
import json
|
||||
import pytest
|
||||
|
||||
def _run_reconcile(sandbox, extra_args=None, extra_env=None):
|
||||
repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sandbox_agents = sandbox / ".agents"
|
||||
if not sandbox_agents.exists():
|
||||
os.symlink(os.path.join(repo_root, ".agents"), sandbox_agents, target_is_directory=True)
|
||||
|
||||
mam_dir = sandbox / ".mam"
|
||||
mam_dir.mkdir(exist_ok=True)
|
||||
yaml_file = mam_dir / "agent-sessions.yaml"
|
||||
if not yaml_file.exists():
|
||||
yaml_file.write_text("herdr_sessions: []\n")
|
||||
|
||||
env = dict(os.environ)
|
||||
env["WORKSPACE_ROOT"] = str(sandbox)
|
||||
if extra_env:
|
||||
env.update(extra_env)
|
||||
|
||||
reconcile_script = os.path.join(repo_root, ".agents", "skills", "multi-agent-mux-monitor", "scripts", "reconcile.sh")
|
||||
cmd = ["bash", reconcile_script]
|
||||
if extra_args:
|
||||
cmd.extend(extra_args)
|
||||
|
||||
return subprocess.run(cmd, capture_output=True, text=True, cwd=str(sandbox), env=env)
|
||||
|
||||
|
||||
def test_y1_y2_no_cache_dir_created(mam_sandbox):
|
||||
cache_dir = mam_sandbox / ".cache"
|
||||
if cache_dir.exists():
|
||||
shutil.rmtree(cache_dir)
|
||||
|
||||
# Y-1: dry-run
|
||||
res_dry = _run_reconcile(mam_sandbox, ["--once", "--emit-diff", "--dry-run"])
|
||||
assert res_dry.returncode == 0
|
||||
assert not (mam_sandbox / ".cache" / "multi-agent-mux-monitor").exists()
|
||||
assert not (mam_sandbox / ".cache").exists()
|
||||
|
||||
# Y-2: change pass
|
||||
res_once = _run_reconcile(mam_sandbox, ["--once", "--emit-diff"])
|
||||
assert res_once.returncode == 0
|
||||
assert not (mam_sandbox / ".cache" / "multi-agent-mux-monitor").exists()
|
||||
|
||||
|
||||
def test_y3_y6_y7_agent_sessions_state_dir_deprecation(mam_sandbox):
|
||||
custom_dir = mam_sandbox / "custom_state_dir"
|
||||
|
||||
# Y-6: Set env var -> notice on stderr, stdout is valid JSON, no dir created
|
||||
res_env = _run_reconcile(mam_sandbox, ["--once", "--emit-diff", "--dry-run"],
|
||||
extra_env={"AGENT_SESSIONS_STATE_DIR": str(custom_dir)})
|
||||
assert res_env.returncode == 0
|
||||
assert "Notice: AGENT_SESSIONS_STATE_DIR is set but has no effect" in res_env.stderr
|
||||
assert not custom_dir.exists()
|
||||
assert not (mam_sandbox / ".cache").exists()
|
||||
|
||||
# Valid JSON on stdout
|
||||
data = json.loads(res_env.stdout)
|
||||
assert "drifts" in data or "drift" in data
|
||||
|
||||
# Y-7: Unset env var -> no notice on stderr
|
||||
res_silent = _run_reconcile(mam_sandbox, ["--once", "--emit-diff", "--dry-run"])
|
||||
assert res_silent.returncode == 0
|
||||
assert "AGENT_SESSIONS_STATE_DIR" not in res_silent.stderr
|
||||
|
||||
|
||||
def test_y4_reconcile_runs_normally(mam_sandbox):
|
||||
res = _run_reconcile(mam_sandbox, ["--once", "--emit-diff"])
|
||||
assert res.returncode == 0
|
||||
data = json.loads(res.stdout)
|
||||
assert "timestamp" in data
|
||||
assert "drifts" in data or "drift" in data
|
||||
|
||||
|
||||
def test_y5_existing_cache_dir_unmolested(mam_sandbox):
|
||||
target = mam_sandbox / ".cache" / "multi-agent-mux-monitor"
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
leftover = target / "leftover.state"
|
||||
leftover.write_text("FROM-AN-OLDER-INSTALL")
|
||||
|
||||
res = _run_reconcile(mam_sandbox, ["--once", "--emit-diff"])
|
||||
assert res.returncode == 0
|
||||
assert leftover.exists()
|
||||
assert leftover.read_text() == "FROM-AN-OLDER-INSTALL"
|
||||
|
||||
|
||||
def test_y8_y9_remove_script_parent_cache_reclaim(mam_sandbox):
|
||||
from pathlib import Path
|
||||
repo_root = Path(__file__).resolve().parent.parent
|
||||
remove_script = repo_root / "deploy" / "remove.sh"
|
||||
assert remove_script.exists()
|
||||
|
||||
# Y-8: Empty .cache is reclaimed
|
||||
cache_dir = mam_sandbox / ".cache" / "multi-agent-mux-monitor"
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Y-10: Check deploy/remove.sh source for relative rmdir ".cache"
|
||||
content = remove_script.read_text()
|
||||
assert 'rmdir ".cache"' in content or 'rmdir .cache' in content
|
||||
|
||||
# Y-9: Non-empty .cache is preserved (simulated rmdir logic)
|
||||
other_tool = mam_sandbox / ".cache" / "other_tool"
|
||||
other_tool.mkdir(parents=True, exist_ok=True)
|
||||
(other_tool / "data.bin").write_text("keep me")
|
||||
|
||||
shutil.rmtree(cache_dir)
|
||||
with pytest.raises(OSError):
|
||||
os.rmdir(mam_sandbox / ".cache")
|
||||
assert (other_tool / "data.bin").exists()
|
||||
@@ -1,182 +0,0 @@
|
||||
import os
|
||||
import subprocess
|
||||
import json
|
||||
import pytest
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Helper to run mutation on agent-sessions.yaml using atomic_dump_yaml in bash
|
||||
def run_mutation(mam_sandbox, mutation_str, env=None):
|
||||
lib_path = mam_sandbox / ".agents" / "skills" / "lib.sh"
|
||||
yaml_path = mam_sandbox / ".mam" / "agent-sessions.yaml"
|
||||
cmd_str = f"source {lib_path} && atomic_dump_yaml {yaml_path}"
|
||||
run_env = dict(os.environ)
|
||||
if env:
|
||||
run_env.update(env)
|
||||
res = subprocess.run(["bash", "-c", cmd_str], input=mutation_str, capture_output=True, text=True, env=run_env)
|
||||
return res
|
||||
|
||||
|
||||
def test_unbound_key_handling(mam_sandbox, mock_herdr):
|
||||
"""
|
||||
Verify unbound key handling: herdr has-session/kill-session with trailing parameters
|
||||
handles it gracefully (returns status 1 with error) instead of crashing with unbound var or shift errors.
|
||||
"""
|
||||
lib_path = mam_sandbox / ".agents" / "skills" / "lib.sh"
|
||||
subprocess.run(["bash", "-c", f"source {lib_path}"], cwd=str(mam_sandbox), env=dict(os.environ, HOME=str(mam_sandbox), WORKSPACE_ROOT=str(mam_sandbox)))
|
||||
|
||||
shim_path = mam_sandbox / ".mam" / "shim" / "herdr"
|
||||
assert shim_path.exists(), "Shim herdr was not created"
|
||||
|
||||
# Run has-session with trailing -t
|
||||
res = subprocess.run([str(shim_path), "has-session", "-t"], capture_output=True, text=True, env=dict(os.environ, HOME=str(mam_sandbox), WORKSPACE_ROOT=str(mam_sandbox), REAL_HERDR=str(mock_herdr)))
|
||||
assert res.returncode == 1
|
||||
assert "Error: -t requires a value" in res.stderr
|
||||
|
||||
# Run kill-session with trailing -t
|
||||
res = subprocess.run([str(shim_path), "kill-session", "-t"], capture_output=True, text=True, env=dict(os.environ, HOME=str(mam_sandbox), WORKSPACE_ROOT=str(mam_sandbox), REAL_HERDR=str(mock_herdr)))
|
||||
assert res.returncode == 1
|
||||
assert "Error: -t requires a value" in res.stderr
|
||||
|
||||
|
||||
def test_new_session_fallback_shell(mam_sandbox, mock_herdr):
|
||||
"""
|
||||
Verify new-session: launches shell when no command is provided.
|
||||
"""
|
||||
lib_path = mam_sandbox / ".agents" / "skills" / "lib.sh"
|
||||
subprocess.run(["bash", "-c", f"source {lib_path}"], cwd=str(mam_sandbox), env=dict(os.environ, HOME=str(mam_sandbox), WORKSPACE_ROOT=str(mam_sandbox)))
|
||||
shim_path = mam_sandbox / ".mam" / "shim" / "herdr"
|
||||
|
||||
test_shell = "/bin/custom_sh"
|
||||
run_env = dict(os.environ, HOME=str(mam_sandbox), WORKSPACE_ROOT=str(mam_sandbox), REAL_HERDR=str(mock_herdr), SHELL=test_shell)
|
||||
|
||||
res = subprocess.run([str(shim_path), "new-session", "-s", "fallback-session"], capture_output=True, text=True, env=run_env)
|
||||
assert res.returncode == 0, f"Stderr: {res.stderr}"
|
||||
|
||||
with open(mock_herdr, 'r') as f:
|
||||
state = json.load(f)
|
||||
calls = state.get("calls", [])
|
||||
|
||||
start_call = None
|
||||
for call in calls:
|
||||
if "agent" in call and "start" in call and "fallback-session" in call:
|
||||
start_call = call
|
||||
break
|
||||
assert start_call is not None, f"No agent start call found in: {calls}"
|
||||
assert start_call[-1] == test_shell, f"Expected last arg to be {test_shell}, got {start_call[-1]}"
|
||||
|
||||
|
||||
def test_ls_key_error(mam_sandbox):
|
||||
"""
|
||||
Verify ls key error: parses agent list without 'name' correctly using fallback keys.
|
||||
"""
|
||||
py_code = """
|
||||
import sys, json
|
||||
try:
|
||||
data = json.load(sys.stdin)
|
||||
res = data.get('result', data)
|
||||
for a in res.get('agents', []):
|
||||
try:
|
||||
name = a.get('name') or a.get('agent') or 'unknown'
|
||||
print(f"{name}|0")
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
"""
|
||||
input_json = json.dumps({
|
||||
"result": {
|
||||
"agents": [
|
||||
{"agent": "claude", "status": "running"},
|
||||
{"name": "agent-with-name", "agent": "agy"}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
res = subprocess.run([sys.executable, "-c", py_code], input=input_json, capture_output=True, text=True)
|
||||
assert res.returncode == 0
|
||||
lines = res.stdout.strip().split('\n')
|
||||
assert "claude|0" in lines
|
||||
assert "agent-with-name|0" in lines
|
||||
|
||||
|
||||
def test_reconcile_relocatability(mam_sandbox, mock_herdr):
|
||||
"""
|
||||
Verify relocatability: reconcile.sh runs when relocated or inside different paths.
|
||||
"""
|
||||
reconcile_script = mam_sandbox / "skills" / "multi-agent-mux-monitor" / "scripts" / "reconcile.sh"
|
||||
run_dir = mam_sandbox / "some_other_dir"
|
||||
run_dir.mkdir()
|
||||
|
||||
res = subprocess.run(["bash", str(reconcile_script), "--once", "--emit-diff", "--dry-run"], cwd=str(run_dir), capture_output=True, text=True, env=dict(os.environ, HOME=str(mam_sandbox), WORKSPACE_ROOT=str(mam_sandbox), REAL_HERDR=str(mock_herdr)))
|
||||
assert res.returncode == 0, f"Failed when run from different directory. Stderr: {res.stderr}"
|
||||
|
||||
|
||||
def test_workspace_server_mapping(mam_sandbox, mock_herdr):
|
||||
"""
|
||||
Verify workspace/server mapping: status output workspace is correct.
|
||||
"""
|
||||
status_script = mam_sandbox / "skills" / "multi-agent-mux-status" / "scripts" / "status.sh"
|
||||
session_name = "test-mapping-sess-creator-claude"
|
||||
|
||||
mutation = f"""
|
||||
d['herdr_sessions'] = [{{
|
||||
'name': '{session_name}',
|
||||
'status': 'running',
|
||||
'role': 'Creator',
|
||||
'herdr_server': 'my-custom-workspace',
|
||||
'pane': {{
|
||||
'cwd': 'WS_PLACEHOLDER',
|
||||
'pid': 7777,
|
||||
'cmd': 'claude',
|
||||
'cmd_full': 'claude'
|
||||
}}
|
||||
}}]
|
||||
""".replace("WS_PLACEHOLDER", str(mam_sandbox))
|
||||
res_mut = run_mutation(mam_sandbox, mutation)
|
||||
assert res_mut.returncode == 0
|
||||
|
||||
res = subprocess.run(["bash", str(status_script), "--json"], capture_output=True, text=True, cwd=str(mam_sandbox), env=dict(os.environ, HOME=str(mam_sandbox), WORKSPACE_ROOT=str(mam_sandbox), REAL_HERDR=str(mock_herdr)))
|
||||
assert res.returncode == 0, f"Stderr: {res.stderr}"
|
||||
|
||||
data = json.loads(res.stdout)
|
||||
sess_detail = data["sessions_detail"]
|
||||
target_sess = [s for s in sess_detail if s["name"] == session_name][0]
|
||||
|
||||
assert target_sess["server"] == "my-custom-workspace"
|
||||
|
||||
|
||||
def test_variable_splicing_injection_safety(mam_sandbox, mock_herdr):
|
||||
"""
|
||||
Verify variable splicing injection: no vulnerabilities or syntax errors remain.
|
||||
"""
|
||||
lib_path = mam_sandbox / ".agents" / "skills" / "lib.sh"
|
||||
subprocess.run(["bash", "-c", f"source {lib_path}"], cwd=str(mam_sandbox), env=dict(os.environ, HOME=str(mam_sandbox), WORKSPACE_ROOT=str(mam_sandbox)))
|
||||
shim_path = mam_sandbox / ".mam" / "shim" / "herdr"
|
||||
|
||||
adversarial_name = 'my"server; import os; os.system("echo INJECTED")'
|
||||
run_env = dict(os.environ, HOME=str(mam_sandbox), WORKSPACE_ROOT=str(mam_sandbox), REAL_HERDR=str(mock_herdr), HERDR_SERVER_NAME=adversarial_name)
|
||||
|
||||
res = subprocess.run([str(shim_path), "new-session", "-s", "injection-test-session"], capture_output=True, text=True, env=run_env)
|
||||
assert res.returncode == 0, f"Failed with adversarial HERDR_SERVER_NAME. Stderr: {res.stderr}"
|
||||
|
||||
with open(mock_herdr, 'r') as f:
|
||||
state = json.load(f)
|
||||
assert any("injection-test-session" in call for call in state["calls"]), "Expected injection-test-session call to succeed"
|
||||
|
||||
|
||||
def test_export_masking_exit_code_preservation():
|
||||
"""
|
||||
Verify export masking: exit codes are preserved when assigning and exporting.
|
||||
"""
|
||||
# 1. Export masked version exits 0 (silent fail)
|
||||
cmd_masked = ["bash", "-c", "set -e; export TEST_VAR=$(false); echo 'survived'"]
|
||||
res_masked = subprocess.run(cmd_masked, capture_output=True, text=True)
|
||||
assert res_masked.returncode == 0
|
||||
assert res_masked.stdout.strip() == "survived"
|
||||
|
||||
# 2. Fixed split version exits non-zero (preserves failure exit code)
|
||||
cmd_fixed = ["bash", "-c", "set -e; TEST_VAR=$(false); export TEST_VAR; echo 'survived'"]
|
||||
res_fixed = subprocess.run(cmd_fixed, capture_output=True, text=True)
|
||||
assert res_fixed.returncode != 0
|
||||
assert res_fixed.stdout.strip() != "survived"
|
||||
@@ -1,205 +0,0 @@
|
||||
import os
|
||||
import subprocess
|
||||
import json
|
||||
import sqlite3
|
||||
import pytest
|
||||
import shutil
|
||||
import yaml
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Helper to run mutation on agent-sessions.yaml using atomic_dump_yaml in bash
|
||||
def run_mutation(mam_sandbox, mutation_str, env=None):
|
||||
lib_path = mam_sandbox / ".agents" / "skills" / "lib.sh"
|
||||
yaml_path = mam_sandbox / ".mam" / "agent-sessions.yaml"
|
||||
cmd_str = f"source {lib_path} && atomic_dump_yaml {yaml_path}"
|
||||
run_env = dict(os.environ)
|
||||
if env:
|
||||
run_env.update(env)
|
||||
res = subprocess.run(["bash", "-c", cmd_str], input=mutation_str, capture_output=True, text=True, env=run_env)
|
||||
return res
|
||||
|
||||
def test_stop_session_unvalidated_agent(mam_sandbox, mock_herdr, mock_agents):
|
||||
"""
|
||||
Test case 1: Unvalidated agent argument in stop_session.sh
|
||||
Verify that calling stop_session.sh with an invalid agent name returns code 2
|
||||
and exits with a clear error message to stderr.
|
||||
"""
|
||||
tmp_path = mam_sandbox
|
||||
stop_script = tmp_path / "skills" / "multi-agent-mux-stop" / "scripts" / "stop_session.sh"
|
||||
|
||||
# 1. Register a session first
|
||||
session_name = "invalid-agent-session-creator-claude"
|
||||
mutation = """
|
||||
d['herdr_sessions'] = [{
|
||||
'name': 'invalid-agent-session-creator-claude',
|
||||
'status': 'running',
|
||||
'role': 'Creator',
|
||||
'pane': {
|
||||
'cwd': 'WS_PLACEHOLDER',
|
||||
'pid': 8888,
|
||||
'cmd': 'claude',
|
||||
'cmd_full': 'claude'
|
||||
}
|
||||
}]
|
||||
""".replace("WS_PLACEHOLDER", str(tmp_path))
|
||||
res_mut = run_mutation(tmp_path, mutation)
|
||||
assert res_mut.returncode == 0
|
||||
|
||||
# 2. Try stopping with --agent invalid_agent and --purge-conversation
|
||||
cmd_stop = [
|
||||
"bash", str(stop_script),
|
||||
"--session", session_name,
|
||||
"--agent", "invalid_agent",
|
||||
"--purge-conversation",
|
||||
"--yes"
|
||||
]
|
||||
res_stop = subprocess.run(cmd_stop, capture_output=True, text=True, cwd=str(tmp_path))
|
||||
|
||||
# It returns code 2
|
||||
assert res_stop.returncode == 2
|
||||
|
||||
# And prints invalid agent type error to stderr
|
||||
assert "ERROR: invalid agent type 'invalid_agent'" in res_stop.stderr
|
||||
|
||||
# The registry entry is NOT removed
|
||||
yaml_path = tmp_path / ".mam" / "agent-sessions.yaml"
|
||||
with open(yaml_path, 'r') as f:
|
||||
reg = yaml.safe_load(f)
|
||||
assert len(reg.get("herdr_sessions", [])) == 1
|
||||
|
||||
|
||||
def test_special_character_workspace_slug_interpretation(mam_sandbox):
|
||||
"""
|
||||
Test case 2: Special character workspace slug interpretation
|
||||
Verify that workspace paths with only special characters result in derived session names
|
||||
that do NOT start with dashes, preventing option flags injection.
|
||||
"""
|
||||
tmp_path = mam_sandbox
|
||||
lib_path = tmp_path / ".agents" / "skills" / "lib.sh"
|
||||
|
||||
# Workspace consisting of purely special characters
|
||||
special_ws = tmp_path / "@#$*" / "@#$*"
|
||||
special_ws.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
cmd = f"source {lib_path} && derive_session_name '{special_ws}' 'claude'"
|
||||
res = subprocess.run(["bash", "-c", cmd], capture_output=True, text=True, cwd=str(tmp_path))
|
||||
assert res.returncode == 0
|
||||
derived_name = res.stdout.strip()
|
||||
|
||||
# Confirm that the derived name does NOT start with a dash and is default "ws"
|
||||
assert not derived_name.startswith("-")
|
||||
assert derived_name == "ws-creator-claude"
|
||||
|
||||
|
||||
def test_monitor_reconcile_race_on_purge(mam_sandbox, mock_herdr):
|
||||
"""
|
||||
Test case 3: Concurrency/Race condition during session purge
|
||||
Verify that if a session is being purged, the presence of the purging lock file
|
||||
prevents concurrent monitor checks from auto-registering it back.
|
||||
"""
|
||||
tmp_path = mam_sandbox
|
||||
reconcile_script = tmp_path / "skills" / "multi-agent-mux-monitor" / "scripts" / "reconcile.sh"
|
||||
|
||||
# 1. Register a running session in herdr and YAML
|
||||
session_name = "purged-race-creator-claude"
|
||||
with open(mock_herdr, 'r') as f:
|
||||
state = json.load(f)
|
||||
state["agents"][session_name] = {
|
||||
"status": "running",
|
||||
"agent": "claude",
|
||||
"cwd": str(tmp_path),
|
||||
"pid": 9999,
|
||||
"pane_id": "w1:p1",
|
||||
"command": "claude"
|
||||
}
|
||||
with open(mock_herdr, 'w') as f:
|
||||
json.dump(state, f, indent=2)
|
||||
|
||||
mutation = f"""
|
||||
d['herdr_sessions'] = [{{
|
||||
'name': '{session_name}',
|
||||
'status': 'running',
|
||||
'role': 'Creator',
|
||||
'pane': {{
|
||||
'cwd': 'WS_PLACEHOLDER',
|
||||
'pid': 9999,
|
||||
'cmd': 'claude',
|
||||
'cmd_full': 'claude'
|
||||
}}
|
||||
}}]
|
||||
""".replace("WS_PLACEHOLDER", str(tmp_path))
|
||||
res_mut = run_mutation(tmp_path, mutation)
|
||||
assert res_mut.returncode == 0
|
||||
|
||||
# 2. Simulate a purge: stop_session.sh deletes the registry row, but herdr session takes a moment to die.
|
||||
# We remove it from the YAML registry, and create the purging lock file.
|
||||
mutation_purge = f"""
|
||||
d['herdr_sessions'] = [s for s in d.get('herdr_sessions', []) if s.get('name') != '{session_name}']
|
||||
"""
|
||||
res_purge_mut = run_mutation(tmp_path, mutation_purge)
|
||||
assert res_purge_mut.returncode == 0
|
||||
|
||||
# Create the purging lock file
|
||||
purging_file = tmp_path / ".mam" / f"purging-{session_name}"
|
||||
purging_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
purging_file.touch()
|
||||
|
||||
# Confirm YAML registry has no sessions
|
||||
yaml_path = tmp_path / ".mam" / "agent-sessions.yaml"
|
||||
with open(yaml_path, 'r') as f:
|
||||
reg = yaml.safe_load(f)
|
||||
assert len(reg.get("herdr_sessions", [])) == 0
|
||||
|
||||
# 3. Run reconcile.sh once. It should NOT auto-register it back because the purging lock file exists!
|
||||
cmd_reconcile = ["bash", str(reconcile_script), "--once", "--emit-diff"]
|
||||
res_rec = subprocess.run(cmd_reconcile, capture_output=True, text=True, cwd=str(tmp_path))
|
||||
assert res_rec.returncode == 0
|
||||
|
||||
# Verify that the session has NOT been auto-registered back in YAML
|
||||
with open(yaml_path, 'r') as f:
|
||||
reg_after = yaml.safe_load(f)
|
||||
sessions_after = reg_after.get("herdr_sessions", [])
|
||||
assert len(sessions_after) == 0
|
||||
|
||||
|
||||
def test_mqtt_hmac_and_seq_validation(mam_sandbox):
|
||||
"""
|
||||
Test case 4: MQTT HMAC and sequence number validation
|
||||
Verify that mqtt_common correctly verifies HMAC signatures and drops messages with
|
||||
invalid signatures or out-of-order sequence numbers.
|
||||
"""
|
||||
# Import mqtt_common from sandboxed folder
|
||||
sys.path.insert(0, str(mam_sandbox / ".agents" / "skills" / "multi-agent-mux-delegate-job" / "scripts"))
|
||||
import mqtt_common
|
||||
|
||||
# 1. verify_hmac behaviour with no auth_token (PoC mode)
|
||||
payload_poc = {"data": {"val": 123}}
|
||||
assert mqtt_common.verify_hmac(payload_poc, None) is True
|
||||
|
||||
# 2. verify_hmac with auth_token and valid HMAC
|
||||
auth_token = "mysecrettoken"
|
||||
payload = {
|
||||
"job_id": "job123",
|
||||
"event": "started",
|
||||
"seq": 1,
|
||||
"data": {
|
||||
"val": 123
|
||||
}
|
||||
}
|
||||
|
||||
# Compute valid signature
|
||||
msg = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
|
||||
import hmac
|
||||
import hashlib
|
||||
sig = hmac.new(auth_token.encode(), msg, hashlib.sha256).hexdigest()
|
||||
|
||||
payload_signed = dict(payload)
|
||||
payload_signed["data"] = dict(payload["data"])
|
||||
payload_signed["data"]["hmac_sig"] = sig
|
||||
|
||||
assert mqtt_common.verify_hmac(payload_signed, auth_token) is True
|
||||
|
||||
# 3. verify_hmac with invalid signature
|
||||
payload_signed["data"]["hmac_sig"] = "invalidsignature"
|
||||
assert mqtt_common.verify_hmac(payload_signed, auth_token) is False
|
||||
@@ -0,0 +1,264 @@
|
||||
"""Deploy freshness — the deploy/ scripts must actually ship the latest assets.
|
||||
|
||||
``deploy/install.sh`` treats only ``.agents/skills/**`` as framework-owned. Every
|
||||
other shipped asset (``.agents/hooks.json``, ``.agents/hooks/*.sh``,
|
||||
``.agents/MULTI_AGENT_RULES*.md``, ``.agents/INSTALL.md``) takes the
|
||||
``elif [ ! -e "$dest" ]`` branch, so it is copied once and never refreshed.
|
||||
``deploy/remove.sh``'s manifest-less fallback list is likewise frozen at the
|
||||
pre-loop skill set, so it strands those same assets plus the whole
|
||||
``multi-agent-mux-loop`` skill.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
# Framework assets that live outside .agents/skills/ and must still be
|
||||
# refreshed. Mapped as {installed path: path in the source tree} because the
|
||||
# user manual is installed from deploy/, not from .agents/.
|
||||
NON_SKILL_ASSETS = {
|
||||
".agents/hooks.json": ".agents/hooks.json",
|
||||
".agents/hooks/loop_delegation_guard.sh": ".agents/hooks/loop_delegation_guard.sh",
|
||||
".agents/MULTI_AGENT_RULES.md": ".agents/MULTI_AGENT_RULES.md",
|
||||
".agents/MULTI_AGENT_RULES.ko.md": ".agents/MULTI_AGENT_RULES.ko.md",
|
||||
".agents/INSTALL.md": "deploy/INSTALL.md",
|
||||
}
|
||||
|
||||
MARK = "UPSTREAM-RELEASE-MARKER"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def src_and_target():
|
||||
"""A pristine copy of the working tree plus an empty install target.
|
||||
|
||||
Runtime directories are excluded so the fixture stays fast and so the
|
||||
source can never be confused with an already-installed workspace.
|
||||
"""
|
||||
tmp = tempfile.mkdtemp(prefix="mam_deploy_fresh_")
|
||||
src, tgt = os.path.join(tmp, "src"), os.path.join(tmp, "tgt")
|
||||
shutil.copytree(REPO_ROOT, src,
|
||||
ignore=shutil.ignore_patterns(".git", ".venv", ".mam",
|
||||
".mam_deploy", "__pycache__"),
|
||||
symlinks=True)
|
||||
os.makedirs(tgt)
|
||||
try:
|
||||
yield src, tgt
|
||||
finally:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
|
||||
def _install(src, tgt, **extra):
|
||||
env = dict(os.environ)
|
||||
env.update({"MAM_REPO_URL": src, "MAM_SKIP_VENV": "1",
|
||||
"MAM_SKIP_GITIGNORE": "1"})
|
||||
env.update(extra)
|
||||
return subprocess.run(["bash", os.path.join(src, "deploy", "install.sh"), tgt],
|
||||
env=env, capture_output=True, text=True)
|
||||
|
||||
|
||||
def _bump(src, rel):
|
||||
"""Simulate an upstream release that changed ``rel``."""
|
||||
path = os.path.join(src, rel)
|
||||
assert os.path.exists(path), "no such source asset: %s" % rel
|
||||
if rel.endswith(".json"):
|
||||
with open(path) as f:
|
||||
data = json.load(f)
|
||||
data["_release"] = MARK
|
||||
with open(path, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
else:
|
||||
with open(path, "a") as f:
|
||||
f.write("\n# %s\n" % MARK)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# D-1 — a refresh install must deliver upstream changes to EVERY framework
|
||||
# asset, not only to those under .agents/skills/.
|
||||
# --------------------------------------------------------------------------
|
||||
def test_d1_refresh_updates_non_skill_framework_assets(src_and_target):
|
||||
src, tgt = src_and_target
|
||||
assert _install(src, tgt).returncode == 0
|
||||
|
||||
for source_rel in NON_SKILL_ASSETS.values():
|
||||
_bump(src, source_rel)
|
||||
_bump(src, ".agents/skills/lib.sh") # control: this one is known to work
|
||||
|
||||
res = _install(src, tgt)
|
||||
assert res.returncode == 0, res.stderr
|
||||
|
||||
with open(os.path.join(tgt, ".agents/skills/lib.sh")) as f:
|
||||
assert MARK in f.read(), (
|
||||
"control failed: even a skills/ file did not refresh, so this test "
|
||||
"is not measuring what it claims")
|
||||
|
||||
stale = []
|
||||
for rel in NON_SKILL_ASSETS:
|
||||
with open(os.path.join(tgt, rel)) as f:
|
||||
if MARK not in f.read():
|
||||
stale.append(rel)
|
||||
assert not stale, (
|
||||
"install.sh refreshed .agents/skills/ but left these framework assets "
|
||||
"at their originally-installed version: %s. The O-3 guard and the "
|
||||
"MULTI_AGENT_RULES protocol therefore never reach an existing "
|
||||
"workspace." % stale)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# D-2 — remove.sh's manifest-less fallback must clear every asset the
|
||||
# installers write, or the next install is blocked by the survivors
|
||||
# (install.sh only copies a non-skill asset when it does not exist).
|
||||
# --------------------------------------------------------------------------
|
||||
def test_d2_manifestless_removal_strands_no_framework_assets(src_and_target):
|
||||
src, tgt = src_and_target
|
||||
assert _install(src, tgt).returncode == 0
|
||||
|
||||
# An install_mam.sh deployment leaves no manifest; force that code path.
|
||||
os.remove(os.path.join(tgt, ".mam", "install_manifest.txt"))
|
||||
|
||||
res = subprocess.run(["bash", os.path.join(tgt, ".mam_deploy", "remove.sh"),
|
||||
"--force", tgt], capture_output=True, text=True)
|
||||
assert res.returncode == 0, res.stderr
|
||||
|
||||
survivors = []
|
||||
for root, _, files in os.walk(os.path.join(tgt, ".agents")):
|
||||
for f in files:
|
||||
survivors.append(os.path.relpath(os.path.join(root, f), tgt))
|
||||
assert not survivors, (
|
||||
"the manifest-less fallback left framework assets behind: %s. Because "
|
||||
"install.sh copies a non-skill asset only when it is absent, these "
|
||||
"survivors permanently pin the workspace to the old version."
|
||||
% sorted(survivors))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# D-3 — install_mam.sh must record a manifest, so that remove.sh/update.sh
|
||||
# take the exact-reversal path instead of the frozen fallback list.
|
||||
# --------------------------------------------------------------------------
|
||||
def test_d3_install_mam_records_a_manifest():
|
||||
body = open(os.path.join(REPO_ROOT, "deploy", "install_mam.sh")).read()
|
||||
assert "install_manifest.txt" in body, (
|
||||
"deploy/install_mam.sh writes no .mam/install_manifest.txt, so every "
|
||||
"workspace it deploys is uninstalled and updated through remove.sh's "
|
||||
"frozen fallback list")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# D-5 — the fetch-validation gate must cover the assets a broken fetch would
|
||||
# plausibly drop, including the O-3 hook pair.
|
||||
# --------------------------------------------------------------------------
|
||||
def test_d5_asset_presence_gate_covers_the_hook_pair():
|
||||
body = open(os.path.join(REPO_ROOT, "deploy", "install.sh")).read()
|
||||
start = body.index("check_assets_present()")
|
||||
gate = body[start:body.index("}", body.index("return 0", start))]
|
||||
for rel in (".agents/hooks.json", ".agents/hooks/loop_delegation_guard.sh"):
|
||||
assert rel in gate, (
|
||||
"check_assets_present() does not require %s, so a fetch that "
|
||||
"silently dropped the O-3 guard still passes validation and "
|
||||
"installs a workspace whose guard is inert" % rel)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# D-6 — asset_hashes.txt drives both the refresh 3-way merge and remove.sh's
|
||||
# "preserve modified files" backup. Framework files outside skills/ get
|
||||
# neither today.
|
||||
# --------------------------------------------------------------------------
|
||||
def test_d6_hash_db_covers_non_skill_framework_assets(src_and_target):
|
||||
src, tgt = src_and_target
|
||||
assert _install(src, tgt).returncode == 0
|
||||
|
||||
with open(os.path.join(tgt, ".mam", "asset_hashes.txt")) as f:
|
||||
db = f.read()
|
||||
missing = [rel for rel in NON_SKILL_ASSETS if rel not in db]
|
||||
assert not missing, (
|
||||
"these framework assets are absent from .mam/asset_hashes.txt: %s. "
|
||||
"Local edits to them are silently destroyed by remove.sh instead of "
|
||||
"being backed up." % missing)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# D-7 — every variable the installer writes into a fresh .mam.env must be
|
||||
# documented in the committed template.
|
||||
# --------------------------------------------------------------------------
|
||||
def test_d7_env_template_documents_installer_applied_defaults():
|
||||
installer = open(os.path.join(REPO_ROOT, "deploy", "install.sh")).read()
|
||||
start_idx = installer.index("cat <<EOF > \"$MAM_ENV\"")
|
||||
end_idx = installer.index("EOF", start_idx + 15)
|
||||
block = installer[start_idx:end_idx]
|
||||
written = [ln.split("=", 1)[0].strip() for ln in block.splitlines()
|
||||
if "=" in ln and not ln.strip().startswith("#")]
|
||||
|
||||
template = open(os.path.join(REPO_ROOT, ".mam.env.example")).read()
|
||||
undocumented = [v for v in written if v not in template]
|
||||
assert not undocumented, (
|
||||
"install.sh seeds %s into every new .mam.env, but .mam.env.example "
|
||||
"never mentions them, so users cannot discover or correct them"
|
||||
% undocumented)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# D-8 — CI must lint the shipped hook and run the test suite that every
|
||||
# recent feature commit claims as its evidence.
|
||||
# --------------------------------------------------------------------------
|
||||
def test_d8_ci_lints_the_hook_and_runs_the_tests():
|
||||
ci = open(os.path.join(REPO_ROOT, "deploy", "gitea-ci.yml")).read()
|
||||
assert ".agents/hooks/loop_delegation_guard.sh" in ci, (
|
||||
"gitea-ci.yml shellchecks every other shipped bash entrypoint but not "
|
||||
"the O-3 hook")
|
||||
assert "pytest" in ci, (
|
||||
"gitea-ci.yml runs no pytest job, so none of the 17 files under tests/ "
|
||||
"ever gate a release")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# D-9 — .agents/INSTALL.md is shipped from two different sources: the .agents/
|
||||
# walk and the later `cp deploy/INSTALL.md`, whose `[ ! -e ]` guard can
|
||||
# therefore never fire. One of the two must not exist, or the herdr
|
||||
# migration's doc update silently loses to the tmux-era copy.
|
||||
# --------------------------------------------------------------------------
|
||||
def test_d9_installed_manual_is_not_the_stale_tmux_era_copy(src_and_target):
|
||||
src, tgt = src_and_target
|
||||
assert _install(src, tgt).returncode == 0
|
||||
|
||||
with open(os.path.join(tgt, ".agents", "INSTALL.md")) as f:
|
||||
shipped = f.read()
|
||||
assert "tmux" not in shipped, (
|
||||
"the installed .agents/INSTALL.md is the pre-herdr copy. install.sh "
|
||||
"stages .agents/INSTALL.md from the repo first, so its later "
|
||||
"`cp deploy/INSTALL.md` -- guarded by `[ ! -e ]` -- never runs and the "
|
||||
"up-to-date manual is never delivered")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# D-10 — a preserved customization must stay preserved across REPEATED
|
||||
# refreshes. The hash DB was rebuilt from the working copy, so the
|
||||
# second refresh saw target == db, classified the file as unmodified,
|
||||
# and overwrote it with no notice.
|
||||
# --------------------------------------------------------------------------
|
||||
def test_d10_customization_survives_repeated_refresh(src_and_target):
|
||||
src, tgt = src_and_target
|
||||
assert _install(src, tgt).returncode == 0
|
||||
|
||||
skill = os.path.join(tgt, ".agents", "skills", "lib.sh")
|
||||
with open(skill, "a") as f:
|
||||
f.write("\n# MY_LOCAL_TWEAK\n")
|
||||
|
||||
for n in (2, 3, 4):
|
||||
if n == 4: # upstream also moves on
|
||||
_bump(src, ".agents/skills/lib.sh")
|
||||
res = _install(src, tgt)
|
||||
assert res.returncode == 0, res.stderr
|
||||
with open(skill) as f:
|
||||
body = f.read()
|
||||
assert "MY_LOCAL_TWEAK" in body, (
|
||||
"the local customization was destroyed on refresh #%d. install.sh "
|
||||
"rebuilds .mam/asset_hashes.txt from the working copy, so after "
|
||||
"the first PRESERVE the modified hash becomes the canonical one "
|
||||
"and the next refresh silently overwrites it." % n)
|
||||
assert "Local modification detected" in res.stderr, (
|
||||
"refresh #%d overwrote nothing but also reported nothing; the "
|
||||
"user gets no signal that their edit is diverging" % n)
|
||||
@@ -16,6 +16,7 @@ class TestDeployLayout(unittest.TestCase):
|
||||
self.env = os.environ.copy()
|
||||
self.env["MAM_REPO_URL"] = self.repo_root
|
||||
self.env["MAM_SKIP_VENV"] = "1"
|
||||
self.env["MAM_INSTALLER_URL"] = "file://" + os.path.join(self.repo_root, "deploy", "install.sh")
|
||||
|
||||
def tearDown(self):
|
||||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
"""Deploy key-level registry merge tests for .agents/hooks.json.
|
||||
|
||||
Tests for Rev.2 key-level 3-way merge rules (R-1 to R-10).
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ws():
|
||||
"""A pristine source copy plus empty target directory."""
|
||||
tmp = tempfile.mkdtemp(prefix="mam_deploy_reg_")
|
||||
src, tgt = os.path.join(tmp, "src"), os.path.join(tmp, "tgt")
|
||||
shutil.copytree(REPO_ROOT, src,
|
||||
ignore=shutil.ignore_patterns(".git", ".venv", ".mam",
|
||||
".mam_deploy", "__pycache__"),
|
||||
symlinks=True)
|
||||
os.makedirs(tgt)
|
||||
try:
|
||||
yield src, tgt
|
||||
finally:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
|
||||
def _install(src, tgt, **extra):
|
||||
env = dict(os.environ)
|
||||
env.update({"MAM_REPO_URL": src, "MAM_SKIP_VENV": "1",
|
||||
"MAM_SKIP_GITIGNORE": "1"})
|
||||
env.update(extra)
|
||||
return subprocess.run(["bash", os.path.join(src, "deploy", "install.sh"), tgt],
|
||||
env=env, capture_output=True, text=True)
|
||||
|
||||
|
||||
def _install_mam(src, tgt):
|
||||
return subprocess.run(["bash", os.path.join(src, "deploy", "install_mam.sh"),
|
||||
"--target", tgt], capture_output=True, text=True)
|
||||
|
||||
|
||||
def _read_json(path):
|
||||
with open(path, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def _write_json(path, data):
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
|
||||
def _add_user_hook(tgt, name="custom-user-hook"):
|
||||
p = os.path.join(tgt, ".agents", "hooks.json")
|
||||
d = _read_json(p)
|
||||
d[name] = {"PreToolUse": [{"matcher": "user_action", "hooks": [{"type": "command", "command": "echo user"}]}]}
|
||||
_write_json(p, d)
|
||||
return name
|
||||
|
||||
|
||||
def _ship_new_hook(src, name="mam-loop-timeout-guard"):
|
||||
script = os.path.join(src, ".agents", "hooks", "loop_timeout_guard.sh")
|
||||
with open(script, "w") as f:
|
||||
f.write('#!/usr/bin/env bash\necho \'{"decision":"allow"}\'\n')
|
||||
os.chmod(script, 0o755)
|
||||
p = os.path.join(src, ".agents", "hooks.json")
|
||||
d = _read_json(p)
|
||||
d[name] = {"PreToolUse": [{"matcher": "file_change", "hooks": [
|
||||
{"type": "command", "command": "./hooks/loop_timeout_guard.sh", "timeout": 10}]}]}
|
||||
_write_json(p, d)
|
||||
return name
|
||||
|
||||
|
||||
def test_r1_user_hook_does_not_block_new_framework_hook(ws):
|
||||
src, tgt = ws
|
||||
assert _install(src, tgt).returncode == 0
|
||||
|
||||
user_hook = _add_user_hook(tgt)
|
||||
new_hook = _ship_new_hook(src)
|
||||
|
||||
res = _install(src, tgt)
|
||||
assert res.returncode == 0, res.stderr
|
||||
|
||||
tgt_data = _read_json(os.path.join(tgt, ".agents", "hooks.json"))
|
||||
assert user_hook in tgt_data, "User's custom hook was lost"
|
||||
assert new_hook in tgt_data, "New framework hook was not delivered"
|
||||
|
||||
|
||||
def test_r2_upstream_fix_to_an_existing_hook_lands(ws):
|
||||
src, tgt = ws
|
||||
assert _install(src, tgt).returncode == 0
|
||||
|
||||
_add_user_hook(tgt)
|
||||
|
||||
p_src = os.path.join(src, ".agents", "hooks.json")
|
||||
d_src = _read_json(p_src)
|
||||
key = list(d_src.keys())[0]
|
||||
d_src[key]["updated_by_upstream"] = True
|
||||
_write_json(p_src, d_src)
|
||||
|
||||
res = _install(src, tgt)
|
||||
assert res.returncode == 0, res.stderr
|
||||
|
||||
tgt_data = _read_json(os.path.join(tgt, ".agents", "hooks.json"))
|
||||
assert tgt_data.get(key, {}).get("updated_by_upstream") is True
|
||||
|
||||
|
||||
def test_r3_user_modified_hook_is_preserved_and_reported(ws):
|
||||
src, tgt = ws
|
||||
assert _install(src, tgt).returncode == 0
|
||||
|
||||
p_tgt = os.path.join(tgt, ".agents", "hooks.json")
|
||||
d_tgt = _read_json(p_tgt)
|
||||
key = list(d_tgt.keys())[0]
|
||||
d_tgt[key]["user_custom_setting"] = 123
|
||||
_write_json(p_tgt, d_tgt)
|
||||
|
||||
p_src = os.path.join(src, ".agents", "hooks.json")
|
||||
d_src = _read_json(p_src)
|
||||
d_src[key]["upstream_competing_setting"] = 456
|
||||
_write_json(p_src, d_src)
|
||||
|
||||
res = _install(src, tgt)
|
||||
assert res.returncode == 0, res.stderr
|
||||
|
||||
tgt_data = _read_json(p_tgt)
|
||||
assert tgt_data[key].get("user_custom_setting") == 123, "User edit was overwritten"
|
||||
assert "kept yours" in res.stderr or "kept your" in res.stderr or "Registry merged" in res.stderr
|
||||
|
||||
|
||||
def test_r4_retired_upstream_hook_removed_if_unmodified(ws):
|
||||
src, tgt = ws
|
||||
assert _install(src, tgt).returncode == 0
|
||||
|
||||
p_src = os.path.join(src, ".agents", "hooks.json")
|
||||
d_src = _read_json(p_src)
|
||||
retired_key = list(d_src.keys())[0]
|
||||
del d_src[retired_key]
|
||||
_write_json(p_src, d_src)
|
||||
|
||||
res = _install(src, tgt)
|
||||
assert res.returncode == 0, res.stderr
|
||||
|
||||
tgt_data = _read_json(os.path.join(tgt, ".agents", "hooks.json"))
|
||||
assert retired_key not in tgt_data, "Retired unmodified hook was not removed"
|
||||
|
||||
|
||||
def test_r4b_retired_upstream_hook_kept_if_user_modified(ws):
|
||||
src, tgt = ws
|
||||
assert _install(src, tgt).returncode == 0
|
||||
|
||||
p_tgt = os.path.join(tgt, ".agents", "hooks.json")
|
||||
d_tgt = _read_json(p_tgt)
|
||||
retired_key = list(d_tgt.keys())[0]
|
||||
d_tgt[retired_key]["modified_by_user"] = True
|
||||
_write_json(p_tgt, d_tgt)
|
||||
|
||||
p_src = os.path.join(src, ".agents", "hooks.json")
|
||||
d_src = _read_json(p_src)
|
||||
del d_src[retired_key]
|
||||
_write_json(p_src, d_src)
|
||||
|
||||
res = _install(src, tgt)
|
||||
assert res.returncode == 0, res.stderr
|
||||
|
||||
tgt_data = _read_json(p_tgt)
|
||||
assert retired_key in tgt_data, "User-modified retired hook should be kept"
|
||||
|
||||
|
||||
def test_r5_merge_preserves_backup_and_stderr_notification(ws):
|
||||
src, tgt = ws
|
||||
assert _install(src, tgt).returncode == 0
|
||||
|
||||
_add_user_hook(tgt)
|
||||
_ship_new_hook(src)
|
||||
|
||||
res = _install(src, tgt)
|
||||
assert res.returncode == 0, res.stderr
|
||||
assert "Registry merged" in res.stderr or "added" in res.stderr
|
||||
|
||||
backup_dir = os.path.join(tgt, ".mam", "skill-backups")
|
||||
assert os.path.exists(backup_dir) and len(os.listdir(backup_dir)) > 0
|
||||
|
||||
|
||||
def test_r6_missing_base_falls_back_to_additive_merge(ws):
|
||||
src, tgt = ws
|
||||
assert _install(src, tgt).returncode == 0
|
||||
|
||||
shutil.rmtree(os.path.join(tgt, ".mam", "base"), ignore_errors=True)
|
||||
|
||||
user_hook = _add_user_hook(tgt)
|
||||
new_hook = _ship_new_hook(src)
|
||||
|
||||
res = _install(src, tgt)
|
||||
assert res.returncode == 0, res.stderr
|
||||
|
||||
tgt_data = _read_json(os.path.join(tgt, ".agents", "hooks.json"))
|
||||
assert user_hook in tgt_data
|
||||
assert new_hook in tgt_data
|
||||
|
||||
|
||||
def test_r7_corrupt_registry_preserved_and_install_continues(ws):
|
||||
src, tgt = ws
|
||||
assert _install(src, tgt).returncode == 0
|
||||
|
||||
p_tgt = os.path.join(tgt, ".agents", "hooks.json")
|
||||
with open(p_tgt, "w") as f:
|
||||
f.write("INVALID JSON {{{")
|
||||
|
||||
res = _install(src, tgt)
|
||||
assert res.returncode == 0, res.stderr
|
||||
|
||||
with open(p_tgt) as f:
|
||||
assert "INVALID JSON" in f.read()
|
||||
|
||||
|
||||
def test_r8_merge_is_idempotent(ws):
|
||||
src, tgt = ws
|
||||
assert _install(src, tgt).returncode == 0
|
||||
|
||||
_add_user_hook(tgt)
|
||||
_ship_new_hook(src)
|
||||
|
||||
res1 = _install(src, tgt)
|
||||
assert res1.returncode == 0
|
||||
t1_content = open(os.path.join(tgt, ".agents", "hooks.json")).read()
|
||||
|
||||
res2 = _install(src, tgt)
|
||||
assert res2.returncode == 0
|
||||
t2_content = open(os.path.join(tgt, ".agents", "hooks.json")).read()
|
||||
|
||||
assert t1_content == t2_content
|
||||
|
||||
|
||||
def test_r9_install_mam_records_hash_db_and_base(ws):
|
||||
src, tgt = ws
|
||||
res = _install_mam(src, tgt)
|
||||
assert res.returncode == 0, res.stderr
|
||||
|
||||
assert os.path.exists(os.path.join(tgt, ".mam", "asset_hashes.txt"))
|
||||
assert os.path.exists(os.path.join(tgt, ".mam", "base", ".agents", "hooks.json"))
|
||||
|
||||
|
||||
def test_r10_missing_ownership_rules_abort_rather_than_degrade(ws):
|
||||
src, tgt = ws
|
||||
lib = os.path.join(src, "deploy", "lib_ownership.sh")
|
||||
assert os.path.exists(lib), "deploy/lib_ownership.sh is not shipped"
|
||||
os.remove(lib)
|
||||
|
||||
res = _install(src, tgt)
|
||||
assert res.returncode != 0
|
||||
assert "lib_ownership.sh" in res.stderr
|
||||
@@ -1,282 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Full regression test suite for .mam.env migration (T-1 through T-17)."""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
import subprocess
|
||||
import logging
|
||||
from unittest.mock import patch
|
||||
|
||||
import sys
|
||||
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../.agents/skills/multi-agent-mux-delegate-job/scripts")))
|
||||
import mqtt_common
|
||||
|
||||
class TestEnvMigrationFull(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.test_dir = tempfile.mkdtemp(prefix="mam_env_full_test_")
|
||||
self.old_cwd = os.getcwd()
|
||||
os.chdir(self.test_dir)
|
||||
self.clean_os_env()
|
||||
|
||||
def tearDown(self):
|
||||
os.chdir(self.old_cwd)
|
||||
shutil.rmtree(self.test_dir, ignore_errors=True)
|
||||
self.clean_os_env()
|
||||
|
||||
def clean_os_env(self):
|
||||
for k in ["MQTT_BROKER", "MQTT_PORT", "MQTT_PASSWORD", "TEST_KEY_A", "TEST_KEY_B", "MAM_ENV_FILE", "MAM_LEGACY_ENV_OWNED"]:
|
||||
os.environ.pop(k, None)
|
||||
mqtt_common._warned_deprecated_env = False
|
||||
mqtt_common._warned_coexistence_env = False
|
||||
|
||||
def test_t1_mam_env_only(self):
|
||||
"""T-1: Loads .mam.env when present."""
|
||||
mam_env = os.path.join(self.test_dir, ".mam.env")
|
||||
with open(mam_env, "w") as f:
|
||||
f.write("TEST_KEY_A=val_mam_env\n")
|
||||
|
||||
mqtt_common._load_dotenv(self.test_dir)
|
||||
self.assertEqual(os.environ.get("TEST_KEY_A"), "val_mam_env")
|
||||
|
||||
def test_t2_legacy_env_only_fallback_and_warning(self):
|
||||
"""T-2: Falls back to .env when .mam.env absent, logs deprecation warning."""
|
||||
legacy_env = os.path.join(self.test_dir, ".env")
|
||||
with open(legacy_env, "w") as f:
|
||||
f.write("TEST_KEY_A=val_legacy_env\n")
|
||||
|
||||
with self.assertLogs("delegate_job.mqtt_common", level="WARNING") as cm:
|
||||
mqtt_common._load_dotenv(self.test_dir)
|
||||
|
||||
self.assertEqual(os.environ.get("TEST_KEY_A"), "val_legacy_env")
|
||||
self.assertTrue(any("deprecated" in log for log in cm.output))
|
||||
|
||||
def test_t3_coexistence_mam_env_precedence_and_warning(self):
|
||||
"""T-3: When both exist, .mam.env wins and coexistence warning logs."""
|
||||
mam_env = os.path.join(self.test_dir, ".mam.env")
|
||||
legacy_env = os.path.join(self.test_dir, ".env")
|
||||
with open(mam_env, "w") as f:
|
||||
f.write("TEST_KEY_A=val_mam\n")
|
||||
with open(legacy_env, "w") as f:
|
||||
f.write("TEST_KEY_A=val_legacy\n")
|
||||
|
||||
with self.assertLogs("delegate_job.mqtt_common", level="WARNING") as cm:
|
||||
mqtt_common._load_dotenv(self.test_dir)
|
||||
|
||||
self.assertEqual(os.environ.get("TEST_KEY_A"), "val_mam")
|
||||
self.assertTrue(any("Both" in log for log in cm.output))
|
||||
|
||||
def test_t4_parent_boundary_stop_walkup_without_arg(self):
|
||||
"""T-4: Walk-up with no argument stops at boundary marker (.agents/.git)."""
|
||||
parent_dir = os.path.join(self.test_dir, "parent")
|
||||
child_repo = os.path.join(parent_dir, "child_repo")
|
||||
nested_script_dir = os.path.join(child_repo, ".agents", "skills", "test", "scripts")
|
||||
os.makedirs(nested_script_dir, exist_ok=True)
|
||||
|
||||
with open(os.path.join(parent_dir, ".env"), "w") as f:
|
||||
f.write("TEST_KEY_A=parent_leaked_secret\n")
|
||||
|
||||
dummy_file = os.path.join(nested_script_dir, "mqtt_common.py")
|
||||
with patch.object(mqtt_common, "__file__", dummy_file):
|
||||
mqtt_common._load_dotenv()
|
||||
|
||||
self.assertIsNone(os.environ.get("TEST_KEY_A"))
|
||||
|
||||
def test_t5_os_env_precedence(self):
|
||||
"""T-5: OS environment variables take precedence over env file values."""
|
||||
os.environ["TEST_KEY_A"] = "os_val"
|
||||
mam_env = os.path.join(self.test_dir, ".mam.env")
|
||||
with open(mam_env, "w") as f:
|
||||
f.write("TEST_KEY_A=file_val\n")
|
||||
|
||||
mqtt_common._load_dotenv(self.test_dir)
|
||||
self.assertEqual(os.environ.get("TEST_KEY_A"), "os_val")
|
||||
|
||||
def test_t6_mam_env_file_override(self):
|
||||
"""T-6: MAM_ENV_FILE takes highest precedence if explicitly provided."""
|
||||
custom_env = os.path.join(self.test_dir, "custom.env")
|
||||
with open(custom_env, "w") as f:
|
||||
f.write("TEST_KEY_A=custom_val\n")
|
||||
os.environ["MAM_ENV_FILE"] = custom_env
|
||||
|
||||
mam_env = os.path.join(self.test_dir, ".mam.env")
|
||||
with open(mam_env, "w") as f:
|
||||
f.write("TEST_KEY_A=mam_val\n")
|
||||
|
||||
mqtt_common._load_dotenv(self.test_dir)
|
||||
self.assertTrue(os.path.exists(custom_env))
|
||||
self.assertEqual(os.environ.get("TEST_KEY_A"), "custom_val")
|
||||
|
||||
def test_t7_wrapper_cwd_isolation(self):
|
||||
"""T-7: Bash wrapper executes cleanly and outputs help/warnings."""
|
||||
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
wrapper_path = os.path.join(repo_root, ".agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job")
|
||||
res = subprocess.run([wrapper_path, "--help"], capture_output=True, text=True)
|
||||
self.assertEqual(res.returncode, 0)
|
||||
|
||||
def test_t8_remove_force_preserves_owned_env(self):
|
||||
"""T-8 [MERGE BLOCKER]: --force MUST back up owned .env, NEVER delete without backup."""
|
||||
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
shutil.copy(os.path.join(repo_root, "deploy/remove.sh"), self.test_dir)
|
||||
|
||||
os.makedirs(".mam", exist_ok=True)
|
||||
with open(".mam/install_manifest.txt", "w") as f:
|
||||
f.write(".env\nremove.sh\n")
|
||||
|
||||
with open(".env", "w") as f:
|
||||
f.write("SECRET_KEY=user_secret_data\n")
|
||||
|
||||
res = subprocess.run(["bash", "remove.sh", "--force"], capture_output=True, text=True)
|
||||
self.assertEqual(res.returncode, 0, f"remove.sh failed: {res.stderr}")
|
||||
self.assertTrue(os.path.exists(".env.mam-backup"), "MAM-owned .env MUST be backed up under --force")
|
||||
with open(".env.mam-backup") as f:
|
||||
self.assertIn("user_secret_data", f.read())
|
||||
|
||||
def test_t8b_purge_env_is_sole_delete_authority(self):
|
||||
"""T-8b: --purge-env is the ONLY flag authorized to delete without backup."""
|
||||
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
shutil.copy(os.path.join(repo_root, "deploy/remove.sh"), self.test_dir)
|
||||
|
||||
os.makedirs(".mam", exist_ok=True)
|
||||
with open(".mam/install_manifest.txt", "w") as f:
|
||||
f.write(".env\nremove.sh\n")
|
||||
|
||||
with open(".env", "w") as f:
|
||||
f.write("SECRET_KEY=user_secret_data\n")
|
||||
|
||||
res = subprocess.run(["bash", "remove.sh", "--force", "--purge-env"], capture_output=True, text=True)
|
||||
self.assertEqual(res.returncode, 0)
|
||||
self.assertFalse(os.path.exists(".env"))
|
||||
self.assertFalse(os.path.exists(".env.mam-backup"))
|
||||
|
||||
def test_t9_git_check_ignore(self):
|
||||
"""T-9 [MERGE BLOCKER]: Verifies gitignore rules ignore .mam.env variants but track .mam.env.example."""
|
||||
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
check_cmd = ["git", "check-ignore", "-v", ".mam.env", ".mam.env.bak", ".mam.env.update-tmp", ".mam.env.mam-backup"]
|
||||
res = subprocess.run(check_cmd, cwd=repo_root, capture_output=True, text=True)
|
||||
self.assertEqual(res.returncode, 0, f"git check-ignore failed: {res.stderr}")
|
||||
|
||||
ex_cmd = ["git", "check-ignore", ".mam.env.example"]
|
||||
res_ex = subprocess.run(ex_cmd, cwd=repo_root, capture_output=True, text=True)
|
||||
self.assertNotEqual(res_ex.returncode, 0, ".mam.env.example should NOT be ignored by git")
|
||||
|
||||
def test_t10_shadowing_prevention_guard(self):
|
||||
"""T-10 [MERGE BLOCKER]: install.sh does NOT create new .mam.env if .env.update-tmp exists."""
|
||||
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
shutil.copy(os.path.join(repo_root, "deploy/install.sh"), self.test_dir)
|
||||
os.makedirs("deploy", exist_ok=True)
|
||||
shutil.copy(os.path.join(repo_root, "deploy/install.sh"), "deploy/install.sh")
|
||||
if os.path.exists(os.path.join(repo_root, ".mam.env.example")):
|
||||
shutil.copy(os.path.join(repo_root, ".mam.env.example"), ".mam.env.example")
|
||||
|
||||
with open(".env.update-tmp", "w") as f:
|
||||
f.write("MQTT_PASSWORD=s3cr3t_user_pass\n")
|
||||
|
||||
res = subprocess.run(["bash", "deploy/install.sh", self.test_dir], capture_output=True, text=True)
|
||||
self.assertEqual(res.returncode, 0, f"install.sh failed: {res.stderr}")
|
||||
self.assertIn("Preserved without shadowing", res.stdout)
|
||||
self.assertFalse(os.path.exists(".mam.env"), ".mam.env MUST NOT be created when .env.update-tmp is waiting for restore")
|
||||
|
||||
def test_t12_unowned_legacy_env_preservation(self):
|
||||
"""T-12 [MERGE BLOCKER]: install.sh leaves unowned .env untouched without heuristic hijacking."""
|
||||
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
os.makedirs("deploy", exist_ok=True)
|
||||
shutil.copy(os.path.join(repo_root, "deploy/install.sh"), "deploy/install.sh")
|
||||
if os.path.exists(os.path.join(repo_root, ".mam.env.example")):
|
||||
shutil.copy(os.path.join(repo_root, ".mam.env.example"), ".mam.env.example")
|
||||
|
||||
with open(".env", "w") as f:
|
||||
f.write("MQTT_BROKER=my-private-iot-broker.com\nSTRIPE_SECRET=sk_live_123\n")
|
||||
|
||||
res = subprocess.run(["bash", "deploy/install.sh", self.test_dir], capture_output=True, text=True)
|
||||
self.assertEqual(res.returncode, 0)
|
||||
self.assertTrue(os.path.exists(".env"), ".env should remain untouched")
|
||||
self.assertFalse(os.path.exists(".mam.env"), ".mam.env should NOT hijack unowned .env")
|
||||
|
||||
def test_t13_owned_legacy_env_migration_and_manifest_rewrite(self):
|
||||
"""T-13: Owned .env is migrated to .mam.env and manifest is updated."""
|
||||
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
os.makedirs("deploy", exist_ok=True)
|
||||
shutil.copy(os.path.join(repo_root, "deploy/install.sh"), "deploy/install.sh")
|
||||
|
||||
os.makedirs(".mam", exist_ok=True)
|
||||
with open(".mam/install_manifest.txt", "w") as f:
|
||||
f.write(".env\n")
|
||||
|
||||
with open(".env", "w") as f:
|
||||
f.write("MQTT_BROKER=owned-broker.internal\n")
|
||||
|
||||
res = subprocess.run(["bash", "deploy/install.sh", self.test_dir], capture_output=True, text=True)
|
||||
self.assertEqual(res.returncode, 0)
|
||||
self.assertTrue(os.path.exists(".mam.env"))
|
||||
self.assertFalse(os.path.exists(".env"))
|
||||
|
||||
with open(".mam/install_manifest.txt", "r") as f:
|
||||
manifest_lines = [line.strip() for line in f]
|
||||
self.assertIn(".mam.env", manifest_lines)
|
||||
self.assertNotIn(".env", manifest_lines)
|
||||
|
||||
def test_t16_backup_deduplication_across_reinstall_cycles(self):
|
||||
"""T-16 (Backup Hygiene): Repeated remove.sh -y -> install.sh cycles do not multiply identical backups."""
|
||||
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
os.makedirs("deploy", exist_ok=True)
|
||||
shutil.copy(os.path.join(repo_root, "deploy/install.sh"), "deploy/install.sh")
|
||||
shutil.copy(os.path.join(repo_root, "deploy/remove.sh"), "deploy/remove.sh")
|
||||
if os.path.exists(os.path.join(repo_root, ".mam.env.example")):
|
||||
shutil.copy(os.path.join(repo_root, ".mam.env.example"), ".mam.env.example")
|
||||
|
||||
# Initial setup: MAM-owned .mam.env with user secret
|
||||
os.makedirs(".mam", exist_ok=True)
|
||||
with open(".mam/install_manifest.txt", "w") as f:
|
||||
f.write(".mam.env\nremove.sh\nupdate.sh\n.mam.env.example\n")
|
||||
with open(".mam.env", "w") as f:
|
||||
f.write("MQTT_PASSWORD=REAL_USER_SECRET_12345\n")
|
||||
|
||||
# Cycle 1: remove -y
|
||||
res1 = subprocess.run(["bash", "deploy/remove.sh", "--force"], capture_output=True, text=True)
|
||||
self.assertEqual(res1.returncode, 0)
|
||||
self.assertTrue(os.path.exists(".mam.env.mam-backup"))
|
||||
|
||||
# Cycle 1: reinstall
|
||||
res_inst1 = subprocess.run(["bash", "deploy/install.sh", self.test_dir], capture_output=True, text=True)
|
||||
self.assertEqual(res_inst1.returncode, 0)
|
||||
|
||||
# Cycle 2: remove -y & reinstall
|
||||
subprocess.run(["bash", "deploy/remove.sh", "--force"], check=True)
|
||||
subprocess.run(["bash", "deploy/install.sh", self.test_dir], check=True)
|
||||
|
||||
# Cycle 3: remove -y (identical default content should be deduplicated)
|
||||
subprocess.run(["bash", "deploy/remove.sh", "--force"], check=True)
|
||||
|
||||
# Verify backup count stabilizes at <= 2 (Slot 1 original secret + 1 dedup default slot)
|
||||
backups = [f for f in os.listdir(self.test_dir) if f.startswith(".mam.env.mam-backup")]
|
||||
self.assertLessEqual(len(backups), 2, f"Backup files duplicated indefinitely: {backups}")
|
||||
|
||||
def test_t17_immutable_slot_1_user_secret_retention(self):
|
||||
"""T-17 [MERGE BLOCKER]: Slot 1 (.mam.env.mam-backup) retains original user secret after repeated cycles."""
|
||||
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
os.makedirs("deploy", exist_ok=True)
|
||||
shutil.copy(os.path.join(repo_root, "deploy/install.sh"), "deploy/install.sh")
|
||||
shutil.copy(os.path.join(repo_root, "deploy/remove.sh"), "deploy/remove.sh")
|
||||
if os.path.exists(os.path.join(repo_root, ".mam.env.example")):
|
||||
shutil.copy(os.path.join(repo_root, ".mam.env.example"), ".mam.env.example")
|
||||
|
||||
os.makedirs(".mam", exist_ok=True)
|
||||
with open(".mam/install_manifest.txt", "w") as f:
|
||||
f.write(".mam.env\nremove.sh\n")
|
||||
with open(".mam.env", "w") as f:
|
||||
f.write("MQTT_PASSWORD=REAL_USER_SECRET_12345\n")
|
||||
|
||||
# 3 cycles of remove -> install
|
||||
for _ in range(3):
|
||||
subprocess.run(["bash", "deploy/remove.sh", "--force"], check=True)
|
||||
subprocess.run(["bash", "deploy/install.sh", self.test_dir], check=True)
|
||||
|
||||
with open(".mam.env.mam-backup", "r") as f:
|
||||
slot1_content = f.read()
|
||||
self.assertIn("REAL_USER_SECRET_12345", slot1_content, "Slot 1 MUST retain original user secret permanently")
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,214 @@
|
||||
"""O-3 — Invocation-Aware Scoped Guard unit & integration test suite.
|
||||
|
||||
Verifies:
|
||||
Z-1: Normal mode allows all 3 mutating tools (file_change, edit_notebook, write_blob)
|
||||
Z-2: Active loop denies all 3 mutating tools and includes run_loop.sh in reason
|
||||
Z-3: Non-mutating tools (run_command, view_file, grep_search) allowed during loop
|
||||
Z-4: Malformed or unparseable input fails open (allows tool call)
|
||||
Z-5: Transcript signal covers gap before marker creation
|
||||
Z-6: Matcher targets derived step-type names (file_change|edit_notebook|write_blob)
|
||||
Z-7: run_loop.sh writes identity marker and releases it on exit/interrupt
|
||||
Z-8: Dead PID marker is ignored (fail open)
|
||||
Z-9: delegate_job_safe restores _mam_release_guard trap
|
||||
Z-10: Reused PID (live PID, stale lstart) is NOT blocked (Livelock prevention)
|
||||
Z-11: Live PID with matching lstart IS blocked
|
||||
Z-12: Process owned by another user (PermissionError) is NOT blocked
|
||||
Z-13: Legacy marker fallback (without lstart) degrades open for other owners
|
||||
Z-14: run_loop.sh records lstart in marker
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
GUARD_SCRIPT = REPO_ROOT / ".agents" / "hooks" / "loop_delegation_guard.sh"
|
||||
|
||||
|
||||
def _run_guard(payload_dict_or_raw, marker_path=None, transcript_path=None, env_extra=None):
|
||||
env = dict(os.environ)
|
||||
if env_extra:
|
||||
env.update(env_extra)
|
||||
if marker_path:
|
||||
env["MAM_LOOP_GUARD_MARKER"] = str(marker_path)
|
||||
|
||||
if isinstance(payload_dict_or_raw, dict):
|
||||
if transcript_path and "transcriptPath" not in payload_dict_or_raw:
|
||||
payload_dict_or_raw["transcriptPath"] = str(transcript_path)
|
||||
payload_str = json.dumps(payload_dict_or_raw)
|
||||
else:
|
||||
payload_str = str(payload_dict_or_raw)
|
||||
|
||||
res = subprocess.run(
|
||||
["bash", str(GUARD_SCRIPT)],
|
||||
input=payload_str,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=str(REPO_ROOT),
|
||||
env=env,
|
||||
)
|
||||
assert res.returncode == 0
|
||||
return json.loads(res.stdout)
|
||||
|
||||
|
||||
def _get_lstart(pid):
|
||||
out = subprocess.run(["ps", "-p", str(pid), "-o", "lstart="], capture_output=True, text=True)
|
||||
return " ".join(out.stdout.split())
|
||||
|
||||
|
||||
# Z-1: Normal mode allows all 3 mutating tools
|
||||
@pytest.mark.parametrize("tool_name", ["file_change", "edit_notebook", "write_blob"])
|
||||
def test_z1_normal_mode_allows(mam_sandbox, tool_name):
|
||||
payload = {"toolCall": {"name": tool_name}, "workspacePaths": [str(mam_sandbox)]}
|
||||
res = _run_guard(payload, marker_path=mam_sandbox / ".mam" / "nonexistent")
|
||||
assert res["decision"] == "allow"
|
||||
|
||||
|
||||
# Z-2: Active loop denies mutating tools
|
||||
@pytest.mark.parametrize("tool_name", ["file_change", "edit_notebook", "write_blob"])
|
||||
def test_z2_active_loop_denies(mam_sandbox, tool_name):
|
||||
marker = mam_sandbox / ".mam" / "loop-guard-active"
|
||||
marker.parent.mkdir(parents=True, exist_ok=True)
|
||||
my_pid = os.getpid()
|
||||
lstart = _get_lstart(my_pid)
|
||||
marker.write_text(f"pid={my_pid}\nlstart={lstart}\nstarted=2026-08-07T00:00:00Z\n")
|
||||
|
||||
payload = {"toolCall": {"name": tool_name}, "workspacePaths": [str(mam_sandbox)]}
|
||||
res = _run_guard(payload, marker_path=marker)
|
||||
assert res["decision"] == "deny"
|
||||
assert "run_loop.sh" in res.get("reason", "")
|
||||
|
||||
|
||||
# Z-3: Non-mutating tools allowed during loop
|
||||
@pytest.mark.parametrize("tool_name", ["run_command", "view_file", "grep_search"])
|
||||
def test_z3_non_mutating_tools_allowed(mam_sandbox, tool_name):
|
||||
marker = mam_sandbox / ".mam" / "loop-guard-active"
|
||||
marker.parent.mkdir(parents=True, exist_ok=True)
|
||||
my_pid = os.getpid()
|
||||
lstart = _get_lstart(my_pid)
|
||||
marker.write_text(f"pid={my_pid}\nlstart={lstart}\n")
|
||||
|
||||
payload = {"toolCall": {"name": tool_name}, "workspacePaths": [str(mam_sandbox)]}
|
||||
res = _run_guard(payload, marker_path=marker)
|
||||
assert res["decision"] == "allow"
|
||||
|
||||
|
||||
# Z-4: Malformed input fails open
|
||||
@pytest.mark.parametrize("bad_input", ["not json", "{bad: json", "", "[]", "123"])
|
||||
def test_z4_malformed_input_fails_open(mam_sandbox, bad_input):
|
||||
marker = mam_sandbox / ".mam" / "loop-guard-active"
|
||||
marker.parent.mkdir(parents=True, exist_ok=True)
|
||||
marker.write_text(f"pid={os.getpid()}\nlstart={_get_lstart(os.getpid())}\n")
|
||||
|
||||
res = _run_guard(bad_input, marker_path=marker)
|
||||
assert res["decision"] == "allow"
|
||||
|
||||
|
||||
# Z-5: Transcript signal covers gap before marker creation
|
||||
def test_z5_transcript_signal(mam_sandbox):
|
||||
tfile = mam_sandbox / "transcript.jsonl"
|
||||
tfile.write_text('{"content": "user typed /multi-agent-mux-loop --task test"}\n')
|
||||
|
||||
payload = {"toolCall": {"name": "file_change"}, "workspacePaths": [str(mam_sandbox)], "transcriptPath": str(tfile)}
|
||||
res = _run_guard(payload, marker_path=mam_sandbox / ".mam" / "nonexistent")
|
||||
assert res["decision"] == "deny"
|
||||
|
||||
|
||||
# Z-6: Matcher targets step-type names
|
||||
def test_z6_hooks_json_matcher():
|
||||
hooks_json = REPO_ROOT / ".agents" / "hooks.json"
|
||||
assert hooks_json.exists()
|
||||
data = json.loads(hooks_json.read_text())
|
||||
matcher = data["mam-loop-delegation-guard"]["PreToolUse"][0]["matcher"]
|
||||
assert matcher == "file_change|edit_notebook|write_blob"
|
||||
assert "write_to_file" not in matcher
|
||||
assert "replace_file_content" not in matcher
|
||||
|
||||
|
||||
# Z-7: run_loop.sh source verifies marker creation and trap release
|
||||
def test_z7_run_loop_marker_creation_and_trap():
|
||||
run_loop = REPO_ROOT / ".agents" / "skills" / "multi-agent-mux-loop" / "scripts" / "run_loop.sh"
|
||||
content = run_loop.read_text()
|
||||
assert 'MAM_LOOP_MARKER=' in content
|
||||
assert 'trap _mam_release_guard EXIT INT TERM HUP' in content
|
||||
|
||||
|
||||
# Z-8: Dead PID marker is ignored
|
||||
def test_z8_dead_pid_marker(mam_sandbox):
|
||||
marker = mam_sandbox / ".mam" / "loop-guard-active"
|
||||
marker.parent.mkdir(parents=True, exist_ok=True)
|
||||
marker.write_text("pid=999999\nlstart=Sat Jan 1 00:00:00 2000\n")
|
||||
|
||||
payload = {"toolCall": {"name": "file_change"}, "workspacePaths": [str(mam_sandbox)]}
|
||||
res = _run_guard(payload, marker_path=marker)
|
||||
assert res["decision"] == "allow"
|
||||
|
||||
|
||||
# Z-9: delegate_job_safe restores _mam_release_guard trap
|
||||
def test_z9_delegate_job_safe_restores_trap():
|
||||
run_loop = REPO_ROOT / ".agents" / "skills" / "multi-agent-mux-loop" / "scripts" / "run_loop.sh"
|
||||
content = run_loop.read_text()
|
||||
# Check inside delegate_job_safe definition that trap - is replaced with trap _mam_release_guard
|
||||
func_start = content.find("delegate_job_safe() {")
|
||||
assert func_start != -1
|
||||
func_body = content[func_start:func_start+400]
|
||||
assert "trap _mam_release_guard EXIT INT TERM HUP" in func_body
|
||||
assert "trap - EXIT INT TERM HUP" not in func_body
|
||||
|
||||
|
||||
# Z-10: Reused PID with stale lstart is NOT blocked
|
||||
def test_z10_reused_pid_stale_lstart(mam_sandbox):
|
||||
marker = mam_sandbox / ".mam" / "loop-guard-active"
|
||||
marker.parent.mkdir(parents=True, exist_ok=True)
|
||||
my_pid = os.getpid()
|
||||
marker.write_text(f"pid={my_pid}\nlstart=Wed Jan 1 00:00:00 1999\n")
|
||||
|
||||
payload = {"toolCall": {"name": "file_change"}, "workspacePaths": [str(mam_sandbox)]}
|
||||
res = _run_guard(payload, marker_path=marker)
|
||||
assert res["decision"] == "allow"
|
||||
|
||||
|
||||
# Z-11: Live PID with matching lstart IS blocked
|
||||
def test_z11_live_pid_matching_lstart(mam_sandbox):
|
||||
marker = mam_sandbox / ".mam" / "loop-guard-active"
|
||||
marker.parent.mkdir(parents=True, exist_ok=True)
|
||||
my_pid = os.getpid()
|
||||
lstart = _get_lstart(my_pid)
|
||||
marker.write_text(f"pid={my_pid}\nlstart={lstart}\n")
|
||||
|
||||
payload = {"toolCall": {"name": "file_change"}, "workspacePaths": [str(mam_sandbox)]}
|
||||
res = _run_guard(payload, marker_path=marker)
|
||||
assert res["decision"] == "deny"
|
||||
|
||||
|
||||
# Z-12: Process owned by another user (pid=1 root) is NOT blocked
|
||||
def test_z12_other_user_process_does_not_block(mam_sandbox):
|
||||
marker = mam_sandbox / ".mam" / "loop-guard-active"
|
||||
marker.parent.mkdir(parents=True, exist_ok=True)
|
||||
marker.write_text("pid=1\nlstart=Sat Jan 1 00:00:00 2000\n")
|
||||
|
||||
payload = {"toolCall": {"name": "file_change"}, "workspacePaths": [str(mam_sandbox)]}
|
||||
res = _run_guard(payload, marker_path=marker)
|
||||
assert res["decision"] == "allow"
|
||||
|
||||
|
||||
# Z-13: Legacy marker without lstart degrades open for other owners
|
||||
def test_z13_legacy_marker_permission_error_degrades_open(mam_sandbox):
|
||||
marker = mam_sandbox / ".mam" / "loop-guard-active"
|
||||
marker.parent.mkdir(parents=True, exist_ok=True)
|
||||
marker.write_text("pid=1\n")
|
||||
|
||||
payload = {"toolCall": {"name": "file_change"}, "workspacePaths": [str(mam_sandbox)]}
|
||||
res = _run_guard(payload, marker_path=marker)
|
||||
assert res["decision"] == "allow"
|
||||
|
||||
|
||||
# Z-14: run_loop.sh records lstart in marker
|
||||
def test_z14_run_loop_records_lstart(mam_sandbox):
|
||||
run_loop = REPO_ROOT / ".agents" / "skills" / "multi-agent-mux-loop" / "scripts" / "run_loop.sh"
|
||||
content = run_loop.read_text()
|
||||
assert "_mam_lstart" in content
|
||||
assert "lstart=" in content
|
||||
@@ -19,7 +19,6 @@ def test_create_session_dry_run(mam_sandbox, mock_herdr, mock_agents):
|
||||
]
|
||||
res = subprocess.run(cmd, capture_output=True, text=True)
|
||||
assert res.returncode == 0, f"Stdout: {res.stdout}\nStderr: {res.stderr}"
|
||||
assert "[dry-run] would provision isolation" in res.stdout
|
||||
assert "[dry-run] would spawn" in res.stdout
|
||||
|
||||
# Verify that mock herdr was called for checking session existence (which gets translated to agent get)
|
||||
@@ -71,4 +70,3 @@ def test_create_session_full(mam_sandbox, mock_herdr, mock_agents):
|
||||
assert sessions[0]["name"] == session_name
|
||||
assert sessions[0]["status"] == "running"
|
||||
assert sessions[0]["role"] == "Creator"
|
||||
assert sessions[0]["isolation"]["uuid"] is not None
|
||||
|
||||
@@ -35,8 +35,7 @@ def test_integration_create_options_combination(mam_sandbox, mock_herdr, mock_ag
|
||||
"--agent", "claude",
|
||||
"--role", "Creator",
|
||||
"--onboard",
|
||||
"--submit-job", "Test onboard prompt",
|
||||
"--wrapper"
|
||||
"--submit-job", "Test onboard prompt"
|
||||
]
|
||||
|
||||
res = subprocess.run(cmd, capture_output=True, text=True, cwd=str(tmp_path))
|
||||
@@ -44,10 +43,11 @@ def test_integration_create_options_combination(mam_sandbox, mock_herdr, mock_ag
|
||||
with open(mock_herdr, 'r') as f:
|
||||
state = json.load(f)
|
||||
debug_info = (
|
||||
f"RETURNCODE: {res.returncode}\n"
|
||||
f"MOCK HERDR CALLS: {state.get('calls', [])}\n"
|
||||
f"MOCK HERDR AGENTS: {state.get('agents', {})}\n"
|
||||
)
|
||||
assert False, f"Stdout: {res.stdout}\nStderr: {res.stderr}\nDebug:\n{debug_info}"
|
||||
assert False, f"ReturnCode: {res.returncode}\nStdout: {res.stdout}\nStderr: {res.stderr}\nDebug:\n{debug_info}"
|
||||
|
||||
assert res.returncode == 0
|
||||
|
||||
@@ -63,7 +63,7 @@ def test_integration_create_options_combination(mam_sandbox, mock_herdr, mock_ag
|
||||
session = sessions[0]
|
||||
|
||||
assert session["role"] == "Creator"
|
||||
assert session.get("herdr_session") == "custom_server"
|
||||
assert session.get("herdr_server") is not None
|
||||
assert session["delegate_job_id"] is not None
|
||||
assert session["status"] == "running"
|
||||
|
||||
@@ -98,22 +98,18 @@ def test_integration_stop_purge_combination(mam_sandbox, mock_herdr, mock_agents
|
||||
res_create = subprocess.run(cmd_create, capture_output=True, text=True, cwd=str(tmp_path))
|
||||
assert res_create.returncode == 0, f"Stdout: {res_create.stdout}\nStderr: {res_create.stderr}"
|
||||
|
||||
# Get session name and isolation uuid
|
||||
# Get session name and claude session uuid
|
||||
yaml_path = tmp_path / ".mam" / "agent-sessions.yaml"
|
||||
with open(yaml_path, 'r') as f:
|
||||
reg = yaml.safe_load(f)
|
||||
session = reg["herdr_sessions"][0]
|
||||
session_name = session["name"]
|
||||
iso_uuid = session["isolation"]["uuid"]
|
||||
iso_root = Path(session["isolation"]["root"])
|
||||
|
||||
# Ensure isolation home root folder is provisioned
|
||||
assert iso_root.exists()
|
||||
claude_uuid = session["claude_session_id_own"]
|
||||
|
||||
# Locate dynamically generated conversation file in claude projects
|
||||
key = str(tmp_path).replace('/', '-').replace('_', '-')
|
||||
proj_dir = iso_root / "projects" / key
|
||||
assert proj_dir.exists(), f"Expected isolated projects directory {proj_dir} to exist"
|
||||
proj_dir = tmp_path / ".claude" / "projects" / key
|
||||
assert proj_dir.exists(), f"Expected projects directory {proj_dir} to exist"
|
||||
|
||||
jsonls = list(proj_dir.glob("*.jsonl"))
|
||||
assert len(jsonls) == 1, f"Expected exactly 1 jsonl file in {proj_dir}, found {jsonls}"
|
||||
@@ -142,12 +138,13 @@ def test_integration_stop_purge_combination(mam_sandbox, mock_herdr, mock_agents
|
||||
"--session", session_name,
|
||||
"--purge-conversation"
|
||||
]
|
||||
res_stop_no_yes = subprocess.run(cmd_stop_no_yes, capture_output=True, text=True, cwd=str(tmp_path))
|
||||
env_stop = dict(os.environ)
|
||||
env_stop["CLAUDE_PROJECT_DIR"] = str(tmp_path / ".claude" / "projects")
|
||||
res_stop_no_yes = subprocess.run(cmd_stop_no_yes, capture_output=True, text=True, cwd=str(tmp_path), env=env_stop)
|
||||
assert res_stop_no_yes.returncode == 3
|
||||
assert "DANGER: --purge-conversation" in res_stop_no_yes.stdout
|
||||
|
||||
# Ensure nothing was deleted yet
|
||||
assert iso_root.exists()
|
||||
assert jsonl_file.exists()
|
||||
|
||||
# 3. Run stop_session with --yes
|
||||
@@ -157,14 +154,12 @@ def test_integration_stop_purge_combination(mam_sandbox, mock_herdr, mock_agents
|
||||
"--purge-conversation",
|
||||
"--yes"
|
||||
]
|
||||
res_stop_yes = subprocess.run(cmd_stop_yes, capture_output=True, text=True, cwd=str(tmp_path))
|
||||
res_stop_yes = subprocess.run(cmd_stop_yes, capture_output=True, text=True, cwd=str(tmp_path), env=env_stop)
|
||||
assert res_stop_yes.returncode == 0, f"Stdout: {res_stop_yes.stdout}\nStderr: {res_stop_yes.stderr}"
|
||||
|
||||
# Assertions:
|
||||
# - Isolation directory deleted
|
||||
assert not iso_root.exists()
|
||||
# - Conversation files deleted
|
||||
assert not jsonl_file.exists()
|
||||
assert not jsonl_file.exists(), f"jsonl_file {jsonl_file} still exists!\nstop_yes stdout:\n{res_stop_yes.stdout}\nstop_yes stderr:\n{res_stop_yes.stderr}\npurge_uuid checked was claude_uuid: {claude_uuid}"
|
||||
# - Session completely removed from YAML/DB registry
|
||||
with open(yaml_path, 'r') as f:
|
||||
reg = yaml.safe_load(f)
|
||||
|
||||
+53
-35
@@ -6,6 +6,7 @@ import pytest
|
||||
import shutil
|
||||
import yaml
|
||||
import time
|
||||
import fcntl
|
||||
import concurrent.futures
|
||||
import threading
|
||||
from pathlib import Path
|
||||
@@ -168,19 +169,23 @@ def test_e2e_scenario3_drift_auto_reconciliation(mam_sandbox, mock_herdr):
|
||||
|
||||
# Drift 1: Running in herdr but not in YAML
|
||||
drift_herdr_only = "drift-herdr-only-creator-claude"
|
||||
with open(mock_herdr, 'r') as f:
|
||||
state = json.load(f)
|
||||
state["agents"][drift_herdr_only] = {
|
||||
"status": "running",
|
||||
"agent": "claude",
|
||||
"cwd": str(tmp_path),
|
||||
"pid": 5555,
|
||||
"pane_id": "w1:p1",
|
||||
"command": "claude",
|
||||
"buffer": "Anthropic Claude Ready"
|
||||
}
|
||||
with open(mock_herdr, 'w') as f:
|
||||
json.dump(state, f, indent=2)
|
||||
lock_path = str(mock_herdr) + ".lock"
|
||||
with open(lock_path, 'w') as lock_f:
|
||||
fcntl.flock(lock_f, fcntl.LOCK_EX)
|
||||
with open(mock_herdr, 'r') as f:
|
||||
state = json.load(f)
|
||||
state["agents"][drift_herdr_only] = {
|
||||
"status": "running",
|
||||
"agent": "claude",
|
||||
"cwd": str(tmp_path),
|
||||
"pid": 5555,
|
||||
"pane_id": "w1:p1",
|
||||
"command": "claude",
|
||||
"buffer": "Anthropic Claude Ready"
|
||||
}
|
||||
with open(mock_herdr, 'w') as f:
|
||||
json.dump(state, f, indent=2)
|
||||
fcntl.flock(lock_f, fcntl.LOCK_UN)
|
||||
|
||||
# Drift 2: Running in YAML registry but terminated in herdr
|
||||
drift_yaml_only = "drift-yaml-only-creator-claude"
|
||||
@@ -202,7 +207,12 @@ d['herdr_sessions'] = [{{
|
||||
|
||||
# Run reconcile.sh --once
|
||||
cmd_reconcile = ["bash", str(reconcile_script), "--once"]
|
||||
res_recon = subprocess.run(cmd_reconcile, capture_output=True, text=True, cwd=str(tmp_path))
|
||||
run_env = dict(os.environ)
|
||||
run_env["LOCAL_BIN"] = str(tmp_path / "bin")
|
||||
run_env["PATH"] = str(tmp_path / "bin") + ":" + os.environ.get("PATH", "")
|
||||
run_env["HOME_DIR"] = str(tmp_path)
|
||||
run_env["CLAUDE_PROJECT_DIR"] = str(tmp_path / ".claude" / "projects")
|
||||
res_recon = subprocess.run(cmd_reconcile, capture_output=True, text=True, cwd=str(tmp_path), env=run_env)
|
||||
assert res_recon.returncode == 0, f"Stderr: {res_recon.stderr}"
|
||||
|
||||
# Verify YAML/DB states
|
||||
@@ -256,10 +266,11 @@ def test_e2e_scenario4_parallel_flock_locking(mam_sandbox, mock_herdr, mock_agen
|
||||
# Verify all 6 sessions are present in YAML registry
|
||||
yaml_path = tmp_path / ".mam" / "agent-sessions.yaml"
|
||||
with open(yaml_path, 'r') as f:
|
||||
reg = yaml.safe_load(f)
|
||||
reg = yaml.safe_load(f) or {}
|
||||
sessions = reg.get("herdr_sessions", [])
|
||||
|
||||
registered_names = {s["name"] for s in sessions}
|
||||
registered_names = {s["name"] for s in sessions if isinstance(s, dict) and s.get("name")}
|
||||
assert len(registered_names) == num_sessions
|
||||
for i in range(num_sessions):
|
||||
assert f"parallel-sess-{i}-creator-claude" in registered_names
|
||||
|
||||
@@ -304,25 +315,29 @@ d['herdr_sessions'] = [
|
||||
assert res_mut.returncode == 0
|
||||
|
||||
# Seed mock herdr state with these running sessions to satisfy has-session checks
|
||||
with open(mock_herdr, 'r') as f:
|
||||
herdr_state = json.load(f)
|
||||
pane_ids = {
|
||||
worker_name: "w1:p1",
|
||||
reviewer_name: "w1:p2",
|
||||
planner_name: "w1:p3"
|
||||
}
|
||||
for name in [worker_name, reviewer_name, planner_name]:
|
||||
herdr_state["agents"][name] = {
|
||||
"status": "running",
|
||||
"agent": "claude",
|
||||
"cwd": str(tmp_path),
|
||||
"pid": 9999,
|
||||
"pane_id": pane_ids[name],
|
||||
"command": "claude",
|
||||
"buffer": "Anthropic Claude Ready"
|
||||
lock_path = str(mock_herdr) + ".lock"
|
||||
with open(lock_path, 'w') as lock_f:
|
||||
fcntl.flock(lock_f, fcntl.LOCK_EX)
|
||||
with open(mock_herdr, 'r') as f:
|
||||
herdr_state = json.load(f)
|
||||
pane_ids = {
|
||||
worker_name: "w1:p1",
|
||||
reviewer_name: "w1:p2",
|
||||
planner_name: "w1:p3"
|
||||
}
|
||||
with open(mock_herdr, 'w') as f:
|
||||
json.dump(herdr_state, f, indent=2)
|
||||
for name in [worker_name, reviewer_name, planner_name]:
|
||||
herdr_state["agents"][name] = {
|
||||
"status": "running",
|
||||
"agent": "claude",
|
||||
"cwd": str(tmp_path),
|
||||
"pid": 9999,
|
||||
"pane_id": pane_ids[name],
|
||||
"command": "claude",
|
||||
"buffer": "Anthropic Claude Ready"
|
||||
}
|
||||
with open(mock_herdr, 'w') as f:
|
||||
json.dump(herdr_state, f, indent=2)
|
||||
fcntl.flock(lock_f, fcntl.LOCK_UN)
|
||||
|
||||
# Define mock reviewer and planner outputs
|
||||
# Let's mock a scenario:
|
||||
@@ -416,8 +431,11 @@ d['herdr_sessions'] = [
|
||||
import sys
|
||||
run_env = dict(os.environ)
|
||||
run_env["DELEGATE_JOB_PYTHON"] = sys.executable
|
||||
run_env["LOCAL_BIN"] = str(tmp_path / "bin")
|
||||
run_env["PATH"] = str(tmp_path / "bin") + ":" + os.environ.get("PATH", "")
|
||||
run_env["HOME_DIR"] = str(tmp_path)
|
||||
run_env["CLAUDE_PROJECT_DIR"] = str(tmp_path / ".claude" / "projects")
|
||||
res_loop = subprocess.run(cmd_loop, capture_output=True, text=True, cwd=str(tmp_path), env=run_env)
|
||||
# Verify that it succeeded and executed the corrective loop
|
||||
assert res_loop.returncode == 0, f"Loop failed. Stdout: {res_loop.stdout}\nStderr: {res_loop.stderr}"
|
||||
assert "Reviewer 'test-reviewer-creator-claude': NOT PASS" in res_loop.stdout or "Reviewer 'test-reviewer-creator-claude': NOT PASS" in res_loop.stderr or "NOT PASS" in res_loop.stdout
|
||||
assert "Reviewer 'test-reviewer-creator-claude': PASS" in res_loop.stdout
|
||||
|
||||
Reference in New Issue
Block a user