feat(isolation): implement Phase 1-3 session isolation with stop purge and resume safety

This commit is contained in:
2026-07-10 12:24:35 +09:00
parent 35068f7a9e
commit 768cfe5c6d
6 changed files with 451 additions and 62 deletions
+226 -18
View File
@@ -311,6 +311,10 @@ 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')}
@@ -382,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
@@ -463,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
@@ -475,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:
@@ -498,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)
@@ -537,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
@@ -659,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
}
# ---------------------------------------------------------------------------