Compare commits
6
Commits
6408f4a5ec
...
d76e470942
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d76e470942 | ||
|
|
768cfe5c6d | ||
|
|
35068f7a9e | ||
|
|
a0a92432b8 | ||
|
|
c7ce014eac | ||
|
|
3a9964c0e2 |
@@ -118,9 +118,16 @@ TMUX 환경에서 실행되는 에이전트가 화면 스크롤 한계로 인해
|
||||
2. **Loop Snapshot**: 장기 실행(5분 이상) 중인 에이전트 세션의 경우, 주기적으로(예: 30초마다) 뷰포트를 스캔하여 증분 데이터를 `/tmp/pane-snap.txt`에 계속 누적(append) 기록합니다.
|
||||
3. **Post-job Capture**: 잡 완료/에러 반환 즉시 전체 pane 상태를 마지막으로 캡처하여 전체 작업 궤적을 보존합니다.
|
||||
|
||||
### 📄 장문 브리핑 전달 방식
|
||||
- TMUX `send-keys`나 입력 버퍼를 통해 수백 줄의 장문 지시나 프롬프트를 직렬로 입력하면, 에이전트의 TUI가 이를 모두 온전히 소화하지 못하고 일부 문자나 문단이 탈취/누락될 수 있습니다.
|
||||
- **해결 지침**: 지시 사항이 긴 경우, 반드시 `/tmp/brief-<job_id>.md` 등의 파일 경로로 지시문을 별도 작성해 전달하고, 에이전트에는 `"Read /tmp/brief-... and execute"` 라는 단순화된 실행 명령만 전달하십시오.
|
||||
### 📄 마크다운 기반 협업 및 결과 전달 (Markdown-Based Workflow & Communication)
|
||||
- **핵심 원칙**: TMUX `send-keys`나 입력 버퍼를 통해 긴 지시사항을 직렬로 입력하는 과정에서 문자 누락이나 레이아웃 유실이 발생하는 것을 방지하기 위해, 에이전트 간의 모든 주요 협업 소통은 파일 기반 마크다운 문서 생성을 원칙으로 합니다.
|
||||
- **세부 규칙 및 규약**:
|
||||
- **예외 사항**: 1~2줄 내외의 매우 단순한 재평가 요청, 상태 확인, 수락 진행 등의 단발성 프롬프트는 기존처럼 tmux 입력을 통해 직접 보낼 수 있습니다.
|
||||
- **작업 위임**: 상세 사양과 계획 수립 등의 복잡한 작업 지시는 먼저 로컬 마크다운 파일(예: `.mam/reports/brief-<job_id>.md` 또는 지정된 워크스페이스 경로)로 작성한 후, 에이전트에게 `"Read <파일경로> and execute."` 라는 단순화된 실행 명령만 전달하십시오.
|
||||
- **결과 안내 및 피드백**: 상세 리뷰 결과, 설계 제안서, 구현 완료 리포트 및 감사 주석 등은 반드시 마크다운 파일로 영속화하여 제공해야 합니다. 기계 전용인 잡 레지스트리 평면(`.mam/jobs/`)과의 물리적 분리를 위해, 모든 에이전트는 `.mam/reports/<tmux_session_name>/` 아래에 전용 디렉터리를 형성하고 결과 보고 마크다운 파일을 저장해야 합니다 (여기서 `<tmux_session_name>`은 `.mam/agent-sessions.yaml`의 `name` 필드와 완전히 일치해야 합니다. 예: `.mam/reports/<workspace_slug>-creator-<agent>/report-<job_id>.md`). 수신 에이전트는 잘린 터미널 화면 캡처에 의존하는 대신 디스크에서 해당 파일을 직접 로드하여 확인합니다.
|
||||
- **디스크 정리 및 보존 정책 계약 (Cleanup & Retention)**:
|
||||
- `.mam/reports/` 폴더 아래의 파일들은 감사 이력(audit-trail) 산출물로 보존됩니다.
|
||||
- 해당 격리 디렉터리들은 `stop_session.sh` 등을 통해 세션이 정상적으로 종료되거나 파기(`--purge-conversation`)될 때 자동으로 함께 정리되어야 합니다.
|
||||
- 버전 관리가 필요한 영구 보존용 주요 산출물(최종 설계 계획, 보안 감사 리포트 등)은 gitignore 대상인 `.mam/` 하위가 아닌, 버전 관리 대상 경로(예: `docs/` 또는 `artifacts/` 등)로 명시적으로 복사하여 기록을 이관 보존해야 합니다.
|
||||
|
||||
### ⏱️ 타임아웃 구성 및 정렬 규칙
|
||||
- **잡 실행 제한 (`timeout_sec` & `idle_timeout_sec`)**: 각 잡은 전체 실행 만료 시간(`timeout_sec`, 기본 3600s)과 메세지 미수신 유휴 시간(`idle_timeout_sec`, 기본 120s)을 독립적으로 가집니다.
|
||||
|
||||
@@ -118,9 +118,16 @@ To ensure that agents running in TMUX environments do not lose debug logs or pre
|
||||
2. **Loop Snapshot**: For long-running agent sessions (5 minutes or more), periodically (e.g., every 30 seconds) scan the viewport and append the incremental data to `/tmp/pane-snap.txt`.
|
||||
3. **Post-job Capture**: Capture the complete pane state one final time immediately after a job completes or returns an error to preserve the entire execution trajectory.
|
||||
|
||||
### 📄 Handling Long Briefing Instructions
|
||||
- Sending long instructions or prompts (hundreds of lines) sequentially via TMUX `send-keys` or input buffers can overwhelm the agent's TUI, leading to lost characters or truncated paragraphs.
|
||||
- **Resolution**: If instructions are long, write them separately to a file path (e.g., `/tmp/brief-<job_id>.md`) and send a simplified execution command to the agent: `"Read /tmp/brief-... and execute"`.
|
||||
### 📄 Markdown-Based Workflow & Communication (마크다운 기반 협업 및 결과 전달)
|
||||
- **Core Principle**: To prevent TUI character loss, truncation, and layout breakage during sequential input typing, all collaborative workflows must favor file-based markdown communication.
|
||||
- **Rules & Protocols**:
|
||||
- **Exception**: Extremely simple prompts (e.g., "Re-evaluate", "Check status", "Proceed") of 1 or 2 lines may be sent directly via tmux input buffers.
|
||||
- **Task Delegation**: All detailed task briefs, specifications, and instructions must be written to a local Markdown file (e.g., `.mam/reports/brief-<job_id>.md` or a workspace path) first. The sender then issues a simple trigger command: `"Read <file_path> and execute."`
|
||||
- **Result Reporting & Feedback**: All detailed review results, design proposals, implementation reports, and audit comments must be saved as Markdown files. To separate human-readable documents from the machine job registry (`.mam/jobs/`), every agent must save its outputs under `.mam/reports/<tmux_session_name>/` (using the full tmux session name matching the `name` field in `.mam/agent-sessions.yaml`, e.g., `.mam/reports/<workspace_slug>-creator-<agent>/report-<job_id>.md`) to ensure strict isolation across multiple roles/instances.
|
||||
- **Cleanup & Retention Contract**:
|
||||
- Files under `.mam/reports/` are audit-trail artifacts.
|
||||
- These folders should be cleaned up automatically during `stop_session.sh` when a session is gracefully stopped or purged (`--purge-conversation`).
|
||||
- Durable outcomes (such as final design plans or security audit reports) that require version control must be explicitly copied to tracked directory paths (e.g., `docs/` or `artifacts/`) instead of remaining in the gitignored `.mam/` runtime tree.
|
||||
|
||||
### ⏱️ 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).
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
# Multi-Agent Collaboration Workflow Reference Guide
|
||||
|
||||
본 문서는 대규모 프로젝트나 정밀한 설계·구현 요구사항을 처리하기 위해 **Planner, Developer, Reviewer** 에이전트 간의 역할 분담 및 피드백 루프를 운영하는 **다중 에이전트 협업 워크플로우(Multi-Agent Collaboration Workflow)**를 정의합니다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 역할 정의 및 분담 (Roles & Responsibilities)
|
||||
|
||||
협업 시스템은 각 에이전트의 책임 영역을 명확히 격리하여 상호 교차 검증을 강제합니다.
|
||||
|
||||
```
|
||||
┌──────────────────────┐
|
||||
│ User Prompt │
|
||||
└──────────┬───────────┘
|
||||
▼
|
||||
┌──────────────────────┐
|
||||
│ 1. Planner Agent │ ◄──────────────────┐
|
||||
│ - Plan & Checklist │ │
|
||||
└──────────┬───────────┘ │
|
||||
▼ │
|
||||
┌──────────────────────┐ │
|
||||
│ 2. Developer Agent │ │
|
||||
│ - Code & DoD Verify │ │
|
||||
└──────────┬───────────┘ │
|
||||
▼ │ (NOT PASS Feedback)
|
||||
┌──────────────────────┐ │
|
||||
│ 3. Reviewer Agents │ │
|
||||
│ - Dual Peer Review │ ───────────────────┘
|
||||
└──────────┬───────────┘
|
||||
▼ (PASS)
|
||||
┌──────────────────────┐
|
||||
│ 4. Done & Commit │
|
||||
└──────────────────────┘
|
||||
```
|
||||
|
||||
### 1.1. Planner Agent (설계 및 통제)
|
||||
- **목적**: 요구사항을 명세화하고, 구현 단계의 설계 결함이나 모순(Contradiction)을 사전에 차단합니다.
|
||||
- **역할**:
|
||||
- 사용자 요구사항에 따른 구현 목표 및 범위 수립.
|
||||
- `implementation_plan.md` 및 `task.md` (체크리스트) 작성 및 버전 관리(Rev.1, Rev.2, ...).
|
||||
- 리뷰어 피드백 발생 시 설계 변경의 파급 범위를 계산하여 계획 갱신.
|
||||
- **핵심 원칙**: 직접 코드를 수정하지 않고 오직 설계와 체크리스트 자산만 관리합니다.
|
||||
|
||||
### 1.2. Developer Agent (구현 및 자가 검증)
|
||||
- **목적**: Planner가 제공한 체크리스트를 기반으로 실제 리포지토리 코드를 물리적으로 수정 및 구현합니다.
|
||||
- **역할**:
|
||||
- `task.md`를 순차적으로 완료 상태(`[x]`)로 업데이트하며 구현 수행.
|
||||
- 커밋 전 **Definition of Done (DoD)** 체크리스트를 자체 실행하여 금지된 코드 패턴, 메모리/구조적 사이드 이펙트 유무 자가 검토.
|
||||
- 수정 사항을 단일 원자적(Atomic) 커밋으로 마감하고 리뷰어에게 전달.
|
||||
|
||||
### 1.3. Reviewer Agents (교차 피드백 및 검증)
|
||||
- **목적**: 구현된 결과물이 최초 설계서 및 학술적 제약 요건에 일치하는지 제3자의 관점에서 엄격하게 검토합니다.
|
||||
- **역할**:
|
||||
- **Reviewer A (Claude)**: 학술 서사와 코드 설계 간의 상위 논리적 정합성 및 결함(예: HOLB 해소 주장과 단일 커넥션 다이얼러의 모순 등) 검증.
|
||||
- **Reviewer B (Cline)**: 전이 조건, 예외 처리, 타입 시그니처, 텔레메트리 매핑 등 하위 레벨 구현의 세부 사항 기계적 검증.
|
||||
- **판정 규칙**: 두 리뷰어 모두 **PASS** 판정을 내릴 때까지 개발자는 마감할 수 없으며, 반려 시 **1단계(Planner)**로 피드백이 환류됩니다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 세부 운영 단계 (Step-by-Step Workflow)
|
||||
|
||||
### 1단계: 설계 수립 (Planning Phase)
|
||||
1. 사용자가 요구사항을 제시하면, **Planner 에이전트**가 프로젝트의 전반적인 구조를 파악합니다.
|
||||
2. `implementation_plan.md` 및 `task.md`를 작성하여 개발을 위한 로드맵을 제공합니다.
|
||||
3. 사용자가 해당 구현 계획을 승인하면 다음 단계로 이행합니다.
|
||||
|
||||
### 2단계: 코드 구현 및 자가 검증 (Development Phase)
|
||||
1. **Developer 에이전트**가 배정된 태스크의 코드를 수정합니다.
|
||||
2. 작업 진행 중 예상치 못한 설계 변경 필요성이 감지되면 작업을 멈추고 **Planner 에이전트**에게 계획 수정을 먼저 위임합니다.
|
||||
3. 구현 완료 후 아래의 **DoD 검증**을 수행합니다:
|
||||
- 핵심 기능의 타입 매핑 확인.
|
||||
- 공유 자원(`tls.Config` 등) 변경 시 사이드 이펙트 방지(복제 후 변이 적용 등).
|
||||
- 문서-코드 간 주장의 정합성 체크.
|
||||
4. 검증 완료 후 단일 커밋을 작성합니다.
|
||||
|
||||
### 3단계: 피드백 루프 및 통과 (Review Phase)
|
||||
1. Developer 에이전트가 리뷰어 세션에 작업 완료 사실과 변경 범위를 전달합니다.
|
||||
2. 리뷰어들은 `git diff`를 바탕으로 개별 검증을 수행하고 보고서 형태의 리뷰 피드백을 출력합니다.
|
||||
- **반려 (`NOT PASS`)** -> 피드백 요약본을 Planner 에이전트에게 전송하여 상위 레벨 계획(Rev.n) 개시.
|
||||
- **통과 (`PASS`)** -> 모든 검토 사항이 해결되었음을 명시.
|
||||
3. 모든 리뷰어가 PASS를 발행하면 작업을 완결하고 세션을 안전하게 종료(`multi-agent-mux-stop`)합니다.
|
||||
|
||||
---
|
||||
|
||||
## 3. Best Practices & 템플릿
|
||||
|
||||
### 3.1. 작업 시작 시 Planner 에이전트 프롬프트 템플릿
|
||||
```
|
||||
[역할 요구]
|
||||
프로젝트의 구조 및 내용을 파악하고, 현재 작업 요건에 맞춰 구현 계획서(implementation_plan.md) 및 태스크 체크리스트(task.md)를 작성해 주세요.
|
||||
|
||||
[검토 초점]
|
||||
- 설계 수준에서 논리적 모순이 발생할 여지가 없는지
|
||||
- naive 구현과 대비되는 핵심 차별점이 코드 명세에 정확히 명시되었는지
|
||||
```
|
||||
|
||||
### 3.2. 피드백 루프 환류 시 프롬프트 템플릿
|
||||
```
|
||||
Reviewer 검토 결과 구현 코드 차원에서 아래의 블로킹(NOT PASS) 피드백이 발생했습니다.
|
||||
위 피드백을 수용하여 설계 문서를 수정하는 계획을 수립하고, implementation_plan.md (Rev.[N]) 및 task.md 내용을 업데이트해 주세요.
|
||||
|
||||
[피드백 내용]
|
||||
- [블로킹 항목 1]
|
||||
- [블로킹 항목 2]
|
||||
```
|
||||
+237
-27
@@ -115,7 +115,7 @@ yaml_path = os.environ['YAML_PATH']
|
||||
db_path = os.path.splitext(yaml_path)[0] + '.db'
|
||||
try:
|
||||
if os.path.exists(db_path):
|
||||
conn = sqlite3.connect(db_path, timeout=10.0)
|
||||
conn = sqlite3.connect(db_path, timeout=60.0)
|
||||
try:
|
||||
row = conn.execute('SELECT data FROM sessions WHERE name=?', (name,)).fetchone()
|
||||
if row:
|
||||
@@ -311,10 +311,17 @@ def _validate(d):
|
||||
raise SystemExit(f"VALIDATE: tmux_sessions[{i}] {s.get('name')!r} bad status {s['status']!r}")
|
||||
if not isinstance(s.get('pane'), dict):
|
||||
raise SystemExit(f"VALIDATE: tmux_sessions[{i}] {s.get('name')!r} missing pane")
|
||||
iso = s.get('isolation')
|
||||
if iso is not None:
|
||||
if not isinstance(iso, dict) or not iso.get('uuid') or not iso.get('root'):
|
||||
raise SystemExit(f"VALIDATE: tmux_sessions[{i}] {s.get('name')!r} isolation block requires uuid/root")
|
||||
|
||||
def get_terminal_set(d):
|
||||
return {s.get('name'): s.get('status') for s in d.get('tmux_sessions', []) if s.get('status') in ('stopped', 'terminated', 'archived')}
|
||||
|
||||
def get_all_sessions_status(d):
|
||||
return {s.get('name'): s.get('status') for s in d.get('tmux_sessions', [])}
|
||||
|
||||
os.makedirs(os.path.dirname(db_path) or '.', exist_ok=True)
|
||||
conn = sqlite3.connect(db_path, timeout=60.0)
|
||||
|
||||
@@ -367,7 +374,7 @@ try:
|
||||
elif 'tmux_sessions' not in d:
|
||||
d['tmux_sessions'] = []
|
||||
|
||||
old_terminals = get_terminal_set(d)
|
||||
old_sessions = get_all_sessions_status(d)
|
||||
old_roles = {s.get('name'): s.get('role') for s in db_sessions if s.get('role')}
|
||||
|
||||
# --- caller mutation (module scope: sees d, yaml, os, glob, subprocess) ---
|
||||
@@ -379,6 +386,19 @@ try:
|
||||
if name in old_roles and s.get('role') != old_roles[name]:
|
||||
raise SystemExit(f"VALIDATE: role of session {name!r} cannot be modified from {old_roles[name]!r} to {s.get('role')!r}")
|
||||
|
||||
# ID Uniqueness Check (T2)
|
||||
running_keys = ['claude_session_id_own', 'agy_conversation_id_own', 'hermes_conversation_id_own', 'cline_conversation_id_own']
|
||||
id_to_session = {}
|
||||
for s in d.get('tmux_sessions', []):
|
||||
if s.get('status') == 'running':
|
||||
s_name = s.get('name')
|
||||
for k in running_keys:
|
||||
v = s.get(k)
|
||||
if v:
|
||||
if v in id_to_session and id_to_session[v] != s_name:
|
||||
raise SystemExit(f"VALIDATE: Duplicate running conversation ID {v!r} detected between {id_to_session[v]!r} and {s_name!r}")
|
||||
id_to_session[v] = s_name
|
||||
|
||||
_validate(d)
|
||||
|
||||
# Separate globals and sessions for normalization
|
||||
@@ -400,13 +420,12 @@ try:
|
||||
else:
|
||||
conn.execute('DELETE FROM sessions')
|
||||
|
||||
new_terminals = get_terminal_set(d)
|
||||
new_sessions = get_all_sessions_status(d)
|
||||
|
||||
conn.commit()
|
||||
|
||||
# Write to YAML ONLY when a session transitions to a finished state
|
||||
# (Moved after conn.commit() per Claude's feedback)
|
||||
if new_terminals != old_terminals:
|
||||
# Write to YAML when sessions status or session list changes (e.g. create/stop/resume)
|
||||
if new_sessions != old_sessions:
|
||||
if os.path.exists(yaml_path):
|
||||
try:
|
||||
shutil.copy2(yaml_path, yaml_path + '.bak')
|
||||
@@ -461,9 +480,9 @@ PYEOF
|
||||
# Prints the UUID on stdout (empty line if none). Always exits 0.
|
||||
# ---------------------------------------------------------------------------
|
||||
find_workspace_uuid() {
|
||||
local workspace="$1" agent="$2"
|
||||
local workspace="$1" agent="$2" session_name="${3:-}"
|
||||
local abs; abs="$(cd "$workspace" 2>/dev/null && pwd)" || abs="$workspace"
|
||||
WS_ABS="$abs" AGENT="$agent" env_python "$AGENT_SESSIONS_YAML" <<'PYEOF'
|
||||
WS_ABS="$abs" AGENT="$agent" TARGET_SESSION="$session_name" env_python "$AGENT_SESSIONS_YAML" <<'PYEOF'
|
||||
import os, json, glob, sqlite3
|
||||
import yaml
|
||||
|
||||
@@ -473,18 +492,33 @@ home = os.environ['HOME_DIR']
|
||||
yaml_path = os.environ['YAML_PATH']
|
||||
db_path = os.path.splitext(yaml_path)[0] + '.db'
|
||||
claude_project_dir = os.environ.get('CLAUDE_PROJECT_DIR', f"{home}/.claude/projects")
|
||||
target = os.environ.get('TARGET_SESSION', '')
|
||||
|
||||
def jsonl_exists(uuid):
|
||||
OWN_KEY = {'claude': 'claude_session_id_own', 'agy': 'agy_conversation_id_own',
|
||||
'hermes': 'hermes_conversation_id_own', 'cline': 'cline_conversation_id_own'}
|
||||
|
||||
|
||||
def iso_root_of(s):
|
||||
# T5: per-row isolation root (all-L2). None => legacy global state dirs.
|
||||
iso = s.get('isolation')
|
||||
return iso.get('root') if isinstance(iso, dict) else None
|
||||
|
||||
|
||||
# exists checks take an optional isolation root; path templates per lever
|
||||
# (Phase 0 measured — note cline's isolated layout is <root>/sessions/, NOT data/sessions/).
|
||||
def jsonl_exists(uuid, iso=None):
|
||||
base = f"{iso}/projects" if iso else claude_project_dir
|
||||
key = ws.replace('/', '-').replace('_', '-')
|
||||
return os.path.exists(f"{claude_project_dir}/{key}/{uuid}.jsonl")
|
||||
return os.path.exists(f"{base}/{key}/{uuid}.jsonl")
|
||||
|
||||
|
||||
def db_exists(uuid):
|
||||
return os.path.exists(f"{home}/.gemini/antigravity-cli/conversations/{uuid}.db")
|
||||
def db_exists(uuid, iso=None):
|
||||
base = f"{iso}/.gemini/antigravity-cli/conversations" if iso else f"{home}/.gemini/antigravity-cli/conversations"
|
||||
return os.path.exists(f"{base}/{uuid}.db")
|
||||
|
||||
|
||||
def hermes_exists(uuid):
|
||||
hdb = f"{home}/.hermes/state.db"
|
||||
def hermes_exists(uuid, iso=None):
|
||||
hdb = f"{iso}/.hermes/state.db" if iso else f"{home}/.hermes/state.db"
|
||||
if not os.path.exists(hdb):
|
||||
return False
|
||||
try:
|
||||
@@ -496,11 +530,52 @@ def hermes_exists(uuid):
|
||||
return False
|
||||
|
||||
|
||||
def cline_exists(uuid):
|
||||
return os.path.exists(f"{home}/.cline/data/sessions/{uuid}/{uuid}.json")
|
||||
def cline_exists(uuid, iso=None):
|
||||
base = f"{iso}/sessions" if iso else f"{home}/.cline/data/sessions"
|
||||
return os.path.exists(f"{base}/{uuid}/{uuid}.json")
|
||||
|
||||
|
||||
def own_exists(a, uuid, iso=None):
|
||||
return {'claude': jsonl_exists, 'agy': db_exists,
|
||||
'hermes': hermes_exists, 'cline': cline_exists}[a](uuid, iso)
|
||||
|
||||
|
||||
running_ids = set()
|
||||
try:
|
||||
if os.path.exists(db_path):
|
||||
conn = sqlite3.connect(db_path, timeout=60.0)
|
||||
try:
|
||||
cursor = conn.execute("SELECT data FROM sessions WHERE status='running'")
|
||||
for r_row in cursor.fetchall():
|
||||
s_data = json.loads(r_row[0])
|
||||
# the target row's own ids are legitimately "claimed by itself" — don't filter them
|
||||
if target and s_data.get('name') == target:
|
||||
continue
|
||||
for k in ['claude_session_id_own', 'agy_conversation_id_own', 'hermes_conversation_id_own', 'cline_conversation_id_own']:
|
||||
val = s_data.get(k)
|
||||
if val:
|
||||
running_ids.add(val)
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
conn.close()
|
||||
if not running_ids and os.path.exists(yaml_path):
|
||||
with open(yaml_path) as f:
|
||||
d_state = yaml.safe_load(f) or {}
|
||||
for s_item in d_state.get('tmux_sessions', []):
|
||||
if s_item.get('status') == 'running':
|
||||
if target and s_item.get('name') == target:
|
||||
continue
|
||||
for k in ['claude_session_id_own', 'agy_conversation_id_own', 'hermes_conversation_id_own', 'cline_conversation_id_own']:
|
||||
val = s_item.get(k)
|
||||
if val:
|
||||
running_ids.add(val)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def emit(u):
|
||||
if u in running_ids:
|
||||
return
|
||||
print(u)
|
||||
raise SystemExit(0)
|
||||
|
||||
@@ -509,7 +584,7 @@ def emit(u):
|
||||
sessions = []
|
||||
try:
|
||||
if os.path.exists(db_path):
|
||||
conn = sqlite3.connect(db_path, timeout=10.0)
|
||||
conn = sqlite3.connect(db_path, timeout=60.0)
|
||||
has_sessions_table = False
|
||||
try:
|
||||
cursor = conn.execute('SELECT data FROM sessions WHERE pane_cwd=?', (ws,))
|
||||
@@ -535,23 +610,75 @@ try:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# T5: target-session mode — resolve strictly within the named row's scope.
|
||||
# An isolated row's conversation lives ONLY in its isolation root (which holds
|
||||
# at most one conversation), so we never fall through to the global tiers —
|
||||
# those would guess another role's conversation (the C3 mtime bug).
|
||||
if target:
|
||||
for s in sessions:
|
||||
if s.get('name') != target:
|
||||
continue
|
||||
iso = iso_root_of(s)
|
||||
cand = s.get(OWN_KEY.get(agent, ''), None)
|
||||
if cand and own_exists(agent, cand, iso):
|
||||
emit(cand)
|
||||
if iso:
|
||||
key = ws.replace('/', '-').replace('_', '-')
|
||||
if agent == 'claude':
|
||||
for j in sorted(glob.glob(f"{iso}/projects/{key}/*.jsonl"), key=os.path.getmtime, reverse=True):
|
||||
sid = None
|
||||
try:
|
||||
with open(j) as f:
|
||||
first = f.readline().strip()
|
||||
if first:
|
||||
sid = json.loads(first).get('sessionId')
|
||||
except Exception:
|
||||
sid = None
|
||||
cand = sid or os.path.basename(j)[:-6]
|
||||
if cand:
|
||||
emit(cand)
|
||||
elif agent == 'agy':
|
||||
for j in sorted(glob.glob(f"{iso}/.gemini/antigravity-cli/conversations/*.db"), key=os.path.getmtime, reverse=True):
|
||||
emit(os.path.basename(j)[:-3])
|
||||
elif agent == 'hermes':
|
||||
hdb = f"{iso}/.hermes/state.db"
|
||||
if os.path.exists(hdb):
|
||||
try:
|
||||
conn = sqlite3.connect(hdb)
|
||||
r = conn.execute("SELECT id FROM sessions WHERE cwd=? ORDER BY started_at DESC LIMIT 1", (ws,)).fetchone()
|
||||
conn.close()
|
||||
if r:
|
||||
emit(r[0])
|
||||
except Exception:
|
||||
pass
|
||||
elif agent == 'cline':
|
||||
for folder in sorted(glob.glob(f"{iso}/sessions/*"), key=os.path.getmtime, reverse=True):
|
||||
fn = os.path.basename(folder)
|
||||
if os.path.exists(f"{folder}/{fn}.json"):
|
||||
emit(fn)
|
||||
# isolated row: nothing found inside its exclusive root -> no guess
|
||||
print('')
|
||||
raise SystemExit(0)
|
||||
# target row absent or legacy (non-isolated): fall through to legacy tiers
|
||||
|
||||
for s in sessions:
|
||||
name = s.get('name', '')
|
||||
iso = iso_root_of(s)
|
||||
if agent == 'claude' and name.endswith('-creator-claude'):
|
||||
cand = s.get('claude_session_id_own')
|
||||
if cand and jsonl_exists(cand):
|
||||
if cand and jsonl_exists(cand, iso):
|
||||
emit(cand)
|
||||
if agent == 'agy' and name.endswith('-creator-agy'):
|
||||
cand = s.get('agy_conversation_id_own')
|
||||
if cand and db_exists(cand):
|
||||
if cand and db_exists(cand, iso):
|
||||
emit(cand)
|
||||
if agent == 'hermes' and name.endswith('-creator-hermes'):
|
||||
cand = s.get('hermes_conversation_id_own')
|
||||
if cand and hermes_exists(cand):
|
||||
if cand and hermes_exists(cand, iso):
|
||||
emit(cand)
|
||||
if agent == 'cline' and name.endswith('-creator-cline'):
|
||||
cand = s.get('cline_conversation_id_own')
|
||||
if cand and cline_exists(cand):
|
||||
if cand and cline_exists(cand, iso):
|
||||
emit(cand)
|
||||
|
||||
# 2) disk scan scoped to THIS workspace
|
||||
@@ -621,7 +748,7 @@ elif agent == 'cline':
|
||||
ai = {}
|
||||
try:
|
||||
if os.path.exists(db_path):
|
||||
conn = sqlite3.connect(db_path, timeout=10.0)
|
||||
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', {})
|
||||
@@ -657,18 +784,101 @@ PYEOF
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# capture_conversation_id <agent> <workdir>
|
||||
# capture_conversation_id <agent> <workdir> [session_name]
|
||||
#
|
||||
# Thin wrapper over find_workspace_uuid: resolves THIS workspace's conversation
|
||||
# id (claude jsonl sessionId / agy db uuid) and prints it on stdout (empty line
|
||||
# if none). find_workspace_uuid is already a workspace-scoped, 3-tier, race-free
|
||||
# resolver (per-row own id -> workspace-scoped disk scan -> cwd-matched cache),
|
||||
# so recording its result into the row before kill guarantees tier-1 on the next
|
||||
# resume. Always exits 0.
|
||||
# resume. Pass session_name to scope resolution to that row (required for
|
||||
# isolated sessions — see isolation block / T5). Always exits 0.
|
||||
# ---------------------------------------------------------------------------
|
||||
capture_conversation_id() {
|
||||
local agent="$1" workdir="$2"
|
||||
find_workspace_uuid "$workdir" "$agent"
|
||||
local agent="$1" workdir="$2" session_name="${3:-}"
|
||||
find_workspace_uuid "$workdir" "$agent" "$session_name"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Session isolation (all-L2) — implementation_plan.session_isolation.md Rev.3
|
||||
#
|
||||
# Every isolated session gets its own state home `.mam/agent_homes/<uuid>/`;
|
||||
# auth/config files are SYMLINKED into it (never copied — token refresh must
|
||||
# converge on the real files). Levers per agent (Phase 0 measured matrix):
|
||||
# claude: CLAUDE_CONFIG_DIR=<root> conv: <root>/projects/<key>/<uuid>.jsonl
|
||||
# cline: --data-dir <root> conv: <root>/sessions/<id>/<id>.json
|
||||
# agy: HOME=<root> conv: <root>/.gemini/antigravity-cli/conversations/
|
||||
# hermes: HOME=<root> conv: <root>/.hermes/state.db
|
||||
#
|
||||
# provision_isolation <agent> <root> — mkdir + seed; prints comma-joined seeded list
|
||||
# isolation_lever <agent> — claude_config_dir|cline_data_dir|home
|
||||
# isolation_env_prefix <agent> <root> — "VAR=<root> " spawn prefix ('' for cline)
|
||||
# isolation_cmd_args <agent> <root> — extra spawn CLI args ('' unless cline)
|
||||
# ---------------------------------------------------------------------------
|
||||
provision_isolation() {
|
||||
local agent="$1" root="$2" seeded="" f base
|
||||
mkdir -p "$root"
|
||||
case "$agent" in
|
||||
claude)
|
||||
ln -sfn "$HOME/.claude/.credentials.json" "$root/.credentials.json"; seeded=".credentials.json"
|
||||
if [ -e "$HOME/.claude/settings.json" ]; then ln -sfn "$HOME/.claude/settings.json" "$root/settings.json"; seeded="$seeded,settings.json"; fi
|
||||
if [ -d "$HOME/.claude/plugins" ]; then ln -sfn "$HOME/.claude/plugins" "$root/plugins"; seeded="$seeded,plugins"; fi
|
||||
;;
|
||||
cline)
|
||||
# Phase 0 실측: --config 공유 지정만으론 auth 미공유 — settings/* + globalState.json 시딩 필수
|
||||
mkdir -p "$root/settings"
|
||||
for f in "$HOME/.cline/data/settings/"*; do
|
||||
[ -e "$f" ] || continue
|
||||
base="$(basename "$f")"
|
||||
ln -sfn "$f" "$root/settings/$base"; seeded="${seeded:+$seeded,}settings/$base"
|
||||
done
|
||||
if [ -e "$HOME/.cline/data/globalState.json" ]; then
|
||||
ln -sfn "$HOME/.cline/data/globalState.json" "$root/globalState.json"; seeded="${seeded:+$seeded,}globalState.json"
|
||||
fi
|
||||
;;
|
||||
agy)
|
||||
mkdir -p "$root/.gemini/antigravity-cli"
|
||||
for f in oauth_creds.json google_accounts.json installation_id settings.json state.json; do
|
||||
if [ -e "$HOME/.gemini/$f" ]; then ln -sfn "$HOME/.gemini/$f" "$root/.gemini/$f"; seeded="${seeded:+$seeded,}.gemini/$f"; fi
|
||||
done
|
||||
for f in antigravity-oauth-token installation_id settings.json; do
|
||||
if [ -e "$HOME/.gemini/antigravity-cli/$f" ]; then ln -sfn "$HOME/.gemini/antigravity-cli/$f" "$root/.gemini/antigravity-cli/$f"; seeded="${seeded:+$seeded,}.gemini/antigravity-cli/$f"; fi
|
||||
done
|
||||
;;
|
||||
hermes)
|
||||
mkdir -p "$root/.hermes"
|
||||
for f in auth.json config.yaml .env; do
|
||||
if [ -e "$HOME/.hermes/$f" ]; then ln -sfn "$HOME/.hermes/$f" "$root/.hermes/$f"; seeded="${seeded:+$seeded,}.hermes/$f"; fi
|
||||
done
|
||||
;;
|
||||
esac
|
||||
printf '%s\n' "$seeded"
|
||||
}
|
||||
|
||||
isolation_lever() {
|
||||
case "$1" in
|
||||
claude) echo "claude_config_dir" ;;
|
||||
cline) echo "cline_data_dir" ;;
|
||||
agy|hermes) echo "home" ;;
|
||||
*) echo "" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
isolation_env_prefix() {
|
||||
local agent="$1" root="$2"
|
||||
case "$agent" in
|
||||
claude) printf 'CLAUDE_CONFIG_DIR=%q ' "$root" ;;
|
||||
agy|hermes) printf 'HOME=%q ' "$root" ;;
|
||||
*) : ;;
|
||||
esac
|
||||
}
|
||||
|
||||
isolation_cmd_args() {
|
||||
local agent="$1" root="$2"
|
||||
case "$agent" in
|
||||
cline) printf -- '--data-dir %q' "$root" ;;
|
||||
*) : ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -687,7 +897,7 @@ yaml_path = os.environ['YAML_PATH']
|
||||
db_path = os.path.splitext(yaml_path)[0] + '.db'
|
||||
try:
|
||||
if os.path.exists(db_path):
|
||||
conn = sqlite3.connect(db_path, timeout=10.0)
|
||||
conn = sqlite3.connect(db_path, timeout=60.0)
|
||||
has_sessions_table = False
|
||||
try:
|
||||
row = conn.execute('SELECT status, data FROM sessions WHERE name=?', (name,)).fetchone()
|
||||
|
||||
@@ -35,6 +35,8 @@ Options:
|
||||
--tmux-server NAME specify isolated tmux server name
|
||||
--submit-job PROMPT submit a job to multi-agent-mux-delegate-job registry with the given prompt
|
||||
--onboard automatically submit a project alignment/orientation job to the new agent
|
||||
--isolate provision an isolated per-session state home (.mam/agent_homes/<uuid>)
|
||||
so same-CLI multi-role sessions never share a conversation id
|
||||
-h, --help this help
|
||||
EOF
|
||||
}
|
||||
@@ -48,6 +50,7 @@ DRY_RUN=0
|
||||
TMUX_SERVER_OPT=""
|
||||
SUBMIT_JOB_PROMPT=""
|
||||
ONBOARD=0
|
||||
ISOLATE=0
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
@@ -60,6 +63,7 @@ while [ $# -gt 0 ]; do
|
||||
--tmux-server) TMUX_SERVER_OPT="$2"; shift 2 ;;
|
||||
--submit-job) SUBMIT_JOB_PROMPT="$2"; shift 2 ;;
|
||||
--onboard) ONBOARD=1; shift ;;
|
||||
--isolate) ISOLATE=1; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "ERROR: unknown arg: $1" >&2; usage; exit 2 ;;
|
||||
esac
|
||||
@@ -111,28 +115,58 @@ if _tmux has-session -t "$SESSION_NAME" 2>/dev/null; then
|
||||
exit 3
|
||||
fi
|
||||
|
||||
# T3: 세션 격리 프로비저닝 (all-L2) — 격리 홈 생성 + auth/설정 심링크 시딩
|
||||
# (implementation_plan.session_isolation.md Rev.3 / Phase 0 실측 매트릭스 기준)
|
||||
ISOLATION_UUID=""
|
||||
ISOLATION_ROOT=""
|
||||
ISOLATION_LEVER=""
|
||||
ISOLATION_SEEDED=""
|
||||
if [ "$ISOLATE" = "1" ]; then
|
||||
command -v uuidgen >/dev/null || { echo "ERROR: uuidgen not found (required for --isolate)" >&2; exit 1; }
|
||||
WORKSPACE_ABS="$(cd "$WORKSPACE" && pwd)"
|
||||
ISOLATION_UUID="$(uuidgen)"
|
||||
ISOLATION_ROOT="$WORKSPACE_ABS/.mam/agent_homes/$ISOLATION_UUID"
|
||||
ISOLATION_LEVER="$(isolation_lever "$AGENT")"
|
||||
if [ "$DRY_RUN" = "1" ]; then
|
||||
echo "[dry-run] would provision isolation: lever=$ISOLATION_LEVER root=$ISOLATION_ROOT"
|
||||
else
|
||||
ISOLATION_SEEDED="$(provision_isolation "$AGENT" "$ISOLATION_ROOT")"
|
||||
echo "isolation: lever=$ISOLATION_LEVER root=$ISOLATION_ROOT"
|
||||
fi
|
||||
fi
|
||||
|
||||
# tmux 세션 띄우기
|
||||
LOCAL_BIN="${LOCAL_BIN:-$HOME/.local/bin}"
|
||||
WRAPPER="$LOCAL_BIN/$SESSION_NAME"
|
||||
|
||||
# cmd_full 결정 — T4: isolation 디스패치(env prefix / CLI args)를 시작 명령에 주입.
|
||||
# 격리 미사용 시 기존 문자열과 byte-identical (V4 회귀 0).
|
||||
ISO_ENV_PREFIX=""
|
||||
ISO_CMD_ARGS=""
|
||||
if [ -n "$ISOLATION_ROOT" ]; then
|
||||
ISO_ENV_PREFIX="$(isolation_env_prefix "$AGENT" "$ISOLATION_ROOT")"
|
||||
ISO_CMD_ARGS="$(isolation_cmd_args "$AGENT" "$ISOLATION_ROOT")"
|
||||
fi
|
||||
case "$AGENT" in
|
||||
claude) CMD_FULL="${ISO_ENV_PREFIX}claude --dangerously-skip-permissions" ;;
|
||||
agy) CMD_FULL="${ISO_ENV_PREFIX}agy --dangerously-skip-permissions" ;;
|
||||
hermes) CMD_FULL="${ISO_ENV_PREFIX}hermes" ;;
|
||||
cline) CMD_FULL="cline -i${ISO_CMD_ARGS:+ $ISO_CMD_ARGS}" ;;
|
||||
esac
|
||||
|
||||
spawn() {
|
||||
case "$AGENT" in
|
||||
claude)
|
||||
if { [ -x "$WRAPPER" ] && [ "$(basename "$WRAPPER")" != "claude" ]; } || [ "$USE_WRAPPER" = "1" ]; then
|
||||
# 격리 시 wrapper 경로는 env 주입을 운반하지 못하므로 인라인 spawn 강제
|
||||
if [ "$ISOLATE" != "1" ] && { { [ -x "$WRAPPER" ] && [ "$(basename "$WRAPPER")" != "claude" ]; } || [ "$USE_WRAPPER" = "1" ]; }; then
|
||||
nohup "$WRAPPER" >/dev/null 2>&1 &
|
||||
disown
|
||||
else
|
||||
_tmux new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" "claude --dangerously-skip-permissions"
|
||||
_tmux new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" "$CMD_FULL"
|
||||
fi
|
||||
;;
|
||||
agy)
|
||||
_tmux new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" "agy --dangerously-skip-permissions"
|
||||
;;
|
||||
hermes)
|
||||
_tmux new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" "hermes"
|
||||
;;
|
||||
cline)
|
||||
_tmux new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" "cline -i"
|
||||
agy|hermes|cline)
|
||||
_tmux new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" "$CMD_FULL"
|
||||
;;
|
||||
*) echo "ERROR: --agent must be claude, agy, hermes or cline, got: $AGENT" >&2; exit 2 ;;
|
||||
esac
|
||||
@@ -145,6 +179,22 @@ fi
|
||||
|
||||
spawn
|
||||
|
||||
# Trap for rolling back/cleaning up tmux session if script exits due to error
|
||||
cleanup_tmux_on_error() {
|
||||
local exit_code=$?
|
||||
if [ $exit_code -ne 0 ]; then
|
||||
echo "⚠️ Error occurred during initialization. Rolling back and killing tmux session '$SESSION_NAME'..." >&2
|
||||
_tmux kill-session -t "$SESSION_NAME" 2>/dev/null || true
|
||||
# T3 rollback: 이 세션용으로 프로비저닝한 격리 홈 제거 (경로 가드 후 rm)
|
||||
if [ -n "$ISOLATION_ROOT" ] && [ -d "$ISOLATION_ROOT" ]; then
|
||||
case "$ISOLATION_ROOT" in
|
||||
*/.mam/agent_homes/*) rm -rf "$ISOLATION_ROOT" ;;
|
||||
esac
|
||||
fi
|
||||
fi
|
||||
}
|
||||
trap cleanup_tmux_on_error EXIT
|
||||
|
||||
# TUI 준비 대기
|
||||
wait_for_tui_ready "$SESSION_NAME" "$AGENT"
|
||||
|
||||
@@ -155,15 +205,7 @@ PANE_CMD=$(_tmux list-panes -t "$SESSION_NAME" -F '#{pane_current_command}' 2>/d
|
||||
TMUX_EPOCH=$(date +%s)
|
||||
NOW_ISO=$(date -u +'%Y-%m-%dT%H:%M:%SZ')
|
||||
|
||||
# cmd_full 결정
|
||||
case "$AGENT" in
|
||||
claude) CMD_FULL='claude --dangerously-skip-permissions' ;;
|
||||
agy) CMD_FULL='agy --dangerously-skip-permissions' ;;
|
||||
hermes) CMD_FULL='hermes' ;;
|
||||
cline) CMD_FULL='cline -i' ;;
|
||||
esac
|
||||
|
||||
# 시작 명령
|
||||
# 시작 명령 (CMD_FULL 은 spawn 전에 isolation 디스패치를 반영해 확정됨 — T4)
|
||||
local_tmux="tmux"
|
||||
if [ -n "${TMUX_SERVER_NAME:-}" ] && [ "$TMUX_SERVER_NAME" != "default" ]; then
|
||||
local_tmux="tmux -L $TMUX_SERVER_NAME"
|
||||
@@ -171,10 +213,10 @@ fi
|
||||
|
||||
case "$AGENT" in
|
||||
claude)
|
||||
if [ -x "$WRAPPER" ]; then
|
||||
if [ "$ISOLATE" != "1" ] && [ -x "$WRAPPER" ]; then
|
||||
START_CMD="$WRAPPER # ~/.local/bin 의 래퍼"
|
||||
else
|
||||
START_CMD="$local_tmux new-session -d -s \"$SESSION_NAME\" -x 140 -y 40 -c \"$WORKSPACE\" \"claude --dangerously-skip-permissions\""
|
||||
START_CMD="$local_tmux new-session -d -s \"$SESSION_NAME\" -x 140 -y 40 -c \"$WORKSPACE\" \"$CMD_FULL\""
|
||||
fi
|
||||
;;
|
||||
agy|hermes|cline)
|
||||
@@ -228,7 +270,9 @@ atomic_dump_yaml "$AGENT_SESSIONS_YAML" \
|
||||
TMUX_EPOCH="$TMUX_EPOCH" PANE_PID="$PANE_PID" PANE_CWD="$PANE_CWD" \
|
||||
CMD_FULL="$CMD_FULL" START_CMD="$START_CMD" CHILD_PID="$CHILD_PID" \
|
||||
TMUX_SERVER_NAME="${TMUX_SERVER_NAME:-default}" \
|
||||
DELEGATE_JOB_ID="$DELEGATE_JOB_ID" ROLE="$ROLE" <<'PYEOF'
|
||||
DELEGATE_JOB_ID="$DELEGATE_JOB_ID" ROLE="$ROLE" \
|
||||
ISOLATION_UUID="$ISOLATION_UUID" ISOLATION_ROOT="$ISOLATION_ROOT" \
|
||||
ISOLATION_LEVER="$ISOLATION_LEVER" ISOLATION_SEEDED="$ISOLATION_SEEDED" <<'PYEOF'
|
||||
name = os.environ['SESSION_NAME']
|
||||
agent = os.environ['AGENT']
|
||||
role = os.environ['ROLE']
|
||||
@@ -267,6 +311,16 @@ entry = {
|
||||
'kill_command': f'tmux {server_opt}kill-session -t {name}',
|
||||
}
|
||||
|
||||
# T5: isolation 블록 영속화 (all-L2) — resume/resolve/stop 이 재적용의 단일 소스로 사용
|
||||
iso_uuid = os.environ.get('ISOLATION_UUID', '')
|
||||
if iso_uuid:
|
||||
entry['isolation'] = {
|
||||
'uuid': iso_uuid,
|
||||
'root': os.environ.get('ISOLATION_ROOT', ''),
|
||||
'lever': os.environ.get('ISOLATION_LEVER', ''),
|
||||
'seeded': [x for x in os.environ.get('ISOLATION_SEEDED', '').split(',') if x],
|
||||
}
|
||||
|
||||
if agent == 'claude':
|
||||
entry['tui'] = {
|
||||
'model': '(unknown — capture after first message)',
|
||||
@@ -333,6 +387,8 @@ Task: $SUBMIT_JOB_PROMPT"
|
||||
WD_PID=$(start_watchdog "$DELEGATE_JOB_ID" "$WORKSPACE")
|
||||
echo "watchdog PID: $WD_PID"
|
||||
fi
|
||||
# Clear cleanup trap as registration was fully successful
|
||||
trap - EXIT
|
||||
echo "agent-sessions.yaml updated"
|
||||
echo
|
||||
if [ -n "${TMUX_SERVER_NAME:-}" ] && [ "$TMUX_SERVER_NAME" != "default" ]; then
|
||||
|
||||
@@ -301,7 +301,7 @@ except NameError:
|
||||
d = {}
|
||||
try:
|
||||
if os.path.exists(db_path):
|
||||
conn = sqlite3.connect(db_path, timeout=10.0)
|
||||
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])
|
||||
|
||||
|
||||
@@ -64,17 +64,18 @@ WORKSPACE=/path/to/project
|
||||
AGENT=claude # or agy or hermes
|
||||
SESSION_NAME=<workspace>-creator-<agent> # same convention as multi-agent-mux-create
|
||||
|
||||
# 1. Resolve the session id
|
||||
# Resolve the isolated tmux server name & load isolation utils
|
||||
source .agents/skills/lib.sh
|
||||
|
||||
# 1. Resolve the session id (T5: pass session name for target-row isolation check)
|
||||
UUID=$(bash .agents/skills/multi-agent-mux-resume/scripts/resolve_session_id.sh \
|
||||
--workspace "$WORKSPACE" --agent "$AGENT")
|
||||
--workspace "$WORKSPACE" --agent "$AGENT" --session "$SESSION_NAME")
|
||||
|
||||
if [ -z "$UUID" ]; then
|
||||
echo "No saved session for $WORKSPACE ($AGENT). Use multi-agent-mux-create first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Resolve the isolated tmux server name
|
||||
source .agents/skills/lib.sh
|
||||
export TMUX_SERVER_NAME="$(resolve_tmux_server "$SESSION_NAME")"
|
||||
|
||||
# 2. If tmux is alive, attach. Done.
|
||||
@@ -83,11 +84,69 @@ if tmux has-session -t "$SESSION_NAME" 2>/dev/null; then
|
||||
exec tmux attach -t "$SESSION_NAME"
|
||||
fi
|
||||
|
||||
# 3. Spawn new tmux session + run agent with the saved id
|
||||
# 3. Resolve isolation settings for this session (T4/T5 re-apply)
|
||||
# _get_session_isolation resolves isolation block for the session row
|
||||
ISO_ROOT=""
|
||||
ISO_ENV=""
|
||||
ISO_ARGS=""
|
||||
ISO_DATA=$(env_python "$AGENT_SESSIONS_YAML" SESSION_NAME="$SESSION_NAME" <<'PYEOF'
|
||||
import os, json, yaml, sqlite3
|
||||
name = os.environ['SESSION_NAME']
|
||||
yaml_path = os.environ['YAML_PATH']
|
||||
db_path = os.path.splitext(yaml_path)[0] + '.db'
|
||||
d = {}
|
||||
try:
|
||||
if os.path.exists(db_path):
|
||||
conn = sqlite3.connect(db_path, timeout=60.0)
|
||||
row = conn.execute('SELECT data FROM sessions WHERE name=?', (name,)).fetchone()
|
||||
if row:
|
||||
s = json.loads(row[0])
|
||||
print(json.dumps(s.get('isolation') or {}))
|
||||
raise SystemExit(0)
|
||||
elif os.path.exists(yaml_path):
|
||||
with open(yaml_path) as f:
|
||||
d = yaml.safe_load(f) or {}
|
||||
except Exception:
|
||||
pass
|
||||
for s in d.get('tmux_sessions', []):
|
||||
if s.get('name') == name:
|
||||
print(json.dumps(s.get('isolation') or {}))
|
||||
raise SystemExit(0)
|
||||
print("{}")
|
||||
PYEOF
|
||||
)
|
||||
ISO_ROOT=$(printf '%s' "$ISO_DATA" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("root",""))')
|
||||
if [ -n "$ISO_ROOT" ]; then
|
||||
ISO_ENV="$(isolation_env_prefix "$AGENT" "$ISO_ROOT")"
|
||||
ISO_ARGS="$(isolation_cmd_args "$AGENT" "$ISO_ROOT")"
|
||||
echo "Re-applying isolation: root=$ISO_ROOT env=$ISO_ENV args=$ISO_ARGS"
|
||||
fi
|
||||
|
||||
# Determine CMD_FULL with isolation applied
|
||||
case "$AGENT" in
|
||||
claude) CMD_FULL="claude --dangerously-skip-permissions -r $UUID" ;;
|
||||
agy) CMD_FULL="agy --dangerously-skip-permissions --conversation $UUID" ;;
|
||||
hermes) CMD_FULL="hermes --resume $UUID" ;;
|
||||
cline) CMD_FULL="cline -i --id $UUID" ;;
|
||||
esac
|
||||
|
||||
# Prepend env prefix and append command args (T4)
|
||||
if [ -n "$ISO_ENV" ]; then
|
||||
CMD_FULL="$ISO_ENV $CMD_FULL"
|
||||
fi
|
||||
if [ -n "$ISO_ARGS" ]; then
|
||||
CMD_FULL="$CMD_FULL $ISO_ARGS"
|
||||
fi
|
||||
|
||||
# 4. Spawn new tmux session + run agent with the saved id (and re-applied isolation)
|
||||
case "$AGENT" in
|
||||
claude)
|
||||
tmux new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" \
|
||||
"claude --dangerously-skip-permissions -r $UUID"
|
||||
if [ -z "$ISO_ROOT" ] && [ -x "$HOME/.local/bin/canary-projects-multi-agent-mux-creator-claude" ]; then
|
||||
START_CMD="tmux new-session -d -s \"$SESSION_NAME\" -x 140 -y 40 -c \"$WORKSPACE\" \"$HOME/.local/bin/canary-projects-multi-agent-mux-creator-claude\""
|
||||
else
|
||||
START_CMD="tmux new-session -d -s \"$SESSION_NAME\" -x 140 -y 40 -c \"$WORKSPACE\" \"$CMD_FULL\""
|
||||
fi
|
||||
eval "$START_CMD"
|
||||
# auto-handle trust / bypass dialogs
|
||||
sleep 5
|
||||
tmux send-keys -t "$SESSION_NAME" Enter 2>/dev/null || true
|
||||
@@ -96,17 +155,8 @@ case "$AGENT" in
|
||||
sleep 0.3
|
||||
tmux send-keys -t "$SESSION_NAME" Enter 2>/dev/null || true
|
||||
;;
|
||||
agy)
|
||||
tmux new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" \
|
||||
"agy --dangerously-skip-permissions --conversation $UUID"
|
||||
;;
|
||||
hermes)
|
||||
tmux new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" \
|
||||
"hermes --resume $UUID"
|
||||
;;
|
||||
cline)
|
||||
tmux new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" \
|
||||
"cline -i --id $UUID"
|
||||
agy|hermes|cline)
|
||||
eval "tmux new-session -d -s \"$SESSION_NAME\" -x 140 -y 40 -c \"$WORKSPACE\" \"$CMD_FULL\""
|
||||
;;
|
||||
esac
|
||||
|
||||
|
||||
@@ -13,18 +13,22 @@ source "$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/lib.sh"
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $0 --workspace <path> --agent <claude|agy>
|
||||
Usage: $0 --workspace <path> --agent <claude|agy|hermes|cline> [--session <name>]
|
||||
Outputs the resolved UUID on stdout (empty if not found).
|
||||
--session scopes resolution to that registry row — required for sessions
|
||||
created with --isolate (their conversation lives only in the row's isolation root).
|
||||
EOF
|
||||
}
|
||||
|
||||
WORKSPACE=""
|
||||
AGENT=""
|
||||
SESSION_NAME=""
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--workspace) WORKSPACE="$2"; shift 2 ;;
|
||||
--agent) AGENT="$2"; shift 2 ;;
|
||||
--session) SESSION_NAME="$2"; shift 2 ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "ERROR: unknown arg: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
@@ -37,4 +41,4 @@ case "$AGENT" in
|
||||
*) echo "ERROR: --agent must be claude or agy or hermes or cline" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
find_workspace_uuid "$WORKSPACE" "$AGENT"
|
||||
find_workspace_uuid "$WORKSPACE" "$AGENT" "$SESSION_NAME"
|
||||
|
||||
@@ -65,7 +65,7 @@ db_path = os.path.splitext(yaml_path)[0] + '.db'
|
||||
d = {}
|
||||
try:
|
||||
if os.path.exists(db_path):
|
||||
conn = sqlite3.connect(db_path, timeout=10.0)
|
||||
conn = sqlite3.connect(db_path, timeout=60.0)
|
||||
try:
|
||||
row = conn.execute('SELECT data FROM sessions WHERE name=?', (name,)).fetchone()
|
||||
if row:
|
||||
|
||||
@@ -42,7 +42,7 @@ d = {}
|
||||
import sqlite3
|
||||
try:
|
||||
if os.path.exists(db_path):
|
||||
conn = sqlite3.connect(db_path, timeout=10.0)
|
||||
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])
|
||||
|
||||
|
||||
@@ -91,7 +91,7 @@ db_path = os.path.splitext(yaml_path)[0] + '.db'
|
||||
d = {}
|
||||
try:
|
||||
if os.path.exists(db_path):
|
||||
conn = sqlite3.connect(db_path, timeout=10.0)
|
||||
conn = sqlite3.connect(db_path, timeout=60.0)
|
||||
try:
|
||||
row = conn.execute('SELECT data FROM sessions WHERE name=?', (name,)).fetchone()
|
||||
if row:
|
||||
@@ -148,7 +148,7 @@ fi
|
||||
# purge 대상 UUID 를 워크스페이스 격리해서 해결 (P0-C — 전역 참조 금지)
|
||||
PURGE_UUID=""
|
||||
if [ "$PURGE" = "1" ] && [ -n "$TARGET_CWD" ]; then
|
||||
PURGE_UUID=$(find_workspace_uuid "$TARGET_CWD" "$AGENT" || true)
|
||||
PURGE_UUID=$(find_workspace_uuid "$TARGET_CWD" "$AGENT" "$SESSION_NAME" || true)
|
||||
fi
|
||||
|
||||
NOW_ISO=$(date -u +'%Y-%m-%dT%H:%M:%SZ')
|
||||
@@ -167,7 +167,7 @@ fi
|
||||
# 를 알아서 시도하므로 tmux 생사와 무관하게 동작.
|
||||
CAPTURED_UUID=""
|
||||
if [ "$CAPTURE_ID" = "1" ] && [ -n "$TARGET_CWD" ]; then
|
||||
CAPTURED_UUID=$(capture_conversation_id "$AGENT" "$TARGET_CWD" || true)
|
||||
CAPTURED_UUID=$(capture_conversation_id "$AGENT" "$TARGET_CWD" "$SESSION_NAME" || true)
|
||||
if [ -n "$CAPTURED_UUID" ]; then
|
||||
echo "captured conversation id: $CAPTURED_UUID"
|
||||
else
|
||||
@@ -270,6 +270,23 @@ if captured and not purge:
|
||||
target['resumable'] = True
|
||||
|
||||
# --purge-conversation: 워크스페이스 격리된 UUID 의 디스크 artifact 만 삭제 (P0-C)
|
||||
# T6: stop-purge 시 격리 디렉터리 청소 및 경로 가드
|
||||
iso = target.get('isolation')
|
||||
if purge and iso:
|
||||
iso_root = iso.get('root')
|
||||
iso_uuid = iso.get('uuid')
|
||||
if iso_root and iso_uuid:
|
||||
ws_abs = os.path.abspath(ws) if ws else ""
|
||||
expected_homes_dir = os.path.join(ws_abs, '.mam', 'agent_homes')
|
||||
expected_iso_root = os.path.join(expected_homes_dir, iso_uuid)
|
||||
if (os.path.abspath(iso_root) == os.path.abspath(expected_iso_root) and
|
||||
os.path.abspath(iso_root).startswith(os.path.abspath(expected_homes_dir) + os.sep)):
|
||||
if os.path.isdir(iso_root):
|
||||
shutil.rmtree(iso_root)
|
||||
print(f"purged isolated home: {iso_root}", flush=True)
|
||||
else:
|
||||
print(f"WARN: isolated home path check failed: {iso_root}", flush=True)
|
||||
|
||||
if purge and purge_uuid:
|
||||
if agent == 'claude':
|
||||
key = ws.replace('/', '-').replace('_', '-')
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# 에이전트 세션 ID 중복 충돌 분석 보고서
|
||||
|
||||
본 보고서는 `multi-agent-mux` 오케스트레이션 환경에서 동일 언어 모델 계열(Claude 및 Cline)의 서로 다른 역할 에이전트들이 동일한 세션 ID를 공유하게 된 문제를 정의하고, 이에 대한 실질적인 해결 방법을 제안합니다.
|
||||
|
||||
---
|
||||
|
||||
## 1. Problem Definition (문제 정의)
|
||||
|
||||
### 1.1 현상
|
||||
- 현재 프로젝트 세션 데이터베이스(`.mam/agent-sessions.yaml`)를 확인한 결과, 역할이 다른 에이전트들이 동일한 고유 세션 ID를 공유하고 있습니다:
|
||||
- **Claude 계열**: `planner`와 `reviewer-a`가 동일한 대화 ID (`7ef1095f-4533-44be-ba9a-07260328f5ff`)를 공유
|
||||
- **Cline 계열**: `developer`와 `reviewer-b`가 동일한 대화 ID (`1783574535923_z770y`)를 공유
|
||||
|
||||
### 1.2 원인
|
||||
- **세션 정보 공유 방식의 한계**: Claude Code 및 Cline CLI는 동일 프로젝트 디렉터리 내에서 실행될 때 기본적으로 가장 최근에 생성되었거나 활성화된 로컬 세션 ID(캐시 파일)를 상속 및 공유하도록 설계되어 있습니다.
|
||||
- **동시 구동 시의 경쟁 상태 (Race Condition)**: 최초 온보딩 시 스크립트가 여러 에이전트를 거의 동시에(`tmux new-session` 병렬 실행) 띄우는 과정에서, 각 세션이 독자적인 신규 UUID를 발급받지 못하고 로컬의 마지막 활성 세션 정보로 진입점이 묶여버렸습니다.
|
||||
|
||||
### 1.3 영향 및 부작용
|
||||
- **컨텍스트 오염 (Context Contamination)**: 기획자(Planner)가 지시 수행 중 출력한 생각과 로그가 리뷰어(Reviewer A)의 화면에도 실시간으로 노출되는 등 역할 격리가 무너집니다.
|
||||
- **락 충돌 (Lock Conflict)**: 동일한 대화 히스토리 파일(`*.jsonl` 또는 `*.db`)에 두 에이전트 프로세스가 동시에 쓰기(Write) 작업을 시도하면서 데이터 유실 및 동작 지연이 발생합니다.
|
||||
- **검증의 객관성 상실**: 리뷰어가 본래의 독립적 검수 목적을 잃고, 개발/기획 세션의 편향된 맥락을 공유하게 됩니다.
|
||||
@@ -0,0 +1,138 @@
|
||||
# Implementation Plan — 배포 스크립트 URL 파라미터화 (Rev.1)
|
||||
|
||||
- **작성자**: Planner Agent
|
||||
- **날짜**: 2026-07-09
|
||||
- **상태**: Draft (사용자 승인 대기)
|
||||
- **관련 리뷰 피드백**: Reviewer A 이식성(portability) 스캔 결과
|
||||
|
||||
---
|
||||
|
||||
## 1. 배경 및 문제 정의
|
||||
|
||||
Reviewer A의 코드베이스 스캔 결과, 배포 스크립트에 배포 원본(origin) URL 3개가 하드코딩되어 있어
|
||||
포크/미러/사설 Gitea 인스턴스 환경으로의 이식성이 저해됨이 확인되었습니다.
|
||||
|
||||
| # | 위치 | 변수 | 현재 하드코딩 값 |
|
||||
|---|------|------|------------------|
|
||||
| 1 | `deploy/install.sh:57` | `REPO_URL` | `https://git.godopu.com/tmpl/multi-agent-mux.git` |
|
||||
| 2 | `deploy/install.sh:58` | `ARCHIVE_URL` | `https://git.godopu.com/tmpl/multi-agent-mux/archive/main.tar.gz` |
|
||||
| 3 | `deploy/update.sh:138` | `INSTALLER_URL` | `https://git.godopu.com/tmpl/multi-agent-mux/raw/branch/main/deploy/install.sh` |
|
||||
|
||||
그 외 스크립트(`lib.sh`, `create_session.sh` 등)는 상대 경로 및 `TARGET_DIR` 파라미터화가
|
||||
올바르게 적용되어 있어 이번 변경 범위에서 제외합니다.
|
||||
|
||||
## 2. 목표 (Goals)
|
||||
|
||||
1. 위 3개 URL을 환경변수 `MAM_REPO_URL`, `MAM_ARCHIVE_URL`, `MAM_INSTALLER_URL`로
|
||||
오버라이드 가능하게 파라미터화하되, **미설정 시 현재 값을 그대로 기본값으로 유지**한다
|
||||
(기존 사용자에 대한 동작 변경 0).
|
||||
2. 세 변수를 `.env.example`과 `deploy/README.md`에 문서화한다.
|
||||
3. 검증 절차를 명문화하여 Developer가 DoD 자가 검증에 사용할 수 있게 한다.
|
||||
|
||||
### Non-Goals (이번 범위 제외)
|
||||
|
||||
- `README.md`/`BOOTSTRAP*.md` 본문의 원라이너 예시 URL 자체를 변수화하는 것
|
||||
(문서상의 예시는 실제 기본 배포 원본이므로 그대로 둔다).
|
||||
- deploy 스크립트가 `.env` 파일을 직접 파싱/소싱하도록 만드는 것 (§3.4 설계 결정 참조).
|
||||
- URL 간 파생 로직 (예: `MAM_REPO_URL`로부터 archive URL 자동 조립) — §3.5 참조.
|
||||
|
||||
## 3. 설계 (Design)
|
||||
|
||||
### 3.1. `deploy/install.sh` 수정
|
||||
|
||||
57–58행을 bash 기본값 확장 패턴으로 교체:
|
||||
|
||||
```bash
|
||||
REPO_URL="${MAM_REPO_URL:-https://git.godopu.com/tmpl/multi-agent-mux.git}"
|
||||
ARCHIVE_URL="${MAM_ARCHIVE_URL:-https://git.godopu.com/tmpl/multi-agent-mux/archive/main.tar.gz}"
|
||||
```
|
||||
|
||||
### 3.2. `deploy/update.sh` 수정
|
||||
|
||||
138행을 동일 패턴으로 교체:
|
||||
|
||||
```bash
|
||||
INSTALLER_URL="${MAM_INSTALLER_URL:-https://git.godopu.com/tmpl/multi-agent-mux/raw/branch/main/deploy/install.sh}"
|
||||
```
|
||||
|
||||
**체이닝 전파 주의**: `update.sh`는 140–142행에서 `curl ... | bash -s --`로 새 installer를
|
||||
실행한다. 호출자가 `MAM_REPO_URL=x bash update.sh` 형태(명령 접두 대입)로 실행하면 해당
|
||||
변수는 프로세스 환경에 export되어 자식 bash에 자동 상속되므로 별도 재-export 코드는
|
||||
불필요하다. 단, 이 상속 동작이 계약임을 스크립트 주석 및 문서에 명시한다.
|
||||
|
||||
### 3.3. `.env.example` 문서화
|
||||
|
||||
새 섹션 `# deploy / distribution source`를 추가하고 기존 파일의 서식 규약
|
||||
(`#default:` 라인 + 주석 처리된 변수 예시)을 따른다. **핵심 주의 문구**를 반드시 포함:
|
||||
|
||||
> 이 변수들은 deploy 스크립트가 **프로세스 환경에서** 읽는다. `install.sh`는 워크스페이스에
|
||||
> `.env`가 생기기 전(curl 원라이너) 실행될 수 있고 deploy 스크립트는 `.env`를 파싱하지
|
||||
> 않으므로, `export MAM_REPO_URL=...` 또는 명령 접두 대입으로 전달해야 한다.
|
||||
|
||||
### 3.4. 설계 결정: `.env` 소싱을 하지 않는 이유
|
||||
|
||||
- `install.sh`는 설치 대상 디렉터리에 파일이 존재하기 전에 실행되므로 `.env` 의존이 불가능.
|
||||
- `.env`를 `source`하면 임의 셸 코드 실행 경로가 생겨 보안·부작용 리스크 발생.
|
||||
- 따라서 세 변수 모두 **환경변수 단일 경로**로 통일하고, `.env.example`에는
|
||||
"문서화 + 사용법 안내" 목적으로만 등재한다.
|
||||
|
||||
### 3.5. 설계 결정: 변수 간 파생 없음
|
||||
|
||||
`MAM_REPO_URL`에서 archive/raw URL을 자동 조립하지 않는다. Gitea(`/archive/main.tar.gz`,
|
||||
`/raw/branch/main/`)와 GitHub(`/archive/refs/heads/main.tar.gz`, `raw.githubusercontent.com`)의
|
||||
URL 스킴이 상이하여 파생 로직이 오히려 이식성을 해친다. 세 변수는 독립이며, 미러 운영 시
|
||||
**셋을 함께 설정**하도록 문서에 권고 문구를 넣는다.
|
||||
|
||||
### 3.6. `deploy/README.md` 문서화
|
||||
|
||||
"How to Install and Deploy" 섹션에 미러/포크 설치 예시를 추가:
|
||||
|
||||
```bash
|
||||
# Installing from a fork/mirror
|
||||
curl -fsSL https://my-mirror.example.com/.../install.sh \
|
||||
| MAM_REPO_URL=https://my-mirror.example.com/me/multi-agent-mux.git \
|
||||
MAM_ARCHIVE_URL=https://my-mirror.example.com/me/multi-agent-mux/archive/main.tar.gz \
|
||||
bash
|
||||
|
||||
# Updating against a mirror
|
||||
MAM_INSTALLER_URL=https://my-mirror.example.com/.../install.sh bash deploy/update.sh
|
||||
```
|
||||
|
||||
(파이프 실행 시 접두 대입은 `bash` 쪽에 붙여야 함을 예시로 보여준다.)
|
||||
|
||||
## 4. 파급 범위 및 리스크 분석
|
||||
|
||||
| 리스크 | 평가 | 완화책 |
|
||||
|--------|------|--------|
|
||||
| 기본값 오타로 기존 설치 경로 파손 | 중 | 검증 §5-3에서 기본값 문자열이 변경 전과 byte-identical한지 diff/grep으로 확인 |
|
||||
| ShellCheck 경고 (SC2154 등) | 낮음 | `${VAR:-default}` 패턴은 미정의 변수 경고 없음. CI(`gitea-ci.yml`)의 shellcheck 단계로 확인 |
|
||||
| `update.sh` 체이닝 시 오버라이드 미전파 | 중 | §3.2 상속 계약 주석 명시 + 검증 §5-5 |
|
||||
| 문서-코드 정합성 (변수명 불일치) | 낮음 | task.md DoD에 변수명 3종 교차 대조 항목 포함 |
|
||||
|
||||
## 5. 검증 절차 (Verification Steps)
|
||||
|
||||
Developer는 커밋 전 아래를 순서대로 수행하고 결과를 리뷰 요청에 첨부한다.
|
||||
|
||||
1. **문법 검사**: `bash -n deploy/install.sh deploy/update.sh` — 종료코드 0.
|
||||
2. **린트**: `shellcheck deploy/install.sh deploy/update.sh` — 신규 경고 0 (CI와 동일 조건).
|
||||
3. **기본값 무결성**: `grep -n 'MAM_\(REPO\|ARCHIVE\|INSTALLER\)_URL' deploy/*.sh` 출력에서
|
||||
`:-` 뒤 기본값 3개가 변경 전 하드코딩 문자열과 정확히 일치하는지 확인.
|
||||
4. **오버라이드 동작 (install.sh)**: 빈 스크래치 디렉터리에서
|
||||
`MAM_ARCHIVE_URL=https://127.0.0.1:1/nope.tar.gz bash deploy/install.sh <scratch-dir>`
|
||||
실행 → fetch 단계가 오버라이드된 URL로 시도하다 실패하는지 확인
|
||||
(`bash -x` 트레이스에서 `ARCHIVE_URL` 해석값 확인). **실제 워크스페이스에서 실행 금지.**
|
||||
5. **오버라이드 동작 (update.sh)**: `update.sh`는 기존 설치를 파괴적으로 제거하므로
|
||||
전체 실행 대신 `bash -x` 트레이스를 138행 부근에서 조기 중단(Ctrl-C 또는 read 삽입 없이
|
||||
확인 후 `--force` 미사용)하거나, 디스포저블 스크래치 설치본에서만 end-to-end 수행.
|
||||
최소 기준: `MAM_INSTALLER_URL` 접두 대입 시 `INSTALLER_URL` 해석값이 오버라이드와 일치.
|
||||
6. **회귀 (기본 경로)**: 리포지토리 루트에서 `bash deploy/install.sh` 재실행 →
|
||||
`check_assets_present`가 충족되어 네트워크 fetch 없이 기존과 동일하게 완료되는지 확인.
|
||||
7. **문서 정합**: `.env.example`·`deploy/README.md`에 세 변수명이 스크립트와 철자까지
|
||||
일치하게 등재되었는지 교차 확인.
|
||||
|
||||
## 6. 산출물 및 커밋 규약
|
||||
|
||||
- 변경 파일: `deploy/install.sh`, `deploy/update.sh`, `.env.example`, `deploy/README.md`
|
||||
- 단일 원자적 커밋, 메시지 제안:
|
||||
`feat(deploy): parameterize distribution URLs via MAM_*_URL env vars`
|
||||
- 완료 후 Reviewer A(논리 정합) / Reviewer B(구현 세부) 이중 리뷰 → 양측 PASS 시 마감.
|
||||
@@ -0,0 +1,141 @@
|
||||
# Implementation Plan — 세션 ID 중복 충돌 해소 / 역할별 세션 격리 (Rev.3)
|
||||
|
||||
- **작성자**: Planner Agent
|
||||
- **날짜**: 2026-07-10 (Rev.2 → Rev.3 개정)
|
||||
- **상태**: Draft (Phase 0 검증 게이트 대기 — 구현 미착수)
|
||||
- **관련 자료**: [Problem_Definition.md](Problem_Definition.md), [session_isolation_discussion.md](session_isolation_discussion.md)
|
||||
- **확정된 방향 (Rev.3)**: **all-L2 단일화** — 전 에이전트 isolation-UUID 격리 디렉터리 + 세션 row 영속화 + R1 claimed-set 불변식
|
||||
|
||||
---
|
||||
|
||||
## 0. Rev.2 → Rev.3 변경 이력 (Decision Log)
|
||||
|
||||
| # | 결정 | 사유 |
|
||||
|---|------|------|
|
||||
| D1 | **L1(생성-시 대화 ID 인자 주입) 전략 폐기** | 관리 모델 통일 우선. claude `--session-id`는 실측 확정된 레버지만, agent별 L1/L2 분기 유지 비용보다 단일 격리 메커니즘의 일관성을 우선함 (트레이드오프 인지 후 사용자 확정) |
|
||||
| D2 | **격리 식별자 = MAM 발급 isolation-UUID** (CLI 대화 ID와 분리) | cline 등 비-UUID 자체 ID 포맷 문제 원천 소멸 — CLI가 자기 방식대로 대화 ID를 mint하되 **자기만의 격리 디렉터리 안에서** 하게 함 |
|
||||
| D3 | **격리 정보를 세션 row(`isolation` 블록)로 영속화** | resume/resolve/stop이 단일 디스패치로 재적용 → 주입/해석 불일치(RK2) 차단 |
|
||||
| D4 | **claude 격리 레버 = `CLAUDE_CONFIG_DIR` + auth 시딩** | 실측: `~/.claude/` 한 지붕 아래 `.credentials.json`+`settings`+`plugins`+`projects/` 동거 확인. 대화만 옮기는 좁은 레버 부재 → 지붕 이동 + 시딩 계약 필수. ⚠️ 기존 문서의 `CLAUDE_PROJECT_DIR`은 **MAM resolver 읽기 전용 변수**(lib.sh:23,477)로 CLI **쓰기** 위치를 바꾸지 못함 — Rev.2의 해당 서술 정정 |
|
||||
| D5 | **cline 격리 레버 = `--data-dir` + `--config` 분리 조합** | 실측: `cline --data-dir <path>`("Use isolated local state", default `~/.cline`) + `--config <path>`(설정/auth, default `~/.cline/data/settings`) 별도 플래그 확인 → 대화만 격리·auth 공유 가능, 시딩 불필요 예상 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 배경 및 문제 정의
|
||||
|
||||
동일 CLI 계열(Claude/Cline)의 서로 다른 역할 에이전트가 같은 workspace에서 동시 구동될 때
|
||||
**동일한 대화 세션 ID를 공유**하는 결함 (`Problem_Definition.md`).
|
||||
|
||||
### 1.1 근본 원인 (코드 근거) 및 all-L2가 끊는 방식
|
||||
|
||||
| # | 원인 | 근거 | all-L2 해소 기전 |
|
||||
|---|------|------|------------------|
|
||||
| C1 | spawn 시 세션 ID 미지정 → CLI "cwd 최근 대화 상속" | `create_session.sh:125/:135` | 신규 격리 디렉터리엔 상속할 최근 대화가 **없음** → 각자 fresh 시작 |
|
||||
| C2 | 저장소가 workspace(cwd) 단위 키잉, role 차원 없음 | `lib.sh:480`, `:615` | 격리 디렉터리가 **UUID 네이밍** → cwd 파생 key 충돌 원천 소멸 |
|
||||
| C3 | resolver Tier-2가 mtime 최신 파일 반환 → 동일 UUID 해석 | `lib.sh:564` | 격리 디렉터리 안엔 대화가 **하나뿐** → disk-scan 항상 유일 후보, mtime 경합 소멸 |
|
||||
|
||||
### 1.2 불변 전제 (변경 금지)
|
||||
|
||||
- **cwd 공유**: 모든 에이전트는 `-c "$WORKSPACE"`로 동일 workspace에서 기동 (협업 전제). 격리는 **대화 상태 저장소의 위치만** env/플래그로 옮기며 cwd는 절대 건드리지 않음.
|
||||
- CLI(claude/cline/agy/hermes) 자체 미수정 — 인자/환경변수 인터페이스만 사용 (Non-Goal).
|
||||
- 기존 단일-에이전트 워크플로우 회귀 0.
|
||||
|
||||
---
|
||||
|
||||
## 2. 설계 (all-L2 단일 격리 메커니즘)
|
||||
|
||||
### 2.1 격리 디렉터리
|
||||
|
||||
- 생성 시 `uuidgen`으로 **isolation-UUID** 발급.
|
||||
- 격리 루트: `<workspace>/.mam/agent_homes/<isolation-uuid>/`
|
||||
- `.mam/` 하위 → `.gitignore:11` 자동 커버, `remove.sh`/stop 청소 계약에 자연 포함.
|
||||
|
||||
### 2.2 세션 row 스키마 확장 (`isolation` 블록)
|
||||
|
||||
```yaml
|
||||
tmux_sessions:
|
||||
- name: <session_name>
|
||||
role: <role>
|
||||
isolation:
|
||||
uuid: <isolation-uuid>
|
||||
root: .mam/agent_homes/<isolation-uuid>
|
||||
lever: claude_config_dir | cline_data_dir | home | <agy/hermes 프로브 결과>
|
||||
seeded: [".credentials.json", "settings.json", "plugins"] # 심링크 목록 (해당 시)
|
||||
```
|
||||
|
||||
- `atomic_dump_yaml` 경유로 DB+YAML 동시 기록 (P1 상시 미러 수정본 전제).
|
||||
- resume/resolve/stop은 이 블록만 읽는 **단일 디스패치 함수**로 재적용 — agent별 레버 차이는 이 함수 내부에 캡슐화.
|
||||
|
||||
### 2.3 agent별 레버 매핑 — ✅ Phase 0 실측 확정 매트릭스 (2026-07-10)
|
||||
|
||||
| Agent | Spawn 레버 | 시딩 목록 (전부 **심링크**) | 격리 내 대화 저장 실경로 | 실증 |
|
||||
|---|---|---|---|---|
|
||||
| claude | `CLAUDE_CONFIG_DIR=<root>` env | `.credentials.json`, `settings.json`, `plugins/` | `<root>/projects/<key>/<uuid>.jsonl` | ✅ 실호출 — jsonl 격리 생성, 로그인 유지, 실HOME 무변화(27→27) |
|
||||
| cline | `--data-dir <root>` 플래그 | `settings/*`(providers.json 등 5종), `globalState.json` | `<root>/sessions/<id>/<id>.json` ⚠️ | ✅ 실호출 — 세션 격리 생성, 실HOME 무변화(13→13) |
|
||||
| agy | `HOME=<root>` env | `~/.gemini/{oauth_creds.json, google_accounts.json, installation_id, settings.json, state.json}` + `antigravity-cli/{antigravity-oauth-token, installation_id, settings.json}` | `<root>/.gemini/antigravity-cli/conversations/<uuid>.db` | ✅ auth 검증(`agy models` — 시딩 전 실패/후 성공), fresh conversations/ 확인 |
|
||||
| hermes | `HOME=<root>` env | `~/.hermes/{auth.json, config.yaml, .env}` | `<root>/.hermes/state.db` (sessions 테이블) | ✅ 읽기 격리 검증 — 격리 HOME "No sessions found" + fresh state.db, 실HOME 세션 비노출 |
|
||||
|
||||
**Phase 0 실측 정정·주의사항**:
|
||||
1. ⚠️ **cline `--config` 가정 반증**: `--config ~/.cline/data/settings` 공유 지정만으로는 auth가 공유되지 않음(`Unauthorized`) — cline이 data-dir 안에 자체 settings를 생성. → **cline도 시딩 필수** (Rev.3 본문 "시딩 불필요 예상" 정정).
|
||||
2. ⚠️ **cline 격리 레이아웃 상이**: 격리 시 `<root>/sessions/`(실HOME은 `~/.cline/data/sessions/`) — resolver 재적용(T5)에서 **lever별 경로 템플릿 분기** 필요.
|
||||
3. hermes `config.yaml`에 실HOME 절대경로(런타임 `hermes-agent`) 내장 — 런타임은 읽기 공유라 무해하나, 격리 범위가 state/세션에 한정됨을 기록.
|
||||
4. agy는 첫 실행 시 격리 HOME에 디렉터리 구조를 자동 부트스트랩(fresh `conversations/` 포함).
|
||||
|
||||
- **resolver 연동**: 디스패치 함수가 row의 `isolation`을 읽어 `HOME_DIR`/`CLAUDE_PROJECT_DIR`(MAM 읽기 변수, `lib.sh:22-23`)를 격리 루트 기준으로 export한 뒤 `find_workspace_uuid`/resume 호출.
|
||||
|
||||
### 2.4 공통 불변식 (전략 무관 유지)
|
||||
|
||||
- **R1. claimed-set 필터**: Tier-2 반환 전, 같은 workspace의 다른 running row 소유 `*_own` 집합 제외 (`lib.sh:540-575`). 격리가 부분 실패해도 이중 배정 구조적 차단.
|
||||
- **R2. 생성-시 유일성 assert**: 새 `*_own`이 기존 running `*_own`과 충돌 시 `SystemExit` (`lib.sh:377` 근처).
|
||||
|
||||
---
|
||||
|
||||
## 3. 단계별 태스크
|
||||
|
||||
### Phase 0 — 검증 게이트 (**구현 전 필수·차단**, Rev.3 재조준: 구 G1 삭제)
|
||||
|
||||
- **G2-claude**: `CLAUDE_CONFIG_DIR=<격리경로>` 기동 시 (a) 대화 jsonl이 `<격리경로>/projects/<key>/`에 생성되는가 (b) `.credentials.json` **심링크만으로 로그인 유지**되는가 (c) settings/plugins 심링크로 행동 드리프트 없는가.
|
||||
- **G2-cline**: `--data-dir <격리경로> --config ~/.cline/data/settings` 기동 시 (a) 세션이 `<격리경로>/data/sessions/`(또는 상응 경로)에 격리 생성되는가 (b) auth/providers가 공유 config에서 정상 동작하는가.
|
||||
- **G2-agy**: `--new-project` per-role 부여 시 대화 격리 여부, 또는 데이터 경로 env 존재 여부. 부재 시 `HOME` 오버라이드+auth 시딩 유효성.
|
||||
- **G2-hermes**: home 이동 env/플래그 실측. 부재 시 `HOME` 오버라이드+auth 시딩 유효성.
|
||||
- **DoD**: `{agent × (레버, 시딩 목록, 대화 저장 실경로)}` 매트릭스 확정 → §2.3 갱신.
|
||||
|
||||
### Phase 1 — 공통 불변식 (Phase 0과 병렬 착수 가능)
|
||||
|
||||
- **T1. claimed-set 필터** / **T2. 생성-시 유일성 assert** (§2.4).
|
||||
- **DoD**: 동일 ID 강제 주입 2개 create → 두 번째 거부 (단위 재현).
|
||||
|
||||
### Phase 2 — all-L2 격리 구현 (구 Phase 2/3 통합)
|
||||
|
||||
- **T3. 격리 디렉터리 프로비저닝**: create 시 isolation-UUID 발급 → `.mam/agent_homes/<uuid>/` 생성 → agent별 시딩(§2.3, 심링크) 수행.
|
||||
- **T4. spawn 주입**: 디스패치 함수가 agent별 레버(env/플래그)로 격리 루트 주입.
|
||||
- **T5. 스키마 영속화 + 재적용**: `isolation` 블록 atomic_dump 기록, resume/resolve가 동일 디스패치로 재소싱 (**저장+재적용은 원자적 세트** — RK2).
|
||||
- **T6. stop 청소 계약**: `stop_session.sh`가 `isolation.root`를 퍼지(`rm -rf`), 심링크 대상 원본은 보존 확인.
|
||||
- **DoD**: 동일 workspace, 같은 CLI 2개(다른 role) 동시 생성 → 대화 파일 물리 분리, `*_own` 상이, 교차 write 0.
|
||||
|
||||
### Phase 3 — 통합 및 회귀 검증
|
||||
|
||||
- **V1**: planner/reviewer-a(claude) + developer/reviewer-b(cline) 4개 동시 기동 → 4개 대화 ID 전부 유일.
|
||||
- **V2**: 동시 write 락 충돌·컨텍스트 오염 재현 불가.
|
||||
- **V3**: 각 role resume이 자기 대화만 복원 (isolation 재적용 경유).
|
||||
- **V4**: 단일-에이전트 기존 워크플로우 회귀 0 (격리 미사용 경로 불변).
|
||||
- **V5**: stop 후 격리 저장소 잔존 0 + 실HOME auth/설정 원본 무손상.
|
||||
|
||||
---
|
||||
|
||||
## 4. 리스크 및 검수 포인트 (Rev.3 갱신)
|
||||
|
||||
| ID | 리스크 | 완화 |
|
||||
|----|--------|------|
|
||||
| RK1 | 격리 실패/부분 적용 시 이중 배정 | R1 claimed-set 필터가 최후 방어선 (무조건 유지) |
|
||||
| RK2 | spawn 주입 경로와 resolve/resume 스캔 경로 불일치 → 전역 회귀 | T5 "저장+재적용" 원자적 세트, 단일 디스패치 함수 강제 |
|
||||
| RK3 | **시딩 드리프트**: CLI 업데이트로 신규 파일 등장 시 심링크 목록 누락 → 행동 이상 | `seeded` 목록을 row에 기록, Phase 0 매트릭스에 파일 목록 명세, 온보딩 문서화 |
|
||||
| RK4 | 토큰 갱신 발산 (복사 시딩 시) | **심링크 강제** — 갱신이 원본 단일 파일에 수렴 (현행 다중 인스턴스 동작과 동일) |
|
||||
| RK5 | 비정상 종료 시 격리 디렉터리 누수 | `.mam/` 하위 배치로 remove.sh 전체 청소 커버 + T6 stop 퍼지 + (선택) create 시 고아 `agent_homes/*` GC |
|
||||
| RK6 | 단일-에이전트 기존 사용자 회귀 | 격리 발동을 명시 플래그/다중성 조건으로 제어 (`--isolate` 등, Phase 0 후 확정), V4 회귀 게이트 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 승인 및 다음 단계
|
||||
|
||||
- 본 계획(Rev.3)은 **Phase 0 게이트 통과 전 코드 구현 착수 금지**를 대전제로 유지한다.
|
||||
- 다음 액션: **Phase 0 (G2-claude / G2-cline / G2-agy / G2-hermes) 프로브 실행** — 스크래치 워크스페이스에서 실측 후 §2.3 매트릭스 확정.
|
||||
@@ -0,0 +1,132 @@
|
||||
# 🛠️ 세션 ID 중복 충돌 해결 종합 설계 및 구현 계획서 (Rev.3 — 단일 격리 디렉터리 통합본)
|
||||
|
||||
동일 CLI 계열(Claude / Cline)의 서로 다른 역할 에이전트가 같은 workspace에서 동시 구동될 때 대화 세션 ID를 공유하는 결함([Problem_Definition.md](Problem_Definition.md))을 해결하기 위한 최종 종합 설계안 및 구현 계획입니다.
|
||||
|
||||
기존의 하이브리드 분기(L1/L2 병행) 구조를 걷어내고, **"모든 에이전트의 격리 디렉터리 관리 일원화"**라는 사용자(GM) 피드백을 반영하여 설계를 단순화한 버전입니다. Rev.3에서는 **실측 프로브 결과에 따른 격리 레버 정정 및 auth 시딩 계약**(사용자 승인 조건)을 반영했습니다. 상세 계획의 단일 원본은 [implementation_plan.session_isolation.md](implementation_plan.session_isolation.md) (Rev.3)입니다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 배경 및 문제 정의
|
||||
|
||||
동일한 워크스페이스에서 다중 역할 에이전트(예: `planner`와 `reviewer-a`, `developer`와 `reviewer-b`)가 기동될 때, 동일한 대화 UUID 또는 Cline 대화 ID를 공유함으로써 컨텍스트가 오염되고, 히스토리 쓰기 경합으로 인한 락 충돌 및 데이터 유실이 발생하는 문제가 발생했습니다.
|
||||
|
||||
### 1.1 근본 원인 분석
|
||||
* **C1. 생성 시 고유 ID 미지정**: 세션 생성 시점에 특정 세션 ID 인자를 지정하지 않아 CLI 기본 동작인 "최근 대화 상속"이 실행됨.
|
||||
* **C2. 워크스페이스 단위 키잉**: 대화 세션 매핑이 역할(role) 차원 없이 단순히 `workspace_root` 경로만을 키로 삼아 이뤄짐.
|
||||
* **C3. mtime 기반 추측 바인딩 (Tier-2)**: resolver가 워크스페이스 내에서 최종 수정 시간(mtime)이 가장 최신인 세션을 바인딩하여, 다른 역할의 에이전트가 동일한 UUID를 상속받게 됨.
|
||||
|
||||
---
|
||||
|
||||
## 2. 개정된 설계 방향: "단일 격리 디렉터리" 일원화 (Rev.3 정정 반영)
|
||||
|
||||
각 에이전트의 ID 파라미터 지원 여부에 따라 구현을 분기하는 대신, **모든 에이전트에 대해 균일하게 격리 디렉터리 주입 방식으로 일원화**하여 아키텍처의 복잡도를 제거합니다.
|
||||
|
||||
> ⚠️ **Rev.3 정정 (실측 근거)**: 이전 판의 "Claude = `CLAUDE_PROJECT_DIR` 격리"는 **동작하지 않는 설계**였습니다. `CLAUDE_PROJECT_DIR`은 MAM resolver의 **읽기 전용** 변수(`lib.sh:23,477`)로, claude CLI가 대화를 **쓰는** 위치를 바꾸지 못합니다. claude의 쓰기 위치 이동은 `CLAUDE_CONFIG_DIR`(`~/.claude` 지붕 전체 이동)로만 가능하며, 이 경우 auth/설정 시딩이 필수입니다(§2.3). 또한 cline은 실측 결과 per-session `HOME`이 아닌 **전용 `--data-dir`/`--config` 분리 플래그**를 보유해 시딩 없이 격리 가능합니다.
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[세션 생성] --> U[uuidgen: isolation-UUID 발급]
|
||||
U --> B[.mam/agent_homes/<uuid>/ 생성]
|
||||
B -->|Claude| B1[CLAUDE_CONFIG_DIR 주입 + auth/설정 심링크 시딩]
|
||||
B -->|Cline| B2[--data-dir 격리 + --config 공유 auth]
|
||||
B -->|Agy / Hermes| B3[Phase 0 프로브: 전용 레버 or HOME+시딩 폴백]
|
||||
A --> C[Defense-in-depth: R1/R2 resolver 불변식]
|
||||
B1 & B2 & B3 --> E[isolation 블록 세션 row 영속화 → resume/resolve/stop 재적용]
|
||||
```
|
||||
|
||||
### 2.1 일원화 설계의 핵심
|
||||
* **격리 식별자 ≠ 대화 식별자**: MAM이 `uuidgen`으로 발급하는 **isolation-UUID는 디렉터리 식별자**입니다. CLI는 자기 방식대로 대화 ID를 mint하되(claude UUID, cline `epoch_rand` 등) **자기만의 격리 디렉터리 안에서** 하게 됩니다 — cline의 비-UUID 포맷 문제가 원천 소멸합니다.
|
||||
* **작업 디렉터리(Cwd) 공유**: 에이전트들의 작업 디렉터리는 기존과 동일하게 같은 프로젝트 루트를 바라봅니다(`-c "$WORKSPACE"` 불변). 협업 소스코드 컨텍스트는 완벽히 일치하며, 격리는 **대화 상태 저장소의 위치만** 이동합니다.
|
||||
* **격리 루트**: `<workspace>/.mam/agent_homes/<isolation-uuid>/` — `.gitignore`(`.mam/`) 자동 커버, remove/stop 청소 계약에 자연 포함.
|
||||
* **스키마 영속화**: 세션 row에 `isolation: {uuid, root, lever, seeded[]}` 블록을 `atomic_dump_yaml`로 영속화(DB+YAML). resume/resolve/stop은 이 블록만 읽는 **단일 디스패치 함수**를 경유 — agent별 레버 차이는 함수 내부에 캡슐화되어 관리 모델은 완전 통일됩니다.
|
||||
* **근본 원인 해소 기전**: 신규 격리 디렉터리엔 상속할 최근 대화가 없음(C1 무해화) / UUID 네이밍으로 cwd-key 충돌 소멸(C2) / 디렉터리당 대화 1개 → resolver 항상 유일 후보(C3 소멸).
|
||||
|
||||
### 2.2 agent별 격리 레버 매핑 — ✅ Phase 0 실측 확정 (2026-07-10)
|
||||
|
||||
| Agent | Spawn 레버 | 시딩 (전부 **심링크**) | 격리 내 대화 경로 | 실증 |
|
||||
|---|---|---|---|---|
|
||||
| claude | `CLAUDE_CONFIG_DIR=<root>` env | `.credentials.json`, `settings.json`, `plugins/` | `<root>/projects/<key>/<uuid>.jsonl` | ✅ 실호출 PASS |
|
||||
| cline | `--data-dir <root>` 플래그 | `settings/*` + `globalState.json` (⚠️ `--config` 공유만으론 auth 미공유 — 실측 반증, 시딩 필수) | `<root>/sessions/<id>/<id>.json` (⚠️ 실HOME `data/sessions/`와 레이아웃 상이) | ✅ 실호출 PASS |
|
||||
| agy | `HOME=<root>` env | `~/.gemini/` auth 3종(`oauth_creds.json`, `google_accounts.json`, `antigravity-oauth-token`) + 메타(`installation_id`/`settings.json`/`state.json`) | `<root>/.gemini/antigravity-cli/conversations/<uuid>.db` | ✅ auth 검증 PASS (시딩 전 실패→후 성공) |
|
||||
| hermes | `HOME=<root>` env | `~/.hermes/{auth.json, config.yaml, .env}` | `<root>/.hermes/state.db` | ✅ 읽기 격리 PASS ("No sessions found" + fresh state.db) |
|
||||
|
||||
### 2.3 시딩 계약 (claude 및 HOME 폴백 agent) — 사용자 승인 조건
|
||||
* **심링크 강제, 복사 금지**: 복사 시 토큰 갱신이 격리 사본으로 발산해 원본과 어긋남. 심링크는 갱신이 원본 단일 파일에 수렴(현행 다중 인스턴스 동작과 동일 의미론).
|
||||
* claude 시딩 목록(초안): `.credentials.json`, `settings.json`, `plugins/` — Phase 0에서 최종 확정, row의 `seeded[]`에 기록.
|
||||
* stop 퍼지 시 **심링크 원본 무손상** 검증 포함.
|
||||
|
||||
### 2.4 R1/R2 resolver 불변식 (2중 안전 장치)
|
||||
* **R1**: resolver(`find_workspace_uuid` 등)가 최신 파일을 스캔해오기 전, 현재 실행 중인 다른 세션들이 소유한 `*_own` 대화 ID 집합을 후보군에서 제외(claimed-set filtering)합니다.
|
||||
* **R2**: `atomic_dump` 시점에 새로 등록하려는 세션 ID가 이미 실행 중인 다른 세션의 ID와 중복될 경우 `SystemExit` 에러로 강제 진입 차단합니다.
|
||||
|
||||
### 2.5 정리(Cleanup) 계약 (RC-2)
|
||||
* 세션 정지(`stop_session.sh`) 시 `isolation.root`를 일괄 청소(`rm -rf`)하는 단순·명확한 클린업 규칙. `.mam/` 하위 배치로 `remove.sh` 전체 청소도 자동 커버.
|
||||
|
||||
### 2.6 Non-Goals
|
||||
* 에이전트 CLI(`claude`, `cline`, `agy`, `hermes`) 자체 바이너리/코드를 수정하지 않습니다. 구동 시 외부 환경변수·플래그 주입 인터페이스만 사용합니다.
|
||||
* 기존 단일 에이전트 동작 구조의 하위 호환성은 완벽하게 보존합니다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 단계별 구현 계획 (Roadmap)
|
||||
|
||||
**대전제: Phase 0 검증 게이트 통과 전에는 격리 주입(Phase 2 이후) 코드 구현에 착수하지 않습니다.**
|
||||
|
||||
```
|
||||
Phase 0 (격리 레버 실측 및 검증 — G2 재조준)
|
||||
▼
|
||||
Phase 1 (R1/R2 공통 불변식 필터 — Phase 0 산출물 비의존, 병렬 선행 가능)
|
||||
▼
|
||||
Phase 2 (격리 프로비저닝·시딩·주입 및 isolation 스키마 영속화 구현)
|
||||
▼
|
||||
Phase 3 (stop_session.sh 클린업 계약 연동 구현)
|
||||
▼
|
||||
Phase 4 (통합 및 회귀 검증)
|
||||
```
|
||||
|
||||
### Phase 0 — 격리 레버 실측 (구현 전 필수 실측)
|
||||
* **G2-claude**: `CLAUDE_CONFIG_DIR=<격리경로>` 기동 시 (a) 대화 jsonl이 `<격리경로>/projects/<key>/`에 생성 (b) `.credentials.json` **심링크만으로 로그인 유지** (c) settings/plugins 심링크로 행동 드리프트 없음.
|
||||
* **G2-cline**: `--data-dir <격리경로> --config ~/.cline/data/settings` 기동 시 (a) 세션이 격리경로에 생성 (b) 공유 auth 정상 동작.
|
||||
* **G2-agy / G2-hermes**: 전용 레버(project/home env) 실측, 부재 시 `HOME` 오버라이드+auth 시딩 유효성.
|
||||
* **DoD**: `{agent × (레버, 시딩 목록, 대화 저장 실경로)}` 매트릭스 확정 → §2.2 갱신.
|
||||
|
||||
### Phase 1 — 공통 불변식 구현 (전략 무관, 선행 가능)
|
||||
* *의존성 참고*: Phase 1은 격리 프로브 결과에 의존하지 않고 공통 `lib.sh`에만 적용되므로, **Phase 0 완료 여부와 무관하게 병렬로 또는 선제적으로 구현할 수 있습니다.**
|
||||
* **T1. claimed-set 필터 구현**: `lib.sh` (`find_workspace_uuid` 계열) 탐색 로직 수정. 후보군 중 다른 실행 중인 row의 `*_own`을 필터링 아웃.
|
||||
* **T2. 생성-시 중복 assertion**: 세션 등록/덤프 로직 부근에서 중복 assert — 중복 ID 충돌 시 즉시 거부.
|
||||
* **DoD**: 동일 대화 ID 강제 주입으로 세션 2개 생성 시도 시, 두 번째 생성 요청이 거부됨을 증명.
|
||||
|
||||
### Phase 2 — 격리 프로비저닝 및 영속화 구현 (전체 에이전트 적용)
|
||||
* **T3. 격리 프로비저닝**: create 시 isolation-UUID 발급 → `.mam/agent_homes/<uuid>/` 생성 → agent별 심링크 시딩(§2.3).
|
||||
* **T4. spawn 주입**: 디스패치 함수가 agent별 레버(§2.2: `CLAUDE_CONFIG_DIR` / `--data-dir` / 프로브 결과)로 격리 루트 주입.
|
||||
* **T5. 스키마 영속화**: `isolation` 블록을 row에 저장하고 resume/resolve 시 재소싱 적용 — **저장+재적용은 원자적 세트**.
|
||||
* **DoD**: Claude / Cline 세션 각각 2개 동시 생성 시 대화 파일 물리 분리·캐시 비공유, resume 시 올바른 복원 확인.
|
||||
|
||||
### Phase 3 — stop 정리 계약 확장
|
||||
* **T6. stop 정리 계약 확장**: `stop_session.sh`가 `isolation.root`를 자동 퍼지(`rm -rf`), **심링크 원본 무손상 검증** 포함.
|
||||
* **DoD**: stop 후 격리 디렉터리 잔존 0, 실HOME auth/설정 원본 무손상, 디스크 누수 없음.
|
||||
|
||||
### Phase 4 — 통합 및 회귀 검증
|
||||
* **V1. 다중 기동 테스트**: planner(claude), reviewer-a(claude), developer(cline), reviewer-b(cline) 4개 동시 기동 시 4개 세션 ID 모두 유일성 확보 검증.
|
||||
* **V2. 동시 쓰기 경합**: 4개 에이전트 동시 동작 시 락 경합/데이터 유실 현상 재현 불가 확인.
|
||||
* **V3. 단일 세션 회귀 검증**: 격리 정책 적용 후 기존 단일 에이전트 구동 환경에서 문제없이 정상 동작함을 검증.
|
||||
|
||||
---
|
||||
|
||||
## 4. 리스크 및 검수 포인트 (Reviewer 관점, Rev.3 갱신)
|
||||
|
||||
| ID | 리스크 | 완화책 |
|
||||
|----|--------|------|
|
||||
| **RK1** | 격리 경로 주입 후 resolve/resume 시 전역 기본 경로 스캔으로 회귀 | `isolation` 블록 저장과 resume 시 재적용을 원자적 세트로 묶고 단일 디스패치 함수 강제 |
|
||||
| **RK2** | **시딩 드리프트**: CLI 업데이트로 신규 파일 등장 시 심링크 목록 누락 → 격리 인스턴스 행동 이상 | `seeded[]`를 row에 기록, Phase 0 매트릭스에 시딩 파일 목록 명세 |
|
||||
| **RK3** | **토큰 갱신 발산**: auth 파일을 복사 시딩하면 격리 사본의 토큰 갱신이 원본과 어긋남 | **심링크 강제** — 갱신이 원본 단일 파일에 수렴 |
|
||||
| **RK4** | 비정상 종료 시 격리 디렉터리 미정리로 인한 디스크 누수 | `.mam/` 하위 배치(remove.sh 커버) + stop 퍼지 + 고아 `agent_homes/*` GC(선택) |
|
||||
| **RK5** | 단일 에이전트 워크플로우 기존 사용자의 하위 호환성 회귀 | 격리 활성화를 세션 다중성 조건 혹은 명시적 플래그(`--isolate-strict`)로 제어, V3 회귀 게이트 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 의사결정 및 다음 단계
|
||||
|
||||
* ✅ 모든 에이전트를 격리 디렉터리 방식으로 일원화하는 방향 최종 승인 (트레이드오프 — claude `--session-id` 실측 확정 레버의 미채택 — 인지 후 결정).
|
||||
* ✅ 승인 조건 반영: (1) Phase 0에 claude `CLAUDE_CONFIG_DIR`+심링크 로그인 유지 프로브 포함, (2) 시딩 계약(§2.3) 명문화.
|
||||
* ⏭️ 승인에 따라 **Phase 0 (격리 레버 실측)** 및 **Phase 1 (공통 불변식 필터)** 태스크에 착수합니다. Phase 2 이후 구현은 Phase 0 매트릭스 확정 후 진행합니다.
|
||||
@@ -0,0 +1,40 @@
|
||||
# Task Checklist — 배포 스크립트 URL 파라미터화 (Rev.1)
|
||||
|
||||
> 기준 문서: [implementation_plan.md](implementation_plan.md) (Rev.1)
|
||||
> 담당: Developer Agent | 순서대로 수행하며 완료 시 `[x]` 갱신
|
||||
|
||||
## Phase 1 — 코드 수정
|
||||
|
||||
- [ ] **T1. `deploy/install.sh` 파라미터화** (§3.1)
|
||||
- 57행: `REPO_URL="${MAM_REPO_URL:-https://git.godopu.com/tmpl/multi-agent-mux.git}"`
|
||||
- 58행: `ARCHIVE_URL="${MAM_ARCHIVE_URL:-https://git.godopu.com/tmpl/multi-agent-mux/archive/main.tar.gz}"`
|
||||
- [ ] **T2. `deploy/update.sh` 파라미터화** (§3.2)
|
||||
- 138행: `INSTALLER_URL="${MAM_INSTALLER_URL:-https://git.godopu.com/tmpl/multi-agent-mux/raw/branch/main/deploy/install.sh}"`
|
||||
- 체이닝 상속 계약(접두 대입 → 자식 bash 상속) 주석 1줄 추가
|
||||
|
||||
## Phase 2 — 문서화
|
||||
|
||||
- [ ] **T3. `.env.example`에 `# deploy / distribution source` 섹션 추가** (§3.3)
|
||||
- `MAM_REPO_URL` / `MAM_ARCHIVE_URL` / `MAM_INSTALLER_URL` 3종, 기존 `#default:` 서식 준수
|
||||
- "deploy 스크립트는 `.env`를 파싱하지 않음 — export/접두 대입으로 전달" 주의 문구 포함
|
||||
- 미러 운영 시 3변수 동시 설정 권고 문구 포함 (§3.5)
|
||||
- [ ] **T4. `deploy/README.md`에 미러/포크 설치·업데이트 예시 추가** (§3.6)
|
||||
- 파이프 실행 시 접두 대입을 `bash` 쪽에 붙이는 예시 포함
|
||||
|
||||
## Phase 3 — 검증 (plan §5, DoD)
|
||||
|
||||
- [ ] **V1.** `bash -n deploy/install.sh deploy/update.sh` 통과
|
||||
- [ ] **V2.** `shellcheck deploy/install.sh deploy/update.sh` 신규 경고 0
|
||||
- [ ] **V3.** 기본값 3개가 변경 전 하드코딩 문자열과 byte-identical (grep 대조)
|
||||
- [ ] **V4.** 스크래치 디렉터리에서 `MAM_ARCHIVE_URL` 오버라이드가 fetch에 반영됨 확인
|
||||
- [ ] **V5.** `MAM_INSTALLER_URL` 오버라이드 해석값 확인 (실 워크스페이스 파괴적 실행 금지)
|
||||
- [ ] **V6.** 리포 루트 재실행 회귀: fetch 생략 경로 정상 동작
|
||||
- [ ] **V7.** 문서(.env.example / deploy/README.md)-코드 간 변수명 철자 교차 대조
|
||||
|
||||
## Phase 4 — 마감
|
||||
|
||||
- [ ] **T5.** 단일 원자적 커밋: `feat(deploy): parameterize distribution URLs via MAM_*_URL env vars`
|
||||
- [ ] **T6.** Reviewer A / Reviewer B에 변경 범위 통지 및 이중 리뷰 요청
|
||||
- [ ] **T7.** 양측 PASS 확인 후 세션 종료 (`multi-agent-mux-stop`) / NOT PASS 시 Planner로 환류
|
||||
|
||||
agy --conversation=20cc2d8e-49f1-4a83-96b5-c49d320b42af
|
||||
@@ -0,0 +1,66 @@
|
||||
# Task Checklist — 세션 ID 격리 (Rev.3)
|
||||
|
||||
> 기준 문서: [implementation_plan.session_isolation.md](implementation_plan.session_isolation.md) (Rev.3) / [session_isolation_discussion.md](session_isolation_discussion.md)
|
||||
> 담당: Developer Agent | 순서대로 수행하며 완료 시 `[x]` 갱신
|
||||
> **대전제: Phase 0 매트릭스 확정 전에는 Phase 2 이후 구현 착수 금지. Phase 1은 병렬 선행 가능.**
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — 격리 레버 실측 (G2 프로브, 차단 게이트)
|
||||
|
||||
> 모든 프로브는 스크래치 워크스페이스(예: `/tmp/mam-probe-*`)에서 수행. 실 워크스페이스/실 세션 오염 금지.
|
||||
|
||||
- [x] **G2-C1. claude `CLAUDE_CONFIG_DIR` 쓰기 격리 실증**
|
||||
- 격리 디렉터리 생성 → `.credentials.json`/`settings.json`/`plugins/` **심링크** 시딩
|
||||
- `CLAUDE_CONFIG_DIR=<격리경로> claude --dangerously-skip-permissions`로 기동, 1메시지 송신
|
||||
- 확인: (a) 대화 jsonl이 `<격리경로>/projects/<key>/`에 생성 (b) 로그인 유지 (c) `~/.claude/projects/`에는 미생성
|
||||
- [x] **G2-C2. claude 행동 드리프트 점검**: 심링크 시딩 상태에서 settings/plugins 정상 로드 확인, 추가 시딩 필요 파일 발견 시 목록에 추가
|
||||
- [x] **G2-L1. cline `--data-dir` 격리 실증**
|
||||
- `cline -i --data-dir <격리경로> --config ~/.cline/data/settings` 기동, 1메시지 송신
|
||||
- 확인: (a) 세션이 `<격리경로>` 하위에 생성 (b) auth/providers 공유 config로 정상 동작 (c) `~/.cline/data/sessions/`에는 미생성
|
||||
- [x] **G2-A1. agy 격리 레버 실측**: `--new-project`/`--project` per-role 부여 시 대화 격리 여부, 데이터 경로 env 유무 → 부재 시 `HOME=<격리경로>`+시딩 유효성(시딩 목록 채록)
|
||||
- [x] **G2-H1. hermes 격리 레버 실측**: home 이동 env/플래그 확인 → 부재 시 `HOME=<격리경로>`+시딩 유효성(시딩 목록 채록)
|
||||
- [x] **G2-M. 매트릭스 확정**: `{agent × (레버, 시딩 목록, 대화 저장 실경로)}` 표를 plan §2.3에 기입 (Planner 갱신 요청)
|
||||
- **DoD**: 4-agent 전부 "격리 디렉터리에서 대화 생성 + auth 유지" 실증 완료, 매트릭스 문서화
|
||||
|
||||
## Phase 1 — 공통 불변식 (R1/R2, Phase 0과 병렬 가능)
|
||||
|
||||
- [x] **T1. claimed-set 필터** (`lib.sh:540-575`, `find_workspace_uuid`)
|
||||
- Tier-2/Tier-3 후보 반환 전, 같은 workspace의 **다른 running row가 소유한 `*_own` 집합**을 제외
|
||||
- 4-agent(`claude_session_id_own`/`agy_…`/`hermes_…`/`cline_…`) 모두 적용
|
||||
- [x] **T2. 생성-시 유일성 assert** (`lib.sh` atomic_dump 뮤테이션 검증부, `:377` role-immutability 인접)
|
||||
- 새 `*_own`이 기존 running row의 `*_own`과 충돌 시 `SystemExit`(비정상 종료 코드)로 거부
|
||||
- [x] **T-V1. 단위 재현**: 동일 ID 강제 주입으로 running row 2개 등록 시도 → 두 번째 거부 확인
|
||||
- **DoD**: 한 대화 ID가 두 role에 배정되는 경로가 구조적으로 차단됨을 재현 테스트로 증명
|
||||
|
||||
## Phase 2 — all-L2 격리 구현 (Phase 0 매트릭스 확정 후)
|
||||
|
||||
- [x] **T3. 격리 프로비저닝** (`create_session.sh --isolate`) ✅ 2026-07-10
|
||||
- `uuidgen` → `<workspace>/.mam/agent_homes/<uuid>/` 생성, `lib.sh::provision_isolation`이 agent별 심링크 시딩(매트릭스 기준) + seeded 목록 반환
|
||||
- dry-run 시 미생성 가드 / 실패 trap에서 격리 홈 롤백(경로 가드 후 rm)
|
||||
- [x] **T4. spawn 주입 디스패치** (`lib.sh`: `isolation_lever`/`isolation_env_prefix`/`isolation_cmd_args`) ✅
|
||||
- claude·agy·hermes: env prefix(`CLAUDE_CONFIG_DIR`/`HOME`) / cline: `--data-dir` args — CMD_FULL/START_CMD/spawn 모두 단일 디스패치 경유
|
||||
- 격리 시 claude wrapper 경로 자동 비활성(env 운반 불가) / 격리 미사용 시 기존 명령과 byte-identical
|
||||
- [x] **T5. `isolation` 스키마 영속화 + 재적용** ✅
|
||||
- row에 `isolation: {uuid, root, lever, seeded[]}` 기록(atomic_dump, DB+YAML 미러 확인) + `_validate` uuid/root 필수 검증
|
||||
- `find_workspace_uuid <ws> <agent> [session]` target-모드: 격리 row는 자기 격리 루트 안에서만 해석(전역 fallthrough 금지), cline `<root>/sessions/` 경로 템플릿 분기 반영
|
||||
- `resolve_session_id.sh --session` / `stop_session.sh` capture·purge에 세션 컨텍스트 전달 / T1 claimed-set의 자기-ID 필터 버그 동시 수정
|
||||
- [ ] **T-V2. 격리 동작 검증(실 스폰)**: 같은 CLI 2개(다른 role) 동시 생성 → 대화 파일 물리 분리, `*_own` 상이, 교차 write 0
|
||||
- (스크래치 기능 검증은 완료: 동일 ws의 cline 2-row가 111_aaa/222_bbb로 상이 해석, claude iso jsonl 해석, 불량 isolation 거부, T2 중복 거부, 레거시 no-target 무회귀 — 실 TUI 동시 스폰만 잔여)
|
||||
- **DoD**: create→resolve 경로 검증 완료 / resume 실측은 T-V2와 함께
|
||||
|
||||
## Phase 3 — stop 청소 계약 (T6)
|
||||
|
||||
- [x] **T6. stop 퍼지** (`stop_session.sh`): row의 `isolation.root` `rm -rf` — 단, root가 `<workspace>/.mam/agent_homes/` 하위임을 **경로 검증 후** 삭제(오삭제 방지 가드) ✅ 2026-07-10
|
||||
- [x] **T6-a. 심링크 원본 무손상 확인**: 퍼지 후 실HOME의 `.credentials.json`/settings/plugins 원본 잔존 검증 ✅ 2026-07-10
|
||||
- [ ] **T6-b. 고아 GC(선택)**: create 시 running row에 매핑되지 않는 `agent_homes/*` 정리
|
||||
- **DoD**: stop 후 격리 저장소 잔존 0 + 원본 무손상
|
||||
|
||||
## Phase 4 — 통합 및 회귀 검증
|
||||
|
||||
- [ ] **V1.** planner(claude)+reviewer-a(claude)+developer(cline)+reviewer-b(cline) 4개 동시 기동 → 4개 대화 ID 전부 유일
|
||||
- [ ] **V2.** 동시 write 락 충돌·컨텍스트 오염 재현 불가 (기존 재현 시나리오 역검증)
|
||||
- [ ] **V3.** 각 role resume이 자기 대화만 복원 (isolation 재적용 경유)
|
||||
- [ ] **V4.** 단일-에이전트 기존 워크플로우 회귀 0 (격리 미발동 경로 불변 — 발동 조건/플래그 확정 포함)
|
||||
- [ ] **V5.** `bash -n` + `shellcheck` 신규 경고 0 (`lib.sh`, `create_session.sh`, `stop_session.sh`, resume 스크립트)
|
||||
- **DoD**: V1~V5 전부 통과 → 리뷰어(Claude/Cline) 교차 리뷰 요청 → 전원 PASS 시 커밋
|
||||
Reference in New Issue
Block a user