Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35935f9e8a | ||
|
|
7c544c06ab | ||
|
|
811b080966 | ||
|
|
ce91cc11b9 | ||
|
|
eedcebcc12 | ||
|
|
feceddf49f | ||
|
|
fb1380fdc1 | ||
|
|
933af344c2 | ||
|
|
ed73f67a4f | ||
|
|
a640b0f072 |
No files matched your search
+17
-2
@@ -29,7 +29,7 @@ export WORKSPACE_ROOT
|
|||||||
# Framework semantic version. Single runtime source of truth; kept in lockstep
|
# 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.
|
# 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.
|
# 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
|
export MAM_VERSION
|
||||||
AGENT_SESSIONS_YAML="${AGENT_SESSIONS_YAML:-$WORKSPACE_ROOT/.mam/agent-sessions.yaml}"
|
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
|
# identity to its sub-agents. Drop only per-session runtime markers - user
|
||||||
# configuration such as CLAUDE_PROJECT_DIR or CLAUDE_CONFIG_DIR still
|
# configuration such as CLAUDE_PROJECT_DIR or CLAUDE_CONFIG_DIR still
|
||||||
# inherits. The family ids mirror what orc_onboard.sh auto-detects.
|
# 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 '
|
_mam_server_pid=$(python3 -c '
|
||||||
import os, subprocess, sys
|
import os, subprocess, sys
|
||||||
deny = ("CLAUDECODE", "CLAUDE_CODE_CHILD_SESSION", "CLAUDE_CODE_SESSION_ID",
|
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_MESSAGING_TOKEN", "CLAUDE_CODE_MESSAGING_SOCKET",
|
||||||
"CLAUDE_CODE_BRIDGE_SESSION_ID",
|
"CLAUDE_CODE_BRIDGE_SESSION_ID",
|
||||||
"ANTIGRAVITY_CONVERSATION_ID", "HERMES_SESSION_ID", "GROK_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}
|
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,
|
p = subprocess.Popen(sys.argv[1:], start_new_session=True, env=env,
|
||||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||||
stdin=subprocess.DEVNULL)
|
stdin=subprocess.DEVNULL)
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -13,7 +13,7 @@ import sys
|
|||||||
from typing import List, Sequence
|
from typing import List, Sequence
|
||||||
|
|
||||||
# Framework version locked with framework constants
|
# Framework version locked with framework constants
|
||||||
FRAMEWORK_VERSION = "4.8.4"
|
FRAMEWORK_VERSION = "4.8.5"
|
||||||
|
|
||||||
CORE_MODULES: Sequence[str] = (
|
CORE_MODULES: Sequence[str] = (
|
||||||
# Standard library modules
|
# Standard library modules
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: multi-agent-mux-create
|
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."
|
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
|
author: godopu
|
||||||
license: MIT
|
license: MIT
|
||||||
platforms: [linux, macos]
|
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 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.
|
- 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.
|
- 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
|
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."
|
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
|
author: godopu
|
||||||
license: MIT
|
license: MIT
|
||||||
platforms: [linux, macos, windows]
|
platforms: [linux, macos, windows]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: multi-agent-mux-loop
|
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."
|
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
|
author: godopu
|
||||||
license: MIT
|
license: MIT
|
||||||
platforms: [linux, macos]
|
platforms: [linux, macos]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: multi-agent-mux-monitor
|
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."
|
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
|
author: godopu
|
||||||
license: MIT
|
license: MIT
|
||||||
platforms: [linux, macos]
|
platforms: [linux, macos]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: multi-agent-mux-orc-onboard
|
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."
|
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
|
author: godopu
|
||||||
license: MIT
|
license: MIT
|
||||||
platforms: [linux, macos]
|
platforms: [linux, macos]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: multi-agent-mux-resume
|
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."
|
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
|
author: godopu
|
||||||
license: MIT
|
license: MIT
|
||||||
platforms: [linux, macos]
|
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>
|
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 |
|
| Option | Meaning |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
|||||||
@@ -233,6 +233,14 @@ if [ "$AGENT" = "opencode" ]; then
|
|||||||
fi
|
fi
|
||||||
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)
|
# 4. Spawn new agent session (delegates to herdr translation shim)
|
||||||
_herdr new-session -d -s "$SESSION_NAME" -c "$WORKSPACE" "$CP_PREFIX$CMD_FULL"
|
_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
|
# Issue #24: every agent, the same call create makes. The signatures and accept
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: multi-agent-mux-status
|
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."
|
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
|
author: godopu
|
||||||
license: MIT
|
license: MIT
|
||||||
platforms: [linux, macos]
|
platforms: [linux, macos]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: multi-agent-mux-stop
|
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."
|
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
|
author: godopu
|
||||||
license: MIT
|
license: MIT
|
||||||
platforms: [linux, macos]
|
platforms: [linux, macos]
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
본 문서는 `herdr_agent_orchestration` 오케스트레이션 및 메시징 백플레인 워크플로우를 새로운 프로젝트에 도입하여 이식하고, 새로운 개발자/에이전트가 초기 가동을 시작할 때 수행해야 하는 환경 설정 및 구축 절차를 안내합니다.
|
본 문서는 `herdr_agent_orchestration` 오케스트레이션 및 메시징 백플레인 워크플로우를 새로운 프로젝트에 도입하여 이식하고, 새로운 개발자/에이전트가 초기 가동을 시작할 때 수행해야 하는 환경 설정 및 구축 절차를 안내합니다.
|
||||||
|
|
||||||
[](VERSIONS.md)
|
[](VERSIONS.md)
|
||||||
|
|
||||||
새로운 에이전트는 이 안내서에 기술된 절차를 순차적으로 실행하여 초기 환경을 안정적으로 설정할 수 있습니다.
|
새로운 에이전트는 이 안내서에 기술된 절차를 순차적으로 실행하여 초기 환경을 안정적으로 설정할 수 있습니다.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -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.
|
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.
|
||||||
|
|
||||||
[](VERSIONS.md)
|
[](VERSIONS.md)
|
||||||
|
|
||||||
A new agent can follow the steps in this guide sequentially to establish a stable and reliable initial environment.
|
A new agent can follow the steps in this guide sequentially to establish a stable and reliable initial environment.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
# Messaging System Technical Analysis & Architecture Report
|
# 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.
|
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
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Herdr와 MQTT 브로커를 기반으로 구축된 고신뢰성 **다중 에이전트 오케스트레이션 및 메시징 백플레인** 프레임워크입니다. `claude`, `agy`, `hermes`, `grok`, `opencode`, `codex` 등 다중 LLM 백엔드 에이전트 전반에 걸쳐 장시간 수행되는 작업(코드 생성, 리팩토링, 보안 검토 등)을 조정, 격리 및 감사할 수 있도록 설계되었습니다.
|
Herdr와 MQTT 브로커를 기반으로 구축된 고신뢰성 **다중 에이전트 오케스트레이션 및 메시징 백플레인** 프레임워크입니다. `claude`, `agy`, `hermes`, `grok`, `opencode`, `codex` 등 다중 LLM 백엔드 에이전트 전반에 걸쳐 장시간 수행되는 작업(코드 생성, 리팩토링, 보안 검토 등)을 조정, 격리 및 감사할 수 있도록 설계되었습니다.
|
||||||
|
|
||||||
[](VERSIONS.md)
|
[](VERSIONS.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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`).
|
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`).
|
||||||
|
|
||||||
[](VERSIONS.md)
|
[](VERSIONS.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+26
-10
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
## 📌 현재 버전 개요 (Current Release)
|
## 📌 현재 버전 개요 (Current Release)
|
||||||
|
|
||||||
- **프레임워크 버전**: `v4.8.4`
|
- **프레임워크 버전**: `v4.8.5`
|
||||||
- **최신 릴리스 일시**: 2026-10-04 (KST)
|
- **최신 릴리스 일시**: 2026-10-04 (KST)
|
||||||
- **기준 브랜치**: `main`
|
- **기준 브랜치**: `main`
|
||||||
- **핵심 아키텍처**:
|
- **핵심 아키텍처**:
|
||||||
@@ -24,23 +24,39 @@
|
|||||||
|
|
||||||
## 🧭 스킬 패키지 버전 매트릭스 (Skills Version Matrix)
|
## 🧭 스킬 패키지 버전 매트릭스 (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-create`** | `4.8.5` | 에이전트 세션 신규 생성 및 Herdr 컨테이너 격리 스폰 | ✅ 배포 |
|
||||||
| **`multi-agent-mux-stop`** | `4.8.4` | 대화 UUID 원자적 캡처 및 세션 안전 종료 (Graceful Stop) | ✅ 배포 |
|
| **`multi-agent-mux-stop`** | `4.8.5` | 대화 UUID 원자적 캡처 및 세션 안전 종료 (Graceful Stop) | ✅ 배포 |
|
||||||
| **`multi-agent-mux-resume`** | `4.8.4` | 온디스크 대화 컨텍스트 기반 Tier-1 초고속 세션 복원 | ✅ 배포 |
|
| **`multi-agent-mux-resume`** | `4.8.5` | 온디스크 대화 컨텍스트 기반 Tier-1 초고속 세션 복원 | ✅ 배포 |
|
||||||
| **`multi-agent-mux-status`** | `4.8.4` | 실시간 Herdr 세션 및 레지스트리 드리프트 스냅샷 조회 | ✅ 배포 |
|
| **`multi-agent-mux-status`** | `4.8.5` | 실시간 Herdr 세션 및 레지스트리 드리프트 스냅샷 조회 | ✅ 배포 |
|
||||||
| **`multi-agent-mux-monitor`** | `4.8.4` | YAML ↔ 런타임 상태 간 자율 조정자 (Reconciler Loop) | ✅ 배포 |
|
| **`multi-agent-mux-monitor`** | `4.8.5` | YAML ↔ 런타임 상태 간 자율 조정자 (Reconciler Loop) | ✅ 배포 |
|
||||||
| **`multi-agent-mux-delegate-job`** | `4.8.4` | MQTT 이벤트 채널 기반 비동기 단위 작업 위임 | ✅ 배포 |
|
| **`multi-agent-mux-delegate-job`** | `4.8.5` | MQTT 이벤트 채널 기반 비동기 단위 작업 위임 | ✅ 배포 |
|
||||||
| **`multi-agent-mux-loop`** | `4.8.4` | Planner-Creator-Reviewer 3자 자율 계획·실행·피어리뷰 루프 | ✅ 배포 |
|
| **`multi-agent-mux-loop`** | `4.8.5` | Planner-Creator-Reviewer 3자 자율 계획·실행·피어리뷰 루프 | ✅ 배포 |
|
||||||
| **`multi-agent-mux-orc-onboard`** | `4.8.4` | 오케스트레이터 UUID 격리 등록 및 서브 세션 오염 방지 | ✅ 배포 |
|
| **`multi-agent-mux-orc-onboard`** | `4.8.5` | 오케스트레이터 UUID 격리 등록 및 서브 세션 오염 방지 | ✅ 배포 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📋 버전별 상세 변경 내역 (Changelog)
|
## 📋 버전별 상세 변경 내역 (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)
|
### 🚀 `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 이었다. 테스트만 고친다.
|
> **패치 (PATCH Release)**: v4.8.3(PR #46)이 넣은 `test_claude_skills_link_resolves_to_agents_skills` 가 병합 후 본 체크아웃에서 실패했다. 테스트는 `.claude/skills` 를 `.agents/skills` 로 가는 링크 하나로 가정했지만, 이 로컬(git-ignored) 디렉터리는 스킬별 링크를 담은 디렉터리일 수도 있다. PR #46 의 리뷰·스위트는 워크트리에서 돌아 이 테스트가 skip 이었다. 테스트만 고친다.
|
||||||
|
|||||||
@@ -236,6 +236,11 @@ def mam_sandbox(tmp_path, monkeypatch, request):
|
|||||||
monkeypatch.delenv("HERDR_SESSION_NAME", raising=False)
|
monkeypatch.delenv("HERDR_SESSION_NAME", raising=False)
|
||||||
monkeypatch.delenv("HERDR_SERVER_NAME", raising=False)
|
monkeypatch.delenv("HERDR_SERVER_NAME", raising=False)
|
||||||
monkeypatch.delenv("HERDR_WORKSPACE_ID", 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
|
import sys
|
||||||
monkeypatch.setenv("AGENT_PYTHON_BIN", sys.executable)
|
monkeypatch.setenv("AGENT_PYTHON_BIN", sys.executable)
|
||||||
|
|
||||||
|
|||||||
@@ -60,8 +60,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"skill": "multi-agent-mux-create",
|
"skill": "multi-agent-mux-create",
|
||||||
"start": 127,
|
"start": 129,
|
||||||
"end": 145,
|
"end": 147,
|
||||||
"lang": "yaml",
|
"lang": "yaml",
|
||||||
"sha": "2dd608d04ae0",
|
"sha": "2dd608d04ae0",
|
||||||
"class": "c",
|
"class": "c",
|
||||||
@@ -74,8 +74,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"skill": "multi-agent-mux-create",
|
"skill": "multi-agent-mux-create",
|
||||||
"start": 151,
|
"start": 153,
|
||||||
"end": 153,
|
"end": 155,
|
||||||
"lang": "bash",
|
"lang": "bash",
|
||||||
"sha": "613003708f19",
|
"sha": "613003708f19",
|
||||||
"class": "c",
|
"class": "c",
|
||||||
@@ -88,8 +88,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"skill": "multi-agent-mux-create",
|
"skill": "multi-agent-mux-create",
|
||||||
"start": 191,
|
"start": 193,
|
||||||
"end": 195,
|
"end": 197,
|
||||||
"lang": "bash",
|
"lang": "bash",
|
||||||
"sha": "38836d31bf38",
|
"sha": "38836d31bf38",
|
||||||
"class": "c",
|
"class": "c",
|
||||||
@@ -103,8 +103,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"skill": "multi-agent-mux-create",
|
"skill": "multi-agent-mux-create",
|
||||||
"start": 217,
|
"start": 219,
|
||||||
"end": 239,
|
"end": 241,
|
||||||
"lang": "bash",
|
"lang": "bash",
|
||||||
"sha": "d90cfd2555fd",
|
"sha": "d90cfd2555fd",
|
||||||
"class": "b",
|
"class": "b",
|
||||||
@@ -478,9 +478,9 @@
|
|||||||
"multi-agent-mux-resume/scripts/resume_session.sh:133-144",
|
"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:147-158",
|
||||||
"multi-agent-mux-resume/scripts/resume_session.sh:172-197",
|
"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:245",
|
||||||
"multi-agent-mux-resume/scripts/resume_session.sh:241",
|
"multi-agent-mux-resume/scripts/resume_session.sh:249",
|
||||||
"multi-agent-mux-resume/scripts/resume_session.sh:247-250",
|
"multi-agent-mux-resume/scripts/resume_session.sh:255-258",
|
||||||
"lib_py/agents/adapters/claude.py:109"
|
"lib_py/agents/adapters/claude.py:109"
|
||||||
],
|
],
|
||||||
"mismatch": [
|
"mismatch": [
|
||||||
|
|||||||
@@ -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
|
lib.sh materialises into $WORKSPACE_ROOT/.mam/shim/herdr. That file is
|
||||||
shared by every checkout bound to one control plane, so keeping it
|
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"
|
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__)))
|
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],
|
show = subprocess.run(["git", "show", "%s:.agents/skills/lib.sh" % base],
|
||||||
capture_output=True, text=True, cwd=repo)
|
capture_output=True, text=True, cwd=repo)
|
||||||
if show.returncode != 0:
|
if show.returncode != 0:
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -150,6 +150,27 @@ def test_bootstrap_strips_agent_runtime_markers(mam_sandbox):
|
|||||||
assert key in server_env
|
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):
|
def test_no_autostart_skips_bootstrap_but_still_routes_the_call(mam_sandbox):
|
||||||
env = _install_fake_herdr(mam_sandbox)
|
env = _install_fake_herdr(mam_sandbox)
|
||||||
env["MAM_NO_AUTOSTART"] = "1"
|
env["MAM_NO_AUTOSTART"] = "1"
|
||||||
|
|||||||
Reference in new issue
Block a user