feat(isolation): implement Phase 1-3 session isolation with stop purge and resume safety
This commit is contained in:
+226
-18
@@ -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
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user