Compare commits

...
10 Commits
Author SHA1 Message Date
GodopuandClaude Opus 5.5 35935f9e8a fix(issue-45): review round 2 R3 - the .mam fallback obeys the same no-overwrite rules
The fallback wrote <.mam>/herdr-server-config.toml unchecked, so it could
replace a foreign file there, the source itself, or a link to it. It now goes
through output_path like the normal path (source identity incl. aliases,
generated-for-this-source ownership, then a per-source name) and writes
nothing when no name is safe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:49:08 +00:00
GodopuandClaude Opus 5.5 7c544c06ab test(issue-45): S11 base follows the review-R1 heredoc change; VERSIONS notes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:18:09 +00:00
GodopuandClaude Opus 5.5 811b080966 fix(issue-45): review R1-R3 - herdr checks the config, any TOML spelling kept, source never overwritten
R1: tomllib accepting a file is not herdr accepting it (a schema error makes
herdr drop the whole file, restore included). The shim passes REAL_HERDR and
the written file must pass `herdr config check` ("parse error"/"using defaults"
reject; unknown keys and sound-file notices do not), else the key-only file is
written and checked. A non-table `session` and a source that is not UTF-8 reach
that fallback instead of raising.

R2: the line edit is replaced by parse -> set -> serialize; the result must
parse back to the source plus the key, so dotted keys and inline tables keep
the user's other settings.

R3: the output is never the source (aliases included) nor a file not generated
for the same source; then a per-source name beside it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:17:59 +00:00
GodopuandClaude Opus 5.5 ce91cc11b9 release(v4.8.5): bump framework and eight skills to 4.8.5 in lockstep
Issue #45 A+B: MAM's isolated herdr servers start with restore-on-restart off,
and resume closes the session's own restored empty shell. Not reviewed yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:53:14 +00:00
GodopuandClaude Opus 5.5 eedcebcc12 fix(issue-45): restore stays off when python3 has no tomllib
The shim runs a bare python3. Before 3.11 the module failed to import and the
server started with restore on; now it gets the key alone, with a warning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:34:27 +00:00
GodopuandClaude Opus 5.5 feceddf49f docs(issue-45): isolation and resume notes; block inventory line shifts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:32:49 +00:00
GodopuandClaude Opus 5.5 fb1380fdc1 fix(issue-45): resume closes the session's own restored empty shell
After a restart with restore off, the session's saved pane comes back as an
empty shell (in $HOME when its directory is gone) holding the agent_session it
reported. A resume with a CP_PREFIX cannot reuse it, so it stayed next to the
new pane. Just before spawning, resume closes panes whose agent_session is the
id being resumed, with no live agent and only the shell in the foreground; the
registry is not consulted, since a session whose server died stays running.

tests/test_issue45_restore.py drives a real herdr server under a throwaway HOME
and session with a fake claude: restore stays off through the MAM bootstrap
(with a restore-on control), the close conditions, and create -> crash ->
resume leaving one pane.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:32:49 +00:00
GodopuandClaude Opus 5.5 933af344c2 test(issue-45): S11 compares the shim heredoc with the Issue #45 bootstrap commit
S11 keeps the generated .mam/shim/herdr from changing by accident; #45 changes
it on purpose, so its base moves to ed73f67.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:32:49 +00:00
GodopuandClaude Opus 5.5 ed73f67a4f fix(issue-45): the shim bootstrap starts MAM servers with restore-on-restart off
The headless bootstrap regenerates the server config on every start and hands
it to the server as HERDR_CONFIG_PATH. Without a file the server still starts,
with a warning. mam_sandbox unsets HERDR_CONFIG_PATH/XDG_CONFIG_HOME so the
file lands in the sandbox HOME.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:32:35 +00:00
GodopuandClaude Opus 5.5 a640b0f072 feat(issue-45): herdr config for MAM servers with restore-on-restart off
lib_py/herdr_config.py builds the user's herdr config (HERDR_CONFIG_PATH, else
XDG_CONFIG_HOME, else ~/.config, as herdr reads it) with only
[session] resume_agents_on_restore forced to false, written next to the source
so relative paths resolve the same. The text merge is re-parsed; a merge that
does not leave the rest equal falls back to the key alone, since herdr drops a
config that does not parse and would restore again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:32:35 +00:00
25 changed files with 1198 additions and 40 deletions

No files matched your search

+17 -2
View File
@@ -29,7 +29,7 @@ export WORKSPACE_ROOT
# Framework semantic version. Single runtime source of truth; kept in lockstep
# with VERSIONS.md and the 8 SKILL.md frontmatters by tests/test_version_consistency.py.
# NOTE: unlike other MAM_* variables this one is intentionally NOT env-overridable.
MAM_VERSION="4.8.4"
MAM_VERSION="4.8.5"
export MAM_VERSION
AGENT_SESSIONS_YAML="${AGENT_SESSIONS_YAML:-$WORKSPACE_ROOT/.mam/agent-sessions.yaml}"
@@ -230,6 +230,19 @@ except Exception:
# identity to its sub-agents. Drop only per-session runtime markers - user
# configuration such as CLAUDE_PROJECT_DIR or CLAUDE_CONFIG_DIR still
# inherits. The family ids mirror what orc_onboard.sh auto-detects.
#
# ISSUE-45: the server restores its saved layout on start and would
# relaunch every saved agent with herdr's own resume command (no permission
# flags, deleted agents included). It gets HERDR_CONFIG_PATH = the user's
# herdr config with [session] resume_agents_on_restore = false, rebuilt on
# every start and checked by this herdr's `config check`
# (lib_py/herdr_config.py). Without that file the server still starts -
# with restore on, as before.
MAM_SERVER_CFG=$(python3 -m lib_py.herdr_config server-config --fallback-dir "$_SHIM_DIR/.." --herdr "$REAL_HERDR" || true)
export MAM_SERVER_CFG
if [ -z "$MAM_SERVER_CFG" ]; then
echo "WARNING: restore-on-restart stays enabled for herdr session $_MAM_SESSION (no server config)" >&2
fi
_mam_server_pid=$(python3 -c '
import os, subprocess, sys
deny = ("CLAUDECODE", "CLAUDE_CODE_CHILD_SESSION", "CLAUDE_CODE_SESSION_ID",
@@ -237,8 +250,10 @@ deny = ("CLAUDECODE", "CLAUDE_CODE_CHILD_SESSION", "CLAUDE_CODE_SESSION_ID",
"CLAUDE_CODE_MESSAGING_TOKEN", "CLAUDE_CODE_MESSAGING_SOCKET",
"CLAUDE_CODE_BRIDGE_SESSION_ID",
"ANTIGRAVITY_CONVERSATION_ID", "HERMES_SESSION_ID", "GROK_SESSION_ID",
"OPENCODE_SESSION_ID")
"OPENCODE_SESSION_ID", "MAM_SERVER_CFG")
env = {k: v for k, v in os.environ.items() if k not in deny}
if os.environ.get("MAM_SERVER_CFG"):
env["HERDR_CONFIG_PATH"] = os.environ["MAM_SERVER_CFG"]
p = subprocess.Popen(sys.argv[1:], start_new_session=True, env=env,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
stdin=subprocess.DEVNULL)
+299
View File
@@ -0,0 +1,299 @@
"""Issue #45: the config file MAM's isolated herdr servers start with.
herdr restores a named session's saved layout when its server starts and, by
default, relaunches each saved agent with herdr's own resume command - without
the permission flags MAM adds, and even for agents MAM deleted while the server
was down. `[session] resume_agents_on_restore = false` turns that off. The
shim's headless bootstrap (lib.sh) hands the server `HERDR_CONFIG_PATH` pointing
at the file built here: the user's own herdr config with only that key forced
to false. The user's file is never modified.
* Source: the file herdr itself would read - HERDR_CONFIG_PATH, else
$XDG_CONFIG_HOME/herdr/config.toml, else ~/.config/herdr/config.toml. A
HERDR_CONFIG_PATH naming a file generated here (every MAM pane inherits it
from the server) is followed back to the source recorded in that file.
* Output: `mam-server-config.toml` next to the source, so relative paths in
it (herdr resolves them against the config file's directory) keep their
meaning. `<.mam>/herdr-server-config.toml` only when that directory is not
writable. Either way never the source itself and never a file this module
did not write for the same source: then a name with a hash of the source
path, and no write at all when that is taken too.
* The source is parsed, the key set, and the result serialized; the result
must parse back to exactly the source plus the key (any TOML spelling of
the key - header, dotted, inline - works). Then herdr's own `config check`
must accept the written file: herdr drops a file it cannot use whole
("using defaults") and would restore again, silently. Anything that fails
falls back to a file holding only the key, which is checked the same way.
CLI: `python3 -m lib_py.herdr_config server-config [--fallback-dir DIR]
[--herdr PATH]` writes the file and prints its path; warnings go to stderr;
exit 1 when no usable file could be written.
"""
from __future__ import annotations
import argparse
import datetime
import hashlib
import json
import math
import os
import re
import subprocess
import sys
import tempfile
from typing import List, Mapping, Optional, Tuple
MARKER = "# generated by multi-agent-mux (Issue #45) - do not edit"
SOURCE_PREFIX = "# source: "
OUTPUT_NAME = "mam-server-config.toml"
FALLBACK_NAME = "herdr-server-config.toml"
KEY = "resume_agents_on_restore"
MINIMAL = f"[session]\n{KEY} = false\n"
LOSES_SETTINGS = f"the MAM server uses only {KEY} = false, without the rest of your settings"
try:
import tomllib
except ImportError: # Python < 3.11: no parser to check a merge with
tomllib = None
_BARE_KEY = re.compile(r"^[A-Za-z0-9_-]+$")
# Where a pane's server, socket and workspace come from; `config check` must not
# be steered by the caller's pane.
_SCOPE_ENV = ("HERDR_WORKSPACE_ID", "HERDR_PANE_ID", "HERDR_TAB_ID", "HERDR_SOCKET_PATH")
def _generated_source(path: str) -> Optional[str]:
"""The source recorded in a file generated here; '' when it is one without
a source line; None when it is not a generated file (or unreadable)."""
try:
with open(path, "rb") as f:
first, second = f.readline(), f.readline()
except OSError:
return None
if first.rstrip(b"\n").decode("utf-8", "replace") != MARKER:
return None
line = second.rstrip(b"\n").decode("utf-8", "replace")
return line[len(SOURCE_PREFIX):] if line.startswith(SOURCE_PREFIX) else ""
def user_config_path(env: Mapping[str, str]) -> str:
"""The config file herdr reads under `env`, seen through generated files."""
explicit = env.get("HERDR_CONFIG_PATH", "")
if explicit:
source = _generated_source(explicit)
if source is None:
return explicit
if source:
return source
xdg = env.get("XDG_CONFIG_HOME", "")
if xdg:
# herdr takes a relative XDG_CONFIG_HOME against its cwd, too.
return os.path.join(os.path.abspath(xdg), "herdr", "config.toml")
return os.path.join(env.get("HOME", os.path.expanduser("~")), ".config", "herdr", "config.toml")
# --- TOML writer for what tomllib returns -----------------------------------
def _str(s: str) -> str:
# json's escapes (\" \\ \b \f \n \r \t \uXXXX) are TOML's; DEL is not
# escaped by json but is not allowed raw in a TOML string.
return json.dumps(s, ensure_ascii=False).replace("\x7f", "\\u007f")
def _key(k: str) -> str:
return k if _BARE_KEY.match(k) else _str(k)
def _value(v) -> str:
if isinstance(v, bool):
return "true" if v else "false"
if isinstance(v, int):
return str(v)
if isinstance(v, float):
if math.isnan(v):
return "nan"
if math.isinf(v):
return "inf" if v > 0 else "-inf"
return repr(v)
if isinstance(v, str):
return _str(v)
if isinstance(v, (datetime.datetime, datetime.date, datetime.time)):
return v.isoformat()
if isinstance(v, list):
return "[" + ", ".join(_value(x) for x in v) + "]"
if isinstance(v, dict):
return "{" + ", ".join(f"{_key(k)} = {_value(x)}" for k, x in v.items()) + "}"
raise TypeError(f"unexpected TOML value {type(v).__name__}")
def dumps(doc: dict) -> str:
"""Top-level values, then one [table] per top-level table with its members
written inline - valid TOML for any document tomllib can return."""
lines = [f"{_key(k)} = {_value(v)}" for k, v in doc.items() if not isinstance(v, dict)]
for k, v in doc.items():
if isinstance(v, dict):
lines.append(f"[{_key(k)}]")
lines += [f"{_key(kk)} = {_value(vv)}" for kk, vv in v.items()]
return "\n".join(lines) + "\n"
def server_config_text(user_text: Optional[str]) -> Tuple[str, List[str]]:
"""(the TOML body for the server, warnings). The body always parses, has
session.resume_agents_on_restore = false and otherwise equals the source -
or is MINIMAL, with a warning, when the source cannot be used."""
if not user_text or not user_text.strip():
return MINIMAL, []
if tomllib is None:
return MINIMAL, [f"this Python has no tomllib (3.11+); {LOSES_SETTINGS}"]
try:
user = tomllib.loads(user_text)
except tomllib.TOMLDecodeError as e:
return MINIMAL, [f"herdr config does not parse ({e}); {LOSES_SETTINGS}"]
session = user.get("session", {})
if not isinstance(session, dict):
# herdr rejects this file as a whole too.
return MINIMAL, [f"herdr config has a non-table `session`; {LOSES_SETTINGS}"]
expected = dict(user, session=dict(session, **{KEY: False}))
try:
text = dumps(expected)
ok = tomllib.loads(text) == expected
except (TypeError, tomllib.TOMLDecodeError):
ok = False
if not ok:
return MINIMAL, [f"could not rewrite the herdr config faithfully; {LOSES_SETTINGS}"]
return text, []
# --- herdr's own check ------------------------------------------------------
def herdr_rejects(path: str, herdr: str) -> Optional[str]:
"""None when herdr would use the config at `path`, else herdr's diagnostics.
herdr 0.9.1 drops a file whole only on a parse or schema error, which it
reports as "config parse error: ...; using defaults"; other issues (an
unknown key, an unsupported sound file) cost only that one setting."""
env = {k: v for k, v in os.environ.items() if k not in _SCOPE_ENV}
env["HERDR_CONFIG_PATH"] = path
res = subprocess.run([herdr, "config", "check"], env=env, capture_output=True, text=True, timeout=30)
out = (res.stdout + res.stderr).strip()
if res.returncode == 0 or not ("parse error" in out or "using defaults" in out):
return None
return out
# --- output -----------------------------------------------------------------
def _same_file(a: str, b: str) -> bool:
try:
return os.path.samefile(a, b)
except OSError:
return os.path.realpath(a) == os.path.realpath(b)
def output_path(source: str, directory: Optional[str] = None, name: str = OUTPUT_NAME) -> str:
"""`name` in `directory` (default: beside the source), unless that is the
source itself (aliases included) or a file this module did not write for
this source; then a per-source name. Every write goes through here."""
d = directory or os.path.dirname(source)
tag = hashlib.sha256(os.path.realpath(source).encode()).hexdigest()[:12]
for n in (name, f"{name[:-len('.toml')]}-{tag}.toml"):
target = os.path.join(d, n)
if _same_file(target, source):
continue
if os.path.lexists(target) and _generated_source(target) not in ("", source):
continue
return target
raise OSError(f"no free output name in {d}")
def _write_atomic(path: str, text: str, mode: int) -> None:
d = os.path.dirname(path)
os.makedirs(d, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=d, prefix=".mam-server-config.", suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(text)
os.chmod(tmp, mode)
os.replace(tmp, path)
except BaseException:
try:
os.unlink(tmp)
except OSError:
pass
raise
def _write_checked(target: str, source: str, body: str, mode: int, herdr: Optional[str],
warnings: List[str]) -> bool:
"""Write body (then MINIMAL if herdr rejects it); True when herdr accepts
what is on disk (or there is no herdr to ask)."""
for candidate in ((body, MINIMAL) if body != MINIMAL else (MINIMAL,)):
_write_atomic(target, f"{MARKER}\n{SOURCE_PREFIX}{source}\n{candidate}", mode)
if not herdr:
return True
try:
why = herdr_rejects(target, herdr)
except (OSError, subprocess.SubprocessError) as e:
warnings.append(f"cannot run `{herdr} config check` ({e}); the config is unchecked")
return True
if why is None:
return True
tail = "" if candidate == MINIMAL else f"; {LOSES_SETTINGS}"
warnings.append(f"herdr rejects the config built from {source}: {why}{tail}")
return False
def write_server_config(env: Mapping[str, str], fallback_dir: Optional[str] = None,
herdr: Optional[str] = None) -> Tuple[Optional[str], List[str]]:
"""Write the server config; (its path or None, warnings)."""
source = user_config_path(env)
warnings: List[str] = []
user_text: Optional[str] = None
mode = 0o644
try:
with open(source, encoding="utf-8") as f:
user_text = f.read()
mode = os.stat(source).st_mode & 0o777
except FileNotFoundError:
pass
except (OSError, UnicodeDecodeError) as e:
# herdr cannot use it either, so it runs on defaults: same as no file.
warnings.append(f"cannot read herdr config {source}: {e}; {LOSES_SETTINGS}")
body, merge_warnings = server_config_text(user_text)
warnings += merge_warnings
try:
target = output_path(source)
if _write_checked(target, source, body, mode, herdr, warnings):
return target, warnings
return None, warnings
except OSError as e:
warnings.append(f"cannot write beside {source}: {e}")
if fallback_dir:
target = os.path.join(fallback_dir, FALLBACK_NAME)
try:
target = output_path(source, fallback_dir, FALLBACK_NAME)
if _write_checked(target, source, body, mode, herdr, warnings):
warnings.append(f"wrote {target} instead; relative paths in the herdr config may not resolve")
return target, warnings
except OSError as e:
warnings.append(f"cannot write {target}: {e}")
return None, warnings
def main(argv=None) -> int:
p = argparse.ArgumentParser(description="herdr config for MAM's isolated servers (Issue #45)")
sub = p.add_subparsers(dest="cmd", required=True)
sc = sub.add_parser("server-config", help="write the server config and print its path")
sc.add_argument("--fallback-dir", default=None, help="where to write when the config dir is not writable")
sc.add_argument("--herdr", default=None, help="the real herdr binary, to `config check` the result")
args = p.parse_args(argv)
path, warnings = write_server_config(os.environ, args.fallback_dir, args.herdr)
for w in warnings:
print(f"WARNING: {w}", file=sys.stderr)
if not path:
return 1
print(path)
return 0
if __name__ == "__main__":
sys.exit(main())
+88
View File
@@ -0,0 +1,88 @@
"""Issue #45 (B): close the restored empty shells of the session being resumed.
With restore-on-restart off (lib_py/herdr_config.py) a restarted MAM server
still brings every saved pane back - as an empty shell in its old cwd, or in
$HOME when that directory is gone. A pane keeps the `agent_session` its agent
reported before the server died. resume_session.sh calls this once, right
before it spawns, with the id it is about to resume; a pane is closed only when
all of these hold:
1. the target is an isolated MAM server (HERDR_SESSION_NAME set, not default);
2. herdr sees no live agent in it;
3. its agent_session value is that id - so a user's pane, another session's
pane and a freshly split pane (no agent_session yet) are never candidates;
4. its foreground process is the shell itself (nothing running in it).
The registry is not consulted: a session whose server died stays `running`
there, so "running" cannot tell a live pane from a restored one.
CLI: `python3 -m lib_py.herdr_restore close-restored-shells --session-id ID
[--herdr PATH]`. One stderr line per closed pane; exit 1 when herdr cannot be
queried (the caller only warns).
"""
from __future__ import annotations
import argparse
import json
import os
import subprocess
import sys
from typing import List, Optional
# A pane exports these; they would point the query at the caller's own server
# and workspace instead of the --session named here.
_SCOPE_ENV = ("HERDR_WORKSPACE_ID", "HERDR_PANE_ID", "HERDR_TAB_ID", "HERDR_SOCKET_PATH")
def _herdr(herdr: str, session: str, *args: str) -> dict:
env = {k: v for k, v in os.environ.items() if k not in _SCOPE_ENV}
out = subprocess.run([herdr, "--session", session, *args], env=env,
capture_output=True, text=True, timeout=30, check=True).stdout
return json.loads(out).get("result") or {}
def _shell_in_foreground(herdr: str, session: str, pane_id: str) -> bool:
info = _herdr(herdr, session, "pane", "process-info", "--pane", pane_id).get("process_info") or {}
shell = info.get("shell_pid")
return shell is not None and info.get("foreground_process_group_id") == shell
def close_restored_shells(session_id: str, herdr: str = "herdr",
session: Optional[str] = None) -> List[str]:
"""Close the matching panes; return their ids."""
session = os.environ.get("HERDR_SESSION_NAME", "") if session is None else session
if not session_id or session in ("", "default"):
return []
closed = []
for p in _herdr(herdr, session, "pane", "list").get("panes") or []:
pane_id = p.get("pane_id")
if not pane_id or p.get("agent"):
continue
if (p.get("agent_session") or {}).get("value") != session_id:
continue
if not _shell_in_foreground(herdr, session, pane_id):
continue
_herdr(herdr, session, "pane", "close", pane_id)
print(f"closed restored pane {pane_id} (cwd={p.get('cwd', '')}, agent_session={session_id})",
file=sys.stderr)
closed.append(pane_id)
return closed
def main(argv=None) -> int:
p = argparse.ArgumentParser(description="close a resumed session's restored empty shells (Issue #45)")
sub = p.add_subparsers(dest="cmd", required=True)
c = sub.add_parser("close-restored-shells")
c.add_argument("--session-id", required=True)
c.add_argument("--herdr", default="", help="the real herdr binary (not the MAM shim)")
args = p.parse_args(argv)
try:
close_restored_shells(args.session_id, args.herdr or "herdr")
except (OSError, subprocess.SubprocessError, ValueError) as e:
print(f"close-restored-shells: {e}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
+1 -1
View File
@@ -13,7 +13,7 @@ import sys
from typing import List, Sequence
# Framework version locked with framework constants
FRAMEWORK_VERSION = "4.8.4"
FRAMEWORK_VERSION = "4.8.5"
CORE_MODULES: Sequence[str] = (
# Standard library modules
@@ -1,7 +1,7 @@
---
name: multi-agent-mux-create
description: "Create a new agent session (claude, antigravity/agy) in a dedicated herdr session for context-preserving long-running work. Always creates a herdr session — never backgrounds with nohup/disown. Writes the new session to .mam/agent-sessions.yaml. Use when you want to start a fresh agent (no prior UUID) for a new project workspace."
version: 4.8.4
version: 4.8.5
author: godopu
license: MIT
platforms: [linux, macos]
@@ -117,6 +117,8 @@ Why use `--herdr-session`?
- By default, all skills target `default` herdr session socket — fine for single-workspace use.
- By using an isolated session via `HERDR_SESSION_NAME` (or `--herdr-session`), your agent sessions are completely separated from your default user workspace, ensuring 0% interference — this is backed by a genuinely separate `herdr` session/socket, not merely a workspace label.
- To deliberately tear down an *entire* isolated group at once (all its workspaces and agents), use `herdr session stop <HERDR_SESSION_NAME>` followed by `herdr session delete <HERDR_SESSION_NAME>` — this only affects that named session, never the default one.
- An isolated server MAM starts does not relaunch saved agents when it restarts (#45): it runs with `HERDR_CONFIG_PATH=<your herdr config dir>/mam-server-config.toml`, your herdr config with `[session] resume_agents_on_restore = false`. Its saved panes come back as empty shells; resume closes the one of the session it resumes.
- That file is rebuilt each time MAM starts the server, so a change to your herdr config reaches a MAM server only when it is next started; `herdr server reload-config` rereads the generated file. A server you start yourself (`herdr session attach`) does not get it.
---
@@ -1,7 +1,7 @@
---
name: multi-agent-mux-delegate-job
description: "Delegate a unit of work to any autonomous agent (claude-code, hermes, agy, grok-build, codex, or a human) and observe it asynchronously over an MQTT event channel. Supported roles include orchestrator, worker, and reviewer."
version: 4.8.4
version: 4.8.5
author: godopu
license: MIT
platforms: [linux, macos, windows]
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: multi-agent-mux-loop
description: "Run an autonomous planning-execution-review loop using multiple agents (Planner, Creator, Reviewers) in the workspace. Automatically orchestrates plan discussion, code changes, and peer reviews until a unanimous PASS is achieved or the maximum iteration limit is reached."
version: 4.8.4
version: 4.8.5
author: godopu
license: MIT
platforms: [linux, macos]
@@ -1,7 +1,7 @@
---
name: multi-agent-mux-monitor
description: "Run a long-lived reconciler that watches .mam/agent-sessions.yaml against the actual herdr/agent runtime state and reconciles them. Use when you want live visibility into which agent sessions are running, which are dead, which have stale YAML entries, and which have new session ids that haven't been recorded yet. Runs as a persistent loop (`reconcile.sh --subscribe`) that keeps going until it times out, idles out, or is interrupted."
version: 4.8.4
version: 4.8.5
author: godopu
license: MIT
platforms: [linux, macos]
@@ -1,7 +1,7 @@
---
name: multi-agent-mux-orc-onboard
description: "Register current or specified orchestrator session UUID into agent-sessions.yaml orchestrator_uuids list to prevent sub-agent discovery capture."
version: 4.8.4
version: 4.8.5
author: godopu
license: MIT
platforms: [linux, macos]
@@ -1,7 +1,7 @@
---
name: multi-agent-mux-resume
description: "Resume an existing agent (claude, antigravity/agy) conversation by UUID into a herdr session. Reads .mam/agent-sessions.yaml for the saved session/conversation id, spawns (or reuses) a herdr session of the matching name, and runs `claude -r <id>` or `agy --conversation <id>` inside. Use when you want to reattach to a previous session's context, or revive a session whose herdr died but the agent's conversation is still on disk."
version: 4.8.4
version: 4.8.5
author: godopu
license: MIT
platforms: [linux, macos]
@@ -60,7 +60,7 @@ If both are empty → for Class A agents (`agy`, `hermes`, `opencode`, `codex`),
bash .agents/skills/multi-agent-mux-resume/scripts/resume_session.sh --workspace <path> --agent <agent> --session <name>
```
`resume_session.sh` does the whole flow: original-cwd check, control-plane binding, session-id resolution (`resolve_session_id.sh`), the herdr-alive check, the agent command (adapter `resume-spec`, or `spawn-spec` for a Class A fresh spawn), spawn, start-up dialogs, and the YAML update (`update_yaml_resumed.sh`, which also publishes a `progress "resumed"` event when the row has a `delegate_job_id`). It never attaches; attaching is a separate step that names the server, because the server the script exports does not reach the calling shell and the native `herdr` binary does not route on environment variables: `herdr --session <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, session-id resolution (`resolve_session_id.sh`), the herdr-alive check, the agent command (adapter `resume-spec`, or `spawn-spec` for a Class A fresh spawn), spawn (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`).
| Option | Meaning |
|---|---|
@@ -233,6 +233,14 @@ if [ "$AGENT" = "opencode" ]; then
fi
fi
# Issue #45: a restarted server brings this session's old pane back as an empty
# shell (restore-on-restart is off on MAM servers). The shim cannot reuse it when
# a CP_PREFIX is passed, nor when it fell back to $HOME, so close it first; the
# conditions live in lib_py/herdr_restore.py. Never blocks the resume.
"$(_delegate_py_bin)" -m lib_py.herdr_restore close-restored-shells --session-id "$UUID" \
--herdr "$(_resolve_real_herdr_path || true)" \
|| echo "WARNING: could not close the restored panes of $SESSION_NAME; an empty shell may remain" >&2
# 4. Spawn new agent session (delegates to herdr translation shim)
_herdr new-session -d -s "$SESSION_NAME" -c "$WORKSPACE" "$CP_PREFIX$CMD_FULL"
# Issue #24: every agent, the same call create makes. The signatures and accept
@@ -1,7 +1,7 @@
---
name: multi-agent-mux-status
description: "Read-only instant snapshot of all agent herdr sessions — name, YAML status, herdr alive, pane cmd/cwd, resume UUID on disk, and any drift. No mutation. Reuses reconcile.sh --dry-run for the diff logic. Use when you want to know 'what's running RIGHT NOW' without spinning up the monitor loop."
version: 4.8.4
version: 4.8.5
author: godopu
license: MIT
platforms: [linux, macos]
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: multi-agent-mux-stop
description: "Stop an agent herdr session (claude, antigravity/agy) and update .mam/agent-sessions.yaml. Default stops gracefully and marks status=stopped with conversation preserved for resume. Does NOT delete on-disk conversation artifacts (jsonl/db) — those are preserved unless --purge-conversation is passed. Use when ending a work session, switching to a different one, or cleaning up before a fresh start."
version: 4.8.4
version: 4.8.5
author: godopu
license: MIT
platforms: [linux, macos]
+1 -1
View File
@@ -2,7 +2,7 @@
본 문서는 `herdr_agent_orchestration` 오케스트레이션 및 메시징 백플레인 워크플로우를 새로운 프로젝트에 도입하여 이식하고, 새로운 개발자/에이전트가 초기 가동을 시작할 때 수행해야 하는 환경 설정 및 구축 절차를 안내합니다.
[![Version: v4.8.4](https://img.shields.io/badge/version-v4.8.4-blue.svg)](VERSIONS.md)
[![Version: v4.8.5](https://img.shields.io/badge/version-v4.8.5-blue.svg)](VERSIONS.md)
새로운 에이전트는 이 안내서에 기술된 절차를 순차적으로 실행하여 초기 환경을 안정적으로 설정할 수 있습니다.
+1 -1
View File
@@ -2,7 +2,7 @@
This document guides you through the setup and initialization procedures required to adopt the `herdr_agent_orchestration` orchestration and messaging backplane workflow in a new project, enabling a new developer or agent to get up and running quickly.
[![Version: v4.8.4](https://img.shields.io/badge/version-v4.8.4-blue.svg)](VERSIONS.md)
[![Version: v4.8.5](https://img.shields.io/badge/version-v4.8.5-blue.svg)](VERSIONS.md)
A new agent can follow the steps in this guide sequentially to establish a stable and reliable initial environment.
+1 -1
View File
@@ -1,6 +1,6 @@
# Messaging System Technical Analysis & Architecture Report
Applies to: MAM v4.8.4
Applies to: MAM v4.8.5
This report provides a comprehensive, deep-dive analysis of the messaging system implemented in the `multi-agent-mux-delegate-job` skill. It covers the MQTT broker architecture, event protocols, job lifecycles, codebase internals, cross-system integration, and a list of known limitations along with production recommendations.
+1 -1
View File
@@ -2,7 +2,7 @@
Herdr와 MQTT 브로커를 기반으로 구축된 고신뢰성 **다중 에이전트 오케스트레이션 및 메시징 백플레인** 프레임워크입니다. `claude`, `agy`, `hermes`, `grok`, `opencode`, `codex` 등 다중 LLM 백엔드 에이전트 전반에 걸쳐 장시간 수행되는 작업(코드 생성, 리팩토링, 보안 검토 등)을 조정, 격리 및 감사할 수 있도록 설계되었습니다.
[![Version: v4.8.4](https://img.shields.io/badge/version-v4.8.4-blue.svg)](VERSIONS.md)
[![Version: v4.8.5](https://img.shields.io/badge/version-v4.8.5-blue.svg)](VERSIONS.md)
---
+1 -1
View File
@@ -2,7 +2,7 @@
An advanced, high-reliability **Multi-Agent Orchestration & Messaging Backplane** framework built on Herdr and MQTT. It is designed to coordinate, isolate, and audit long-running agent tasks (such as code generation, refactoring, and security reviews) across multiple LLM backend clients (`claude`, `agy`, `hermes`, `grok`, `opencode`, `codex`).
[![Version: v4.8.4](https://img.shields.io/badge/version-v4.8.4-blue.svg)](VERSIONS.md)
[![Version: v4.8.5](https://img.shields.io/badge/version-v4.8.5-blue.svg)](VERSIONS.md)
---
+26 -10
View File
@@ -6,7 +6,7 @@
## 📌 현재 버전 개요 (Current Release)
- **프레임워크 버전**: `v4.8.4`
- **프레임워크 버전**: `v4.8.5`
- **최신 릴리스 일시**: 2026-10-04 (KST)
- **기준 브랜치**: `main`
- **핵심 아키텍처**:
@@ -24,23 +24,39 @@
## 🧭 스킬 패키지 버전 매트릭스 (Skills Version Matrix)
모든 8개 스킬은 YAML frontmatter 메타데이터(`author`, `version`, `platforms`, `environments`) 표준화를 통해 `v4.8.4`로 동기화되어 배포됩니다.
모든 8개 스킬은 YAML frontmatter 메타데이터(`author`, `version`, `platforms`, `environments`) 표준화를 통해 `v4.8.5`로 동기화되어 배포됩니다.
| 스킬명 | 버전 | 역할 및 주요 책임 | 상태 |
| :--- | :---: | :--- | :---: |
| **`multi-agent-mux-create`** | `4.8.4` | 에이전트 세션 신규 생성 및 Herdr 컨테이너 격리 스폰 | ✅ 배포 |
| **`multi-agent-mux-stop`** | `4.8.4` | 대화 UUID 원자적 캡처 및 세션 안전 종료 (Graceful Stop) | ✅ 배포 |
| **`multi-agent-mux-resume`** | `4.8.4` | 온디스크 대화 컨텍스트 기반 Tier-1 초고속 세션 복원 | ✅ 배포 |
| **`multi-agent-mux-status`** | `4.8.4` | 실시간 Herdr 세션 및 레지스트리 드리프트 스냅샷 조회 | ✅ 배포 |
| **`multi-agent-mux-monitor`** | `4.8.4` | YAML ↔ 런타임 상태 간 자율 조정자 (Reconciler Loop) | ✅ 배포 |
| **`multi-agent-mux-delegate-job`** | `4.8.4` | MQTT 이벤트 채널 기반 비동기 단위 작업 위임 | ✅ 배포 |
| **`multi-agent-mux-loop`** | `4.8.4` | Planner-Creator-Reviewer 3자 자율 계획·실행·피어리뷰 루프 | ✅ 배포 |
| **`multi-agent-mux-orc-onboard`** | `4.8.4` | 오케스트레이터 UUID 격리 등록 및 서브 세션 오염 방지 | ✅ 배포 |
| **`multi-agent-mux-create`** | `4.8.5` | 에이전트 세션 신규 생성 및 Herdr 컨테이너 격리 스폰 | ✅ 배포 |
| **`multi-agent-mux-stop`** | `4.8.5` | 대화 UUID 원자적 캡처 및 세션 안전 종료 (Graceful Stop) | ✅ 배포 |
| **`multi-agent-mux-resume`** | `4.8.5` | 온디스크 대화 컨텍스트 기반 Tier-1 초고속 세션 복원 | ✅ 배포 |
| **`multi-agent-mux-status`** | `4.8.5` | 실시간 Herdr 세션 및 레지스트리 드리프트 스냅샷 조회 | ✅ 배포 |
| **`multi-agent-mux-monitor`** | `4.8.5` | YAML ↔ 런타임 상태 간 자율 조정자 (Reconciler Loop) | ✅ 배포 |
| **`multi-agent-mux-delegate-job`** | `4.8.5` | MQTT 이벤트 채널 기반 비동기 단위 작업 위임 | ✅ 배포 |
| **`multi-agent-mux-loop`** | `4.8.5` | Planner-Creator-Reviewer 3자 자율 계획·실행·피어리뷰 루프 | ✅ 배포 |
| **`multi-agent-mux-orc-onboard`** | `4.8.5` | 오케스트레이터 UUID 격리 등록 및 서브 세션 오염 방지 | ✅ 배포 |
---
## 📋 버전별 상세 변경 내역 (Changelog)
### 🚀 `v4.8.5` — Issue #45 A·B: 재기동된 격리 서버가 저장된 에이전트를 다시 띄우지 않고, resume 이 복원된 빈 셸을 닫는다 (2026-10-04)
> **패치 (PATCH Release)**: 격리 herdr 서버가 에이전트를 정지하지 않은 채 꺼졌다 다시 뜨면, herdr 가 저장 레이아웃을 복원하며 에이전트를 자기 재개 명령으로(권한 플래그 없이, 지운 에이전트까지) 다시 띄웠다. herdr 0.9.1 의 `[session] resume_agents_on_restore = false` 로 MAM 이 띄우는 서버에서 이를 끈다(A). 끈 뒤에도 pane 은 빈 셸로 복원되므로, resume 이 그 세션 자신의 빈 셸을 닫는다(B). 이슈의 제안 2·4 는 범위 밖.
#### 🌟 주요 개선 사항
1. **서버 설정 (`lib_py/herdr_config.py`)**: herdr 가 읽는 사용자 설정(`HERDR_CONFIG_PATH` → `XDG_CONFIG_HOME` → `~/.config`)에서 그 키만 false 로 바꾼 `mam-server-config.toml` 을 원본 옆에 만든다(상대 경로가 같게 풀린다). 사용자 파일은 고치지 않는다: 출력이 원본 자신(별칭 포함)이거나 같은 원본으로 만든 파일이 아니면 원본별 이름을 쓴다. 파싱 → 키 설정 → 직렬화하고, 결과가 원본 + 키와 정확히 같아야 한다(헤더·점 표기·인라인 표 모두). 쓴 파일은 그 herdr 의 `config check` 를 통과해야 한다. herdr 는 파싱·스키마 오류면 파일 전체를 버리고("using defaults") 복원을 켜기 때문이다. 통과하지 못하면(깨짐, 비-bool 값, 표가 아닌 `session`, UTF-8 아님) 키 하나만 있는 파일을 쓰고 경고한다. MAM pane 이 물려받은 생성 파일은 기록된 원본으로 거슬러 올라간다.
2. **shim bootstrap (`lib.sh` heredoc)**: 서버를 띄울 때마다 설정을 다시 만들어 `HERDR_CONFIG_PATH` 로 넘긴다. 못 만들면 경고하고 예전처럼(복원 켜짐) 띄운다. 기본 `default` 서버와 사용자가 직접 띄운 서버는 바꾸지 않는다.
3. **복원된 빈 셸 (`lib_py/herdr_restore.py`, `resume_session.sh`)**: spawn 직전 한 번, `agent_session` 이 resume 하는 id 와 같고 live 에이전트가 없고 전경이 셸뿐인 pane 을 닫는다. 등록부는 보지 않는다(서버가 죽은 세션도 running 으로 남는다, Creator 이의 job 9f5f1ebe). `CP_PREFIX` 가 붙는 resume 이 재사용하지 못해 남던 빈 셸과 `$HOME` 으로 떨어진 빈 셸이 대상이다.
4. **S11 (`tests/test_issue23_24_startup_path.py`)**: 생성 shim 이 우연히 바뀌지 않게 하는 비교 기준을 heredoc 을 의도적으로 바꾼 ed73f67 로 옮겼다.
#### ✅ 품질 검증
- **실서버 테스트 (`tests/test_issue45_restore.py`, herdr 0.9.1)**: 짧은 일회용 HOME·세션과 가짜 claude 로, MAM bootstrap 경로에서 복원이 꺼짐(사용자 설정에 `[session]` 표가 있을 때 포함)과 같은 레이아웃을 일반 서버로 띄우면 `claude --resume` 이 뜨는 양성 대조, 닫기 조건, create → crash → resume 뒤 pane 하나만 남음을 확인한다. 수정을 끄면 A·B 테스트가 각각 실패함을 확인했다.
- **단위 (`tests/test_issue45_herdr_config.py`)**: 합치기 12 가지 경우와 실제 `herdr config check`, 원본 경로 규칙, 쓰기·폴백.
- **관련 스위트**: 새 두 파일 40 passed. issue9·shim contract·issue42·issue24 resume·uuid_target·issue21 prereqs·issue23_24 199 passed / 1 skipped(S11 기준 이동 후). tier1~4·issue21_phase2_loop_a 148 passed. 전체 스위트는 돌리지 않았다.
- **리뷰**: 1회차 codex NOT PASS(R1 herdr 스키마 검증 없음, R2 점 표기·인라인 표에서 설정 유실, R3 원본과 출력 이름 충돌 시 덮어씀). 811b080 에서 수정. 2회차 codex NOT PASS(R3 의 폴백 경로는 같은 검사를 거치지 않음), 폴백도 같은 이름 선택을 거치도록 수정.
### 🚀 `v4.8.4` — `.claude/skills` 링크 검사가 두 로컬 배치를 받고 깨진 링크를 잡는다 (2026-10-04, PR #47)
> **패치 (PATCH Release)**: v4.8.3(PR #46)이 넣은 `test_claude_skills_link_resolves_to_agents_skills` 가 병합 후 본 체크아웃에서 실패했다. 테스트는 `.claude/skills` 를 `.agents/skills` 로 가는 링크 하나로 가정했지만, 이 로컬(git-ignored) 디렉터리는 스킬별 링크를 담은 디렉터리일 수도 있다. PR #46 의 리뷰·스위트는 워크트리에서 돌아 이 테스트가 skip 이었다. 테스트만 고친다.
+5
View File
@@ -236,6 +236,11 @@ def mam_sandbox(tmp_path, monkeypatch, request):
monkeypatch.delenv("HERDR_SESSION_NAME", raising=False)
monkeypatch.delenv("HERDR_SERVER_NAME", raising=False)
monkeypatch.delenv("HERDR_WORKSPACE_ID", raising=False)
# Issue #45: the shim bootstrap writes a herdr server config next to the
# user's; with these unset that is $HOME/.config/herdr, inside the sandbox.
# Every pane of a MAM server inherits HERDR_CONFIG_PATH.
monkeypatch.delenv("HERDR_CONFIG_PATH", raising=False)
monkeypatch.delenv("XDG_CONFIG_HOME", raising=False)
import sys
monkeypatch.setenv("AGENT_PYTHON_BIN", sys.executable)
+11 -11
View File
@@ -60,8 +60,8 @@
},
{
"skill": "multi-agent-mux-create",
"start": 127,
"end": 145,
"start": 129,
"end": 147,
"lang": "yaml",
"sha": "2dd608d04ae0",
"class": "c",
@@ -74,8 +74,8 @@
},
{
"skill": "multi-agent-mux-create",
"start": 151,
"end": 153,
"start": 153,
"end": 155,
"lang": "bash",
"sha": "613003708f19",
"class": "c",
@@ -88,8 +88,8 @@
},
{
"skill": "multi-agent-mux-create",
"start": 191,
"end": 195,
"start": 193,
"end": 197,
"lang": "bash",
"sha": "38836d31bf38",
"class": "c",
@@ -103,8 +103,8 @@
},
{
"skill": "multi-agent-mux-create",
"start": 217,
"end": 239,
"start": 219,
"end": 241,
"lang": "bash",
"sha": "d90cfd2555fd",
"class": "b",
@@ -478,9 +478,9 @@
"multi-agent-mux-resume/scripts/resume_session.sh:133-144",
"multi-agent-mux-resume/scripts/resume_session.sh:147-158",
"multi-agent-mux-resume/scripts/resume_session.sh:172-197",
"multi-agent-mux-resume/scripts/resume_session.sh:237",
"multi-agent-mux-resume/scripts/resume_session.sh:241",
"multi-agent-mux-resume/scripts/resume_session.sh:247-250",
"multi-agent-mux-resume/scripts/resume_session.sh:245",
"multi-agent-mux-resume/scripts/resume_session.sh:249",
"multi-agent-mux-resume/scripts/resume_session.sh:255-258",
"lib_py/agents/adapters/claude.py:109"
],
"mismatch": [
+4 -2
View File
@@ -296,9 +296,11 @@ def test_s11_this_branch_does_not_change_the_generated_shim(mam_sandbox):
lib.sh materialises into $WORKSPACE_ROOT/.mam/shim/herdr. That file is
shared by every checkout bound to one control plane, so keeping it
byte-identical to base is what makes "the control plane is not modified"
true at runtime."""
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
REAL_HERDR to the config check), so the base is that commit."""
repo = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
base = os.environ.get("MAM_BASE_SHA", "1e78dae")
base = os.environ.get("MAM_BASE_SHA", "811b080")
show = subprocess.run(["git", "show", "%s:.agents/skills/lib.sh" % base],
capture_output=True, text=True, cwd=repo)
if show.returncode != 0:
+360
View File
@@ -0,0 +1,360 @@
"""Issue #45 (A): the herdr config MAM's isolated servers start with.
lib_py/herdr_config.py builds the user's herdr config with only
[session] resume_agents_on_restore forced to false. herdr drops a config that
does not parse and runs on defaults - restore on - so every result here is
re-parsed, and fed to the real `herdr config check` when herdr is installed.
That restore really is off on a live server is test_issue45_restore.py.
"""
from __future__ import annotations
import os
import shutil
import stat
import subprocess
import sys
import tomllib
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO_ROOT / ".agents" / "skills"))
from lib_py import herdr_config as hc # noqa: E402
HERDR = shutil.which("herdr")
# (name, user config text or None, expect the user's other settings kept, expect a warning)
CASES = [
("missing", None, True, False),
("empty", "", True, False),
("broken", "[session]\n[session]\n", False, True),
("session_not_a_table", "session = 1\n[ui]\nconfirm_close = false\n", False, True),
("no_session", "[ui]\nconfirm_close = false\n", True, False),
("no_session_no_newline", "[ui]\nconfirm_close = false", True, False),
("session_without_key", "[session]\n# a comment\n\n[ui]\nconfirm_close = false\n", True, False),
("key_true", "[ui]\nconfirm_close = false\n[session]\nresume_agents_on_restore = true # mine\n", True, False),
("key_false", "[session]\nresume_agents_on_restore=false\n", True, False),
("sub_table_only", "[session.extra]\na = 1\n", True, False),
# valid spellings a line edit cannot reach: the rest must survive (review R2)
("dotted_key", "session.resume_agents_on_restore = true\n[ui]\nconfirm_close = false\n", True, False),
("inline_table", "session = { resume_agents_on_restore = true }\n[ui]\nconfirm_close = false\n", True, False),
("header_inside_string", 'banner = """\n[session]\n"""\n[ui]\nconfirm_close = false\n', True, False),
("value_kinds", 'top = "a\\"b\\u007f\\u00e9"\n"odd key" = [1, 2.5, inf, true]\n'
'when = 1979-05-27T07:32:00Z\n[ui.sound]\npath = "s/done.wav"\n'
'[[keys.bind]]\nkey = "a"\n[[keys.bind]]\nkey = "b"\n', True, False),
]
def _without_key(d):
d = dict(d)
session = dict(d.get("session") or {})
session.pop("resume_agents_on_restore", None)
if session:
d["session"] = session
else:
d.pop("session", None)
return d
@pytest.mark.parametrize("name,user,kept,warned", CASES, ids=[c[0] for c in CASES])
def test_merge_always_parses_with_restore_off(name, user, kept, warned):
text, warnings = hc.server_config_text(user)
parsed = tomllib.loads(text)
assert parsed["session"]["resume_agents_on_restore"] is False
assert bool(warnings) == warned, warnings
if kept and user:
assert _without_key(parsed) == _without_key(tomllib.loads(user))
if not kept:
assert text == hc.MINIMAL
def test_a_non_bool_restore_value_is_replaced():
# herdr would reject the whole file for this; the key is ours to set anyway.
text, warnings = hc.server_config_text('[session]\nresume_agents_on_restore = "no"\n')
assert tomllib.loads(text) == {"session": {"resume_agents_on_restore": False}} and warnings == []
def test_without_tomllib_restore_is_still_off(monkeypatch):
# The shim runs a bare `python3`; on one older than 3.11 an import error
# would leave the server with restore on. Off wins over the user's settings.
monkeypatch.setattr(hc, "tomllib", None)
text, warnings = hc.server_config_text("[ui]\nconfirm_close = false\n")
assert text == hc.MINIMAL and "no tomllib" in warnings[0]
def _env(tmp_path, **kw):
env = {"HOME": str(tmp_path / "home")}
env.update(kw)
return env
def test_source_is_home_config_by_default(tmp_path):
assert hc.user_config_path(_env(tmp_path)) == str(tmp_path / "home" / ".config" / "herdr" / "config.toml")
def test_source_follows_xdg_config_home(tmp_path, monkeypatch):
assert hc.user_config_path(_env(tmp_path, XDG_CONFIG_HOME=str(tmp_path / "x"))) == \
str(tmp_path / "x" / "herdr" / "config.toml")
monkeypatch.chdir(tmp_path) # herdr takes a relative one against its cwd
assert hc.user_config_path(_env(tmp_path, XDG_CONFIG_HOME="rel")) == \
str(tmp_path / "rel" / "herdr" / "config.toml")
def test_source_is_herdr_config_path_unless_generated(tmp_path):
user = tmp_path / "custom.toml"
user.write_text("[ui]\nconfirm_close = false\n")
env = _env(tmp_path, HERDR_CONFIG_PATH=str(user), XDG_CONFIG_HOME=str(tmp_path / "x"))
assert hc.user_config_path(env) == str(user)
# A MAM pane inherits HERDR_CONFIG_PATH=<generated>; a server bootstrapped
# from there must still build from the user's file, not from the copy.
generated, _ = hc.write_server_config(env)
assert generated == str(tmp_path / hc.OUTPUT_NAME)
assert hc.user_config_path(dict(env, HERDR_CONFIG_PATH=generated)) == str(user)
# A generated file without a source line falls through to XDG / HOME.
bare = tmp_path / "bare.toml"
bare.write_text(hc.MARKER + "\n" + hc.MINIMAL)
assert hc.user_config_path(dict(env, HERDR_CONFIG_PATH=str(bare))) == \
str(tmp_path / "x" / "herdr" / "config.toml")
def test_written_next_to_source_with_its_mode(tmp_path):
cfg_dir = tmp_path / "home" / ".config" / "herdr"
cfg_dir.mkdir(parents=True)
user = cfg_dir / "config.toml"
user.write_text('[ui.sound]\npath = "sounds/done.wav"\n')
user.chmod(0o600)
path, warnings = hc.write_server_config(_env(tmp_path))
assert path == str(cfg_dir / hc.OUTPUT_NAME) and warnings == []
text = Path(path).read_text()
assert text.splitlines()[:2] == [hc.MARKER, hc.SOURCE_PREFIX + str(user)]
parsed = tomllib.loads(text)
# same directory, so the relative sound path resolves to the same file
assert parsed["ui"]["sound"]["path"] == "sounds/done.wav"
assert parsed["session"]["resume_agents_on_restore"] is False
assert stat.S_IMODE(os.stat(path).st_mode) == 0o600
assert user.read_text() == '[ui.sound]\npath = "sounds/done.wav"\n' # untouched
assert [p.name for p in cfg_dir.iterdir() if p.name.endswith(".tmp")] == []
def test_written_without_any_user_config(tmp_path):
path, warnings = hc.write_server_config(_env(tmp_path))
assert path == str(tmp_path / "home" / ".config" / "herdr" / hc.OUTPUT_NAME) and warnings == []
assert stat.S_IMODE(os.stat(path).st_mode) == 0o644
assert tomllib.loads(Path(path).read_text()) == {"session": {"resume_agents_on_restore": False}}
def test_rewritten_on_every_call(tmp_path):
cfg_dir = tmp_path / "home" / ".config" / "herdr"
cfg_dir.mkdir(parents=True)
(cfg_dir / "config.toml").write_text("[ui]\nconfirm_close = true\n")
path, _ = hc.write_server_config(_env(tmp_path))
(cfg_dir / "config.toml").write_text("[ui]\nconfirm_close = false\n")
hc.write_server_config(_env(tmp_path))
assert tomllib.loads(Path(path).read_text())["ui"]["confirm_close"] is False
@pytest.mark.skipif(os.geteuid() == 0, reason="root ignores directory permissions")
def test_unwritable_config_dir_falls_back_to_mam_dir(tmp_path):
cfg_dir = tmp_path / "home" / ".config" / "herdr"
cfg_dir.mkdir(parents=True)
(cfg_dir / "config.toml").write_text("[ui]\nconfirm_close = false\n")
cfg_dir.chmod(0o555)
try:
mam = tmp_path / ".mam"
mam.mkdir()
path, warnings = hc.write_server_config(_env(tmp_path), str(mam))
assert path == str(mam / hc.FALLBACK_NAME)
assert any("relative paths" in w for w in warnings), warnings
assert tomllib.loads(Path(path).read_text())["ui"]["confirm_close"] is False
if HERDR:
assert hc.herdr_rejects(path, HERDR) is None
assert hc.write_server_config(_env(tmp_path))[0] is None # no fallback given
finally:
cfg_dir.chmod(0o755)
# --- the fallback obeys the same rules (review round 2, R3) ----------------
def _tag(source: Path) -> str:
import hashlib
return hashlib.sha256(os.path.realpath(source).encode()).hexdigest()[:12]
@pytest.mark.skipif(os.geteuid() == 0, reason="root ignores directory permissions")
def test_a_foreign_fallback_file_is_kept(tmp_path):
cfg_dir = tmp_path / "cfg"
cfg_dir.mkdir()
source = cfg_dir / "config.toml"
source.write_text("[ui]\nconfirm_close = false\n")
cfg_dir.chmod(0o555)
mam = tmp_path / ".mam"
mam.mkdir()
foreign = mam / hc.FALLBACK_NAME
foreign.write_bytes(b"# user-owned\n[ui]\nconfirm_close = true\n")
try:
path, warnings = hc.write_server_config({"HERDR_CONFIG_PATH": str(source)}, str(mam), HERDR)
finally:
cfg_dir.chmod(0o755)
assert foreign.read_bytes() == b"# user-owned\n[ui]\nconfirm_close = true\n"
assert path == str(mam / f"herdr-server-config-{_tag(source)}.toml"), warnings
assert tomllib.loads(Path(path).read_text())["ui"]["confirm_close"] is False
@pytest.mark.parametrize("alias", [False, True], ids=["same-path", "symlink-alias"])
def test_the_fallback_is_never_the_source(tmp_path, alias):
mam = tmp_path / ".mam"
mam.mkdir()
body = b"[ui]\nconfirm_close = false\n"
if alias:
source = tmp_path / "real.toml"
source.write_bytes(body)
(mam / hc.FALLBACK_NAME).symlink_to(source)
source_dir = tmp_path
else:
source = mam / hc.FALLBACK_NAME
source.write_bytes(body)
source_dir = mam
# both names beside the source are someone else's, so the fallback is used
for name in (hc.OUTPUT_NAME, f"mam-server-config-{_tag(source)}.toml"):
(source_dir / name).write_bytes(b"# foreign\n")
path, _ = hc.write_server_config({"HERDR_CONFIG_PATH": str(source)}, str(mam), HERDR)
assert source.read_bytes() == body
if alias: # os.replace would swap out the user's link itself
assert (mam / hc.FALLBACK_NAME).is_symlink()
assert path and not os.path.samefile(path, source)
assert tomllib.loads(Path(path).read_text())["ui"]["confirm_close"] is False
def test_no_write_when_every_name_is_taken(tmp_path):
mam = tmp_path / ".mam"
mam.mkdir()
source = tmp_path / "config.toml"
source.write_text("[ui]\nconfirm_close = false\n")
taken = [tmp_path / hc.OUTPUT_NAME, tmp_path / f"mam-server-config-{_tag(source)}.toml",
mam / hc.FALLBACK_NAME, mam / f"herdr-server-config-{_tag(source)}.toml"]
for f in taken:
f.write_bytes(b"# foreign\n")
path, warnings = hc.write_server_config({"HERDR_CONFIG_PATH": str(source)}, str(mam))
assert path is None and any("no free output name" in w for w in warnings), warnings
assert all(f.read_bytes() == b"# foreign\n" for f in taken)
def test_cli_prints_the_path(tmp_path):
env = dict(os.environ, HOME=str(tmp_path), PYTHONPATH=str(REPO_ROOT / ".agents" / "skills"))
env.pop("HERDR_CONFIG_PATH", None)
env.pop("XDG_CONFIG_HOME", None)
cfg_dir = tmp_path / ".config" / "herdr"
cfg_dir.mkdir(parents=True)
(cfg_dir / "config.toml").write_text("[session]\n[session]\n")
res = subprocess.run([sys.executable, "-m", "lib_py.herdr_config", "server-config"],
capture_output=True, text=True, env=env)
assert res.returncode == 0, res.stderr
assert res.stdout == str(cfg_dir / hc.OUTPUT_NAME) + "\n"
assert "WARNING: herdr config does not parse" in res.stderr
@pytest.mark.skipif(not HERDR, reason="herdr is not installed")
@pytest.mark.parametrize("name,user,kept,warned", CASES, ids=[c[0] for c in CASES])
def test_real_herdr_accepts_every_result(tmp_path, name, user, kept, warned):
if kept and user: # a source herdr itself takes must stay one it takes
src = tmp_path / "source.toml"
src.write_text(user)
assert hc.herdr_rejects(str(src), HERDR) is None
text, _ = hc.server_config_text(user)
cfg = tmp_path / "server.toml"
cfg.write_text(hc.MARKER + "\n" + text)
assert hc.herdr_rejects(str(cfg), HERDR) is None
@pytest.mark.skipif(not HERDR, reason="herdr is not installed")
def test_herdr_rejects_tells_schema_errors_from_harmless_issues(tmp_path):
bad = tmp_path / "bad.toml"
bad.write_text('[ui]\nconfirm_close = "no"\n')
assert "using defaults" in hc.herdr_rejects(str(bad), HERDR)
soft = tmp_path / "soft.toml"
soft.write_text('made_up = 1\n[ui.sound]\npath = "x.wav"\n')
assert hc.herdr_rejects(str(soft), HERDR) is None
def _cli(tmp_path, source: Path, *extra) -> subprocess.CompletedProcess:
env = {k: v for k, v in os.environ.items() if not k.startswith("HERDR_") and k != "XDG_CONFIG_HOME"}
env.update(HOME=str(tmp_path), HERDR_CONFIG_PATH=str(source),
PYTHONPATH=str(REPO_ROOT / ".agents" / "skills"))
return subprocess.run([sys.executable, "-m", "lib_py.herdr_config", "server-config", *extra],
capture_output=True, text=True, env=env)
@pytest.mark.skipif(not HERDR, reason="herdr is not installed")
@pytest.mark.parametrize("body,kept", [
(b"[ui]\nconfirm_close = false\n", True), # control: valid, kept
(b'[ui]\nconfirm_close = "no"\n', False), # tomllib yes, herdr's schema no (review R1)
(b"session = 1\n[ui]\nconfirm_close = false\n", False), # non-table session (R1)
(b"[ui]\nconfirm_close = false\n# \xff\xfe\n", False), # not UTF-8 (R1)
], ids=["valid", "schema-error", "session-scalar", "invalid-utf8"])
def test_cli_always_hands_herdr_a_restore_off_config_it_accepts(tmp_path, body, kept):
source = tmp_path / "custom.toml"
source.write_bytes(body)
res = _cli(tmp_path, source, "--herdr", HERDR)
assert res.returncode == 0, res.stderr
out = Path(res.stdout.strip())
assert out.parent == tmp_path and out != source
assert hc.herdr_rejects(str(out), HERDR) is None
parsed = tomllib.loads(out.read_text())
assert parsed["session"]["resume_agents_on_restore"] is False
if kept:
assert parsed["ui"]["confirm_close"] is False and res.stderr == ""
else:
assert parsed == {"session": {"resume_agents_on_restore": False}} and "WARNING" in res.stderr
assert source.read_bytes() == body
@pytest.mark.skipif(not HERDR, reason="herdr is not installed")
def test_a_config_herdr_rejects_is_never_left_in_place(tmp_path):
# the merge itself is fine for tomllib; only herdr's schema says no
source = tmp_path / "config.toml"
source.write_text('[ui]\nconfirm_close = "no"\n[session]\nresume_agents_on_restore = true\n')
path, warnings = hc.write_server_config({"HERDR_CONFIG_PATH": str(source)}, herdr=HERDR)
assert tomllib.loads(Path(path).read_text()) == {"session": {"resume_agents_on_restore": False}}
assert any("herdr rejects" in w for w in warnings), warnings
# --- the user's file is never the output (review R3) -----------------------
def test_source_named_like_the_output_is_not_overwritten(tmp_path):
source = tmp_path / hc.OUTPUT_NAME
source.write_bytes(b"[ui]\nconfirm_close = false\n")
path, _ = hc.write_server_config({"HERDR_CONFIG_PATH": str(source)})
assert source.read_bytes() == b"[ui]\nconfirm_close = false\n"
assert Path(path).parent == tmp_path and Path(path) != source
assert tomllib.loads(Path(path).read_text())["ui"]["confirm_close"] is False
# and the next start finds the same file again
assert hc.write_server_config({"HERDR_CONFIG_PATH": str(source)})[0] == path
def test_an_alias_of_the_source_is_not_overwritten(tmp_path):
source = tmp_path / "config.toml"
source.write_bytes(b"[ui]\nconfirm_close = false\n")
(tmp_path / hc.OUTPUT_NAME).symlink_to(source)
path, _ = hc.write_server_config({"HERDR_CONFIG_PATH": str(source)})
assert source.read_bytes() == b"[ui]\nconfirm_close = false\n"
assert os.path.islink(tmp_path / hc.OUTPUT_NAME)
assert not os.path.samefile(path, source)
def test_a_foreign_file_with_the_output_name_is_kept(tmp_path):
source = tmp_path / "config.toml"
source.write_text("[ui]\nconfirm_close = false\n")
foreign = tmp_path / hc.OUTPUT_NAME
foreign.write_bytes(b"# someone else's\n")
path, _ = hc.write_server_config({"HERDR_CONFIG_PATH": str(source)})
assert foreign.read_bytes() == b"# someone else's\n" and Path(path) != foreign
# a file generated for another source is someone else's too
other = tmp_path / "other.toml"
other.write_text("")
other_path, _ = hc.write_server_config({"HERDR_CONFIG_PATH": str(other)})
assert other_path not in (path, str(foreign))
assert hc.user_config_path({"HERDR_CONFIG_PATH": path}) == str(source)
+342
View File
@@ -0,0 +1,342 @@
"""Issue #45 against a real herdr server: a restarted MAM server must not relaunch
saved agents (A), and resume must not leave the session's restored empty shell
behind (B).
Everything runs on a throwaway herdr session under a short throwaway HOME, so
the server's socket, session.json and config never touch the user's herdr (the
running `multi-agent-mux` server included). The agent is a fake `claude`
script that logs its argv; the user's herdr config sets `[terminal]
default_shell` to a shell that puts the fake first on PATH, so herdr's own
restore command (`claude --resume <id>`) reaches the fake too. That is the
positive control: with restore on, the fake logs `--resume <id>`; through the
MAM bootstrap it must not. No real claude runs and no API is called.
A crash is `kill -9` of the server after session.json has the pane's
agent_session (herdr writes it a few seconds after a change).
"""
from __future__ import annotations
import json
import os
import shutil
import signal
import subprocess
import sys
import tempfile
import time
import uuid
from pathlib import Path
import pytest
HERDR = shutil.which("herdr")
pytestmark = pytest.mark.skipif(not HERDR, reason="real herdr is not installed")
REPO_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO_ROOT / ".agents" / "skills"))
from lib_py import herdr_config # noqa: E402
# Inherited from the pane this suite may run in: they would point herdr at that
# pane's server and workspace.
SCOPE_ENV = ("HERDR_WORKSPACE_ID", "HERDR_PANE_ID", "HERDR_TAB_ID", "HERDR_SOCKET_PATH",
"HERDR_SESSION", "HERDR_ENV", "HERDR_CONFIG_PATH", "XDG_CONFIG_HOME")
class Rig:
def __init__(self, sandbox: Path, root: Path, session: str):
self.sandbox, self.root, self.session = sandbox, root, session
self.bin = root / "bin"
self.argv_log = root / "argv.log"
self.sdir = root / ".config" / "herdr" / "sessions" / session
self.user_cfg = root / ".config" / "herdr" / "config.toml"
# -- herdr ---------------------------------------------------------------
def env(self, **extra) -> dict:
env = {k: v for k, v in os.environ.items() if k not in SCOPE_ENV}
env["PATH"] = f"{self.bin}:{env['PATH']}"
env.update(extra)
return env
def hd(self, *args) -> dict:
out = subprocess.run([HERDR, "--session", self.session, *args], env=self.env(),
capture_output=True, text=True, timeout=30)
assert out.returncode == 0, f"herdr {args}: {out.stdout}{out.stderr}"
return json.loads(out.stdout).get("result") or {} if out.stdout.strip() else {}
def panes(self) -> list:
return self.hd("pane", "list").get("panes") or []
def server_pids(self) -> list:
out = subprocess.run(["pgrep", "-f", "--", f"--session {self.session} server"],
capture_output=True, text=True).stdout
return [int(p) for p in out.split()]
def _wait_socket(self):
sock = self.sdir / "herdr.sock"
for _ in range(80):
if sock.exists():
return
time.sleep(0.25)
raise AssertionError("herdr server socket never appeared")
def restores(self) -> int:
log = self.sdir / "herdr-server.log"
return log.read_text().count("session restore evaluated") if log.exists() else 0
def start_raw(self, config: Path | None = None):
"""A server as the user would start it: restore on unless `config` says otherwise."""
env = self.env(**({"HERDR_CONFIG_PATH": str(config)} if config else {}))
subprocess.Popen([HERDR, "--session", self.session, "server"], env=env, start_new_session=True,
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
self._wait_socket()
def run_lib(self, body: str, **extra) -> subprocess.CompletedProcess:
lib = self.sandbox / ".agents" / "skills" / "lib.sh"
return subprocess.run(["bash", "-c", f'source "{lib}"\n{body}'], cwd=str(self.sandbox),
env=self.env(HERDR_SESSION_NAME=self.session, **extra),
capture_output=True, text=True, timeout=120)
def start_via_shim(self):
"""The only way MAM starts a server: the shim's headless bootstrap."""
res = self.run_lib("herdr has-session -t nobody-here || true")
assert res.returncode == 0, res.stderr
assert "restore-on-restart stays enabled" not in res.stderr, res.stderr
try:
self._wait_socket()
except AssertionError:
raise AssertionError(f"the shim did not start the server: {res.stderr}")
def wait_restored(self, before: int):
for _ in range(60):
if self.restores() > before:
time.sleep(3) # let herdr spawn whatever it is going to spawn
return
time.sleep(0.25)
raise AssertionError("the server never evaluated its saved session")
# -- panes ---------------------------------------------------------------
def wait_shell(self, pane_id: str):
for _ in range(40):
info = self.hd("pane", "process-info", "--pane", pane_id)["process_info"]
if info.get("shell_pid"):
return
time.sleep(0.25)
def new_pane(self, cwd: Path, label: str) -> str:
cwd.mkdir(parents=True, exist_ok=True)
pane = self.hd("workspace", "create", "--cwd", str(cwd), "--label", label, "--no-focus")["root_pane"]["pane_id"]
self.wait_shell(pane)
return pane
def run_agent(self, pane_id: str, session_id: str):
"""The fake claude, plus the session report herdr's claude integration would send."""
self.hd("pane", "run", pane_id, f"claude --session-id {session_id}")
self.hd("pane", "report-agent", "--source", "herdr:claude", "--agent", "claude",
"--state", "idle", "--agent-session-id", session_id, pane_id)
def crash(self, *session_ids: str):
"""kill -9 the server once session.json holds every id."""
saved = self.sdir / "session.json"
for _ in range(80):
text = saved.read_text() if saved.exists() else ""
if all(s in text for s in session_ids):
break
time.sleep(0.25)
else:
raise AssertionError(f"session.json never recorded {session_ids}")
for pid in self.server_pids():
os.kill(pid, signal.SIGKILL)
self.kill_fakes()
time.sleep(0.5)
(self.sdir / "herdr.sock").unlink(missing_ok=True)
def kill_fakes(self):
subprocess.run(["pkill", "-9", "-f", f"{self.bin}/claude"], capture_output=True)
def resumed(self, session_id: str) -> bool:
return self.argv_log.exists() and f"--resume {session_id}" in self.argv_log.read_text()
@pytest.fixture
def rig(mam_sandbox, monkeypatch):
# A pytest tmp_path is too long for a unix socket path under it.
root = Path(tempfile.mkdtemp(prefix="m45", dir="/tmp"))
session = f"mamt45-{os.getpid()}-{uuid.uuid4().hex[:4]}"
for k in SCOPE_ENV:
monkeypatch.delenv(k, raising=False)
monkeypatch.setenv("HOME", str(root))
monkeypatch.setenv("HOME_DIR", str(root))
r = Rig(mam_sandbox, root, session)
r.bin.mkdir()
fake = r.bin / "claude"
fake.write_text(f'#!/usr/bin/env bash\n'
'if [ "${1:-}" = auth ]; then echo \'{"loggedIn": true}\'; exit 0; fi\n'
f'echo "$*" >> "{r.argv_log}"\n'
'echo "Welcome to Claude Code (fake)"\nwhile :; do sleep 1; done\n')
fake.chmod(0o755)
shell = root / "shell.sh"
shell.write_text(f'#!/usr/bin/env bash\nexport PATH="{r.bin}:$PATH"\nexec bash --norc -i\n')
shell.chmod(0o755)
r.user_cfg.parent.mkdir(parents=True)
r.user_cfg.write_text(f'[terminal]\ndefault_shell = "{shell}"\n')
try:
yield r
finally:
subprocess.run([HERDR, "session", "stop", session], env=r.env(), capture_output=True, timeout=30)
for pid in r.server_pids():
try:
os.kill(pid, signal.SIGKILL)
except ProcessLookupError:
pass
r.kill_fakes()
subprocess.run(["pkill", "-9", "-f", str(root)], capture_output=True)
subprocess.run([HERDR, "session", "delete", session], env=r.env(), capture_output=True, timeout=30)
shutil.rmtree(root, ignore_errors=True)
def _pane_with(rig: Rig, session_id: str) -> dict | None:
return next((p for p in rig.panes() if (p.get("agent_session") or {}).get("value") == session_id), None)
# --- A ---------------------------------------------------------------------
@pytest.mark.parametrize("user_session_table", [
"",
# an existing [session] table: appending a second one would break the TOML
# and herdr would silently restore again
"[session]\nresume_agents_on_restore = true\n",
], ids=["plain-user-config", "user-config-with-session-table"])
def test_a_mam_server_does_not_relaunch_saved_agents(rig, user_session_table):
rig.user_cfg.write_text(rig.user_cfg.read_text() + user_session_table)
sid = str(uuid.uuid4())
rig.start_raw()
rig.run_agent(rig.new_pane(rig.root / "wsA", "A"), sid)
rig.crash(sid)
# MAM brings the server back: nothing may be relaunched.
before = rig.restores()
rig.start_via_shim()
rig.wait_restored(before)
assert not rig.resumed(sid), rig.argv_log.read_text()
pane = _pane_with(rig, sid)
assert pane is not None and not pane.get("agent"), rig.panes()
info = rig.hd("pane", "process-info", "--pane", pane["pane_id"])["process_info"]
assert info["foreground_process_group_id"] == info["shell_pid"], info
# and it got there through HERDR_CONFIG_PATH
environ = Path(f"/proc/{rig.server_pids()[0]}/environ").read_bytes().split(b"\0")
assert f"HERDR_CONFIG_PATH={rig.user_cfg.parent / herdr_config.OUTPUT_NAME}".encode() in environ
# Positive control: the same saved layout under a plain server relaunches
# the agent, so the "not resumed" above is not vacuous.
rig.crash(sid)
before = rig.restores()
rig.start_raw()
rig.wait_restored(before)
assert rig.resumed(sid), rig.argv_log.read_text() if rig.argv_log.exists() else "no argv log"
# --- B ---------------------------------------------------------------------
def _close(rig: Rig, session_id: str) -> subprocess.CompletedProcess:
env = rig.env(HERDR_SESSION_NAME=rig.session, PYTHONPATH=str(rig.sandbox / ".agents" / "skills"))
return subprocess.run([sys.executable, "-m", "lib_py.herdr_restore", "close-restored-shells",
"--session-id", session_id, "--herdr", HERDR],
env=env, capture_output=True, text=True, timeout=60)
def test_b_only_the_resumed_sessions_restored_shell_is_closed(rig):
ids = {k: str(uuid.uuid4()) for k in ("X", "Y", "Z", "X2")}
rig.start_raw()
for k in ids:
rig.run_agent(rig.new_pane(rig.root / f"ws{k}", k), ids[k])
rig.new_pane(rig.root / "wsU", "U") # a user's plain shell: no agent_session
rig.crash(*ids.values())
shutil.rmtree(rig.root / "wsY") # a removed worktree: restored into $HOME
before = rig.restores()
rig.start_via_shim()
rig.wait_restored(before)
rig.run_agent(_pane_with(rig, ids["Z"])["pane_id"], ids["Z"]) # running again
rig.hd("pane", "run", _pane_with(rig, ids["X2"])["pane_id"], "sleep 60") # busy shell
time.sleep(1)
assert _pane_with(rig, ids["Y"])["cwd"] == str(rig.root)
n = len(rig.panes())
for k in ("Z", "X2"):
res = _close(rig, ids[k])
assert res.returncode == 0 and "closed" not in res.stderr, res.stderr
res = _close(rig, "")
assert res.returncode == 0 and "closed" not in res.stderr, res.stderr
assert len(rig.panes()) == n
x = _pane_with(rig, ids["X"])["pane_id"]
res = _close(rig, ids["X"])
assert res.returncode == 0, res.stderr
assert f"closed restored pane {x}" in res.stderr
assert _pane_with(rig, ids["X"]) is None and len(rig.panes()) == n - 1
res = _close(rig, ids["Y"])
assert res.returncode == 0 and "closed restored pane" in res.stderr, res.stderr
assert _pane_with(rig, ids["Y"]) is None and len(rig.panes()) == n - 2
# the user's shell, the running agent and the busy shell are all still there
assert any(p.get("cwd") == str(rig.root / "wsU") for p in rig.panes())
assert _pane_with(rig, ids["Z"]) and _pane_with(rig, ids["X2"])
def test_b_default_server_is_never_touched(rig):
env = rig.env(HERDR_SESSION_NAME="default", PYTHONPATH=str(rig.sandbox / ".agents" / "skills"))
# a herdr that would fail loudly if called at all
res = subprocess.run([sys.executable, "-m", "lib_py.herdr_restore", "close-restored-shells",
"--session-id", "some-id", "--herdr", "/nonexistent/herdr"],
env=env, capture_output=True, text=True, timeout=30)
assert res.returncode == 0 and res.stderr == "", res.stderr
def _script(rig: Rig, rel: str, *args: str, timeout: int = 180) -> subprocess.CompletedProcess:
"""Output goes through files: a server the script bootstraps outlives it."""
path = rig.sandbox / ".agents" / "skills" / rel
out, err = rig.root / "script.out", rig.root / "script.err"
with open(out, "w") as o, open(err, "w") as e:
try:
p = subprocess.run(["bash", str(path), *args], cwd=str(rig.sandbox), env=rig.env(),
stdout=o, stderr=e, text=True, timeout=timeout)
except subprocess.TimeoutExpired:
raise AssertionError(f"{rel} timed out:\n{out.read_text()}\n{err.read_text()}")
return subprocess.CompletedProcess(p.args, p.returncode, out.read_text(), err.read_text())
def test_b_resume_leaves_no_restored_shell_behind(rig):
"""The real create -> crash -> resume path. The row create writes stays
`running` through the crash, as it does in production; resume runs from a
worktree whose control plane is the sandbox, so it spawns with a CP_PREFIX
and the shim cannot reuse the restored shell (it splits a new pane)."""
ws = rig.root / "wsX"
ws.mkdir()
name = "creator-claude-m45"
res = _script(rig, "multi-agent-mux-create/scripts/create_session.sh", "--workspace", str(ws),
"--agent", "claude", "--role", "creator", "--session", name,
"--herdr-session", rig.session, "--no-onboard", timeout=60)
assert res.returncode == 0, res.stdout + res.stderr
rows = json.loads(rig.run_lib("load_state_json").stdout)["herdr_sessions"]
row = next(r for r in rows if r["name"] == name)
sid = row["claude_session_id_own"]
assert row["status"] == "running" and sid
pane = next(p for p in rig.panes() if p.get("cwd") == str(ws))
# what herdr's claude integration reports for a real claude (checked on a live server)
rig.hd("pane", "report-agent", "--source", "herdr:claude", "--agent", "claude",
"--state", "idle", "--agent-session-id", sid, pane["pane_id"])
rig.crash(sid)
res = _script(rig, "multi-agent-mux-resume/scripts/resume_session.sh", "--workspace", str(ws),
"--agent", "claude", "--session", name)
assert res.returncode == 0, res.stdout + res.stderr
assert not rig.resumed(sid) # herdr did not relaunch it ...
# ... MAM did, with its own command: create's launch plus resume's, both flagged
launches = rig.argv_log.read_text().splitlines()
assert len(launches) == 2 and all("--dangerously-skip-permissions" in l and sid in l for l in launches), launches
in_ws = [p for p in rig.panes() if p.get("cwd") == str(ws)]
assert len(in_ws) == 1, in_ws
info = rig.hd("pane", "process-info", "--pane", in_ws[0]["pane_id"])["process_info"]
assert info["foreground_process_group_id"] != info["shell_pid"], info # the agent, not an empty shell
assert "closed restored pane" in res.stderr, res.stderr
+21
View File
@@ -150,6 +150,27 @@ def test_bootstrap_strips_agent_runtime_markers(mam_sandbox):
assert key in server_env
def test_bootstrap_hands_the_server_a_no_restore_config(mam_sandbox):
"""Issue #45: the server starts with HERDR_CONFIG_PATH = the user's herdr
config plus [session] resume_agents_on_restore = false (that herdr obeys it
is test_issue45_restore.py, on a real server)."""
import tomllib
env = _install_fake_herdr(mam_sandbox)
user_cfg = mam_sandbox / ".config" / "herdr" / "config.toml"
user_cfg.parent.mkdir(parents=True)
user_cfg.write_text("[ui]\nconfirm_close = false\n")
res = _run_through_shim(mam_sandbox, env, "issue45-probe")
assert res.returncode == 0, res.stderr
assert "restore-on-restart stays enabled" not in res.stderr, res.stderr
server_env = json.loads(Path(env["FAKE_HERDR_ENV_DUMP"]).read_text())
assert server_env.get("HERDR_CONFIG_PATH") == str(user_cfg.parent / "mam-server-config.toml")
assert "MAM_SERVER_CFG" not in server_env
cfg = tomllib.loads(Path(server_env["HERDR_CONFIG_PATH"]).read_text())
assert cfg == {"ui": {"confirm_close": False}, "session": {"resume_agents_on_restore": False}}
def test_no_autostart_skips_bootstrap_but_still_routes_the_call(mam_sandbox):
env = _install_fake_herdr(mam_sandbox)
env["MAM_NO_AUTOSTART"] = "1"