fix: attach_command from one builder; no kill/start_command; stop closes a row-less session (Issue #71)

- attach_command is `herdr --session <server> agent attach <herdr name>`
  (`default` written too), built by lib_py.agents.sanitize.attach_command;
  create, resume and reconcile all use it. kill_command and start_command
  are no longer written, and status --json drops start_command.
- The two raw kill-session hints (create exit 43, id_order's orphan) print a
  stop-skill command with --herdr-session instead.
- stop_session.sh closes a session that has no registry row, herdr only and
  without --agent, when herdr runs it inside this workspace; another
  workspace's session is refused (exit 5), a missing one is exit 1, one that
  survives kill-session is exit 6. The registry is not changed.
- Docs: only the SKILL.md lines these changes made wrong; the skill block
  pins follow. Tests that asserted the removed fields keep their intent
  (issue50: create's own row; issue21: no control-root path in
  attach_command; tier2: the new form and the absent fields). S11 pins the
  shim to 4243da5, which changed it on purpose.

Refs #71

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
GodopuandClaude Opus 5.5 committed 2026-10-25 15:16:53 +00:00
1 parent 9c21fa4e4f
commit e4c7c8fe9b
19 files changed
+605 -109

No files matched your search

+2 -1
View File
@@ -341,7 +341,8 @@ def wait(cwd: str, agent: str, session: str, owner_pid: int,
print( print(
f"ERROR: {m['session']}'s create/resume exited before recording it, " f"ERROR: {m['session']}'s create/resume exited before recording it, "
f"and {seen}; close it with: " f"and {seen}; close it with: "
f"HERDR_SESSION_NAME={srv} herdr kill-session -t {m['session']}", f"bash .agents/skills/multi-agent-mux-stop/scripts/stop_session.sh "
f"--session {m['session']} --herdr-session {srv}",
file=sys.stderr, flush=True, file=sys.stderr, flush=True,
) )
return 1 return 1
@@ -146,12 +146,10 @@ herdr_sessions:
cmd: claude cmd: claude
cmd_full: claude --dangerously-skip-permissions cmd_full: claude --dangerously-skip-permissions
cwd: /path/to/project cwd: /path/to/project
start_command: "HERDR_SESSION_NAME=<herdr_session> herdr new-session -d -s <SESSION_NAME> -x 140 -y 40 -c <WORKSPACE> <CMD_FULL>" attach_command: "herdr --session <herdr_session> agent attach <herdr name of SESSION_NAME>"
attach_command: "HERDR_SESSION_NAME=<herdr_session> herdr agent attach <SESSION_NAME>"
kill_command: "HERDR_SESSION_NAME=<herdr_session> herdr kill-session -t <SESSION_NAME>"
``` ```
The three commands require `source .agents/skills/lib.sh` first — `new-session`/`kill-session` are tmux-compat pseudo-commands the shim translates, and `HERDR_SESSION_NAME` is what the shim reads to route to the right isolated herdr *session* (real `herdr` has no env-var-based scoping of its own; `herdr_session: default` needs no prefix at all). `attach_command` is a native herdr command and runs from any shell: it always names the server (`default` too) and names the agent as herdr registered it (lower case, at most 32 characters: `lib_py/agents/sanitize.py`). Start and stop sessions only through the create, resume and stop skills.
## Workflow ## Workflow
@@ -258,5 +256,5 @@ herdr agent read "$SESSION_NAME" --source visible --lines 20 # TUI ready = age
- **Resuming an old conversation** → `multi-agent-mux-resume` - **Resuming an old conversation** → `multi-agent-mux-resume`
- **Killing an existing session** → `multi-agent-mux-stop` - **Killing an existing session** → `multi-agent-mux-stop`
- **Just attaching to an existing session** → `herdr agent attach <name>` (no skill needed) - **Just attaching to an existing session** → the row's `attach_command` (no skill needed)
- **One-shot print mode (claude -p "...")** → no herdr needed; use `claude-code` skill's print mode - **One-shot print mode (claude -p "...")** → no herdr needed; use `claude-code` skill's print mode
@@ -361,7 +361,7 @@ cleanup_herdr_on_error() {
if [ "$alive" = "1" ]; then if [ "$alive" = "1" ]; then
_mark_row_rolled_back "running" "rollback-failed" _mark_row_rolled_back "running" "rollback-failed"
echo "ERROR: rollback could not kill herdr session '$SESSION_NAME' (kill rc=$kill_rc) - it is still running." >&2 echo "ERROR: rollback could not kill herdr session '$SESSION_NAME' (kill rc=$kill_rc) - it is still running." >&2
echo " Clean it up with: HERDR_SESSION_NAME=${HERDR_SESSION_NAME:-default} herdr kill-session -t $SESSION_NAME" >&2 echo " Clean it up with: bash .agents/skills/multi-agent-mux-stop/scripts/stop_session.sh --session $SESSION_NAME --herdr-session ${HERDR_SESSION_NAME:-default}" >&2
exit 43 exit 43
fi fi
_mark_row_rolled_back "terminated" "rolled-back" _mark_row_rolled_back "terminated" "rolled-back"
@@ -428,11 +428,6 @@ PANE_PID=$(_herdr list-panes -t "$SESSION_NAME" -F '#{pane_pid}' 2>/dev/null ||
PANE_CWD=$(_herdr list-panes -t "$SESSION_NAME" -F '#{pane_current_path}' 2>/dev/null || echo "$WORKSPACE") PANE_CWD=$(_herdr list-panes -t "$SESSION_NAME" -F '#{pane_current_path}' 2>/dev/null || echo "$WORKSPACE")
PANE_CMD=$(_herdr list-panes -t "$SESSION_NAME" -F '#{pane_current_command}' 2>/dev/null || echo "$AGENT") PANE_CMD=$(_herdr list-panes -t "$SESSION_NAME" -F '#{pane_current_command}' 2>/dev/null || echo "$AGENT")
# 시작 명령
# NOTE: this must match what `spawn()` actually ran above — env-var-driven
# herdr server shim (HERDR_SESSION_NAME picked up by the lib.sh shim).
START_CMD="HERDR_SESSION_NAME=${HERDR_SESSION_NAME:-default} herdr new-session -d -s \"$SESSION_NAME\" -x 140 -y 40 -c \"$WORKSPACE\" \"$CMD_FULL\""
# If --onboard is specified, automatically build the onboarding prompt (if session is ready) # If --onboard is specified, automatically build the onboarding prompt (if session is ready)
if [ "$ONBOARD" = "1" ] && [ -z "$SUBMIT_JOB_PROMPT" ] && [ -z "$LAST_VISIBLE_STATUS_OVERRIDE" ]; then if [ "$ONBOARD" = "1" ] && [ -z "$SUBMIT_JOB_PROMPT" ] && [ -z "$LAST_VISIBLE_STATUS_OVERRIDE" ]; then
SUBMIT_JOB_PROMPT="You are a newly spawned $ROLE Team Leader agent in this workspace. Without making any non-standard pre-preparations or modifying files directly, proceed immediately to align yourself with the project context by performing the following tasks: SUBMIT_JOB_PROMPT="You are a newly spawned $ROLE Team Leader agent in this workspace. Without making any non-standard pre-preparations or modifying files directly, proceed immediately to align yourself with the project context by performing the following tasks:
@@ -508,12 +503,13 @@ fi
atomic_dump_yaml "$AGENT_SESSIONS_YAML" \ atomic_dump_yaml "$AGENT_SESSIONS_YAML" \
SESSION_NAME="$SESSION_NAME" AGENT="$AGENT" NOW_ISO="$NOW_ISO" \ SESSION_NAME="$SESSION_NAME" AGENT="$AGENT" NOW_ISO="$NOW_ISO" \
HERDR_EPOCH="$HERDR_EPOCH" PANE_PID="$PANE_PID" PANE_CWD="$PANE_CWD" \ HERDR_EPOCH="$HERDR_EPOCH" PANE_PID="$PANE_PID" PANE_CWD="$PANE_CWD" \
CMD_FULL="$CMD_FULL" START_CMD="$START_CMD" CHILD_PID="$CHILD_PID" \ CMD_FULL="$CMD_FULL" CHILD_PID="$CHILD_PID" \
HERDR_SESSION_NAME="${HERDR_SESSION_NAME:-default}" \ HERDR_SESSION_NAME="${HERDR_SESSION_NAME:-default}" \
MAM_WS_LABEL="$MAM_WS_LABEL" \ MAM_WS_LABEL="$MAM_WS_LABEL" \
SESSION_UUID="$SESSION_UUID" \ SESSION_UUID="$SESSION_UUID" \
LAST_VISIBLE_STATUS_OVERRIDE="${LAST_VISIBLE_STATUS_OVERRIDE:-}" \ LAST_VISIBLE_STATUS_OVERRIDE="${LAST_VISIBLE_STATUS_OVERRIDE:-}" \
DELEGATE_JOB_ID="$DELEGATE_JOB_ID" ROLE="$ROLE" CONTROL_PLANE_JSON="$CP_JSON" <<'PYEOF' DELEGATE_JOB_ID="$DELEGATE_JOB_ID" ROLE="$ROLE" CONTROL_PLANE_JSON="$CP_JSON" <<'PYEOF'
from lib_py.agents.sanitize import attach_command
name = os.environ['SESSION_NAME'] name = os.environ['SESSION_NAME']
agent = os.environ['AGENT'] agent = os.environ['AGENT']
role = os.environ['ROLE'] role = os.environ['ROLE']
@@ -567,9 +563,7 @@ entry = {
'cmd_full': os.environ['CMD_FULL'], 'cmd_full': os.environ['CMD_FULL'],
'cwd': os.environ['PANE_CWD'], 'cwd': os.environ['PANE_CWD'],
}, },
'start_command': os.environ['START_CMD'], 'attach_command': attach_command(server_name, name),
'attach_command': f'HERDR_SESSION_NAME={server_name} herdr agent attach {name}',
'kill_command': f'HERDR_SESSION_NAME={server_name} herdr kill-session -t {name}',
} }
if agent == 'claude': if agent == 'claude':
@@ -566,7 +566,7 @@ def _pin_and_verify_resume(s, agent, cwd, uuid, degraded=False):
actions.append(f"updated {id_name} id: {uuid}") actions.append(f"updated {id_name} id: {uuid}")
from lib_py.agents.sanitize import sanitize_herdr_agent_name as _sanitize from lib_py.agents.sanitize import sanitize_herdr_agent_name as _sanitize, attach_command
yaml_sessions = d.get('herdr_sessions', []) yaml_sessions = d.get('herdr_sessions', [])
yaml_session_names = {s['name'] for s in yaml_sessions if s.get('name')} yaml_session_names = {s['name'] for s in yaml_sessions if s.get('name')}
@@ -656,7 +656,6 @@ if herdr_confirmed:
_ambient = os.environ.get('HERDR_SESSION_NAME', '') _ambient = os.environ.get('HERDR_SESSION_NAME', '')
if srv == 'default' and _ambient and _ambient != 'default': if srv == 'default' and _ambient and _ambient != 'default':
srv = _ambient srv = _ambient
server_opt = f"-L {srv} " if srv != 'default' else ""
# The shim resolves this from the pane's root process. Fall back to now # The shim resolves this from the pane's root process. Fall back to now
# only if that failed: 'now' can merely over-estimate creation time, # only if that failed: 'now' can merely over-estimate creation time,
# which tightens the stale-transcript guard, whereas 0 disables it and # which tightens the stale-transcript guard, whereas 0 disables it and
@@ -674,9 +673,7 @@ if herdr_confirmed:
'herdr_server': srv, 'herdr_server': srv,
'herdr_workspace': _slug(pm['cwd']), 'herdr_workspace': _slug(pm['cwd']),
'pane': {'index': 0, 'pid': pm['pid'], 'cmd': agent, 'cmd_full': cmd_full, 'cwd': pm['cwd']}, 'pane': {'index': 0, 'pid': pm['pid'], 'cmd': agent, 'cmd_full': cmd_full, 'cwd': pm['cwd']},
'start_command': f'HERDR_SESSION_NAME={srv} herdr new-session -d -s "{name}" -x 140 -y 40 -c "{pm["cwd"]}" "{cmd_full}"', 'attach_command': attach_command(srv, name),
'attach_command': f'HERDR_SESSION_NAME={srv} herdr agent attach {name}',
'kill_command': f'herdr {server_opt}kill-session -t {name}',
'last_visible_status': 'running', 'last_visible_status': 'running',
'last_visible_note': 'auto-registered by monitor', 'last_visible_note': 'auto-registered by monitor',
} }
@@ -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 <id>` / `agy --conversation <id>`. 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 <id>` / `agy --conversation <id>`.
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/<workspace-key>/` (defaults to `~/.claude/projects/`) for claude. 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/<workspace-key>/` (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 --session <server> agent attach <name>`, where `<server>` is the row's `herdr_session` (the server it was resumed on). 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 the row's `attach_command` (`herdr --session <server> agent attach <name>`, where `<server>` is the row's `herdr_session` and `<name>` the name herdr registered).
### Resuming a `stopped` session (`stopped → running`) ### Resuming a `stopped` session (`stopped → running`)
@@ -60,7 +60,7 @@ If the resolved id is empty → for Class A agents (`agy`, `hermes`, `opencode`,
bash .agents/skills/multi-agent-mux-resume/scripts/resume_session.sh --workspace <path> --agent <agent> --session <name> bash .agents/skills/multi-agent-mux-resume/scripts/resume_session.sh --workspace <path> --agent <agent> --session <name>
``` ```
`resume_session.sh` does the whole flow: original-cwd check, control-plane binding, the session-id decision (`lib_py/agents/session_ids.py`), the herdr-alive check, the agent command (adapter `resume-spec`, or `spawn-spec` for a Class A fresh spawn), spawn (just before it, the session's own empty shell left by a server restart is closed: same session id, no agent, nothing running; `lib_py/herdr_restore.py`), 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 <server> agent attach <name>` (`<server>` = the `--herdr-session` you passed, or the row's `herdr_session`). `resume_session.sh` does the whole flow: original-cwd check, control-plane binding, the session-id decision (`lib_py/agents/session_ids.py`), the herdr-alive check, the agent command (adapter `resume-spec`, or `spawn-spec` for a Class A fresh spawn), spawn (just before it, the session's own empty shell left by a server restart is closed: same session id, no agent, nothing running; `lib_py/herdr_restore.py`), 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: the row's `attach_command`, `herdr --session <server> agent attach <name>` (`<server>` = the `--herdr-session` you passed, or the row's `herdr_session`; `<name>` = the name herdr registered).
| Option | Meaning | | Option | Meaning |
|---|---| |---|---|
@@ -136,4 +136,4 @@ herdr --session "$SERVER" agent read "$SESSION_NAME" --source visible --lines 30
- **No saved session yet** → `multi-agent-mux-create` - **No saved session yet** → `multi-agent-mux-create`
- **Killing an existing session** → `multi-agent-mux-stop` - **Killing an existing session** → `multi-agent-mux-stop`
- **Just attaching** → `herdr --session <server> agent attach <name>` (no skill needed; `<server>` = the row's `herdr_session`) - **Just attaching** → the row's `attach_command` (no skill needed; `herdr --session <server> agent attach <name>`, `<name>` = the name herdr registered)
@@ -116,6 +116,7 @@ atomic_dump_yaml "$AGENT_SESSIONS_YAML" \
MAM_WS_LABEL="$MAM_WS_LABEL" MAM_WS_LABEL_EXPLICIT="${MAM_WS_LABEL_EXPLICIT:-0}" \ MAM_WS_LABEL="$MAM_WS_LABEL" MAM_WS_LABEL_EXPLICIT="${MAM_WS_LABEL_EXPLICIT:-0}" \
ALREADY_RUNNING="$ALREADY_RUNNING" DROP_STALE_ID="$DROP_STALE_ID" \ ALREADY_RUNNING="$ALREADY_RUNNING" DROP_STALE_ID="$DROP_STALE_ID" \
CMD_FULL_OPT="$CMD_FULL_OPT" <<'PYEOF' CMD_FULL_OPT="$CMD_FULL_OPT" <<'PYEOF'
from lib_py.agents.sanitize import attach_command
name = os.environ['SESSION_NAME'] name = os.environ['SESSION_NAME']
uuid = os.environ['UUID'] uuid = os.environ['UUID']
agent = os.environ['AGENT'] agent = os.environ['AGENT']
@@ -147,9 +148,7 @@ if target is None:
'herdr_workspace': wsl, 'herdr_workspace': wsl,
'delegate_job_id': None, 'delegate_job_id': None,
'pane': {'index': 0, 'pid': int(pane_pid) if pane_pid.isdigit() else 0, 'cmd': agent, 'cwd': ws_root}, 'pane': {'index': 0, 'pid': int(pane_pid) if pane_pid.isdigit() else 0, 'cmd': agent, 'cwd': ws_root},
'start_command': f'HERDR_SESSION_NAME={server_name} herdr agent attach {name}', 'attach_command': attach_command(server_name, name),
'attach_command': f'HERDR_SESSION_NAME={server_name} herdr agent attach {name}',
'kill_command': f'HERDR_SESSION_NAME={server_name} herdr kill-session -t {name}',
} }
d.setdefault('herdr_sessions', []).append(target) d.setdefault('herdr_sessions', []).append(target)
else: else:
@@ -159,9 +158,7 @@ else:
if is_explicit or not target.get('herdr_session'): if is_explicit or not target.get('herdr_session'):
target['herdr_session'] = sn target['herdr_session'] = sn
target['herdr_server'] = sn target['herdr_server'] = sn
target['start_command'] = f'HERDR_SESSION_NAME={sn} herdr agent attach {name}' target['attach_command'] = attach_command(sn, name)
target['attach_command'] = f'HERDR_SESSION_NAME={sn} herdr agent attach {name}'
target['kill_command'] = f'HERDR_SESSION_NAME={sn} herdr kill-session -t {name}'
wsl = os.environ.get('MAM_WS_LABEL', '') wsl = os.environ.get('MAM_WS_LABEL', '')
ws_explicit = os.environ.get('MAM_WS_LABEL_EXPLICIT') == '1' ws_explicit = os.environ.get('MAM_WS_LABEL_EXPLICIT') == '1'
if wsl and (ws_explicit or not target.get('herdr_workspace')): if wsl and (ws_explicit or not target.get('herdr_workspace')):
@@ -94,11 +94,10 @@ lab-paper-pdf2md-creator-claude default running alive clau
"job_id": null, "job_id": null,
"job_status": null, "job_status": null,
"pane_cwd": "/home/.../refer_landing_page", "pane_cwd": "/home/.../refer_landing_page",
"attach_command": "HERDR_SESSION_NAME=default herdr agent attach lab-landing-page-creator-claude", "attach_command": "herdr --session default agent attach lab-landing-page-creator-claude",
"drift_classes": [], "drift_classes": [],
"pane_pid": 12345, "pane_pid": 12345,
"cmd_full": "claude --dangerously-skip-permissions --session-id <uuid>", "cmd_full": "claude --dangerously-skip-permissions --session-id <uuid>",
"start_command": "...",
"last_visible_status": "pinned" "last_visible_status": "pinned"
} }
] ]
@@ -221,11 +221,10 @@ for s in d.get('herdr_sessions', []):
'pane_cwd': pane.get('cwd'), 'pane_cwd': pane.get('cwd'),
'attach_command': s.get('attach_command'), 'attach_command': s.get('attach_command'),
'drift_classes': drift_by_name.get(name, []), 'drift_classes': drift_by_name.get(name, []),
# Additive beyond the D8 example — needed by the M1 Detail Pane # Additive beyond the D8 example (pane pid / full launch command /
# (pane pid / full launch command / start command / last banner text). # last banner text).
'pane_pid': pane.get('pid'), 'pane_pid': pane.get('pid'),
'cmd_full': pane.get('cmd_full'), 'cmd_full': pane.get('cmd_full'),
'start_command': s.get('start_command'),
'last_visible_status': s.get('last_visible_status'), 'last_visible_status': s.get('last_visible_status'),
}) })
+4 -3
View File
@@ -25,7 +25,6 @@ Stop an agent's herdr session gracefully, resolve and store the conversation ID,
- The herdr session's recorded `pane.pid / cmd / cwd / mcp_attachments` for audit - The herdr session's recorded `pane.pid / cmd / cwd / mcp_attachments` for audit
- The agent's on-disk conversation (claude `*.jsonl`, agy `conversations/*.db`) — so the user can `multi-agent-mux-resume` later - The agent's on-disk conversation (claude `*.jsonl`, agy `conversations/*.db`) — so the user can `multi-agent-mux-resume` later
- The `start_command` so a future `multi-agent-mux-create --session <name>` reproduces the same herdr spec
The stop command is always **graceful by default**: The stop command is always **graceful by default**:
1. Sends exit keys to the agent TUI (`/exit` for Claude, `/exit` for Agy) and waits 3 seconds. 1. Sends exit keys to the agent TUI (`/exit` for Claude, `/exit` for Agy) and waits 3 seconds.
@@ -35,7 +34,7 @@ The stop command is always **graceful by default**:
## Pre-flight ## Pre-flight
`stop_session.sh` checks this itself: the YAML exists and the session is registered (otherwise exit 1), and a session already `stopped` is a no-op (exit 0). `stop_session.sh` checks this itself: the YAML exists (otherwise exit 1), and a session already `stopped` is a no-op (exit 0). A session with no registry row (its create or resume died before registering it) is still closed, herdr only and with no `--agent`: only when herdr runs it inside this workspace, on the `--herdr-session` server (or `HERDR_SESSION_NAME`). The registry is not changed.
## Workflow ## Workflow
@@ -59,10 +58,12 @@ bash .agents/skills/multi-agent-mux-stop/scripts/stop_session.sh \
| Exit | Meaning | | Exit | Meaning |
|---|---| |---|---|
| 0 | Stopped; or already `stopped` (no-op, except with `--purge-conversation`, which still purges) | | 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) | | 1 | From the checks before the stop, nothing was changed: YAML not found, or the session is neither registered nor on the herdr server. **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 | | 2 | Bad arguments (including the retired `--mode` / `--capture-id` / `--graceful`), or `--agent` omitted and not resolvable |
| 3 | `--purge-conversation` without `--yes` | | 3 | `--purge-conversation` without `--yes` |
| 4 | Purge aborted: the herdr session, or an agent process still holding the conversation, survived the kill chain. Records preserved: the registry row and the conversation files are untouched, though the session itself may already be down | | 4 | Purge aborted: the herdr session, or an agent process still holding the conversation, survived the kill chain. Records preserved: the registry row and the conversation files are untouched, though the session itself may already be down |
| 5 | No registry row, and herdr runs the session outside this workspace: refused, nothing changed |
| 6 | No registry row, and its herdr session survived `kill-session`; the registry is unchanged |
| other | The registry update after the stop (YAML / DB write) failed and its code is passed through | | other | The registry update after the stop (YAML / DB write) failed and its code is passed through |
Exits 2 and 3, and exit 1 from the pre-stop checks, come before anything is stopped, so nothing was changed. On exit 1 from the registry update, or an `other` exit, the session may already be down while its row still says `running`: check with `multi-agent-mux-status`, then run the stop again (it is idempotent). Exits 2 and 3, and exit 1 from the pre-stop checks, come before anything is stopped, so nothing was changed. On exit 1 from the registry update, or an `other` exit, the session may already be down while its row still says `running`: check with `multi-agent-mux-status`, then run the stop again (it is idempotent).
@@ -28,9 +28,12 @@
# 구분은 --purge-conversation 유무로 대체되었습니다. # 구분은 --purge-conversation 유무로 대체되었습니다.
# #
# Exit codes: # Exit codes:
# 0 = success (or already-stopped no-op) | 1 = YAML not found / not registered # 0 = success (or already-stopped no-op; or a session with no row closed)
# 1 = YAML not found / not registered and not on the herdr server
# 2 = invalid args | 3 = interactive confirmation required (--yes 누락) # 2 = invalid args | 3 = interactive confirmation required (--yes 누락)
# 4 = purge aborted (herdr session or an agent process holding the conversation survived the kill chain) # 4 = purge aborted (herdr session or an agent process holding the conversation survived the kill chain)
# 5 = no row, and herdr runs the session outside this workspace (refused, nothing changed)
# 6 = no row, and its herdr session survived kill-session (registry unchanged)
set -euo pipefail set -euo pipefail
# shellcheck disable=SC1091 # shellcheck disable=SC1091
@@ -110,16 +113,50 @@ else
export HERDR_SESSION_NAME export HERDR_SESSION_NAME
fi fi
# --agent 미지정 시 레지스트리 기록으로 해석 (B-21). # Issue #71: a session with no registry row (its create/resume died before
# ① row['agent'] → ② 세션명 접미사 → ③ pane.cmd 순. 셋 다 실패하면 # registering it) is closed here - herdr only, the registry is left alone. It
# 종전과 동일하게 exit 2 (헤더 :27-30 의 종료 코드 계약 유지). # has no agent kind, so --agent is not needed. Only a session herdr runs
if [ -z "$AGENT" ]; then # inside this workspace is closed; anything else changes nothing.
AGENT="$(resolve_agent_type_from_registry "$SESSION_NAME")" || AGENT="" stop_rowless() {
[ -n "$AGENT" ] || { if [ "$PURGE" = "1" ]; then
echo "ERROR: cannot infer agent from '$SESSION_NAME'; pass --agent" >&2 echo "ERROR: session '$SESSION_NAME' not in $AGENT_SESSIONS_YAML; --purge-conversation needs its row. Nothing changed." >&2
exit 2 exit 1
} fi
fi local info cwd
info=$(herdr agent get "$SESSION_NAME" 2>/dev/null) || info=""
if [ -z "$info" ]; then
echo "ERROR: session '$SESSION_NAME' not in $AGENT_SESSIONS_YAML, and herdr server '${HERDR_SESSION_NAME:-default}' has no agent of that name. Nothing changed." >&2
exit 1
fi
cwd=$(printf '%s' "$info" | python3 -c '
import sys, json
raw = sys.stdin.read()
try:
print(json.loads(raw[raw.index("{"):]).get("result", {}).get("agent", {}).get("cwd", "") or "")
except Exception:
print("")
')
if ! PANE_CWD="$cwd" python3 -c '
import os, sys
cwd, root = os.environ.get("PANE_CWD", ""), os.environ.get("WORKSPACE_ROOT", "")
if not cwd or not root:
sys.exit(1)
cwd, root = os.path.realpath(cwd), os.path.realpath(root)
sys.exit(0 if cwd == root or cwd.startswith(root + os.sep) else 1)
'; then
echo "ERROR: refusing to stop '$SESSION_NAME': it is not in $AGENT_SESSIONS_YAML, and herdr runs it in '${cwd:-<unknown>}', outside this workspace ($WORKSPACE_ROOT). Nothing changed." >&2
exit 5
fi
echo "session '$SESSION_NAME' has no registry row: closing its herdr session only (registry unchanged)"
herdr kill-session -t "$SESSION_NAME" 2>/dev/null || true
_wait_session_gone "$SESSION_NAME" 8 || true
if herdr has-session -t "$SESSION_NAME" 2>/dev/null; then
echo "ERROR: herdr session '$SESSION_NAME' is still running after kill-session (registry unchanged)" >&2
exit 6
fi
echo "closed herdr session '$SESSION_NAME' on server '${HERDR_SESSION_NAME:-default}' (no registry row)"
exit 0
}
# 세션이 YAML 에 있는지 + 해당 row 의 워크스페이스 cwd 및 delegate_job_id 추출. # 세션이 YAML 에 있는지 + 해당 row 의 워크스페이스 cwd 및 delegate_job_id 추출.
# JSON 으로 emit — cwd 에 '|' 가 들어가도 안전 (review item 7; 기존 cwd|jid 파서 대체). # JSON 으로 emit — cwd 에 '|' 가 들어가도 안전 (review item 7; 기존 cwd|jid 파서 대체).
@@ -136,10 +173,18 @@ for s in d.get('herdr_sessions', []):
'delegate_job_logs_dir': cp.get('delegate_job_logs_dir', '') or ''})) 'delegate_job_logs_dir': cp.get('delegate_job_logs_dir', '') or ''}))
sys.exit(0) sys.exit(0)
sys.exit(7) sys.exit(7)
") || { ") || stop_rowless
echo "ERROR: session '$SESSION_NAME' not in $AGENT_SESSIONS_YAML" >&2
exit 1 # --agent 미지정 시 레지스트리 기록으로 해석 (B-21).
} # ① row['agent'] → ② 세션명 접미사 → ③ pane.cmd 순. 셋 다 실패하면
# 종전과 동일하게 exit 2 (헤더 :27-30 의 종료 코드 계약 유지).
if [ -z "$AGENT" ]; then
AGENT="$(resolve_agent_type_from_registry "$SESSION_NAME")" || AGENT=""
[ -n "$AGENT" ] || {
echo "ERROR: cannot infer agent from '$SESSION_NAME'; pass --agent" >&2
exit 2
}
fi
TARGET_CWD=$(printf '%s' "$MAPPED_DATA" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("cwd",""))') TARGET_CWD=$(printf '%s' "$MAPPED_DATA" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("cwd",""))')
DELEGATE_JOB_ID=$(printf '%s' "$MAPPED_DATA" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("job_id",""))') DELEGATE_JOB_ID=$(printf '%s' "$MAPPED_DATA" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("job_id",""))')
+34 -32
View File
@@ -61,21 +61,21 @@
{ {
"skill": "multi-agent-mux-create", "skill": "multi-agent-mux-create",
"start": 134, "start": 134,
"end": 152, "end": 150,
"lang": "yaml", "lang": "yaml",
"sha": "2dd608d04ae0", "sha": "efba73c1d7f7",
"class": "c", "class": "c",
"kind": "example", "kind": "example",
"evidence": [ "evidence": [
"multi-agent-mux-create/scripts/create_session.sh:518-545" "multi-agent-mux-create/scripts/create_session.sh:514-541"
], ],
"mismatch": null, "mismatch": null,
"note": "Output format: the row create_session.sh writes" "note": "Output format: the row create_session.sh writes"
}, },
{ {
"skill": "multi-agent-mux-create", "skill": "multi-agent-mux-create",
"start": 158, "start": 156,
"end": 160, "end": 158,
"lang": "bash", "lang": "bash",
"sha": "613003708f19", "sha": "613003708f19",
"class": "c", "class": "c",
@@ -88,8 +88,8 @@
}, },
{ {
"skill": "multi-agent-mux-create", "skill": "multi-agent-mux-create",
"start": 204, "start": 202,
"end": 208, "end": 206,
"lang": "bash", "lang": "bash",
"sha": "38836d31bf38", "sha": "38836d31bf38",
"class": "c", "class": "c",
@@ -103,8 +103,8 @@
}, },
{ {
"skill": "multi-agent-mux-create", "skill": "multi-agent-mux-create",
"start": 231, "start": 229,
"end": 253, "end": 251,
"lang": "bash", "lang": "bash",
"sha": "d90cfd2555fd", "sha": "d90cfd2555fd",
"class": "b", "class": "b",
@@ -245,7 +245,7 @@
"class": "c", "class": "c",
"kind": "example", "kind": "example",
"evidence": [ "evidence": [
"multi-agent-mux-monitor/scripts/reconcile.sh:708" "multi-agent-mux-monitor/scripts/reconcile.sh:705"
], ],
"mismatch": null, "mismatch": null,
"note": "drift B example" "note": "drift B example"
@@ -259,7 +259,7 @@
"class": "c", "class": "c",
"kind": "example", "kind": "example",
"evidence": [ "evidence": [
"multi-agent-mux-monitor/scripts/reconcile.sh:735" "multi-agent-mux-monitor/scripts/reconcile.sh:732"
], ],
"mismatch": null, "mismatch": null,
"note": "drift C example (indented fence)" "note": "drift C example (indented fence)"
@@ -385,7 +385,7 @@
"class": "c", "class": "c",
"kind": "example", "kind": "example",
"evidence": [ "evidence": [
"multi-agent-mux-status/scripts/status.sh:411" "multi-agent-mux-status/scripts/status.sh:410"
], ],
"mismatch": null, "mismatch": null,
"note": "table output example" "note": "table output example"
@@ -393,15 +393,15 @@
{ {
"skill": "multi-agent-mux-status", "skill": "multi-agent-mux-status",
"start": 74, "start": 74,
"end": 106, "end": 105,
"lang": "json", "lang": "json",
"sha": "cfea3a10dc8d", "sha": "8e55751f5e8d",
"class": "c", "class": "c",
"kind": "example", "kind": "example",
"evidence": [ "evidence": [
"multi-agent-mux-status/scripts/status.sh:31-36", "multi-agent-mux-status/scripts/status.sh:31-36",
"multi-agent-mux-status/scripts/status.sh:209-232", "multi-agent-mux-status/scripts/status.sh:209-231",
"multi-agent-mux-monitor/scripts/reconcile.sh:816" "multi-agent-mux-monitor/scripts/reconcile.sh:813"
], ],
"mismatch": [ "mismatch": [
"S1 (fixed): the example used rows/yaml_entries/unregistered; the real keys are reconcile.sh's drift object plus sessions_detail" "S1 (fixed): the example used rows/yaml_entries/unregistered; the real keys are reconcile.sh's drift object plus sessions_detail"
@@ -410,14 +410,14 @@
}, },
{ {
"skill": "multi-agent-mux-stop", "skill": "multi-agent-mux-stop",
"start": 42, "start": 41,
"end": 55, "end": 54,
"lang": "bash", "lang": "bash",
"sha": "301c347ff881", "sha": "301c347ff881",
"class": "c", "class": "c",
"kind": "invocation", "kind": "invocation",
"evidence": [ "evidence": [
"multi-agent-mux-stop/scripts/stop_session.sh:1-33" "multi-agent-mux-stop/scripts/stop_session.sh:1-36"
], ],
"mismatch": [ "mismatch": [
"T2 (fixed): the --purge-conversation example lacked --yes and would exit 3" "T2 (fixed): the --purge-conversation example lacked --yes and would exit 3"
@@ -426,22 +426,22 @@
}, },
{ {
"skill": "multi-agent-mux-stop", "skill": "multi-agent-mux-stop",
"start": 81, "start": 82,
"end": 85, "end": 86,
"lang": "-", "lang": "-",
"sha": "a973676cb2d1", "sha": "a973676cb2d1",
"class": "c", "class": "c",
"kind": "diagram", "kind": "diagram",
"evidence": [ "evidence": [
"multi-agent-mux-stop/scripts/stop_session.sh:183-197" "multi-agent-mux-stop/scripts/stop_session.sh:228-242"
], ],
"mismatch": null, "mismatch": null,
"note": "state machine" "note": "state machine"
}, },
{ {
"skill": "multi-agent-mux-stop", "skill": "multi-agent-mux-stop",
"start": 102, "start": 103,
"end": 104, "end": 105,
"lang": "bash", "lang": "bash",
"sha": "4189d73d6896", "sha": "4189d73d6896",
"class": "c", "class": "c",
@@ -454,8 +454,8 @@
}, },
{ {
"skill": "multi-agent-mux-stop", "skill": "multi-agent-mux-stop",
"start": 127, "start": 128,
"end": 145, "end": 146,
"lang": "bash", "lang": "bash",
"sha": "bcaa2a03484e", "sha": "bcaa2a03484e",
"class": "b", "class": "b",
@@ -503,9 +503,9 @@
"base_sha": "a7ed3e902cae", "base_sha": "a7ed3e902cae",
"class": "a", "class": "a",
"evidence": [ "evidence": [
"multi-agent-mux-stop/scripts/stop_session.sh:98", "multi-agent-mux-stop/scripts/stop_session.sh:101",
"multi-agent-mux-stop/scripts/stop_session.sh:139-142", "multi-agent-mux-stop/scripts/stop_session.sh:120-176",
"multi-agent-mux-stop/scripts/stop_session.sh:183-187" "multi-agent-mux-stop/scripts/stop_session.sh:228-232"
], ],
"mismatch": [ "mismatch": [
"T1: the doc offered --force-no-yaml, which no script accepts" "T1: the doc offered --force-no-yaml, which no script accepts"
@@ -568,7 +568,7 @@
"base_sha": "f71be0f26fb8", "base_sha": "f71be0f26fb8",
"class": "a", "class": "a",
"evidence": [ "evidence": [
"multi-agent-mux-create/scripts/create_session.sh:491-610" "multi-agent-mux-create/scripts/create_session.sh:486-604"
], ],
"mismatch": [], "mismatch": [],
"replacement": "bash .agents/skills/multi-agent-mux-create/scripts/create_session.sh --workspace <path> --agent <agent> --role <role>", "replacement": "bash .agents/skills/multi-agent-mux-create/scripts/create_session.sh --workspace <path> --agent <agent> --role <role>",
@@ -596,10 +596,12 @@
"2", "2",
"3", "3",
"4", "4",
"5",
"6",
"other" "other"
], ],
"passthrough": { "passthrough": {
"1": "multi-agent-mux-stop/scripts/stop_session.sh:322" "1": "multi-agent-mux-stop/scripts/stop_session.sh:367"
} }
}, },
"multi-agent-mux-create/scripts/create_session.sh": { "multi-agent-mux-create/scripts/create_session.sh": {
@@ -617,7 +619,7 @@
"other" "other"
], ],
"passthrough": { "passthrough": {
"4": "multi-agent-mux-create/scripts/create_session.sh:550" "4": "multi-agent-mux-create/scripts/create_session.sh:546"
} }
} }
}, },
+1 -1
View File
@@ -581,7 +581,7 @@ def test_t3_1_create_persists_and_forwards_a_foreign_binding(mam_sandbox, mock_h
for k, v in zip(FORWARDED_ENV, list(_binding(tmp).values())[:3]): for k, v in zip(FORWARDED_ENV, list(_binding(tmp).values())[:3]):
assert f"{k}={v}" in flags, flags assert f"{k}={v}" in flags, flags
assert "WORKSPACE_ROOT=" not in row["pane"]["cmd_full"] assert "WORKSPACE_ROOT=" not in row["pane"]["cmd_full"]
assert "WORKSPACE_ROOT=" not in row["start_command"] assert "WORKSPACE_ROOT=" not in row["attach_command"]
_assert_no_env_in_agent_args(mock_herdr) _assert_no_env_in_agent_args(mock_herdr)
+14 -6
View File
@@ -299,10 +299,11 @@ def test_s11_this_branch_does_not_change_the_generated_shim(mam_sandbox):
true at runtime. Issue #45 changed the heredoc on purpose (the bootstrap true at runtime. Issue #45 changed the heredoc on purpose (the bootstrap
starts the server with restore-on-restart off, and its review R1 passes starts the server with restore-on-restart off, and its review R1 passes
REAL_HERDR to the config check), and so did the real-server leak fix REAL_HERDR to the config check), and so did the real-server leak fix
(only a directory that is itself *-shim is skipped), so the base is (only a directory that is itself *-shim is skipped). Issue #71 changed it
that commit.""" too (one sanitize definition written in with `declare -f`, agent attach
resolved, no silent wrong name), so the base is that commit."""
repo = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) repo = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
base = os.environ.get("MAM_BASE_SHA", "525df2b") base = os.environ.get("MAM_BASE_SHA", "4243da5")
show = subprocess.run(["git", "show", "%s:.agents/skills/lib.sh" % base], show = subprocess.run(["git", "show", "%s:.agents/skills/lib.sh" % base],
capture_output=True, text=True, cwd=repo) capture_output=True, text=True, cwd=repo)
if show.returncode != 0: if show.returncode != 0:
@@ -316,16 +317,21 @@ def test_s11_this_branch_does_not_change_the_generated_shim(mam_sandbox):
def _heredoc_body(text): def _heredoc_body(text):
"""What lib.sh writes into the shim: the sanitize function it declares in
(Issue #71), then the heredoc."""
lines = text.splitlines(True) lines = text.splitlines(True)
fn = [i for i, line in enumerate(lines) if line.startswith("_sanitize_herdr_agent_name() {")]
assert len(fn) == 1, "one _sanitize_herdr_agent_name definition in lib.sh"
fn_end = next(j for j in range(fn[0], len(lines)) if lines[j].rstrip("\n") == "}")
start = None start = None
for i, line in enumerate(lines): for i, line in enumerate(lines):
if line.strip() == "cat <<'EOF' > \"$tmp_file\"": if line.strip() == "cat <<'EOF'":
start = i + 1 start = i + 1
break break
assert start is not None, "shim heredoc not found in lib.sh" assert start is not None, "shim heredoc not found in lib.sh"
for j in range(start, len(lines)): for j in range(start, len(lines)):
if lines[j].rstrip("\n") == "EOF": if lines[j].rstrip("\n") == "EOF":
return "".join(lines[start:j]) return "".join(lines[fn[0]:fn_end + 1] + lines[start:j])
raise AssertionError("unterminated shim heredoc") raise AssertionError("unterminated shim heredoc")
@@ -1272,7 +1278,9 @@ def test_s9_a_kill_that_left_the_session_alive_is_reported(mam_sandbox):
"rolled back" anyway, leaving a `running` row against a failed job.""" "rolled back" anyway, leaving a `running` row against a failed job."""
res, published, yaml_text = _run_rollback(mam_sandbox, kill_rc=1, still_alive=True) res, published, yaml_text = _run_rollback(mam_sandbox, kill_rc=1, still_alive=True)
assert res.returncode == 43, res.stdout + res.stderr assert res.returncode == 43, res.stdout + res.stderr
assert "test-sess" in res.stderr and "kill-session" in res.stderr # Issue #71: the clean-up hint is the stop skill, naming the server
assert "Clean it up with: bash .agents/skills/multi-agent-mux-stop/scripts/stop_session.sh --session test-sess --herdr-session default" in res.stderr
assert "kill-session -t" not in res.stderr
assert "rollback-failed" in yaml_text, yaml_text assert "rollback-failed" in yaml_text, yaml_text
assert "status: running" in yaml_text, "a session that is still up must not be called gone" assert "status: running" in yaml_text, "a session that is still up must not be called gone"
assert "delegate_job_id: null" in yaml_text, yaml_text assert "delegate_job_id: null" in yaml_text, yaml_text
+1 -1
View File
@@ -217,7 +217,7 @@ def test_literal_exit_rule_on_base_scripts():
"""The counting rule: heredocs and comments skipped, so create's header `exit 4` """The counting rule: heredocs and comments skipped, so create's header `exit 4`
and stop's `sys.exit(7)` are not counted.""" and stop's `sys.exit(7)` are not counted."""
assert literal_exit_codes("multi-agent-mux-resume/scripts/resume_session.sh") == {"0", "1", "2"} assert literal_exit_codes("multi-agent-mux-resume/scripts/resume_session.sh") == {"0", "1", "2"}
assert literal_exit_codes("multi-agent-mux-stop/scripts/stop_session.sh") == {"0", "1", "2", "3", "4"} assert literal_exit_codes("multi-agent-mux-stop/scripts/stop_session.sh") == {"0", "1", "2", "3", "4", "5", "6"}
assert literal_exit_codes("multi-agent-mux-create/scripts/create_session.sh") == {"0", "1", "2", "3", "42", "43", "44", "45"} assert literal_exit_codes("multi-agent-mux-create/scripts/create_session.sh") == {"0", "1", "2", "3", "42", "43", "44", "45"}
+4 -1
View File
@@ -170,7 +170,10 @@ def test_a1_create_replaces_the_monitors_registration_of_its_own_pane(mam_sandbo
assert f"NOTE: the monitor registered {name} while it was being created" in res.stdout assert f"NOTE: the monitor registered {name} while it was being created" in res.stdout
rows = [r for r in _rows(yaml_path) if r["name"] == name] rows = [r for r in _rows(yaml_path) if r["name"] == name]
assert len(rows) == 1 and rows[0]["status"] == "running" and rows[0]["role"] == "Creator" assert len(rows) == 1 and rows[0]["status"] == "running" and rows[0]["role"] == "Creator"
assert rows[0].get("last_visible_note") != "auto-registered by monitor" and rows[0].get("start_command") # create's own row: only create records how the claude id was chosen
# (the monitor's registration and the seeded row have no session_id_source)
assert rows[0].get("last_visible_note") != "auto-registered by monitor"
assert rows[0].get("session_id_source") in ("assigned", "pending-discovery")
@pytest.mark.parametrize("kind", ["real", "old-adoption", "adoption-with-job", "other-server"]) @pytest.mark.parametrize("kind", ["real", "old-adoption", "adoption-with-job", "other-server"])
+5 -3
View File
@@ -1090,7 +1090,9 @@ def test_id_order_dead_owner_marker_blocks_while_its_unregistered_herdr_session_
assert marker_a.exists() assert marker_a.exists()
assert ("srv-x", "orphan-a-agy") in probes assert ("srv-x", "orphan-a-agy") in probes
err = capsys.readouterr().err err = capsys.readouterr().err
assert "HERDR_SESSION_NAME=srv-x herdr kill-session -t orphan-a-agy" in err # Issue #71: the hint is a stop-skill command naming the server
assert "bash .agents/skills/multi-agent-mux-stop/scripts/stop_session.sh --session orphan-a-agy --herdr-session srv-x" in err
assert "kill-session" not in err
# No authoritative answer from herdr -> kept and blocks as well # No authoritative answer from herdr -> kept and blocks as well
unknown_sessions.add("orphan-a-agy") unknown_sessions.add("orphan-a-agy")
@@ -1137,7 +1139,7 @@ def test_id_order_dead_owner_marker_blocks_while_its_unregistered_herdr_session_
assert probes == [("", "same-agy")] assert probes == [("", "same-agy")]
assert json.loads(same.read_text())["owner_pid"] == live.pid assert json.loads(same.read_text())["owner_pid"] == live.pid
err = capsys.readouterr().err err = capsys.readouterr().err
assert "its herdr session is alive" in err and "kill-session -t same-agy" in err assert "its herdr session is alive" in err and "bash .agents/skills/multi-agent-mux-stop/scripts/stop_session.sh --session same-agy --herdr-session " in err
unknown_sessions.add("same-agy") unknown_sessions.add("same-agy")
assert wait_same() == 1 assert wait_same() == 1
assert json.loads(same.read_text())["owner_pid"] == live.pid assert json.loads(same.read_text())["owner_pid"] == live.pid
@@ -1188,7 +1190,7 @@ def test_id_order_create_waits_for_orphan_herdr_session_of_a_killed_create(
res = create_b() res = create_b()
assert res.returncode == 1, res.stdout + res.stderr assert res.returncode == 1, res.stdout + res.stderr
assert name_a in res.stderr and "kill-session -t " + name_a in res.stderr assert name_a in res.stderr and "bash .agents/skills/multi-agent-mux-stop/scripts/stop_session.sh --session " + name_a + " --herdr-session orphan-ws" in res.stderr
assert "its herdr session is alive" in res.stderr assert "its herdr session is alive" in res.stderr
assert name_b not in _read_rows(mam_sandbox) assert name_b not in _read_rows(mam_sandbox)
assert name_b not in json.loads(Path(mock_herdr).read_text()).get("agents", {}) assert name_b not in json.loads(Path(mock_herdr).read_text()).get("agents", {})
+447
View File
@@ -0,0 +1,447 @@
"""Issue #71: herdr agent names in the registry and the lib.sh shim.
- attach_command is `herdr --session <server> agent attach <herdr name>` from
one builder (create, resume, reconcile); kill_command/start_command are gone.
- the shim's `agent attach` resolves its target like get/read/prompt.
- one bash sanitize rule, equal to the Python one, with no silent wrong name.
- an old registry is migrated by the writer (YAML and DB), with an entry point.
- stop closes a session that has no registry row, only inside this workspace.
- stall_watch works out the herdr name once.
"""
import hashlib
import json
import os
import shutil
import sqlite3
import subprocess
import sys
from pathlib import Path
import pytest
import yaml
REPO = Path(__file__).resolve().parent.parent
SKILLS = REPO / ".agents" / "skills"
sys.path.insert(0, str(SKILLS))
from lib_py.agents.sanitize import sanitize_herdr_agent_name # noqa: E402
LONG = "lab-issue71-a-very-long-branch-name-creator-claude" # > 32 characters
UPPER = "Lab-Upper-creator-claude"
PLAIN = "lab-plain-creator-claude" # needs no conversion
STOP = "bash .agents/skills/multi-agent-mux-stop/scripts/stop_session.sh"
def test_the_names_need_converting_and_the_plain_one_does_not():
assert len(LONG) > 32 and sanitize_herdr_agent_name(LONG) != LONG
assert sanitize_herdr_agent_name(UPPER) == UPPER.lower()
assert sanitize_herdr_agent_name(PLAIN) == PLAIN
@pytest.mark.parametrize("name", [LONG, UPPER, PLAIN])
@pytest.mark.parametrize("server", ["default", "mam-x", None])
def test_attach_command_names_the_server_and_the_herdr_name(name, server):
from lib_py.agents.sanitize import attach_command
want = "herdr --session %s agent attach %s" % (server or "default", sanitize_herdr_agent_name(name))
assert attach_command(server, name) == want
def test_attach_command_quotes_a_server_that_needs_it():
from lib_py.agents.sanitize import attach_command
assert attach_command("my server", PLAIN) == "herdr --session 'my server' agent attach " + PLAIN
# --- writers -----------------------------------------------------------------
def _rows(sandbox):
return yaml.safe_load((sandbox / ".mam" / "agent-sessions.yaml").read_text())["herdr_sessions"]
def _db_rows(sandbox):
conn = sqlite3.connect(str(sandbox / ".mam" / "agent-sessions.db"))
try:
return {r[0]: json.loads(r[1]) for r in conn.execute("SELECT name, data FROM sessions")}
finally:
conn.close()
def _set_mock(mock_herdr, **kw):
state = json.loads(Path(mock_herdr).read_text())
state.update(kw)
Path(mock_herdr).write_text(json.dumps(state))
def _assert_new_form(row, server):
assert row["attach_command"] == "herdr --session %s agent attach %s" % (
server, sanitize_herdr_agent_name(row["name"]))
assert "kill_command" not in row and "start_command" not in row
@pytest.mark.parametrize("name", [LONG, UPPER, PLAIN])
@pytest.mark.parametrize("server", ["default", "mam-iso71"])
def test_create_writes_the_attach_command(mam_sandbox, mock_herdr, mock_agents, name, server):
_set_mock(mock_herdr, strict_agent_names=True)
script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh"
res = subprocess.run(["bash", str(script), "--workspace", str(mam_sandbox), "--agent", "claude",
"--role", "creator", "--session", name, "--herdr-session", server, "--no-onboard"],
capture_output=True, text=True, cwd=str(mam_sandbox), timeout=300)
assert res.returncode == 0, res.stdout + res.stderr
(row,) = [r for r in _rows(mam_sandbox) if r["name"] == name]
_assert_new_form(row, server)
agents = json.loads(Path(mock_herdr).read_text())["agents"]
assert sanitize_herdr_agent_name(name) in agents, "the attach name is the one herdr registered"
def _seed(sandbox, rows):
lib = sandbox / ".agents" / "skills" / "lib.sh"
mutation = "d['herdr_sessions'] = " + repr(rows) + "\n"
res = subprocess.run(["bash", "-c", 'source "$1" && atomic_dump_yaml "$AGENT_SESSIONS_YAML"', "_", str(lib)],
input=mutation, capture_output=True, text=True)
assert res.returncode == 0, res.stderr
def _resume_update(sandbox, name, server):
script = sandbox / ".agents" / "skills" / "multi-agent-mux-resume" / "scripts" / "update_yaml_resumed.sh"
return subprocess.run(["bash", str(script), "--session", name, "--uuid", "11111111-2222-3333-4444-555555555555",
"--agent", "claude", "--herdr-session", server, "--workspace", str(sandbox)],
capture_output=True, text=True, cwd=str(sandbox), timeout=120)
@pytest.mark.parametrize("name", [LONG, UPPER, PLAIN])
@pytest.mark.parametrize("server", ["default", "mam-iso71"])
@pytest.mark.parametrize("has_row", [True, False])
def test_resume_writes_the_attach_command(mam_sandbox, mock_herdr, mock_agents, name, server, has_row):
if has_row:
_seed(mam_sandbox, [{"name": name, "status": "stopped", "role": "creator", "herdr_session": "old-srv",
"herdr_server": "old-srv", "pane": {"cwd": str(mam_sandbox), "cmd": "claude"}}])
res = _resume_update(mam_sandbox, name, server)
assert res.returncode == 0, res.stdout + res.stderr
(row,) = [r for r in _rows(mam_sandbox) if r["name"] == name]
assert row["status"] == "running"
_assert_new_form(row, server)
@pytest.mark.parametrize("name", [LONG, UPPER, PLAIN])
def test_reconcile_registers_with_the_attach_command(mam_sandbox, mock_herdr, mock_agents, name):
# The pane must exist, or reconcile never reaches the registration.
_set_mock(mock_herdr, agents={name: {
"agent": "claude", "status": "running", "cwd": str(mam_sandbox), "workspace_id": "w1",
"pid": 9999, "pane_id": "w1:p71", "command": "claude", "buffer": "Ready"}})
assert not any(r["name"] == name for r in _rows(mam_sandbox))
script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-monitor" / "scripts" / "reconcile.sh"
res = subprocess.run(["bash", str(script), "--once"], capture_output=True, text=True,
cwd=str(mam_sandbox), timeout=300)
assert res.returncode == 0, res.stdout + res.stderr
rows = [r for r in _rows(mam_sandbox) if r["name"] == name]
assert len(rows) == 1, "reconcile wrote no row: " + res.stdout + res.stderr
assert rows[0]["last_visible_note"] == "auto-registered by monitor"
_assert_new_form(rows[0], rows[0]["herdr_session"])
# --- shim: agent attach --------------------------------------------------------
def _run_lib(sandbox, body, env=None):
lib = sandbox / ".agents" / "skills" / "lib.sh"
return subprocess.run(["bash", "-c", 'source "$1"\n' + body, "_", str(lib)], capture_output=True,
text=True, cwd=str(sandbox), env=dict(os.environ, **(env or {})), timeout=120)
@pytest.mark.parametrize("name", [LONG, UPPER, PLAIN])
def test_wrapper_attach_calls_herdr_with_the_herdr_name(mam_sandbox, mock_herdr, name):
herdr_name = sanitize_herdr_agent_name(name)
_set_mock(mock_herdr, strict_agent_names=True, agents={herdr_name: {
"agent": "claude", "status": "running", "cwd": str(mam_sandbox), "workspace_id": "w1",
"pid": 9999, "pane_id": "w1:p71", "command": "claude", "buffer": "Ready"}})
res = _run_lib(mam_sandbox, 'herdr agent attach "$0" --takeover'.replace("$0", name))
calls = [c for c in json.loads(Path(mock_herdr).read_text())["calls"] if c[:2] == ["agent", "attach"]]
assert calls == [["agent", "attach", herdr_name, "--takeover"]], (calls, res.stderr)
# --- one sanitize rule ----------------------------------------------------------
NAMES = ["", "plain", "Upper-Case", "9lead", "-dash", "has space/and.dot", "x" * 32, "y" * 33,
LONG, LONG.upper(), "creator-claude-fix-issue-99-long-branch"]
def test_lib_sh_has_one_definition():
text = (SKILLS / "lib.sh").read_text()
assert text.count("_sanitize_herdr_agent_name() {") == 1
assert "00000000" not in text
assert "lambda s: s.lower()[:32]" not in text
def test_the_generated_shim_carries_the_same_definition(mam_sandbox):
res = _run_lib(mam_sandbox, "declare -f _sanitize_herdr_agent_name")
assert res.returncode == 0, res.stderr
shim = (mam_sandbox / ".mam" / "shim" / "herdr").read_text()
assert shim.startswith("#!/usr/bin/env bash\n")
assert shim.count("_sanitize_herdr_agent_name ()") == 1
assert res.stdout.strip() in shim
def _definition(tmp_path):
"""The function lib.sh defines, as bash prints it (sourced with a tmp workspace)."""
res = subprocess.run(["bash", "-c", 'source "$1" >/dev/null 2>&1; declare -f _sanitize_herdr_agent_name',
"_", str(SKILLS / "lib.sh")], capture_output=True, text=True,
env=dict(os.environ, WORKSPACE_ROOT=str(tmp_path / "defn-ws")))
assert res.returncode == 0 and res.stdout, res.stderr
return res.stdout
def _tools_dir(tmp_path, tools):
d = tmp_path / ("bin-" + "-".join(tools))
d.mkdir()
for t in tools:
src = shutil.which(t)
assert src, t
(d / t).symlink_to(src)
return d
def _sanitize_with_path(defn, path_dir, name):
bash = shutil.which("bash")
return subprocess.run([bash, "--noprofile", "--norc", "-c", defn + '\n_sanitize_herdr_agent_name "$1"', "_", name],
capture_output=True, text=True, env={"PATH": str(path_dir), "LC_ALL": "C"})
@pytest.mark.parametrize("hashers", [["shasum"], ["sha1sum"], ["openssl"], ["python3"]])
def test_each_hasher_alone_matches_python(tmp_path, hashers):
if not shutil.which(hashers[0]):
pytest.skip(hashers[0] + " not installed")
defn = _definition(tmp_path)
d = _tools_dir(tmp_path, ["tr", "sed", "awk", "cut"] + hashers)
for name in NAMES:
res = _sanitize_with_path(defn, d, name)
assert res.returncode == 0, (name, res.stderr)
assert res.stdout == sanitize_herdr_agent_name(name) + "\n", name
def test_no_hasher_at_all_is_an_error_not_a_name(tmp_path):
defn = _definition(tmp_path)
d = _tools_dir(tmp_path, ["tr", "sed", "awk", "cut"])
res = _sanitize_with_path(defn, d, LONG)
assert res.returncode != 0 and res.stdout == "" and "ERROR" in res.stderr
# a short name needs no hash and still works
res = _sanitize_with_path(defn, d, UPPER)
assert res.returncode == 0 and res.stdout == UPPER.lower() + "\n"
def _no_hash_bin(tmp_path):
"""Stubs: shasum/sha1sum/openssl fail; python3 runs, except the name hash."""
d = tmp_path / "nohash"
d.mkdir()
for t in ("shasum", "sha1sum", "openssl"):
(d / t).write_text("#!/bin/sh\nexit 1\n")
(d / t).chmod(0o755)
(d / "python3").write_text('#!/bin/sh\ncase "$*" in *hashlib.sha1*) exit 1 ;; esac\nexec %s "$@"\n'
% shutil.which("python3"))
(d / "python3").chmod(0o755)
return d
def test_spawn_does_not_go_on_without_the_name(mam_sandbox, mock_herdr, tmp_path):
d = _no_hash_bin(tmp_path)
body = 'export PATH="%s:$PATH"\nherdr new-session -d -s "%s" -c "%s" claude' % (d, LONG, mam_sandbox)
res = _run_lib(mam_sandbox, body)
assert res.returncode != 0, res.stdout + res.stderr
assert "cannot make the herdr name" in res.stderr
calls = json.loads(Path(mock_herdr).read_text())["calls"]
assert not [c for c in calls if c[:2] in (["agent", "start"], ["pane", "split"], ["workspace", "create"])], calls
def test_lookups_report_failure_without_the_name(mam_sandbox, mock_herdr, tmp_path):
d = _no_hash_bin(tmp_path)
for verb in ('has-session -t "%s"', 'agent get "%s"', 'kill-session -t "%s"'):
res = _run_lib(mam_sandbox, 'export PATH="%s:$PATH"\nherdr %s' % (d, verb % LONG))
assert res.returncode != 0 and "cannot make the herdr name" in res.stderr, (verb, res.stderr)
# --- registry migration ----------------------------------------------------------
def _old_rows(ws):
def row(name, status, srv):
return {"name": name, "status": status, "role": "creator", "herdr_session": srv, "herdr_server": srv,
"pane": {"index": 0, "pid": 1, "cmd": "claude", "cwd": str(ws)},
"start_command": "HERDR_SESSION_NAME=%s herdr new-session -d -s %s" % (srv, name),
"attach_command": "HERDR_SESSION_NAME=%s herdr agent attach %s" % (srv, name),
"kill_command": "HERDR_SESSION_NAME=%s herdr kill-session -t %s" % (srv, name)}
return [row(LONG, "running", "mam-x"), row(UPPER, "stopped", "default"), row(PLAIN, "terminated", "mam-y")]
def _write_old_registry(ws, with_db):
mam = ws / ".mam"
mam.mkdir(parents=True, exist_ok=True)
top = {"orchestrator_uuids": ["u-1"], "snapshot": {"taken_at": "2026-10-01T00:00:00Z"}}
d = dict(top, herdr_sessions=_old_rows(ws))
(mam / "agent-sessions.yaml").write_text(yaml.safe_dump(d, sort_keys=False))
if with_db:
conn = sqlite3.connect(str(mam / "agent-sessions.db"))
conn.execute("CREATE TABLE state (id INTEGER PRIMARY KEY, data TEXT)")
conn.execute("CREATE TABLE sessions (name TEXT PRIMARY KEY, status TEXT, pane_cwd TEXT, data JSON)")
conn.execute("INSERT INTO state VALUES (1, ?)", (json.dumps(top),))
for s in d["herdr_sessions"]:
conn.execute("INSERT INTO sessions VALUES (?, ?, ?, ?)", (s["name"], s["status"], s["pane"]["cwd"], json.dumps(s)))
conn.commit()
conn.close()
return mam / "agent-sessions.yaml"
def _entry(yaml_path):
return subprocess.run([sys.executable, "-m", "lib_py.registry_format", "--yaml", str(yaml_path)],
capture_output=True, text=True, env=dict(os.environ, PYTHONPATH=str(SKILLS)), timeout=60)
def _state(ws):
conn = sqlite3.connect(str(ws / ".mam" / "agent-sessions.db"))
try:
return json.loads(conn.execute("SELECT data FROM state WHERE id=1").fetchone()[0])
finally:
conn.close()
def test_migrate_is_pure_and_idempotent(tmp_path):
from lib_py import registry_format
old = {"herdr_sessions": _old_rows(tmp_path), "snapshot": {}}
before = json.dumps(old, sort_keys=True)
new, n = registry_format.migrate(old)
assert json.dumps(old, sort_keys=True) == before, "the input is not modified"
assert n == 3 and new[registry_format.FORMAT_KEY] == registry_format.CURRENT == 2
for s in new["herdr_sessions"]:
_assert_new_form(s, s["herdr_session"])
again, n2 = registry_format.migrate(new)
assert n2 == 0 and again == new
assert registry_format.stored_version({}) == 1
@pytest.mark.parametrize("with_db", [True, False])
def test_entry_point_migrates_yaml_and_db_once(tmp_path, with_db):
ws = tmp_path / "ws"
yaml_path = _write_old_registry(ws, with_db)
res = _entry(yaml_path)
assert res.returncode == 0, res.stderr
assert res.stdout == "registry-format: 1 -> 2, rows changed: 3\n"
y = yaml.safe_load(yaml_path.read_text())
db = _db_rows(ws)
assert y["registry_format"] == 2 and _state(ws)["registry_format"] == 2
assert y["orchestrator_uuids"] == ["u-1"] and y["snapshot"]["taken_at"] == "2026-10-01T00:00:00Z"
assert {s["name"]: s for s in y["herdr_sessions"]} == db, "YAML and DB agree"
for s in y["herdr_sessions"]:
_assert_new_form(s, s["herdr_session"])
assert {s["name"]: s["status"] for s in y["herdr_sessions"]} == {LONG: "running", UPPER: "stopped", PLAIN: "terminated"}
text = yaml_path.read_text()
res = _entry(yaml_path)
assert res.returncode == 0, res.stderr
assert res.stdout == "registry-format: 2 -> 2, rows changed: 0\n"
assert yaml_path.read_text() == text and _db_rows(ws) == db
def test_any_write_migrates_and_rewrites_the_yaml(own_workspace_root, tmp_path):
"""A mutation that changes nothing still leaves YAML and DB in the new form (point A)."""
ws = tmp_path / "ws"
yaml_path = _write_old_registry(ws, with_db=True)
res = subprocess.run(["bash", "-c", 'source "$1" && atomic_dump_yaml "$2"', "_", str(SKILLS / "lib.sh"), str(yaml_path)],
input="pass\n", capture_output=True, text=True, timeout=60)
assert res.returncode == 0, res.stderr
y = yaml.safe_load(yaml_path.read_text())
assert y["registry_format"] == 2
assert {s["name"]: s for s in y["herdr_sessions"]} == _db_rows(ws)
for s in y["herdr_sessions"]:
_assert_new_form(s, s["herdr_session"])
def test_a_new_registry_is_written_current(own_workspace_root, tmp_path):
yaml_path = tmp_path / "fresh" / ".mam" / "agent-sessions.yaml"
yaml_path.parent.mkdir(parents=True)
res = subprocess.run(["bash", "-c", 'source "$1" && atomic_dump_yaml "$2"', "_", str(SKILLS / "lib.sh"), str(yaml_path)],
input="d['herdr_sessions'] = []\n", capture_output=True, text=True, timeout=60)
assert res.returncode == 0, res.stderr
assert yaml.safe_load(yaml_path.read_text())["registry_format"] == 2
def test_entry_point_fails_without_a_registry(tmp_path):
res = _entry(tmp_path / "none" / "agent-sessions.yaml")
assert res.returncode != 0 and "error" in res.stderr and res.stdout == ""
# --- stop: a session with no registry row ----------------------------------------
def _registry_bytes(sandbox):
out = {}
for f in ("agent-sessions.yaml", "agent-sessions.db"):
p = sandbox / ".mam" / f
out[f] = hashlib.sha256(p.read_bytes()).hexdigest() if p.exists() else None
return out
def _stop(sandbox, name, *extra):
script = sandbox / ".agents" / "skills" / "multi-agent-mux-stop" / "scripts" / "stop_session.sh"
return subprocess.run(["bash", str(script), "--session", name, "--herdr-session", "default"] + list(extra),
capture_output=True, text=True, cwd=str(sandbox), timeout=120)
def _agent(cwd, pane):
return {"agent": "claude", "status": "running", "cwd": str(cwd), "workspace_id": "w1",
"pid": 9999, "pane_id": pane, "command": "claude", "buffer": "Ready"}
@pytest.fixture
def registry_with_a_row(mam_sandbox):
_seed(mam_sandbox, [{"name": "other-creator-claude", "status": "stopped", "role": "creator",
"pane": {"cwd": str(mam_sandbox), "cmd": "claude"}}])
return _registry_bytes(mam_sandbox)
def test_stop_closes_a_rowless_session_of_this_workspace(mam_sandbox, mock_herdr, registry_with_a_row):
# no agent suffix and no row: there is no agent kind, and none is needed
_set_mock(mock_herdr, agents={"orphan71": _agent(mam_sandbox / "sub", "w1:p71")})
res = _stop(mam_sandbox, "orphan71")
assert res.returncode == 0, res.stdout + res.stderr
assert "orphan71" not in json.loads(Path(mock_herdr).read_text())["agents"]
assert _registry_bytes(mam_sandbox) == registry_with_a_row
def test_stop_refuses_a_rowless_session_of_another_workspace(mam_sandbox, mock_herdr, registry_with_a_row, tmp_path_factory):
elsewhere = tmp_path_factory.mktemp("elsewhere")
_set_mock(mock_herdr, agents={"foreign71-creator-claude": _agent(elsewhere, "w1:p72")})
res = _stop(mam_sandbox, "foreign71-creator-claude")
assert res.returncode == 5, res.stdout + res.stderr
assert "outside this workspace" in res.stderr
assert "foreign71-creator-claude" in json.loads(Path(mock_herdr).read_text())["agents"]
calls = json.loads(Path(mock_herdr).read_text())["calls"]
assert not [c for c in calls if c[:2] == ["pane", "close"]], calls
assert _registry_bytes(mam_sandbox) == registry_with_a_row
def test_stop_reports_a_rowless_session_that_is_not_there(mam_sandbox, mock_herdr, registry_with_a_row):
_set_mock(mock_herdr, strict_agent_names=True)
res = _stop(mam_sandbox, "absent71")
assert res.returncode == 1, res.stdout + res.stderr
assert "has no agent of that name" in res.stderr
assert _registry_bytes(mam_sandbox) == registry_with_a_row
def test_stop_without_a_row_purges_nothing(mam_sandbox, mock_herdr, registry_with_a_row):
_set_mock(mock_herdr, agents={"orphan72": _agent(mam_sandbox, "w1:p73")})
res = _stop(mam_sandbox, "orphan72", "--purge-conversation", "--yes")
assert res.returncode == 1, res.stdout + res.stderr
assert "orphan72" in json.loads(Path(mock_herdr).read_text())["agents"]
assert _registry_bytes(mam_sandbox) == registry_with_a_row
# --- stall_watch -----------------------------------------------------------------
def test_stall_watch_works_out_the_herdr_name_once(monkeypatch):
from lib_py import stall_watch
seen = []
real = stall_watch.sanitize_herdr_agent_name
monkeypatch.setattr(stall_watch, "sanitize_herdr_agent_name", lambda n: seen.append(n) or real(n))
class Args(object):
target, agent, herdr_session, registry_dir, job = LONG, None, "mam-x", "/nonexistent", "j"
sw = stall_watch.StallWatch(Args())
assert seen == [LONG]
assert sw.herdr_name == sw.src.name == sanitize_herdr_agent_name(LONG)
assert sw.src.target == LONG and sw.src.agent == "claude"
+5 -1
View File
@@ -217,7 +217,11 @@ def test_stop_is_already_stopped_not_found(mam_sandbox):
assert res.returncode == 1 assert res.returncode == 1
def test_stop_session_invalid_agent_suffix(mam_sandbox): def test_stop_session_invalid_agent_suffix(mam_sandbox):
"""Test stop_session.sh fails when agent cannot be inferred from session name.""" """Test stop_session.sh fails when agent cannot be inferred from session name.
Issue #71: a session with no row needs no agent kind (stop closes it herdr
only), so the registered row here carries no agent, suffix or pane.cmd."""
(mam_sandbox / ".mam" / "agent-sessions.yaml").write_text(
"herdr_sessions:\n- name: bad-session-name\n status: running\n pane: {}\n")
script_path = mam_sandbox / "skills" / "multi-agent-mux-stop" / "scripts" / "stop_session.sh" script_path = mam_sandbox / "skills" / "multi-agent-mux-stop" / "scripts" / "stop_session.sh"
res = subprocess.run(["bash", str(script_path), "--session", "bad-session-name"], capture_output=True, text=True) res = subprocess.run(["bash", str(script_path), "--session", "bad-session-name"], capture_output=True, text=True)
assert res.returncode == 2 assert res.returncode == 2
+10 -11
View File
@@ -214,13 +214,13 @@ def test_comp_create_herdr_session_default_preserved(mam_sandbox, mock_herdr, mo
assert s["name"] == "custom-proj-default-claude" assert s["name"] == "custom-proj-default-claude"
assert s["herdr_session"] == "default" assert s["herdr_session"] == "default"
assert s["herdr_server"] == "default" assert s["herdr_server"] == "default"
assert "HERDR_SESSION_NAME=default" in s["start_command"] # Issue #71: the native form, `default` named too; no start/kill_command
assert "HERDR_SESSION_NAME=default" in s["attach_command"] assert s["attach_command"] == "herdr --session default agent attach custom-proj-default-claude"
assert "HERDR_SESSION_NAME=default" in s["kill_command"] assert "start_command" not in s and "kill_command" not in s
def test_comp_create_herdr_session_yaml_propagation(mam_sandbox, mock_herdr, mock_agents): def test_comp_create_herdr_session_yaml_propagation(mam_sandbox, mock_herdr, mock_agents):
"""Verify HERDR_SESSION_NAME propagation into start_command / herdr_session YAML field.""" """Verify HERDR_SESSION_NAME propagation into attach_command / herdr_session YAML field."""
script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh" script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh"
res = subprocess.run([ res = subprocess.run([
"bash", str(script), "bash", str(script),
@@ -242,9 +242,8 @@ def test_comp_create_herdr_session_yaml_propagation(mam_sandbox, mock_herdr, moc
assert s["name"] == "custom-proj-creator-claude" assert s["name"] == "custom-proj-creator-claude"
assert s["herdr_session"] == "isolated-suite-01" assert s["herdr_session"] == "isolated-suite-01"
assert s["herdr_server"] == "isolated-suite-01" assert s["herdr_server"] == "isolated-suite-01"
assert "HERDR_SESSION_NAME=isolated-suite-01" in s["start_command"] assert s["attach_command"] == "herdr --session isolated-suite-01 agent attach custom-proj-creator-claude"
assert "HERDR_SESSION_NAME=isolated-suite-01" in s["attach_command"] assert "start_command" not in s and "kill_command" not in s
assert "HERDR_SESSION_NAME=isolated-suite-01" in s["kill_command"]
def test_comp_create_herdr_workspace_parsing_and_env_fallback(mam_sandbox, mock_herdr, mock_agents): def test_comp_create_herdr_workspace_parsing_and_env_fallback(mam_sandbox, mock_herdr, mock_agents):
@@ -332,9 +331,9 @@ def test_comp_create_herdr_workspace_yaml_propagation(mam_sandbox, mock_herdr, m
assert s["herdr_session"] == "isolated-sock-01" assert s["herdr_session"] == "isolated-sock-01"
assert s["herdr_server"] == "isolated-sock-01" assert s["herdr_server"] == "isolated-sock-01"
assert s["herdr_workspace"] == "distinct-ws-label" assert s["herdr_workspace"] == "distinct-ws-label"
assert "distinct-ws-label" not in s["start_command"]
assert "distinct-ws-label" not in s["attach_command"] assert "distinct-ws-label" not in s["attach_command"]
assert "distinct-ws-label" not in s["kill_command"] assert s["attach_command"] == "herdr --session isolated-sock-01 agent attach custom-ws-creator-claude"
assert "start_command" not in s and "kill_command" not in s
def test_create_does_not_inherit_a_stale_workspace_label(mam_sandbox, mock_herdr, mock_agents): def test_create_does_not_inherit_a_stale_workspace_label(mam_sandbox, mock_herdr, mock_agents):
@@ -573,8 +572,8 @@ d['herdr_sessions'] = [{{
assert s["status"] == "running" assert s["status"] == "running"
assert s["herdr_session"] == "NEW-HERDR-SESSION" assert s["herdr_session"] == "NEW-HERDR-SESSION"
assert s["herdr_server"] == "NEW-HERDR-SESSION" assert s["herdr_server"] == "NEW-HERDR-SESSION"
assert "HERDR_SESSION_NAME=NEW-HERDR-SESSION" in s["attach_command"] assert s["attach_command"] == "herdr --session NEW-HERDR-SESSION agent attach test-proj-creator-claude"
assert "HERDR_SESSION_NAME=NEW-HERDR-SESSION" in s["kill_command"] assert "start_command" not in s and "kill_command" not in s
def test_comp_resume_herdr_workspace_propagation(mam_sandbox, mock_herdr, mock_agents): def test_comp_resume_herdr_workspace_propagation(mam_sandbox, mock_herdr, mock_agents):