feat(create/monitor): implement automatic UUID materialization, auto-pinning, and drift-C2 resolution (13/13 PASS)

This commit is contained in:
2026-08-09 10:24:32 +09:00
parent 9df0fc36f3
commit 245abe62c8
16 changed files with 1239 additions and 61 deletions
+123 -33
View File
@@ -796,6 +796,52 @@ derive_workspace_slug() {
printf 'mam-%s' "$slug"
}
# ---------------------------------------------------------------------------
# mam_gen_uuid
# ---------------------------------------------------------------------------
mam_gen_uuid() {
if command -v uuidgen >/dev/null 2>&1; then
uuidgen | tr 'A-Z' 'a-z'
else
python3 -c 'import uuid; print(uuid.uuid4())'
fi
}
# ---------------------------------------------------------------------------
# mam_abs_workspace <path>
# ---------------------------------------------------------------------------
mam_abs_workspace() {
local p="${1:-}"
( cd -P "$p" 2>/dev/null && pwd -P ) || printf '%s' "$p"
}
# ---------------------------------------------------------------------------
# mam_workspace_key <workspace>
# ---------------------------------------------------------------------------
mam_workspace_key() {
printf '%s' "$(mam_abs_workspace "$1")" | tr '/_' '--'
}
# ---------------------------------------------------------------------------
# mam_session_iso_root <session_name>
# ---------------------------------------------------------------------------
mam_session_iso_root() {
MAM_STATE_JSON="$(load_state_json)" MAM_ISO_SESSION="$1" env_python "$AGENT_SESSIONS_YAML" <<'PYEOF'
import json, os
name = os.environ.get('MAM_ISO_SESSION', '')
try:
d = json.loads(os.environ.get('MAM_STATE_JSON', '{}'))
except Exception:
d = {}
for s in (d.get('herdr_sessions') or []):
if s.get('name') == name:
iso = s.get('isolation')
if isinstance(iso, dict) and iso.get('root'):
print(iso['root'])
break
PYEOF
}
# ---------------------------------------------------------------------------
# derive_session_name <workspace> <agent>
# ---------------------------------------------------------------------------
@@ -1126,13 +1172,21 @@ PYEOF
# ---------------------------------------------------------------------------
VERIFY_SESSION_PYTHON='
def workspace_key(path):
return path.replace("/", "-").replace("_", "-")
import os
try:
p = os.path.realpath(path)
except Exception:
p = path
return p.replace("/", "-").replace("_", "-")
def verify_session_uuid(ws, agent, uuid, row=None, home_dir=None, claude_dir=None, mode="discover"):
import os, json, sqlite3
home = home_dir or os.environ.get("HOME_DIR", "")
c_dir = claude_dir or os.environ.get("CLAUDE_PROJECT_DIR", f"{home}/.claude/projects")
row = row or {}
_iso = row.get("isolation")
iso_root = _iso.get("root") if isinstance(_iso, dict) and _iso.get("root") else None
# The floor answers "could this transcript belong to a PREVIOUS incarnation
# of this session?", which only matters while picking an unknown uuid off
# disk. In "revalidate" the uuid is one this row already recorded, and a
@@ -1142,11 +1196,20 @@ def verify_session_uuid(ws, agent, uuid, row=None, home_dir=None, claude_dir=Non
epoch = row.get("herdr_session_epoch", 0) if mode == "discover" else 0
cwd = row.get("pane", {}).get("cwd", "") or ws
# ORDERING INVARIANT: the workspace check below MUST run before the
# assigned-id shortcut. Moving the shortcut above it would return True for a
# row belonging to a different workspace purely because it is assigned and
# unverified, breaking the one guarantee find_workspace_uuid exists to give
# -- never hand back an id that belongs to a different workspace. (T-12)
if workspace_key(cwd) != workspace_key(ws):
return False
if (mode == "revalidate" and row.get("session_id_source") == "assigned"
and not row.get("session_id_verified")):
return True
if agent == "claude":
base = c_dir
base = (iso_root + "/projects") if iso_root else c_dir
key = workspace_key(ws)
path = f"{base}/{key}/{uuid}.jsonl"
if not os.path.exists(path):
@@ -1154,27 +1217,41 @@ def verify_session_uuid(ws, agent, uuid, row=None, home_dir=None, claude_dir=Non
if epoch and os.path.getmtime(path) < epoch:
return False
try:
valid_session = False
found_cwd = None
with open(path) as f:
first = f.readline().strip()
if not first:
for _ in range(50):
line = f.readline()
if not line:
break
line = line.strip()
if not line:
continue
try:
payload = json.loads(line)
if payload.get("sessionId") == uuid:
valid_session = True
if payload.get("cwd"):
found_cwd = payload.get("cwd")
break
except Exception:
pass
if not valid_session:
return False
payload = json.loads(first)
if payload.get("sessionId") != uuid:
return False
if payload.get("cwd") and payload.get("cwd") != cwd:
if found_cwd and found_cwd != cwd:
return False
except Exception:
return False
elif agent == "agy":
base = f"{home}/.gemini/antigravity-cli/conversations"
base = f"{iso_root or home}/.gemini/antigravity-cli/conversations"
path = f"{base}/{uuid}.db"
if not os.path.exists(path):
return False
if epoch and os.path.getmtime(path) < epoch:
return False
if mode == "discover":
lc = f"{home}/.gemini/antigravity-cli/cache/last_conversations.json"
lc = f"{iso_root or home}/.gemini/antigravity-cli/cache/last_conversations.json"
cache_match = False
if os.path.exists(lc):
try:
@@ -1196,7 +1273,7 @@ def verify_session_uuid(ws, agent, uuid, row=None, home_dir=None, claude_dir=Non
return False
elif agent == "hermes":
hdb = f"{home}/.hermes/state.db"
hdb = f"{iso_root or home}/.hermes/state.db"
if not os.path.exists(hdb):
return False
if epoch and os.path.getmtime(hdb) < epoch:
@@ -1211,7 +1288,7 @@ def verify_session_uuid(ws, agent, uuid, row=None, home_dir=None, claude_dir=Non
return False
elif agent == "cline":
base = f"{home}/.cline/data/sessions"
base = (iso_root + "/sessions") if iso_root else f"{home}/.cline/data/sessions"
path = f"{base}/{uuid}/{uuid}.json"
if not os.path.exists(path):
return False
@@ -1260,7 +1337,7 @@ PYEOF
# 2 = check not possible (capture failed, session gone)
verify_tui_viewport() {
local session_name="$1" agent="$2" workspace="$3"
local abs; abs="$(cd "$workspace" 2>/dev/null && pwd)" || abs="$workspace"
local abs; abs="$(mam_abs_workspace "$workspace")"
local base; base="$(basename "$abs")"
if ! _herdr has-session -t "$session_name" >/dev/null 2>&1; then
@@ -1273,23 +1350,16 @@ verify_tui_viewport() {
return 2
fi
case "$agent" in
claude)
if printf '%s\n' "$pane_content" | grep -q "$base"; then
return 0
fi
return 1
;;
agy|hermes|cline)
if printf '%s\n' "$pane_content" | grep -q "$base"; then
return 0
fi
return 1
;;
*)
return 2
;;
esac
local flat base_flat
flat=$(printf '%s' "$pane_content" | tr -d '[:space:]')
base_flat=$(printf '%s' "$base" | tr -d '[:space:]')
if printf '%s' "$flat" | grep -Fq "$base_flat"; then
return 0
fi
if printf '%s\n' "$pane_content" | grep -Eq '(^|[^[:alnum:]])/[A-Za-z0-9._-]+/[A-Za-z0-9._/-]+'; then
return 1
fi
return 2
}
# ---------------------------------------------------------------------------
@@ -1308,7 +1378,7 @@ verify_tui_viewport() {
# ---------------------------------------------------------------------------
find_workspace_uuid() {
local workspace="$1" agent="$2" session_name="${3:-}"
local abs; abs="$(cd "$workspace" 2>/dev/null && pwd)" || abs="$workspace"
local abs; abs="$(mam_abs_workspace "$workspace")"
MAM_STATE_JSON="$(load_state_json)" WS_ABS="$abs" AGENT="$agent" TARGET_SESSION="$session_name" MAM_VERIFY_PY="$VERIFY_SESSION_PYTHON" env_python "$AGENT_SESSIONS_YAML" <<'PYEOF'
import os, sys, json, glob, sqlite3
@@ -1791,7 +1861,27 @@ _sks_herdr() {
mam_herdr "$@"
}
_pane_capture() { _sks_herdr capture-pane -p -t "$1" 2>/dev/null || echo ""; }
_pane_capture() {
local raw
raw="$(_sks_herdr capture-pane -p -t "$1" 2>/dev/null || echo "")"
if [ -z "$raw" ]; then
echo ""
return 0
fi
python3 -c '
import sys, json
raw = sys.stdin.read()
try:
data = json.loads(raw)
text = data.get("result", {}).get("read", {}).get("text")
if text is not None:
print(text, end="")
else:
print(raw, end="")
except Exception:
print(raw, end="")
' <<< "$raw"
}
# Bottom-N *content* lines: capture-pane -p pads the viewport with trailing
# blank rows; window on non-blank lines or top-anchored dialogs are invisible.
@@ -233,10 +233,10 @@ The script handles the YAML append, pane capture, and the `last_visible_status`
## Pitfalls
- **Don't use `nohup`/`disown`/`setsid` for the agent itself** — those background the agent outside herdr. The whole point of this skill is *the herdr session is the supervisor*. `nohup` is OK only for *launching the wrapper* (which itself creates the herdr session via `herdr new-session -d`).
- **Don't trust `--session-id <uuid>` flags blindly** — claude/agy may not accept a fixed session id on first spawn. The session id is *assigned* on first user message; you can read it back from `~/.claude/projects/.../session.jsonl` headers or `~/.gemini/.../cache/last_conversations.json` AFTER the first message.
- **Claude Session ID Auto-Assignment**: Fresh `claude` sessions automatically generate a new UUID (`mam_gen_uuid`) passed via `claude --session-id <uuid>`. `claude_session_id_own` is stored in `.mam/agent-sessions.yaml` with `session_id_source: assigned` and `session_id_verified: false`.
- **First Message Materialization**: The transcript file `.jsonl` is created on disk when the first user prompt is sent. The monitor loop (`reconcile.sh`) verifies the transcript and promotes `session_id_verified: true` and `last_visible_status: pinned`.
- **Wrapper script MUST NOT be created via `hermes profile alias`** — that command writes a `hermes -p <profile>` wrapper that destroys the herdr behavior. Create wrappers manually (see `lab-landing-page-creator-claude` template).
- **Always use the workspace-relative path** in herdr `cwd` — relative paths break when herdr respawns in a different shell context.
- **The first `claude` message generates the session id** — `multi-agent-mux-create` only sets up the *container*. If you need a known session id for later resume, send a placeholder message (e.g. "init") and read it back, then call `multi-agent-mux-resume` later.
## Verification
@@ -148,8 +148,13 @@ if [ "$(uname)" = "Darwin" ] && [ -f "$RESOLVED_BIN" ]; then
xattr -d com.apple.quarantine "$RESOLVED_BIN" 2>/dev/null || true
fi
SESSION_UUID=""
if [ "$AGENT" = "claude" ]; then
SESSION_UUID="$(mam_gen_uuid)"
fi
case "$AGENT" in
claude) CMD_FULL="${RESOLVED_BIN} --dangerously-skip-permissions" ;;
claude) CMD_FULL="${RESOLVED_BIN} --dangerously-skip-permissions --session-id ${SESSION_UUID}" ;;
agy) CMD_FULL="${RESOLVED_BIN} --dangerously-skip-permissions" ;;
hermes) CMD_FULL="${RESOLVED_BIN}" ;;
cline) CMD_FULL="${RESOLVED_BIN} -i" ;;
@@ -162,6 +167,7 @@ spawn() {
case "$AGENT" in
claude)
if { [ -x "$WRAPPER" ] && [ "$(basename "$WRAPPER")" != "claude" ]; } || [ "$USE_WRAPPER" = "1" ]; then
SESSION_UUID=""
nohup "$WRAPPER" >/dev/null 2>&1 &
disown
else
@@ -262,6 +268,7 @@ atomic_dump_yaml "$AGENT_SESSIONS_YAML" \
HERDR_EPOCH="$HERDR_EPOCH" PANE_PID="$PANE_PID" PANE_CWD="$PANE_CWD" \
CMD_FULL="$CMD_FULL" START_CMD="$START_CMD" CHILD_PID="$CHILD_PID" \
HERDR_SESSION_NAME="${HERDR_SESSION_NAME:-default}" \
SESSION_UUID="$SESSION_UUID" \
DELEGATE_JOB_ID="$DELEGATE_JOB_ID" ROLE="$ROLE" <<'PYEOF'
name = os.environ['SESSION_NAME']
agent = os.environ['AGENT']
@@ -302,8 +309,6 @@ entry = {
'kill_command': f'HERDR_SESSION_NAME={server_name} herdr kill-session -t {name}',
}
if agent == 'claude':
entry['tui'] = {
'model': '(unknown — capture after first message)',
@@ -312,8 +317,11 @@ if agent == 'claude':
'account': '(unknown — read from claude auth status)',
'version': '(unknown — read from TUI)',
}
entry['claude_session_id_own'] = None
entry['last_visible_status'] = "unverified"
assigned = os.environ.get('SESSION_UUID', '') or None
entry['claude_session_id_own'] = assigned
entry['session_id_source'] = 'assigned' if assigned else 'pending-discovery'
entry['session_id_verified'] = False
entry['last_visible_status'] = "assigned (awaiting first message)" if assigned else "unverified"
elif agent == 'agy':
cp = os.environ.get('CHILD_PID', '0')
entry['child_pid'] = int(cp) if cp.isdigit() else 0
@@ -124,15 +124,18 @@ YAML: no such session
→ report: "lab-paper-pdf2md-creator-agy: herdr found but not in YAML. Auto-registered."
```
### C. New session id materializes (claude first message sent)
### C. Session ID Discovery & Confirmation
```
YAML: claude_session_id_own=null (placeholder)
disk: ~/.claude/projects/.../b3a7...c2f.jsonl exists, mtime=now,
first line sessionId=b3a7...c2f
→ update claude_session_id_own=b3a7...c2f
→ report: "lab-landing-page-creator-claude: session id materialized b3a7...c2f"
```
- **C0. Assigned ID Confirmation**: For sessions created with an auto-assigned UUID (`session_id_source: assigned`, `session_id_verified: false`), the monitor verifies that the transcript file `.jsonl` has materialized on disk. Once verified, it promotes `session_id_verified: true` and updates `last_visible_status: pinned`.
- **C. New session id materializes (unassigned/legacy)**:
```
YAML: claude_session_id_own=null (placeholder)
disk: ~/.claude/projects/.../b3a7...c2f.jsonl exists, mtime=now,
first line sessionId=b3a7...c2f
→ update claude_session_id_own=b3a7...c2f
→ report: "lab-landing-page-creator-claude: session id materialized b3a7...c2f"
```
- **C-ambiguous. Multiple candidates detected**: If multiple candidate transcripts match an unassigned session, the monitor avoids random pinning, reports `C-ambiguous`, and sets `last_visible_status: "ambiguous: N candidates"`.
### D. Stale UUID (artifact gone)
@@ -565,9 +565,50 @@ if herdr_confirmed:
'msg': f"{name}: herdr found but not in YAML. Auto-registered (pane {pm['pid']}, cmd {pm['cmd']}, cwd {pm['cwd']})."})
actions.append(f"registered: {name}")
def row_agent(s):
cmd = ((s.get('pane') or {}).get('cmd') or '').strip()
if cmd in ('claude', 'agy', 'hermes', 'cline'):
return cmd
full = ((s.get('pane') or {}).get('cmd_full') or '')
for a in ('claude', 'agy', 'hermes', 'cline'):
if a in full:
return a
name = s.get('name', '')
for a in ('claude', 'agy', 'hermes', 'cline'):
if name.endswith('-creator-' + a):
return a
return None
OWN_KEY_BY_AGENT = {
'claude': 'claude_session_id_own',
'agy': 'agy_conversation_id_own',
'hermes': 'hermes_conversation_id_own',
'cline': 'cline_conversation_id_own'
}
# === drift C0: 지정된 ID 는 발견이 아니라 '확인'만 필요하다 ===
for s in d.get('herdr_sessions', []):
if s.get('status') != 'running':
continue
if s.get('session_id_source') != 'assigned' or s.get('session_id_verified'):
continue
agent = row_agent(s)
if not agent:
continue
uuid = s.get(OWN_KEY_BY_AGENT[agent])
cwd = (s.get('pane') or {}).get('cwd', '')
if not uuid or not cwd:
continue
if verify_session_uuid(cwd, agent, uuid, s, mode="discover"):
s['session_id_verified'] = True
s['last_visible_status'] = 'pinned'
drifts.append({'class': 'C', 'name': s['name'],
'msg': f"{s['name']}: assigned session id confirmed on disk: {uuid}"})
actions.append(f"confirmed session id: {uuid}")
# === drift C: claude 새 session id materialize (per-row own id) ===
for s in d.get('herdr_sessions', []):
if not s.get('name', '').endswith('-creator-claude'):
if row_agent(s) != 'claude':
continue
if s.get('status') != 'running':
continue
@@ -588,6 +629,11 @@ for s in d.get('herdr_sessions', []):
if verify_session_uuid(cwd, 'claude', uuid, s, mode="discover"):
valid_candidates.append(uuid)
if len(valid_candidates) > 1:
drifts.append({'class': 'C-ambiguous', 'name': s['name'],
'msg': f"{s['name']}: {len(valid_candidates)} candidate transcripts newer than session epoch; not pinning"})
s['last_visible_status'] = f"ambiguous: {len(valid_candidates)} candidates"
actions.append(f"ambiguous candidates: {s['name']}")
if len(valid_candidates) == 1:
uuid = valid_candidates[0]
cmd = ['bash', '-c', f'source "{lib_sh}" && verify_tui_viewport "{s["name"]}" "claude" "{cwd}"']
@@ -601,7 +647,7 @@ for s in d.get('herdr_sessions', []):
# === drift C (agy): agy 새 session id materialize (per-row own id) ===
for s in d.get('herdr_sessions', []):
if not s.get('name', '').endswith('-creator-agy'):
if row_agent(s) != 'agy':
continue
if s.get('status') != 'running':
continue
@@ -632,6 +678,11 @@ for s in d.get('herdr_sessions', []):
if verify_session_uuid(cwd, 'agy', uuid, s_eval, mode="discover"):
valid_candidates.append(uuid)
if len(valid_candidates) > 1:
drifts.append({'class': 'C-ambiguous', 'name': s['name'],
'msg': f"{s['name']}: {len(valid_candidates)} candidate transcripts newer than session epoch; not pinning"})
s['last_visible_status'] = f"ambiguous: {len(valid_candidates)} candidates"
actions.append(f"ambiguous candidates: {s['name']}")
if len(valid_candidates) == 1:
uuid = valid_candidates[0]
cmd = ['bash', '-c', f'source "{lib_sh}" && verify_tui_viewport "{s["name"]}" "agy" "{cwd}"']
@@ -645,7 +696,7 @@ for s in d.get('herdr_sessions', []):
# === drift C (hermes): hermes 새 session id materialize (per-row own id) ===
for s in d.get('herdr_sessions', []):
if not s.get('name', '').endswith('-creator-hermes'):
if row_agent(s) != 'hermes':
continue
if s.get('status') != 'running':
continue
@@ -670,6 +721,11 @@ for s in d.get('herdr_sessions', []):
except Exception:
pass
if len(valid_candidates) > 1:
drifts.append({'class': 'C-ambiguous', 'name': s['name'],
'msg': f"{s['name']}: {len(valid_candidates)} candidate transcripts newer than session epoch; not pinning"})
s['last_visible_status'] = f"ambiguous: {len(valid_candidates)} candidates"
actions.append(f"ambiguous candidates: {s['name']}")
if len(valid_candidates) == 1:
uuid = valid_candidates[0]
cmd = ['bash', '-c', f'source "{lib_sh}" && verify_tui_viewport "{s["name"]}" "hermes" "{cwd}"']
@@ -683,7 +739,7 @@ for s in d.get('herdr_sessions', []):
# === drift C (cline): cline 새 session id materialize (per-row own id) ===
for s in d.get('herdr_sessions', []):
if not s.get('name', '').endswith('-creator-cline'):
if row_agent(s) != 'cline':
continue
if s.get('status') != 'running':
continue
@@ -709,8 +765,12 @@ for s in d.get('herdr_sessions', []):
uuid = os.path.basename(j)[:-5]
if verify_session_uuid(cwd, 'cline', uuid, s, mode="discover"):
valid_candidates.append(uuid)
break
if len(valid_candidates) > 1:
drifts.append({'class': 'C-ambiguous', 'name': s['name'],
'msg': f"{s['name']}: {len(valid_candidates)} candidate transcripts newer than session epoch; not pinning"})
s['last_visible_status'] = f"ambiguous: {len(valid_candidates)} candidates"
actions.append(f"ambiguous candidates: {s['name']}")
if len(valid_candidates) == 1:
uuid = valid_candidates[0]
cmd = ['bash', '-c', f'source "{lib_sh}" && verify_tui_viewport "{s["name"]}" "cline" "{cwd}"']
@@ -85,8 +85,9 @@ if herdr has-session -t "$SESSION_NAME" 2>/dev/null; then
fi
# 3. Determine CMD_FULL
# For claude: if assigned UUID transcript .jsonl is unmaterialized on disk, use --session-id <uuid>; otherwise use -r <uuid>
case "$AGENT" in
claude) CMD_FULL="claude --dangerously-skip-permissions -r $UUID" ;;
claude) CMD_FULL="claude --dangerously-skip-permissions $CLAUDE_ID_FLAG $UUID" ;;
agy) CMD_FULL="agy --dangerously-skip-permissions --conversation $UUID" ;;
hermes) CMD_FULL="hermes --resume $UUID" ;;
cline) CMD_FULL="cline -i --id $UUID" ;;
@@ -80,9 +80,23 @@ if [ "$(uname)" = "Darwin" ] && [ -f "$RESOLVED_BIN" ]; then
xattr -d com.apple.quarantine "$RESOLVED_BIN" 2>/dev/null || true
fi
CLAUDE_ID_FLAG="-r"
if [ "$AGENT" = "claude" ]; then
_ws_key="$(mam_workspace_key "$WORKSPACE")"
_iso_root="$(mam_session_iso_root "$SESSION_NAME" 2>/dev/null || true)"
if [ -n "$_iso_root" ]; then
_proj_dir="$_iso_root/projects"
else
_proj_dir="${CLAUDE_PROJECT_DIR:-$HOME/.claude/projects}"
fi
if [ ! -f "${_proj_dir}/${_ws_key}/${UUID}.jsonl" ]; then
CLAUDE_ID_FLAG="--session-id"
fi
fi
# Determine CMD_FULL
case "$AGENT" in
claude) CMD_FULL="${RESOLVED_BIN} --dangerously-skip-permissions -r $UUID" ;;
claude) CMD_FULL="${RESOLVED_BIN} --dangerously-skip-permissions $CLAUDE_ID_FLAG $UUID" ;;
agy) CMD_FULL="${RESOLVED_BIN} --dangerously-skip-permissions --conversation $UUID" ;;
hermes) CMD_FULL="${RESOLVED_BIN} --resume $UUID" ;;
cline) CMD_FULL="${RESOLVED_BIN} -i --id $UUID" ;;