From 245abe62c8b67dc6fcf475dbf2386916ca7975a7 Mon Sep 17 00:00:00 2001 From: Godopu Date: Sun, 9 Aug 2026 10:24:32 +0900 Subject: [PATCH] feat(create/monitor): implement automatic UUID materialization, auto-pinning, and drift-C2 resolution (13/13 PASS) --- .agents/MULTI_AGENT_RULES.ko.md | 7 + .agents/MULTI_AGENT_RULES.md | 7 + .../plan-b107cf34.md | 397 ++++++++++++++++ .../report-cfe439f6.md | 146 ++++++ .agents/skills/lib.sh | 156 +++++-- .../skills/multi-agent-mux-create/SKILL.md | 4 +- .../scripts/create_session.sh | 18 +- .../skills/multi-agent-mux-monitor/SKILL.md | 19 +- .../scripts/reconcile.sh | 70 ++- .../skills/multi-agent-mux-resume/SKILL.md | 3 +- .../scripts/resume_session.sh | 16 +- IMPROVEMENTS.md | 7 + LOG.md | 6 +- tests/conftest.py | 13 +- tests/test_tier3_integration.py | 2 +- tests/test_uuid_target.py | 429 ++++++++++++++++++ 16 files changed, 1239 insertions(+), 61 deletions(-) create mode 100644 .agents/reports/canary-projects-multi-agent-mux-creator-claude/plan-b107cf34.md create mode 100644 .agents/reports/canary-projects-multi-agent-mux-creator-cline/report-cfe439f6.md create mode 100644 tests/test_uuid_target.py diff --git a/.agents/MULTI_AGENT_RULES.ko.md b/.agents/MULTI_AGENT_RULES.ko.md index d7dbd5d..d4781aa 100644 --- a/.agents/MULTI_AGENT_RULES.ko.md +++ b/.agents/MULTI_AGENT_RULES.ko.md @@ -57,6 +57,13 @@ - **잡 레지스트리 (Job Registry)**: 각 비동기 잡의 메타데이터와 생명주기는 개별 JSON 파일(`.mam/jobs/.json`)로 기록되며, 다중 세션 간의 동시 청구(claiming) 경합은 파일 단위의 `fcntl` advisory lock(`registry_lock` via `registry.py`)을 통해 방어합니다. - **세션 레지스트리 (Session Registry)**: TMUX 모니터링 상태 및 에이전트 구동 정보는 SQLite WAL 데이터베이스(`.mam/agent-sessions.db`)를 통해 단일 호스트 내에서 안정적인 동시 트랜잭션으로 일관되게 제어합니다. 단, SQLite WAL 모드는 NFS(네트워크 파일 시스템) 환경에서는 완전한 파일 락이 보장되지 않으므로 로컬 파일 시스템 사용을 권장합니다. +### 🔑 세션 ID 생명주기 및 자동 할당 프로토콜 +- **생성 시 자동 할당**: 신규 `claude` 세션은 생성 시 무작위 UUID(`mam_gen_uuid`)를 생성하여 `claude --session-id `로 전달합니다. `.mam/agent-sessions.yaml`에는 `claude_session_id_own` 값과 함께 `session_id_source: assigned`, `session_id_verified: false`로 기록됩니다. +- **첫 메시지 구체화**: 트랜스크립트 `.jsonl` 파일은 사용자의 첫 프롬프트 메시지가 전달될 때 디스크에 구체화(materialize)됩니다. +- **모니터 확정 (C0)**: 모니터 루프(`reconcile.sh`)는 디스크상의 트랜스크립트 존재를 검증한 후 `session_id_verified: true` 및 `last_visible_status: pinned`로 승격시킵니다. +- **모호성 방어 (C-ambiguous)**: 미할당 세션에 대해 다수의 트랜스크립트 후보가 발견되면 임의 고정 없이 `C-ambiguous` 상태로 보고합니다. +- **경로 정규화 일치**: 모든 경로 계산(`mam_abs_workspace`, `mam_workspace_key`)은 심볼릭 링크를 실경로로 정규화(`cd -P && pwd -P` / `os.path.realpath`)하여 100% 키 일치를 보장합니다. + ### 🛡️ 보안 프로토콜 (HMAC-SHA256) - **무인증 PoC 모드**: 잡 레지스트리 생성 시 `auth_token`이 `null`로 지정된 경우(PoC 기본 모드), 별도의 서명 검증을 생략하고 모든 이벤트를 수용합니다 (`verify_hmac`이 항상 `True`를 반환). - **인증 Production 모드**: 실배포 환경이나 인증이 필요한 연동 단계에서는 각 잡마다 고유 암호화 토큰(`auth_token`)을 발급합니다. 퍼블리셔는 이 토큰을 키로 삼아 `hmac_sig` 서명을 페이로드에 동반해야 하며, 수신단(`verify_hmac`)에서 서명이 없거나 일치하지 않는 메시지는 즉시 드랍하여 다운그레이드 공격을 원천 차단합니다. diff --git a/.agents/MULTI_AGENT_RULES.md b/.agents/MULTI_AGENT_RULES.md index f59ed6e..bd2c892 100644 --- a/.agents/MULTI_AGENT_RULES.md +++ b/.agents/MULTI_AGENT_RULES.md @@ -57,6 +57,13 @@ Asynchronous communication and state management between agents are controlled vi - **Job Registry**: The metadata and lifecycle of each asynchronous job are recorded in individual JSON files (`.mam/jobs/.json`). Concurrency conflicts (claiming races) across multiple sessions are prevented via file-based `fcntl` advisory locks (`registry_lock` via `registry.py`). - **Session Registry**: TMUX monitoring states and running agent metadata are consistently controlled using a SQLite WAL database (`.mam/agent-sessions.db`) to support reliable concurrent transactions on a single host. However, since SQLite WAL mode does not guarantee complete file locking in Network File System (NFS) environments, we recommend using a local file system. +### 🔑 Session ID Lifecycle & Auto-Assignment Protocol +- **Auto-Assignment at Creation**: Fresh `claude` sessions automatically generate a random UUID (`mam_gen_uuid`) passed via `claude --session-id `. `claude_session_id_own` is recorded in `.mam/agent-sessions.yaml` with `session_id_source: assigned` and `session_id_verified: false`. +- **First Message Materialization**: Transcripts `.jsonl` are only created on disk when the first prompt message is delivered. +- **Reconciler Confirmation (C0)**: The monitor loop (`reconcile.sh`) verifies the transcript on disk and promotes `session_id_verified: true` and `last_visible_status: pinned`. +- **Ambiguity Guard (C-ambiguous)**: Unassigned sessions matching multiple candidate transcripts are flagged as `C-ambiguous` without random pinning. +- **Path Equivalence**: All path calculations (`mam_abs_workspace`, `mam_workspace_key`) canonicalize symlinks (`cd -P && pwd -P` / `os.path.realpath`) ensuring 100% key match. + ### 🛡️ Security Protocol (HMAC-SHA256) - **Unauthenticated PoC Mode**: If the `auth_token` in the job registry is set to `null` (the default PoC mode), signature verification is skipped and all events are accepted (`verify_hmac` always returns `True`). - **Authenticated Production Mode**: In production environments or integrations requiring authentication, a unique cryptographic token (`auth_token`) is issued for each job. The publisher must include an `hmac_sig` signature in the payload keyed by this token, and the receiving end (`verify_hmac`) will immediately drop messages that lack a signature or have mismatching signatures to prevent downgrade attacks. diff --git a/.agents/reports/canary-projects-multi-agent-mux-creator-claude/plan-b107cf34.md b/.agents/reports/canary-projects-multi-agent-mux-creator-claude/plan-b107cf34.md new file mode 100644 index 0000000..88423b2 --- /dev/null +++ b/.agents/reports/canary-projects-multi-agent-mux-creator-claude/plan-b107cf34.md @@ -0,0 +1,397 @@ +# b4a1d094 — 신규 세션 UUID 자동 확보·고정 구현 계획서 **Rev.2** + +**Job**: b4a1d094 · **Role**: Planner · **Supersedes**: b107cf34 (Rev.1) +**응답 대상**: 챌린지 `7e6aa90d` (`agy`, `[CHALLENGE]`) — F8 경로 정규화 / F8 격리 루트 / F5 배치 순서 +**Base**: `9df0fc3` (working tree 는 `LOG.md` 만 수정 — 본 작업으로 저장소를 건드리지 않았다) + +--- + +## 1. 판정 요약 + +**세 건 모두 채택한다.** 그중 둘은 agy 가 말한 것보다 **더 크다**. + +| # | 챌린지 | 판정 | 근거 | +|---|---|---|---| +| C-1 | F8 의 `$WORKSPACE` 미정규화 | **채택 + 강화** | 6가지 경로 형태 중 Rev.1 은 **1/6** 만 맞다. agy 의 제안(`cd && pwd`)은 5/6 — **심볼릭 링크에서 여전히 틀린다.** `cd -P`/`pwd -P` 라야 6/6 | +| C-2 | F8 의 isolation root 누락 | **채택 — 그리고 더 깊다** | `--isolate` 는 `f0a2103` 이후 **no-op**이라 새 격리 세션은 생기지 않는다. 그러나 legacy 행은 여전히 읽히고, 확인해 보니 `verify_session_uuid` 자체가 isolation 을 모른다 → `find_workspace_uuid` 의 격리 분기는 **이미 죽은 코드**였다 | +| C-3 | F5 가 워크스페이스 검사보다 앞설 위험 | **채택 (문서 결함)** | 프로토타입은 이미 검사 **뒤**에 있었다. 틀린 것은 코드가 아니라 Rev.1 §5 의 `"lib.sh:1145 뒤"` 라는 모호한 표현이다. 불변식으로 승격하고 순서를 뒤집으면 깨지는 테스트를 붙였다 | + +측정 결과: **HEAD 6/17 · Rev.1 11/17 · Rev.2 17/17.** +신규 변이 5건 전부 의도한 테스트가 잡았다. 그중 N-1 은 **agy 의 제안 그대로를 적용한 변이**이고, 실제로 깨진다. + +정정 하나. Rev.1 §5 F8 은 내가 `find_workspace_uuid` 가 이미 하고 있던 정규화를 확인하지 않고 +경로 문자열을 그대로 쓴 것이다. agy 가 정확히 짚었다. + +--- + +## 2. C-1 — 경로 정규화: 채택하되 제안보다 한 단계 더 + +### 2.1 실측 + +`$SB/wsprobe/real` 을 만들고 `$SB/wsprobe/link → real` 심링크를 건 뒤, +실제 `claude --session-id ... -p ok` 을 **두 경로에서** 돌려 ground truth 를 잡았다. + +``` +cd real → ~/.claude/projects/…-scratchpad-wsprobe-real +cd link → ~/.claude/projects/…-scratchpad-wsprobe-real ← 링크로 들어가도 real 키 +``` + +즉 **claude 는 물리 경로(realpath)로 키를 만든다.** 이 기준으로 세 가지 키 계산을 비교했다: + +| `--workspace` 입력 | Rev.1 (raw `tr`) | agy 제안 (`cd && pwd`) | Rev.2 (`cd -P && pwd -P`) | +|---|---|---|---| +| `.` | MISS | OK | OK | +| `./` | MISS | OK | OK | +| `/…/wsprobe/real` | OK | OK | OK | +| `/…/wsprobe/real/` | MISS | OK | OK | +| `/…/wsprobe/link` | MISS | **MISS** | OK | +| `/…/wsprobe/../wsprobe/real` | MISS | OK | OK | +| **합계** | **1/6** | **5/6** | **6/6** | + +agy 의 실패 모드 서술은 맞다. 다만 `cd && pwd` 는 **논리 경로**를 돌려준다 — +`pwd` 는 `$PWD` 를, `pwd -P` 는 해석된 경로를 준다. 심링크 워크스페이스에서는 링크 이름이 +그대로 남아 존재하지 않는 디렉터리를 가리킨다. + +### 2.2 이 결함이 실제로 무엇을 하는가 + +키가 틀리면 `-f` 검사가 실패하고 → `CLAUDE_ID_FLAG="--session-id"` 로 떨어진다. +**이미 대화가 있는 세션에 대해 새 대화를 시작한다.** 조용히. 사용자는 재개했다고 믿는다. +`null` 보다 나쁜 종류의 실패다. + +### 2.3 같은 결함이 F8 밖에도 있다 + +`find_workspace_uuid`(lib.sh:1303, 1357)도 `cd "$workspace" && pwd` 를 쓴다 — 논리 경로다. +그래서 심링크 워크스페이스에서는 F8 에 도달하기도 전에 깨진다. 실측: +**HEAD 에서 T-10[symlink] 이 `ERROR: No saved session` 으로 실패한다.** F8 이 없는 HEAD 에서도. + +정규화를 한 곳으로 모아야 하는 이유가 이것이다. 두 군데가 서로 다른 규칙을 쓰면 +한쪽을 고쳐도 다른 쪽이 되돌린다. + +--- + +## 3. C-2 — 격리 루트: 메커니즘은 죽었지만, 파고들자 더 큰 게 나왔다 + +### 3.1 `--isolate` 는 더 이상 아무것도 만들지 않는다 + +``` +$ grep -rn "\['isolation'\] =" .agents/skills/ → (없음) +$ grep -c isolation .mam/agent-sessions.yaml → 0 +$ git log --oneline -S"entry['isolation']" + f0a2103 refactor(isolation): simplify agent session isolation and remove legacy home-isolation helpers +``` + +`create_session.sh:71-72` 도 `--isolate/--no-isolate` 를 NOTE 만 찍는 no-op 으로 선언한다. +따라서 **앞으로 격리 세션은 생기지 않는다.** agy 가 상정한 "`--isolate` 로 만들어 정상 대화한 세션"은 +현재 코드로는 만들 수 없다. + +### 3.2 그런데 읽는 쪽은 살아 있다 — 그리고 고장 나 있다 + +`find_workspace_uuid`(lib.sh:1327, 1363-1397)는 여전히 `isolation.root` 를 읽고 +`{iso}/projects/{key}/*.jsonl` 을 glob 한다. 그런데 각 후보를 `verify_session_uuid` 로 검증하는데, +`verify_session_uuid` 는 `c_dir`(= `CLAUDE_PROJECT_DIR`) 만 본다. **isolation 을 모른다.** + +결과: glob 이 찾아낸 모든 후보가 검증에서 떨어진다. **격리 분기 전체가 inert 다.** +Rev.2 의 T-11 을 HEAD 에 돌리면 그대로 재현된다 — 격리 루트에만 transcript 가 있는 행은 +`No saved session` 이 난다. + +agy 는 F8 하나만 지적했지만, F8 만 고치면 resume 의 `-f` 검사는 통과하고 +`resolve_session_id.sh` 는 여전히 빈 값을 뱉는다. 그래서 **양쪽 다** 고친다(G3 + G5). + +### 3.3 agy 가 제안한 헬퍼는 존재하지 않는다 + +``` +$ grep -c get_session_isolation_root .agents/skills/lib.sh +0 +``` + +개선안 1 의 `get_session_isolation_root` 는 코드베이스에 없는 함수다. +Rev.2 는 이름이 같은 헬퍼를 **새로 정의**해서 쓴다(G1 의 `mam_session_iso_root`). +없는 함수를 호출하는 명세를 그대로 넘기면 구현자가 `command not found` 를 만난다. + +--- + +## 4. C-3 — F5 배치 순서: 코드는 이미 옳았고, 명세가 모호했다 + +Rev.1 프로토타입의 실제 배치: + +```python + cwd = row.get("pane", {}).get("cwd", "") or ws + + if workspace_key(cwd) != workspace_key(ws): + return False + + if (mode == "revalidate" and row.get("session_id_source") == "assigned" + and not row.get("session_id_verified")): + return True +``` + +검사 **뒤**다. 그러니 "우회가 일어난다"는 실패는 발생하지 않았다 — +T-12 는 HEAD·Rev.1·Rev.2 **세 트리 모두에서 PASS** 한다. + +그렇다고 챌린지가 공허하지는 않다. 틀린 것은 코드가 아니라 **Rev.1 §5 의 `"lib.sh:1145 뒤"`** 라는 +표현이다. 1145 는 `if workspace_key(...)` 그 줄이고, "뒤"는 `if` 뒤인지 `return False` 뒤인지 +읽는 사람에 따라 갈린다. 구현자가 앞에 붙였다면 격리 보장이 깨졌을 것이다. +**명세 결함은 코드 결함과 같은 값으로 취급한다.** + +그래서 두 가지를 한다. + +1. 코드에 **ORDERING INVARIANT** 주석을 박아 이유와 함께 순서를 고정한다. +2. 순서를 뒤집으면 깨지는 테스트(T-12)를 붙인다. 변이 N-3 으로 검증했다 — + F5 를 검사 위로 옮기면 T-12 **만** FAIL 한다. 세 트리에서 모두 PASS 라는 사실이 + 이 테스트를 무용하게 만들지 않는다. **불변식 보호 장치**이고, 그게 정확히 이 챌린지가 요구한 것이다. + +(T-7 과 같은 성격이다. Rev.1 §7.2 에서도 같은 구분을 해 뒀다.) + +--- + +## 5. 변경 명세 — Rev.1 대비 델타 + +Rev.1 의 **F0–F4, F6, F7, F9 는 그대로**다. 아래 G1–G5 가 추가·교체분이다. +(Rev.1 전문은 `.mam/jobs/b107cf34/claude-reports/report-final.md`) + +### G1 · `lib.sh` — 정규화·키·격리루트 헬퍼 3종 (신규, `mam_gen_uuid` 앞) + +```bash +mam_abs_workspace() { + local p="${1:-}" + ( cd -P "$p" 2>/dev/null && pwd -P ) || printf '%s' "$p" +} + +mam_workspace_key() { + printf '%s' "$(mam_abs_workspace "$1")" | tr '/_' '--' +} + +mam_session_iso_root() { + MAM_STATE_JSON="$(load_state_json)" MAM_ISO_SESSION="$1" env_python "$AGENT_SESSIONS_YAML" <<'PYEOF' +import json, os +name = os.environ.get('MAM_ISO_SESSION', '') +try: + d = json.loads(os.environ.get('MAM_STATE_JSON', '{}')) +except Exception: + d = {} +for s in (d.get('herdr_sessions') or []): + if s.get('name') == name: + iso = s.get('isolation') + if isinstance(iso, dict) and iso.get('root'): + print(iso['root']) + break +PYEOF +} +``` + +> `env_python` 은 `atomic_dump_yaml` 과 달리 **`d` 를 미리 정의해 주지 않는다.** +> 프로토타입 1차에서 이걸 빠뜨려 `NameError` 가 났다. `load_state_json` 으로 직접 실어야 한다. +> `mam_workspace_key` 는 `VERIFY_SESSION_PYTHON` 의 `workspace_key()` 와 **같은 값을 내야 한다** — T-13 이 지킨다. + +### G2 · `lib.sh:1303, 1357` — `find_workspace_uuid` 도 같은 정규화를 쓴다 + +```bash +- local abs; abs="$(cd "$workspace" 2>/dev/null && pwd)" || abs="$workspace" ++ local abs; abs="$(mam_abs_workspace "$workspace")" +``` + +두 군데 모두. §2.3 의 심링크 결함이 여기서 온다. + +### G3 · `resume_session.sh` — **F8 교체** (Rev.1 F8 은 폐기) + +```bash +CLAUDE_ID_FLAG="-r" +if [ "$AGENT" = "claude" ]; then + _ws_key="$(mam_workspace_key "$WORKSPACE")" + _iso_root="$(mam_session_iso_root "$SESSION_NAME" 2>/dev/null || true)" + if [ -n "$_iso_root" ]; then + _proj_dir="$_iso_root/projects" + else + _proj_dir="${CLAUDE_PROJECT_DIR:-$HOME/.claude/projects}" + fi + if [ ! -f "${_proj_dir}/${_ws_key}/${UUID}.jsonl" ]; then + CLAUDE_ID_FLAG="--session-id" + fi +fi + +case "$AGENT" in + claude) CMD_FULL="${RESOLVED_BIN} --dangerously-skip-permissions $CLAUDE_ID_FLAG $UUID" ;; +``` + +### G4 · `lib.sh` — F5 순서를 불변식으로 명문화 + +```python + # ORDERING INVARIANT: the workspace check below MUST run before the + # assigned-id shortcut. Moving the shortcut above it would return True for a + # row belonging to a different workspace purely because it is assigned and + # unverified, breaking the one guarantee find_workspace_uuid exists to give + # -- never hand back an id that belongs to a different workspace. (T-12) + if workspace_key(cwd) != workspace_key(ws): + return False + + if (mode == "revalidate" and row.get("session_id_source") == "assigned" + and not row.get("session_id_verified")): + return True +``` + +> 주석에 아포스트로피를 쓰지 말 것. `VERIFY_SESSION_PYTHON` 은 **작은따옴표로 감싼 bash 문자열**이라 +> `workspace's` 하나가 문자열을 끊고 `syntax error near unexpected token` 을 낸다. +> 프로토타입에서 실제로 났다. + +### G5 · `lib.sh` — `verify_session_uuid` 가 isolation 을 안다 + +```python + row = row or {} + _iso = row.get("isolation") + iso_root = _iso.get("root") if isinstance(_iso, dict) and _iso.get("root") else None + … + if agent == "claude": + base = (iso_root + "/projects") if iso_root else c_dir + elif agent == "agy": + base = f"{iso_root or home}/.gemini/antigravity-cli/conversations" + elif agent == "hermes": + hdb = f"{iso_root or home}/.hermes/state.db" + elif agent == "cline": + base = (iso_root + "/sessions") if iso_root else f"{home}/.cline/data/sessions" +``` + +경로 레이아웃은 `find_workspace_uuid` 의 격리 분기(lib.sh:1370-1397)와 +`stop_session.sh` 의 `clin_base` 오버라이드에서 그대로 가져왔다. 새로 정하지 않았다. + +--- + +## 6. 문서 변경 (Rev.1 §6 에 추가) + +| 파일 | 추가 | +|---|---| +| `.agents/skills/multi-agent-mux-resume/SKILL.md` | 워크스페이스 인자는 **물리 절대경로로 정규화된 뒤** 키가 계산된다. 상대경로·끝슬래시·심링크 모두 같은 세션으로 해석된다 | +| `.agents/MULTI_AGENT_RULES.md` / `.ko.md` | 워크스페이스 키의 단일 정의: `mam_workspace_key` (shell) ≡ `workspace_key` (python), 둘 다 물리 경로 기준. 새 코드가 `cd && pwd` 를 다시 쓰지 않도록 명시 | +| `.agents/skills/multi-agent-mux-monitor/SKILL.md` | `verify_session_uuid` 의 **순서 불변식**(워크스페이스 검사 → assigned 지름길)을 규칙으로 기재 | +| `IMPROVEMENTS.md` | 격리 분기가 inert 였다는 사실을 별도 항목으로. 지금은 legacy 행에만 영향이지만 조용히 죽어 있던 코드다 | + +--- + +## 7. 테스트 + +### 7.1 신규 (Rev.2) + +| ID | 무엇을 | HEAD | Rev.1 | Rev.2 | +|---|---|---|---|---| +| T-10[absolute] | 절대경로 재개 → `-r` | PASS | PASS | PASS | +| T-10[trailing_slash] | 끝 슬래시 | PASS¹ | **FAIL** | PASS | +| T-10[dotdot] | `../` 포함 | PASS¹ | **FAIL** | PASS | +| T-10[relative] | `--workspace .` | PASS¹ | **FAIL** | PASS | +| T-10[symlink] | 심링크 워크스페이스 | **FAIL** | **FAIL** | PASS | +| T-11 | legacy 격리 행 → `isolation.root` 아래에서 찾는다 | **FAIL** | **FAIL** | PASS | +| T-12 | 타 워크스페이스 assigned 행은 revalidate 통과 못 한다 | PASS | PASS | PASS | +| T-13 | `mam_workspace_key` ≡ python `workspace_key` (4형태) | **FAIL** | **FAIL** | PASS | + +¹ HEAD 에는 F8 자체가 없어 항상 `-r` 이다. 통과하지만 **아무것도 증명하지 않는다** — +Rev.1 이 도입한 회귀를 잡는 테스트이지 HEAD 결함을 잡는 테스트가 아니다. 표를 그렇게 읽어야 한다. + +### 7.2 전체 + +**HEAD 6/17 · Rev.1 11/17 · Rev.2 17/17.** +Rev.1 이 떨어뜨리는 6건이 정확히 C-1(4) + C-2(2) 이다. 챌린지가 실제로 무엇을 잡았는지가 이 숫자다. + +### 7.3 변이 — 신규 5건 + +| 변이 | 되돌린 것 | 잡은 테스트 | +|---|---|---| +| N-1 | `pwd -P` → `pwd` (**agy 제안 그대로**) | T-10[symlink], T-13 | +| N-2 | `mam_workspace_key` → raw `tr` (**Rev.1 F8 그대로**) | T-10[trailing_slash, dotdot, relative, symlink] | +| N-3 | F5 를 워크스페이스 검사 **위로** | T-12 | +| N-4 | resume 이 `isolation.root` 무시 | T-11 | +| N-5 | `verify_session_uuid` 가 `isolation.root` 무시 | T-11 | + +5/5 검출. Rev.1 의 변이 6건(M-1…M-6)도 그대로 유효하다 → **누적 11건**. + +N-1 과 N-3 은 특별히 짚어 둔다. N-1 은 **제안된 수정안을 변이로 삼은 것**이고 실제로 깨진다 — +그래서 agy 의 remedy 를 그대로 채택하지 않았다. N-3 은 T-12 가 공허하지 않음을 보인다. + +### 7.4 회귀 + +세 트리 모두 동일 조건(`pytest tests/ -q`, 신규 스위트 2개 제외)으로 전체 실행: + +``` +base (HEAD) 149 passed in 419.60s +fix (Rev.1) 149 passed in 420.02s +rev2 (Rev.2) 149 passed in 431.81s +``` + +**회귀 0.** G2 가 `find_workspace_uuid` 의 정규화를 논리→물리로 바꾸므로 여기가 제일 위험했는데, +심링크가 없는 경로에서는 두 값이 같아 기존 테스트에 영향이 없다(§8.8 에 남은 조건을 적었다). + +### 7.5 변경 규모 + +``` +.agents/skills/lib.sh 180 lines +.agents/skills/multi-agent-mux-monitor/…/reconcile.sh 68 +.agents/skills/multi-agent-mux-resume/…/resume_session.sh 26 +.agents/skills/multi-agent-mux-create/…/create_session.sh 23 +tests/conftest.py 13 +``` + +프로토타입 트리: `scratchpad/base`(HEAD) · `scratchpad/fix`(Rev.1) · `scratchpad/rev2`(Rev.2) · +`scratchpad/n1…n5`(신규 변이). 패치 스크립트 `patch_b107.py` → `patch_rev2.py` 순서로 적용된다. +저장소에는 반영하지 않았다. + +--- + +## 8. 남는 위험 (Rev.1 §8 갱신) + +Rev.1 의 8.1(agy/cline 발견 정확도), 8.3(hermes 미설치), 8.4(trust 다이얼로그 문구), +8.5(`stop --capture-id` 덮어쓰기), 8.6(shellcheck 로컬 부재)는 **그대로 유효**하다. 아래는 변경분. + +**8.2 (갱신) F1b wrapper 경로** — 여전히 미재현. Rev.1 의 (a) 권고 유지. + +**8.7 (신규) 격리 지원의 처분을 정해야 한다.** §3 에서 드러난 것은 +"격리 분기에 버그가 있다"가 아니라 **"격리 분기가 처음부터 동작한 적이 없을 가능성이 높다"** 이다. +G5 는 그것을 되살린다. 두 갈래 중 하나를 골라야 한다 — +(a) **되살린다**(G5 채택, 지금 계획): legacy 행이 정상 재개된다. 단 아무도 안 쓰는 경로를 유지한다. +(b) **걷어낸다**: `find_workspace_uuid` 의 격리 분기와 `stop_session.sh` 의 purge 분기를 함께 제거. +**(a) 를 권한다** — 제거는 legacy YAML 을 가진 사용자에게 파괴적이고, 이 브리프의 범위도 아니다. +다만 (b) 를 별도 티켓으로 남기는 편이 정직하다. + +**8.8 (신규) 물리 경로 정규화의 파급.** `mam_abs_workspace` 는 `find_workspace_uuid` 의 +동작을 바꾼다(논리→물리). 심링크가 없는 환경에서는 값이 동일하고, 전체 스위트에 회귀가 없음을 +확인했다(§7.4). 그러나 **심링크 워크스페이스를 쓰는 기존 YAML 행이 있다면** +`pane.cwd` 는 herdr 가 기록한 값이라 물리/논리 중 무엇인지 이 머신에서 확정하지 못했다. +`workspace_key(cwd) != workspace_key(ws)` 비교의 양변이 어긋날 여지가 남는다. +구현자는 실제 심링크 워크스페이스로 세션 1개를 띄워 `pane.cwd` 를 확인할 것. + +--- + +## 9. 구현 순서 (Rev.1 §9 교체) + +G1 이 모든 것의 선행 조건이다. G2/G3 은 G1 없이는 컴파일도 안 된다. + +1. **F0** `mam_gen_uuid` · **G1** `mam_abs_workspace` / `mam_workspace_key` / `mam_session_iso_root` +2. **G4** F5 + ORDERING INVARIANT 주석 (F1 의 선행 조건) +3. **G5** `verify_session_uuid` 격리 인식 +4. **G2** `find_workspace_uuid` 정규화 통일 +5. **F1 (+F1b 결정)** 생성 시 지정 +6. **F2** drift C0 + `row_agent` +7. **G3** 재개 분기 (Rev.1 F8 대체) +8. **F4** cwd 스캔 · **F6** pane 디코드 · **F7** 뷰포트 semantics (상호 독립) +9. **F3** C-ambiguous 보고 +10. **F9** mock 충실도 — F1 과 **같은 커밋**에 +11. 문서 (Rev.1 §6 + §6 위) + +**수용 기준** + +- `tests/test_uuid_target.py` **17/17** +- 기존 스위트 149 passed, 회귀 0 +- 변이 **11건**(M-1…M-6, N-1…N-5) 전부 검출 +- `bash -n` 4파일 + CI shellcheck 통과 +- 실 세션 1개: 생성 직후 `session_id_verified: false`, 첫 응답 뒤 모니터 1사이클에 `true` +- 심링크 워크스페이스 1개로 `pane.cwd` 실측 (§8.8) + +--- + +## 10. 결론 + +챌린지 세 건 중 둘은 실제 결함이었고, 하나는 명세의 모호함이었다. 셋 다 고쳤다. +그리고 C-2 를 따라 들어가다 **격리 분기가 이미 inert 였다**는, 양쪽 다 보지 못했던 것이 나왔다. + +한 가지는 그대로 채택하지 않았다. agy 의 `cd && pwd` 는 6가지 경로 형태 중 5개만 맞는다. +그 제안을 변이(N-1)로 만들어 돌려 보면 심링크 케이스가 깨진다. `cd -P`/`pwd -P` 를 쓴다. + +`.mam/` 산출물 외에 저장소는 건드리지 않았다. + +**[AGREEMENT: REACHED]** diff --git a/.agents/reports/canary-projects-multi-agent-mux-creator-cline/report-cfe439f6.md b/.agents/reports/canary-projects-multi-agent-mux-creator-cline/report-cfe439f6.md new file mode 100644 index 0000000..fa90f79 --- /dev/null +++ b/.agents/reports/canary-projects-multi-agent-mux-creator-cline/report-cfe439f6.md @@ -0,0 +1,146 @@ +# 🛡️ Cross-Code Review: Job cfe439f6 — Auto UUID Capture & Pinning for Fresh Agent Sessions + +- **Job ID**: cfe439f6 +- **Reviewer**: cline +- **Target**: Review and implement robust automatic UUID capture and pinning mechanisms for fresh agent sessions (preventing null `session_id_own` after create/onboard). Update `create_session.sh`, `reconcile.sh`, `lib.sh`, and relevant skill/rule docs. +- **Change scope**: 13 modified files + 1 new test file (uncommitted working-tree diff; 268 insertions, 62 deletions). + +## 0. Verdict Summary + +The implementation is **sound and complete**. The core goal — preventing a null `session_id_own` after `create`/`onboard` by assigning a UUID at creation time and confirming it on disk via the reconciler — is achieved through a coherent three-stage protocol: (1) `create_session.sh` generates a UUID via `mam_gen_uuid` and passes `claude --session-id `, recording `session_id_source: assigned` / `session_id_verified: false`; (2) `reconcile.sh` drift C0 verifies the transcript materialized and promotes `session_id_verified: true` / `last_visible_status: pinned`; (3) `resume_session.sh` detects whether the transcript exists on disk and chooses `--session-id` (fresh) vs `-r` (resume). Path normalization (`os.path.realpath` / `cd -P && pwd -P`) is applied consistently across shell and Python, closing the symlink/trailing-slash key-mismatch class. Lint passes, the new 13-case suite is green, and there is no cross-regression. Five non-blocking findings are documented below; none require a design-level rework. + +[VERDICT: PASS] + +--- + +## 1. Change Inventory (verified against working tree) + +| File | Change | Status | +|---|---|---| +| `.agents/MULTI_AGENT_RULES.md` / `.ko.md` | New "Session ID Lifecycle & Auto-Assignment Protocol" section (auto-assign, first-message materialization, C0 confirmation, C-ambiguous guard, path equivalence) | ✅ verified | +| `.agents/skills/lib.sh` | New `mam_gen_uuid`, `mam_abs_workspace`, `mam_workspace_key`, `mam_session_iso_root`; `workspace_key` → `os.path.realpath`; `verify_session_uuid` multi-line scan + revalidate shortcut + iso_root paths + ordering invariant; `verify_tui_viewport` simplified; `find_workspace_uuid`/`verify_tui_viewport` → `mam_abs_workspace`; `_pane_capture` JSON unwrap | ✅ verified | +| `create_session.sh` | `mam_gen_uuid` for claude; `--session-id` flag in CMD_FULL; `session_id_source`/`session_id_verified` fields in YAML | ✅ verified | +| `reconcile.sh` | `row_agent()` helper; drift C0 (assigned-ID confirmation); C-ambiguous guard (all 4 agents); `os.path.realpath` in drift-B A-1 gate; drift-C loops use `row_agent()` | ✅ verified | +| `resume_session.sh` | `CLAUDE_ID_FLAG` logic: `--session-id` if transcript unmaterialized, `-r` if exists; `mam_workspace_key` + `mam_session_iso_root` for path resolution | ✅ verified | +| `multi-agent-mux-create/SKILL.md` | Pitfalls updated: removed "don't trust --session-id" / "first message generates id"; added auto-assignment + materialization docs | ✅ verified | +| `multi-agent-mux-monitor/SKILL.md` | Drift C rewritten: C0 (assigned confirmation), C (unassigned materialize), C-ambiguous (multiple candidates) | ✅ verified | +| `multi-agent-mux-resume/SKILL.md` | CMD_FULL comment: `--session-id` vs `-r` based on transcript existence | ✅ verified | +| `IMPROVEMENTS.md` | Rev.2 (b4a1d094) completed-task entry | ✅ verified | +| `LOG.md` | Agent status `stopped`→`running`, "캡처 완료"→"복원 완료" | ✅ verified | +| `tests/conftest.py` | Mock extracts `--session-id ` from cmd; `ws_abs = os.path.realpath(cwd)` | ✅ verified | +| `tests/test_tier3_integration.py` | `key = os.path.realpath(str(tmp_path))...` | ✅ verified | +| `tests/test_uuid_target.py` | NEW — 13 tests (T-1..T-13) | ✅ verified | + +`git status --porcelain`: ` M` on 13 tracked files + `??` on `tests/test_uuid_target.py` (uncommitted — see R-4). + +--- + +## 2. Lint / Static Checks + +| Check | Command | Result | +|---|---|---| +| Shell syntax (lib.sh) | `bash -n .agents/skills/lib.sh` | ✅ PASS | +| Shell syntax (reconcile.sh) | `bash -n .../reconcile.sh` | ✅ PASS | +| Shell syntax (create_session.sh) | `bash -n .../create_session.sh` | ✅ PASS | +| Shell syntax (resume_session.sh) | `bash -n .../resume_session.sh` | ✅ PASS | +| Embedded Python (`_pane_capture`) | `sed -n '1872,1882p' lib.sh \| python3 -c 'compile(...)'` | ✅ PASS | +| `workspace_key` availability in reconcile | `exec(os.environ['MAM_VERIFY_PY'])` at reconcile.sh:327 loads `workspace_key` + `verify_session_uuid` from shared `VERIFY_SESSION_PYTHON` | ✅ no NameError | +| Path-key parity | `mam_workspace_key` (shell `tr '/_' '--'`) vs Python `.replace("/","-").replace("_","-")` — both over `realpath`/`cd -P && pwd -P` | ✅ verified by T-13 | + +`shellcheck` is not installed locally (matches prior job convention); CI gate remains the authority for that check. + +--- + +## 3. Operability / Logic Review + +### 3.1 Create: auto-assignment (`create_session.sh:148-157, 312-324`) +- `SESSION_UUID="$(mam_gen_uuid)"` for claude only (agy/hermes/cline don't accept `--session-id`). +- `CMD_FULL="${RESOLVED_BIN} --dangerously-skip-permissions --session-id ${SESSION_UUID}"`. +- YAML entry records `claude_session_id_own = assigned`, `session_id_source = 'assigned'`, `session_id_verified = False`, `last_visible_status = "assigned (awaiting first message)"`. Correct — the row is created atomically with the assigned UUID, so there is no window where `session_id_own` is null after create. This is the core fix for the stated goal. + +### 3.2 Reconciler C0: assigned-ID confirmation (`reconcile.sh:587-610`) +- Iterates `session_id_source == 'assigned' and not session_id_verified` running sessions. +- Calls `verify_session_uuid(cwd, agent, uuid, s, mode="discover")` — note **discover** mode, so the revalidate shortcut does NOT fire; the agent-specific transcript check runs (file exists, sessionId matches, cwd matches, epoch floor applies). +- On success: promotes `session_id_verified = True`, `last_visible_status = 'pinned'`, reports drift class C "confirmed on disk". +- **Separation from drift C**: drift C only processes sessions where `claude_session_id_own` is falsy (`if s.get('claude_session_id_own'): continue`). An assigned session has the UUID set, so drift C skips it. No double-processing. Correct. + +### 3.3 Reconciler C-ambiguous guard (`reconcile.sh:628-633, 678-683, 721-726, 765-770`) +- For unassigned/legacy sessions, after scanning candidates, `if len(valid_candidates) > 1`: reports `C-ambiguous`, sets `last_visible_status = "ambiguous: N candidates"`, does NOT pin. The `== 1` pin block is skipped. Correct — prevents random pinning when multiple transcripts match. + +### 3.4 `verify_session_uuid` hardening (`lib.sh:1173-1244`) +- **Ordering invariant** (lib.sh:1199-1209): workspace-key check runs BEFORE the revalidate shortcut. Comment cites T-12. This prevents handing back an id that belongs to a different workspace purely because the row is assigned+unverified. Verified by T-12. +- **Revalidate shortcut** (mode=="revalidate" + assigned + unverified → return True): lets a freshly-resumed session validate before its transcript is rewritten. The workspace check still guards it. Correct. +- **Multi-line scan** (lib.sh:1219-1242): reads up to 50 lines looking for `sessionId == uuid` and `cwd`. The old code read only the first line. This handles transcripts where the first line is a `queue-operation` event (no cwd) and the cwd appears on a later `user` line. Verified by T-2. +- **iso_root paths**: claude uses `(iso_root + "/projects") if iso_root else c_dir`; agy/hermes/cline use `iso_root or home`. Consistent with the isolation-root model. Verified by T-11. + +### 3.5 Path normalization (`lib.sh:813-823, 1174-1180`; `reconcile.sh:509-510`; `resume_session.sh:85-95`) +- `mam_abs_workspace`: `( cd -P "$p" && pwd -P )` — resolves symlinks to real path. +- `mam_workspace_key`: pipes `mam_abs_workspace` through `tr '/_' '--'`. +- Python `workspace_key`: `os.path.realpath(path)` then `.replace("/","-").replace("_","-")`. +- These produce identical keys for absolute, trailing-slash, `..`-dotdot, and symlink inputs. Verified by T-13 (4 variations). This closes the symlink-key-mismatch class that could cause a session to be invisible to the reconciler. + +### 3.6 Resume flag selection (`resume_session.sh:83-95`) +- `CLAUDE_ID_FLAG="-r"` default; if the transcript `${_proj_dir}/${_ws_key}/${UUID}.jsonl` does NOT exist → `--session-id` (fresh spawn). Otherwise `-r` (resume). +- `_proj_dir` resolves via `mam_session_iso_root` (isolation root) or `CLAUDE_PROJECT_DIR`/`$HOME/.claude/projects`. Matches `verify_session_uuid`'s claude base path. Correct — this is the key mechanism preventing resume from failing on a freshly created (unmaterialized) session. + +### 3.7 `_pane_capture` JSON unwrap (`lib.sh:1864-1884`) +- The herdr shim returns `{"result": {"read": {"text": "..."}}}`. The new `_pane_capture` extracts `result.read.text` and falls back to raw on any parse failure. Defensive and correct — keeps `verify_tui_viewport` working against the JSON-wrapping shim. + +### 3.8 `verify_tui_viewport` simplification (`lib.sh:1338-1362`) +- Removed 4 identical per-agent case branches (all did `grep -q "$base"`). Now: flatten whitespace, fixed-string grep on `base_flat`; regex fallback for absolute-path detection. Cleaner, equivalent behavior. + +--- + +## 4. Test Results + +| Suite | Command | Result | +|---|---|---| +| UUID target (new) | `pytest tests/test_uuid_target.py -q` | **13 passed** (57.27s) | +| Pure-lib.sh subset (fast) | `pytest test_t3 test_t12 test_t13 -v` | **3 passed** (0.40s) | +| Cross-regression (O-3+sanity+B-4) | `pytest tests/test_o3_scoped_guard.py tests/test_sanity.py tests/test_b4_session_created.py -q` | **47 passed** (23.79s) | +| Tier3 (modified `realpath` assertion) | `pytest tests/test_tier3_integration.py::test_integration_stop_purge_combination -q` | **1 passed** (36.61s) | + +The 13-case `test_uuid_target.py` covers: T-1 create-assigned, T-2 assigned-ID promotion after materialize, T-3 different-cwd transcript not pinned, T-4 C-ambiguous (2 candidates), T-5 custom session name pinned, T-6..T-9 (agy/hermes/cline/legacy paths), T-10 path-variation resume (`/`, `..`, symlink), T-11 legacy isolation row, T-12 other-workspace revalidate fails (ordering invariant), T-13 shell/Python `workspace_key` equivalence across 4 path forms. + +--- + +## 5. Findings (non-blocking) + +### R-1: Wrapper-mode `SESSION_UUID` clear leaves misleading `cmd_full` in YAML (`create_session.sh:164-170, 156`) +In the claude `spawn()` branch, when a wrapper binary is used (`[ -x "$WRAPPER" ] && basename != claude`, or `--wrapper`), `SESSION_UUID=""` is cleared **after** `CMD_FULL` was already built as `... --session-id `. The YAML therefore records `pane.cmd_full` containing `--session-id ` but `claude_session_id_own = None` / `session_id_source = 'pending-discovery'` (because `assigned = os.environ.get('SESSION_UUID', '') or None` reads the cleared value). The wrapper is expected to manage its own session-id, so functionally the session is discovered later via drift C — no runtime break. But the recorded `cmd_full` is cosmetically misleading. **Severity**: low. **Suggested fix**: rebuild `CMD_FULL` without `--session-id` in the wrapper branch (or clear it before `CMD_FULL` is composed and re-add only in the non-wrapper path). + +### R-2: drift-B still inlines `name.endswith('-creator-')` (`reconcile.sh:494-500`) +The drift-B auto-register section infers the agent from the session-name suffix inline, while the new `row_agent()` helper (reconcile.sh:567-578) does the same with a pane-metadata-first fallback. This is **not** a bug — drift-B processes herdr sessions not yet in YAML (no `pane` dict), so the name convention is the only signal and `row_agent()` would degrade to the same fallback. It is a missed consolidation opportunity only. **Severity**: cosmetic. **Suggested fix**: optionally call `row_agent(t)` (it falls back to name) for single-source consistency. + +### R-3: `verify_session_uuid` claude scan breaks on first cwd-bearing line (`lib.sh:1234-1236`) +The multi-line scan sets `found_cwd` and `break`s on the first line carrying a `cwd` field, even if `valid_session` (sessionId match) hasn't been confirmed on that line. In real claude transcripts every line carries the same `sessionId`, so `valid_session` and `found_cwd` converge. The only risk is a pathological transcript whose first cwd line has a *different* sessionId, which would return False (safe direction — fails closed). **Severity**: low / safe-direction. No fix required; documented for completeness. + +### R-4: Changes uncommitted in working tree +`git status` shows 13 modified + 1 untracked file, none staged or committed. This matches the prior job's R-3 observation and is a process/policy note, not a code defect. The diff under review is the working-tree state. **Suggested action**: `git add -A && git commit -m "feat(uuid): auto-assign & pin session UUID at create; reconcile C0 confirm + C-ambiguous + path canonicalization"`. + +### R-5: `mam_session_iso_root` spawns a Python process per resume (`resume_session.sh:86`, `lib.sh:828-842`) +`mam_session_iso_root` and `mam_workspace_key` each shell out to `env_python`. On the resume path this adds two Python startup costs. Correct and well-isolated; only a minor latency note for cold-resume. **Severity**: perf, non-blocking. + +--- + +## 6. Completeness / Loss Check + +| Aspect | Coverage | +|---|---| +| Goal: no null `session_id_own` after create | ✅ T-1 asserts `claude_session_id_own` non-null + `source=assigned` + `verified=False` immediately after create | +| Goal: confirmation after first message | ✅ T-2 asserts `verified=True` + `pinned` after transcript materialize + one reconcile cycle | +| Goal: resume uses correct flag | ✅ T-10 exercises `--session-id` vs `-r` via `--dry-run` across path variations; resume_session.sh:83-95 logic matches verify path | +| Ambiguity defense | ✅ T-4 asserts C-ambiguous reported + no pinning for 2 candidates | +| Cross-workspace safety | ✅ T-12 asserts revalidate fails for assigned row of a different workspace (ordering invariant) | +| Path canonicalization | ✅ T-13 asserts shell/Python key equivalence across 4 path forms; conftest + tier3 use `os.path.realpath` | +| Orphaned code | None found — `_pane_capture`/`verify_tui_viewport` simplifications remove now-redundant branches; `endswith` at reconcile.sh:494-500 is a distinct context (drift-B), not an orphan | +| Doc/test sync | ✅ SKILL.md (create/monitor/resume) + MULTI_AGENT_RULES + IMPROVEMENTS all reflect the new protocol; conftest mock updated to honor `--session-id` | +| Cross-regression | ✅ 47/47 (O-3 + sanity + B-4) green — no regression to prior fixes | + +--- + +## 7. Conclusion + +The change set implements the auto-assignment-and-pinning protocol end-to-end with clean separation of concerns: create assigns, reconcile confirms (C0) and guards ambiguity (C-ambiguous), resume adapts to transcript existence. Path canonicalization is applied uniformly in shell and Python and is proven equivalent by T-13. The ordering invariant (workspace check before revalidate shortcut) closes the cross-workspace leakage class (T-12). Lint passes on all touched shell scripts and embedded Python; the new 13-case suite is fully green; cross-regression (47) and the modified tier3 test are green. Five non-blocking findings are recorded (R-1..R-5), none requiring design-level rework. The stated goal — preventing a null `session_id_own` after create/onboard — is met. + +[VERDICT: PASS] \ No newline at end of file diff --git a/.agents/skills/lib.sh b/.agents/skills/lib.sh index aabc4cc..7ad413d 100644 --- a/.agents/skills/lib.sh +++ b/.agents/skills/lib.sh @@ -796,6 +796,52 @@ derive_workspace_slug() { printf 'mam-%s' "$slug" } +# --------------------------------------------------------------------------- +# mam_gen_uuid +# --------------------------------------------------------------------------- +mam_gen_uuid() { + if command -v uuidgen >/dev/null 2>&1; then + uuidgen | tr 'A-Z' 'a-z' + else + python3 -c 'import uuid; print(uuid.uuid4())' + fi +} + +# --------------------------------------------------------------------------- +# mam_abs_workspace +# --------------------------------------------------------------------------- +mam_abs_workspace() { + local p="${1:-}" + ( cd -P "$p" 2>/dev/null && pwd -P ) || printf '%s' "$p" +} + +# --------------------------------------------------------------------------- +# mam_workspace_key +# --------------------------------------------------------------------------- +mam_workspace_key() { + printf '%s' "$(mam_abs_workspace "$1")" | tr '/_' '--' +} + +# --------------------------------------------------------------------------- +# mam_session_iso_root +# --------------------------------------------------------------------------- +mam_session_iso_root() { + MAM_STATE_JSON="$(load_state_json)" MAM_ISO_SESSION="$1" env_python "$AGENT_SESSIONS_YAML" <<'PYEOF' +import json, os +name = os.environ.get('MAM_ISO_SESSION', '') +try: + d = json.loads(os.environ.get('MAM_STATE_JSON', '{}')) +except Exception: + d = {} +for s in (d.get('herdr_sessions') or []): + if s.get('name') == name: + iso = s.get('isolation') + if isinstance(iso, dict) and iso.get('root'): + print(iso['root']) + break +PYEOF +} + # --------------------------------------------------------------------------- # derive_session_name # --------------------------------------------------------------------------- @@ -1126,13 +1172,21 @@ PYEOF # --------------------------------------------------------------------------- VERIFY_SESSION_PYTHON=' def workspace_key(path): - return path.replace("/", "-").replace("_", "-") + import os + try: + p = os.path.realpath(path) + except Exception: + p = path + return p.replace("/", "-").replace("_", "-") def verify_session_uuid(ws, agent, uuid, row=None, home_dir=None, claude_dir=None, mode="discover"): import os, json, sqlite3 home = home_dir or os.environ.get("HOME_DIR", "") c_dir = claude_dir or os.environ.get("CLAUDE_PROJECT_DIR", f"{home}/.claude/projects") row = row or {} + _iso = row.get("isolation") + iso_root = _iso.get("root") if isinstance(_iso, dict) and _iso.get("root") else None + # The floor answers "could this transcript belong to a PREVIOUS incarnation # of this session?", which only matters while picking an unknown uuid off # disk. In "revalidate" the uuid is one this row already recorded, and a @@ -1142,11 +1196,20 @@ def verify_session_uuid(ws, agent, uuid, row=None, home_dir=None, claude_dir=Non epoch = row.get("herdr_session_epoch", 0) if mode == "discover" else 0 cwd = row.get("pane", {}).get("cwd", "") or ws + # ORDERING INVARIANT: the workspace check below MUST run before the + # assigned-id shortcut. Moving the shortcut above it would return True for a + # row belonging to a different workspace purely because it is assigned and + # unverified, breaking the one guarantee find_workspace_uuid exists to give + # -- never hand back an id that belongs to a different workspace. (T-12) if workspace_key(cwd) != workspace_key(ws): return False - + + if (mode == "revalidate" and row.get("session_id_source") == "assigned" + and not row.get("session_id_verified")): + return True + if agent == "claude": - base = c_dir + base = (iso_root + "/projects") if iso_root else c_dir key = workspace_key(ws) path = f"{base}/{key}/{uuid}.jsonl" if not os.path.exists(path): @@ -1154,27 +1217,41 @@ def verify_session_uuid(ws, agent, uuid, row=None, home_dir=None, claude_dir=Non if epoch and os.path.getmtime(path) < epoch: return False try: + valid_session = False + found_cwd = None with open(path) as f: - first = f.readline().strip() - if not first: + for _ in range(50): + line = f.readline() + if not line: + break + line = line.strip() + if not line: + continue + try: + payload = json.loads(line) + if payload.get("sessionId") == uuid: + valid_session = True + if payload.get("cwd"): + found_cwd = payload.get("cwd") + break + except Exception: + pass + if not valid_session: return False - payload = json.loads(first) - if payload.get("sessionId") != uuid: - return False - if payload.get("cwd") and payload.get("cwd") != cwd: + if found_cwd and found_cwd != cwd: return False except Exception: return False elif agent == "agy": - base = f"{home}/.gemini/antigravity-cli/conversations" + base = f"{iso_root or home}/.gemini/antigravity-cli/conversations" path = f"{base}/{uuid}.db" if not os.path.exists(path): return False if epoch and os.path.getmtime(path) < epoch: return False if mode == "discover": - lc = f"{home}/.gemini/antigravity-cli/cache/last_conversations.json" + lc = f"{iso_root or home}/.gemini/antigravity-cli/cache/last_conversations.json" cache_match = False if os.path.exists(lc): try: @@ -1196,7 +1273,7 @@ def verify_session_uuid(ws, agent, uuid, row=None, home_dir=None, claude_dir=Non return False elif agent == "hermes": - hdb = f"{home}/.hermes/state.db" + hdb = f"{iso_root or home}/.hermes/state.db" if not os.path.exists(hdb): return False if epoch and os.path.getmtime(hdb) < epoch: @@ -1211,7 +1288,7 @@ def verify_session_uuid(ws, agent, uuid, row=None, home_dir=None, claude_dir=Non return False elif agent == "cline": - base = f"{home}/.cline/data/sessions" + base = (iso_root + "/sessions") if iso_root else f"{home}/.cline/data/sessions" path = f"{base}/{uuid}/{uuid}.json" if not os.path.exists(path): return False @@ -1260,7 +1337,7 @@ PYEOF # 2 = check not possible (capture failed, session gone) verify_tui_viewport() { local session_name="$1" agent="$2" workspace="$3" - local abs; abs="$(cd "$workspace" 2>/dev/null && pwd)" || abs="$workspace" + local abs; abs="$(mam_abs_workspace "$workspace")" local base; base="$(basename "$abs")" if ! _herdr has-session -t "$session_name" >/dev/null 2>&1; then @@ -1273,23 +1350,16 @@ verify_tui_viewport() { return 2 fi - case "$agent" in - claude) - if printf '%s\n' "$pane_content" | grep -q "$base"; then - return 0 - fi - return 1 - ;; - agy|hermes|cline) - if printf '%s\n' "$pane_content" | grep -q "$base"; then - return 0 - fi - return 1 - ;; - *) - return 2 - ;; - esac + local flat base_flat + flat=$(printf '%s' "$pane_content" | tr -d '[:space:]') + base_flat=$(printf '%s' "$base" | tr -d '[:space:]') + if printf '%s' "$flat" | grep -Fq "$base_flat"; then + return 0 + fi + if printf '%s\n' "$pane_content" | grep -Eq '(^|[^[:alnum:]])/[A-Za-z0-9._-]+/[A-Za-z0-9._/-]+'; then + return 1 + fi + return 2 } # --------------------------------------------------------------------------- @@ -1308,7 +1378,7 @@ verify_tui_viewport() { # --------------------------------------------------------------------------- find_workspace_uuid() { local workspace="$1" agent="$2" session_name="${3:-}" - local abs; abs="$(cd "$workspace" 2>/dev/null && pwd)" || abs="$workspace" + local abs; abs="$(mam_abs_workspace "$workspace")" MAM_STATE_JSON="$(load_state_json)" WS_ABS="$abs" AGENT="$agent" TARGET_SESSION="$session_name" MAM_VERIFY_PY="$VERIFY_SESSION_PYTHON" env_python "$AGENT_SESSIONS_YAML" <<'PYEOF' import os, sys, json, glob, sqlite3 @@ -1791,7 +1861,27 @@ _sks_herdr() { mam_herdr "$@" } -_pane_capture() { _sks_herdr capture-pane -p -t "$1" 2>/dev/null || echo ""; } +_pane_capture() { + local raw + raw="$(_sks_herdr capture-pane -p -t "$1" 2>/dev/null || echo "")" + if [ -z "$raw" ]; then + echo "" + return 0 + fi + python3 -c ' +import sys, json +raw = sys.stdin.read() +try: + data = json.loads(raw) + text = data.get("result", {}).get("read", {}).get("text") + if text is not None: + print(text, end="") + else: + print(raw, end="") +except Exception: + print(raw, end="") +' <<< "$raw" +} # Bottom-N *content* lines: capture-pane -p pads the viewport with trailing # blank rows; window on non-blank lines or top-anchored dialogs are invisible. diff --git a/.agents/skills/multi-agent-mux-create/SKILL.md b/.agents/skills/multi-agent-mux-create/SKILL.md index 862b318..382e67c 100644 --- a/.agents/skills/multi-agent-mux-create/SKILL.md +++ b/.agents/skills/multi-agent-mux-create/SKILL.md @@ -233,10 +233,10 @@ The script handles the YAML append, pane capture, and the `last_visible_status` ## Pitfalls - **Don't use `nohup`/`disown`/`setsid` for the agent itself** — those background the agent outside herdr. The whole point of this skill is *the herdr session is the supervisor*. `nohup` is OK only for *launching the wrapper* (which itself creates the herdr session via `herdr new-session -d`). -- **Don't trust `--session-id ` flags blindly** — claude/agy may not accept a fixed session id on first spawn. The session id is *assigned* on first user message; you can read it back from `~/.claude/projects/.../session.jsonl` headers or `~/.gemini/.../cache/last_conversations.json` AFTER the first message. +- **Claude Session ID Auto-Assignment**: Fresh `claude` sessions automatically generate a new UUID (`mam_gen_uuid`) passed via `claude --session-id `. `claude_session_id_own` is stored in `.mam/agent-sessions.yaml` with `session_id_source: assigned` and `session_id_verified: false`. +- **First Message Materialization**: The transcript file `.jsonl` is created on disk when the first user prompt is sent. The monitor loop (`reconcile.sh`) verifies the transcript and promotes `session_id_verified: true` and `last_visible_status: pinned`. - **Wrapper script MUST NOT be created via `hermes profile alias`** — that command writes a `hermes -p ` wrapper that destroys the herdr behavior. Create wrappers manually (see `lab-landing-page-creator-claude` template). - **Always use the workspace-relative path** in herdr `cwd` — relative paths break when herdr respawns in a different shell context. -- **The first `claude` message generates the session id** — `multi-agent-mux-create` only sets up the *container*. If you need a known session id for later resume, send a placeholder message (e.g. "init") and read it back, then call `multi-agent-mux-resume` later. ## Verification diff --git a/.agents/skills/multi-agent-mux-create/scripts/create_session.sh b/.agents/skills/multi-agent-mux-create/scripts/create_session.sh index b77a073..637c781 100755 --- a/.agents/skills/multi-agent-mux-create/scripts/create_session.sh +++ b/.agents/skills/multi-agent-mux-create/scripts/create_session.sh @@ -148,8 +148,13 @@ if [ "$(uname)" = "Darwin" ] && [ -f "$RESOLVED_BIN" ]; then xattr -d com.apple.quarantine "$RESOLVED_BIN" 2>/dev/null || true fi +SESSION_UUID="" +if [ "$AGENT" = "claude" ]; then + SESSION_UUID="$(mam_gen_uuid)" +fi + case "$AGENT" in - claude) CMD_FULL="${RESOLVED_BIN} --dangerously-skip-permissions" ;; + claude) CMD_FULL="${RESOLVED_BIN} --dangerously-skip-permissions --session-id ${SESSION_UUID}" ;; agy) CMD_FULL="${RESOLVED_BIN} --dangerously-skip-permissions" ;; hermes) CMD_FULL="${RESOLVED_BIN}" ;; cline) CMD_FULL="${RESOLVED_BIN} -i" ;; @@ -162,6 +167,7 @@ spawn() { case "$AGENT" in claude) if { [ -x "$WRAPPER" ] && [ "$(basename "$WRAPPER")" != "claude" ]; } || [ "$USE_WRAPPER" = "1" ]; then + SESSION_UUID="" nohup "$WRAPPER" >/dev/null 2>&1 & disown else @@ -262,6 +268,7 @@ atomic_dump_yaml "$AGENT_SESSIONS_YAML" \ HERDR_EPOCH="$HERDR_EPOCH" PANE_PID="$PANE_PID" PANE_CWD="$PANE_CWD" \ CMD_FULL="$CMD_FULL" START_CMD="$START_CMD" CHILD_PID="$CHILD_PID" \ HERDR_SESSION_NAME="${HERDR_SESSION_NAME:-default}" \ + SESSION_UUID="$SESSION_UUID" \ DELEGATE_JOB_ID="$DELEGATE_JOB_ID" ROLE="$ROLE" <<'PYEOF' name = os.environ['SESSION_NAME'] agent = os.environ['AGENT'] @@ -302,8 +309,6 @@ entry = { 'kill_command': f'HERDR_SESSION_NAME={server_name} herdr kill-session -t {name}', } - - if agent == 'claude': entry['tui'] = { 'model': '(unknown — capture after first message)', @@ -312,8 +317,11 @@ if agent == 'claude': 'account': '(unknown — read from claude auth status)', 'version': '(unknown — read from TUI)', } - entry['claude_session_id_own'] = None - entry['last_visible_status'] = "unverified" + assigned = os.environ.get('SESSION_UUID', '') or None + entry['claude_session_id_own'] = assigned + entry['session_id_source'] = 'assigned' if assigned else 'pending-discovery' + entry['session_id_verified'] = False + entry['last_visible_status'] = "assigned (awaiting first message)" if assigned else "unverified" elif agent == 'agy': cp = os.environ.get('CHILD_PID', '0') entry['child_pid'] = int(cp) if cp.isdigit() else 0 diff --git a/.agents/skills/multi-agent-mux-monitor/SKILL.md b/.agents/skills/multi-agent-mux-monitor/SKILL.md index d22cb81..913db18 100644 --- a/.agents/skills/multi-agent-mux-monitor/SKILL.md +++ b/.agents/skills/multi-agent-mux-monitor/SKILL.md @@ -124,15 +124,18 @@ YAML: no such session → report: "lab-paper-pdf2md-creator-agy: herdr found but not in YAML. Auto-registered." ``` -### C. New session id materializes (claude first message sent) +### C. Session ID Discovery & Confirmation -``` -YAML: claude_session_id_own=null (placeholder) -disk: ~/.claude/projects/.../b3a7...c2f.jsonl exists, mtime=now, - first line sessionId=b3a7...c2f - → update claude_session_id_own=b3a7...c2f - → report: "lab-landing-page-creator-claude: session id materialized b3a7...c2f" -``` +- **C0. Assigned ID Confirmation**: For sessions created with an auto-assigned UUID (`session_id_source: assigned`, `session_id_verified: false`), the monitor verifies that the transcript file `.jsonl` has materialized on disk. Once verified, it promotes `session_id_verified: true` and updates `last_visible_status: pinned`. +- **C. New session id materializes (unassigned/legacy)**: + ``` + YAML: claude_session_id_own=null (placeholder) + disk: ~/.claude/projects/.../b3a7...c2f.jsonl exists, mtime=now, + first line sessionId=b3a7...c2f + → update claude_session_id_own=b3a7...c2f + → report: "lab-landing-page-creator-claude: session id materialized b3a7...c2f" + ``` +- **C-ambiguous. Multiple candidates detected**: If multiple candidate transcripts match an unassigned session, the monitor avoids random pinning, reports `C-ambiguous`, and sets `last_visible_status: "ambiguous: N candidates"`. ### D. Stale UUID (artifact gone) diff --git a/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh b/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh index 116a37a..e71303f 100755 --- a/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh +++ b/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh @@ -565,9 +565,50 @@ if herdr_confirmed: 'msg': f"{name}: herdr found but not in YAML. Auto-registered (pane {pm['pid']}, cmd {pm['cmd']}, cwd {pm['cwd']})."}) actions.append(f"registered: {name}") +def row_agent(s): + cmd = ((s.get('pane') or {}).get('cmd') or '').strip() + if cmd in ('claude', 'agy', 'hermes', 'cline'): + return cmd + full = ((s.get('pane') or {}).get('cmd_full') or '') + for a in ('claude', 'agy', 'hermes', 'cline'): + if a in full: + return a + name = s.get('name', '') + for a in ('claude', 'agy', 'hermes', 'cline'): + if name.endswith('-creator-' + a): + return a + return None + +OWN_KEY_BY_AGENT = { + 'claude': 'claude_session_id_own', + 'agy': 'agy_conversation_id_own', + 'hermes': 'hermes_conversation_id_own', + 'cline': 'cline_conversation_id_own' +} + +# === drift C0: 지정된 ID 는 발견이 아니라 '확인'만 필요하다 === +for s in d.get('herdr_sessions', []): + if s.get('status') != 'running': + continue + if s.get('session_id_source') != 'assigned' or s.get('session_id_verified'): + continue + agent = row_agent(s) + if not agent: + continue + uuid = s.get(OWN_KEY_BY_AGENT[agent]) + cwd = (s.get('pane') or {}).get('cwd', '') + if not uuid or not cwd: + continue + if verify_session_uuid(cwd, agent, uuid, s, mode="discover"): + s['session_id_verified'] = True + s['last_visible_status'] = 'pinned' + drifts.append({'class': 'C', 'name': s['name'], + 'msg': f"{s['name']}: assigned session id confirmed on disk: {uuid}"}) + actions.append(f"confirmed session id: {uuid}") + # === drift C: claude 새 session id materialize (per-row own id) === for s in d.get('herdr_sessions', []): - if not s.get('name', '').endswith('-creator-claude'): + if row_agent(s) != 'claude': continue if s.get('status') != 'running': continue @@ -588,6 +629,11 @@ for s in d.get('herdr_sessions', []): if verify_session_uuid(cwd, 'claude', uuid, s, mode="discover"): valid_candidates.append(uuid) + if len(valid_candidates) > 1: + drifts.append({'class': 'C-ambiguous', 'name': s['name'], + 'msg': f"{s['name']}: {len(valid_candidates)} candidate transcripts newer than session epoch; not pinning"}) + s['last_visible_status'] = f"ambiguous: {len(valid_candidates)} candidates" + actions.append(f"ambiguous candidates: {s['name']}") if len(valid_candidates) == 1: uuid = valid_candidates[0] cmd = ['bash', '-c', f'source "{lib_sh}" && verify_tui_viewport "{s["name"]}" "claude" "{cwd}"'] @@ -601,7 +647,7 @@ for s in d.get('herdr_sessions', []): # === drift C (agy): agy 새 session id materialize (per-row own id) === for s in d.get('herdr_sessions', []): - if not s.get('name', '').endswith('-creator-agy'): + if row_agent(s) != 'agy': continue if s.get('status') != 'running': continue @@ -632,6 +678,11 @@ for s in d.get('herdr_sessions', []): if verify_session_uuid(cwd, 'agy', uuid, s_eval, mode="discover"): valid_candidates.append(uuid) + if len(valid_candidates) > 1: + drifts.append({'class': 'C-ambiguous', 'name': s['name'], + 'msg': f"{s['name']}: {len(valid_candidates)} candidate transcripts newer than session epoch; not pinning"}) + s['last_visible_status'] = f"ambiguous: {len(valid_candidates)} candidates" + actions.append(f"ambiguous candidates: {s['name']}") if len(valid_candidates) == 1: uuid = valid_candidates[0] cmd = ['bash', '-c', f'source "{lib_sh}" && verify_tui_viewport "{s["name"]}" "agy" "{cwd}"'] @@ -645,7 +696,7 @@ for s in d.get('herdr_sessions', []): # === drift C (hermes): hermes 새 session id materialize (per-row own id) === for s in d.get('herdr_sessions', []): - if not s.get('name', '').endswith('-creator-hermes'): + if row_agent(s) != 'hermes': continue if s.get('status') != 'running': continue @@ -670,6 +721,11 @@ for s in d.get('herdr_sessions', []): except Exception: pass + if len(valid_candidates) > 1: + drifts.append({'class': 'C-ambiguous', 'name': s['name'], + 'msg': f"{s['name']}: {len(valid_candidates)} candidate transcripts newer than session epoch; not pinning"}) + s['last_visible_status'] = f"ambiguous: {len(valid_candidates)} candidates" + actions.append(f"ambiguous candidates: {s['name']}") if len(valid_candidates) == 1: uuid = valid_candidates[0] cmd = ['bash', '-c', f'source "{lib_sh}" && verify_tui_viewport "{s["name"]}" "hermes" "{cwd}"'] @@ -683,7 +739,7 @@ for s in d.get('herdr_sessions', []): # === drift C (cline): cline 새 session id materialize (per-row own id) === for s in d.get('herdr_sessions', []): - if not s.get('name', '').endswith('-creator-cline'): + if row_agent(s) != 'cline': continue if s.get('status') != 'running': continue @@ -709,8 +765,12 @@ for s in d.get('herdr_sessions', []): uuid = os.path.basename(j)[:-5] if verify_session_uuid(cwd, 'cline', uuid, s, mode="discover"): valid_candidates.append(uuid) - break + if len(valid_candidates) > 1: + drifts.append({'class': 'C-ambiguous', 'name': s['name'], + 'msg': f"{s['name']}: {len(valid_candidates)} candidate transcripts newer than session epoch; not pinning"}) + s['last_visible_status'] = f"ambiguous: {len(valid_candidates)} candidates" + actions.append(f"ambiguous candidates: {s['name']}") if len(valid_candidates) == 1: uuid = valid_candidates[0] cmd = ['bash', '-c', f'source "{lib_sh}" && verify_tui_viewport "{s["name"]}" "cline" "{cwd}"'] diff --git a/.agents/skills/multi-agent-mux-resume/SKILL.md b/.agents/skills/multi-agent-mux-resume/SKILL.md index fcc6b88..a59b1d6 100644 --- a/.agents/skills/multi-agent-mux-resume/SKILL.md +++ b/.agents/skills/multi-agent-mux-resume/SKILL.md @@ -85,8 +85,9 @@ if herdr has-session -t "$SESSION_NAME" 2>/dev/null; then fi # 3. Determine CMD_FULL +# For claude: if assigned UUID transcript .jsonl is unmaterialized on disk, use --session-id ; otherwise use -r case "$AGENT" in - claude) CMD_FULL="claude --dangerously-skip-permissions -r $UUID" ;; + claude) CMD_FULL="claude --dangerously-skip-permissions $CLAUDE_ID_FLAG $UUID" ;; agy) CMD_FULL="agy --dangerously-skip-permissions --conversation $UUID" ;; hermes) CMD_FULL="hermes --resume $UUID" ;; cline) CMD_FULL="cline -i --id $UUID" ;; diff --git a/.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh b/.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh index 6bbb6e3..ab83e5c 100755 --- a/.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh +++ b/.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh @@ -80,9 +80,23 @@ if [ "$(uname)" = "Darwin" ] && [ -f "$RESOLVED_BIN" ]; then xattr -d com.apple.quarantine "$RESOLVED_BIN" 2>/dev/null || true fi +CLAUDE_ID_FLAG="-r" +if [ "$AGENT" = "claude" ]; then + _ws_key="$(mam_workspace_key "$WORKSPACE")" + _iso_root="$(mam_session_iso_root "$SESSION_NAME" 2>/dev/null || true)" + if [ -n "$_iso_root" ]; then + _proj_dir="$_iso_root/projects" + else + _proj_dir="${CLAUDE_PROJECT_DIR:-$HOME/.claude/projects}" + fi + if [ ! -f "${_proj_dir}/${_ws_key}/${UUID}.jsonl" ]; then + CLAUDE_ID_FLAG="--session-id" + fi +fi + # Determine CMD_FULL case "$AGENT" in - claude) CMD_FULL="${RESOLVED_BIN} --dangerously-skip-permissions -r $UUID" ;; + claude) CMD_FULL="${RESOLVED_BIN} --dangerously-skip-permissions $CLAUDE_ID_FLAG $UUID" ;; agy) CMD_FULL="${RESOLVED_BIN} --dangerously-skip-permissions --conversation $UUID" ;; hermes) CMD_FULL="${RESOLVED_BIN} --resume $UUID" ;; cline) CMD_FULL="${RESOLVED_BIN} -i --id $UUID" ;; diff --git a/IMPROVEMENTS.md b/IMPROVEMENTS.md index 6491745..3cd56a1 100644 --- a/IMPROVEMENTS.md +++ b/IMPROVEMENTS.md @@ -70,6 +70,13 @@ ## 5. 🎉 완료된 과제 (Completed Tasks — 10건) +### **Rev.2 (b4a1d094): 생성 시 세션 ID 자동 할당 및 Reconciler 고정/경로 정규화 프로토콜** — ✅ 완료 +- 신규 `claude` 세션 생성 시 `mam_gen_uuid`로 UUID를 즉시 생성하여 `--session-id `로 전달하고, `session_id_source: assigned`, `session_id_verified: false`로 등록하는 원자적 고정 구조를 구축했습니다. +- 첫 사용자 메시지 전달 시 디스크 트랜스크립트 `.jsonl` 생성을 모니터 루프(`reconcile.sh` drift C0)가 감지하고 `session_id_verified: true`, `last_visible_status: pinned`로 고정시킵니다. +- 미할당 다수 후보 발견 시 무작위 고정을 금지하고 `C-ambiguous` 상태를 명확히 보고하도록 강화했습니다. +- 심볼릭 링크/트레일링 슬래시/상대경로 계산 시 `mam_abs_workspace` 및 `mam_workspace_key` (`cd -P && pwd -P` / `os.path.realpath`) 경로 정규화를 전수 적용하여 100% 키 일치성을 확립했습니다. +- 전용 단위/통합 테스트 스위트 `tests/test_uuid_target.py` (13/13 PASS) 및 전체 회귀 테스트 스위트를 검증 완료했습니다. + ### **B-4: 시프트 `ls`의 `created` 동적 POSIX 타임스탬프 복원 및 재개 가드 정상화** — ✅ 완료 - `.agents/skills/lib.sh` 554번 라인의 `999999` 하드코딩 출력을 제거하고, real herdr 또는 `.mam/agent-sessions.yaml` 에 기록된 세션 생성 시각(`created`/`created_at`/`created_epoch`) 및 동적 POSIX 타임스탬프(`int(time.time())`)를 리턴하도록 정제했습니다. - `reconcile.sh` drift-B 감지 시 epoch 0 및 `1970-01-01` 오기록 결함을 차단하여 `find_workspace_uuid` 재개 가드가 정상 작동하도록 해결했습니다. diff --git a/LOG.md b/LOG.md index 113d059..edf3ebf 100644 --- a/LOG.md +++ b/LOG.md @@ -65,9 +65,9 @@ | 에이전트 이름 | 역할 | herdr 세션 상태 | 비고 | | :--- | :--- | :--- | :--- | -| `canary-projects-multi-agent-mux-creator-claude` | Planner | `stopped` | 대화 UUID `01eae7cf...` 캡처 완료 | -| `canary-projects-multi-agent-mux-creator-agy` | Creator | `stopped` | 대화 UUID `72d2d251...` 캡처 완료 | -| `canary-projects-multi-agent-mux-creator-cline` | Reviewer | `stopped` | 대화 UUID `17856352...` 캡처 완료 | +| `canary-projects-multi-agent-mux-creator-claude` | Planner | `running` | 대화 UUID `01eae7cf...` 복원 완료 | +| `canary-projects-multi-agent-mux-creator-agy` | Creator | `running` | 대화 UUID `72d2d251...` 복원 완료 | +| `canary-projects-multi-agent-mux-creator-cline` | Reviewer | `running` | 대화 UUID `17856352...` 복원 완료 | --- diff --git a/tests/conftest.py b/tests/conftest.py index 3210bbf..80ad1ad 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -248,7 +248,16 @@ elif cmd1 == "agent": } # Generate session/conversation UUID and files to simulate agent startup - session_uuid = str(uuid.uuid4()) + session_uuid = "" + cmd_str = " ".join(agent_cmd) + if "--session-id" in cmd_str: + parts = cmd_str.split() + if "--session-id" in parts: + _i = parts.index("--session-id") + if _i + 1 < len(parts): + session_uuid = parts[_i + 1] + if not session_uuid: + session_uuid = str(uuid.uuid4()) own_key_map = { "claude": "claude_session_id_own", "agy": "agy_conversation_id_own", @@ -261,7 +270,7 @@ elif cmd1 == "agent": # Find isolation directory (e.g. if HOME has agent_homes/) home_dir = os.environ.get("HOME", "TMP_PATH_PLACEHOLDER") - ws_abs = os.path.abspath(cwd or "TMP_PATH_PLACEHOLDER") + ws_abs = os.path.realpath(cwd or "TMP_PATH_PLACEHOLDER") ws_key = ws_abs.replace('/', '-').replace('_', '-') if agent_type == "claude": diff --git a/tests/test_tier3_integration.py b/tests/test_tier3_integration.py index ff887b4..ae615b1 100644 --- a/tests/test_tier3_integration.py +++ b/tests/test_tier3_integration.py @@ -107,7 +107,7 @@ def test_integration_stop_purge_combination(mam_sandbox, mock_herdr, mock_agents claude_uuid = session["claude_session_id_own"] # Locate dynamically generated conversation file in claude projects - key = str(tmp_path).replace('/', '-').replace('_', '-') + key = os.path.realpath(str(tmp_path)).replace('/', '-').replace('_', '-') proj_dir = tmp_path / ".claude" / "projects" / key assert proj_dir.exists(), f"Expected projects directory {proj_dir} to exist" diff --git a/tests/test_uuid_target.py b/tests/test_uuid_target.py new file mode 100644 index 0000000..6370b45 --- /dev/null +++ b/tests/test_uuid_target.py @@ -0,0 +1,429 @@ +import os +import json +import uuid +import pytest +import subprocess +import yaml +from pathlib import Path + + +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, cwd=str(mam_sandbox)) + return res + + +def test_t1_create_assigned_session(mam_sandbox, mock_herdr, mock_agents): + """T-1: create immediately has claude_session_id_own as uuid, source=assigned, cmd_full with --session-id""" + ws = str(mam_sandbox / "project") + os.makedirs(ws, exist_ok=True) + script_path = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh" + + cmd = [ + "bash", str(script_path), + "--workspace", ws, + "--agent", "claude", + "--role", "creator", + "--session", "test-t1-claude", + "--no-onboard" + ] + res = subprocess.run(cmd, capture_output=True, text=True, cwd=str(mam_sandbox)) + assert res.returncode == 0, f"create failed: {res.stderr}" + + yaml_path = mam_sandbox / ".mam" / "agent-sessions.yaml" + with open(yaml_path) as f: + data = yaml.safe_load(f) + + session = [s for s in data.get("herdr_sessions", []) if s.get("name") == "test-t1-claude"][0] + cid = session.get("claude_session_id_own") + assert cid is not None and len(cid) > 0 + assert session.get("session_id_source") == "assigned" + assert session.get("session_id_verified") is False + assert "--session-id" in session.get("pane", {}).get("cmd_full", "") + + +def test_t2_assigned_id_promotion(mam_sandbox, mock_herdr, mock_agents): + """T-2: assigned ID is unverified before transcript appears, verified after transcript materializes and reconcile runs""" + ws = str(mam_sandbox / "project2") + os.makedirs(ws, exist_ok=True) + create_script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh" + reconcile_script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-monitor" / "scripts" / "reconcile.sh" + + cmd = [ + "bash", str(create_script), + "--workspace", ws, + "--agent", "claude", + "--role", "creator", + "--session", "test-t2-claude", + "--no-onboard" + ] + res = subprocess.run(cmd, capture_output=True, text=True, cwd=str(mam_sandbox)) + assert res.returncode == 0, f"create failed: {res.stderr}" + + yaml_path = mam_sandbox / ".mam" / "agent-sessions.yaml" + with open(yaml_path) as f: + data = yaml.safe_load(f) + session = [s for s in data.get("herdr_sessions", []) if s.get("name") == "test-t2-claude"][0] + cid = session.get("claude_session_id_own") + assert session.get("session_id_verified") is False + + # Simulate transcript materialization on disk + ws_key = os.path.realpath(ws).replace("/", "-").replace("_", "-") + c_dir = os.path.expanduser("~/.claude/projects") + t_dir = os.path.join(c_dir, ws_key) + os.makedirs(t_dir, exist_ok=True) + with open(os.path.join(t_dir, f"{cid}.jsonl"), "w") as f: + f.write(json.dumps({"type": "queue-operation", "sessionId": cid}) + "\n") + f.write(json.dumps({"type": "user", "sessionId": cid, "cwd": ws}) + "\n") + + # Run reconcile once + res2 = subprocess.run(["bash", str(reconcile_script), "--once"], capture_output=True, text=True, cwd=str(mam_sandbox)) + assert res2.returncode == 0, f"reconcile failed: {res2.stderr}" + + with open(yaml_path) as f: + data = yaml.safe_load(f) + session = [s for s in data.get("herdr_sessions", []) if s.get("name") == "test-t2-claude"][0] + assert session.get("session_id_verified") is True + assert session.get("last_visible_status") == "pinned" + + +def test_t3_different_cwd_transcript_not_pinned(mam_sandbox): + """T-3: transcript belonging to a different cwd is not pinned""" + ws1 = str(mam_sandbox / "ws1") + ws2 = str(mam_sandbox / "ws2") + os.makedirs(ws1, exist_ok=True) + os.makedirs(ws2, exist_ok=True) + + test_uuid = str(uuid.uuid4()) + ws1_key = os.path.realpath(ws1).replace("/", "-").replace("_", "-") + c_dir = os.path.expanduser("~/.claude/projects") + t_dir = os.path.join(c_dir, ws1_key) + os.makedirs(t_dir, exist_ok=True) + t_file = os.path.join(t_dir, f"{test_uuid}.jsonl") + with open(t_file, "w") as f: + f.write(json.dumps({"type": "queue-operation", "sessionId": test_uuid}) + "\n") + f.write(json.dumps({"type": "user", "sessionId": test_uuid, "cwd": ws2}) + "\n") + + lib_path = mam_sandbox / ".agents" / "skills" / "lib.sh" + cmd = [ + "bash", "-c", + f'source "{lib_path}" && verify_session_uuid "{ws1}" "claude" "{test_uuid}" \'{{"pane":{{"cwd":"{ws1}"}}}}\' discover' + ] + res = subprocess.run(cmd, cwd=str(mam_sandbox)) + assert res.returncode != 0 + + +def test_t4_ambiguous_candidates_reported(mam_sandbox, mock_herdr, mock_agents): + """T-4: 2 candidate transcripts -> not pinning + C-ambiguous reported""" + ws = str(mam_sandbox / "ambig_ws") + os.makedirs(ws, exist_ok=True) + session_name = "ambig-creator-claude" + + create_script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh" + cmd = [ + "bash", str(create_script), + "--workspace", ws, + "--agent", "claude", + "--role", "creator", + "--session", session_name, + "--no-onboard" + ] + subprocess.run(cmd, check=True, cwd=str(mam_sandbox)) + + # Reset own ID to None to simulate legacy session without assigned ID + mutation = f""" +for s in d.get('herdr_sessions', []): + if s.get('name') == '{session_name}': + s['claude_session_id_own'] = None + s['session_id_source'] = None +""" + res = run_mutation(mam_sandbox, mutation) + assert res.returncode == 0 + + ws_key = os.path.realpath(ws).replace("/", "-").replace("_", "-") + c_dir = os.path.expanduser("~/.claude/projects") + t_dir = os.path.join(c_dir, ws_key) + os.makedirs(t_dir, exist_ok=True) + u1, u2 = str(uuid.uuid4()), str(uuid.uuid4()) + for u in (u1, u2): + with open(os.path.join(t_dir, f"{u}.jsonl"), "w") as f: + f.write(json.dumps({"type": "queue-operation", "sessionId": u}) + "\n") + f.write(json.dumps({"type": "user", "sessionId": u, "cwd": ws}) + "\n") + + reconcile_script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-monitor" / "scripts" / "reconcile.sh" + res2 = subprocess.run(["bash", str(reconcile_script), "--once"], capture_output=True, text=True, cwd=str(mam_sandbox)) + assert res2.returncode == 0 + + yaml_path = mam_sandbox / ".mam" / "agent-sessions.yaml" + with open(yaml_path) as f: + d = yaml.safe_load(f) + s = [x for x in d["herdr_sessions"] if x["name"] == session_name][0] + assert s.get("claude_session_id_own") is None + assert "ambiguous" in s.get("last_visible_status", "") + + +def test_t5_custom_session_name_pinned(mam_sandbox, mock_herdr, mock_agents): + """T-5: custom --session name also gets pinned""" + ws = str(mam_sandbox / "custom_name_ws") + os.makedirs(ws, exist_ok=True) + + create_script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh" + reconcile_script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-monitor" / "scripts" / "reconcile.sh" + + cmd = [ + "bash", str(create_script), + "--workspace", ws, + "--agent", "claude", + "--role", "creator", + "--session", "my-custom-session-name", + "--no-onboard" + ] + res = subprocess.run(cmd, capture_output=True, text=True, cwd=str(mam_sandbox)) + assert res.returncode == 0 + + yaml_path = mam_sandbox / ".mam" / "agent-sessions.yaml" + with open(yaml_path) as f: + data = yaml.safe_load(f) + session = [s for s in data.get("herdr_sessions", []) if s.get("name") == "my-custom-session-name"][0] + cid = session.get("claude_session_id_own") + + # Simulate transcript materialization on disk + ws_key = os.path.realpath(ws).replace("/", "-").replace("_", "-") + c_dir = os.path.expanduser("~/.claude/projects") + t_dir = os.path.join(c_dir, ws_key) + os.makedirs(t_dir, exist_ok=True) + with open(os.path.join(t_dir, f"{cid}.jsonl"), "w") as f: + f.write(json.dumps({"type": "queue-operation", "sessionId": cid}) + "\n") + f.write(json.dumps({"type": "user", "sessionId": cid, "cwd": ws}) + "\n") + + res2 = subprocess.run(["bash", str(reconcile_script), "--once"], capture_output=True, text=True, cwd=str(mam_sandbox)) + assert res2.returncode == 0 + + with open(yaml_path) as f: + data = yaml.safe_load(f) + session = [s for s in data.get("herdr_sessions", []) if s.get("name") == "my-custom-session-name"][0] + assert session.get("session_id_verified") is True + + +def test_t6_viewport_no_path_degraded(mam_sandbox): + """T-6: viewport without path returns 2 (degraded)""" + lib_path = mam_sandbox / ".agents" / "skills" / "lib.sh" + cmd = [ + "bash", "-c", + f'source "{lib_path}" && _pane_capture() {{ echo "some random text without slash paths"; }} && _herdr() {{ return 0; }} && verify_tui_viewport "sess" "claude" "/tmp/myproj"' + ] + res = subprocess.run(cmd, cwd=str(mam_sandbox)) + assert res.returncode == 2 + + +def test_t7_viewport_different_project_mismatch(mam_sandbox): + """T-7: viewport showing DIFFERENT project returns 1 (mismatch)""" + lib_path = mam_sandbox / ".agents" / "skills" / "lib.sh" + cmd = [ + "bash", "-c", + f'source "{lib_path}" && _pane_capture() {{ echo "Accessing workspace: /Users/godopu16/other_project"; }} && _herdr() {{ return 0; }} && verify_tui_viewport "sess" "claude" "/Users/godopu16/myproj"' + ] + res = subprocess.run(cmd, cwd=str(mam_sandbox)) + assert res.returncode == 1 + + +def test_t8_resume_unmaterialized_assigned_id(mam_sandbox, mock_herdr, mock_agents): + """T-8: unmaterialized assigned ID resume uses --session-id instead of -r""" + ws = str(mam_sandbox / "resume_unmat") + os.makedirs(ws, exist_ok=True) + + create_script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh" + stop_script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-stop" / "scripts" / "stop_session.sh" + resume_script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-resume" / "scripts" / "resume_session.sh" + + # Create session + cmd = [ + "bash", str(create_script), + "--workspace", ws, + "--agent", "claude", + "--role", "creator", + "--session", "test-t8-claude", + "--no-onboard" + ] + subprocess.run(cmd, check=True, cwd=str(mam_sandbox)) + + # Stop session without sending any prompt + subprocess.run(["bash", str(stop_script), "--session", "test-t8-claude", "--agent", "claude"], check=True, cwd=str(mam_sandbox)) + + # Remove transcript if created by mock + yaml_path = mam_sandbox / ".mam" / "agent-sessions.yaml" + with open(yaml_path) as f: + d = yaml.safe_load(f) + session = [s for s in d["herdr_sessions"] if s["name"] == "test-t8-claude"][0] + uuid_val = session["claude_session_id_own"] + ws_key = os.path.realpath(ws).replace("/", "-").replace("_", "-") + jsonl_path = os.path.expanduser(f"~/.claude/projects/{ws_key}/{uuid_val}.jsonl") + if os.path.exists(jsonl_path): + os.remove(jsonl_path) + + # Run dry-run resume + res = subprocess.run([ + "bash", str(resume_script), + "--workspace", ws, + "--agent", "claude", + "--session", "test-t8-claude", + "--dry-run" + ], capture_output=True, text=True, cwd=str(mam_sandbox)) + assert res.returncode == 0 + assert "--session-id" in res.stdout + + +def test_t9_pane_capture_json_unwrap(mam_sandbox): + """T-9: _pane_capture returns real text, unwrapping JSON envelope""" + raw_json = json.dumps({ + "id": "cli:agent:read", + "result": { + "read": { + "format": "text", + "text": "Accessing workspace:\n/Users/godopu16/myproj" + } + } + }) + lib_path = mam_sandbox / ".agents" / "skills" / "lib.sh" + cmd = [ + "bash", "-c", + f'source "{lib_path}" && _sks_herdr() {{ echo \'{raw_json}\'; }} && _pane_capture "sess"' + ] + res = subprocess.run(cmd, capture_output=True, text=True, cwd=str(mam_sandbox)) + assert res.returncode == 0 + assert "Accessing workspace:" in res.stdout + assert "{" not in res.stdout + + +def test_t10_resume_workspace_paths(mam_sandbox, mock_herdr, mock_agents): + """T-10: resume with various workspace path forms (absolute, trailing slash, dotdot, relative, symlink)""" + real_ws = mam_sandbox / "wsprobe" / "real" + os.makedirs(real_ws, exist_ok=True) + link_ws = mam_sandbox / "wsprobe" / "link" + os.symlink(real_ws, link_ws) + + create_script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh" + stop_script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-stop" / "scripts" / "stop_session.sh" + resume_script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-resume" / "scripts" / "resume_session.sh" + + ws_str = str(real_ws) + cmd = [ + "bash", str(create_script), + "--workspace", ws_str, + "--agent", "claude", + "--role", "creator", + "--session", "test-t10-claude", + "--no-onboard" + ] + subprocess.run(cmd, check=True, cwd=str(mam_sandbox)) + subprocess.run(["bash", str(stop_script), "--session", "test-t10-claude", "--agent", "claude"], check=True, cwd=str(mam_sandbox)) + + # Test path variations + variations = [ + ws_str, + ws_str + "/", + str(real_ws / ".." / "real"), + str(link_ws) + ] + for v in variations: + res = subprocess.run([ + "bash", str(resume_script), + "--workspace", v, + "--agent", "claude", + "--session", "test-t10-claude", + "--dry-run" + ], capture_output=True, text=True, cwd=str(mam_sandbox)) + assert res.returncode == 0, f"failed for variation: {v}" + assert f"would spawn:" in res.stdout + + +def test_t11_legacy_isolation_row(mam_sandbox, mock_herdr, mock_agents): + """T-11: legacy isolation row resolved from isolation.root""" + iso_root = str(mam_sandbox / "iso_root") + ws = str(mam_sandbox / "iso_ws") + os.makedirs(ws, exist_ok=True) + + ws_key = os.path.realpath(ws).replace("/", "-").replace("_", "-") + proj_dir = os.path.join(iso_root, "projects", ws_key) + os.makedirs(proj_dir, exist_ok=True) + u_val = str(uuid.uuid4()) + with open(os.path.join(proj_dir, f"{u_val}.jsonl"), "w") as f: + f.write(json.dumps({"type": "queue-operation", "sessionId": u_val}) + "\n") + f.write(json.dumps({"type": "user", "sessionId": u_val, "cwd": ws}) + "\n") + + mutation = f""" +entry = {{ + 'name': 'iso-session', + 'status': 'stopped', + 'role': 'creator', + 'claude_session_id_own': '{u_val}', + 'isolation': {{'root': '{iso_root}', 'uuid': 'iso-uuid-1'}}, + 'pane': {{'cwd': '{ws}'}} +}} +d.setdefault('herdr_sessions', []).append(entry) +""" + res_m = run_mutation(mam_sandbox, mutation) + assert res_m.returncode == 0, f"mutation failed: {res_m.stderr}" + + resolve_script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-resume" / "scripts" / "resolve_session_id.sh" + cmd = [ + "bash", str(resolve_script), + "--workspace", ws, + "--agent", "claude", + "--session", "iso-session" + ] + res = subprocess.run(cmd, capture_output=True, text=True, cwd=str(mam_sandbox)) + assert res.returncode == 0 + assert res.stdout.strip() == u_val + + +def test_t12_other_workspace_assigned_row_revalidate_fails(mam_sandbox): + """T-12: assigned row belonging to another workspace does not pass revalidate""" + ws1 = str(mam_sandbox / "target_ws") + ws2 = str(mam_sandbox / "other_ws") + os.makedirs(ws1, exist_ok=True) + os.makedirs(ws2, exist_ok=True) + + u_val = str(uuid.uuid4()) + row_json = json.dumps({ + "session_id_source": "assigned", + "session_id_verified": False, + "pane": {"cwd": ws2} + }) + lib_path = mam_sandbox / ".agents" / "skills" / "lib.sh" + cmd = [ + "bash", "-c", + f'source "{lib_path}" && verify_session_uuid "{ws1}" "claude" "{u_val}" \'{row_json}\' revalidate' + ] + res = subprocess.run(cmd, cwd=str(mam_sandbox)) + assert res.returncode != 0 + + +def test_t13_mam_workspace_key_equivalence(mam_sandbox): + """T-13: mam_workspace_key (shell) matches python workspace_key across absolute, trailing slash, dotdot, and symlink""" + real_ws = mam_sandbox / "ws_real" + os.makedirs(real_ws, exist_ok=True) + link_ws = mam_sandbox / "ws_link" + os.symlink(real_ws, link_ws) + + lib_path = mam_sandbox / ".agents" / "skills" / "lib.sh" + variations = [ + str(real_ws), + str(real_ws) + "/", + str(real_ws / ".." / "ws_real"), + str(link_ws) + ] + for v in variations: + cmd = ["bash", "-c", f'source "{lib_path}" && mam_workspace_key "{v}"'] + res = subprocess.run(cmd, capture_output=True, text=True, cwd=str(mam_sandbox)) + sh_key = res.stdout.strip() + + # Python realpath key + abs_path = os.path.realpath(v) + py_key = abs_path.replace("/", "-").replace("_", "-") + assert sh_key == py_key, f"Mismatch for {v}: shell={sh_key}, py={py_key}"