Compare commits

..
10 Commits
Author SHA1 Message Date
GodopuandClaude Opus 5.5 5f6065344f fix(lib.sh): load_state_json strict treats a missing DB table as unreadable (Issue #71)
A DB without its sessions table (or without both tables) was read as an
empty registry in strict mode, so stop closed a registered session
row-less and exited 0. Strict mode now fails on any OperationalError;
the non-strict reader is unchanged.

Refs #71

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 22:15:02 +00:00
GodopuandClaude Opus 5.5 a47a4683f3 test: review round 2 of Issue #71 (line breaks, unreadable registry, shim anchor)
- parity and create with real line breaks in the name;
- stop with malformed YAML (no DB) or a corrupt DB refuses (exit 1);
- test_layout: the shim heredoc anchor follows the { ... } > "$tmp_file"
  form (the rule it checks is unchanged);
- S11: base re-pinned to 0fc162e, which changes the shim's sanitize body.

Refs #71

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 19:31:18 +00:00
GodopuandClaude Opus 5.5 8b829647f4 fix(stop): a registry that cannot be read is not "no row" (Issue #71)
load_state_json printed {} on a YAML/DB read error, so a registered
session was closed row-less (exit 0) and its stop workflow skipped.
stop now reads the registry strictly and exits 1 before any herdr call.
Pins of the shifted stop_session.sh lines updated.

Refs #71

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 19:31:18 +00:00
GodopuandClaude Opus 5.5 0fc162e8cd fix(lib.sh): line breaks in a name become '-' like Python's; load_state_json strict (Issue #71)
- The ASCII branch of _sanitize_herdr_agent_name translates line breaks
  before sed (line by line) and $(...) (drops a trailing one) can lose
  them: "newline-creator-claude\n" was started as newline-creator-claude
  while its attach_command named newline-creator-claude-.
- `load_state_json strict` exits 1 (stderr) when the YAML or DB cannot be
  read instead of printing {}; without the argument nothing changes.

Refs #71

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 19:31:10 +00:00
GodopuandClaude Opus 5.5 a6fb39ba9f test: review round 1 of Issue #71 (names, row-less stop server, migration retry)
- create with a non-ASCII name: the attach target is the name herdr has;
  bash/Python parity for non-ASCII and option-like names; no python3 is
  an error for a non-ASCII name.
- row-less stop without --herdr-session uses HERDR_SESSION_NAME; an
  unreadable row is not taken for no row.
- a retry (entry point, or any write) repairs a YAML a failed migration
  left in format 1.
- tier3: the "cannot infer agent" case registers its row, as tier1 does.
- S11: base re-pinned to 5cd72d2, which changes the shim's sanitize body.

Refs #71

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 17:35:58 +00:00
GodopuandClaude Opus 5.5 9410913da9 docs(stop): exit 1 also covers a row that could not be read (Issue #71)
Refs #71

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 17:35:58 +00:00
GodopuandClaude Opus 5.5 bd61ef532f refactor(stall_watch): HerdrSource needs the herdr name, no fallback to the original (Issue #71)
Refs #71

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 17:00:25 +00:00
GodopuandClaude Opus 5.5 015075077c fix(registry): a write repairs a YAML that a failed migration left in format 1 (Issue #71)
The writer commits the migrated DB before it replaces the YAML. If that
replacement failed, the DB said format 2 and the next run did nothing.
When the DB is current the writer now also reads the YAML's format and
rewrites a YAML that is older or missing.

Refs #71

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 17:00:25 +00:00
GodopuandClaude Opus 5.5 263a72483a fix(stop): a row-less stop uses the environment's server; only "no row" is row-less (Issue #71)
With no --herdr-session the server is chosen as create_session.sh chose
it (HERDR_SESSION_NAME, else the workspace's), not always derived from
the workspace. The row-less path runs only when the lookup says the row
is missing (exit 7); any other lookup failure stops with exit 1 as
before. Pins of the shifted stop_session.sh lines updated.

Refs #71

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 17:00:25 +00:00
GodopuandClaude Opus 5.5 5cd72d2df3 fix(lib.sh): the herdr name of a non-ASCII or option-like name equals Python's (Issue #71)
tr/sed lowercase differently from str.lower() outside ASCII, so a name
like "İstanbul-creator-claude" was started as x--stanbul-... while the
row's attach_command named i-stanbul-.... A non-ASCII name is now
lowercased and replaced by python3 (fail closed without it), and the
name is read with printf, not echo ("-n" no longer vanishes).

Refs #71

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 17:00:17 +00:00
11 changed files with 198 additions and 27 deletions

No files matched your search

+27 -8
View File
@@ -37,15 +37,26 @@ AGENT_SESSIONS_YAML="${AGENT_SESSIONS_YAML:-$WORKSPACE_ROOT/.mam/agent-sessions.
# (Issue #71). The herdr shim gets this same definition: _init_herdr_isolation
# writes it in with `declare -f`. A long name needs its SHA-1 suffix; with no
# shasum, sha1sum, openssl or python3 to make it, this fails (stderr, nonzero)
# rather than return a name herdr never registered.
# rather than return a name herdr never registered. A name that is not ASCII
# is lowercased and replaced by python3 (tr/sed differ from str.lower() there),
# and fails the same way without it. Line breaks become '-' before sed (which
# works line by line) and before $(...) could drop a trailing one.
_sanitize_herdr_agent_name() {
local n="$1"
if [ -z "$n" ]; then
printf 'agent\n'
return 0
fi
local s
s=$(echo "$n" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9_-]/-/g')
local s=""
if [ -z "$(printf '%s' "$n" | LC_ALL=C tr -d '\000-\177')" ]; then
s=$(printf '%s' "$n" | tr '\n' '-' | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9_-]/-/g')
elif command -v python3 >/dev/null 2>&1; then
s=$(python3 -c 'import re,sys; sys.stdout.write(re.sub(r"[^a-z0-9_-]", "-", sys.argv[1].lower()))' "$n") || s=""
fi
if [ -z "$s" ]; then
echo "ERROR: cannot make the herdr name of '$n': it is not ASCII and there is no python3 to convert it" >&2
return 1
fi
if [[ ! "$s" =~ ^[a-z] ]]; then s="x-$s"; fi
if [ "${#s}" -gt 32 ]; then
local h=""
@@ -1580,10 +1591,14 @@ herdr() {
# ---------------------------------------------------------------------------
# NOTE: Retained as small inline PYEOF block (39 lines) per Plan Rev.2 (Job 98393a97).
# Small blocks have minimal overhead and high local cohesion; extraction into lib_py is reserved for large blocks (>200 lines).
# `load_state_json strict` (Issue #71) fails (stderr, exit 1) when the YAML or
# DB cannot be read, a DB table missing included, instead of printing {} - for
# a caller that acts on a session being absent.
load_state_json() {
env_python "$AGENT_SESSIONS_YAML" <<'PYEOF'
env_python "$AGENT_SESSIONS_YAML" "MAM_STATE_READ=${1:-}" <<'PYEOF'
import os, sys, sqlite3, json, yaml
yaml_path = os.environ['YAML_PATH']
strict = os.environ.get('MAM_STATE_READ') == 'strict'
db_path = os.path.splitext(yaml_path)[0] + '.db'
d = {}
db_sessions = []
@@ -1596,20 +1611,24 @@ try:
if row:
d = json.loads(row[0])
except sqlite3.OperationalError:
pass
if strict:
raise
try:
cursor = conn.execute('SELECT data FROM sessions')
for r in cursor.fetchall():
db_sessions.append(json.loads(r[0]))
d['herdr_sessions'] = db_sessions
except sqlite3.OperationalError:
pass
if strict:
raise
conn.close()
elif os.path.exists(yaml_path):
with open(yaml_path) as f:
d = yaml.safe_load(f) or {}
except Exception:
pass
except Exception as e:
if strict:
print('ERROR: cannot read the registry %s: %s' % (yaml_path, e), file=sys.stderr)
sys.exit(1)
# Clean lone surrogates to prevent UnicodeEncodeError on stdout print
def clean_surrogates(obj):
if isinstance(obj, str):
+11
View File
@@ -124,6 +124,17 @@ def atomic_dump_yaml_main():
if from_version < registry_format.CURRENT:
d, migrated_rows = registry_format.migrate(d)
force_yaml = True
elif row:
# The DB is current; its YAML copy may not be (a migration whose
# YAML rewrite failed after the DB commit). Then rewrite it.
y = None
if os.path.exists(yaml_path):
try:
with open(yaml_path) as f:
y = yaml.safe_load(f)
except Exception:
y = None
force_yaml = registry_format.stored_version(y) < registry_format.CURRENT
old_sessions = get_all_sessions_status(d)
# a mutation that replaces a row the monitor registered for a pane it
+2 -2
View File
@@ -155,12 +155,12 @@ class HerdrError(Exception):
class HerdrSource(object):
"""Every herdr and OS read the judgement needs, in one place."""
def __init__(self, target, agent, session=None, name=None):
def __init__(self, target, agent, session=None, *, name):
self.target, self.session = target, session or None
# `name` is the name herdr knows the agent by (#66), worked out once
# by StallWatch (#71); the kind is read off the original name, whose
# suffix a long name's hash replaces.
self.name = name or target
self.name = name
self.agent = resolve_agent(agent, target)
adapter = get_adapter(self.agent) if self.agent else None
self.resident = tuple(adapter.resident_children) if adapter else ()
+1 -1
View File
@@ -58,7 +58,7 @@ bash .agents/skills/multi-agent-mux-stop/scripts/stop_session.sh \
| Exit | Meaning |
|---|---|
| 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 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) |
| 1 | From the checks before the stop, nothing was changed: YAML not found, its row could not be read, 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 |
| 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 |
@@ -29,7 +29,7 @@
#
# Exit codes:
# 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
# 1 = YAML not found / its row unreadable / not registered and not on the herdr server
# 2 = invalid args | 3 = interactive confirmation required (--yes 누락)
# 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)
@@ -106,6 +106,7 @@ if [ "$PURGE" = "1" ]; then
trap 'rm -f "$WORKSPACE_ROOT/.mam/purging-$SESSION_NAME"' EXIT
fi
AMBIENT_HERDR_SESSION="${HERDR_SESSION_NAME:-}"
if [ -n "$HERDR_SERVER_OPT" ]; then
export HERDR_SESSION_NAME="$HERDR_SERVER_OPT"
else
@@ -116,12 +117,17 @@ fi
# Issue #71: a session with no registry row (its create/resume died before
# registering it) is closed here - herdr only, the registry is left alone. It
# has no agent kind, so --agent is not needed. Only a session herdr runs
# inside this workspace is closed; anything else changes nothing.
# inside this workspace is closed; anything else changes nothing. With no row
# the server is chosen as create_session.sh chose it: --herdr-session, else
# the environment's HERDR_SESSION_NAME, else the workspace's server.
stop_rowless() {
if [ "$PURGE" = "1" ]; then
echo "ERROR: session '$SESSION_NAME' not in $AGENT_SESSIONS_YAML; --purge-conversation needs its row. Nothing changed." >&2
exit 1
fi
if [ -z "$HERDR_SERVER_OPT" ] && [ -n "$AMBIENT_HERDR_SESSION" ] && [ "$AMBIENT_HERDR_SESSION" != "default" ]; then
export HERDR_SESSION_NAME="$AMBIENT_HERDR_SESSION"
fi
local info cwd
info=$(herdr agent get "$SESSION_NAME" 2>/dev/null) || info=""
if [ -z "$info" ]; then
@@ -160,7 +166,12 @@ sys.exit(0 if cwd == root or cwd.startswith(root + os.sep) else 1)
# 세션이 YAML 에 있는지 + 해당 row 의 워크스페이스 cwd 및 delegate_job_id 추출.
# JSON 으로 emit — cwd 에 '|' 가 들어가도 안전 (review item 7; 기존 cwd|jid 파서 대체).
MAPPED_DATA=$(MAM_STATE_JSON="$(load_state_json)" SESSION_NAME="$SESSION_NAME" python3 -c "
# A registry that cannot be read is not "no row" (Issue #71): stop before herdr.
STATE_JSON=$(load_state_json strict) || {
echo "ERROR: cannot read $AGENT_SESSIONS_YAML, so whether '$SESSION_NAME' has a row is unknown. Nothing changed." >&2
exit 1
}
MAPPED_DATA=$(MAM_STATE_JSON="$STATE_JSON" SESSION_NAME="$SESSION_NAME" python3 -c "
import sys, os, json
name = os.environ['SESSION_NAME']
d = json.loads(os.environ.get('MAM_STATE_JSON', '{}'))
@@ -173,7 +184,13 @@ for s in d.get('herdr_sessions', []):
'delegate_job_logs_dir': cp.get('delegate_job_logs_dir', '') or ''}))
sys.exit(0)
sys.exit(7)
") || stop_rowless
") || MAPPED_RC=$?
# 7 = no row; any other failure (registry unreadable) stops as before.
[ "${MAPPED_RC:-0}" -ne 7 ] || stop_rowless
if [ "${MAPPED_RC:-0}" -ne 0 ]; then
echo "ERROR: cannot read the row of '$SESSION_NAME' from $AGENT_SESSIONS_YAML" >&2
exit 1
fi
# --agent 미지정 시 레지스트리 기록으로 해석 (B-21).
# ① row['agent'] → ② 세션명 접미사 → ③ pane.cmd 순. 셋 다 실패하면
+4 -4
View File
@@ -433,7 +433,7 @@
"class": "c",
"kind": "diagram",
"evidence": [
"multi-agent-mux-stop/scripts/stop_session.sh:228-242"
"multi-agent-mux-stop/scripts/stop_session.sh:245-259"
],
"mismatch": null,
"note": "state machine"
@@ -504,8 +504,8 @@
"class": "a",
"evidence": [
"multi-agent-mux-stop/scripts/stop_session.sh:101",
"multi-agent-mux-stop/scripts/stop_session.sh:120-176",
"multi-agent-mux-stop/scripts/stop_session.sh:228-232"
"multi-agent-mux-stop/scripts/stop_session.sh:123-193",
"multi-agent-mux-stop/scripts/stop_session.sh:245-249"
],
"mismatch": [
"T1: the doc offered --force-no-yaml, which no script accepts"
@@ -601,7 +601,7 @@
"other"
],
"passthrough": {
"1": "multi-agent-mux-stop/scripts/stop_session.sh:367"
"1": "multi-agent-mux-stop/scripts/stop_session.sh:384"
}
},
"multi-agent-mux-create/scripts/create_session.sh": {
+1 -1
View File
@@ -303,7 +303,7 @@ def test_s11_this_branch_does_not_change_the_generated_shim(mam_sandbox):
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__)))
base = os.environ.get("MAM_BASE_SHA", "4243da5")
base = os.environ.get("MAM_BASE_SHA", "0fc162e")
show = subprocess.run(["git", "show", "%s:.agents/skills/lib.sh" % base],
capture_output=True, text=True, cwd=repo)
if show.returncode != 0:
+1 -1
View File
@@ -96,7 +96,7 @@ def test_the_1x_capture_still_reads_empty():
@pytest.mark.parametrize("name", HOME_SCREENS)
def test_stall_watch_sees_the_2x_home_box_empty(name, monkeypatch):
src = stall_watch.HerdrSource("t", "opencode")
src = stall_watch.HerdrSource("t", "opencode", name="t")
monkeypatch.setattr(src, "_herdr", lambda *args: _pane(name))
assert src.input_empty() is True
+124 -5
View File
@@ -29,6 +29,8 @@ 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
UNI = "\u0130stanbul-creator-claude" # str.lower() differs from tr here
NL = "newline-creator-claude\n" # $(...) would drop the line break
STOP = "bash .agents/skills/multi-agent-mux-stop/scripts/stop_session.sh"
@@ -77,7 +79,7 @@ def _assert_new_form(row, server):
assert "kill_command" not in row and "start_command" not in row
@pytest.mark.parametrize("name", [LONG, UPPER, PLAIN])
@pytest.mark.parametrize("name", [LONG, UPPER, PLAIN, UNI, NL])
@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)
@@ -160,7 +162,10 @@ def test_wrapper_attach_calls_herdr_with_the_herdr_name(mam_sandbox, mock_herdr,
# --- 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"]
LONG, LONG.upper(), "creator-claude-fix-issue-99-long-branch", "-n", "-e", "a\\nb",
NL, "a\nb", "\n", "x\r\ny", "Up\nper"]
UNI_NAMES = [UNI, "caf\u00e9-creator-claude", "\u00fc" * 40, "kelvin-\u212a", "\u65e5\u672c-creator-claude",
"\u0130" * 20 + "-creator-claude", "\u00fc\n"]
def test_lib_sh_has_one_definition():
@@ -216,6 +221,26 @@ def test_each_hasher_alone_matches_python(tmp_path, hashers):
assert res.stdout == sanitize_herdr_agent_name(name) + "\n", name
@pytest.mark.parametrize("hashers", [["python3"], ["shasum", "python3"]])
def test_a_name_that_is_not_ascii_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 UNI_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_a_name_that_is_not_ascii_needs_python3(tmp_path):
defn = _definition(tmp_path)
d = _tools_dir(tmp_path, ["tr", "sed", "awk", "cut"])
for name in UNI_NAMES:
res = _sanitize_with_path(defn, d, name)
assert res.returncode != 0 and res.stdout == "" and "not ASCII" in res.stderr, (name, res.stderr)
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"])
@@ -351,6 +376,41 @@ def test_any_write_migrates_and_rewrites_the_yaml(own_workspace_root, tmp_path):
_assert_new_form(s, s["herdr_session"])
@pytest.mark.parametrize("retry", ["entry-point", "any-write"])
def test_a_retry_repairs_the_yaml_a_failed_migration_left_behind(own_workspace_root, tmp_path, monkeypatch, retry):
"""The DB is committed before the YAML is replaced; if that fails, the next run rewrites the YAML."""
from lib_py import registry_format
ws = tmp_path / "ws"
yaml_path = _write_old_registry(ws, with_db=True)
real_replace, failed = os.replace, []
def replace_once(src, dst, *a, **kw):
if os.path.abspath(str(dst)) == str(yaml_path) and not failed:
failed.append(dst)
raise OSError("injected: YAML not replaced")
return real_replace(src, dst, *a, **kw)
monkeypatch.setenv("YAML_PATH", "")
monkeypatch.setenv("AGENT_SESSIONS_MUTATION", "")
monkeypatch.setattr(os, "replace", replace_once)
assert registry_format.main(["--yaml", str(yaml_path)]) == 1 and failed
monkeypatch.setattr(os, "replace", real_replace)
assert _state(ws)["registry_format"] == 2
assert registry_format.stored_version(yaml.safe_load(yaml_path.read_text())) == 1
if retry == "entry-point":
res = _entry(yaml_path)
else:
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), "YAML and DB agree"
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)
@@ -375,10 +435,12 @@ def _registry_bytes(sandbox):
return out
def _stop(sandbox, name, *extra):
def _stop(sandbox, name, *extra, server="default", env=None):
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)
flag = ["--herdr-session", server] if server else []
return subprocess.run(["bash", str(script), "--session", name] + flag + list(extra),
capture_output=True, text=True, cwd=str(sandbox), timeout=120,
env=dict(os.environ, **(env or {})))
def _agent(cwd, pane):
@@ -422,6 +484,63 @@ def test_stop_reports_a_rowless_session_that_is_not_there(mam_sandbox, mock_herd
assert _registry_bytes(mam_sandbox) == registry_with_a_row
def test_stop_without_a_row_uses_the_environments_server(mam_sandbox, mock_herdr, registry_with_a_row):
"""No --herdr-session: the server is HERDR_SESSION_NAME, as create chose it, not one derived from the workspace."""
_set_mock(mock_herdr, agents={"orphan73": _agent(mam_sandbox, "w1:p74")}, calls=[])
res = _stop(mam_sandbox, "orphan73", server=None, env={"HERDR_SESSION_NAME": "wanted-env-server"})
assert res.returncode == 0, res.stdout + res.stderr
assert "wanted-env-server" in res.stdout
calls = json.loads(Path(mock_herdr).read_text())["calls"]
# every call on an agent or pane names that server (`session list` and `config check` name none)
targeted = [c for c in calls if "agent" in c[:3] or "pane" in c[:3]]
assert targeted and all(c[:2] == ["--session", "wanted-env-server"] for c in targeted), calls
assert ["--session", "wanted-env-server", "agent", "get", "orphan73"] in calls
assert _registry_bytes(mam_sandbox) == registry_with_a_row
def test_stop_does_not_take_an_unreadable_row_for_no_row(mam_sandbox, mock_herdr):
"""Only "no such row" leads to the row-less path; a lookup that fails otherwise stops."""
db = mam_sandbox / ".mam" / "agent-sessions.db"
if db.exists():
db.unlink()
(mam_sandbox / ".mam" / "agent-sessions.yaml").write_text("herdr_sessions:\n- 42\n")
_set_mock(mock_herdr, agents={"orphan74": _agent(mam_sandbox, "w1:p75")})
res = _stop(mam_sandbox, "orphan74")
assert res.returncode == 1, res.stdout + res.stderr
assert "cannot read the row" in res.stderr
assert "orphan74" in json.loads(Path(mock_herdr).read_text())["agents"]
@pytest.mark.parametrize("broken", ["yaml-without-db", "db", "no-sessions-table", "no-tables"])
def test_stop_does_not_take_an_unreadable_registry_for_no_row(mam_sandbox, mock_herdr, broken):
"""load_state_json's {} on a read error is not "no row": a registered session is not closed row-less."""
name = "orphan75-creator-claude"
_seed(mam_sandbox, [{"name": name, "status": "running", "role": "creator", "agent": "claude",
"pane": {"cwd": str(mam_sandbox), "cmd": "claude"}}])
mam = mam_sandbox / ".mam"
if broken == "yaml-without-db":
for f in mam.glob("agent-sessions.db*"):
f.unlink()
(mam / "agent-sessions.yaml").write_text("herdr_sessions: [\n - {name: " + name + "\n")
elif broken == "db":
for f in mam.glob("agent-sessions.db-*"):
f.unlink()
(mam / "agent-sessions.db").write_bytes(b"this is not a database" * 64)
else:
# a missing table is not an empty registry
conn = sqlite3.connect(str(mam / "agent-sessions.db"))
conn.execute("DROP TABLE sessions")
if broken == "no-tables":
conn.execute("DROP TABLE state")
conn.commit()
conn.close()
_set_mock(mock_herdr, agents={name: _agent(mam_sandbox, "w1:p76")})
res = _stop(mam_sandbox, name)
assert res.returncode == 1, res.stdout + res.stderr
assert "cannot read" in res.stderr and "no registry row" not in res.stdout
assert name in json.loads(Path(mock_herdr).read_text())["agents"]
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")
+2 -1
View File
@@ -239,7 +239,8 @@ def test_lib_sh_no_local_in_shim_heredoc():
with open(lib_path, "r", encoding="utf-8") as f:
content = f.read()
start_idx = content.find("cat <<'EOF' > \"$tmp_file\"")
# Issue #71: the shim is written as { printf ...; declare -f ...; cat <<'EOF' ... } > "$tmp_file"
start_idx = content.find("cat <<'EOF'\n")
end_idx = content.find("\nEOF\n", start_idx)
assert start_idx != -1 and end_idx != -1
+4
View File
@@ -393,6 +393,10 @@ def test_integration_invalid_arguments_exit_statuses(mam_sandbox, mock_herdr, mo
assert "ERROR: --session required" in res.stderr
# 3. stop_session.sh cannot infer agent from weird session name
# (Issue #71: a session with no row needs no agent kind, so the row is
# registered here, with no agent, suffix or pane.cmd)
(tmp_path / ".mam" / "agent-sessions.yaml").write_text(
"herdr_sessions:\n- name: bad-name\n status: running\n pane: {}\n")
res = subprocess.run(["bash", str(stop_script), "--session", "bad-name"], capture_output=True, text=True, cwd=str(tmp_path))
assert res.returncode == 2
assert "ERROR: cannot infer agent" in res.stderr