diff --git a/.agents/skills/multi-agent-mux-create/SKILL.md b/.agents/skills/multi-agent-mux-create/SKILL.md index 089c4a0..60c95c2 100644 --- a/.agents/skills/multi-agent-mux-create/SKILL.md +++ b/.agents/skills/multi-agent-mux-create/SKILL.md @@ -161,7 +161,7 @@ bash .agents/skills/multi-agent-mux-create/scripts/create_session.sh --workspace | `--role` | Required. Assigned role | | `--session` (`--name`) | herdr session name (default: `derive_session_name`) | | `--wrapper` | claude only: use the `~/.local/bin/` wrapper even if it is not present | -| `--dry-run` | Print what would be spawned; spawn and write nothing | +| `--dry-run` | Print what would be spawned; no spawn and no registry (YAML/DB) update. The checks before it still run, and on macOS they remove the quarantine attribute from the agent binary | | `--herdr-session` (`--herdr-server`) | Isolated herdr server name | | `--herdr-workspace` | herdr workspace label recorded in the registry (a label only, never a socket) | | `--submit-job PROMPT` | Register a delegated job with this prompt and send it to the new agent | @@ -176,7 +176,7 @@ bash .agents/skills/multi-agent-mux-create/scripts/create_session.sh --workspace | 2 | Bad arguments; control-plane binding refused for a worktree; whitespace in a control-plane path that must be forwarded | | 3 | A herdr session of that name already exists (`multi-agent-mux-resume`, or `multi-agent-mux-stop` first) | | 4 | The YAML already has a `running` row of that name. The YAML write raises it after spawn, and the herdr session is rolled back | -| 42 | The TUI never became ready and the process is gone | +| 42 | The TUI never became ready and the process is gone, or it is alive and `MAM_KILL_ON_READY_TIMEOUT=1` (rolled back) | | 43 | The rollback could not kill the herdr session; it is still running | | other | A later step failed and its code is passed through. The EXIT trap (`cleanup_herdr_on_error`) rolls back the herdr session and closes the onboarding job as `cancelled`; if that kill fails, the exit is 43 instead | diff --git a/.agents/skills/multi-agent-mux-resume/SKILL.md b/.agents/skills/multi-agent-mux-resume/SKILL.md index 74194a0..e7ed42b 100644 --- a/.agents/skills/multi-agent-mux-resume/SKILL.md +++ b/.agents/skills/multi-agent-mux-resume/SKILL.md @@ -27,7 +27,7 @@ Three cases this skill handles: 1. **herdr is dead, conversation lives** — `agent-sessions.yaml` has the UUID. The JSONL/db is on disk. Re-spawn the herdr session + run `claude -r ` / `agy --conversation `. 2. **herdr is alive but empty** — You started a session with `multi-agent-mux-create` but haven't sent a message yet (so no session id was assigned). The user can either send their first message (and the id is auto-assigned), or you can read the *workspace's* most recent conversation from `$HOME_DIR/.gemini/antigravity-cli/cache/last_conversations.json` (defaults to `~/.gemini/...`) for agy, or the latest `*.jsonl` in `$CLAUDE_PROJECT_DIR//` (defaults to `~/.claude/projects/`) for claude. -3. **herdr is alive AND the agent inside is already running** — `resume_session.sh` only updates the row and exits 0. It does not attach and does not check the agent's permission flags (#45); attach with `herdr agent attach `. +3. **herdr is alive AND the agent inside is already running** — `resume_session.sh` only updates the row and exits 0. It does not attach and does not check the agent's permission flags (#45); attach with `herdr --session agent attach `, where `` is the row's `herdr_session` (the server it was resumed on). ### Resuming a `stopped` session (`stopped → running`) @@ -60,7 +60,7 @@ If both are empty → for Class A agents (`agy`, `hermes`, `opencode`, `codex`), bash .agents/skills/multi-agent-mux-resume/scripts/resume_session.sh --workspace --agent --session ``` -`resume_session.sh` does the whole flow: original-cwd check, control-plane binding, session-id resolution (`resolve_session_id.sh`), the herdr-alive check, the agent command (adapter `resume-spec`, or `spawn-spec` for a Class A fresh spawn), spawn, start-up dialogs, and the YAML update (`update_yaml_resumed.sh`, which also publishes a `progress "resumed"` event when the row has a `delegate_job_id`). It never attaches; attaching is a separate step: `herdr agent attach `. +`resume_session.sh` does the whole flow: original-cwd check, control-plane binding, session-id resolution (`resolve_session_id.sh`), the herdr-alive check, the agent command (adapter `resume-spec`, or `spawn-spec` for a Class A fresh spawn), spawn, start-up dialogs, and the YAML update (`update_yaml_resumed.sh`, which also publishes a `progress "resumed"` event when the row has a `delegate_job_id`). It never attaches; attaching is a separate step that names the server, because the server the script exports does not reach the calling shell and the native `herdr` binary does not route on environment variables: `herdr --session agent attach ` (`` = the `--herdr-session` you passed, or the row's `herdr_session`). | Option | Meaning | |---|---| @@ -70,7 +70,7 @@ bash .agents/skills/multi-agent-mux-resume/scripts/resume_session.sh --workspace | `--herdr-session` (`--herdr-server`) | Isolated herdr server name | | `--herdr-workspace` | herdr workspace label | | `--auto-trust` / `--no-auto-trust` | Answer a start-up directory-trust dialog or leave it to a human (`MAM_AUTO_TRUST`; default: answer) | -| `--dry-run` | Resolve the command and print it; write nothing | +| `--dry-run` | Resolve the command and print it; no spawn and no registry (YAML/DB) update. The checks before it still run, and on macOS they remove the quarantine attribute from the agent binary | | Exit | Meaning | |---|---| @@ -101,8 +101,12 @@ A session is resumed only in the directory it ran in (its row's `pane.cwd`): the ## Verification ```bash +# The server the session runs on (its row's herdr_session); the native herdr +# binary needs it as --session, an exported variable is not enough. +SERVER=$(python3 -c "import yaml; d = yaml.safe_load(open('.mam/agent-sessions.yaml')); print([s for s in d['herdr_sessions'] if s['name'] == '$SESSION_NAME'][0].get('herdr_session') or 'default')") + # 1. herdr alive with the right cmd (real native command, no lib.sh needed) -herdr agent get "$SESSION_NAME" | python3 -c " +herdr --session "$SERVER" agent get "$SESSION_NAME" | python3 -c " import sys, json a = json.load(sys.stdin)['result']['agent'] print(f\"cmd={a['agent']} cwd={a['cwd']}\") @@ -119,7 +123,7 @@ print(f' pane.cmd_full: {s[\"pane\"][\"cmd_full\"]}') # 3. TUI shows resumed conversation (real native command, no lib.sh needed) sleep 5 -herdr agent read "$SESSION_NAME" --source visible --lines 30 +herdr --session "$SERVER" agent read "$SESSION_NAME" --source visible --lines 30 # look for the previous message at top of the buffer (claude) or last_visible_status set (agy) ``` @@ -132,4 +136,4 @@ herdr agent read "$SESSION_NAME" --source visible --lines 30 - **No saved session yet** → `multi-agent-mux-create` - **Killing an existing session** → `multi-agent-mux-stop` -- **Just attaching** → `herdr agent attach ` (no skill needed) +- **Just attaching** → `herdr --session agent attach ` (no skill needed; `` = the row's `herdr_session`) diff --git a/.agents/skills/multi-agent-mux-stop/SKILL.md b/.agents/skills/multi-agent-mux-stop/SKILL.md index 6900b90..fe9d90f 100644 --- a/.agents/skills/multi-agent-mux-stop/SKILL.md +++ b/.agents/skills/multi-agent-mux-stop/SKILL.md @@ -58,7 +58,7 @@ bash .agents/skills/multi-agent-mux-stop/scripts/stop_session.sh \ | Exit | Meaning | |---|---| -| 0 | Stopped; or already `stopped` (no-op) | +| 0 | Stopped; or already `stopped` (no-op, except with `--purge-conversation`, which still purges) | | 1 | From the checks before the stop, nothing was changed: YAML not found, or the session is not registered. **The registry update after the stop also exits 1** (the row disappeared from the YAML during the run) | | 2 | Bad arguments (including the retired `--mode` / `--capture-id` / `--graceful`), or `--agent` omitted and not resolvable | | 3 | `--purge-conversation` without `--yes` | diff --git a/tests/data/skill_code_blocks.json b/tests/data/skill_code_blocks.json index 6b4c811..4c91a05 100644 --- a/tests/data/skill_code_blocks.json +++ b/tests/data/skill_code_blocks.json @@ -339,9 +339,9 @@ { "skill": "multi-agent-mux-resume", "start": 103, - "end": 124, + "end": 128, "lang": "bash", - "sha": "f4bddf5ba4d1", + "sha": "8a468dfa43bd", "class": "b", "kind": "verification", "evidence": [], @@ -492,7 +492,8 @@ ], "replacement": "bash .agents/skills/multi-agent-mux-resume/scripts/resume_session.sh --workspace --agent --session ", "script": "multi-agent-mux-resume/scripts/resume_session.sh", - "note": "Workflow re-wrote resume_session.sh in bash" + "note": "Workflow re-wrote resume_session.sh in bash", + "base_fingerprint": "cd4000a4afae" }, { "skill": "multi-agent-mux-stop", @@ -511,7 +512,8 @@ ], "replacement": "bash .agents/skills/multi-agent-mux-stop/scripts/stop_session.sh", "script": "multi-agent-mux-stop/scripts/stop_session.sh", - "note": "pre-flight (registered? already stopped?); replaced by one prose line, the Workflow block already calls the entry point" + "note": "pre-flight (registered? already stopped?); replaced by one prose line, the Workflow block already calls the entry point", + "base_fingerprint": "f2e5380dbaa4" }, { "skill": "multi-agent-mux-create", @@ -531,7 +533,8 @@ ], "replacement": "bash .agents/skills/multi-agent-mux-create/scripts/create_session.sh --workspace --agent --role ", "script": "multi-agent-mux-create/scripts/create_session.sh", - "note": "pre-flight; replaced by prose, the has_real_herdr vs command -v pitfall kept as prose" + "note": "pre-flight; replaced by prose, the has_real_herdr vs command -v pitfall kept as prose", + "base_fingerprint": "6caf3573a587" }, { "skill": "multi-agent-mux-create", @@ -554,7 +557,8 @@ ], "replacement": "bash .agents/skills/multi-agent-mux-create/scripts/create_session.sh --workspace --agent --role ", "script": "multi-agent-mux-create/scripts/create_session.sh", - "note": "Workflow re-wrote create_session.sh in bash" + "note": "Workflow re-wrote create_session.sh in bash", + "base_fingerprint": "bc8eec474b36" }, { "skill": "multi-agent-mux-create", @@ -569,7 +573,8 @@ "mismatch": [], "replacement": "bash .agents/skills/multi-agent-mux-create/scripts/create_session.sh --workspace --agent --role ", "script": "multi-agent-mux-create/scripts/create_session.sh", - "note": "manual \"append this row\" step; the script writes the row. The lib.sh shim note (base 248-251) moved to prose under Output format" + "note": "manual \"append this row\" step; the script writes the row. The lib.sh shim note (base 248-251) moved to prose under Output format", + "base_fingerprint": "b8ff0badf537" } ], "entry_points": { @@ -620,5 +625,19 @@ "C6: create_session.sh header lists exit 4 \"YAML append failure\"; there is no literal `exit 4`, but the YAML write raises SystemExit(4) for a duplicate running row (create_session.sh:515) and it passes through, so 4 IS a real exit code (the plan assumed it was not)", "R6: resume's \"The script above re-passes them\" and \"as done in Workflow above\" pointed at the removed block; reworded (same for create's Verification note)", "R7 (review 08e1f1b2): stop's table said exit 1 is always pre-stop; the registry update after the kill chain also raises SystemExit(1) (stop_session.sh:310) when the row disappeared" + ], + "superseded": [ + { + "skill": "multi-agent-mux-create", + "base_start": 263, + "base_end": 266, + "lang": "bash", + "base_sha": "ceb96a8c1293", + "class": "c", + "kind": "invocation", + "replacement": "bash .agents/skills/multi-agent-mux-create/scripts/create_session.sh", + "note": "The base call example under 'Updating agent-sessions.yaml' (with the nonexistent agent-sessions-yaml-edit script above it, prose_mismatches C4) is folded into the Workflow call block; no information lost.", + "base_fingerprint": "b6a8a25279c5" + } ] -} \ No newline at end of file +} diff --git a/tests/test_issue42_skill_blocks.py b/tests/test_issue42_skill_blocks.py index 0b979cd..505fb77 100644 --- a/tests/test_issue42_skill_blocks.py +++ b/tests/test_issue42_skill_blocks.py @@ -4,8 +4,10 @@ tests/data/skill_code_blocks.json lists every fenced block of the eight SKILL.md files with its class - (b) a procedure no script offers yet, (c) an example, call or diagram - and the (a) blocks removed because the entry script already does them. These tests fail when a SKILL.md block changes without the -inventory, when an (a) block comes back, or when an exit-code table drifts from -the entry script. +inventory, when a deleted block's body comes back (by exact sha or by a +whitespace-insensitive fingerprint, whatever the inventory says about it), or +when an exit-code table drifts from the entry script. A (c) block folded into +another call is recorded under "superseded". """ import hashlib import json @@ -127,6 +129,55 @@ def test_no_duplicate_block_left(): assert r["class"] == "a", r +def fingerprint(body): + """A deleted body's identity that survives reindenting and reflowing.""" + return hashlib.sha256(" ".join(body.split()).encode()).hexdigest()[:12] + + +def _returned_bodies(texts, gone): + """(skill, start, which) for each current block whose body is a deleted one.""" + hits = [] + for skill, text in texts.items(): + for start, _end, _lang, sha, body in parse_blocks(text): + for g in gone: + if sha == g["base_sha"] or fingerprint(body) == g["base_fingerprint"]: + hits.append((skill, start, f"{g['skill']}:{g['base_start']}")) + return hits + + +# 2b. A deleted body stays deleted - even when the inventory is updated to list +# it again under another class (review R3). +def test_no_deleted_body_came_back(): + gone = DATA["removed"] + DATA["superseded"] + texts = {s: skill_text(s) for s in SKILL_NAMES} + assert _returned_bodies(texts, gone) == [] + + +def test_a_returned_body_is_caught_reindented_and_with_the_inventory_updated(): + """The guard itself: a current block posing as a deleted one is found by its + exact sha and, reindented, by its fingerprint; listing it in the inventory + does not hide it (that check never reads DATA["blocks"]).""" + skill = "multi-agent-mux-stop" + start, _end, _lang, sha, body = parse_blocks(skill_text(skill))[0] + fake = {"skill": skill, "base_start": 1, "base_sha": sha, "base_fingerprint": fingerprint(body)} + assert _returned_bodies({skill: skill_text(skill)}, [fake]) == [(skill, start, f"{skill}:1")] + reindented = "\n".join(" " + line for line in body.split("\n")) + other = {"skill": skill, "base_start": 1, "base_sha": "0" * 12, "base_fingerprint": fingerprint(reindented)} + assert _returned_bodies({skill: skill_text(skill)}, [other]) == [(skill, start, f"{skill}:1")] + + +# 2c. A deleted (c) block folded into another call is recorded too (review C1): +# with it the 37 base blocks are all accounted for. +def test_superseded_blocks_are_recorded_and_replaced(): + assert DATA["superseded"], "the folded create call is recorded" + for r in DATA["superseded"]: + assert r["class"] == "c" and r["kind"] in KINDS, r + bodies = [b[4] for b in parse_blocks(skill_text(r["skill"]))] + assert any(r["replacement"] in body for body in bodies), r + for r in DATA["removed"] + DATA["superseded"]: + assert re.fullmatch(r"[0-9a-f]{12}", r["base_fingerprint"]), r + + # 3. A removed block is replaced by the entry-point call. @pytest.mark.parametrize("removed", DATA["removed"], ids=lambda r: f"{r['skill']}:{r['base_start']}") def test_removed_block_replaced_by_entry_call(removed):