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:
1 parent
9c21fa4e4f
commit
e4c7c8fe9b
19 files changed
+605
-109
No files matched your search
@@ -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'),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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",""))')
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
|||||||
@@ -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", {})
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
Reference in new issue
Block a user