diff --git a/.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh b/.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh index c99c476..3879f55 100755 --- a/.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh +++ b/.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh @@ -51,9 +51,23 @@ esac UUID=$(bash "$(dirname "${BASH_SOURCE[0]}")/resolve_session_id.sh" \ --workspace "$WORKSPACE" --agent "$AGENT" --session "$SESSION_NAME") +# ISSUE-3: agy/hermes/opencode (Class A) are spawned without a UUID - their +# own conversation id is only assigned by the agent itself on its first turn. +# A session stopped before any message therefore has no UUID to resolve here, +# but IS still cleanly recoverable: relaunching the agent's plain first-run +# spawn command restores the exact state multi-agent-mux-create would have +# produced. claude/grok (Class B) always resolve a UUID via the +# verify_session.py:99-101 assigned-but-unverified escape hatch (see test_t8) +# and never reach this branch - do not extend the fallback to them. +FRESH_SPAWN=0 if [ -z "$UUID" ]; then - echo "ERROR: No saved session for $WORKSPACE ($AGENT). Use multi-agent-mux-create first." >&2 - exit 1 + case "$AGENT" in + agy|hermes|opencode) FRESH_SPAWN=1 ;; + *) + echo "ERROR: No saved session for $WORKSPACE ($AGENT). Use multi-agent-mux-create first." >&2 + exit 1 + ;; + esac fi if [ -n "$HERDR_SERVER_OPT" ]; then @@ -96,16 +110,29 @@ if [ "$(uname)" = "Darwin" ] && [ -f "$RESOLVED_BIN" ]; then fi # Determine CMD_FULL via adapter -CMD_FULL="$("$(_delegate_py_bin)" -m lib_py.agents resume-spec "$AGENT" "$RESOLVED_BIN" "$UUID" "$WORKSPACE" 2>/dev/null || true)" -if [ -z "$CMD_FULL" ]; then - case "$AGENT" in - claude) CMD_FULL="${RESOLVED_BIN} --dangerously-skip-permissions -r $UUID" ;; - agy) CMD_FULL="${RESOLVED_BIN} --dangerously-skip-permissions --conversation $UUID" ;; - hermes) CMD_FULL="${RESOLVED_BIN} --resume $UUID --no-restore-cwd --yolo --accept-hooks" ;; - grok) CMD_FULL="${RESOLVED_BIN} --resume $UUID --permission-mode bypassPermissions" ;; - opencode) CMD_FULL="${RESOLVED_BIN} --session $UUID --auto --agent build" ;; - *) echo "ERROR: unsupported agent: $AGENT" >&2; exit 2 ;; - esac +if [ "$FRESH_SPAWN" = "1" ]; then + # ISSUE-3: no UUID to resume with - spawn the same plain first-run command + # multi-agent-mux-create would use (spawn-spec, not resume-spec). + CMD_FULL="$("$(_delegate_py_bin)" -m lib_py.agents spawn-spec "$AGENT" "$RESOLVED_BIN" "" 0 2>/dev/null || true)" + if [ -z "$CMD_FULL" ]; then + case "$AGENT" in + agy) CMD_FULL="${RESOLVED_BIN} --dangerously-skip-permissions" ;; + hermes) CMD_FULL="${RESOLVED_BIN} --yolo --accept-hooks" ;; + opencode) CMD_FULL="${RESOLVED_BIN} --auto --agent build" ;; + esac + fi +else + CMD_FULL="$("$(_delegate_py_bin)" -m lib_py.agents resume-spec "$AGENT" "$RESOLVED_BIN" "$UUID" "$WORKSPACE" 2>/dev/null || true)" + if [ -z "$CMD_FULL" ]; then + case "$AGENT" in + claude) CMD_FULL="${RESOLVED_BIN} --dangerously-skip-permissions -r $UUID" ;; + agy) CMD_FULL="${RESOLVED_BIN} --dangerously-skip-permissions --conversation $UUID" ;; + hermes) CMD_FULL="${RESOLVED_BIN} --resume $UUID --no-restore-cwd --yolo --accept-hooks" ;; + grok) CMD_FULL="${RESOLVED_BIN} --resume $UUID --permission-mode bypassPermissions" ;; + opencode) CMD_FULL="${RESOLVED_BIN} --session $UUID --auto --agent build" ;; + *) echo "ERROR: unsupported agent: $AGENT" >&2; exit 2 ;; + esac + fi fi # Validate binary exists and is executable @@ -145,7 +172,7 @@ sleep 2 # 5. Update agent-sessions.yaml: status running, last_visible_status bash "$(dirname "${BASH_SOURCE[0]}")/update_yaml_resumed.sh" \ --session "$SESSION_NAME" --uuid "$UUID" --agent "$AGENT" --workspace "$WORKSPACE" \ - --herdr-session "$HERDR_SESSION_NAME" \ + --herdr-session "$HERDR_SESSION_NAME" --cmd-full "$CMD_FULL" \ ${HERDR_WORKSPACE_OPT:+--herdr-workspace "$HERDR_WORKSPACE_OPT"} echo "Successfully resumed $SESSION_NAME ($AGENT)" diff --git a/.agents/skills/multi-agent-mux-resume/scripts/update_yaml_resumed.sh b/.agents/skills/multi-agent-mux-resume/scripts/update_yaml_resumed.sh index e5833fd..36bf274 100755 --- a/.agents/skills/multi-agent-mux-resume/scripts/update_yaml_resumed.sh +++ b/.agents/skills/multi-agent-mux-resume/scripts/update_yaml_resumed.sh @@ -11,7 +11,11 @@ source "$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/lib.sh" usage() { cat < --uuid [--agent claude|agy|hermes|grok|opencode] [--herdr-session ] +Usage: $0 --session --uuid [--agent claude|agy|hermes|grok|opencode] [--herdr-session ] [--cmd-full ] + +--uuid may be empty for an Issue-3 Class A fresh-spawn resume (agy/hermes/ +opencode 0-turn stopped session, no conversation id ever assigned) - pass +--cmd-full so the display command can be recorded without a uuid to embed. EOF } @@ -22,6 +26,7 @@ WORKSPACE="" ROLE="" HERDR_SERVER_OPT="" HERDR_WORKSPACE_OPT="" +CMD_FULL_OPT="" while [ $# -gt 0 ]; do case "$1" in @@ -32,13 +37,13 @@ while [ $# -gt 0 ]; do --role) ROLE="$2"; shift 2 ;; --herdr-session|--herdr-server) HERDR_SERVER_OPT="$2"; shift 2 ;; --herdr-workspace) HERDR_WORKSPACE_OPT="$2"; shift 2 ;; + --cmd-full) CMD_FULL_OPT="$2"; shift 2 ;; -h|--help) usage; exit 0 ;; *) echo "ERROR: unknown arg: $1" >&2; exit 2 ;; esac done [ -n "$SESSION_NAME" ] || { echo "ERROR: --session required" >&2; exit 2; } -[ -n "$UUID" ] || { echo "ERROR: --uuid required" >&2; exit 2; } [ -f "$AGENT_SESSIONS_YAML" ] || { echo "ERROR: $AGENT_SESSIONS_YAML not found" >&2; exit 1; } if [ -n "$HERDR_SERVER_OPT" ]; then @@ -104,7 +109,8 @@ atomic_dump_yaml "$AGENT_SESSIONS_YAML" \ SESSION_NAME="$SESSION_NAME" UUID="$UUID" AGENT="$AGENT" NOW_ISO="$NOW_ISO" \ NOW_EPOCH="$NOW_EPOCH" TARGET_WORKSPACE="${WORKSPACE:-$WORKSPACE_ROOT}" ROLE="$ROLE" \ PANE_PID="$PANE_PID" CHILD_PID="$CHILD_PID" HERDR_SERVER_OPT_EXPLICIT="${HERDR_SERVER_OPT_EXPLICIT:-0}" \ - MAM_WS_LABEL="$MAM_WS_LABEL" MAM_WS_LABEL_EXPLICIT="${MAM_WS_LABEL_EXPLICIT:-0}" <<'PYEOF' + MAM_WS_LABEL="$MAM_WS_LABEL" MAM_WS_LABEL_EXPLICIT="${MAM_WS_LABEL_EXPLICIT:-0}" \ + CMD_FULL_OPT="$CMD_FULL_OPT" <<'PYEOF' name = os.environ['SESSION_NAME'] uuid = os.environ['UUID'] agent = os.environ['AGENT'] @@ -157,6 +163,18 @@ else: target['herdr_workspace'] = wsl target['status'] = 'running' +if not uuid: + # ISSUE-3 (challenge c4b0a075): a Class A fresh-spawn resume re-arms + # discovery from scratch - the resumed agent has not spoken yet, so any + # on-disk conversation older than THIS resume must not be auto-pinned to + # it by reconcile.sh's mode="discover" mtime >= epoch check. Without this, + # the row's watermark stays at the original create_session.sh timestamp, + # letting reconcile.sh capture a stale/unrelated transcript before the + # resumed agent's first real turn. + target['herdr_session_epoch'] = epoch + target['herdr_session_created_at'] = now + target['session_id_source'] = 'pending-discovery' + target['session_id_verified'] = False target.pop('terminated_at', None) target.pop('terminated_at_epoch', None) target.pop('termination_mode', None) @@ -166,7 +184,11 @@ target.pop('stopped_at', None) target.pop('stopped_at_epoch', None) target.pop('stop_reason', None) target.pop('resumable', None) -target['last_visible_status'] = f'resumed conversation {uuid} at {now}' +if uuid: + target['last_visible_status'] = f'resumed conversation {uuid} at {now}' +else: + # ISSUE-3 Class A fresh-spawn resume: no conversation id existed yet. + target['last_visible_status'] = f'resumed (fresh spawn, no prior turn) at {now}' target.setdefault('pane', {}) if pane_pid.isdigit(): @@ -178,15 +200,21 @@ if agent == 'claude': target['claude_session_id_own'] = uuid elif agent == 'agy': target['pane']['cmd'] = 'agy' - target['pane']['cmd_full'] = f'agy --dangerously-skip-permissions --conversation {uuid}' - target['agy_conversation_id_own'] = uuid + if uuid: + target['pane']['cmd_full'] = f'agy --dangerously-skip-permissions --conversation {uuid}' + target['agy_conversation_id_own'] = uuid + else: + target['pane']['cmd_full'] = os.environ.get('CMD_FULL_OPT', '') or 'agy --dangerously-skip-permissions' cp = os.environ.get('CHILD_PID', '0') if cp.isdigit() and int(cp) > 0: target['child_pid'] = int(cp) elif agent == 'hermes': target['pane']['cmd'] = 'hermes' - target['pane']['cmd_full'] = f'hermes --resume {uuid}' - target['hermes_conversation_id_own'] = uuid + if uuid: + target['pane']['cmd_full'] = f'hermes --resume {uuid}' + target['hermes_conversation_id_own'] = uuid + else: + target['pane']['cmd_full'] = os.environ.get('CMD_FULL_OPT', '') or 'hermes --yolo --accept-hooks' cp = os.environ.get('CHILD_PID', '0') if cp.isdigit() and int(cp) > 0: target['child_pid'] = int(cp) @@ -199,8 +227,11 @@ elif agent == 'grok': target['child_pid'] = int(cp) elif agent == 'opencode': target['pane']['cmd'] = 'opencode' - target['pane']['cmd_full'] = f'opencode --session {uuid} --auto --agent build' - target['opencode_session_id_own'] = uuid + if uuid: + target['pane']['cmd_full'] = f'opencode --session {uuid} --auto --agent build' + target['opencode_session_id_own'] = uuid + else: + target['pane']['cmd_full'] = os.environ.get('CMD_FULL_OPT', '') or 'opencode --auto --agent build' cp = os.environ.get('CHILD_PID', '0') if cp.isdigit() and int(cp) > 0: target['child_pid'] = int(cp) diff --git a/tests/test_uuid_target.py b/tests/test_uuid_target.py index 8ec6dd3..4fc9ac4 100644 --- a/tests/test_uuid_target.py +++ b/tests/test_uuid_target.py @@ -1,5 +1,6 @@ import os import json +import time import uuid import pytest import subprocess @@ -389,3 +390,125 @@ def test_t13_mam_workspace_key_equivalence(mam_sandbox): abs_path = os.path.realpath(v) py_key = abs_path.replace("/", "-").replace("_", "-") assert sh_key == py_key, f"Mismatch for {v}: shell={sh_key}, py={py_key}" + + +def test_t14_resume_class_a_fresh_spawn_fallback(mam_sandbox, mock_herdr, mock_agents): + """T-14 (Issue #3): agy (Class A) stopped at 0-turns has no conversation id to + resolve; resume_session.sh must fall back to a fresh spawn (RC=0, plain spawn + command) rather than hard-failing with 'No saved session'.""" + ws = str(mam_sandbox / "resume_class_a") + os.makedirs(ws, exist_ok=True) + + create_script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh" + stop_script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-stop" / "scripts" / "stop_session.sh" + resume_script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-resume" / "scripts" / "resume_session.sh" + + cmd = [ + "bash", str(create_script), + "--workspace", ws, + "--agent", "agy", + "--role", "creator", + "--session", "test-t14-agy", + "--no-onboard" + ] + subprocess.run(cmd, check=True, cwd=str(mam_sandbox)) + + # Stop the session without ever sending a prompt (0-turn stop). + subprocess.run( + ["bash", str(stop_script), "--session", "test-t14-agy", "--agent", "agy"], + check=True, cwd=str(mam_sandbox) + ) + + yaml_path = mam_sandbox / ".mam" / "agent-sessions.yaml" + with open(yaml_path) as f: + d = yaml.safe_load(f) + session = [s for s in d["herdr_sessions"] if s["name"] == "test-t14-agy"][0] + assert session["status"] == "stopped" + assert not session.get("agy_conversation_id_own") + + res = subprocess.run([ + "bash", str(resume_script), + "--workspace", ws, + "--agent", "agy", + "--session", "test-t14-agy", + "--dry-run" + ], capture_output=True, text=True, cwd=str(mam_sandbox)) + assert res.returncode == 0, f"stderr: {res.stderr}" + assert "would spawn:" in res.stdout + assert "--dangerously-skip-permissions" in res.stdout + assert "--conversation" not in res.stdout + + +def test_t15_resume_class_b_still_hard_fails_when_truly_unresolvable(mam_sandbox, mock_herdr, mock_agents): + """T-15 (Issue #3 regression guard): claude/grok (Class B) must NOT receive the + Class A fresh-spawn fallback - a claude session with no resolvable id at all + (never created) still hard-fails exactly as before.""" + ws = str(mam_sandbox / "resume_class_b_missing") + os.makedirs(ws, exist_ok=True) + resume_script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-resume" / "scripts" / "resume_session.sh" + + res = subprocess.run([ + "bash", str(resume_script), + "--workspace", ws, + "--agent", "claude", + "--session", "never-created-claude", + "--dry-run" + ], capture_output=True, text=True, cwd=str(mam_sandbox)) + assert res.returncode == 1 + assert "ERROR: No saved session for" in res.stderr + + +def test_t16_resume_class_a_fresh_spawn_resets_discovery_epoch(mam_sandbox, mock_herdr, mock_agents): + """T-16 (Issue #3 challenge refinement): a Class A (agy) 0-turn resume must reset + herdr_session_epoch to the resume timestamp so reconcile.sh does not auto-capture + stale conversations created between the original create and the resume.""" + ws = str(mam_sandbox / "resume_class_a_epoch") + os.makedirs(ws, exist_ok=True) + + create_script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh" + stop_script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-stop" / "scripts" / "stop_session.sh" + resume_script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-resume" / "scripts" / "resume_session.sh" + + cmd = [ + "bash", str(create_script), + "--workspace", ws, + "--agent", "agy", + "--role", "creator", + "--session", "test-t16-agy", + "--no-onboard" + ] + subprocess.run(cmd, check=True, cwd=str(mam_sandbox)) + + yaml_path = mam_sandbox / ".mam" / "agent-sessions.yaml" + with open(yaml_path) as f: + d = yaml.safe_load(f) + initial_epoch = [s for s in d["herdr_sessions"] if s["name"] == "test-t16-agy"][0]["herdr_session_epoch"] + + # Stop at 0-turns. + subprocess.run( + ["bash", str(stop_script), "--session", "test-t16-agy", "--agent", "agy"], + check=True, cwd=str(mam_sandbox) + ) + + # Wait to guarantee the epoch advances by at least 1 second. + time.sleep(1.1) + + # Resume the session (live execution, updating YAML). + res = subprocess.run([ + "bash", str(resume_script), + "--workspace", ws, + "--agent", "agy", + "--session", "test-t16-agy" + ], capture_output=True, text=True, cwd=str(mam_sandbox)) + assert res.returncode == 0, f"stderr: {res.stderr}\nstdout: {res.stdout}" + + with open(yaml_path) as f: + d = yaml.safe_load(f) + session = [s for s in d["herdr_sessions"] if s["name"] == "test-t16-agy"][0] + assert session["status"] == "running" + assert session["herdr_session_epoch"] > initial_epoch, ( + f"herdr_session_epoch was not refreshed: {session['herdr_session_epoch']} <= {initial_epoch}" + ) + assert session.get("session_id_source") == "pending-discovery" + assert session.get("session_id_verified") is False +