feat(issue-21): 워크트리 운영 모델 1단계 선행 작업 — 경로 절대화, 루프 --worktree, 제어 평면 바인딩, 설계 문서 정정 #22
No files matched your search
@@ -2693,3 +2693,19 @@ handle_startup_dialogs() {
|
||||
|
||||
# Auto-initialize the herdr translation shim wrapper on library source
|
||||
_init_herdr_isolation
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Issue #21 (item 3): control-plane binding of a session row. The logic lives
|
||||
# in lib_py/control_plane.py; these are thin shell entry points.
|
||||
# mam_control_plane_json <workspace> [<row control_plane JSON>]
|
||||
mam_control_plane_json() {
|
||||
PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" "$(_delegate_py_bin)" -m lib_py.control_plane json "$@"
|
||||
}
|
||||
|
||||
# mam_control_plane_prefix <json>: the leading K=V tokens for new-session, or
|
||||
# nothing when no forward is needed. rc 3: a forward is needed but a path
|
||||
# contains whitespace (lib.sh splits the --env flags unquoted).
|
||||
mam_control_plane_prefix() {
|
||||
[ -n "${1:-}" ] || return 0
|
||||
PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" "$(_delegate_py_bin)" -m lib_py.control_plane prefix "$1"
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Control-plane binding of a session row (Issue #21, item 3).
|
||||
|
||||
The binding is the four absolute paths a session's tools must agree on:
|
||||
WORKSPACE_ROOT, AGENT_SESSIONS_YAML, MAM_JOBS_DIR, DELEGATE_JOB_LOGS_DIR.
|
||||
create/resume persist it on the row (`control_plane`) and forward it to the
|
||||
pane only when it differs from what `--workspace` alone would imply, through
|
||||
the leading `K=V` tokens lib.sh's new-session already turns into `--env`.
|
||||
|
||||
CLI (used by lib.sh):
|
||||
python -m lib_py.control_plane json <workspace> [<row control_plane JSON>]
|
||||
-> {4 keys, "forward": bool, "whitespace": bool, "source": "row"|"env"}
|
||||
python -m lib_py.control_plane prefix <json>
|
||||
-> "WORKSPACE_ROOT=... AGENT_SESSIONS_YAML=... ... " or "" (no forward)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
import sys
|
||||
from typing import Dict, Optional
|
||||
|
||||
KEYS = ("workspace_root", "agent_sessions_yaml", "mam_jobs_dir", "delegate_job_logs_dir")
|
||||
ENV = {"workspace_root": "WORKSPACE_ROOT", "agent_sessions_yaml": "AGENT_SESSIONS_YAML",
|
||||
"mam_jobs_dir": "MAM_JOBS_DIR", "delegate_job_logs_dir": "DELEGATE_JOB_LOGS_DIR"}
|
||||
|
||||
|
||||
def _abs(p: str) -> str:
|
||||
return os.path.realpath(os.path.expanduser(p))
|
||||
|
||||
|
||||
def defaults(workspace: str) -> Dict[str, str]:
|
||||
"""D(ws): the binding `--workspace` implies on its own."""
|
||||
ws = _abs(workspace)
|
||||
mam = os.path.join(ws, ".mam")
|
||||
return {"workspace_root": ws, "agent_sessions_yaml": os.path.join(mam, "agent-sessions.yaml"),
|
||||
"mam_jobs_dir": os.path.join(mam, "jobs"), "delegate_job_logs_dir": os.path.join(mam, "delegate_job_logs")}
|
||||
|
||||
|
||||
def from_env(env=None) -> Dict[str, str]:
|
||||
env = os.environ if env is None else env
|
||||
b = defaults(env.get("WORKSPACE_ROOT") or os.getcwd())
|
||||
for k, var in ENV.items():
|
||||
v = (env.get(var) or "").strip()
|
||||
if v:
|
||||
b[k] = _abs(v)
|
||||
return b
|
||||
|
||||
|
||||
def describe(workspace: str, row: Optional[dict] = None) -> dict:
|
||||
"""The binding to use: the row's persisted one if complete (D11), else the
|
||||
caller's env. A row binding that differs from the env is reported on stderr."""
|
||||
env_b = from_env()
|
||||
source = "env"
|
||||
b = env_b
|
||||
if isinstance(row, dict) and all(isinstance(row.get(k), str) and row.get(k) for k in KEYS):
|
||||
b = {k: row[k] for k in KEYS}
|
||||
source = "row"
|
||||
if b != env_b:
|
||||
print("WARNING: using the control-plane binding recorded in the row "
|
||||
f"({json.dumps(b, sort_keys=True)}) instead of the caller's environment", file=sys.stderr)
|
||||
return {**b, "forward": b != defaults(workspace),
|
||||
"whitespace": any(any(c.isspace() for c in v) for v in b.values()), "source": source}
|
||||
|
||||
|
||||
def prefix(desc: dict) -> str:
|
||||
if not desc.get("forward"):
|
||||
return ""
|
||||
return "".join(f"{ENV[k]}={shlex.quote(desc[k])} " for k in KEYS)
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
if len(argv) >= 2 and argv[0] == "json":
|
||||
row = None
|
||||
if len(argv) >= 3 and argv[2].strip():
|
||||
try:
|
||||
row = json.loads(argv[2])
|
||||
except json.JSONDecodeError:
|
||||
row = None
|
||||
print(json.dumps(describe(argv[1], row)))
|
||||
return 0
|
||||
if len(argv) == 2 and argv[0] == "prefix":
|
||||
desc = json.loads(argv[1])
|
||||
if desc.get("forward") and desc.get("whitespace"):
|
||||
return 3 # lib.sh splits --env flags unquoted: refuse rather than break silently
|
||||
sys.stdout.write(prefix(desc))
|
||||
return 0
|
||||
print(__doc__, file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -202,6 +202,22 @@ if [ -z "$CMD_FULL" ]; then
|
||||
esac
|
||||
fi
|
||||
|
||||
# Issue #21 (item 3): the control-plane binding. Persisted on the row always;
|
||||
# forwarded to the pane (leading K=V tokens that lib.sh's new-session turns
|
||||
# into --env) only when it differs from what --workspace alone implies.
|
||||
# lib.sh splits those flags unquoted (and the idle-pane path evals them), so a
|
||||
# binding with whitespace that would have to be forwarded is refused.
|
||||
CP_JSON="$(mam_control_plane_json "$WORKSPACE" 2>/dev/null || true)"
|
||||
CP_PREFIX=""
|
||||
if [ -n "$CP_JSON" ]; then
|
||||
cp_rc=0
|
||||
CP_PREFIX="$(mam_control_plane_prefix "$CP_JSON")" || cp_rc=$?
|
||||
if [ "$cp_rc" -eq 3 ]; then
|
||||
echo "ERROR: control-plane path contains whitespace; cannot forward to the pane" >&2
|
||||
exit 2
|
||||
fi
|
||||
fi
|
||||
|
||||
spawn() {
|
||||
if [ -z "$HERDR_SERVER_OPT" ]; then
|
||||
if [ -z "${HERDR_SESSION_NAME:-}" ] || [ "$HERDR_SESSION_NAME" = "default" ]; then
|
||||
@@ -213,20 +229,21 @@ spawn() {
|
||||
if { [ -x "$WRAPPER" ] && [ "$(basename "$WRAPPER")" != "claude" ]; } || [ "$USE_WRAPPER" = "1" ]; then
|
||||
SESSION_UUID=""
|
||||
CMD_FULL="$("$(_delegate_py_bin)" -m lib_py.agents spawn-spec "$AGENT" "$RESOLVED_BIN" "" "true" 2>/dev/null || echo "${RESOLVED_BIN} --dangerously-skip-permissions")"
|
||||
[ -z "$CP_PREFIX" ] || echo "WARNING: the ~/.local/bin wrapper path bypasses herdr; the control-plane binding is recorded but not forwarded" >&2
|
||||
nohup "$WRAPPER" >/dev/null 2>&1 &
|
||||
disown
|
||||
else
|
||||
HERDR_SESSION_NAME="$HERDR_SESSION_NAME" _herdr new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" "$CMD_FULL"
|
||||
HERDR_SESSION_NAME="$HERDR_SESSION_NAME" _herdr new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" "$CP_PREFIX$CMD_FULL"
|
||||
fi
|
||||
;;
|
||||
agy|hermes|grok|codex)
|
||||
HERDR_SESSION_NAME="$HERDR_SESSION_NAME" _herdr new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" "$CMD_FULL"
|
||||
HERDR_SESSION_NAME="$HERDR_SESSION_NAME" _herdr new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" "$CP_PREFIX$CMD_FULL"
|
||||
;;
|
||||
opencode)
|
||||
if [ -z "${OPENCODE_PERMISSION:-}" ]; then
|
||||
export OPENCODE_PERMISSION='{"*":"allow"}'
|
||||
fi
|
||||
HERDR_SESSION_NAME="$HERDR_SESSION_NAME" _herdr new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" "$CMD_FULL"
|
||||
HERDR_SESSION_NAME="$HERDR_SESSION_NAME" _herdr new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" "$CP_PREFIX$CMD_FULL"
|
||||
;;
|
||||
*) echo "ERROR: --agent must be claude, agy, hermes, grok, opencode, or codex, got: $AGENT" >&2; exit 2 ;;
|
||||
esac
|
||||
@@ -391,7 +408,7 @@ atomic_dump_yaml "$AGENT_SESSIONS_YAML" \
|
||||
MAM_WS_LABEL="$MAM_WS_LABEL" \
|
||||
SESSION_UUID="$SESSION_UUID" \
|
||||
LAST_VISIBLE_STATUS_OVERRIDE="${LAST_VISIBLE_STATUS_OVERRIDE:-}" \
|
||||
DELEGATE_JOB_ID="$DELEGATE_JOB_ID" ROLE="$ROLE" <<'PYEOF'
|
||||
DELEGATE_JOB_ID="$DELEGATE_JOB_ID" ROLE="$ROLE" CONTROL_PLANE_JSON="$CP_JSON" <<'PYEOF'
|
||||
name = os.environ['SESSION_NAME']
|
||||
agent = os.environ['AGENT']
|
||||
role = os.environ['ROLE']
|
||||
@@ -487,6 +504,15 @@ last_vis_override = os.environ.get('LAST_VISIBLE_STATUS_OVERRIDE', '')
|
||||
if last_vis_override:
|
||||
entry['last_visible_status'] = last_vis_override
|
||||
|
||||
# Issue #21: the control-plane binding this row was created against.
|
||||
_cp_raw = os.environ.get('CONTROL_PLANE_JSON', '')
|
||||
if _cp_raw:
|
||||
try:
|
||||
import json as _json
|
||||
_cp = _json.loads(_cp_raw)
|
||||
entry['control_plane'] = {k: _cp[k] for k in ('workspace_root', 'agent_sessions_yaml', 'mam_jobs_dir', 'delegate_job_logs_dir')}
|
||||
except Exception:
|
||||
pass
|
||||
sessions.append(entry)
|
||||
|
||||
snap = d.setdefault('snapshot', {})
|
||||
|
||||
@@ -100,7 +100,7 @@ if [ "$SUBSCRIBE" = "1" ]; then
|
||||
# The MQTT subscribe loop exits 3 to signal "broker unavailable → poll instead".
|
||||
set +e
|
||||
YAML_PATH="$AGENT_SESSIONS_YAML" HOME_DIR="$HOME_DIR" CLAUDE_PROJECT_DIR="$CLAUDE_PROJECT_DIR" LOCAL_BIN="$LOCAL_BIN" \
|
||||
WORKSPACE_ROOT="$WORKSPACE_ROOT" SUB_TIMEOUT="$SUB_TIMEOUT" SUB_IDLE_TIMEOUT="$SUB_IDLE_TIMEOUT" \
|
||||
WORKSPACE_ROOT="$WORKSPACE_ROOT" MAM_JOBS_DIR="${MAM_JOBS_DIR:-}" SUB_TIMEOUT="$SUB_TIMEOUT" SUB_IDLE_TIMEOUT="$SUB_IDLE_TIMEOUT" \
|
||||
SKILLS_DIR="$SKILLS_DIR" LIB_SH="$LIB_SH" \
|
||||
"$PYBIN" - <<'PYEOF' &
|
||||
import os, sys, json, time, subprocess
|
||||
@@ -236,7 +236,9 @@ def on_message(_client, _userdata, msg):
|
||||
if not jid or not event:
|
||||
return
|
||||
|
||||
if workspace_root:
|
||||
if os.environ.get('MAM_JOBS_DIR'):
|
||||
registry_dir = os.environ['MAM_JOBS_DIR'] # Issue #21: MAM_JOBS_DIR wins
|
||||
elif workspace_root:
|
||||
registry_dir = os.path.join(workspace_root, '.mam', 'jobs')
|
||||
else:
|
||||
yaml_dir = os.path.dirname(yaml_path) if yaml_path else ""
|
||||
|
||||
@@ -150,6 +150,29 @@ else
|
||||
fi
|
||||
fi
|
||||
|
||||
# Issue #21 (item 3, D11): the control-plane binding recorded on the row wins
|
||||
# over the caller's environment (a difference is reported on stderr); a legacy
|
||||
# row without one uses the caller's binding, which update_yaml_resumed.sh then
|
||||
# records. Forwarded to the pane only when it differs from --workspace's own.
|
||||
ROW_CP="$(MAM_STATE_JSON="$(load_state_json)" SESSION_NAME="$SESSION_NAME" python3 -c "
|
||||
import json, os
|
||||
d = json.loads(os.environ.get('MAM_STATE_JSON') or '{}')
|
||||
for s in d.get('herdr_sessions', []):
|
||||
if s.get('name') == os.environ['SESSION_NAME'] and isinstance(s.get('control_plane'), dict):
|
||||
print(json.dumps(s['control_plane']))
|
||||
break
|
||||
" 2>/dev/null || true)"
|
||||
CP_JSON="$(mam_control_plane_json "$WORKSPACE" "$ROW_CP" || true)"
|
||||
CP_PREFIX=""
|
||||
if [ -n "$CP_JSON" ]; then
|
||||
cp_rc=0
|
||||
CP_PREFIX="$(mam_control_plane_prefix "$CP_JSON")" || cp_rc=$?
|
||||
if [ "$cp_rc" -eq 3 ]; then
|
||||
echo "ERROR: control-plane path contains whitespace; cannot forward to the pane" >&2
|
||||
exit 2
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "${DRY_RUN:-0}" = "1" ]; then
|
||||
echo "[dry-run] would spawn: $CMD_FULL"
|
||||
exit 0
|
||||
@@ -162,7 +185,7 @@ if [ "$AGENT" = "opencode" ]; then
|
||||
fi
|
||||
|
||||
# 4. Spawn new agent session (delegates to herdr translation shim)
|
||||
_herdr new-session -d -s "$SESSION_NAME" -c "$WORKSPACE" "$CMD_FULL"
|
||||
_herdr new-session -d -s "$SESSION_NAME" -c "$WORKSPACE" "$CP_PREFIX$CMD_FULL"
|
||||
if [ "$AGENT" = "claude" ]; then
|
||||
# auto-handle trust / bypass dialogs
|
||||
handle_startup_dialogs "$SESSION_NAME" 20
|
||||
@@ -172,7 +195,7 @@ fi
|
||||
sleep 2
|
||||
|
||||
# 5. Update agent-sessions.yaml: status running, last_visible_status
|
||||
bash "$(dirname "${BASH_SOURCE[0]}")/update_yaml_resumed.sh" \
|
||||
CONTROL_PLANE_JSON="$CP_JSON" bash "$(dirname "${BASH_SOURCE[0]}")/update_yaml_resumed.sh" \
|
||||
--session "$SESSION_NAME" --uuid "$UUID" --agent "$AGENT" --workspace "$WORKSPACE" \
|
||||
--herdr-session "$HERDR_SESSION_NAME" --cmd-full "$CMD_FULL" \
|
||||
${HERDR_WORKSPACE_OPT:+--herdr-workspace "$HERDR_WORKSPACE_OPT"}
|
||||
|
||||
@@ -163,6 +163,16 @@ else:
|
||||
target['herdr_workspace'] = wsl
|
||||
|
||||
target['status'] = 'running'
|
||||
# Issue #21 (D11): a row keeps the control-plane binding it was created with;
|
||||
# a legacy row without one gets the binding this resume used.
|
||||
_cp_raw = os.environ.get('CONTROL_PLANE_JSON', '')
|
||||
if _cp_raw and not isinstance(target.get('control_plane'), dict):
|
||||
try:
|
||||
import json as _json
|
||||
_cp = _json.loads(_cp_raw)
|
||||
target['control_plane'] = {k: _cp[k] for k in ('workspace_root', 'agent_sessions_yaml', 'mam_jobs_dir', 'delegate_job_logs_dir')}
|
||||
except Exception:
|
||||
pass
|
||||
if not uuid:
|
||||
# ISSUE-3 (challenge c4b0a075): a Class A fresh-spawn resume re-arms
|
||||
# discovery from scratch - the resumed agent has not spoken yet, so any
|
||||
|
||||
@@ -160,7 +160,14 @@ def get_job_status(s):
|
||||
return ('-', '-')
|
||||
|
||||
project_root = os.environ.get('PROJECT_ROOT', '.')
|
||||
candidates = [
|
||||
# Issue #21: MAM_JOBS_DIR and the registry the row was created against
|
||||
# (control_plane) first, then the matching audit logs, then the old ones.
|
||||
cp = s.get('control_plane') or {}
|
||||
candidates = [os.path.join(r, f"{jid}.json")
|
||||
for r in (os.environ.get('MAM_JOBS_DIR'), cp.get('mam_jobs_dir')) if r]
|
||||
candidates += [os.path.join(r, jid, 'status.json')
|
||||
for r in (os.environ.get('DELEGATE_JOB_LOGS_DIR'), cp.get('delegate_job_logs_dir')) if r]
|
||||
candidates += [
|
||||
os.path.join('.mam', 'jobs', f"{jid}.json"),
|
||||
os.path.join(project_root, '.mam', 'jobs', f"{jid}.json"),
|
||||
os.path.join(project_root, '.mam', 'delegate_job_logs', jid, 'status.json'),
|
||||
@@ -361,7 +368,14 @@ def get_job_status(s):
|
||||
project_root = os.environ.get('PROJECT_ROOT', '.')
|
||||
# Candidate locations (review item 6: project-root-relative, no hardcoded abs paths):
|
||||
# 1) cwd-relative registry 2) project-root registry 3) project-root audit log
|
||||
candidates = [
|
||||
# Issue #21: MAM_JOBS_DIR and the registry the row was created against
|
||||
# (control_plane) first, then the matching audit logs, then the old ones.
|
||||
cp = s.get('control_plane') or {}
|
||||
candidates = [os.path.join(r, f"{jid}.json")
|
||||
for r in (os.environ.get('MAM_JOBS_DIR'), cp.get('mam_jobs_dir')) if r]
|
||||
candidates += [os.path.join(r, jid, 'status.json')
|
||||
for r in (os.environ.get('DELEGATE_JOB_LOGS_DIR'), cp.get('delegate_job_logs_dir')) if r]
|
||||
candidates += [
|
||||
os.path.join('.mam', 'jobs', f"{jid}.json"),
|
||||
os.path.join(project_root, '.mam', 'jobs', f"{jid}.json"),
|
||||
os.path.join(project_root, '.mam', 'delegate_job_logs', jid, 'status.json'),
|
||||
|
||||
@@ -132,7 +132,8 @@ for s in d.get('herdr_sessions', []):
|
||||
if s.get('name') == name:
|
||||
cwd = (s.get('pane') or {}).get('cwd', '')
|
||||
jid = s.get('delegate_job_id', '') or ''
|
||||
print(json.dumps({'cwd': cwd, 'job_id': jid}))
|
||||
cp = s.get('control_plane') or {}
|
||||
print(json.dumps({'cwd': cwd, 'job_id': jid, 'mam_jobs_dir': cp.get('mam_jobs_dir', '') or ''}))
|
||||
sys.exit(0)
|
||||
sys.exit(7)
|
||||
") || {
|
||||
@@ -142,6 +143,13 @@ sys.exit(7)
|
||||
|
||||
TARGET_CWD=$(printf '%s' "$MAPPED_DATA" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("cwd",""))')
|
||||
DELEGATE_JOB_ID=$(printf '%s' "$MAPPED_DATA" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("job_id",""))')
|
||||
# Issue #21: a row created against another control plane names its registry.
|
||||
# Order: MAM_JOBS_DIR > the row's control_plane.mam_jobs_dir > WORKSPACE_ROOT >
|
||||
# .mam/jobs; exported so delegate_publish_event (lib.sh) uses the same one.
|
||||
ROW_JOBS_DIR=$(printf '%s' "$MAPPED_DATA" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("mam_jobs_dir",""))')
|
||||
if [ -z "${MAM_JOBS_DIR:-}" ] && [ -n "$ROW_JOBS_DIR" ]; then
|
||||
export MAM_JOBS_DIR="$ROW_JOBS_DIR"
|
||||
fi
|
||||
DELEGATE_JOB_TOKEN=""
|
||||
DELEGATE_JOB_ATTEMPT=""
|
||||
if [ -n "$DELEGATE_JOB_ID" ]; then
|
||||
|
||||
@@ -486,3 +486,207 @@ def test_t2_14_undeterminable_change_set_is_refused(wt_repo, monkeypatch, tmp_pa
|
||||
assert rc == 1
|
||||
assert _roles(calls, "Reviewer") == []
|
||||
assert "refusing to request reviews on no evidence" in capsys.readouterr().err
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# §3 item 3 — control-plane binding persisted on the row, forwarded to the pane
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
CP_ENV = ("WORKSPACE_ROOT", "AGENT_SESSIONS_YAML", "MAM_JOBS_DIR", "DELEGATE_JOB_LOGS_DIR")
|
||||
|
||||
|
||||
def _binding(root: Path) -> dict:
|
||||
r = os.path.realpath(root)
|
||||
return {"workspace_root": r, "agent_sessions_yaml": f"{r}/.mam/agent-sessions.yaml",
|
||||
"mam_jobs_dir": f"{r}/.mam/jobs", "delegate_job_logs_dir": f"{r}/.mam/delegate_job_logs"}
|
||||
|
||||
|
||||
def _rows(tmp: Path) -> list:
|
||||
import yaml
|
||||
return (yaml.safe_load((tmp / ".mam" / "agent-sessions.yaml").read_text()) or {}).get("herdr_sessions") or []
|
||||
|
||||
|
||||
def _env_flags(mock_state_file, since=0) -> list:
|
||||
calls = json.loads(Path(mock_state_file).read_text()).get("calls", [])[since:]
|
||||
return [c[i + 1] for c in calls for i, t in enumerate(c[:-1]) if t == "--env"]
|
||||
|
||||
|
||||
def _ncalls(mock_state_file) -> int:
|
||||
return len(json.loads(Path(mock_state_file).read_text()).get("calls", []))
|
||||
|
||||
|
||||
def _skill(tmp: Path, *parts) -> str:
|
||||
return str(tmp / ".agents" / "skills" / Path(*parts))
|
||||
|
||||
|
||||
def _create(tmp: Path, ws: Path, env=None) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(["bash", _skill(tmp, "multi-agent-mux-create", "scripts", "create_session.sh"),
|
||||
"--workspace", str(ws), "--agent", "claude", "--role", "creator", "--no-onboard"],
|
||||
capture_output=True, text=True, cwd=str(tmp), env=env or dict(os.environ), timeout=180)
|
||||
|
||||
|
||||
def _mutate(tmp: Path, code: str) -> None:
|
||||
res = subprocess.run(["bash", "-c", f"source {_skill(tmp, 'lib.sh')} && atomic_dump_yaml {tmp / '.mam' / 'agent-sessions.yaml'}"],
|
||||
input=code, capture_output=True, text=True)
|
||||
assert res.returncode == 0, res.stderr
|
||||
|
||||
|
||||
def test_t3_0_control_plane_module(tmp_path, monkeypatch):
|
||||
from lib_py import control_plane as cp
|
||||
for k in CP_ENV:
|
||||
monkeypatch.delenv(k, raising=False)
|
||||
monkeypatch.setenv("WORKSPACE_ROOT", str(tmp_path))
|
||||
d = cp.describe(str(tmp_path))
|
||||
assert d["forward"] is False and d["source"] == "env" and cp.prefix(d) == ""
|
||||
d = cp.describe(str(tmp_path / "wt"))
|
||||
assert d["forward"] is True
|
||||
assert cp.prefix(d).startswith(f"WORKSPACE_ROOT={os.path.realpath(tmp_path)} ")
|
||||
row = _binding(tmp_path / "other")
|
||||
d = cp.describe(str(tmp_path / "wt"), row)
|
||||
assert d["source"] == "row" and d["workspace_root"] == row["workspace_root"]
|
||||
monkeypatch.setenv("MAM_JOBS_DIR", str(tmp_path / "a b"))
|
||||
d = cp.describe(str(tmp_path))
|
||||
assert d["forward"] and d["whitespace"]
|
||||
assert cp.main(["prefix", json.dumps(d)]) == 3
|
||||
|
||||
|
||||
def test_t3_1_create_persists_and_forwards_a_foreign_binding(mam_sandbox, mock_herdr, mock_agents):
|
||||
tmp = mam_sandbox
|
||||
wt = tmp / "wt"
|
||||
wt.mkdir()
|
||||
res = _create(tmp, wt)
|
||||
assert res.returncode == 0, res.stdout + res.stderr
|
||||
(row,) = _rows(tmp)
|
||||
assert row["control_plane"] == _binding(tmp)
|
||||
flags = _env_flags(mock_herdr)
|
||||
for k, v in zip(CP_ENV, _binding(tmp).values()):
|
||||
assert f"{k}={v}" in flags, flags
|
||||
assert "WORKSPACE_ROOT=" not in row["pane"]["cmd_full"]
|
||||
assert "WORKSPACE_ROOT=" not in row["start_command"]
|
||||
|
||||
|
||||
def test_t3_2_default_binding_is_recorded_not_forwarded(mam_sandbox, mock_herdr, mock_agents):
|
||||
tmp = mam_sandbox
|
||||
res = _create(tmp, tmp)
|
||||
assert res.returncode == 0, res.stdout + res.stderr
|
||||
(row,) = _rows(tmp)
|
||||
assert row["control_plane"] == _binding(tmp)
|
||||
assert not [f for f in _env_flags(mock_herdr) if f.startswith(CP_ENV)], "no forward for the default binding"
|
||||
|
||||
|
||||
def test_t3_5_whitespace_binding_that_needs_forwarding_is_refused(mam_sandbox, mock_herdr, mock_agents):
|
||||
tmp = mam_sandbox
|
||||
env = {**os.environ, "MAM_JOBS_DIR": str(tmp / "jobs dir")}
|
||||
res = _create(tmp, tmp, env)
|
||||
assert res.returncode == 2, res.stdout + res.stderr
|
||||
assert "control-plane path contains whitespace" in res.stderr
|
||||
assert _rows(tmp) == []
|
||||
|
||||
|
||||
def test_t3_3_4_stop_keeps_the_binding_and_resume_reforwards_it(mam_sandbox, mock_herdr, mock_agents):
|
||||
tmp = mam_sandbox
|
||||
wt = tmp / "wt"
|
||||
wt.mkdir()
|
||||
assert _create(tmp, wt).returncode == 0
|
||||
(row,) = _rows(tmp)
|
||||
name = row["name"]
|
||||
res = subprocess.run(["bash", _skill(tmp, "multi-agent-mux-stop", "scripts", "stop_session.sh"),
|
||||
"--session", name, "--agent", "claude"], capture_output=True, text=True, timeout=120)
|
||||
assert res.returncode == 0, res.stdout + res.stderr
|
||||
(row,) = _rows(tmp)
|
||||
assert row["control_plane"] == _binding(tmp), "stop edits the row in place"
|
||||
|
||||
env = {k: v for k, v in os.environ.items() if k not in ("MAM_JOBS_DIR", "DELEGATE_JOB_LOGS_DIR")}
|
||||
since = _ncalls(mock_herdr)
|
||||
res = subprocess.run(["bash", _skill(tmp, "multi-agent-mux-resume", "scripts", "resume_session.sh"),
|
||||
"--workspace", str(wt), "--agent", "claude", "--session", name],
|
||||
capture_output=True, text=True, env=env, timeout=180)
|
||||
assert res.returncode == 0, res.stdout + res.stderr
|
||||
# A fresh pane gets `--env K=V` flags; a reused idle pane (the stopped
|
||||
# session's) gets the same K=V tokens as `env K=V ... <cmd>` (lib.sh D9).
|
||||
state = json.loads(Path(mock_herdr).read_text())
|
||||
calls = state.get("calls", [])[since:]
|
||||
args = [a for c in calls for a in c if isinstance(a, str)] + _sent_texts(state)
|
||||
forwarded = {tok for a in args for tok in a.split()}
|
||||
for k, v in zip(CP_ENV, _binding(tmp).values()):
|
||||
assert f"{k}={v}" in forwarded, (res.stderr[-1500:], json.dumps(calls)[:1500])
|
||||
(row,) = _rows(tmp)
|
||||
assert row["control_plane"] == _binding(tmp)
|
||||
|
||||
|
||||
def test_t3_4b_stop_finds_the_job_in_the_rows_registry(mam_sandbox, mock_herdr, mock_agents, monkeypatch):
|
||||
from lib_py.delegation import registry
|
||||
tmp = mam_sandbox
|
||||
ctl_jobs = tmp / "ctl" / "jobs"
|
||||
jid = registry.register_job("t3-4", registry_dir=str(ctl_jobs), agent_session="herdr:t34-creator-claude")
|
||||
binding = {**_binding(tmp), "mam_jobs_dir": os.path.realpath(ctl_jobs)}
|
||||
_mutate(tmp, f"""
|
||||
d['herdr_sessions'] = [{{
|
||||
'name': 't34-creator-claude', 'status': 'running', 'role': 'creator',
|
||||
'delegate_job_id': {jid!r}, 'pane': {{'cwd': {str(tmp)!r}, 'cmd': 'claude'}},
|
||||
'control_plane': {binding!r},
|
||||
}}]
|
||||
""")
|
||||
env = {k: v for k, v in os.environ.items() if k != "MAM_JOBS_DIR"}
|
||||
res = subprocess.run(["bash", _skill(tmp, "multi-agent-mux-stop", "scripts", "stop_session.sh"),
|
||||
"--session", "t34-creator-claude", "--agent", "claude"],
|
||||
capture_output=True, text=True, env=env, timeout=120)
|
||||
assert res.returncode == 0, res.stdout + res.stderr
|
||||
assert (ctl_jobs / f"{jid}.events.log").exists(), "stop published into the row's registry"
|
||||
assert not (tmp / ".mam" / "jobs" / f"{jid}.json").exists()
|
||||
|
||||
|
||||
def test_t3_6_status_reads_mam_jobs_dir_and_the_row_binding(mam_sandbox, monkeypatch):
|
||||
tmp = mam_sandbox
|
||||
x, y = tmp.parent / f"x-{tmp.name}", tmp / "row-jobs"
|
||||
for d, jid in ((x, "job21x"), (y, "job21y")):
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
(d / f"{jid}.json").write_text('{"status": "completed"}')
|
||||
status_sh = _skill(tmp, "multi-agent-mux-status", "scripts", "status.sh")
|
||||
try:
|
||||
_mutate(tmp, f"""
|
||||
d['herdr_sessions'] = [{{'name': 's21-creator-claude', 'status': 'running', 'delegate_job_id': 'job21x',
|
||||
'pane': {{'cwd': {str(tmp)!r}}}}}]
|
||||
""")
|
||||
env = {**os.environ, "MAM_JOBS_DIR": str(x)}
|
||||
res = subprocess.run(["bash", status_sh, "--json"], capture_output=True, text=True, env=env, timeout=120)
|
||||
assert res.returncode == 0, res.stderr
|
||||
assert json.loads(res.stdout)["sessions_detail"][0]["job_status"] == "completed"
|
||||
|
||||
_mutate(tmp, f"""
|
||||
d['herdr_sessions'] = [{{'name': 's21-creator-claude', 'status': 'running', 'delegate_job_id': 'job21y',
|
||||
'pane': {{'cwd': {str(tmp)!r}}}, 'control_plane': {{'mam_jobs_dir': {str(y)!r}}}}}]
|
||||
""")
|
||||
env = {k: v for k, v in os.environ.items() if k != "MAM_JOBS_DIR"}
|
||||
res = subprocess.run(["bash", status_sh, "--json"], capture_output=True, text=True, env=env, timeout=120)
|
||||
assert json.loads(res.stdout)["sessions_detail"][0]["job_status"] == "completed"
|
||||
finally:
|
||||
import shutil
|
||||
shutil.rmtree(x, ignore_errors=True)
|
||||
|
||||
|
||||
def test_t3_7_reconcile_prefers_mam_jobs_dir_static_guard():
|
||||
"""Weak by design: the subscribe loop is a heredoc run only against a live
|
||||
broker, so this pins the source text."""
|
||||
src = (SKILLS / "multi-agent-mux-monitor" / "scripts" / "reconcile.sh").read_text()
|
||||
assert "if os.environ.get('MAM_JOBS_DIR'):\n registry_dir = os.environ['MAM_JOBS_DIR']" in src
|
||||
assert 'MAM_JOBS_DIR="${MAM_JOBS_DIR:-}"' in src
|
||||
|
||||
|
||||
def test_t3_8_legacy_row_without_binding_is_filled_on_resume(mam_sandbox, mock_herdr, mock_agents):
|
||||
tmp = mam_sandbox
|
||||
_mutate(tmp, f"""
|
||||
d['herdr_sessions'] = [{{
|
||||
'name': 'legacy-creator-claude', 'status': 'stopped', 'role': 'creator',
|
||||
'herdr_session': 'default', 'herdr_server': 'default',
|
||||
'pane': {{'cwd': {str(tmp)!r}, 'cmd': 'claude'}},
|
||||
'claude_session_id_own': '11111111-2222-4333-8444-555555555555',
|
||||
'session_id_source': 'assigned', 'session_id_verified': False,
|
||||
}}]
|
||||
""")
|
||||
res = subprocess.run(["bash", _skill(tmp, "multi-agent-mux-resume", "scripts", "resume_session.sh"),
|
||||
"--workspace", str(tmp), "--agent", "claude", "--session", "legacy-creator-claude"],
|
||||
capture_output=True, text=True, timeout=180)
|
||||
assert res.returncode == 0, res.stdout + res.stderr
|
||||
(row,) = _rows(tmp)
|
||||
assert row["control_plane"] == _binding(tmp)
|
||||
Reference in new issue
Block a user