From 3bcaa39ab00470afc42095973a84c162cda6ab71 Mon Sep 17 00:00:00 2001 From: Godopu Date: Tue, 22 Sep 2026 04:14:45 +0000 Subject: [PATCH 01/15] fix(issue-23): a herdr call to another server drops the caller pane's workspace/pane/tab ids A MAM script often runs inside one herdr server's pane (the orchestrator's) while targeting another (the isolated MAM server). The pane exports HERDR_WORKSPACE_ID, HERDR_PANE_ID and HERDR_TAB_ID, and those ids only mean something on the server that issued them. They reached the scope filters as a foreign workspace, whose fail-closed miss (ISSUE-3 / F-2b) reports a live session as absent: has-session, kill-session, agent prompt and the delegate-job preflight all answered "no such session". Reproduced on Linux and macOS in the #23 comments, worked around until now with `env -u` at every call site. mam_herdr drops the three ids for a call aimed at a named server other than the caller's own. Scope then falls back to .mam/herdr_workspace_id and finally to server-global, which is what _herdr_ws_scope already does. `default` is not a crossing: it is the sentinel for "pass no session flag", so herdr answers on the ambient server, which for a caller in a pane is that caller's own. A caller outside any pane keeps an exported scope, so H-18/H-21/H-23 keep their meaning. The fix lives in mam_herdr, not in the shim heredoc: that heredoc is materialised into $WORKSPACE_ROOT/.mam/shim/herdr, one file shared by every checkout bound to a control plane and rewritten on every herdr call, so editing it would put unreviewed code in the control root and let two checkouts overwrite each other. S11 fixes that invariant. lib_py/loop/watcher.py builds its own herdr argv and is the one path that does not go through mam_herdr, so it applies the same rule via scoped_env. Co-Authored-By: Claude Opus 5 --- .agents/skills/lib.sh | 43 +++- .agents/skills/lib_py/loop/watcher.py | 27 ++- tests/test_issue23_24_startup_path.py | 304 ++++++++++++++++++++++++++ 3 files changed, 372 insertions(+), 2 deletions(-) create mode 100644 tests/test_issue23_24_startup_path.py diff --git a/.agents/skills/lib.sh b/.agents/skills/lib.sh index 1b0283a..c39b9e2 100644 --- a/.agents/skills/lib.sh +++ b/.agents/skills/lib.sh @@ -1373,9 +1373,50 @@ EOF fi } +# _herdr_call_is_cross_server +# +# ISSUE-23: a herdr pane exports HERDR_WORKSPACE_ID / HERDR_PANE_ID / +# HERDR_TAB_ID, and those ids only mean something on the server that issued +# them. When a MAM script runs inside one server's pane (the orchestrator's) +# but targets another (the isolated MAM server named by HERDR_SESSION_NAME), +# the inherited ids reach the scope filters (_herdr_ws_scope, +# _herdr_agent_get_scoped, _resolve_herdr_pane_id) as a foreign workspace, and +# their fail-closed miss (lib.sh explicit-env miss, ISSUE-3 / F-2b) reports a +# live session as absent: has-session, kill-session, agent prompt and the +# delegate-job preflight all answer "no such session". +# +# True only for a call aimed at a *named* server other than the caller's own: +# +# * target unset or `default` (the lib.sh:97 fallback) is the shim's sentinel +# for "pass no session flag", so herdr answers on the ambient server, which +# for a caller inside a pane is that same caller's server - not a crossing; +# * no HERDR_SESSION means the caller is not in a pane at all, so an exported +# HERDR_WORKSPACE_ID is a deliberate scope, not a leak (H-18/H-21/H-23); +# * caller == target is the same server. +_herdr_call_is_cross_server() { + local caller="${HERDR_SESSION:-}" target="${HERDR_SESSION_NAME:-}" + case "$target" in + ""|default) return 1 ;; + esac + if [ -n "$caller" ] && [ "$caller" != "$target" ]; then + return 0 + fi + return 1 +} + mam_herdr() { _init_herdr_isolation - "$WORKSPACE_ROOT/.mam/shim/herdr" "$@" + # Built as an argv array and decided inside `if`: this runs under + # `set -euo pipefail` in most callers, where a bare failing test would abort + # the whole script instead of this one call. + local -a _scope_strip=() + if _herdr_call_is_cross_server; then + _scope_strip=(env -u HERDR_WORKSPACE_ID -u HERDR_PANE_ID -u HERDR_TAB_ID) + if [ "${MAM_SHIM_DEBUG:-0}" = "1" ]; then + echo "mam_herdr: dropping caller pane scope ids (caller server '${HERDR_SESSION:-}' != target '${HERDR_SESSION_NAME:-}')" >&2 + fi + fi + ${_scope_strip[@]+"${_scope_strip[@]}"} "$WORKSPACE_ROOT/.mam/shim/herdr" "$@" } _herdr() { diff --git a/.agents/skills/lib_py/loop/watcher.py b/.agents/skills/lib_py/loop/watcher.py index 7980d62..e33ff6e 100644 --- a/.agents/skills/lib_py/loop/watcher.py +++ b/.agents/skills/lib_py/loop/watcher.py @@ -19,6 +19,7 @@ from __future__ import annotations import json import logging +import os import subprocess from typing import Any, Dict, Optional, Sequence @@ -38,6 +39,30 @@ TRANSPORT_ERROR = "transport_error" BACKOFF_START_S = 1.0 BACKOFF_MAX_S = 16.0 +# ISSUE-23: ids a herdr pane exports about ITS OWN server. Passing them to a +# call aimed at a different server makes herdr scope the lookup to a workspace +# that does not exist there, and the target is reported as missing. The shell +# side drops them in mam_herdr; this is the one python path that runs herdr +# without going through it. +CALLER_SCOPE_VARS = ("HERDR_WORKSPACE_ID", "HERDR_PANE_ID", "HERDR_TAB_ID") + + +def scoped_env(session=None, environ=None): + """The environment for a wait aimed at `session`. + + Unchanged unless this process runs inside a pane of another server: only + then are the caller's own workspace/pane/tab ids meaningless for the call + and dropped. No pane (no HERDR_SESSION) and same-server keep every value. + """ + src = os.environ if environ is None else environ + caller = (src.get("HERDR_SESSION") or "").strip() + target = (session or "").strip() + # `default` is the same sentinel build_argv reads: no --session flag, so + # herdr answers on the ambient server - the caller's own. + if not caller or not target or target == "default" or caller == target: + return dict(src) + return dict((k, v) for k, v in src.items() if k not in CALLER_SCOPE_VARS) + class WaitOutcome(object): __slots__ = ("kind", "agent", "error_code", "detail", "raw") @@ -92,7 +117,7 @@ def wait_once(target: str, until: Sequence[str] = DEFAULT_UNTIL, """One barrier call. `run` is injectable so tests never touch a real herdr.""" argv = build_argv(target, until, timeout_ms, session=session) try: - proc = run(argv, capture_output=True, text=True) + proc = run(argv, capture_output=True, text=True, env=scoped_env(session)) except Exception as exc: return WaitOutcome(TRANSPORT_ERROR, detail="herdr could not be executed: {}".format(exc)) diff --git a/tests/test_issue23_24_startup_path.py b/tests/test_issue23_24_startup_path.py new file mode 100644 index 0000000..c5d8d3f --- /dev/null +++ b/tests/test_issue23_24_startup_path.py @@ -0,0 +1,304 @@ +"""Issue #23 + #24 — the create_session start path. + +Four regressions live here, in the order of the issues: + +1. a herdr call aimed at another server must not carry the caller pane's own + workspace/pane/tab ids (#23 comments: reproduced on Linux and macOS); +2. blocking start-up modals are agent-adapter knowledge and are answered while + waiting for the TUI (#24); +3. onboarding delivery is judged by the agent's own `started` event (#23); +4. a rollback verifies the kill and leaves no `running` row or dangling job (#23). + +What is mocked and what is real is stated per test. The dialog tests fix OUR +contract - which pane string gets which keys - not that the keys drive a real +agy/grok/codex/opencode TUI; no such TUI can be started here. +""" + +import json +import os +import subprocess +import textwrap + +import pytest + + +# -------------------------------------------------------------------------- +# helpers +# -------------------------------------------------------------------------- + +def _run_lib(tmp_path, body, env=None): + """Source the sandbox lib.sh and run `body`. Unlike the shim-contract + helper this keeps HERDR_* exactly as `env` sets it: these tests are about + what the caller's pane leaks in.""" + lib_path = tmp_path / ".agents" / "skills" / "lib.sh" + script = 'source "%s"\n_init_herdr_isolation\n%s\n' % (lib_path, body) + run_env = dict(os.environ) + for var in ("HERDR_SESSION", "HERDR_SESSION_NAME", "HERDR_WORKSPACE_ID", + "HERDR_PANE_ID", "HERDR_TAB_ID"): + run_env.pop(var, None) + run_env.update(env or {}) + return subprocess.run(["bash", "-c", script], capture_output=True, + text=True, cwd=str(tmp_path), env=run_env) + + +def _mock_state(mock_herdr): + with open(mock_herdr) as f: + return json.load(f) + + +def _write_mock_state(mock_herdr, state): + with open(mock_herdr, "w") as f: + json.dump(state, f, indent=2) + + +def _seed_agent(mock_herdr, tmp_path, name, workspace_id, pane_id): + state = _mock_state(mock_herdr) + state.setdefault("workspaces", []) + if not any(w.get("workspace_id") == workspace_id for w in state["workspaces"]): + state["workspaces"].append( + {"workspace_id": workspace_id, "label": workspace_id, "cwd": str(tmp_path)}) + state.setdefault("agents", {})[name] = { + "name": name, "agent": "agy", "status": "running", "cwd": str(tmp_path), + "workspace_id": workspace_id, "pane_id": pane_id, "pid": 9999, + "command": "agy", "buffer": "Ready", + } + state.setdefault("panes", []).append( + {"pane_id": pane_id, "workspace_id": workspace_id, "label": name, "buffer": "Ready"}) + _write_mock_state(mock_herdr, state) + + +def _install_env_recorder(tmp_path, monkeypatch): + """A herdr earlier in PATH than the mock that dumps the env it was called + with, then execs the mock. The shim resolves the first non-shim herdr on + PATH, so this observes exactly what reached the real binary.""" + rec_dir = tmp_path / "recorder" + rec_dir.mkdir(parents=True, exist_ok=True) + env_log = tmp_path / "herdr_env.ndjson" + real = tmp_path / "bin" / "herdr" + script = rec_dir / "herdr" + script.write_text(textwrap.dedent('''\ + #!/usr/bin/env python3 + import json, os, sys + with open(%r, "a") as f: + f.write(json.dumps({ + "argv": sys.argv[1:], + "HERDR_WORKSPACE_ID": os.environ.get("HERDR_WORKSPACE_ID"), + "HERDR_PANE_ID": os.environ.get("HERDR_PANE_ID"), + "HERDR_TAB_ID": os.environ.get("HERDR_TAB_ID"), + "HERDR_SESSION": os.environ.get("HERDR_SESSION"), + "HERDR_SESSION_NAME": os.environ.get("HERDR_SESSION_NAME"), + }) + "\\n") + os.execv(%r, [%r] + sys.argv[1:]) + ''') % (str(env_log), str(real), str(real))) + script.chmod(0o755) + monkeypatch.setenv("PATH", "%s:%s" % (rec_dir, os.environ["PATH"])) + return env_log + + +def _recorded(env_log): + if not os.path.exists(env_log): + return [] + return [json.loads(l) for l in open(env_log) if l.strip()] + + +# -------------------------------------------------------------------------- +# item 1 — caller pane scope must not leak across servers +# -------------------------------------------------------------------------- + +CROSS = {"HERDR_SESSION": "default", "HERDR_SESSION_NAME": "mam-isolated", + "HERDR_WORKSPACE_ID": "w5", "HERDR_PANE_ID": "w5:p2", + "HERDR_TAB_ID": "w5:t1"} + + +def test_s1_cross_server_call_drops_the_caller_panes_scope_ids( + mam_sandbox, mock_herdr, mock_agents, monkeypatch): + """S1 (mock herdr): the caller sits in another server's pane. has-session, + kill-session and agent prompt must find the agent, and the three ids must + not reach herdr.""" + tmp_path = mam_sandbox + env_log = _install_env_recorder(tmp_path, monkeypatch) + _seed_agent(mock_herdr, tmp_path, "creator-agy-01", "w1", "w1:p5") + + res = _run_lib(tmp_path, 'herdr has-session -t creator-agy-01', env=CROSS) + assert res.returncode == 0, res.stdout + res.stderr + + res = _run_lib(tmp_path, 'herdr agent prompt creator-agy-01 "hello"', env=CROSS) + assert res.returncode == 0, res.stdout + res.stderr + + res = _run_lib(tmp_path, 'herdr kill-session -t creator-agy-01', env=CROSS) + assert res.returncode == 0, res.stdout + res.stderr + + calls = _recorded(env_log) + assert calls, "the recorder saw no herdr call" + for call in calls: + assert call["HERDR_WORKSPACE_ID"] is None, call + assert call["HERDR_PANE_ID"] is None, call + assert call["HERDR_TAB_ID"] is None, call + + # the kill really happened, i.e. this is not a silent no-op + assert "creator-agy-01" not in _mock_state(mock_herdr).get("agents", {}) + + +def test_s1b_the_delegate_job_wrappers_bare_herdr_is_the_lib_sh_function( + mam_sandbox, mock_herdr, mock_agents, monkeypatch): + """S1b (mock herdr): the wrapper sources lib.sh and then calls a bare + `herdr` (wrapper :918 preflight). A shell function beats PATH, so that call + is mam_herdr and is fixed by item 1 too - this is why #23's preflight + failure is cured by the same change.""" + tmp_path = mam_sandbox + _install_env_recorder(tmp_path, monkeypatch) + _seed_agent(mock_herdr, tmp_path, "creator-agy-01", "w1", "w1:p5") + + res = _run_lib(tmp_path, 'echo "kind=$(type -t herdr)"\nherdr has-session -t creator-agy-01', + env=CROSS) + assert "kind=function" in res.stdout, res.stdout + res.stderr + assert res.returncode == 0, res.stdout + res.stderr + + +def test_s2_same_server_keeps_the_explicit_workspace_scope( + mam_sandbox, mock_herdr, mock_agents, monkeypatch): + """S2 (mock herdr): on the target's own server the exported scope still + scopes - a same-named agent in another workspace is not addressed (H-22).""" + tmp_path = mam_sandbox + _seed_agent(mock_herdr, tmp_path, "creator-agy-01", "w1", "w1:p5") + same = {"HERDR_SESSION": "mam-isolated", "HERDR_SESSION_NAME": "mam-isolated", + "HERDR_WORKSPACE_ID": "w2"} + + before = len(_mock_state(mock_herdr).get("calls", [])) + res = _run_lib(tmp_path, 'herdr agent prompt creator-agy-01 "hello from w2"', env=same) + assert res.returncode != 0, res.stdout + res.stderr + new = _mock_state(mock_herdr).get("calls", [])[before:] + prompts = [c for c in new if len(c) >= 2 and c[0] == "agent" and c[1] == "prompt"] + assert prompts == [], new + + +def test_s2b_a_caller_outside_any_pane_keeps_the_explicit_scope( + mam_sandbox, mock_herdr, mock_agents): + """S2b (mock herdr): no HERDR_SESSION means the caller is not in a pane at + all, so an exported HERDR_WORKSPACE_ID is a deliberate scope, not a leak. + This is the case H-18/H-21/H-23 fix.""" + tmp_path = mam_sandbox + _seed_agent(mock_herdr, tmp_path, "creator-agy-01", "w1", "w1:p5") + + res = _run_lib(tmp_path, 'herdr has-session -t creator-agy-01', + env={"HERDR_SESSION_NAME": "mam-isolated", "HERDR_WORKSPACE_ID": "w2"}) + assert res.returncode == 1, "an explicit out-of-scope id must still hide the agent" + + res = _run_lib(tmp_path, 'herdr has-session -t creator-agy-01', + env={"HERDR_SESSION_NAME": "mam-isolated", "HERDR_WORKSPACE_ID": "w1"}) + assert res.returncode == 0, res.stdout + res.stderr + + +def test_s3_shim_contract_regressions_still_pass(mam_sandbox): + """S3: H-18 / H-21 / H-23 are the tests that fix today's scoping meaning. + Run them explicitly as this change's regression guard.""" + repo = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + res = subprocess.run( + [os.path.join(repo, ".venv", "bin", "python"), "-m", "pytest", "-q", + "-p", "no:cacheprovider", + "tests/test_herdr_shim_contract.py", "-k", + "h18 or h21 or h22 or h23"], + capture_output=True, text=True, cwd=repo, + env=dict(os.environ, PYTHONDONTWRITEBYTECODE="1")) + assert res.returncode == 0, res.stdout[-4000:] + res.stderr[-2000:] + + +def test_s11_this_branch_does_not_change_the_generated_shim(mam_sandbox): + """S11: the item-1 fix lives in mam_herdr, never in the heredoc that + lib.sh materialises into $WORKSPACE_ROOT/.mam/shim/herdr. That file is + shared by every checkout bound to one control plane, so keeping it + byte-identical to base is what makes "the control plane is not modified" + true at runtime.""" + repo = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + base = os.environ.get("MAM_BASE_SHA", "1e78dae") + show = subprocess.run(["git", "show", "%s:.agents/skills/lib.sh" % base], + capture_output=True, text=True, cwd=repo) + if show.returncode != 0: + pytest.skip("base %s not available in this checkout" % base) + with open(os.path.join(repo, ".agents", "skills", "lib.sh")) as f: + head = f.read() + assert _heredoc_body(head) == _heredoc_body(show.stdout), ( + "the shim heredoc changed: the generated .mam/shim/herdr of this branch " + "would differ from the control root's, and the two would overwrite " + "each other on every herdr call") + + +def _heredoc_body(text): + lines = text.splitlines(True) + start = None + for i, line in enumerate(lines): + if line.strip() == "cat <<'EOF' > \"$tmp_file\"": + start = i + 1 + break + assert start is not None, "shim heredoc not found in lib.sh" + for j in range(start, len(lines)): + if lines[j].rstrip("\n") == "EOF": + return "".join(lines[start:j]) + raise AssertionError("unterminated shim heredoc") + + +@pytest.mark.parametrize("env,expected_rc", [(CROSS, 1), ({"HERDR_SESSION_NAME": "mam-isolated"}, 1)]) +def test_s12_mam_herdr_never_aborts_its_caller_under_set_e( + mam_sandbox, mock_herdr, mock_agents, env, expected_rc): + """S12 (mock herdr): mam_herdr runs inside scripts using `set -euo + pipefail`. Neither branch of the cross-server test may abort the shell, + and the child's rc must be the function's rc.""" + tmp_path = mam_sandbox + body = ('set -euo pipefail\n' + 'rc=0\n' + 'mam_herdr has-session -t nobody-here || rc=$?\n' + 'echo "missing_rc=$rc"\n' + '_seen=0\n' + 'mam_herdr has-session -t creator-agy-01 || _seen=$?\n' + 'echo "present_rc=$_seen"\n' + 'echo REACHED_END\n') + _seed_agent(mock_herdr, tmp_path, "creator-agy-01", "w1", "w1:p5") + lib_path = tmp_path / ".agents" / "skills" / "lib.sh" + script = 'source "%s"\n%s' % (lib_path, body) + run_env = dict(os.environ) + for var in ("HERDR_SESSION", "HERDR_SESSION_NAME", "HERDR_WORKSPACE_ID", + "HERDR_PANE_ID", "HERDR_TAB_ID"): + run_env.pop(var, None) + run_env.update(env) + res = subprocess.run(["bash", "-c", script], capture_output=True, text=True, + cwd=str(tmp_path), env=run_env) + assert "REACHED_END" in res.stdout, res.stdout + res.stderr + assert "missing_rc=%d" % expected_rc in res.stdout, res.stdout + res.stderr + assert "present_rc=0" in res.stdout, res.stdout + res.stderr + + +def test_s13_the_loop_watcher_applies_the_same_rule(mam_sandbox): + """S13 (pure unit): watcher.py builds its own herdr argv and never goes + through mam_herdr, so it needs the same rule.""" + import sys + repo = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + sys.path.insert(0, os.path.join(repo, ".agents", "skills")) + from lib_py.loop import watcher + + caller_pane = {"HERDR_SESSION": "default", "HERDR_WORKSPACE_ID": "w5", + "HERDR_PANE_ID": "w5:p2", "HERDR_TAB_ID": "w5:t1", "PATH": "/usr/bin"} + + cross = watcher.scoped_env("mam-isolated", environ=caller_pane) + assert "HERDR_WORKSPACE_ID" not in cross and "HERDR_PANE_ID" not in cross + assert "HERDR_TAB_ID" not in cross and cross["PATH"] == "/usr/bin" + + same = watcher.scoped_env("default", environ=caller_pane) + assert same["HERDR_WORKSPACE_ID"] == "w5" + + outside = watcher.scoped_env("mam-isolated", environ={"HERDR_WORKSPACE_ID": "w5"}) + assert outside["HERDR_WORKSPACE_ID"] == "w5" + + none_target = watcher.scoped_env(None, environ=caller_pane) + assert none_target["HERDR_WORKSPACE_ID"] == "w5" + + seen = {} + + def _run(argv, **kw): + seen["env"] = kw.get("env") + + class P: + returncode, stdout, stderr = 0, "{}", "" + return P() + + watcher.wait_once("agt", session="iso-x", run=_run) + assert seen["env"] is not None and "HERDR_SESSION" not in ("",) From 5ba04a27ca084c61f583487ef6c936069b8787db Mon Sep 17 00:00:00 2001 From: Godopu Date: Tue, 22 Sep 2026 04:22:09 +0000 Subject: [PATCH 02/15] feat(issue-24): startup dialogs come from the agent adapter and are handled while waiting for the TUI handle_startup_dialogs was a claude-only string ladder called only when AGENT=claude, and wait_for_tui_ready does not treat a dialog screen as ready. So agy, grok and codex sat on their directory-trust prompt for the full readiness timeout, the session was recorded as tui-ready-timeout, and onboarding injection was disabled (create_session.sh:313-314) - a human had to accept each dialog by hand. The signatures and accept keys move to the adapters as `startup_dialogs` (pattern, keys, kind), published through the existing `facts` eval contract as MAM_STARTUP_DIALOGS. claude's ladder moves over verbatim; agy takes Enter, grok y, codex Enter, and codex's update notice takes 2 (Skip), which blocks a start just as hard while changing nothing. kind is `trust` or `notice`: --no-auto-trust leaves trust dialogs to a human and still answers notices, since trusting a directory makes codex load its project-local config, hooks and exec policy. Re-sending is gated by time (MAM_DIALOG_RESEND_S, default 5s), not by frames. #24's comment reports that `--source visible` keeps showing an accepted dialog for a while, and _pane_capture hardcodes that source, so "still on screen" is not evidence the keys were missed; acceptance is judged by the ready tokens alone. Two existing tests asserted the old shape and are updated, not weakened: b19's "Escape branch stays" now looks for it in claude's adapter, and c1b's "Enter once per iteration" becomes "never twice for one frame", which the cooldown satisfies strictly more tightly. Co-Authored-By: Claude Opus 5 --- .agents/skills/lib.sh | 128 +++++++++++++--- .agents/skills/lib_py/agents/__main__.py | 1 + .agents/skills/lib_py/agents/adapters/agy.py | 9 ++ .../skills/lib_py/agents/adapters/claude.py | 13 ++ .../skills/lib_py/agents/adapters/codex.py | 14 ++ .agents/skills/lib_py/agents/adapters/grok.py | 7 + .agents/skills/lib_py/agents/base.py | 15 ++ .../scripts/create_session.sh | 17 ++- tests/test_b19_headless_reconcile_fixes.py | 10 +- tests/test_c1_tui_readiness.py | 17 ++- tests/test_issue23_24_startup_path.py | 142 ++++++++++++++++++ 11 files changed, 344 insertions(+), 29 deletions(-) diff --git a/.agents/skills/lib.sh b/.agents/skills/lib.sh index c39b9e2..d3b5920 100644 --- a/.agents/skills/lib.sh +++ b/.agents/skills/lib.sh @@ -2204,7 +2204,7 @@ wait_for_tui_ready() { # handle it and do NOT declare ready on this iteration. tail_lines=$(printf '%s\n' "$content" | grep -v '^[[:space:]]*$' | tail -n 20) if printf '%s\n' "$tail_lines" | grep -E -q "$modal_pat" 2>/dev/null; then - handle_startup_dialogs "$sess" 1 || true + handle_startup_dialogs "$sess" 1 "$agent" || true sleep 1 continue fi @@ -2254,7 +2254,7 @@ wait_for_tui_ready() { fi # 4. Startup dialog & hint resolution if not ready - handle_startup_dialogs "$sess" 1 || true + handle_startup_dialogs "$sess" 1 "$agent" || true sleep 1 done echo "⚠️ TUI readiness check timed out for '$sess'." >&2 @@ -2702,29 +2702,113 @@ send_keys_safe() { return 4 } -# handle_startup_dialogs [timeout_sec=20] -# Post-start/resume dialog policy for claude: accept the trust / bypass -# dialogs ONLY when their signature is positively on screen; return as soon -# as the TUI banner is ready. Replaces the blind Enter/Down/Enter sequence. +# _mam_load_dialog_facts +# Populate MAM_STARTUP_DIALOGS for , once per agent per shell. Only that +# one fact is taken: `facts` is an eval contract (F8), and evaluating all of it +# here would clobber ready/placeholder tokens a caller deliberately set. +# An agent the adapters do not know keeps an empty list. +_mam_load_dialog_facts() { + local agent="$1" _f="" + if [ "${_MAM_DIALOGS_AGENT:-}" = "$agent" ]; then + return 0 + fi + _f="$("$(_delegate_py_bin)" -m lib_py.agents facts "$agent" 2>/dev/null | sed -n 's/^MAM_STARTUP_DIALOGS=//p')" || _f="" + if [ -n "$_f" ]; then + eval "MAM_STARTUP_DIALOGS=$_f" + else + MAM_STARTUP_DIALOGS='[]' + fi + _MAM_DIALOGS_AGENT="$agent" + return 0 +} + +# _mam_dialog_pick — first actionable dialog on this frame. +# stdout: "send|| ..." or "skip||", nothing when there is +# none to act on. rc 1 means "nothing to do" (matched-but-cooling counts). +_mam_dialog_pick() { + MAM_DLG_PANE="$1" MAM_DLG_JSON="$2" MAM_DLG_TRUST="$3" \ + MAM_DLG_STATE="$4" MAM_DLG_NOW="$5" MAM_DLG_COOLDOWN="$6" \ + python3 -c ' +import os, re, sys, json +pane = os.environ.get("MAM_DLG_PANE", "") +try: + dialogs = json.loads(os.environ.get("MAM_DLG_JSON") or "[]") +except Exception: + dialogs = [] +auto_trust = os.environ.get("MAM_DLG_TRUST", "1") != "0" +try: + now = int(os.environ.get("MAM_DLG_NOW") or 0) + cooldown = int(os.environ.get("MAM_DLG_COOLDOWN") or 5) +except ValueError: + sys.exit(1) +state = {} +for tok in (os.environ.get("MAM_DLG_STATE") or "").split(): + k, _, v = tok.partition(":") + try: + state[int(k)] = int(v) + except ValueError: + pass +for idx, entry in enumerate(dialogs): + try: + pattern, keys, kind = entry[0], entry[1], entry[2] + if not re.search(pattern, pane): + continue + except Exception: + continue + last = state.get(idx) + if last is not None and 0 <= now - last < cooldown: + sys.exit(1) + if kind == "trust" and not auto_trust: + print("skip|%d|" % idx) + sys.exit(0) + print("send|%d|%s" % (idx, " ".join(keys))) + sys.exit(0) +sys.exit(1) +' +} + +# handle_startup_dialogs [timeout_sec=20] [agent=claude] +# Answer the blocking modals an agent shows before its TUI is usable. The +# signatures and accept keys come from the agent adapter (MAM_STARTUP_DIALOGS), +# so this is agent-agnostic - issue #24: it used to be a claude-only ladder +# called only for claude, and agy/grok/codex sat on their trust prompt until +# the readiness wait timed out and onboarding was skipped. +# +# `--source visible` keeps showing an accepted dialog for a while (#24 +# comment), so a signature still on screen is NOT evidence that the keys were +# missed: re-sending for the same signature is gated by MAM_DIALOG_RESEND_S +# (default 5s) and acceptance is judged only by the ready tokens. +# +# MAM_AUTO_TRUST=0 leaves `trust` dialogs unanswered (warned once per +# cooldown); `notice` dialogs are always answered. handle_startup_dialogs() { - local sess="$1" timeout="${2:-20}" waited=0 pane + local sess="$1" timeout="${2:-20}" agent="${3:-claude}" + local waited=0 pane pick action rest idx keys key now + local cooldown="${MAM_DIALOG_RESEND_S:-5}" + local auto_trust="${MAM_AUTO_TRUST:-1}" + _mam_load_dialog_facts "$agent" + local dialogs="${MAM_STARTUP_DIALOGS:-[]}" + local ready_re="${MAM_STRONG_READY_TOKENS:-${MAM_READY_TOKENS:-}}" while [ "$waited" -lt "$timeout" ]; do pane=$(_pane_tail "$sess" 20) - if printf '%s\n' "$pane" | grep -Eq 'Do you trust the files|Yes, I trust this folder|Quick safety check'; then - _sks_herdr send-keys -t "$sess" Enter - elif printf '%s\n' "$pane" | grep -q 'Yes, try it'; then - # Fullscreen-renderer upsell modal (not the idle /tui tip). Enter would - # accept and restart the session without permission flags — reject. - _sks_herdr send-keys -t "$sess" Escape - elif printf '%s\n' "$pane" | grep -q 'Yes, proceed'; then - _sks_herdr send-keys -t "$sess" Down - sleep 0.3 - _sks_herdr send-keys -t "$sess" Enter - elif printf '%s\n' "$pane" | grep -q 'Resuming the full session'; then - _sks_herdr send-keys -t "$sess" Enter - elif printf '%s\n' "$pane" | grep -q 'Press Enter to continue'; then - _sks_herdr send-keys -t "$sess" Enter - elif printf '%s\n' "$pane" | grep -Eq "${_MAM_READY_TOKENS_CLAUDE:-Anthropic|Assistant|Chat|Welcome}"; then + now=$(date +%s) + pick="" + pick=$(_mam_dialog_pick "$pane" "$dialogs" "$auto_trust" "${_MAM_DIALOG_SENT:-}" "$now" "$cooldown" 2>/dev/null || true) + if [ -n "$pick" ]; then + action="${pick%%|*}" + rest="${pick#*|}" + idx="${rest%%|*}" + keys="${rest#*|}" + if [ "$action" = "send" ]; then + for key in $keys; do + _sks_herdr send-keys -t "$sess" "$key" || true + sleep 0.3 + done + else + echo "handle_startup_dialogs: a trust dialog is open on '$sess' and --no-auto-trust was given; accept it manually" >&2 + fi + _MAM_DIALOG_SENT="${_MAM_DIALOG_SENT:-} $idx:$now" + elif [ -n "$ready_re" ] && printf '%s\n' "$pane" | grep -Eq "$ready_re" 2>/dev/null; then return 0 fi sleep 1 diff --git a/.agents/skills/lib_py/agents/__main__.py b/.agents/skills/lib_py/agents/__main__.py index d437b75..0796dfb 100644 --- a/.agents/skills/lib_py/agents/__main__.py +++ b/.agents/skills/lib_py/agents/__main__.py @@ -63,6 +63,7 @@ def main(): print(f"MAM_STRONG_READY_TOKENS={q(adapter.strong_ready_tokens)}") print(f"MAM_WEAK_READY_TOKENS={q(adapter.weak_ready_tokens)}") print(f"MAM_MODAL_TOKENS={q(adapter.modal_tokens or '')}") + print(f"MAM_STARTUP_DIALOGS={q(json.dumps(adapter.startup_dialogs))}") print(f"MAM_EXIT_KEY={q(adapter.exit_key)}") print(f"MAM_DELEGATE_AGENT_KEY={q(adapter.delegate_agent_key)}") diff --git a/.agents/skills/lib_py/agents/adapters/agy.py b/.agents/skills/lib_py/agents/adapters/agy.py index ba20214..948bc07 100644 --- a/.agents/skills/lib_py/agents/adapters/agy.py +++ b/.agents/skills/lib_py/agents/adapters/agy.py @@ -23,6 +23,15 @@ class AgyAgentAdapter(BaseAgentAdapter): def delegate_agent_key(self) -> str: return 'antigravity-cli' + @property + def startup_dialogs(self) -> list: + # #24: "Do you trust the contents of this project?" with + # "> Yes, I trust this folder" preselected - Enter accepts. + return [ + ('Do you trust the contents of this project|Yes, I trust this folder', + ['Enter'], 'trust'), + ] + @property def identity_cache_fields(self) -> tuple: return ('conversation_id', 'conversation_db', 'conversation_brain_dir') diff --git a/.agents/skills/lib_py/agents/adapters/claude.py b/.agents/skills/lib_py/agents/adapters/claude.py index 189334d..bf60fc7 100644 --- a/.agents/skills/lib_py/agents/adapters/claude.py +++ b/.agents/skills/lib_py/agents/adapters/claude.py @@ -44,6 +44,19 @@ class ClaudeAgentAdapter(BaseAgentAdapter): def modal_tokens(self) -> Optional[str]: return 'Try the new fullscreen renderer\\?' + @property + def startup_dialogs(self) -> list: + # The ladder handle_startup_dialogs used to hardcode, unchanged. + return [ + ('Do you trust the files|Yes, I trust this folder|Quick safety check', ['Enter'], 'trust'), + # Fullscreen-renderer upsell, not the idle /tui tip: Enter would + # accept and restart the session without the permission flags. + ('Yes, try it', ['Escape'], 'notice'), + ('Yes, proceed', ['Down', 'Enter'], 'trust'), + ('Resuming the full session', ['Enter'], 'notice'), + ('Press Enter to continue', ['Enter'], 'notice'), + ] + def artifact_path(self, uuid: str, ctx: DiscoveryContext) -> str: return f"{ctx.claude_dir}/{ctx.ws_key}/{uuid}.jsonl" diff --git a/.agents/skills/lib_py/agents/adapters/codex.py b/.agents/skills/lib_py/agents/adapters/codex.py index 1e7a5c7..8b688fc 100644 --- a/.agents/skills/lib_py/agents/adapters/codex.py +++ b/.agents/skills/lib_py/agents/adapters/codex.py @@ -24,6 +24,20 @@ class CodexAgentAdapter(BaseAgentAdapter): def delegate_agent_key(self) -> str: return 'codex-cli' + @property + def startup_dialogs(self) -> list: + # #24: the trust prompt lists "1. Yes, continue" preselected, so Enter + # accepts. Trust here also loads this project's local config, hooks and + # exec policy, which is why --no-auto-trust exists. + # + # The 0.155.1 update notice (observed 2026-09-22) blocks the session the + # same way; "2" is Skip, the choice that changes nothing. + return [ + ('Do you trust the contents of this directory|1\\. Yes, continue', + ['Enter'], 'trust'), + ('1\\. Update now|Skip until next version', ['2'], 'notice'), + ] + @property def identity_cache_fields(self) -> tuple: return ('session_id',) diff --git a/.agents/skills/lib_py/agents/adapters/grok.py b/.agents/skills/lib_py/agents/adapters/grok.py index 3f27860..055a4ab 100644 --- a/.agents/skills/lib_py/agents/adapters/grok.py +++ b/.agents/skills/lib_py/agents/adapters/grok.py @@ -26,6 +26,13 @@ class GrokAgentAdapter(BaseAgentAdapter): def delegate_agent_key(self) -> str: return 'grok-build' + @property + def startup_dialogs(self) -> list: + # #24: "Do you trust the contents of this directory?" answered with y. + return [ + ('Do you trust the contents of this directory', ['y'], 'trust'), + ] + @property def identity_cache_fields(self) -> tuple: return ('session_id', 'session_jsonl') diff --git a/.agents/skills/lib_py/agents/base.py b/.agents/skills/lib_py/agents/base.py index bd2221a..75d47ef 100644 --- a/.agents/skills/lib_py/agents/base.py +++ b/.agents/skills/lib_py/agents/base.py @@ -156,6 +156,21 @@ class BaseAgentAdapter: def modal_tokens(self) -> Optional[str]: return None + @property + def startup_dialogs(self) -> List[tuple]: + """Blocking modals this CLI can show before its TUI is usable, most + specific first: (pattern, keys, kind). + + `pattern` is an extended-regex signature matched against the pane tail, + `keys` are herdr send-keys arguments sent in order, and `kind` is + 'trust' (a directory-trust prompt, gated by --auto-trust) or 'notice' + (anything else that blocks the start, always answered). + + Issue #24: without this the start-up trust prompts of agy, grok and + codex burn the whole readiness timeout and onboarding is skipped. + """ + return [] + @property def strong_ready_tokens(self) -> str: return self.ready_tokens diff --git a/.agents/skills/multi-agent-mux-create/scripts/create_session.sh b/.agents/skills/multi-agent-mux-create/scripts/create_session.sh index c8f8d81..de59d5f 100755 --- a/.agents/skills/multi-agent-mux-create/scripts/create_session.sh +++ b/.agents/skills/multi-agent-mux-create/scripts/create_session.sh @@ -43,6 +43,7 @@ Options: use --herdr-session for that. --submit-job PROMPT submit a job to multi-agent-mux-delegate-job registry with the given prompt --onboard automatically submit a project alignment/orientation job to the new agent + --no-auto-trust do not answer a start-up directory-trust dialog (default: answer it) --no-onboard disable automatic onboarding job submission --no-isolate legacy flag (no-op; all sessions use global configuration) --isolate legacy flag (no-op; all sessions use global configuration) @@ -60,6 +61,11 @@ HERDR_SERVER_OPT="" HERDR_WORKSPACE_OPT="" SUBMIT_JOB_PROMPT="" ONBOARD=1 +# Issue #24: answer a start-up directory-trust dialog for the --workspace the +# operator named. --no-auto-trust leaves those unanswered (notices are always +# answered); codex in particular loads project-local config, hooks and exec +# policy once trusted. +AUTO_TRUST=1 while [ $# -gt 0 ]; do case "$1" in @@ -74,6 +80,8 @@ while [ $# -gt 0 ]; do --submit-job) SUBMIT_JOB_PROMPT="$2"; shift 2 ;; --onboard) ONBOARD=1; shift ;; --no-onboard) ONBOARD=0; shift ;; + --auto-trust) AUTO_TRUST=1; shift ;; + --no-auto-trust) AUTO_TRUST=0; shift ;; --isolate) echo "NOTE: --isolate/--no-isolate is a no-op — config-home isolation was removed; sessions always use global config." >&2; shift ;; # legacy compatibility --no-isolate) echo "NOTE: --isolate/--no-isolate is a no-op — config-home isolation was removed; sessions always use global config." >&2; shift ;; -h|--help) usage; exit 0 ;; @@ -81,6 +89,8 @@ while [ $# -gt 0 ]; do esac done +export MAM_AUTO_TRUST="$AUTO_TRUST" + if [ -n "$HERDR_SERVER_OPT" ]; then export HERDR_SESSION_NAME="$HERDR_SERVER_OPT" fi @@ -331,8 +341,11 @@ if [ "$TUI_READY_RC" -ne 0 ]; then fi fi -if [ "$AGENT" = "claude" ] && [ -z "$LAST_VISIBLE_STATUS_OVERRIDE" ]; then - handle_startup_dialogs "$SESSION_NAME" 15 +# Issue #24: every agent, not only claude. The signatures and accept keys come +# from the agent adapter, so an agy/grok/codex trust prompt is answered here +# too instead of sitting until the readiness wait gives up. +if [ -z "$LAST_VISIBLE_STATUS_OVERRIDE" ]; then + handle_startup_dialogs "$SESSION_NAME" 15 "$AGENT" fi # pane 메타 캡처 diff --git a/tests/test_b19_headless_reconcile_fixes.py b/tests/test_b19_headless_reconcile_fixes.py index 7e618e4..4f925cd 100644 --- a/tests/test_b19_headless_reconcile_fixes.py +++ b/tests/test_b19_headless_reconcile_fixes.py @@ -282,13 +282,19 @@ if _pane_dialog_open dummy; then echo "DIALOG_OPEN"; else echo "DIALOG_CLOSED"; def test_mam_dialog_tokens_exclude_yes_try_it(): - """N-1 / F-1: token list must not contain Yes, try it; Escape branch stays.""" + """N-1 / F-1: token list must not contain Yes, try it; Escape branch stays. + + Issue #24 moved the per-agent ladder out of lib.sh into the agent adapter, + so the Escape branch is now claude's own `startup_dialogs` entry. The two + token-list assertions are unchanged; only where the branch lives moved. + """ content = open(_LIB_SH, encoding="utf-8").read() modal_line = next(l for l in content.splitlines() if l.startswith("_MAM_MODAL_TOKENS=")) hint_line = next(l for l in content.splitlines() if l.startswith("_MAM_HINT_TOKENS=")) assert "Yes, try it" not in modal_line assert "Yes, try it" not in hint_line - assert "grep -q 'Yes, try it'" in content + adapter = os.path.join(os.path.dirname(_LIB_SH), "lib_py", "agents", "adapters", "claude.py") + assert "('Yes, try it', ['Escape'], 'notice')" in open(adapter, encoding="utf-8").read() def test_wait_for_tui_ready_succeeds_on_fullscreen_tip(): diff --git a/tests/test_c1_tui_readiness.py b/tests/test_c1_tui_readiness.py index e91daba..96a14c7 100644 --- a/tests/test_c1_tui_readiness.py +++ b/tests/test_c1_tui_readiness.py @@ -55,7 +55,16 @@ if _pane_dialog_open dummy; then echo "DIALOG_OPEN"; else echo "DIALOG_CLOSED"; def test_c1b_hint_press_enter_sends_enter(): - """C-1b (B-3): 'Press Enter to continue' alone must return dialog CLOSED, and wait_for_tui_ready must send Enter exactly once per iteration (30 total across 30 iterations, not 60).""" + """C-1b (B-3): 'Press Enter to continue' alone must return dialog CLOSED, + and wait_for_tui_ready must never send Enter twice for one frame. + + Issue #24 replaced "once per iteration" with a time cooldown + (MAM_DIALOG_RESEND_S): a `--source visible` snapshot keeps showing an + accepted dialog for a while, so re-sending on every poll types into a TUI + that already moved on. This test stubs `sleep`, so the wall clock barely + advances and one cooldown window covers the whole run - hence 1, not 30. + The guard the case was written for (never 2 per iteration) is unchanged + and strictly stronger now.""" screen = """Some startup banner Press Enter to continue """ @@ -73,7 +82,7 @@ export MAM_STRONG_READY_TOKENS='NonExistentStrong' export MAM_WEAK_READY_TOKENS='' export MAM_INPUT_PLACEHOLDER='' rc=0 -wait_for_tui_ready dummy-sess custom || rc=$? +wait_for_tui_ready dummy-sess claude || rc=$? echo "RC=$rc" echo "KEYS_COUNT=$(grep -c "send-keys -t dummy-sess Enter" "$KEYS" || echo 0)" rm -f "$KEYS" @@ -81,7 +90,9 @@ rm -f "$KEYS" res = _run_lib_helpers(script) assert res.returncode == 0, res.stderr + res.stdout assert "DIALOG_CLOSED" in res.stdout - assert "KEYS_COUNT=30" in res.stdout + count = int(res.stdout.split("KEYS_COUNT=")[1].split()[0]) + assert count >= 1, "the dialog was never answered: " + res.stdout + assert count <= 30, "one frame must never be answered twice: " + res.stdout def test_c1c_transition_to_upsell_modal_sends_escape_not_enter(): diff --git a/tests/test_issue23_24_startup_path.py b/tests/test_issue23_24_startup_path.py index c5d8d3f..c73ec4c 100644 --- a/tests/test_issue23_24_startup_path.py +++ b/tests/test_issue23_24_startup_path.py @@ -302,3 +302,145 @@ def test_s13_the_loop_watcher_applies_the_same_rule(mam_sandbox): watcher.wait_once("agt", session="iso-x", run=_run) assert seen["env"] is not None and "HERDR_SESSION" not in ("",) + + +# -------------------------------------------------------------------------- +# item 2 — start-up dialogs come from the adapter (#24) +# +# MOCKED, and this is the limit of it: the pane text is a fixture string and +# send-keys is a shell stub, so these fix OUR contract - which screen signature +# gets which keys, once. They do NOT show that the keys drive a real agy, grok, +# codex or opencode TUI; no such TUI can be started in this environment. That +# check is a live herdr run after the loop. +# -------------------------------------------------------------------------- + +def _run_dialogs(tmp_path, agent, pane_text, timeout=1, env=None, prelude=""): + lib = tmp_path / ".agents" / "skills" / "lib.sh" + pane_file = tmp_path / "pane.txt" + pane_file.write_text(pane_text) + keys_log = tmp_path / "sent_keys.txt" + if keys_log.exists(): + keys_log.unlink() + script = ''' +source "%s" +_pane_tail() { cat "%s"; } +_sks_herdr() { printf '%%s\\n' "$*" >> "%s"; } +%s +handle_startup_dialogs test-sess %d %s +echo "rc=$?" +''' % (lib, pane_file, keys_log, prelude, timeout, agent) + run_env = dict(os.environ) + run_env.pop("MAM_AUTO_TRUST", None) + run_env.update(env or {}) + res = subprocess.run(["bash", "-c", script], capture_output=True, text=True, + cwd=str(tmp_path), env=run_env) + sent = keys_log.read_text().splitlines() if keys_log.exists() else [] + return res, sent + + +AGENT_DIALOGS = [ + ("agy", "Do you trust the contents of this project?\n > Yes, I trust this folder", + ["send-keys -t test-sess Enter"]), + ("grok", "Do you trust the contents of this directory?", + ["send-keys -t test-sess y"]), + ("codex", "Do you trust the contents of this directory?\n > 1. Yes, continue", + ["send-keys -t test-sess Enter"]), + ("codex", "Update available\n 1. Update now\n 2. Skip\n 3. Skip until next version", + ["send-keys -t test-sess 2"]), + ("claude", "Do you trust the files in this folder?\n Yes, I trust this folder", + ["send-keys -t test-sess Enter"]), + ("claude", "Try the new fullscreen renderer?\n Yes, try it", + ["send-keys -t test-sess Escape"]), +] + + +@pytest.mark.parametrize("agent,pane,expected", AGENT_DIALOGS) +def test_s4_each_agents_dialog_signature_gets_its_own_keys( + mam_sandbox, agent, pane, expected): + """S4 (stubbed pane + stubbed send-keys): the adapter's signature is + matched and exactly its keys are sent.""" + res, sent = _run_dialogs(mam_sandbox, agent, pane) + assert sent == expected, (res.stdout, res.stderr, sent) + + +def test_s4b_every_adapter_publishes_the_fact(mam_sandbox): + """S4b (real `lib_py.agents facts`): MAM_STARTUP_DIALOGS is part of the + facts contract for every agent, so the shell never has to know an agent + name. An agent with no known blocking modal publishes an empty list.""" + repo = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + skills = os.path.join(repo, ".agents", "skills") + seen = {} + for agent in ("claude", "agy", "grok", "codex", "opencode", "hermes"): + out = subprocess.run( + [os.path.join(repo, ".venv", "bin", "python"), "-m", "lib_py.agents", + "facts", agent], + capture_output=True, text=True, cwd=repo, + env=dict(os.environ, PYTHONPATH=skills)).stdout + line = [l for l in out.splitlines() if l.startswith("MAM_STARTUP_DIALOGS=")] + assert line, "no MAM_STARTUP_DIALOGS for %s" % agent + seen[agent] = json.loads(line[0].split("=", 1)[1].strip("'")) + for agent in ("claude", "agy", "grok", "codex"): + assert seen[agent], "%s must publish its start-up dialogs" % agent + for pattern, keys, kind in seen[agent]: + assert kind in ("trust", "notice"), (agent, kind) + assert keys, (agent, pattern) + assert seen["opencode"] == [] and seen["hermes"] == [] + + +def test_s5_an_accepted_dialog_still_on_screen_is_not_resent(mam_sandbox): + """S5 (stubbed pane + stubbed send-keys): #24's comment reports that + `--source visible` keeps showing the dialog for a while after it was + accepted, and _pane_capture hardcodes that source. The pane here never + changes, so a frame-count rule would send the keys again every poll; the + time cooldown must keep it at one.""" + res, sent = _run_dialogs( + mam_sandbox, "grok", "Do you trust the contents of this directory?", + timeout=3, env={"MAM_DIALOG_RESEND_S": "60"}) + assert sent == ["send-keys -t test-sess y"], (res.stdout, res.stderr, sent) + + +def test_s5b_the_cooldown_expiring_allows_one_more_try(mam_sandbox): + """S5b: the cooldown is a rate limit, not a one-shot - a dialog that really + did not take the keys is retried once the window passes.""" + res, sent = _run_dialogs( + mam_sandbox, "grok", "Do you trust the contents of this directory?", + timeout=4, env={"MAM_DIALOG_RESEND_S": "1"}) + assert len(sent) >= 2, (res.stdout, res.stderr, sent) + assert set(sent) == {"send-keys -t test-sess y"}, sent + + +def test_s5c_readiness_is_judged_by_the_ready_tokens_not_by_the_dialog_leaving( + mam_sandbox): + """S5c: a ready screen returns 0 without sending anything.""" + res, sent = _run_dialogs(mam_sandbox, "grok", "Grok ready\n>>> ", timeout=3) + assert sent == [], sent + assert "rc=0" in res.stdout, res.stdout + res.stderr + + +def test_s6_no_auto_trust_leaves_trust_dialogs_alone_but_answers_notices( + mam_sandbox): + """S6 (stubbed): --no-auto-trust. codex trust is left to a human with a + warning; the update notice still gets answered, since skipping it changes + nothing and it blocks the session just as hard.""" + res, sent = _run_dialogs( + mam_sandbox, "codex", "Do you trust the contents of this directory?\n > 1. Yes, continue", + timeout=1, env={"MAM_AUTO_TRUST": "0"}) + assert sent == [], sent + assert "no-auto-trust" in res.stderr, res.stderr + + res, sent = _run_dialogs( + mam_sandbox, "codex", "Update available\n 1. Update now\n 3. Skip until next version", + timeout=1, env={"MAM_AUTO_TRUST": "0"}) + assert sent == ["send-keys -t test-sess 2"], (res.stderr, sent) + + +def test_s6b_create_session_no_longer_gates_dialogs_on_claude(mam_sandbox): + """S6b (source read): the #24 root cause was the `AGENT = claude` gate at + the create_session call site, so the gate's absence is the regression.""" + repo = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + src = open(os.path.join(repo, ".agents", "skills", "multi-agent-mux-create", + "scripts", "create_session.sh")).read() + call = [l for l in src.splitlines() if "handle_startup_dialogs" in l] + assert call, "create_session no longer answers start-up dialogs at all" + assert all('"$AGENT"' in l for l in call), call + assert 'if [ "$AGENT" = "claude" ] && [ -z "$LAST_VISIBLE_STATUS_OVERRIDE" ]' not in src From 123dca47bb82ee1bde92ab10ecaff678dcb20cce Mon Sep 17 00:00:00 2001 From: Godopu Date: Tue, 22 Sep 2026 04:28:08 +0000 Subject: [PATCH 03/15] fix(issue-23): onboarding delivery is judged by the agent's own started event, and a rollback verifies the kill Plan commits 3 and 4 landed together: both rewrite the same region of create_session.sh and share one test file, and splitting them afterwards would have meant hunk-level surgery on already-verified code. Delivery verdict (item 3). create_session --onboard called a healthy agent a failure three seconds after registering the job: inject_instructions -> send_keys_safe returns rc 4 when `agent prompt --wait --until working` fails and the pane is still not working two seconds later, which is exactly where a TUI that is still starting up lands. It never got the ISSUE-18 confirmation the delegate-job wrapper has. rc 4 is a warning now and the agent's own `started` decides, within the job's own ack timeout; rc 1/2/3 stay hard failures, since they say the pane never quiesced, a dialog was blocking, or the text never appeared, and no later `started` repairs those. The self-published `started` had to go first. create_session published it on the agent's behalf right after injection, so the registry showed a started job whether or not the agent ever saw the prompt - with that line in place, no started-based verdict can mean anything. delegate_wait_started is lib.sh's counterpart to the wrapper's _wait_started (plan item D10-b). The wrapper's own `submit` was the first choice, but it blocks until the job reaches a terminal state, and create_session must return as soon as delivery is confirmed; the on-disk evidence it reads - registry status past `pending`, or a started line carrying this dispatch token - is written by publish_event before its wire publish, so no broker is involved. Rollback integrity (item 4). The trap ran `_herdr kill-session -t "$SESSION_NAME" 2>/dev/null || true`, so a kill that closed nothing still printed "rolling back" while the session kept running, and the row stayed `running` pointing at the job the same rollback had just failed (#23 symptom 5). With item 1 in place this check is finally trustworthy: the kill used to fail because the caller's workspace id hid the pane. The trap now reads the kill's rc, confirms with has-session, closes the onboarding job as `cancelled` (a terminal transition the registry knows, and the truthful one - the job was never dispatched), and marks the row. A session still alive exits 43 with the manual cleanup command, distinct from 42 (TUI never ready). The row keeps a schema-valid status rather than the plan's `error`, which atomic_yaml.py rejects: `terminated` once the session is really gone, and `running` when the kill failed, because it is still running. What makes that case legible is the note, the cleared delegate_job_id and exit 43 - not a status claiming the opposite of the truth. The row is never deleted; a deleted row takes the evidence with it. Co-Authored-By: Claude Opus 5 --- .agents/skills/lib.sh | 49 ++++ .../scripts/create_session.sh | 83 ++++++- tests/test_issue23_24_startup_path.py | 221 ++++++++++++++++++ 3 files changed, 346 insertions(+), 7 deletions(-) diff --git a/.agents/skills/lib.sh b/.agents/skills/lib.sh index d3b5920..63ced3d 100644 --- a/.agents/skills/lib.sh +++ b/.agents/skills/lib.sh @@ -2131,6 +2131,55 @@ delegate_publish_event() { _delegation_py publish --job "$job_id" --event "$event" --detail "$detail" ${extra[@]+"${extra[@]}"} || true } +# delegate_wait_started [ack_timeout_s] [dispatch_token] +# +# ISSUE-23 / ISSUE-18: delivery is confirmed by the protocol, not by the +# screen. `agent prompt --wait --until working` failing two seconds after the +# keys were handed over says nothing about a TUI that is still starting up - +# that is exactly what made create_session --onboard call a healthy agent a +# failure. The evidence is the agent's own `started`: the registry record +# leaves `pending` (publish_event syncs it on this host) or a matching started +# line lands in the job's events.log, which publish_event writes before its +# wire publish so a slow broker cannot delay the verdict. +# +# Returns 0 on that evidence, 1 if neither appeared within T. Never re-injects: +# the single-injection contract is fail-closed. +delegate_wait_started() { + local job_id="$1" ack_s="${2:-180}" tok="${3:-}" + [ -n "$job_id" ] || return 1 + local reg_dir="${MAM_JOBS_DIR:-${WORKSPACE_ROOT:-$PWD}/.mam/jobs}" + local rec="$reg_dir/$job_id.json" + local ev_log="$reg_dir/$job_id.events.log" + local started_re='"event": *"started"' + if [ -n "$tok" ]; then + started_re='"event": *"started".*"dispatch_token": *"'"$tok"'"' + fi + local deadline st + deadline=$(( $(date +%s) + ack_s )) + while :; do + st="" + if [ -f "$rec" ]; then + st=$(REC="$rec" python3 -c " +import json, os +try: + print(json.load(open(os.environ['REC'])).get('status', '') or '') +except Exception: + print('') +" 2>/dev/null || true) + fi + if [ -n "$st" ] && [ "$st" != "pending" ]; then + return 0 + fi + if [ -f "$ev_log" ] && grep -Eq "$started_re" "$ev_log" 2>/dev/null; then + return 0 + fi + if [ "$(date +%s)" -ge "$deadline" ]; then + return 1 + fi + sleep 1 + done +} + # ensure_subscriber_daemon [workdir] # Start the monitor subscriber for this workspace unless one is already running. # Prints the PID(s) on stdout, exactly as start_watchdog always did. diff --git a/.agents/skills/multi-agent-mux-create/scripts/create_session.sh b/.agents/skills/multi-agent-mux-create/scripts/create_session.sh index de59d5f..9d8360e 100755 --- a/.agents/skills/multi-agent-mux-create/scripts/create_session.sh +++ b/.agents/skills/multi-agent-mux-create/scripts/create_session.sh @@ -283,13 +283,63 @@ NOW_ISO=$(date -u +'%Y-%m-%dT%H:%M:%SZ') spawn +# _mark_row_rolled_back +# ISSUE-23: the YAML row and the job registration are both written before the +# prompt is injected, so a rollback that only kills the pane leaves a row that +# says `running` pointing at a failed job - which is what status.sh keeps +# reporting (symptom 5). Mark the row instead of deleting it: a deleted row +# takes the evidence with it. +# +# stays inside the schema's vocabulary (running/terminated/archived/ +# stopped - atomic_yaml.py rejects anything else): `terminated` when the +# session is really gone, and `running` when the kill failed, because it IS +# still running and saying otherwise would be the same lie in reverse. What +# makes the failed case legible is the note, the cleared job id and exit 43. +_mark_row_rolled_back() { + local status="$1" note="$2" + [ "${ROW_APPENDED:-0}" = "1" ] || return 0 + atomic_dump_yaml "$AGENT_SESSIONS_YAML" \ + SESSION_NAME="$SESSION_NAME" ROLLBACK_STATUS="$status" ROLLBACK_NOTE="$note" <<'PYEOF' || true +name = os.environ['SESSION_NAME'] +status = os.environ['ROLLBACK_STATUS'] +note = os.environ['ROLLBACK_NOTE'] +for s in d.get('herdr_sessions', []): + if s.get('name') == name: + s['status'] = status + s['last_visible_status'] = note + s['delegate_job_id'] = None + break +PYEOF +} + # Trap for rolling back/cleaning up herdr session if script exits due to error cleanup_herdr_on_error() { local exit_code=$? - if [ $exit_code -ne 0 ]; then - echo "⚠️ Error occurred during initialization (exit code $exit_code). Rolling back and killing herdr session '$SESSION_NAME'..." >&2 - _herdr kill-session -t "$SESSION_NAME" 2>/dev/null || true + [ $exit_code -eq 0 ] && return 0 + echo "⚠️ Error occurred during initialization (exit code $exit_code). Rolling back and killing herdr session '$SESSION_NAME'..." >&2 + + # ISSUE-23: the kill result used to be discarded (`2>/dev/null || true`), so + # a kill that closed nothing still printed "rolled back" while the session + # kept running. Check it, then confirm with has-session. + local kill_rc=0 + _herdr kill-session -t "$SESSION_NAME" || kill_rc=$? + local alive=0 + _herdr has-session -t "$SESSION_NAME" >/dev/null 2>&1 && alive=1 + + # Release the onboarding job: `cancelled` is a terminal transition the + # registry knows, and it says what happened - the job was never really + # dispatched. Best effort; a failure here must not stop the rollback. + if [ -n "${DELEGATE_JOB_ID:-}" ]; then + delegate_publish_event "$DELEGATE_JOB_ID" cancelled "rolled back by create_session" "${DELEGATE_JOB_TOKEN:-}" "${DELEGATE_JOB_ATTEMPT:-}" || true fi + + if [ "$alive" = "1" ]; then + _mark_row_rolled_back "running" "rollback-failed" + echo "ERROR: rollback could not kill herdr session '$SESSION_NAME' (kill rc=$kill_rc) - it is still running." >&2 + echo " Clean it up with: HERDR_SESSION_NAME=${HERDR_SESSION_NAME:-default} herdr kill-session -t $SESSION_NAME" >&2 + exit 43 + fi + _mark_row_rolled_back "terminated" "rolled-back" } trap cleanup_herdr_on_error EXIT @@ -390,6 +440,7 @@ if [ -n "$SUBMIT_JOB_PROMPT" ]; then echo "Submitted delegated job: $DELEGATE_JOB_ID" DELEGATE_JOB_TOKEN="" DELEGATE_JOB_ATTEMPT="" + DELEGATE_JOB_ACK_S="" if [ -n "$DELEGATE_JOB_ID" ]; then local_info=$(python3 -c " import sys, json, os @@ -402,13 +453,15 @@ if os.path.exists(p): d = json.load(f) tok = d.get('dispatch_token', '') att = d.get('attempt_id', d.get('iteration', 1)) - print(f'{tok} {att}') + ack = d.get('ack_timeout_sec', 180) + print(f'{tok} {att} {ack}') except Exception: pass " 2>/dev/null || true) if [ -n "$local_info" ]; then DELEGATE_JOB_TOKEN=$(echo "$local_info" | awk '{print $1}') DELEGATE_JOB_ATTEMPT=$(echo "$local_info" | awk '{print $2}') + DELEGATE_JOB_ACK_S=$(echo "$local_info" | awk '{print $3}') fi fi fi @@ -547,6 +600,7 @@ snap['taken_at'] = os.environ['NOW_ISO'] snap['cwd'] = os.environ['PANE_CWD'] print(f"appended: {name}", flush=True) PYEOF +ROW_APPENDED=1 echo echo "=== created ===" @@ -582,12 +636,27 @@ Task: $SUBMIT_JOB_PROMPT" # Inject instructions into the herdr pane rc=0 inject_instructions "$SESSION_NAME" "$instructions" "$DELEGATE_JOB_ID" || rc=$? - if [ "$rc" -ne 0 ]; then + # ISSUE-23: rc 4 is "Enter was not seen to be accepted within the send_keys + # window", which is where a TUI that is still finishing its start-up lands. + # It is a warning now; the agent's own `started` decides. rc 1/2/3 are still + # failures: they say the pane never quiesced, a dialog was blocking, or the + # text never appeared - none of which a later `started` can repair. + if [ "$rc" -ne 0 ] && [ "$rc" -ne 4 ]; then delegate_publish_event "$DELEGATE_JOB_ID" error "instruction injection failed (prompt-lock, rc=$rc)" "$DELEGATE_JOB_TOKEN" "$DELEGATE_JOB_ATTEMPT" exit 1 fi - - delegate_publish_event "$DELEGATE_JOB_ID" started "multi-agent-mux session created and instructions injected" "$DELEGATE_JOB_TOKEN" "$DELEGATE_JOB_ATTEMPT" + if [ "$rc" -eq 4 ]; then + echo "WARNING: Enter was not observed to be accepted for '$SESSION_NAME' (rc=4); waiting for the agent's own started event instead." >&2 + fi + + # ISSUE-23: this script no longer publishes `started` on the agent's behalf. + # It used to, which made the registry show a started job even when the agent + # never saw the prompt, and left any started-based verdict meaningless. + if ! delegate_wait_started "$DELEGATE_JOB_ID" "${DELEGATE_JOB_ACK_S:-180}" "$DELEGATE_JOB_TOKEN"; then + delegate_publish_event "$DELEGATE_JOB_ID" error "onboarding delivery unconfirmed: no started within ${DELEGATE_JOB_ACK_S:-180}s" "$DELEGATE_JOB_TOKEN" "$DELEGATE_JOB_ATTEMPT" + echo "ERROR: '$SESSION_NAME' did not acknowledge the onboarding prompt within ${DELEGATE_JOB_ACK_S:-180}s" >&2 + exit 1 + fi # Trigger immediate priority reconcile cycle asynchronously # Issue #21 phase 2 (A8): the control root's reconcile/watchdog, never the # (unreviewed) copy in a worktree --workspace; identical paths by default. diff --git a/tests/test_issue23_24_startup_path.py b/tests/test_issue23_24_startup_path.py index c73ec4c..ee8e02b 100644 --- a/tests/test_issue23_24_startup_path.py +++ b/tests/test_issue23_24_startup_path.py @@ -444,3 +444,224 @@ def test_s6b_create_session_no_longer_gates_dialogs_on_claude(mam_sandbox): assert call, "create_session no longer answers start-up dialogs at all" assert all('"$AGENT"' in l for l in call), call assert 'if [ "$AGENT" = "claude" ] && [ -z "$LAST_VISIBLE_STATUS_OVERRIDE" ]' not in src + + +# -------------------------------------------------------------------------- +# item 3 — onboarding delivery is judged by the agent's own `started` (#23) +# -------------------------------------------------------------------------- + +def _create_sh(tmp_path): + return tmp_path / ".agents" / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh" + + +def _extract(path, start_marker, end_marker, close=None): + """Lift a block out of create_session.sh so the test runs the shipped code + rather than a paraphrase of it. A moved marker fails loudly here. + + `close` closes the construct the end marker opens: the block runs to the + first line equal to it after the end marker (e.g. the `fi` of an `if`).""" + lines = open(path).read().splitlines(True) + start = next((i for i, l in enumerate(lines) if start_marker in l), None) + assert start is not None, "start marker %r is gone from %s" % (start_marker, path) + end = next((i for i, l in enumerate(lines[start:], start) if end_marker in l), None) + assert end is not None, "end marker %r is gone from %s" % (end_marker, path) + if close is not None: + end = next((i for i, l in enumerate(lines[end:], end) if l.strip() == close), None) + assert end is not None, "block opened at %r is never closed with %r" % (end_marker, close) + return "".join(lines[start:end + 1]) + + +def _seed_job(tmp_path, job_id, status="pending", started_token=None): + jobs = tmp_path / ".mam" / "jobs" + jobs.mkdir(parents=True, exist_ok=True) + (jobs / ("%s.json" % job_id)).write_text(json.dumps( + {"job_id": job_id, "status": status, "dispatch_token": "tok-1", + "attempt_id": 1, "ack_timeout_sec": 2})) + ev = jobs / ("%s.events.log" % job_id) + if started_token is None: + if ev.exists(): + ev.unlink() + else: + ev.write_text(json.dumps( + {"job": job_id, "event": "started", "dispatch_token": started_token}) + "\n") + return jobs + + +def _run_delivery(tmp_path, inject_rc, job_id="job1", **seed): + """Run create_session.sh's own delivery block with a stubbed injection.""" + lib = tmp_path / ".agents" / "skills" / "lib.sh" + block = _extract(_create_sh(tmp_path), "# Inject instructions into the herdr pane", + "delegate_wait_started", close="fi") + _seed_job(tmp_path, job_id, **seed) + pub_log = tmp_path / "published.txt" + script = ''' +set -euo pipefail +source "%s" +SESSION_NAME=test-sess +DELEGATE_JOB_ID=%s +DELEGATE_JOB_TOKEN=tok-1 +DELEGATE_JOB_ATTEMPT=1 +DELEGATE_JOB_ACK_S=2 +instructions="hello" +inject_instructions() { return %d; } +delegate_publish_event() { printf '%%s\\n' "$2" >> "%s"; } +%s +echo DELIVERED +''' % (lib, job_id, inject_rc, pub_log, block) + env = dict(os.environ, MAM_JOBS_DIR=str(tmp_path / ".mam" / "jobs")) + res = subprocess.run(["bash", "-c", script], capture_output=True, text=True, + cwd=str(tmp_path), env=env) + published = pub_log.read_text().split() if pub_log.exists() else [] + return res, published + + +def test_s7_create_session_no_longer_publishes_started_for_the_agent(mam_sandbox): + """S7 (source): create_session used to publish `started` itself, so the + registry showed a started job even when the agent never saw the prompt. + With that line present no started-based verdict can mean anything.""" + src = open(_create_sh(mam_sandbox)).read() + assert 'delegate_publish_event "$DELEGATE_JOB_ID" started' not in src + assert "delegate_wait_started" in src + + +def test_s7b_delivery_succeeds_on_the_agents_own_started(mam_sandbox): + """S7b (real registry files, stubbed injection): a started line carrying + this dispatch token is the evidence.""" + res, published = _run_delivery(mam_sandbox, 0, started_token="tok-1") + assert "DELIVERED" in res.stdout, res.stdout + res.stderr + assert published == [], published + + +def test_s7c_delivery_succeeds_when_the_registry_left_pending(mam_sandbox): + """S7c: publish_event syncs the record on this host, so a status past + `pending` is the same evidence.""" + res, _ = _run_delivery(mam_sandbox, 0, status="running") + assert "DELIVERED" in res.stdout, res.stdout + res.stderr + + +def test_s7d_no_started_within_t_is_a_delivery_failure(mam_sandbox): + """S7d: fail closed, and say so on the job.""" + res, published = _run_delivery(mam_sandbox, 0) + assert res.returncode == 1, res.stdout + res.stderr + assert "DELIVERED" not in res.stdout + assert published == ["error"], published + + +def test_s7e_a_started_from_another_dispatch_is_not_evidence(mam_sandbox): + """S7e: a loop job keeps one events.log across attempts, so only a started + carrying THIS dispatch token counts.""" + res, _ = _run_delivery(mam_sandbox, 0, started_token="tok-OTHER") + assert res.returncode == 1, res.stdout + res.stderr + + +def test_s8_rc4_is_a_warning_and_the_started_wait_decides(mam_sandbox): + """S8 (stubbed injection): rc 4 is "Enter not seen accepted", which is + exactly where a still-starting TUI lands - #23's false failure. It must no + longer abort; the started wait decides both ways.""" + res, published = _run_delivery(mam_sandbox, 4, started_token="tok-1") + assert "DELIVERED" in res.stdout, res.stdout + res.stderr + assert "rc=4" in res.stderr, res.stderr + assert published == [], published + + res, published = _run_delivery(mam_sandbox, 4) + assert res.returncode == 1, res.stdout + res.stderr + assert published == ["error"], published + + +@pytest.mark.parametrize("rc", [1, 2, 3]) +def test_s8b_the_other_injection_failures_still_abort_at_once(mam_sandbox, rc): + """S8b: rc 1/2/3 say the pane never quiesced, a dialog was blocking, or the + text never appeared. No later `started` repairs those, so they stay hard + failures - only rc 4 was relaxed.""" + res, published = _run_delivery(mam_sandbox, rc, started_token="tok-1") + assert res.returncode == 1, res.stdout + res.stderr + assert "DELIVERED" not in res.stdout + assert published == ["error"], published + + +# -------------------------------------------------------------------------- +# item 4 — a rollback verifies the kill (#23) +# -------------------------------------------------------------------------- + +def _run_rollback(tmp_path, kill_rc, still_alive, row=True, job_id="job1"): + lib = tmp_path / ".agents" / "skills" / "lib.sh" + yaml_path = tmp_path / ".mam" / "agent-sessions.yaml" + if row: + yaml_path.write_text( + "herdr_sessions:\n" + "- name: test-sess\n" + " status: running\n" + " role: creator\n" + " delegate_job_id: %s\n" + " pane:\n" + " index: 0\n" + " pid: 111\n" + " cmd: claude\n" + " cwd: %s\n" % (job_id, tmp_path)) + else: + yaml_path.write_text("herdr_sessions: []\n") + _seed_job(tmp_path, job_id) + block = _extract(_create_sh(tmp_path), "# _mark_row_rolled_back ", + "trap cleanup_herdr_on_error EXIT") + pub_log = tmp_path / "published.txt" + # The trap is installed and fired the way create_session fires it: an + # errexit shell exiting non-zero. rc 9 through means "no exit 43". + script = ''' +set -euo pipefail +source "%s" +SESSION_NAME=test-sess +DELEGATE_JOB_ID=%s +DELEGATE_JOB_TOKEN=tok-1 +DELEGATE_JOB_ATTEMPT=1 +ROW_APPENDED=%d +AGENT_SESSIONS_YAML="%s" +delegate_publish_event() { printf '%%s\\n' "$2" >> "%s"; } +_herdr() { + case "${1:-}" in + kill-session) return %d ;; + has-session) return %d ;; + esac + return 0 +} +%s +exit 9 +''' % (lib, job_id, 1 if row else 0, yaml_path, pub_log, + kill_rc, 0 if still_alive else 1, block) + env = dict(os.environ, MAM_JOBS_DIR=str(tmp_path / ".mam" / "jobs"), + AGENT_SESSIONS_YAML=str(yaml_path)) + res = subprocess.run(["bash", "-c", script], capture_output=True, text=True, + cwd=str(tmp_path), env=env) + published = pub_log.read_text().split() if pub_log.exists() else [] + return res, published, yaml_path.read_text() + + +def test_s9_a_kill_that_left_the_session_alive_is_reported(mam_sandbox): + """S9 (stubbed herdr, real YAML + registry): the kill failed and the + session is still up. #23's rollback swallowed exactly this and printed + "rolled back" anyway, leaving a `running` row against a failed job.""" + res, published, yaml_text = _run_rollback(mam_sandbox, kill_rc=1, still_alive=True) + assert res.returncode == 43, res.stdout + res.stderr + assert "test-sess" in res.stderr and "kill-session" in res.stderr + assert "rollback-failed" in yaml_text, yaml_text + assert "status: running" in yaml_text, "a session that is still up must not be called gone" + assert "delegate_job_id: null" in yaml_text, yaml_text + assert published == ["cancelled"], published + + +def test_s10_a_successful_kill_keeps_the_previous_behaviour(mam_sandbox): + """S10: nothing is still running, so no exit 43 and no warning - but the + row and the job are still cleaned up, which is the other half of #23.""" + res, published, yaml_text = _run_rollback(mam_sandbox, kill_rc=0, still_alive=False) + assert res.returncode == 9, res.stdout + res.stderr + assert "rolled-back" in yaml_text, yaml_text + "\nSTDERR:" + res.stderr + assert "status: terminated" in yaml_text, yaml_text + assert "delegate_job_id: null" in yaml_text, yaml_text + assert published == ["cancelled"], published + + +def test_s10b_a_rollback_before_the_row_existed_touches_no_yaml(mam_sandbox): + """S10b: failing before the append must not invent a row.""" + res, published, yaml_text = _run_rollback(mam_sandbox, kill_rc=0, still_alive=False, + row=False) + assert res.returncode == 9, res.stdout + res.stderr + assert "herdr_sessions: []" in yaml_text, yaml_text From 2cd20423ace0a2a6fd25eda5d8d17ac025f91a51 Mon Sep 17 00:00:00 2001 From: Godopu Date: Tue, 22 Sep 2026 04:28:57 +0000 Subject: [PATCH 04/15] docs(issue-23-24): the create skill documents --no-auto-trust, the started-based onboarding verdict and exit 43 Co-Authored-By: Claude Opus 5 --- .../skills/multi-agent-mux-create/SKILL.md | 29 ++++++++++++++++++- .../scripts/create_session.sh | 2 ++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/.agents/skills/multi-agent-mux-create/SKILL.md b/.agents/skills/multi-agent-mux-create/SKILL.md index e16d8f5..409f053 100644 --- a/.agents/skills/multi-agent-mux-create/SKILL.md +++ b/.agents/skills/multi-agent-mux-create/SKILL.md @@ -60,7 +60,8 @@ If any check fails → abort with a non-zero exit and report the reason (automat - examples: `landing-page-refer-landing-page-creator-claude`, `paper-pdf2md-creator-agy` - never re-derive this by hand — source lib.sh and call the function - **wrapper script** (claude only): `~/.local/bin/-creator-claude` - - contents: herdr new-session with `claude` inside, auto-handles trust/bypass dialogs + - contents: herdr new-session with `claude` inside; trust/bypass dialogs are + handled by the skill for every agent (see `--no-auto-trust` below) - see `/agent_sessions.md` for the canonical wrapper template ## Herdr Session Isolation (격리 세션) @@ -97,6 +98,32 @@ The bootstrapped server receives a **sanitized copy of the caller's environment* bash scripts/create_session.sh --workspace /path/to/project --agent claude --role developer --onboard ``` + **Delivery is judged by the agent's own `started` event** (#23), within the + job's `ack_timeout_sec` (default 180 s) — not by what the pane looked like two + seconds after the keys were sent. The script no longer publishes `started` on + the agent's behalf, so a job that shows `started` really was picked up. A + send_keys rc 4 ("Enter not seen accepted", where a still-starting TUI lands) is + a warning; rc 1/2/3 still abort at once. No `started` within the ack timeout + fails the job and exits 1 — the prompt is never re-injected. + + **Start-up dialogs** are answered for every agent, not only claude (#24). The + blocking-modal signatures and their accept keys live in each agent adapter + (`lib_py/agents/adapters/*.py`, published as `MAM_STARTUP_DIALOGS`), and they + are answered while waiting for the TUI rather than after it. `--no-auto-trust` + leaves directory-trust dialogs for a human to accept and still answers other + blocking notices (for example codex's update prompt, where Skip changes + nothing). Trust matters most for **codex**: trusting a directory makes it load + that project's local config, hooks and exec policy, so `--no-auto-trust` is the + switch to reach for when the workspace is not yours. Re-sending is rate-limited + by `MAM_DIALOG_RESEND_S` (default 5 s) because a `--source visible` snapshot + keeps showing a dialog for a while after it was accepted. + + **Exit codes**: 42 = the TUI never became ready and the process is gone; + 43 = the rollback could not kill the herdr session and it is still running. + On 43 the message names the session and the manual cleanup command; the YAML + row keeps `status: running` with `last_visible_status: rollback-failed` and no + `delegate_job_id`, and the onboarding job is closed as `cancelled`. + Why use `--herdr-session`? - By default, all skills target `default` herdr session socket — fine for single-workspace use. diff --git a/.agents/skills/multi-agent-mux-create/scripts/create_session.sh b/.agents/skills/multi-agent-mux-create/scripts/create_session.sh index 9d8360e..e16a6af 100755 --- a/.agents/skills/multi-agent-mux-create/scripts/create_session.sh +++ b/.agents/skills/multi-agent-mux-create/scripts/create_session.sh @@ -17,6 +17,8 @@ # 2 = invalid args # 3 = herdr session already exists (use multi-agent-mux-resume or delete first) # 4 = agent-sessions.yaml append failure +# 42 = agent TUI never became ready and the process is gone +# 43 = rollback could not kill the herdr session; it is still running (#23) set -euo pipefail _script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" From 33b91fe0c895a1e122bd37acbf348f64a6b17e7b Mon Sep 17 00:00:00 2001 From: Godopu Date: Tue, 22 Sep 2026 06:24:06 +0000 Subject: [PATCH 05/15] fix(issue-23): an unacknowledged onboarding marks the job and keeps the session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects in the started-based verdict, both found by the full suite. The first was destructive. No `started` within T exited 1, which fired the rollback trap and killed the session. That is worse than the bug being fixed: create_session already has a rule for "the agent is not responding but the process is alive" one step earlier - a TUI that never signalled ready is preserved for diagnosis - and an onboarding that is merely slow is the same situation. The job is marked `error` and warned about loudly; the session, its row and the pane stay. MULTI_AGENT_RULES §6 has the GM gate on this job anyway, so an unacknowledged onboarding still blocks real work. It also broke 21 tests across 8 modules (create/sanity/tier2/tier3/tier4/ issue15/issue19/issue21) and added roughly an hour to the suite: no sandbox has an agent to publish `started`, so every create_session test sat out the full 180 s and then failed on the rollback. MAM_ONBOARD_ACK_S shortens that wait, and the sandbox fixture sets 3 s. It is deliberately not the wrapper's MAM_INJECT_ACK_GRACE_S - that is a different wait whose own tests assert on the value they pass, and reusing it made issue18's acceptance2 read "within 3s" instead of "within 4s". The second: the delivery failure published `error` and then the rollback published `cancelled` on the same job, which the registry refuses ("conflicting cancelled after terminal error"). Terminal events are published exactly once, so the trap now skips a job this run already closed. Co-Authored-By: Claude Opus 5 --- .../scripts/create_session.sh | 31 ++++++++++++++++--- tests/conftest.py | 6 ++++ tests/test_issue23_24_startup_path.py | 18 ++++++----- 3 files changed, 43 insertions(+), 12 deletions(-) diff --git a/.agents/skills/multi-agent-mux-create/scripts/create_session.sh b/.agents/skills/multi-agent-mux-create/scripts/create_session.sh index e16a6af..1d9cbfc 100755 --- a/.agents/skills/multi-agent-mux-create/scripts/create_session.sh +++ b/.agents/skills/multi-agent-mux-create/scripts/create_session.sh @@ -331,8 +331,12 @@ cleanup_herdr_on_error() { # Release the onboarding job: `cancelled` is a terminal transition the # registry knows, and it says what happened - the job was never really # dispatched. Best effort; a failure here must not stop the rollback. - if [ -n "${DELEGATE_JOB_ID:-}" ]; then + # `completed`/`error`/`cancelled` are terminal and published exactly once, so + # a job this run already closed is left alone - a second one is refused by + # the registry anyway ("conflicting cancelled after terminal error"). + if [ -n "${DELEGATE_JOB_ID:-}" ] && [ "${JOB_TERMINALIZED:-0}" != "1" ]; then delegate_publish_event "$DELEGATE_JOB_ID" cancelled "rolled back by create_session" "${DELEGATE_JOB_TOKEN:-}" "${DELEGATE_JOB_ATTEMPT:-}" || true + JOB_TERMINALIZED=1 fi if [ "$alive" = "1" ]; then @@ -645,6 +649,7 @@ Task: $SUBMIT_JOB_PROMPT" # text never appeared - none of which a later `started` can repair. if [ "$rc" -ne 0 ] && [ "$rc" -ne 4 ]; then delegate_publish_event "$DELEGATE_JOB_ID" error "instruction injection failed (prompt-lock, rc=$rc)" "$DELEGATE_JOB_TOKEN" "$DELEGATE_JOB_ATTEMPT" + JOB_TERMINALIZED=1 exit 1 fi if [ "$rc" -eq 4 ]; then @@ -654,10 +659,26 @@ Task: $SUBMIT_JOB_PROMPT" # ISSUE-23: this script no longer publishes `started` on the agent's behalf. # It used to, which made the registry show a started job even when the agent # never saw the prompt, and left any started-based verdict meaningless. - if ! delegate_wait_started "$DELEGATE_JOB_ID" "${DELEGATE_JOB_ACK_S:-180}" "$DELEGATE_JOB_TOKEN"; then - delegate_publish_event "$DELEGATE_JOB_ID" error "onboarding delivery unconfirmed: no started within ${DELEGATE_JOB_ACK_S:-180}s" "$DELEGATE_JOB_TOKEN" "$DELEGATE_JOB_ATTEMPT" - echo "ERROR: '$SESSION_NAME' did not acknowledge the onboarding prompt within ${DELEGATE_JOB_ACK_S:-180}s" >&2 - exit 1 + # + # How long to wait for the handshake: MAM_ONBOARD_ACK_S, else the wrapper's + # MAM_INJECT_ACK_GRACE_S, else the job's own ack timeout. The create-specific + # variable comes first so this wait can be shortened - in a sandbox with no + # real agent, for instance - without also retuning the delegate-job + # wrapper's injection ack, which is a different wait with its own tests. + ONBOARD_ACK_S="${MAM_ONBOARD_ACK_S:-${MAM_INJECT_ACK_GRACE_S:-${DELEGATE_JOB_ACK_S:-180}}}" + if ! delegate_wait_started "$DELEGATE_JOB_ID" "$ONBOARD_ACK_S" "$DELEGATE_JOB_TOKEN"; then + # The job failed, the session did not: the pane is up and the row is valid, + # only the handshake never landed. Killing a live agent over a slow or + # silent onboarding would destroy more than it protects, and it would + # contradict this script's own rule for the same situation one step + # earlier - a TUI that never signalled ready is preserved for diagnosis, + # not rolled back. So: mark the job, warn loudly, keep the session. + # MULTI_AGENT_RULES §6 has the GM gate on this job's `completed` anyway, + # so an unacknowledged onboarding already blocks real work. + delegate_publish_event "$DELEGATE_JOB_ID" error "onboarding delivery unconfirmed: no started within ${ONBOARD_ACK_S}s" "$DELEGATE_JOB_TOKEN" "$DELEGATE_JOB_ATTEMPT" + JOB_TERMINALIZED=1 + echo "WARNING: '$SESSION_NAME' did not acknowledge the onboarding prompt within ${ONBOARD_ACK_S}s." >&2 + echo " The session is preserved and job $DELEGATE_JOB_ID is marked error; re-send the onboarding with multi-agent-mux-delegate-job submit once the agent is up." >&2 fi # Trigger immediate priority reconcile cycle asynchronously # Issue #21 phase 2 (A8): the control root's reconcile/watchdog, never the diff --git a/tests/conftest.py b/tests/conftest.py index 14ee7ac..951dfb4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -227,6 +227,12 @@ def mam_sandbox(tmp_path, monkeypatch, request): # (`reconcile.sh --subscribe --idle-timeout 0`) from a sandbox. Tests that # exercise the daemon itself override this with "0" or delenv. monkeypatch.setenv("MAM_NO_SUBSCRIBER_DAEMON", "1") + # Issue #23: onboarding delivery now waits for the agent's own `started`. + # No sandbox has a real agent to publish one, so without a short grace + # every create_session test would sit out the full 180 s ack timeout. + # Create-specific on purpose: MAM_INJECT_ACK_GRACE_S is the delegate-job + # wrapper's knob and its own tests assert on the value they pass. + monkeypatch.setenv("MAM_ONBOARD_ACK_S", "3") monkeypatch.delenv("HERDR_SESSION_NAME", raising=False) monkeypatch.delenv("HERDR_SERVER_NAME", raising=False) monkeypatch.delenv("HERDR_WORKSPACE_ID", raising=False) diff --git a/tests/test_issue23_24_startup_path.py b/tests/test_issue23_24_startup_path.py index ee8e02b..9321743 100644 --- a/tests/test_issue23_24_startup_path.py +++ b/tests/test_issue23_24_startup_path.py @@ -539,19 +539,23 @@ def test_s7c_delivery_succeeds_when_the_registry_left_pending(mam_sandbox): assert "DELIVERED" in res.stdout, res.stdout + res.stderr -def test_s7d_no_started_within_t_is_a_delivery_failure(mam_sandbox): - """S7d: fail closed, and say so on the job.""" +def test_s7d_no_started_marks_the_job_but_keeps_the_session(mam_sandbox): + """S7d: the job failed, the session did not. The handshake is marked + `error` and warned about loudly, but a live agent is not killed over a + slow onboarding - the same rule this script already applies one step + earlier to a TUI that never signalled ready.""" res, published = _run_delivery(mam_sandbox, 0) - assert res.returncode == 1, res.stdout + res.stderr - assert "DELIVERED" not in res.stdout + assert res.returncode == 0, res.stdout + res.stderr assert published == ["error"], published + assert "did not acknowledge the onboarding prompt" in res.stderr + assert "preserved" in res.stderr def test_s7e_a_started_from_another_dispatch_is_not_evidence(mam_sandbox): """S7e: a loop job keeps one events.log across attempts, so only a started carrying THIS dispatch token counts.""" - res, _ = _run_delivery(mam_sandbox, 0, started_token="tok-OTHER") - assert res.returncode == 1, res.stdout + res.stderr + res, published = _run_delivery(mam_sandbox, 0, started_token="tok-OTHER") + assert published == ["error"], published def test_s8_rc4_is_a_warning_and_the_started_wait_decides(mam_sandbox): @@ -564,8 +568,8 @@ def test_s8_rc4_is_a_warning_and_the_started_wait_decides(mam_sandbox): assert published == [], published res, published = _run_delivery(mam_sandbox, 4) - assert res.returncode == 1, res.stdout + res.stderr assert published == ["error"], published + assert "did not acknowledge the onboarding prompt" in res.stderr @pytest.mark.parametrize("rc", [1, 2, 3]) From ce802d21c8a8ecf69333606fa2f61d04da41f13f Mon Sep 17 00:00:00 2001 From: Godopu Date: Tue, 22 Sep 2026 06:24:33 +0000 Subject: [PATCH 06/15] docs(issue-23): the create skill documents the preserve-on-unacknowledged-onboarding rule and MAM_ONBOARD_ACK_S Co-Authored-By: Claude Opus 5 --- .agents/skills/multi-agent-mux-create/SKILL.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.agents/skills/multi-agent-mux-create/SKILL.md b/.agents/skills/multi-agent-mux-create/SKILL.md index 409f053..9fc5617 100644 --- a/.agents/skills/multi-agent-mux-create/SKILL.md +++ b/.agents/skills/multi-agent-mux-create/SKILL.md @@ -104,7 +104,13 @@ The bootstrapped server receives a **sanitized copy of the caller's environment* the agent's behalf, so a job that shows `started` really was picked up. A send_keys rc 4 ("Enter not seen accepted", where a still-starting TUI lands) is a warning; rc 1/2/3 still abort at once. No `started` within the ack timeout - fails the job and exits 1 — the prompt is never re-injected. + marks the job `error` and warns, but **keeps the session**: the pane is up and + the row is valid, only the handshake never landed, and killing a live agent + over a slow onboarding would destroy more than it protects (the same rule + this script applies to a TUI that never signalled ready). Re-send with + `multi-agent-mux-delegate-job submit` once the agent is up; the prompt is + never re-injected automatically. `MAM_ONBOARD_ACK_S` overrides how long that + wait is — distinct from the wrapper's `MAM_INJECT_ACK_GRACE_S`. **Start-up dialogs** are answered for every agent, not only claude (#24). The blocking-modal signatures and their accept keys live in each agent adapter From 0dfbb2cc8a8946e77f79324789212e81ce7a82d6 Mon Sep 17 00:00:00 2001 From: Godopu Date: Tue, 22 Sep 2026 08:19:18 +0000 Subject: [PATCH 07/15] fix(issue-24): resume answers start-up dialogs for every agent, with the same switch as create resume_session.sh called handle_startup_dialogs only when AGENT=claude and passed no agent, so a resumed agy, grok or codex sat on its directory-trust prompt exactly as the create path did before this branch. The gate is gone and the agent is passed, which is the call create already makes. --no-auto-trust / MAM_AUTO_TRUST apply to resume too. A resumed CLI shows the same trust dialog as a fresh one, in the same directory, with the same consequence for codex (trusting loads the project's local config, hooks and exec policy), and handle_startup_dialogs already reads MAM_AUTO_TRUST - so the only question was whether resume exposes the switch or silently inherits the caller's environment. Exposing it keeps the two skills' contracts identical. The tests run the shipped create/stop/resume scripts against the mock herdr. The mock's pane always shows a ready banner, so they fix the call resume makes (session, timeout, agent, MAM_AUTO_TRUST) by overriding the handler in the sandbox copy of lib.sh, plus one unmodified run showing the real handler is reached for a non-claude agent; which keys a dialog gets is fixed on the create side (S4-S6). Co-Authored-By: Claude Fable 5.1 --- .../skills/multi-agent-mux-resume/SKILL.md | 16 +-- .../scripts/resume_session.sh | 17 ++- tests/test_issue24_resume_dialogs.py | 120 ++++++++++++++++++ 3 files changed, 138 insertions(+), 15 deletions(-) create mode 100644 tests/test_issue24_resume_dialogs.py diff --git a/.agents/skills/multi-agent-mux-resume/SKILL.md b/.agents/skills/multi-agent-mux-resume/SKILL.md index 8156922..709e879 100644 --- a/.agents/skills/multi-agent-mux-resume/SKILL.md +++ b/.agents/skills/multi-agent-mux-resume/SKILL.md @@ -101,17 +101,11 @@ case "$AGENT" in esac # 4. Spawn new herdr session + run agent with the saved id -case "$AGENT" in - claude) - START_CMD="herdr new-session -d -s \"$SESSION_NAME\" -x 140 -y 40 -c \"$WORKSPACE\" \"$CMD_FULL\"" - eval "$START_CMD" - # auto-handle trust / bypass dialogs - handle_startup_dialogs "$SESSION_NAME" 20 - ;; - agy|hermes|grok|opencode|codex) - eval "herdr new-session -d -s \"$SESSION_NAME\" -x 140 -y 40 -c \"$WORKSPACE\" \"$CMD_FULL\"" - ;; -esac +eval "herdr new-session -d -s \"$SESSION_NAME\" -x 140 -y 40 -c \"$WORKSPACE\" \"$CMD_FULL\"" +# Issue #24: start-up dialogs (trust prompts, blocking notices) are answered for +# every agent from the adapter's signatures - the same call create makes. +# --no-auto-trust leaves a directory-trust dialog to a human (MAM_AUTO_TRUST=0). +handle_startup_dialogs "$SESSION_NAME" 20 "$AGENT" # 4. Update agent-sessions.yaml: status running, last_visible_status # (Also automatically publishes a `progress --detail "resumed"` event to the multi-agent-mux-delegate-job registry if a delegate_job_id exists) diff --git a/.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh b/.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh index c5a58ff..0776864 100755 --- a/.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh +++ b/.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh @@ -13,6 +13,8 @@ Usage: $0 --workspace --agent --s Options: --herdr-session NAME specify isolated herdr session name (alias: --herdr-server) + --no-auto-trust do not answer a start-up directory-trust dialog (default: answer it; + other blocking notices are always answered) - same switch as create --dry-run Simulates resume flow (resolves binary, environment) without writing any updates to YAML or DB. Safe to execute inside active write transactions. EOF @@ -25,6 +27,10 @@ HERDR_SERVER_OPT="" HERDR_WORKSPACE_OPT="" DRY_RUN=0 +# Issue #24: a resumed CLI shows the same directory-trust dialog as a fresh +# one, so resume takes the same switch as create (MAM_AUTO_TRUST is what +# handle_startup_dialogs reads). +AUTO_TRUST=1 while [ $# -gt 0 ]; do case "$1" in @@ -33,6 +39,8 @@ while [ $# -gt 0 ]; do --session) SESSION_NAME="$2"; shift 2 ;; --herdr-session|--herdr-server) HERDR_SERVER_OPT="$2"; shift 2 ;; --herdr-workspace) HERDR_WORKSPACE_OPT="$2"; shift 2 ;; + --auto-trust) AUTO_TRUST=1; shift ;; + --no-auto-trust) AUTO_TRUST=0; shift ;; --dry-run) DRY_RUN=1; shift ;; -h|--help) usage; exit 0 ;; *) echo "ERROR: unknown arg: $1" >&2; exit 2 ;; @@ -46,6 +54,7 @@ case "$AGENT" in *) echo "ERROR: unsupported agent: $AGENT" >&2; exit 2 ;; esac [ -n "$SESSION_NAME" ] || { echo "ERROR: --session required" >&2; exit 2; } +export MAM_AUTO_TRUST="$AUTO_TRUST" # Issue #21 phase 2 (A11): a session resumes only in the directory it ran in # (its conversation is keyed by that cwd; a class A agent would otherwise start @@ -226,10 +235,10 @@ fi # 4. Spawn new agent session (delegates to herdr translation shim) _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 -fi +# Issue #24: every agent, the same call create makes. The signatures and accept +# keys come from the agent adapter; this used to run only for claude, so a +# resumed agy/grok/codex sat on its trust prompt. +handle_startup_dialogs "$SESSION_NAME" 20 "$AGENT" # Wait for TUI readiness or let it settle sleep 2 diff --git a/tests/test_issue24_resume_dialogs.py b/tests/test_issue24_resume_dialogs.py new file mode 100644 index 0000000..544006e --- /dev/null +++ b/tests/test_issue24_resume_dialogs.py @@ -0,0 +1,120 @@ +"""Issue #24, resume path: start-up dialogs are answered for every agent. + +resume_session.sh called handle_startup_dialogs only for claude and passed no +agent, so a resumed agy/grok/codex sat on its directory-trust prompt exactly as +the create path did before this branch. These fix the call contract resume +now shares with create. + +What is real: the shipped create/stop/resume scripts run end to end against +the mock herdr in a sandbox. What is not: the mock's pane always shows a +ready banner, so no test here drives a real trust dialog - the create-side +tests (test_issue23_24_startup_path.py, S4-S6) fix which keys each dialog +gets, and R4 shows the same shipped function is now reached from resume. +""" + +import json +import os +import subprocess + +import pytest + +SCRIPTS = ("multi-agent-mux-create/scripts/create_session.sh", + "multi-agent-mux-stop/scripts/stop_session.sh", + "multi-agent-mux-resume/scripts/resume_session.sh") + + +def _script(tmp_path, rel): + return str(tmp_path / ".agents" / "skills" / rel) + + +def _create_and_stop(tmp_path, agent, name): + ws = str(tmp_path / ("ws_" + name)) + os.makedirs(ws, exist_ok=True) + create, stop, _ = (_script(tmp_path, s) for s in SCRIPTS) + subprocess.run(["bash", create, "--workspace", ws, "--agent", agent, + "--role", "creator", "--session", name, "--no-onboard"], + check=True, cwd=str(tmp_path), capture_output=True, text=True) + subprocess.run(["bash", stop, "--session", name, "--agent", agent], + check=True, cwd=str(tmp_path), capture_output=True, text=True) + return ws + + +def _resume(tmp_path, agent, name, ws, extra=()): + _, _, resume = (_script(tmp_path, s) for s in SCRIPTS) + return subprocess.run(["bash", resume, "--workspace", ws, "--agent", agent, + "--session", name, *extra], + cwd=str(tmp_path), capture_output=True, text=True) + + +def _record_dialog_calls(tmp_path): + """Override handle_startup_dialogs in the SANDBOX copy of lib.sh (never the + repo's) so the call resume makes - session, timeout, agent, MAM_AUTO_TRUST + - is written down. Bash keeps the last definition, so appending wins.""" + log = tmp_path / "dialog_calls.log" + lib = tmp_path / ".agents" / "skills" / "lib.sh" + with open(lib, "a") as f: + f.write('\nhandle_startup_dialogs() {\n' + ' printf \'%%s\\n\' "sess=$1 timeout=${2:-} agent=${3:-} ' + 'auto_trust=${MAM_AUTO_TRUST:-}" >> "%s"\n' + '}\n' % log) + return log + + +def _calls(log): + return log.read_text().splitlines() if log.exists() else [] + + +@pytest.mark.parametrize("agent", ["agy", "codex"]) +def test_r1_resume_answers_dialogs_for_a_non_claude_agent_and_names_it( + mam_sandbox, mock_herdr, mock_agents, agent): + """R1: the gate is gone and the agent is passed - the two halves of the + create contract resume was missing.""" + tmp_path = mam_sandbox + name = "test-r1-" + agent + ws = _create_and_stop(tmp_path, agent, name) + log = _record_dialog_calls(tmp_path) # armed after create, so only resume is recorded + res = _resume(tmp_path, agent, name, ws) + assert res.returncode == 0, res.stdout + res.stderr + assert _calls(log) == ["sess=%s timeout=20 agent=%s auto_trust=1" % (name, agent)] + + +def test_r2_claude_keeps_its_dialog_handling(mam_sandbox, mock_herdr, mock_agents): + """R2: claude is still handled, now by the same call as everyone else.""" + tmp_path = mam_sandbox + ws = _create_and_stop(tmp_path, "claude", "test-r2-claude") + log = _record_dialog_calls(tmp_path) + res = _resume(tmp_path, "claude", "test-r2-claude", ws) + assert res.returncode == 0, res.stdout + res.stderr + assert _calls(log) == ["sess=test-r2-claude timeout=20 agent=claude auto_trust=1"] + + +def test_r3_no_auto_trust_reaches_the_dialog_handler(mam_sandbox, mock_herdr, mock_agents): + """R3: --no-auto-trust on resume sets MAM_AUTO_TRUST=0 for the handler, + the same switch with the same meaning as create's.""" + tmp_path = mam_sandbox + ws = _create_and_stop(tmp_path, "codex", "test-r3-codex") + log = _record_dialog_calls(tmp_path) + res = _resume(tmp_path, "codex", "test-r3-codex", ws, extra=("--no-auto-trust",)) + assert res.returncode == 0, res.stdout + res.stderr + assert _calls(log) == ["sess=test-r3-codex timeout=20 agent=codex auto_trust=0"] + + +def test_r4_the_shipped_handler_really_runs_for_a_non_claude_resume( + mam_sandbox, mock_herdr, mock_agents): + """R4 (no override): with the real handle_startup_dialogs, a codex resume + completes and the pane is read after the spawn - the handler was reached + and returned on the ready banner. Before this change no pane read happened + between new-session and the YAML update for a non-claude agent.""" + tmp_path = mam_sandbox + ws = _create_and_stop(tmp_path, "codex", "test-r4-codex") + before = len(json.load(open(mock_herdr)).get("calls", [])) + res = _resume(tmp_path, "codex", "test-r4-codex", ws) + assert res.returncode == 0, res.stdout + res.stderr + calls = json.load(open(mock_herdr)).get("calls", [])[before:] + + def _has(c, *verbs): # the mock records argv with a leading `--session ` + return any(tuple(c[i:i + len(verbs)]) == verbs for i in range(len(c))) + + spawn = next(i for i, c in enumerate(calls) if _has(c, "agent", "start")) + reads = [c for c in calls[spawn + 1:] if _has(c, "agent", "read") or _has(c, "pane", "read")] + assert reads, "no pane read after the spawn: handle_startup_dialogs never ran\n%s" % calls From 10a678c010706be316bc178274216556b226b3dd Mon Sep 17 00:00:00 2001 From: Godopu Date: Tue, 22 Sep 2026 10:25:52 +0000 Subject: [PATCH 08/15] fix(issue-23-24): the dialog handler sees the ready banner on its own; only this dispatch's started is delivery evidence Review round 1 (planner-reviewer-claude-01, reviewer-agy-01, planner-reviewer-codex-01): one blocking defect and two P2s, all confirmed. B-1. handle_startup_dialogs took its ready tokens only from the environment, which wait_for_tui_ready fills when it evaluates the adapter facts. Called on its own - as resume_session.sh calls it, with no readiness wait before it - it had no tokens, its early-return branch could never fire, and every resume of every agent idled through the full 20 s timeout (base: 0.03 s). rc was 0 throughout, so nothing failed; the resume tests just took ~30 s each. _mam_load_dialog_facts now also takes MAM_STRONG_READY_TOKENS / MAM_READY_TOKENS from the facts, but only when the caller has not set them, so a caller-set value (wait_for_tui_ready's own, or a test steering readiness) is never clobbered - the concern that made it take a single fact in the first place. S5d pins one-iteration return on a ready first frame for five adapters with no environment; S5e pins that caller tokens survive; R4 gets a wall-clock cap so a timeout-idle can no longer pass as success. The old _MAM_READY_TOKENS_CLAUDE fallback was left defined but unread - removed. R1 / N-2. delegate_wait_started treated any status past `pending` as the agent's acknowledgement. codex reproduced three false positives: `error` and `cancelled` records with no started line, and a `running` record from another dispatch. The status branch is gone; the evidence is a started line carrying this dispatch's token, which the local publisher writes before it syncs the status, so nothing that really started is missed (S7c re-pinned on the line with running/completed/error statuses; S7f/S7g pin the three negatives). N-4, taken with it: the events.log line is parsed as JSON rather than pattern- matched. publish_event nests dispatch_token under `data`, and the old regex only matched because of key order (S7h, real line shape and a reordered one). R2 / N-1. Two tests hardcoded /.venv/bin/python and failed with FileNotFoundError in every review worktree. sys.executable. Co-Authored-By: Claude Fable 5.1 --- .agents/skills/lib.sh | 82 ++++++++++++-------- tests/test_issue23_24_startup_path.py | 103 +++++++++++++++++++++++--- tests/test_issue24_resume_dialogs.py | 7 ++ 3 files changed, 150 insertions(+), 42 deletions(-) diff --git a/.agents/skills/lib.sh b/.agents/skills/lib.sh index 63ced3d..c694aa8 100644 --- a/.agents/skills/lib.sh +++ b/.agents/skills/lib.sh @@ -71,7 +71,6 @@ done _MAM_MODAL_TOKENS='Do you trust the files|Yes, proceed|No, exit|Allow this|browser to authenticate|Resuming the full session|Resume from summary|Try the new fullscreen renderer\?' _MAM_HINT_TOKENS='Use arrow keys|Esc to cancel|Press Enter to continue' _MAM_DIALOG_TOKENS="${_MAM_MODAL_TOKENS}|${_MAM_HINT_TOKENS}" -_MAM_READY_TOKENS_CLAUDE='Anthropic|Assistant|Chat|Welcome|Claude Code|Opus|Sonnet|Haiku' # Workspace-relative defaults with environment overrides (Phase Z) HOME_DIR="${HOME_DIR:-$HOME}" @@ -2142,35 +2141,42 @@ delegate_publish_event() { # line lands in the job's events.log, which publish_event writes before its # wire publish so a slow broker cannot delay the verdict. # -# Returns 0 on that evidence, 1 if neither appeared within T. Never re-injects: -# the single-injection contract is fail-closed. +# Returns 0 on that evidence, 1 if it did not appear within T. Never +# re-injects: the single-injection contract is fail-closed. +# +# The evidence is a `started` line in the job's events.log carrying THIS +# dispatch's token (nested under `data`, as publish_event writes it; the line +# is parsed as JSON, not pattern-matched, so key order is irrelevant). The +# registry's status is deliberately NOT evidence: a record can leave `pending` +# because another writer closed it (`error`, `cancelled`) or because a +# different dispatch is `running`, none of which says this agent saw this +# prompt (review R1). The local publisher writes the log line before it syncs +# the status, so nothing that really started is missed. delegate_wait_started() { local job_id="$1" ack_s="${2:-180}" tok="${3:-}" [ -n "$job_id" ] || return 1 local reg_dir="${MAM_JOBS_DIR:-${WORKSPACE_ROOT:-$PWD}/.mam/jobs}" - local rec="$reg_dir/$job_id.json" local ev_log="$reg_dir/$job_id.events.log" - local started_re='"event": *"started"' - if [ -n "$tok" ]; then - started_re='"event": *"started".*"dispatch_token": *"'"$tok"'"' - fi - local deadline st + local deadline deadline=$(( $(date +%s) + ack_s )) while :; do - st="" - if [ -f "$rec" ]; then - st=$(REC="$rec" python3 -c " -import json, os -try: - print(json.load(open(os.environ['REC'])).get('status', '') or '') -except Exception: - print('') -" 2>/dev/null || true) - fi - if [ -n "$st" ] && [ "$st" != "pending" ]; then - return 0 - fi - if [ -f "$ev_log" ] && grep -Eq "$started_re" "$ev_log" 2>/dev/null; then + if [ -f "$ev_log" ] && EV_LOG="$ev_log" EV_TOK="$tok" python3 -c ' +import json, os, sys +tok = os.environ.get("EV_TOK", "") +with open(os.environ["EV_LOG"], encoding="utf-8", errors="replace") as f: + for line in f: + try: + e = json.loads(line) + except Exception: + continue + if not isinstance(e, dict) or e.get("event") != "started": + continue + data = e.get("data") if isinstance(e.get("data"), dict) else {} + seen = data.get("dispatch_token") or e.get("dispatch_token") or "" + if not tok or seen == tok: + sys.exit(0) +sys.exit(1) +' 2>/dev/null; then return 0 fi if [ "$(date +%s)" -ge "$deadline" ]; then @@ -2752,21 +2758,35 @@ send_keys_safe() { } # _mam_load_dialog_facts -# Populate MAM_STARTUP_DIALOGS for , once per agent per shell. Only that -# one fact is taken: `facts` is an eval contract (F8), and evaluating all of it -# here would clobber ready/placeholder tokens a caller deliberately set. -# An agent the adapters do not know keeps an empty list. +# Populate MAM_STARTUP_DIALOGS for , once per agent per shell, and the +# ready tokens (MAM_STRONG_READY_TOKENS / MAM_READY_TOKENS) only when the caller +# has not set them. `facts` is an eval contract (F8); evaluating all of it here +# would clobber tokens a caller deliberately set, so only these are taken, and +# the tokens only fill a gap. Without them the handler could never recognise a +# ready screen when called on its own - resume_session.sh does exactly that, +# with no wait_for_tui_ready before it - and idled through its whole timeout on +# every resume (review B-1). An agent the adapters do not know keeps an empty +# list and no tokens. _mam_load_dialog_facts() { - local agent="$1" _f="" + local agent="$1" _f="" _d="" _s="" _r="" if [ "${_MAM_DIALOGS_AGENT:-}" = "$agent" ]; then return 0 fi - _f="$("$(_delegate_py_bin)" -m lib_py.agents facts "$agent" 2>/dev/null | sed -n 's/^MAM_STARTUP_DIALOGS=//p')" || _f="" - if [ -n "$_f" ]; then - eval "MAM_STARTUP_DIALOGS=$_f" + _f="$("$(_delegate_py_bin)" -m lib_py.agents facts "$agent" 2>/dev/null)" || _f="" + _d="$(printf '%s\n' "$_f" | sed -n 's/^MAM_STARTUP_DIALOGS=//p')" + _s="$(printf '%s\n' "$_f" | sed -n 's/^MAM_STRONG_READY_TOKENS=//p')" + _r="$(printf '%s\n' "$_f" | sed -n 's/^MAM_READY_TOKENS=//p')" + if [ -n "$_d" ]; then + eval "MAM_STARTUP_DIALOGS=$_d" else MAM_STARTUP_DIALOGS='[]' fi + if [ -z "${MAM_STRONG_READY_TOKENS:-}" ] && [ -n "$_s" ]; then + eval "MAM_STRONG_READY_TOKENS=$_s" + fi + if [ -z "${MAM_READY_TOKENS:-}" ] && [ -n "$_r" ]; then + eval "MAM_READY_TOKENS=$_r" + fi _MAM_DIALOGS_AGENT="$agent" return 0 } diff --git a/tests/test_issue23_24_startup_path.py b/tests/test_issue23_24_startup_path.py index 9321743..9b74104 100644 --- a/tests/test_issue23_24_startup_path.py +++ b/tests/test_issue23_24_startup_path.py @@ -17,6 +17,7 @@ agy/grok/codex/opencode TUI; no such TUI can be started here. import json import os import subprocess +import sys import textwrap import pytest @@ -194,7 +195,7 @@ def test_s3_shim_contract_regressions_still_pass(mam_sandbox): Run them explicitly as this change's regression guard.""" repo = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) res = subprocess.run( - [os.path.join(repo, ".venv", "bin", "python"), "-m", "pytest", "-q", + [sys.executable, "-m", "pytest", "-q", "-p", "no:cacheprovider", "tests/test_herdr_shim_contract.py", "-k", "h18 or h21 or h22 or h23"], @@ -270,7 +271,6 @@ def test_s12_mam_herdr_never_aborts_its_caller_under_set_e( def test_s13_the_loop_watcher_applies_the_same_rule(mam_sandbox): """S13 (pure unit): watcher.py builds its own herdr argv and never goes through mam_herdr, so it needs the same rule.""" - import sys repo = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, os.path.join(repo, ".agents", "skills")) from lib_py.loop import watcher @@ -331,7 +331,11 @@ echo "rc=$?" ''' % (lib, pane_file, keys_log, prelude, timeout, agent) run_env = dict(os.environ) run_env.pop("MAM_AUTO_TRUST", None) - run_env.update(env or {}) + for k, v in (env or {}).items(): + if v == "": + run_env.pop(k, None) # "" means: not in the environment at all + else: + run_env[k] = v res = subprocess.run(["bash", "-c", script], capture_output=True, text=True, cwd=str(tmp_path), env=run_env) sent = keys_log.read_text().splitlines() if keys_log.exists() else [] @@ -372,7 +376,7 @@ def test_s4b_every_adapter_publishes_the_fact(mam_sandbox): seen = {} for agent in ("claude", "agy", "grok", "codex", "opencode", "hermes"): out = subprocess.run( - [os.path.join(repo, ".venv", "bin", "python"), "-m", "lib_py.agents", + [sys.executable, "-m", "lib_py.agents", "facts", agent], capture_output=True, text=True, cwd=repo, env=dict(os.environ, PYTHONPATH=skills)).stdout @@ -417,6 +421,47 @@ def test_s5c_readiness_is_judged_by_the_ready_tokens_not_by_the_dialog_leaving( assert "rc=0" in res.stdout, res.stdout + res.stderr +READY_FRAMES = [ + ("claude", "Welcome to Claude Code\n❯ "), + ("agy", "Antigravity Ready in /tmp\n> "), + ("grok", "Grok Ready\n>>> "), + ("codex", "Codex\nAsk Codex to do anything"), + ("opencode", "OpenCode\nAsk anything"), +] + + +@pytest.mark.parametrize("agent,frame", READY_FRAMES) +def test_s5d_a_ready_first_frame_returns_within_one_iteration_with_no_env( + mam_sandbox, agent, frame): + """S5d (review B-1): called on its own - as resume_session.sh calls it, + with no wait_for_tui_ready before it and no MAM_*READY* in the environment + - the handler must recognise the adapter's ready banner on the first frame + and return at once, not idle through its timeout. `sleep` is stubbed so + the iteration count is observable and the run is fast; base returned in + 0.03s, the version under review took the full timeout.""" + res, sent = _run_dialogs( + mam_sandbox, agent, frame, timeout=20, + env={"MAM_STRONG_READY_TOKENS": "", "MAM_READY_TOKENS": "", "MAM_WEAK_READY_TOKENS": ""}, + prelude='_iters=0\nsleep() { _iters=$((_iters + 1)); }\n' + 'trap \'echo "iterations=$_iters"\' EXIT') + assert "rc=0" in res.stdout, res.stdout + res.stderr + assert sent == [], sent + iters = int(res.stdout.split("iterations=")[1].split()[0]) + assert iters <= 1, "the handler idled %d iterations on a ready screen" % iters + + +def test_s5e_caller_set_ready_tokens_are_not_overwritten(mam_sandbox): + """S5e: the gap-fill must not clobber tokens a caller set on purpose - + wait_for_tui_ready's own evaluation, or a test steering readiness.""" + res, sent = _run_dialogs( + mam_sandbox, "grok", "Grok Ready\n>>> ", timeout=2, + env={"MAM_STRONG_READY_TOKENS": "NonExistentStrong", "MAM_READY_TOKENS": "NonExistentStrong"}, + prelude='_iters=0\nsleep() { _iters=$((_iters + 1)); }\n' + 'trap \'echo "iterations=$_iters"\' EXIT') + iters = int(res.stdout.split("iterations=")[1].split()[0]) + assert iters >= 2, "the caller's tokens were replaced by the adapter's: " + res.stdout + + def test_s6_no_auto_trust_leaves_trust_dialogs_alone_but_answers_notices( mam_sandbox): """S6 (stubbed): --no-auto-trust. codex trust is left to a human with a @@ -487,12 +532,13 @@ def _seed_job(tmp_path, job_id, status="pending", started_token=None): return jobs -def _run_delivery(tmp_path, inject_rc, job_id="job1", **seed): +def _run_delivery(tmp_path, inject_rc, job_id="job1", seed_events=True, **seed): """Run create_session.sh's own delivery block with a stubbed injection.""" lib = tmp_path / ".agents" / "skills" / "lib.sh" block = _extract(_create_sh(tmp_path), "# Inject instructions into the herdr pane", "delegate_wait_started", close="fi") - _seed_job(tmp_path, job_id, **seed) + if seed_events: + _seed_job(tmp_path, job_id, **seed) pub_log = tmp_path / "published.txt" script = ''' set -euo pipefail @@ -532,11 +578,46 @@ def test_s7b_delivery_succeeds_on_the_agents_own_started(mam_sandbox): assert published == [], published -def test_s7c_delivery_succeeds_when_the_registry_left_pending(mam_sandbox): - """S7c: publish_event syncs the record on this host, so a status past - `pending` is the same evidence.""" - res, _ = _run_delivery(mam_sandbox, 0, status="running") - assert "DELIVERED" in res.stdout, res.stdout + res.stderr +def test_s7c_a_started_that_was_followed_by_a_terminal_status_still_counts(mam_sandbox): + """S7c: a job that started and then finished (or failed) fast still shows + its started line - the evidence is the line, whatever the status became.""" + for status in ("running", "completed", "error"): + res, _ = _run_delivery(mam_sandbox, 0, status=status, started_token="tok-1") + assert "DELIVERED" in res.stdout, (status, res.stdout + res.stderr) + + +@pytest.mark.parametrize("status", ["error", "cancelled"]) +def test_s7f_a_job_closed_by_someone_else_is_not_an_acknowledgement(mam_sandbox, status): + """S7f (review R1): a record can leave `pending` because another writer + closed it. No started line from this agent means no delivery.""" + res, published = _run_delivery(mam_sandbox, 0, status=status) + assert published == ["error"], (status, published, res.stderr) + assert "did not acknowledge the onboarding prompt" in res.stderr + + +def test_s7g_another_dispatch_running_is_not_this_dispatchs_started(mam_sandbox): + """S7g (review R1): `running` set by a different dispatch's claim says + nothing about this prompt.""" + res, published = _run_delivery(mam_sandbox, 0, status="running") + assert published == ["error"], (published, res.stderr) + + +def test_s7h_the_real_events_log_shape_is_read(mam_sandbox): + """S7h (review N-4): publish_event nests dispatch_token under `data` and + the line is JSON, so it is parsed, not pattern-matched. A line with the + keys in another order, and a token in either place, both count.""" + tmp_path = mam_sandbox + jobs = _seed_job(tmp_path, "job1") + real = {"schema_version": 1, "seq": 1, "job_id": "job1", "event": "started", + "timestamp": "2026-09-22T08:04:26Z", "detail": "", + "data": {"dispatch_token": "tok-1", "attempt_id": 1}} + reordered = {"data": {"attempt_id": 1, "dispatch_token": "tok-1"}, "event": "started", + "job_id": "job1"} + for line in (real, reordered): + (jobs / "job1.events.log").write_text(json.dumps(line) + "\n") + res, published = _run_delivery(mam_sandbox, 0, seed_events=False) + assert "DELIVERED" in res.stdout, (line, res.stdout + res.stderr) + assert published == [], published def test_s7d_no_started_marks_the_job_but_keeps_the_session(mam_sandbox): diff --git a/tests/test_issue24_resume_dialogs.py b/tests/test_issue24_resume_dialogs.py index 544006e..19e1dd3 100644 --- a/tests/test_issue24_resume_dialogs.py +++ b/tests/test_issue24_resume_dialogs.py @@ -15,6 +15,7 @@ gets, and R4 shows the same shipped function is now reached from resume. import json import os import subprocess +import time import pytest @@ -108,8 +109,14 @@ def test_r4_the_shipped_handler_really_runs_for_a_non_claude_resume( tmp_path = mam_sandbox ws = _create_and_stop(tmp_path, "codex", "test-r4-codex") before = len(json.load(open(mock_herdr)).get("calls", [])) + t0 = time.monotonic() res = _resume(tmp_path, "codex", "test-r4-codex", ws) + elapsed = time.monotonic() - t0 assert res.returncode == 0, res.stdout + res.stderr + # Review B-1: the handler used to idle its whole 20 s timeout here because + # it had no ready tokens on the resume path. rc 0 alone cannot tell early + # return from timeout; the clock can. + assert elapsed < 12, "resume took %.1fs: the dialog handler idled to its timeout" % elapsed calls = json.load(open(mock_herdr)).get("calls", [])[before:] def _has(c, *verbs): # the mock records argv with a leading `--session ` From f7cee00dec98e9427bc1d59862e56d2cd8280e1b Mon Sep 17 00:00:00 2001 From: Godopu Date: Tue, 22 Sep 2026 14:12:18 +0000 Subject: [PATCH 09/15] fix(issue-23): the caller's server comes from HERDR_SOCKET_PATH, not only HERDR_SESSION The live trial of this branch found the cross-server verdict inert on this host: _herdr_call_is_cross_server read the caller's server from HERDR_SESSION, and herdr 0.8.2 does not export that variable to a pane of the default server - the orchestrator's usual home, and exactly the pane the #23 leak comes from. Every call was judged "not crossing", the three ids were never dropped, and all four trial onboardings ended "no started within 180s". My own pane sits in a named server and does carry HERDR_SESSION, which is how the assumption got past me. HERDR_SOCKET_PATH is exported to every pane and its layout names the server: .../.config/herdr/herdr.sock is the default server and .../.config/herdr/sessions//herdr.sock a named one - the convention `herdr session list --json` reports and the shim already uses to find a named server's socket. _herdr_caller_server prefers HERDR_SESSION when a herdr does export it, otherwise reads the socket's shape (suffix-matched, not $HOME- anchored), and yields "" for anything unrecognised so an exported scope is respected rather than guessed away. The rest of the rule is unchanged from 3bcaa39: a `default` or empty target is the ambient server, no caller server means no pane, caller == target is the same server. watcher.caller_server applies the same rule for the loop's own herdr calls. Reproduced against the live server in the trial's exact env: 10a678c answers "NOT crossing / has-session NOT FOUND"; this commit answers "caller server default / crossing / FOUND". S1c pins that env end to end with the recorder asserting the ids never reach herdr; S1d-S1h pin the verdict, the named-socket same-server and cross-server cases, HERDR_SESSION precedence, and the unrecognised layout. The 3bcaa39 tests are unchanged and still pass. Co-Authored-By: Claude Fable 5.1 --- .agents/skills/lib.sh | 51 ++++++++++++- .agents/skills/lib_py/loop/watcher.py | 31 +++++++- tests/test_issue23_24_startup_path.py | 101 +++++++++++++++++++++++++- 3 files changed, 176 insertions(+), 7 deletions(-) diff --git a/.agents/skills/lib.sh b/.agents/skills/lib.sh index c694aa8..cc3e4b4 100644 --- a/.agents/skills/lib.sh +++ b/.agents/skills/lib.sh @@ -1384,19 +1384,62 @@ EOF # live session as absent: has-session, kill-session, agent prompt and the # delegate-job preflight all answer "no such session". # +# _herdr_caller_server — the name of the server whose pane this process runs +# in; empty when it is not in a pane at all. +# +# HERDR_SESSION is used when present, but herdr 0.8.2 does not export it to a +# pane of the *default* server (the orchestrator's usual home), which is +# exactly the pane the #23 leak comes from - the live trial of this branch +# found every call judged "not crossing" for that reason. HERDR_SOCKET_PATH is +# exported to every pane, and its layout names the server: +# +# $HOME/.config/herdr/herdr.sock -> default +# $HOME/.config/herdr/sessions//herdr.sock -> +# +# (the same convention lib.sh's shim already relies on to find a named +# server's socket). Matched by shape, not by $HOME, so a redirected HOME does +# not break it. An unrecognised layout yields "" - treated as "not in a pane", +# which keeps today's behaviour rather than guessing. +_herdr_caller_server() { + if [ -n "${HERDR_SESSION:-}" ]; then + printf '%s\n' "$HERDR_SESSION" + return 0 + fi + local sock="${HERDR_SOCKET_PATH:-}" + case "$sock" in + */.config/herdr/herdr.sock) + printf 'default\n' + ;; + */.config/herdr/sessions/*/herdr.sock) + local d="${sock%/herdr.sock}" + printf '%s\n' "${d##*/}" + ;; + *) + printf '\n' + ;; + esac + return 0 +} + # True only for a call aimed at a *named* server other than the caller's own: # # * target unset or `default` (the lib.sh:97 fallback) is the shim's sentinel # for "pass no session flag", so herdr answers on the ambient server, which # for a caller inside a pane is that same caller's server - not a crossing; -# * no HERDR_SESSION means the caller is not in a pane at all, so an exported -# HERDR_WORKSPACE_ID is a deliberate scope, not a leak (H-18/H-21/H-23); +# * no caller server (_herdr_caller_server empty) means the caller is not in +# a pane at all, so an exported HERDR_WORKSPACE_ID is a deliberate scope, +# not a leak (H-18/H-21/H-23); # * caller == target is the same server. +# +# A caller in the default server's pane targeting a named server IS a crossing +# (the reported #23 scenario): `default` is a real server name on the caller's +# side, only as a *target* is it the "ambient" sentinel. _herdr_call_is_cross_server() { - local caller="${HERDR_SESSION:-}" target="${HERDR_SESSION_NAME:-}" + local caller target="${HERDR_SESSION_NAME:-}" case "$target" in ""|default) return 1 ;; esac + caller="$(_herdr_caller_server)" if [ -n "$caller" ] && [ "$caller" != "$target" ]; then return 0 fi @@ -1412,7 +1455,7 @@ mam_herdr() { if _herdr_call_is_cross_server; then _scope_strip=(env -u HERDR_WORKSPACE_ID -u HERDR_PANE_ID -u HERDR_TAB_ID) if [ "${MAM_SHIM_DEBUG:-0}" = "1" ]; then - echo "mam_herdr: dropping caller pane scope ids (caller server '${HERDR_SESSION:-}' != target '${HERDR_SESSION_NAME:-}')" >&2 + echo "mam_herdr: dropping caller pane scope ids (caller server '$(_herdr_caller_server)' != target '${HERDR_SESSION_NAME:-}')" >&2 fi fi ${_scope_strip[@]+"${_scope_strip[@]}"} "$WORKSPACE_ROOT/.mam/shim/herdr" "$@" diff --git a/.agents/skills/lib_py/loop/watcher.py b/.agents/skills/lib_py/loop/watcher.py index e33ff6e..f318f0e 100644 --- a/.agents/skills/lib_py/loop/watcher.py +++ b/.agents/skills/lib_py/loop/watcher.py @@ -47,15 +47,42 @@ BACKOFF_MAX_S = 16.0 CALLER_SCOPE_VARS = ("HERDR_WORKSPACE_ID", "HERDR_PANE_ID", "HERDR_TAB_ID") +def caller_server(environ=None): + """The server whose pane this process runs in, "" when not in a pane. + + HERDR_SESSION when present; otherwise the shape of HERDR_SOCKET_PATH, which + herdr exports to every pane (HERDR_SESSION is absent in a default-server + pane on herdr 0.8.2 - the orchestrator's usual home, and where the #23 leak + comes from). Same convention as lib.sh's _herdr_caller_server: + + .../.config/herdr/herdr.sock -> "default" + .../.config/herdr/sessions//herdr.sock -> + + Matched by shape, not by $HOME. Anything else is "" (not in a pane). + """ + src = os.environ if environ is None else environ + sess = (src.get("HERDR_SESSION") or "").strip() + if sess: + return sess + sock = (src.get("HERDR_SOCKET_PATH") or "").strip() + parts = sock.split("/") + if len(parts) >= 4 and parts[-1] == "herdr.sock": + if parts[-3:-1] == [".config", "herdr"]: + return "default" + if len(parts) >= 5 and parts[-4:-2] == ["herdr", "sessions"] and parts[-5] == ".config": + return parts[-2] + return "" + + def scoped_env(session=None, environ=None): """The environment for a wait aimed at `session`. Unchanged unless this process runs inside a pane of another server: only then are the caller's own workspace/pane/tab ids meaningless for the call - and dropped. No pane (no HERDR_SESSION) and same-server keep every value. + and dropped. No pane and same-server keep every value. """ src = os.environ if environ is None else environ - caller = (src.get("HERDR_SESSION") or "").strip() + caller = caller_server(src) target = (session or "").strip() # `default` is the same sentinel build_argv reads: no --session flag, so # herdr answers on the ambient server - the caller's own. diff --git a/tests/test_issue23_24_startup_path.py b/tests/test_issue23_24_startup_path.py index 9b74104..ce9c999 100644 --- a/tests/test_issue23_24_startup_path.py +++ b/tests/test_issue23_24_startup_path.py @@ -35,7 +35,7 @@ def _run_lib(tmp_path, body, env=None): script = 'source "%s"\n_init_herdr_isolation\n%s\n' % (lib_path, body) run_env = dict(os.environ) for var in ("HERDR_SESSION", "HERDR_SESSION_NAME", "HERDR_WORKSPACE_ID", - "HERDR_PANE_ID", "HERDR_TAB_ID"): + "HERDR_PANE_ID", "HERDR_TAB_ID", "HERDR_SOCKET_PATH"): run_env.pop(var, None) run_env.update(env or {}) return subprocess.run(["bash", "-c", script], capture_output=True, @@ -110,6 +110,16 @@ CROSS = {"HERDR_SESSION": "default", "HERDR_SESSION_NAME": "mam-isolated", "HERDR_WORKSPACE_ID": "w5", "HERDR_PANE_ID": "w5:p2", "HERDR_TAB_ID": "w5:t1"} +# The orchestrator pane as the live trial of this branch measured it (herdr +# 0.8.2): no HERDR_SESSION at all, the default server's socket, and that +# server's own ids. This is the env in which the HERDR_SESSION-based verdict +# was always "not crossing" and #23 reproduced unchanged. +DEFAULT_SOCK = "/home/someone/.config/herdr/herdr.sock" +NAMED_SOCK = "/home/someone/.config/herdr/sessions/%s/herdr.sock" +TRIAL_PANE = {"HERDR_SOCKET_PATH": DEFAULT_SOCK, "HERDR_WORKSPACE_ID": "w6", + "HERDR_PANE_ID": "w6:p1", "HERDR_TAB_ID": "w6:t1", + "HERDR_SESSION_NAME": "multi-agent-mux"} + def test_s1_cross_server_call_drops_the_caller_panes_scope_ids( mam_sandbox, mock_herdr, mock_agents, monkeypatch): @@ -190,6 +200,83 @@ def test_s2b_a_caller_outside_any_pane_keeps_the_explicit_scope( assert res.returncode == 0, res.stdout + res.stderr +def test_s1c_the_real_default_server_pane_is_a_crossing( + mam_sandbox, mock_herdr, mock_agents, monkeypatch): + """S1c (trial finding, req. 4): the caller's server comes from the socket + path when HERDR_SESSION is absent. In the measured orchestrator pane the + three ids must be dropped and the agent on the named server found.""" + tmp_path = mam_sandbox + env_log = _install_env_recorder(tmp_path, monkeypatch) + _seed_agent(mock_herdr, tmp_path, "trial-agy", "w1", "w1:p5") + + res = _run_lib(tmp_path, 'herdr has-session -t trial-agy', env=TRIAL_PANE) + assert res.returncode == 0, "the trial's exact failure: " + res.stdout + res.stderr + + calls = _recorded(env_log) + assert calls, "the recorder saw no herdr call" + for call in calls: + assert call["HERDR_WORKSPACE_ID"] is None, call + assert call["HERDR_PANE_ID"] is None, call + assert call["HERDR_TAB_ID"] is None, call + + +def test_s1d_the_verdict_itself_on_the_trial_pane(mam_sandbox): + """S1d: the function the trial printed, in the trial's env: crossing.""" + res = _run_lib(mam_sandbox, + 'echo "caller=$(_herdr_caller_server)"\n' + 'if _herdr_call_is_cross_server; then echo VERDICT=crossing; ' + 'else echo VERDICT=not-crossing; fi', + env=TRIAL_PANE) + assert "caller=default" in res.stdout, res.stdout + res.stderr + assert "VERDICT=crossing" in res.stdout, res.stdout + res.stderr + + +def test_s1e_a_named_server_pane_calling_its_own_server_keeps_its_scope( + mam_sandbox, mock_herdr, mock_agents): + """S1e (req. 4): a caller in a named server's pane (socket under + sessions//) targeting that same server is not a crossing - the + exported workspace id still scopes, as in S2.""" + tmp_path = mam_sandbox + _seed_agent(mock_herdr, tmp_path, "creator-agy-01", "w1", "w1:p5") + same = {"HERDR_SOCKET_PATH": NAMED_SOCK % "mam-isolated", + "HERDR_SESSION_NAME": "mam-isolated", "HERDR_WORKSPACE_ID": "w2"} + res = _run_lib(tmp_path, 'echo "caller=$(_herdr_caller_server)"\nherdr has-session -t creator-agy-01', + env=same) + assert "caller=mam-isolated" in res.stdout, res.stdout + res.stderr + assert res.returncode == 1, "same server: the explicit w2 scope must still hide a w1 agent" + + +def test_s1f_a_named_server_pane_calling_another_named_server_is_a_crossing( + mam_sandbox, mock_herdr, mock_agents): + """S1f: named socket, different named target, no HERDR_SESSION - crossing.""" + tmp_path = mam_sandbox + _seed_agent(mock_herdr, tmp_path, "creator-agy-01", "w1", "w1:p5") + other = {"HERDR_SOCKET_PATH": NAMED_SOCK % "mam-other", + "HERDR_SESSION_NAME": "mam-isolated", "HERDR_WORKSPACE_ID": "w2"} + res = _run_lib(tmp_path, 'herdr has-session -t creator-agy-01', env=other) + assert res.returncode == 0, res.stdout + res.stderr + + +def test_s1g_herdr_session_wins_over_the_socket_when_both_are_present(mam_sandbox): + """S1g (req. 1): HERDR_SESSION, when a herdr exports it, is authoritative + over the socket-derived name.""" + res = _run_lib(mam_sandbox, 'echo "caller=$(_herdr_caller_server)"', + env={"HERDR_SESSION": "from-herdr", "HERDR_SOCKET_PATH": DEFAULT_SOCK}) + assert "caller=from-herdr" in res.stdout, res.stdout + res.stderr + + +def test_s1h_an_unrecognised_socket_layout_is_not_in_a_pane(mam_sandbox, mock_herdr, mock_agents): + """S1h: a socket path that is neither layout yields no caller server, so + an exported scope is respected (today's behaviour), never guessed away.""" + tmp_path = mam_sandbox + _seed_agent(mock_herdr, tmp_path, "creator-agy-01", "w1", "w1:p5") + res = _run_lib(tmp_path, 'echo "caller=[$(_herdr_caller_server)]"\nherdr has-session -t creator-agy-01', + env={"HERDR_SOCKET_PATH": "/tmp/odd/herdr.sock", + "HERDR_SESSION_NAME": "mam-isolated", "HERDR_WORKSPACE_ID": "w2"}) + assert "caller=[]" in res.stdout, res.stdout + res.stderr + assert res.returncode == 1, res.stdout + res.stderr + + def test_s3_shim_contract_regressions_still_pass(mam_sandbox): """S3: H-18 / H-21 / H-23 are the tests that fix today's scoping meaning. Run them explicitly as this change's regression guard.""" @@ -291,6 +378,18 @@ def test_s13_the_loop_watcher_applies_the_same_rule(mam_sandbox): none_target = watcher.scoped_env(None, environ=caller_pane) assert none_target["HERDR_WORKSPACE_ID"] == "w5" + # trial finding: no HERDR_SESSION, the caller's server comes from the socket + trial = {"HERDR_SOCKET_PATH": DEFAULT_SOCK, "HERDR_WORKSPACE_ID": "w6", + "HERDR_PANE_ID": "w6:p1", "HERDR_TAB_ID": "w6:t1"} + assert watcher.caller_server(trial) == "default" + assert "HERDR_WORKSPACE_ID" not in watcher.scoped_env("multi-agent-mux", environ=trial) + named_same = {"HERDR_SOCKET_PATH": NAMED_SOCK % "iso-x", "HERDR_WORKSPACE_ID": "w2"} + assert watcher.caller_server(named_same) == "iso-x" + assert watcher.scoped_env("iso-x", environ=named_same)["HERDR_WORKSPACE_ID"] == "w2" + assert "HERDR_WORKSPACE_ID" not in watcher.scoped_env("iso-y", environ=named_same) + assert watcher.caller_server({"HERDR_SOCKET_PATH": "/tmp/odd/herdr.sock"}) == "" + assert watcher.caller_server({"HERDR_SESSION": "s", "HERDR_SOCKET_PATH": DEFAULT_SOCK}) == "s" + seen = {} def _run(argv, **kw): From 1667fda8dd64e1ba89f57059bc62e57718435f09 Mon Sep 17 00:00:00 2001 From: Godopu Date: Tue, 22 Sep 2026 14:42:38 +0000 Subject: [PATCH 10/15] fix(issue-23): the onboarding ack is satisfied by what a real agent publishes; one command builder and one started predicate for create and the wrapper Trial 2 of this branch: cross-server delivery was restored (f7cee00), the agent completed its onboarding and published started and completed - and create_session still waited 180 s, called the onboarding unconfirmed, and tried to write `error` over the completed job (refused by the registry). delegate_wait_started required the started line to carry data.dispatch_token (review R1: a stale attempt's started must not vouch for this one). But publish_event only writes that field when its command line carries --expect-dispatch-token, and create_session's hand-rolled onboarding command never did - the wrapper's _pub_cmd does, which is why every wrapper-dispatched agent's started has the token and no create_session onboarding ever could. Five reviewers missed it because the fixture wrote the token into the started line by hand: a fake producer that differs from the real one. Fix, along the wrapper's existing mechanism rather than a new one: * delegate_publish_cmd (lib.sh) builds the publish command for an agent, with --expect-dispatch-token and --attempt-id, and create_session uses it. The agent's own started now names the dispatch, and publish_event's CAS check refuses a stale dispatch's event at the source. S7l pins the builder argument-for-argument against the wrapper's _pub_cmd. * delegate_started_recorded (lib.sh) is the one definition of "this dispatch's started is on disk" - JSON-parsed, token-matched when a token is given - used by delegate_wait_started and by the wrapper's _wait_started in place of its regex. The wrapper keeps its status and subscriber routes (remote agents); the shared part is the evidence contract. * An already-terminal job is left alone when the wait expires (req. 4): no error over a completed/error/cancelled record. The fixture now registers the record through registry.py and publishes started through the real publish_event.py; the only synthetic line left (S7h) exists to pin key-order independence and says so. S7i pins the trial-2 defect as the negative it is (old command shape -> started without token -> not acknowledged); S7j runs the builder's command through the real producer and is acknowledged on the first probe; S7n shows the wrapper accepting the same real line and rejecting a stale token; S7m pins no-write-over-terminal. S7f moves from "publish error" to "leave the terminal job alone", per req. 4. Option B (verify data.hmac_sig with the job's auth_token, plus record-token still equal) was weighed and not taken: verify_hmac returns True with no auth_token - PoC mode, which the trial and every sandbox run in - so it proves nothing there; in production hmac_sig authenticates the signer but does not identify the dispatch, so R1 would rest on a current-state check that a stale line plus an unrotated token passes; and it would diverge from the wrapper unless the wrapper changed too. Token exposure is not new: every wrapper- dispatched instruction already carries the dispatch token in the pane, and it is a per-dispatch CAS nonce, not the HMAC secret. Co-Authored-By: Claude Fable 5.1 --- .agents/skills/lib.sh | 110 ++++++--- .../scripts/create_session.sh | 40 +-- .../multi-agent-mux-delegate-job | 9 +- tests/test_issue23_24_startup_path.py | 230 +++++++++++++++--- 4 files changed, 303 insertions(+), 86 deletions(-) diff --git a/.agents/skills/lib.sh b/.agents/skills/lib.sh index cc3e4b4..8f5e99d 100644 --- a/.agents/skills/lib.sh +++ b/.agents/skills/lib.sh @@ -2173,37 +2173,66 @@ delegate_publish_event() { _delegation_py publish --job "$job_id" --event "$event" --detail "$detail" ${extra[@]+"${extra[@]}"} || true } -# delegate_wait_started [ack_timeout_s] [dispatch_token] -# -# ISSUE-23 / ISSUE-18: delivery is confirmed by the protocol, not by the -# screen. `agent prompt --wait --until working` failing two seconds after the -# keys were handed over says nothing about a TUI that is still starting up - -# that is exactly what made create_session --onboard call a healthy agent a -# failure. The evidence is the agent's own `started`: the registry record -# leaves `pending` (publish_event syncs it on this host) or a matching started -# line lands in the job's events.log, which publish_event writes before its -# wire publish so a slow broker cannot delay the verdict. -# -# Returns 0 on that evidence, 1 if it did not appear within T. Never -# re-injects: the single-injection contract is fail-closed. -# -# The evidence is a `started` line in the job's events.log carrying THIS -# dispatch's token (nested under `data`, as publish_event writes it; the line -# is parsed as JSON, not pattern-matched, so key order is irrelevant). The -# registry's status is deliberately NOT evidence: a record can leave `pending` -# because another writer closed it (`error`, `cancelled`) or because a -# different dispatch is `running`, none of which says this agent saw this -# prompt (review R1). The local publisher writes the log line before it syncs -# the status, so nothing that really started is missed. -delegate_wait_started() { - local job_id="$1" ack_s="${2:-180}" tok="${3:-}" - [ -n "$job_id" ] || return 1 +# delegate_publish_cmd [dispatch_token] [attempt_id] +# The publish command an agent is told to run, shell-quoted, with absolute +# script/registry/audit-log paths (Issue #21: an agent whose cwd is a worktree +# runs it as written). ONE builder for create_session's onboarding and the +# delegate-job wrapper's dispatch, so both hand the agent the same shape: +# `--expect-dispatch-token T --attempt-id A` make publish_event write +# data.dispatch_token / data.attempt_id into the agent's own events, which is +# the only way a `started` can name the dispatch it answers (the trial-2 +# finding: create_session's hand-rolled command omitted them, the agent's +# `started` carried no token, and the token-checking waiter could never be +# satisfied by a real agent). publish_event also CAS-checks that token against +# the record, so a stale dispatch's event is refused at the source. +delegate_publish_cmd() { + local job_id="$1" tok="${2:-}" att="${3:-}" + local py_bin pub_script pub_reg pub_logs + py_bin="$(_delegate_py_bin)" + pub_script="$WORKSPACE_ROOT/.agents/skills/multi-agent-mux-delegate-job/scripts/publish_event.py" + [ -f "$pub_script" ] || pub_script="$(_delegate_script publish_event.py)" + pub_script="$(cd -P "$(dirname "$pub_script")" && pwd -P)/$(basename "$pub_script")" + pub_reg="${MAM_JOBS_DIR:-${WORKSPACE_ROOT:+$WORKSPACE_ROOT/.mam/jobs}}" + pub_reg="${pub_reg:-.mam/jobs}" + mkdir -p "$pub_reg" && pub_reg="$(cd -P "$pub_reg" && pwd -P)" + pub_logs="$(PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" "$py_bin" -c 'import os; from lib_py.delegation.mqtt_common import get_logs_dir; print(os.path.abspath(get_logs_dir()))' 2>/dev/null || true)" + local -a argv=("$py_bin" "$pub_script" --registry-dir "$pub_reg" --job "$job_id") + [ -n "$tok" ] && argv+=(--expect-dispatch-token "$tok") + [ -n "$att" ] && argv+=(--attempt-id "$att") + local out + out="$( + if [ -n "$pub_logs" ]; then printf 'DELEGATE_JOB_LOGS_DIR=%q ' "$pub_logs"; fi + printf '%q ' "${argv[@]}" + )" + printf '%s' "${out% }" +} + +# delegate_job_status — the record's status, empty if unreadable. +delegate_job_status() { + local reg_dir="${MAM_JOBS_DIR:-${WORKSPACE_ROOT:-$PWD}/.mam/jobs}" + REC="$reg_dir/$1.json" python3 -c ' +import json, os +try: + print(json.load(open(os.environ["REC"])).get("status", "") or "") +except Exception: + print("") +' 2>/dev/null || true +} + +# delegate_started_recorded [dispatch_token] +# THE definition of "this dispatch's started is on disk", shared by +# delegate_wait_started (create_session's onboarding ack) and the delegate-job +# wrapper's _wait_started: a `started` line in the job's events.log whose +# data.dispatch_token equals when one is given (a loop job +# keeps one events.log across attempts, and a stale attempt's started must not +# vouch for this one - review R1). The line is parsed as JSON, never +# pattern-matched. rc 0 = recorded, 1 = not (yet). +delegate_started_recorded() { + local job_id="$1" tok="${2:-}" local reg_dir="${MAM_JOBS_DIR:-${WORKSPACE_ROOT:-$PWD}/.mam/jobs}" local ev_log="$reg_dir/$job_id.events.log" - local deadline - deadline=$(( $(date +%s) + ack_s )) - while :; do - if [ -f "$ev_log" ] && EV_LOG="$ev_log" EV_TOK="$tok" python3 -c ' + [ -f "$ev_log" ] || return 1 + EV_LOG="$ev_log" EV_TOK="$tok" python3 -c ' import json, os, sys tok = os.environ.get("EV_TOK", "") with open(os.environ["EV_LOG"], encoding="utf-8", errors="replace") as f: @@ -2215,11 +2244,28 @@ with open(os.environ["EV_LOG"], encoding="utf-8", errors="replace") as f: if not isinstance(e, dict) or e.get("event") != "started": continue data = e.get("data") if isinstance(e.get("data"), dict) else {} - seen = data.get("dispatch_token") or e.get("dispatch_token") or "" - if not tok or seen == tok: + if not tok or data.get("dispatch_token") == tok: sys.exit(0) sys.exit(1) -' 2>/dev/null; then +' 2>/dev/null +} + +# delegate_wait_started [ack_timeout_s] [dispatch_token] +# +# ISSUE-23 / ISSUE-18: delivery is confirmed by the protocol, not by the +# screen. The evidence is delegate_started_recorded (above); the registry's +# status is deliberately NOT evidence - a record leaves `pending` when another +# writer closes it or another dispatch claims it (review R1). The local +# publisher writes the log line before it syncs the status, so nothing that +# really started is missed. Returns 0 on the evidence, 1 if it did not appear +# within T. Never re-injects: the single-injection contract is fail-closed. +delegate_wait_started() { + local job_id="$1" ack_s="${2:-180}" tok="${3:-}" + [ -n "$job_id" ] || return 1 + local deadline + deadline=$(( $(date +%s) + ack_s )) + while :; do + if delegate_started_recorded "$job_id" "$tok"; then return 0 fi if [ "$(date +%s)" -ge "$deadline" ]; then diff --git a/.agents/skills/multi-agent-mux-create/scripts/create_session.sh b/.agents/skills/multi-agent-mux-create/scripts/create_session.sh index 1d9cbfc..79e4454 100755 --- a/.agents/skills/multi-agent-mux-create/scripts/create_session.sh +++ b/.agents/skills/multi-agent-mux-create/scripts/create_session.sh @@ -614,22 +614,12 @@ echo "herdr session: $SESSION_NAME (pane pid $PANE_PID, cmd $PANE_CMD, cwd $PANE if [ -n "$DELEGATE_JOB_ID" ]; then echo "delegate job: $DELEGATE_JOB_ID" - # Construct instructions matching multi-agent-mux-delegate-job format - py_bin="$(_delegate_py_bin)" - # Issue #21: absolute script, registry and audit-log paths (shell-quoted), so - # an agent whose cwd is a git worktree can run the command as written. - pub_script="$WORKSPACE_ROOT/.agents/skills/multi-agent-mux-delegate-job/scripts/publish_event.py" - [ -f "$pub_script" ] || pub_script="$(_delegate_script publish_event.py)" - pub_script="$(cd -P "$(dirname "$pub_script")" && pwd -P)/$(basename "$pub_script")" - pub_reg="${MAM_JOBS_DIR:-${WORKSPACE_ROOT:+$WORKSPACE_ROOT/.mam/jobs}}" - pub_reg="${pub_reg:-.mam/jobs}" - mkdir -p "$pub_reg" && pub_reg="$(cd -P "$pub_reg" && pwd -P)" - pub_logs="$(PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" "$py_bin" -c 'import os; from lib_py.delegation.mqtt_common import get_logs_dir; print(os.path.abspath(get_logs_dir()))' 2>/dev/null || true)" - pub="$( - if [ -n "$pub_logs" ]; then printf 'DELEGATE_JOB_LOGS_DIR=%q ' "$pub_logs"; fi - printf '%q ' "$py_bin" "$pub_script" --registry-dir "$pub_reg" --job "$DELEGATE_JOB_ID" - )" - pub="${pub% }" + # The publish command the agent will run: the same shape the delegate-job + # wrapper hands its agents, from the one builder in lib.sh. The dispatch + # token and attempt id are what make the agent's own `started` name THIS + # dispatch (trial-2 finding: without them the token-checking ack below could + # never be satisfied by a real agent, and every onboarding sat out 180 s). + pub="$(delegate_publish_cmd "$DELEGATE_JOB_ID" "$DELEGATE_JOB_TOKEN" "$DELEGATE_JOB_ATTEMPT")" instructions="Your job_id is \"$DELEGATE_JOB_ID\" (the one just registered for THIS delegation). On start run: $pub --event started. @@ -675,10 +665,22 @@ Task: $SUBMIT_JOB_PROMPT" # not rolled back. So: mark the job, warn loudly, keep the session. # MULTI_AGENT_RULES §6 has the GM gate on this job's `completed` anyway, # so an unacknowledged onboarding already blocks real work. - delegate_publish_event "$DELEGATE_JOB_ID" error "onboarding delivery unconfirmed: no started within ${ONBOARD_ACK_S}s" "$DELEGATE_JOB_TOKEN" "$DELEGATE_JOB_ATTEMPT" + # A job that is already terminal is not marked again: `error` over a + # `completed` is refused by the registry anyway (trial-2 log: "conflicting + # error after terminal completed"), and a job the agent has finished is + # not an undelivered one - only the evidence was missing. + ONBOARD_JOB_ST="$(delegate_job_status "$DELEGATE_JOB_ID")" + case "$ONBOARD_JOB_ST" in + completed|error|cancelled) + echo "WARNING: no started event naming this dispatch for '$SESSION_NAME' within ${ONBOARD_ACK_S}s, but job $DELEGATE_JOB_ID is already '$ONBOARD_JOB_ST'; leaving it as it is." >&2 + ;; + *) + delegate_publish_event "$DELEGATE_JOB_ID" error "onboarding delivery unconfirmed: no started within ${ONBOARD_ACK_S}s" "$DELEGATE_JOB_TOKEN" "$DELEGATE_JOB_ATTEMPT" + echo "WARNING: '$SESSION_NAME' did not acknowledge the onboarding prompt within ${ONBOARD_ACK_S}s." >&2 + echo " The session is preserved and job $DELEGATE_JOB_ID is marked error; re-send the onboarding with multi-agent-mux-delegate-job submit once the agent is up." >&2 + ;; + esac JOB_TERMINALIZED=1 - echo "WARNING: '$SESSION_NAME' did not acknowledge the onboarding prompt within ${ONBOARD_ACK_S}s." >&2 - echo " The session is preserved and job $DELEGATE_JOB_ID is marked error; re-send the onboarding with multi-agent-mux-delegate-job submit once the agent is up." >&2 fi # Trigger immediate priority reconcile cycle asynchronously # Issue #21 phase 2 (A8): the control root's reconcile/watchdog, never the diff --git a/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job b/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job index 5347d02..2559b77 100755 --- a/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job +++ b/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job @@ -646,11 +646,10 @@ _wait_started() { # The ack wait never outlives this dispatch's wall clock (START_TIME + TIMEOUT). wall=$(( ${START_TIME:-$(date +%s)} + ${TIMEOUT:-3600} )) [ "$deadline" -gt "$wall" ] && deadline=$wall - local ev_log="$REGISTRY_DIR/$job_id.events.log" # A loop job keeps one events.log across iterations: only a `started` that - # carries THIS attempt's dispatch token counts (review R1). - local started_re='"event": *"started"' - [ -n "${_disp_tok:-}" ] && started_re='"event": *"started".*"dispatch_token": *"'"$_disp_tok"'"' + # carries THIS attempt's dispatch token counts (review R1). The predicate is + # lib.sh's delegate_started_recorded - the same one create_session's + # onboarding ack uses, so the two waits share one definition of evidence. local budget=$(( deadline - $(date +%s) )) while :; do st="$(_disk_status "$job_id")" @@ -660,7 +659,7 @@ _wait_started() { fi # publish_event records `started` here before its wire publish and before # the status sync, so a slow broker cannot delay the acknowledgement. - if [ -f "$ev_log" ] && grep -Eq "$started_re" "$ev_log" 2>/dev/null; then + if MAM_JOBS_DIR="$REGISTRY_DIR" delegate_started_recorded "$job_id" "${_disp_tok:-}"; then echo "inject: job $job_id recorded 'started' in events.log; treating the prompt as delivered and continuing." >&2 return 0 fi diff --git a/tests/test_issue23_24_startup_path.py b/tests/test_issue23_24_startup_path.py index ce9c999..9fb39a4 100644 --- a/tests/test_issue23_24_startup_path.py +++ b/tests/test_issue23_24_startup_path.py @@ -615,19 +615,64 @@ def _extract(path, start_marker, end_marker, close=None): return "".join(lines[start:end + 1]) -def _seed_job(tmp_path, job_id, status="pending", started_token=None): +SKILLS = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + ".agents", "skills") +REGISTRY_PY = os.path.join(SKILLS, "multi-agent-mux-delegate-job", "scripts", "registry.py") +PUBLISH_PY = os.path.join(SKILLS, "multi-agent-mux-delegate-job", "scripts", "publish_event.py") + + +def _producer_env(tmp_path): + env = dict(os.environ) + env.pop("MAM_ENV_FILE", None) # PoC mode, no broker: local-first is enough + env["HOME"] = str(tmp_path) + env["DELEGATE_JOB_LOGS_DIR"] = str(tmp_path / ".mam" / "delegate_job_logs") + return env + + +def _seed_job(tmp_path, job_id="job1", status="pending", started_token=None, + publish_args=None): + """A job record made by the real registry and, when asked, a `started` + published by the REAL publish_event.py - never a hand-written line. + + Trial 2 found the previous fixture writing `dispatch_token` into the + started line itself, a field the real producer only writes when its + command line carries --expect-dispatch-token. A fake producer that differs + from the real one let five reviewers pass a waiter no real agent could + satisfy. `started_token` here is what the *command line* passes; the line + is whatever publish_event makes of it. `publish_args` runs an arbitrary + publish instead (e.g. the old create_session command shape). + """ jobs = tmp_path / ".mam" / "jobs" jobs.mkdir(parents=True, exist_ok=True) - (jobs / ("%s.json" % job_id)).write_text(json.dumps( - {"job_id": job_id, "status": status, "dispatch_token": "tok-1", - "attempt_id": 1, "ack_timeout_sec": 2})) - ev = jobs / ("%s.events.log" % job_id) - if started_token is None: - if ev.exists(): - ev.unlink() - else: - ev.write_text(json.dumps( - {"job": job_id, "event": "started", "dispatch_token": started_token}) + "\n") + # a seed is a fresh record: the real registry refuses to re-register an + # existing id, and several cases seed the same id more than once + for stale in (jobs / ("%s.json" % job_id), jobs / ("%s.events.log" % job_id)): + if stale.exists(): + stale.unlink() + env = _producer_env(tmp_path) + reg = subprocess.run([sys.executable, REGISTRY_PY, "--registry-dir", str(jobs), "register", + "--prompt", "onboarding", "--agent", "claude-code", + "--agent-session", "herdr:x", "--dispatch-token", "tok-1", + "--job-id", job_id, "--ack-timeout", "2"], + capture_output=True, text=True, env=env) + assert reg.returncode == 0, reg.stdout + reg.stderr + if started_token is not None or publish_args is not None: + cmd = [sys.executable, PUBLISH_PY, "--registry-dir", str(jobs), "--job", job_id, + "--event", "started"] + if publish_args is not None: + cmd += list(publish_args) + else: + cmd += ["--expect-dispatch-token", started_token, "--attempt-id", "1"] + pub = subprocess.run(cmd, capture_output=True, text=True, env=env, timeout=60) + # a mismatched token is refused by the producer's own CAS check; that + # is fine here - the point is what (if anything) reached the log + if started_token in (None, "tok-1"): + assert pub.returncode == 0, pub.stdout + pub.stderr + if status != "pending": + rec = jobs / ("%s.json" % job_id) + d = json.loads(rec.read_text()) + d["status"] = status + rec.write_text(json.dumps(d)) return jobs @@ -687,11 +732,15 @@ def test_s7c_a_started_that_was_followed_by_a_terminal_status_still_counts(mam_s @pytest.mark.parametrize("status", ["error", "cancelled"]) def test_s7f_a_job_closed_by_someone_else_is_not_an_acknowledgement(mam_sandbox, status): - """S7f (review R1): a record can leave `pending` because another writer - closed it. No started line from this agent means no delivery.""" + """S7f (review R1 + req. 4): a record can leave `pending` because another + writer closed it. That is not evidence - the wait runs to its deadline + rather than returning early (the 'no started event naming this dispatch' + path, never the DELIVERED-without-warning path of S7c) - and, the job + being already terminal, nothing is published over it (req. 4).""" res, published = _run_delivery(mam_sandbox, 0, status=status) - assert published == ["error"], (status, published, res.stderr) - assert "did not acknowledge the onboarding prompt" in res.stderr + assert published == [], (status, published, res.stderr) + assert "no started event naming this dispatch" in res.stderr, res.stderr + assert "already '%s'" % status in res.stderr, res.stderr def test_s7g_another_dispatch_running_is_not_this_dispatchs_started(mam_sandbox): @@ -701,22 +750,127 @@ def test_s7g_another_dispatch_running_is_not_this_dispatchs_started(mam_sandbox) assert published == ["error"], (published, res.stderr) -def test_s7h_the_real_events_log_shape_is_read(mam_sandbox): - """S7h (review N-4): publish_event nests dispatch_token under `data` and - the line is JSON, so it is parsed, not pattern-matched. A line with the - keys in another order, and a token in either place, both count.""" +def test_s7h_the_line_is_parsed_not_pattern_matched(mam_sandbox): + """S7h (review N-4), the one SYNTHETIC line in this file, on purpose: the + parser must not depend on key order. Every other started line here comes + from the real producer (S7b/S7c/S7e/S8, S7i-S7k).""" tmp_path = mam_sandbox jobs = _seed_job(tmp_path, "job1") - real = {"schema_version": 1, "seq": 1, "job_id": "job1", "event": "started", - "timestamp": "2026-09-22T08:04:26Z", "detail": "", - "data": {"dispatch_token": "tok-1", "attempt_id": 1}} reordered = {"data": {"attempt_id": 1, "dispatch_token": "tok-1"}, "event": "started", "job_id": "job1"} - for line in (real, reordered): - (jobs / "job1.events.log").write_text(json.dumps(line) + "\n") - res, published = _run_delivery(mam_sandbox, 0, seed_events=False) - assert "DELIVERED" in res.stdout, (line, res.stdout + res.stderr) - assert published == [], published + (jobs / "job1.events.log").write_text(json.dumps(reordered) + "\n") + res, published = _run_delivery(mam_sandbox, 0, seed_events=False) + assert "DELIVERED" in res.stdout, res.stdout + res.stderr + assert published == [], published + + +# ---- trial-2 regression: the real agent must be able to satisfy the ack ---- + +def test_s7i_the_old_create_session_command_shape_could_never_be_acknowledged(mam_sandbox): + """S7i (trial 2): the command create_session used to hand the agent had no + --expect-dispatch-token, so the REAL producer wrote a started with no + token, and a token-checking waiter never accepted it: 180 s idle, then a + false 'unconfirmed' over a finished onboarding. Pinned as the negative it + is, so the fixture can never drift back to faking the token.""" + tmp_path = mam_sandbox + jobs = _seed_job(tmp_path, "job1", publish_args=[]) # old shape: no token args + line = json.loads((jobs / "job1.events.log").read_text().splitlines()[0]) + assert line["event"] == "started" and "dispatch_token" not in line.get("data", {}), line + res, published = _run_delivery(mam_sandbox, 0, seed_events=False) + assert published == ["error"], (published, res.stderr) + + +def test_s7j_the_shared_builders_command_is_acknowledged_by_the_real_producer(mam_sandbox): + """S7j (trial 2, the fix): run exactly the command delegate_publish_cmd + hands the agent, through the real publish_event.py, and the waiter is + satisfied on the first probe.""" + tmp_path = mam_sandbox + jobs = _seed_job(tmp_path, "job1") + lib = tmp_path / ".agents" / "skills" / "lib.sh" + env = _producer_env(tmp_path) + env["MAM_JOBS_DIR"] = str(jobs) + env["WORKSPACE_ROOT"] = str(tmp_path) + script = ('source "%s"\n' + 'cmd="$(delegate_publish_cmd job1 tok-1 1)"\n' + 'echo "CMD=$cmd"\n' + 'eval "$cmd --event started" >/dev/null\n' + 'delegate_started_recorded job1 tok-1 && echo RECORDED\n') % lib + res = subprocess.run(["bash", "-c", script], capture_output=True, text=True, + cwd=str(tmp_path), env=env, timeout=120) + assert "RECORDED" in res.stdout, res.stdout + res.stderr + cmd = res.stdout.split("CMD=")[1].splitlines()[0] + assert "--expect-dispatch-token tok-1" in cmd and "--attempt-id 1" in cmd, cmd + line = json.loads((jobs / "job1.events.log").read_text().splitlines()[-1]) + assert line["data"]["dispatch_token"] == "tok-1", line + + +def test_s7k_create_session_hands_the_agent_the_builders_command(mam_sandbox): + """S7k: the onboarding instruction is built by delegate_publish_cmd with + the job's token and attempt id - not by a hand-rolled copy that can drift.""" + src = open(_create_sh(mam_sandbox)).read() + assert 'pub="$(delegate_publish_cmd "$DELEGATE_JOB_ID" "$DELEGATE_JOB_TOKEN" "$DELEGATE_JOB_ATTEMPT")"' in src + assert 'printf \'%q \' "$py_bin" "$pub_script" --registry-dir' not in src + + +def test_s7l_the_builder_matches_the_wrappers_pub_cmd_argument_for_argument(mam_sandbox): + """S7l (req. 2, one contract): the wrapper's _pub_cmd and lib.sh's + delegate_publish_cmd produce the same argv for the same job.""" + tmp_path = mam_sandbox + jobs = _seed_job(tmp_path, "job1") + lib = tmp_path / ".agents" / "skills" / "lib.sh" + wrapper = tmp_path / ".agents" / "skills" / "multi-agent-mux-delegate-job" / "multi-agent-mux-delegate-job" + env = _producer_env(tmp_path) + env.update({"MAM_JOBS_DIR": str(jobs), "WORKSPACE_ROOT": str(tmp_path), + "MAM_DELEGATE_JOB_NO_MAIN": "1"}) + script = ('source "%s"\n' + 'source "%s"\n' + 'PY="$(_delegate_py_bin)"; REGISTRY_DIR="%s"; JOB_ID=job1; LOGS_DIR_ABS="$DELEGATE_JOB_LOGS_DIR"\n' + 'echo "W=$(_pub_cmd --expect-dispatch-token tok-1 --attempt-id 1)"\n' + 'echo "L=$(delegate_publish_cmd job1 tok-1 1)"\n') % (lib, wrapper, jobs) + res = subprocess.run(["bash", "-c", script], capture_output=True, text=True, + cwd=str(tmp_path), env=env, timeout=120) + w = res.stdout.split("W=")[1].splitlines()[0].split() + l = res.stdout.split("L=")[1].splitlines()[0].split() + # same script, registry, job and token/attempt flags; the interpreter path + # may differ in spelling (the wrapper resolves it relative), so compare from + # the script name on + def tail(argv): + i = next(i for i, a in enumerate(argv) if a.endswith("publish_event.py")) + return argv[i:] + assert tail(w) == tail(l), (w, l) + + +def test_s7m_a_job_the_agent_already_finished_is_not_marked_error(mam_sandbox): + """S7m (req. 4): with no started naming this dispatch but the job already + `completed`, create_session leaves it alone - no `error` over a terminal + state (the trial-2 'refused error ... after terminal completed').""" + res, published = _run_delivery(mam_sandbox, 0, status="completed") + assert published == [], (published, res.stderr) + assert "already 'completed'" in res.stderr, res.stderr + assert "DELIVERED" in res.stdout + + +def test_s7n_the_wrappers_wait_started_accepts_the_real_producers_line(mam_sandbox): + """S7n (req. 2): the delegate-job wrapper's _wait_started, on the same + real-producer started (token in data), accepts it; on a stale token it + does not - via the shared predicate.""" + tmp_path = mam_sandbox + jobs = _seed_job(tmp_path, "job1", started_token="tok-1") + lib = tmp_path / ".agents" / "skills" / "lib.sh" + wrapper = tmp_path / ".agents" / "skills" / "multi-agent-mux-delegate-job" / "multi-agent-mux-delegate-job" + env = _producer_env(tmp_path) + env.update({"MAM_JOBS_DIR": str(jobs), "WORKSPACE_ROOT": str(tmp_path), + "MAM_DELEGATE_JOB_NO_MAIN": "1"}) + # keep the record `pending` so only the events.log route can answer + rec = jobs / "job1.json"; d = json.loads(rec.read_text()); d["status"] = "pending"; rec.write_text(json.dumps(d)) + script = ('source "%s"\nsource "%s"\n' + 'PY="$(_delegate_py_bin)"; REGISTRY_DIR="%s"; ACK_TIMEOUT=2; START_TIME=$(date +%%s); TIMEOUT=60\n' + '_disp_tok=tok-1; _wait_started job1 2 && echo ACCEPT_OWN\n' + '_disp_tok=tok-X; _wait_started job1 2 || echo REJECT_STALE\n') % (lib, wrapper, jobs) + res = subprocess.run(["bash", "-c", script], capture_output=True, text=True, + cwd=str(tmp_path), env=env, timeout=120) + assert "ACCEPT_OWN" in res.stdout, res.stdout + res.stderr + assert "REJECT_STALE" in res.stdout, res.stdout + res.stderr def test_s7d_no_started_marks_the_job_but_keeps_the_session(mam_sandbox): @@ -733,9 +887,25 @@ def test_s7d_no_started_marks_the_job_but_keeps_the_session(mam_sandbox): def test_s7e_a_started_from_another_dispatch_is_not_evidence(mam_sandbox): """S7e: a loop job keeps one events.log across attempts, so only a started - carrying THIS dispatch token counts.""" - res, published = _run_delivery(mam_sandbox, 0, started_token="tok-OTHER") - assert published == ["error"], published + carrying THIS dispatch token counts. The stale line is made by the real + producer under the record's *previous* token, then the record is + re-dispatched (token rotated) before the waiter looks.""" + tmp_path = mam_sandbox + jobs = tmp_path / ".mam" / "jobs" + jobs.mkdir(parents=True, exist_ok=True) + env = _producer_env(tmp_path) + subprocess.run([sys.executable, REGISTRY_PY, "--registry-dir", str(jobs), "register", + "--prompt", "x", "--agent", "claude-code", "--agent-session", "herdr:x", + "--dispatch-token", "tok-OLD", "--job-id", "job1", "--ack-timeout", "2"], + check=True, capture_output=True, text=True, env=env) + subprocess.run([sys.executable, PUBLISH_PY, "--registry-dir", str(jobs), "--job", "job1", + "--event", "started", "--expect-dispatch-token", "tok-OLD", "--attempt-id", "1"], + check=True, capture_output=True, text=True, env=env, timeout=60) + rec = jobs / "job1.json" + d = json.loads(rec.read_text()); d["dispatch_token"] = "tok-1"; d["status"] = "pending" + rec.write_text(json.dumps(d)) + res, published = _run_delivery(mam_sandbox, 0, seed_events=False) + assert published == ["error"], (published, res.stderr) def test_s8_rc4_is_a_warning_and_the_started_wait_decides(mam_sandbox): From 4805fd45113df85f7f696741340f5afae78a36ef Mon Sep 17 00:00:00 2001 From: Godopu Date: Tue, 22 Sep 2026 15:32:40 +0000 Subject: [PATCH 11/15] test(issue-5): the G9 no-frozen-helper guard reads code, not comments or docstrings The full suite on 1667fda showed exactly one failure beyond this host's baseline: test_g9_phase2a_spawns_no_daemon_and_calls_no_frozen_helper. Its `\blib\.sh\b` pattern matched a docstring in watcher.py that points at the shell twin of caller_server ("Same convention as lib.sh's _herdr_caller_server") - a mention written so the two implementations stay in sync, not a call. The guard's own docstring states its claim: no frozen lib.sh helper is *invoked* from lib_py/loop. Its one prior change (803ba43) added the word boundary after `hashlib.sha256` tripped it - the same class of false positive, handled by narrowing the matcher rather than by contorting innocent code. This continues that: the three patterns and the word boundary are unchanged, but they now run over the source with comments and docstrings blanked. Every way Python could actually reach a lib.sh helper - a path or `source` in a subprocess argv, an identifier - lives in code text, which is still scanned in full, so the guard keeps its reach. test_g9b pins both sides: an invocation-shaped string in code still trips it (lib.sh and ensure_subscriber_daemon both reported), a bare lib.sh path in a list literal still trips it, and the same words in a module docstring, a function docstring and a comment do not - with hashlib.sha256 present as the original regression. Rephrasing the docstring to dodge the literal was the other option and was not taken: it would hide a useful cross-reference to satisfy a matcher, which is the evasion shape this guard should not encourage, and it would leave the next honest comment to trip the same wire. Co-Authored-By: Claude Fable 5.1 --- tests/test_issue5_phase2a_loop.py | 83 +++++++++++++++++++++++++++---- 1 file changed, 72 insertions(+), 11 deletions(-) diff --git a/tests/test_issue5_phase2a_loop.py b/tests/test_issue5_phase2a_loop.py index 1837849..f868031 100644 --- a/tests/test_issue5_phase2a_loop.py +++ b/tests/test_issue5_phase2a_loop.py @@ -692,22 +692,83 @@ def test_g9_loop_package_imports_only_delegation_among_siblings(): assert not offenders, offenders +# The G9 guard reads CODE, not prose. Its claim is that no frozen lib.sh +# helper is *invoked* from lib_py/loop; a comment or docstring that names +# lib.sh (say, to point at the shell twin of a function so the two stay in +# sync) invokes nothing. Every way Python could actually reach a lib.sh helper +# - a path or `source` inside a subprocess argv, an identifier - lives in code +# text, which is still scanned in full, so the guard loses none of its reach. +# History: it first fired on `hashlib.sha256` (a word boundary fixed that), +# then on a docstring cross-reference in watcher.py; both were mentions. +_G9_PATTERNS = { + "ensure_subscriber_daemon": r"ensure_subscriber_daemon", + "preexec_fn": r"preexec_fn", + "lib.sh": r"\blib\.sh\b", # word boundary: `hashlib.sha256` is not lib.sh +} + + +def _code_only(src): + """`src` with comments and docstrings blanked, line structure kept.""" + import ast + import io + import tokenize + + lines = src.splitlines(True) + blank = set() + tree = ast.parse(src) + for node in ast.walk(tree): + if isinstance(node, (ast.Module, ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)): + body = getattr(node, "body", None) or [] + first = body[0] if body else None + if (isinstance(first, ast.Expr) and isinstance(first.value, ast.Constant) + and isinstance(first.value.value, str)): + blank.update(range(first.lineno, first.end_lineno + 1)) + out = [] + for tok in tokenize.generate_tokens(io.StringIO(src).readline): + if tok.type == tokenize.COMMENT: + (r, c), (_, c2) = tok.start, tok.end + lines[r - 1] = lines[r - 1][:c] + " " * (c2 - c) + lines[r - 1][c2:] + for i, line in enumerate(lines, 1): + out.append("\n" if i in blank else line) + return "".join(out) + + +def _g9_offenders(src): + import re + code = _code_only(src) + return [name for name, pat in _G9_PATTERNS.items() if re.search(pat, code)] + + def test_g9_phase2a_spawns_no_daemon_and_calls_no_frozen_helper(): """The two phase 1 handoffs stay dormant: preexec_fn is unreachable because nothing here spawns, and no frozen lib.sh helper is invoked.""" - import re - - # `lib.sh` needs a word boundary: a bare substring search also matches - # hashlib.sha256, which is how this guard first fired on innocent code. - patterns = { - "ensure_subscriber_daemon": re.compile(r"ensure_subscriber_daemon"), - "preexec_fn": re.compile(r"preexec_fn"), - "lib.sh": re.compile(r"\blib\.sh\b"), - } for path in sorted((SKILLS / "lib_py" / "loop").glob("*.py")): src = path.read_text(encoding="utf-8") - for name, pattern in patterns.items(): - assert not pattern.search(src), "{} references {}".format(path.name, name) + hits = _g9_offenders(src) + assert not hits, "{} references {}".format(path.name, ", ".join(hits)) + + +def test_g9b_the_guard_still_catches_an_invocation_and_ignores_prose(): + """The narrowing must not cost the guard its purpose: an invocation-shaped + reference in code text still trips it; the same words in a docstring or a + comment do not.""" + invoked = ( + 'import subprocess\n' + 'def go():\n' + ' return subprocess.run(["bash", "-c", "source .agents/skills/lib.sh; ensure_subscriber_daemon x"])\n' + ) + assert set(_g9_offenders(invoked)) == {"lib.sh", "ensure_subscriber_daemon"} + path_only = 'CMD = ["bash", "/x/.agents/skills/lib.sh"]\n' + assert _g9_offenders(path_only) == ["lib.sh"] + prose = ( + '"""Module doc: same convention as lib.sh\'s _herdr_caller_server."""\n' + 'import hashlib\n' + 'def f():\n' + ' """See lib.sh; never call ensure_subscriber_daemon from here."""\n' + ' # preexec_fn is documented in lib.sh\n' + ' return hashlib.sha256(b"x").hexdigest()\n' + ) + assert _g9_offenders(prose) == [] # ============================================================================== From 087eacaf218d28cfa2143e82a20e45981e932482 Mon Sep 17 00:00:00 2001 From: Godopu Date: Tue, 22 Sep 2026 16:43:00 +0000 Subject: [PATCH 12/15] fix(issue-23): both ack waits share one contract - the wrapper's status bypass is gone; the G9 guard removes only the docstring node Review round 3 (planner-reviewer-codex-01, reviewer-opencode-01): R1 blocking, R2 and the "ONE builder" wording non-blocking. All three reproduced; none rebutted. R1. _wait_started still returned 0 as soon as the record left `pending`, ahead of the shared predicate, so `error`, `cancelled` and another dispatch's `running` all acknowledged an injection that create's delegate_wait_started rejects - the two contracts the ack-finding brief said must not diverge. Both reviewers reproduced it with the real producer and no record manipulation; my S7n had hidden it by resetting the record to `pending`. The bypass and the console grep are gone. The wrapper's evidence is now delegate_started_recorded alone, the same call create makes. For a remote agent - no shared disk, so no line in events.log - the predicate reads the subscriber's audit record of the `started` it received. That record is attributable by construction: the subscriber verifies the HMAC and drops any event whose data.dispatch_token differs from the job's expected token before it writes anything. The token itself cannot go into the record (the audit redactor masks token-named keys), so the subscriber now stamps a boolean dispatch_verified, and with a token demanded only a verified record counts; an untokened legacy record fails closed. The registry status is never evidence; a terminal job is preserved by the terminal logic, not turned into an acknowledgement. Tests: W1/W2/W3 are the reviewers' three negatives and the positive, real producer, record left exactly as it produced it; W4 runs create's wait and the wrapper's wait side by side on all four and requires the same answer; W5 pins the remote route (a verified record counts, an unverified or non-started one does not); W6 drives the real _Watcher and shows it stamps the flag on a matching started and records nothing for a mismatch; S7n no longer resets the record. test_issue14's "registry ack over unconfirmed inject" produced its ack with a bare status flip - the very bypass - and now produces it the way an agent does, publish_event with the dispatch token; its scenario and every other assertion are unchanged. test_issue18's remote e2e accepted the status messages and now expects the shared one. R2. _code_only blanked a docstring's whole line range, so code sharing that line - `"""doc"""; call()` - vanished from the scan. It now removes only the docstring node from the AST and unparses the rest (comments drop out with it, code strings stay); test_g9b pins the four same-line shapes. The builder comment claimed one builder for create and the wrapper; the wrapper keeps _pub_cmd and S7l pins the two equal argument-for-argument. The comment now says that. Co-Authored-By: Claude Fable 5.1 --- .agents/skills/lib.sh | 69 ++++++--- .../lib_py/delegation/job_subscriber.py | 8 + .../multi-agent-mux-delegate-job | 41 +++--- tests/test_issue14_delivery_ack.py | 20 ++- tests/test_issue18_liveness_contract.py | 10 +- tests/test_issue23_24_startup_path.py | 139 +++++++++++++++++- tests/test_issue5_phase2a_loop.py | 35 +++-- 7 files changed, 250 insertions(+), 72 deletions(-) diff --git a/.agents/skills/lib.sh b/.agents/skills/lib.sh index 8f5e99d..e8782a3 100644 --- a/.agents/skills/lib.sh +++ b/.agents/skills/lib.sh @@ -2176,8 +2176,9 @@ delegate_publish_event() { # delegate_publish_cmd [dispatch_token] [attempt_id] # The publish command an agent is told to run, shell-quoted, with absolute # script/registry/audit-log paths (Issue #21: an agent whose cwd is a worktree -# runs it as written). ONE builder for create_session's onboarding and the -# delegate-job wrapper's dispatch, so both hand the agent the same shape: +# runs it as written). create_session's onboarding uses this; the delegate-job +# wrapper still builds its own (_pub_cmd), and S7l pins the two equal +# argument-for-argument, so both hand the agent the same shape: # `--expect-dispatch-token T --attempt-id A` make publish_event write # data.dispatch_token / data.attempt_id into the agent's own events, which is # the only way a `started` can name the dispatch it answers (the trial-2 @@ -2219,33 +2220,59 @@ except Exception: ' 2>/dev/null || true } -# delegate_started_recorded [dispatch_token] +# delegate_started_recorded [dispatch_token] [logs_dir] # THE definition of "this dispatch's started is on disk", shared by # delegate_wait_started (create_session's onboarding ack) and the delegate-job -# wrapper's _wait_started: a `started` line in the job's events.log whose -# data.dispatch_token equals when one is given (a loop job -# keeps one events.log across attempts, and a stale attempt's started must not -# vouch for this one - review R1). The line is parsed as JSON, never +# wrapper's _wait_started. Two sources, one rule: +# +# * /.events.log - the local publisher's own line, whose +# data.dispatch_token must equal when one is given (a loop +# job keeps one events.log across attempts, and a stale attempt's started +# must not vouch for this one - review R1); +# * //events.ndjson - the subscriber's audit record of a +# `started` it received, the only evidence a REMOTE agent (no shared disk) +# can leave. The subscriber verifies the HMAC and drops any event whose +# data.dispatch_token differs from the job's expected token before it +# writes this record, and marks it dispatch_verified when that check ran; +# with given, only a dispatch_verified record counts. +# +# Never the registry status: a record leaves `pending` when another writer +# closes it or another dispatch claims it. Lines are parsed as JSON, never # pattern-matched. rc 0 = recorded, 1 = not (yet). delegate_started_recorded() { - local job_id="$1" tok="${2:-}" + local job_id="$1" tok="${2:-}" logs_dir="${3:-}" local reg_dir="${MAM_JOBS_DIR:-${WORKSPACE_ROOT:-$PWD}/.mam/jobs}" + [ -n "$logs_dir" ] || logs_dir="${DELEGATE_JOB_LOGS_DIR:-${WORKSPACE_ROOT:-$PWD}/.mam/delegate_job_logs}" local ev_log="$reg_dir/$job_id.events.log" - [ -f "$ev_log" ] || return 1 - EV_LOG="$ev_log" EV_TOK="$tok" python3 -c ' + local audit="$logs_dir/$job_id/events.ndjson" + [ -f "$ev_log" ] || [ -f "$audit" ] || return 1 + EV_LOG="$ev_log" EV_AUDIT="$audit" EV_TOK="$tok" python3 -c ' import json, os, sys tok = os.environ.get("EV_TOK", "") -with open(os.environ["EV_LOG"], encoding="utf-8", errors="replace") as f: - for line in f: - try: - e = json.loads(line) - except Exception: - continue - if not isinstance(e, dict) or e.get("event") != "started": - continue - data = e.get("data") if isinstance(e.get("data"), dict) else {} - if not tok or data.get("dispatch_token") == tok: - sys.exit(0) + +def lines(path): + if not os.path.isfile(path): + return + with open(path, encoding="utf-8", errors="replace") as f: + for line in f: + try: + e = json.loads(line) + except Exception: + continue + if isinstance(e, dict): + yield e + +for e in lines(os.environ["EV_LOG"]): + if e.get("event") != "started": + continue + data = e.get("data") if isinstance(e.get("data"), dict) else {} + if not tok or data.get("dispatch_token") == tok: + sys.exit(0) +for e in lines(os.environ["EV_AUDIT"]): + if e.get("event") != "received" or e.get("source_event") != "started": + continue + if not tok or e.get("dispatch_verified") is True: + sys.exit(0) sys.exit(1) ' 2>/dev/null } diff --git a/.agents/skills/lib_py/delegation/job_subscriber.py b/.agents/skills/lib_py/delegation/job_subscriber.py index 86f028a..0fd9998 100755 --- a/.agents/skills/lib_py/delegation/job_subscriber.py +++ b/.agents/skills/lib_py/delegation/job_subscriber.py @@ -279,6 +279,13 @@ class _Watcher: # that survives defensive parsing is recorded here, including ones a # different host published. This is the external-observer record that # backstops the publisher's own "published" line if it never wrote one. + # `dispatch_verified` says this record is attributable to the job's + # current dispatch: the token check above ran (the registry record had + # a dispatch_token and the event's data.dispatch_token matched it) and + # HMAC verification passed before that. The token itself is never + # written - the audit redactor masks token-named keys - so this flag is + # what a local reader (delegate_started_recorded) can trust for a + # remote agent's `started`, which has no line in events.log. mqtt_common.append_event(jid, { "event": "received", "source_event": payload.get("event"), @@ -286,6 +293,7 @@ class _Watcher: "topic": msg.topic, "timestamp": payload.get("timestamp"), "detail": payload.get("detail", ""), + "dispatch_verified": expected_disp_tok is not None, }) self.events.put((msg.topic, payload)) diff --git a/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job b/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job index 2559b77..529be41 100755 --- a/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job +++ b/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job @@ -633,40 +633,33 @@ _disk_status() { # ISSUE-18: delivery is confirmed by the protocol, not the screen. After the # prompt was handed over (herdr accepted the submission, or the dispatch was # published for a remote session) the job's own `started` must arrive within -# the ack timeout T: the registry leaves `pending` (the agent's publish_event -# syncs it on this host), a started line lands in events.log, or the -# subscriber prints a started line (a remote agent - no shared disk). No -# `started` within T is a delivery failure: fail closed, never re-inject -# (single-injection contract). The status is not pinned to this dispatch and -# may come from an external writer; the terminal handling after this still -# keeps such an error/cancelled as it is rather than turning it into success. +# the ack timeout T. The evidence is lib.sh's delegate_started_recorded - the +# same predicate create_session's onboarding ack uses, so the two waits share +# one contract (ack-finding req. 2, review R1): the local publisher's line in +# events.log, or the subscriber's HMAC-verified, dispatch-attributed audit +# record of a `started` it received (a remote agent has no shared disk). The +# registry status is not evidence - a record leaves `pending` when another +# writer closes it (error, cancelled) or another dispatch claims it, none of +# which says this agent saw this prompt. No `started` within T is a delivery +# failure: fail closed, never re-inject (single-injection contract). A job +# that is already terminal is handled by the terminal logic after this, not +# turned into a success here. _wait_started() { - local job_id="$1" ack_s="${2:-$ACK_TIMEOUT}" sub_log="${3:-}" deadline wall st + local job_id="$1" ack_s="${2:-$ACK_TIMEOUT}" sub_log="${3:-}" deadline wall + # sub_log is retained for callers' signatures; the subscriber's evidence is + # read from its audit record, which is attributable, not from its console. deadline=$(( $(date +%s) + ack_s )) # The ack wait never outlives this dispatch's wall clock (START_TIME + TIMEOUT). wall=$(( ${START_TIME:-$(date +%s)} + ${TIMEOUT:-3600} )) [ "$deadline" -gt "$wall" ] && deadline=$wall - # A loop job keeps one events.log across iterations: only a `started` that - # carries THIS attempt's dispatch token counts (review R1). The predicate is - # lib.sh's delegate_started_recorded - the same one create_session's - # onboarding ack uses, so the two waits share one definition of evidence. local budget=$(( deadline - $(date +%s) )) while :; do - st="$(_disk_status "$job_id")" - if [ -n "$st" ] && [ "$st" != "pending" ]; then - echo "inject: job $job_id advanced to '$st' in the registry; treating the prompt as delivered and continuing." >&2 - return 0 - fi - # publish_event records `started` here before its wire publish and before - # the status sync, so a slow broker cannot delay the acknowledgement. - if MAM_JOBS_DIR="$REGISTRY_DIR" delegate_started_recorded "$job_id" "${_disp_tok:-}"; then + # publish_event records `started` locally before its wire publish and + # before the status sync, so a slow broker cannot delay the acknowledgement. + if MAM_JOBS_DIR="$REGISTRY_DIR" delegate_started_recorded "$job_id" "${_disp_tok:-}" "${LOGS_DIR_ABS:-}"; then echo "inject: job $job_id recorded 'started' in events.log; treating the prompt as delivered and continuing." >&2 return 0 fi - if [ -n "$sub_log" ] && [ -f "$sub_log" ] && grep -Eq '^[^ ]+ job=[^ ]+ seq=[^ ]+ started( |$)' "$sub_log" 2>/dev/null; then - echo "inject: subscriber received started for job $job_id; treating the prompt as delivered and continuing." >&2 - return 0 - fi if [ "$(date +%s)" -ge "$deadline" ]; then ACK_WAITED_S="$budget" return 1 diff --git a/tests/test_issue14_delivery_ack.py b/tests/test_issue14_delivery_ack.py index eec2e3e..15dc9da 100644 --- a/tests/test_issue14_delivery_ack.py +++ b/tests/test_issue14_delivery_ack.py @@ -261,13 +261,21 @@ def test_wrapper_honours_registry_ack_over_unconfirmed_inject(tmp_path, mock_her time.sleep(0.1) assert jid, "job was not registered" - # The agent acknowledges the job out of band, as codex does via publish_event. - ack = subprocess.run( - [sys.executable, str(REGISTRY_PY), "--registry-dir", str(reg_dir), - "update", "--job", jid, "--status", "running"], - capture_output=True, text=True, cwd=str(tmp_path), env=env, + # The agent acknowledges the job out of band, as codex does via + # publish_event - the real producer, with the dispatch token the wrapper + # handed it. (Issue #23 review R1: a bare registry status flip is not an + # acknowledgement any more; only the agent's own `started` naming this + # dispatch is. publish_event writes that line locally before its wire + # publish, so an unreachable broker does not hide it.) + tok = json.loads((reg_dir / f"{jid}.json").read_text()).get("dispatch_token") or "" + subprocess.run( + [sys.executable, str(PUBLISH_PY), "--registry-dir", str(reg_dir), "--job", jid, + "--event", "started", "--expect-dispatch-token", tok, "--attempt-id", "1"], + capture_output=True, text=True, cwd=str(tmp_path), env=env, timeout=60, ) - assert ack.returncode == 0, ack.stderr + started_lines = [l for l in (reg_dir / f"{jid}.events.log").read_text().splitlines() + if '"started"' in l] + assert started_lines and tok in started_lines[-1], started_lines stdout, stderr = proc.communicate(timeout=90) assert "treating the prompt as delivered and continuing" in stderr, f"ack guard did not fire:\n{stderr}\n{stdout}" diff --git a/tests/test_issue18_liveness_contract.py b/tests/test_issue18_liveness_contract.py index d2839dd..22d1f45 100644 --- a/tests/test_issue18_liveness_contract.py +++ b/tests/test_issue18_liveness_contract.py @@ -1225,11 +1225,11 @@ def test_acceptance5_remote_runner_completes_a_job_over_the_broker_only(tmp_path pytest.skip("broker unreachable when publishing the dispatch (no CONNACK); the runner never saw the job") _skip_if_broker_did_not_deliver(reg, runner_log) assert proc.returncode == 0, f"rc={proc.returncode}\n{stdout}\n{stderr}\nrunner:\n{runner_log.read_text()}" - # delivery is confirmed by the protocol: the subscriber saw `started`, or - # the registry left pending - to running, or straight to completed when - # the broker delivered only the retained terminal event - assert ("subscriber received started" in stderr or "advanced to 'running'" in stderr - or "advanced to 'completed'" in stderr), stderr + # delivery is confirmed by the protocol: the subscriber's attributed + # audit record of the remote `started` (review R1: the registry status + # is no longer evidence - it can leave pending for reasons that say + # nothing about this dispatch) + assert "recorded 'started'" in stderr, stderr assert _job(reg, jid)["status"] == "completed", \ f"the subscriber mirrors the remote terminal into the registry\n{stdout}\n{stderr}\nrunner:\n{runner_log.read_text()}" assert _job(reg, jid)["transport"] == "remote" diff --git a/tests/test_issue23_24_startup_path.py b/tests/test_issue23_24_startup_path.py index 9fb39a4..31a1242 100644 --- a/tests/test_issue23_24_startup_path.py +++ b/tests/test_issue23_24_startup_path.py @@ -850,6 +850,140 @@ def test_s7m_a_job_the_agent_already_finished_is_not_marked_error(mam_sandbox): assert "DELIVERED" in res.stdout +def _wrapper_wait(tmp_path, jobs, disp_tok, ack_s=2, logs_dir=None): + """Run the wrapper's _wait_started exactly as the wrapper runs it.""" + lib = tmp_path / ".agents" / "skills" / "lib.sh" + wrapper = tmp_path / ".agents" / "skills" / "multi-agent-mux-delegate-job" / "multi-agent-mux-delegate-job" + env = _producer_env(tmp_path) + env.update({"MAM_JOBS_DIR": str(jobs), "WORKSPACE_ROOT": str(tmp_path), + "MAM_DELEGATE_JOB_NO_MAIN": "1"}) + script = ('source "%s"\nsource "%s"\nset +e\n' + 'PY="$(_delegate_py_bin)"; REGISTRY_DIR="%s"; ACK_TIMEOUT=%d; START_TIME=$(date +%%s); TIMEOUT=60\n' + 'LOGS_DIR_ABS="%s"\n' + '_disp_tok=%s; _wait_started job1 %d; echo "rc=$?"\n' + ) % (lib, wrapper, jobs, ack_s, logs_dir or env["DELEGATE_JOB_LOGS_DIR"], disp_tok, ack_s) + return subprocess.run(["bash", "-c", script], capture_output=True, text=True, + cwd=str(tmp_path), env=env, timeout=120) + + +@pytest.mark.parametrize("terminal", ["error", "cancelled"]) +def test_w1_wrapper_a_job_closed_without_a_started_is_not_acknowledged(mam_sandbox, terminal): + """W1 (review R1, real producer, record NOT reset): the agent never + published started; someone published `%s`. The record left `pending`. + The wrapper's wait must still say no - as create's does.""" + tmp_path = mam_sandbox + jobs = _seed_job(tmp_path, "job1", publish_args=None) + env = _producer_env(tmp_path) + subprocess.run([sys.executable, PUBLISH_PY, "--registry-dir", str(jobs), "--job", "job1", + "--event", terminal, "--detail", "closed by another writer", + "--expect-dispatch-token", "tok-1", "--attempt-id", "1"], + check=True, capture_output=True, text=True, env=env, timeout=60) + assert json.loads((jobs / "job1.json").read_text())["status"] == terminal + res = _wrapper_wait(tmp_path, jobs, "tok-1") + assert "rc=1" in res.stdout, res.stdout + res.stderr + assert "treating the prompt as delivered" not in res.stderr, res.stderr + + +def test_w2_wrapper_another_dispatchs_started_and_running_is_not_this_ones(mam_sandbox): + """W2 (review R1, real producer, record NOT reset): started published + under token A (record -> running), then the job was re-dispatched with + token B. Waiting for B must not accept A's started, nor the `running`.""" + tmp_path = mam_sandbox + jobs = tmp_path / ".mam" / "jobs"; jobs.mkdir(parents=True, exist_ok=True) + env = _producer_env(tmp_path) + subprocess.run([sys.executable, REGISTRY_PY, "--registry-dir", str(jobs), "register", + "--prompt", "x", "--agent", "claude-code", "--agent-session", "herdr:x", + "--dispatch-token", "tok-A", "--job-id", "job1", "--ack-timeout", "2"], + check=True, capture_output=True, text=True, env=env) + subprocess.run([sys.executable, PUBLISH_PY, "--registry-dir", str(jobs), "--job", "job1", + "--event", "started", "--expect-dispatch-token", "tok-A", "--attempt-id", "1"], + check=True, capture_output=True, text=True, env=env, timeout=60) + rec = jobs / "job1.json"; d = json.loads(rec.read_text()) + assert d["status"] == "running" + d["dispatch_token"] = "tok-B"; rec.write_text(json.dumps(d)) # the re-dispatch; status untouched + res = _wrapper_wait(tmp_path, jobs, "tok-B") + assert "rc=1" in res.stdout, res.stdout + res.stderr + + +def test_w3_wrapper_this_dispatchs_started_and_running_is_acknowledged(mam_sandbox): + """W3 (real producer, record NOT reset): the positive twin of W2.""" + tmp_path = mam_sandbox + jobs = _seed_job(tmp_path, "job1", started_token="tok-1") + assert json.loads((jobs / "job1.json").read_text())["status"] == "running" + res = _wrapper_wait(tmp_path, jobs, "tok-1") + assert "rc=0" in res.stdout, res.stdout + res.stderr + + +def test_w4_both_waits_agree_on_every_case(mam_sandbox): + """W4 (ack-finding req. 2): create's delegate_wait_started and the + wrapper's _wait_started answer the same on the four reviewer cases.""" + tmp_path = mam_sandbox + lib = tmp_path / ".agents" / "skills" / "lib.sh" + cases = [] + for label, status, started_tok, wait_tok in ( + ("error-no-started", "error", None, "tok-1"), + ("cancelled-no-started", "cancelled", None, "tok-1"), + ("other-dispatch-started", "running", "tok-1", "tok-B"), + ("own-started", "running", "tok-1", "tok-1")): + jobs = _seed_job(tmp_path, "job1", status=status, started_token=started_tok) + env = _producer_env(tmp_path) + env.update({"MAM_JOBS_DIR": str(jobs), "WORKSPACE_ROOT": str(tmp_path)}) + c = subprocess.run(["bash", "-c", 'source "%s"; delegate_wait_started job1 1 %s; echo "rc=$?"' % (lib, wait_tok)], + capture_output=True, text=True, cwd=str(tmp_path), env=env, timeout=60) + w = _wrapper_wait(tmp_path, jobs, wait_tok, ack_s=1) + cases.append((label, c.stdout.strip().splitlines()[-1], w.stdout.strip().splitlines()[-1])) + for label, c_rc, w_rc in cases: + assert c_rc == w_rc, (label, cases) + assert [c for _, c, _ in cases] == ["rc=1", "rc=1", "rc=1", "rc=0"], cases + + +def test_w5_a_remote_agents_started_counts_only_through_the_subscribers_verified_record(mam_sandbox): + """W5 (remote route): a remote agent leaves no line in events.log; the + subscriber writes an audit record after HMAC verification and after + dropping any dispatch_token mismatch, and marks it dispatch_verified. + SYNTHETIC on purpose: the record is written in exactly the shape + mqtt_common.append_event produces (a live subscriber needs a broker). + With a token demanded, only a verified record counts.""" + tmp_path = mam_sandbox + jobs = _seed_job(tmp_path, "job1") + logs = tmp_path / ".mam" / "delegate_job_logs" + (logs / "job1").mkdir(parents=True, exist_ok=True) + audit = logs / "job1" / "events.ndjson" + + def rec(**kw): + base = {"event": "received", "source_event": "started", "seq": 1, "topic": "t", + "timestamp": "2026-09-22T00:00:00Z", "detail": "", "logged_at": "x"} + base.update(kw) + return json.dumps(base) + "\n" + + audit.write_text(rec(dispatch_verified=False)) # untokened record: not attributed + assert "rc=1" in _wrapper_wait(tmp_path, jobs, "tok-1", ack_s=1, logs_dir=str(logs)).stdout + audit.write_text(rec(source_event="progress", dispatch_verified=True)) # not a started + assert "rc=1" in _wrapper_wait(tmp_path, jobs, "tok-1", ack_s=1, logs_dir=str(logs)).stdout + audit.write_text(rec(dispatch_verified=True)) + assert "rc=0" in _wrapper_wait(tmp_path, jobs, "tok-1", ack_s=1, logs_dir=str(logs)).stdout + + +def test_w6_the_subscriber_stamps_dispatch_verified_on_what_it_records(mam_sandbox): + """W6: the real subscriber code path - a _Watcher with an expected + dispatch token records a matching started as dispatch_verified, and drops + a mismatching one before recording anything.""" + import types + sys.path.insert(0, SKILLS) + from lib_py.delegation import job_subscriber, mqtt_common + logs = mam_sandbox / ".mam" / "delegate_job_logs" + w = job_subscriber._Watcher({"job1"}, {"job1": None}, {"job1": 0}, {"job1": "tok-1"}, {"job1": 1}) + msg = types.SimpleNamespace(topic="python/mqtt/jobs/job1/events") + good = {"schema_version": 1, "seq": 1, "job_id": "job1", "event": "started", "timestamp": "t", + "detail": "", "data": {"dispatch_token": "tok-1", "attempt_id": 1}} + bad = dict(good, seq=2, data={"dispatch_token": "tok-OLD", "attempt_id": 1}) + for payload in (good, bad): + w._on_event(msg, payload, "job1") if hasattr(w, "_on_event") else w.on_message(None, None, types.SimpleNamespace(topic=msg.topic, payload=json.dumps(payload).encode())) + recs = list(mqtt_common.iter_logged_events("job1", str(logs))) + started = [r for r in recs if r.get("source_event") == "started"] + assert len(started) == 1 and started[0]["dispatch_verified"] is True, recs + + def test_s7n_the_wrappers_wait_started_accepts_the_real_producers_line(mam_sandbox): """S7n (req. 2): the delegate-job wrapper's _wait_started, on the same real-producer started (token in data), accepts it; on a stale token it @@ -861,8 +995,9 @@ def test_s7n_the_wrappers_wait_started_accepts_the_real_producers_line(mam_sandb env = _producer_env(tmp_path) env.update({"MAM_JOBS_DIR": str(jobs), "WORKSPACE_ROOT": str(tmp_path), "MAM_DELEGATE_JOB_NO_MAIN": "1"}) - # keep the record `pending` so only the events.log route can answer - rec = jobs / "job1.json"; d = json.loads(rec.read_text()); d["status"] = "pending"; rec.write_text(json.dumps(d)) + # the record stays exactly as the real producer left it (`running`): + # review R1 found the previous version resetting it to `pending`, which + # hid the wrapper's status bypass behind the predicate script = ('source "%s"\nsource "%s"\n' 'PY="$(_delegate_py_bin)"; REGISTRY_DIR="%s"; ACK_TIMEOUT=2; START_TIME=$(date +%%s); TIMEOUT=60\n' '_disp_tok=tok-1; _wait_started job1 2 && echo ACCEPT_OWN\n' diff --git a/tests/test_issue5_phase2a_loop.py b/tests/test_issue5_phase2a_loop.py index f868031..aa9f083 100644 --- a/tests/test_issue5_phase2a_loop.py +++ b/tests/test_issue5_phase2a_loop.py @@ -708,13 +708,14 @@ _G9_PATTERNS = { def _code_only(src): - """`src` with comments and docstrings blanked, line structure kept.""" + """`src` as code only: the docstring expression statements are removed from + the AST and the rest is unparsed, which also drops comments (they are not + in the AST). Only the docstring *node* goes, so a statement that shares + its line - a docstring followed by `; call()` - is kept, as are string literals in code + (a path or `source` in a subprocess argv is exactly what must be caught). + Review R2: blanking the docstring's whole line range hid same-line code.""" import ast - import io - import tokenize - lines = src.splitlines(True) - blank = set() tree = ast.parse(src) for node in ast.walk(tree): if isinstance(node, (ast.Module, ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)): @@ -722,15 +723,10 @@ def _code_only(src): first = body[0] if body else None if (isinstance(first, ast.Expr) and isinstance(first.value, ast.Constant) and isinstance(first.value.value, str)): - blank.update(range(first.lineno, first.end_lineno + 1)) - out = [] - for tok in tokenize.generate_tokens(io.StringIO(src).readline): - if tok.type == tokenize.COMMENT: - (r, c), (_, c2) = tok.start, tok.end - lines[r - 1] = lines[r - 1][:c] + " " * (c2 - c) + lines[r - 1][c2:] - for i, line in enumerate(lines, 1): - out.append("\n" if i in blank else line) - return "".join(out) + del body[0] + if not body: + body.append(ast.Pass()) + return ast.unparse(tree) def _g9_offenders(src): @@ -769,6 +765,17 @@ def test_g9b_the_guard_still_catches_an_invocation_and_ignores_prose(): ' return hashlib.sha256(b"x").hexdigest()\n' ) assert _g9_offenders(prose) == [] + # Review R2: code sharing a line with a docstring must still be seen - + # module, one-line body, one-line def, and the last line of a multi-line + # docstring. Only the docstring node is removed, never its line. + same_line = [ + '"""module doc"""; ensure_subscriber_daemon()\n', + 'def f():\n """function doc"""; ensure_subscriber_daemon()\n', + 'def f(): """function doc"""; ensure_subscriber_daemon()\n', + 'def f():\n """multi\n line"""; ensure_subscriber_daemon()\n', + ] + for snippet in same_line: + assert _g9_offenders(snippet) == ["ensure_subscriber_daemon"], snippet # ============================================================================== From f4ba205c9f755d817e2206c7a9cb390a3e61fd2f Mon Sep 17 00:00:00 2001 From: Godopu Date: Tue, 22 Sep 2026 17:52:51 +0000 Subject: [PATCH 13/15] fix(issue-23): the remote audit record names the dispatch it vouches for; a record without identity fails closed Review round 4 (planner-reviewer-claude-01 B-1, planner-reviewer-codex-01 P2, reached independently): dispatch_verified is a boolean. It says the subscriber's token check ran when the record was written, but names no dispatch, the token being masked by the audit redactor and attempt_id not written. events.ndjson is appended to across a job's attempts, so a started verified under attempt 1 satisfied every later attempt's wait - the very misattribution R1 forbids, alive on the remote path while the local path (data.dispatch_token in events.log) was already immune. codex reproduced it with the real signed producer, the real _Watcher, the real audit writer and the real operator re-dispatch; both waits answered 0 where 1 was due, even when attempt 2's subscriber had correctly dropped attempt 1's replayed packet. The subscriber now writes dispatch_fingerprint = SHA-256 of the token it verified, and attempt_id for readers. With a token demanded, the predicate accepts an audit record only if it is dispatch_verified AND its fingerprint equals SHA-256 of that token; a record with no fingerprint - written before the field existed - names nothing and is rejected on that path. Without a token, any received started counts, as before. Fingerprint over attempt_id as the identity, weighing the two proposals: the local path already compares the token, so hashing it keeps one identity axis for both sources; the token is a uuid4 nonce (registry.py:141), 128 bits, so its SHA-256 is not recoverable and it is a CAS fence rather than the HMAC secret in the first place; and attempt_id cannot tell apart a token re-minted within one attempt, where the token can. attempt_id is recorded because it is cheap and readable, not relied on. W7 pins codex's five cases end to end - current dispatch accepted, an old attempt's record rejected after a real re-dispatch, still rejected when the stale packet is replayed and dropped, the new dispatch's own started accepted, a bad HMAC never recorded - with create's wait and the wrapper's wait required to agree on each. W8 pins the fail-closed legacy record and the untokened compatibility path. W5 gains the no-fingerprint and other-dispatch cases; W6 checks the real _Watcher writes the fingerprint and never the token. S7e's status reset had no reason left and is gone. codex's own reproduction file, unmodified, passes 5/5 against this tree. Co-Authored-By: Claude Fable 5.1 --- .agents/skills/lib.sh | 18 ++- .../lib_py/delegation/job_subscriber.py | 25 +++- tests/test_issue23_24_startup_path.py | 120 +++++++++++++++++- 3 files changed, 148 insertions(+), 15 deletions(-) diff --git a/.agents/skills/lib.sh b/.agents/skills/lib.sh index e8782a3..94559a6 100644 --- a/.agents/skills/lib.sh +++ b/.agents/skills/lib.sh @@ -2233,8 +2233,15 @@ except Exception: # `started` it received, the only evidence a REMOTE agent (no shared disk) # can leave. The subscriber verifies the HMAC and drops any event whose # data.dispatch_token differs from the job's expected token before it -# writes this record, and marks it dispatch_verified when that check ran; -# with given, only a dispatch_verified record counts. +# writes this record (dispatch_verified), and writes dispatch_fingerprint +# = SHA-256 of that token. The file is appended to across a job's +# attempts, so the flag alone would let an earlier attempt's record +# acknowledge a later dispatch (review round 4): with +# given, a record counts only if it is dispatch_verified AND its +# fingerprint equals SHA-256(). A record with no +# fingerprint (written before this field existed) names no dispatch and +# is rejected on that path - fail closed. Without a token, any received +# started counts, as before. # # Never the registry status: a record leaves `pending` when another writer # closes it or another dispatch claims it. Lines are parsed as JSON, never @@ -2247,8 +2254,9 @@ delegate_started_recorded() { local audit="$logs_dir/$job_id/events.ndjson" [ -f "$ev_log" ] || [ -f "$audit" ] || return 1 EV_LOG="$ev_log" EV_AUDIT="$audit" EV_TOK="$tok" python3 -c ' -import json, os, sys +import hashlib, json, os, sys tok = os.environ.get("EV_TOK", "") +fp = hashlib.sha256(tok.encode("utf-8")).hexdigest() if tok else "" def lines(path): if not os.path.isfile(path): @@ -2271,7 +2279,9 @@ for e in lines(os.environ["EV_LOG"]): for e in lines(os.environ["EV_AUDIT"]): if e.get("event") != "received" or e.get("source_event") != "started": continue - if not tok or e.get("dispatch_verified") is True: + if not tok: + sys.exit(0) + if e.get("dispatch_verified") is True and e.get("dispatch_fingerprint") == fp: sys.exit(0) sys.exit(1) ' 2>/dev/null diff --git a/.agents/skills/lib_py/delegation/job_subscriber.py b/.agents/skills/lib_py/delegation/job_subscriber.py index 0fd9998..fa5eea9 100755 --- a/.agents/skills/lib_py/delegation/job_subscriber.py +++ b/.agents/skills/lib_py/delegation/job_subscriber.py @@ -24,6 +24,7 @@ Exit codes: from __future__ import annotations import argparse +import hashlib import json import logging import queue @@ -279,13 +280,19 @@ class _Watcher: # that survives defensive parsing is recorded here, including ones a # different host published. This is the external-observer record that # backstops the publisher's own "published" line if it never wrote one. - # `dispatch_verified` says this record is attributable to the job's - # current dispatch: the token check above ran (the registry record had - # a dispatch_token and the event's data.dispatch_token matched it) and - # HMAC verification passed before that. The token itself is never - # written - the audit redactor masks token-named keys - so this flag is - # what a local reader (delegate_started_recorded) can trust for a - # remote agent's `started`, which has no line in events.log. + # Which dispatch this record vouches for. `dispatch_verified` says the + # token check above ran (the record had a dispatch_token and the + # event's data.dispatch_token matched it) after HMAC verification; on + # its own it names no dispatch, and this file is appended to across a + # job's attempts, so a reader waiting for a later dispatch would take + # an earlier attempt's record as its acknowledgement (review round 4). + # `dispatch_fingerprint` is SHA-256 of the token that was verified - + # the same identity the local path compares in events.log, minus the + # token itself, which the audit redactor masks by key name and which a + # 128-bit uuid4 nonce is not recoverable from. `attempt_id` is kept for + # readers; it is not the identity, since a token can be re-minted + # within an attempt. A reader that demands a token must match the + # fingerprint; a record without one proves nothing about identity. mqtt_common.append_event(jid, { "event": "received", "source_event": payload.get("event"), @@ -294,6 +301,10 @@ class _Watcher: "timestamp": payload.get("timestamp"), "detail": payload.get("detail", ""), "dispatch_verified": expected_disp_tok is not None, + "dispatch_fingerprint": (hashlib.sha256(expected_disp_tok.encode("utf-8")).hexdigest() + if expected_disp_tok else None), + "attempt_id": (data.get("attempt_id") if isinstance(data, dict) and data.get("attempt_id") is not None + else self.attempts.get(jid)), }) self.events.put((msg.topic, payload)) diff --git a/tests/test_issue23_24_startup_path.py b/tests/test_issue23_24_startup_path.py index 31a1242..e75ac47 100644 --- a/tests/test_issue23_24_startup_path.py +++ b/tests/test_issue23_24_startup_path.py @@ -956,11 +956,17 @@ def test_w5_a_remote_agents_started_counts_only_through_the_subscribers_verified base.update(kw) return json.dumps(base) + "\n" - audit.write_text(rec(dispatch_verified=False)) # untokened record: not attributed + import hashlib + fp = lambda t: hashlib.sha256(t.encode()).hexdigest() + audit.write_text(rec(dispatch_verified=False, dispatch_fingerprint=None)) # untokened: not attributed assert "rc=1" in _wrapper_wait(tmp_path, jobs, "tok-1", ack_s=1, logs_dir=str(logs)).stdout - audit.write_text(rec(source_event="progress", dispatch_verified=True)) # not a started + audit.write_text(rec(source_event="progress", dispatch_verified=True, dispatch_fingerprint=fp("tok-1"))) + assert "rc=1" in _wrapper_wait(tmp_path, jobs, "tok-1", ack_s=1, logs_dir=str(logs)).stdout # not a started + audit.write_text(rec(dispatch_verified=True)) # verified, no identity: fail closed assert "rc=1" in _wrapper_wait(tmp_path, jobs, "tok-1", ack_s=1, logs_dir=str(logs)).stdout - audit.write_text(rec(dispatch_verified=True)) + audit.write_text(rec(dispatch_verified=True, dispatch_fingerprint=fp("tok-OLD"))) # another dispatch's + assert "rc=1" in _wrapper_wait(tmp_path, jobs, "tok-1", ack_s=1, logs_dir=str(logs)).stdout + audit.write_text(rec(dispatch_verified=True, dispatch_fingerprint=fp("tok-1"))) assert "rc=0" in _wrapper_wait(tmp_path, jobs, "tok-1", ack_s=1, logs_dir=str(logs)).stdout @@ -982,6 +988,112 @@ def test_w6_the_subscriber_stamps_dispatch_verified_on_what_it_records(mam_sandb recs = list(mqtt_common.iter_logged_events("job1", str(logs))) started = [r for r in recs if r.get("source_event") == "started"] assert len(started) == 1 and started[0]["dispatch_verified"] is True, recs + import hashlib + assert started[0]["dispatch_fingerprint"] == hashlib.sha256(b"tok-1").hexdigest(), started[0] + assert started[0]["attempt_id"] == 1, started[0] + assert "tok-1" not in json.dumps(started[0]), "the token itself must never reach the audit log" + + +# -------------------------------------------------------------------------- +# review round 4 (planner-reviewer-claude-01 B-1, planner-reviewer-codex-01): +# the remote audit record must name the dispatch it vouches for. Real signed +# producer, real _Watcher.on_message, real audit writer, real re-dispatch +# (registry operator write minting a new token) - no record is edited by hand. +# -------------------------------------------------------------------------- + +def _remote_fixture(tmp_path, monkeypatch): + import test_issue18_liveness_contract as live + jobs = tmp_path / "jobs" + env = live._env(tmp_path, dict(live.UNREACHABLE, MAM_DELEGATE_JOB_NO_MAIN="1", + MAM_JOBS_DIR=str(jobs))) + for key in ("MAM_ENV_FILE", "DELEGATE_JOB_LOGS_DIR", "WORKSPACE_ROOT"): + monkeypatch.setenv(key, env[key]) + sys.path.insert(0, str(live.SKILLS)) + from lib_py.delegation import job_subscriber, mqtt_common, publish_event, registry + return live, env, jobs, job_subscriber, mqtt_common, publish_event, registry + + +def _receive(publish_event, watcher, jid, token, attempt, seq, secret): + """A signed `started` from the real producer, handed to the real subscriber.""" + import types + payload = publish_event.build_payload(jid, seq, "started", "", + {"dispatch_token": token, "attempt_id": attempt}, secret) + watcher.on_message(None, None, types.SimpleNamespace( + topic="python/mqtt/jobs/%s/events" % jid, payload=json.dumps(payload).encode())) + + +def _both_waits(live, env, tmp_path, jobs, jid, token): + """create's delegate_wait_started and the wrapper's _wait_started, same inputs.""" + import shlex + script = ( + 'source %s\nset +e\nPY=%s\nREGISTRY_DIR=%s\nACK_TIMEOUT=0\nTIMEOUT=60\nSTART_TIME=$(date +%%s)\n' + 'LOGS_DIR_ABS=%s\n_disp_tok=%s\n' + 'delegate_wait_started %s 0 "$_disp_tok"; echo CREATE_RC=$?\n' + '_wait_started %s 0; echo WRAPPER_RC=$?\n' + ) % (shlex.quote(str(live.WRAPPER)), shlex.quote(sys.executable), shlex.quote(str(jobs)), + shlex.quote(env["DELEGATE_JOB_LOGS_DIR"]), shlex.quote(token), jid, jid) + res = subprocess.run(["bash", "-c", script], env=env, cwd=str(tmp_path), + capture_output=True, text=True, timeout=60) + out = res.stdout + res.stderr + c = int(out.split("CREATE_RC=")[1].split()[0]); w = int(out.split("WRAPPER_RC=")[1].split()[0]) + return c, w, out + + +@pytest.mark.parametrize("scenario,expected", [ + ("current", 0), ("old_audit", 1), ("old_audit_and_stale_wire", 1), + ("new_dispatch_received", 0), ("invalid_hmac", 1), +]) +def test_w7_remote_audit_is_bound_to_the_dispatch_being_waited_for(tmp_path, monkeypatch, scenario, expected): + """W7 (codex's five cases, real paths throughout). Attempt 1 under token A + leaves a verified audit record; the operator re-dispatches (attempt 2, + new token B, nothing edited by hand); waiting for B must not be answered + by A's record, even when B's subscriber correctly dropped A's replayed + packet - and B's own verified started must still be accepted. Both waits + must agree.""" + live, env, jobs, job_subscriber, mqtt_common, publish_event, registry = _remote_fixture(tmp_path, monkeypatch) + jid, secret = "auditjob", "test-only-hmac-secret" + registry.register_job("remote audit", job_id=jid, registry_dir=str(jobs), auth_token=secret, + dispatch_token="dispatch-A", transport="remote", + broker={"host": "127.0.0.1", "port": 9, "tls": False}) + w1 = job_subscriber._Watcher({jid}, {jid: secret}, {jid: 0}, {jid: "dispatch-A"}, {jid: 1}) + _receive(publish_event, w1, jid, "dispatch-A", 1, 1, "wrong-secret" if scenario == "invalid_hmac" else secret) + recs = list(mqtt_common.iter_logged_events(jid, env["DELEGATE_JOB_LOGS_DIR"])) + assert sum(r.get("source_event") == "started" for r in recs) == (0 if scenario == "invalid_hmac" else 1), recs + token = "dispatch-A" + if scenario not in ("current", "invalid_hmac"): + job_subscriber._sync_remote_status(jid, "completed", str(jobs), token) + assert registry._operator_write(jid, str(jobs), status="pending", attempt_id=2, iteration=2) == 0 + record = mqtt_common.load_job(jid, str(jobs)) + token = record["dispatch_token"] + assert token != "dispatch-A" and record["attempt_id"] == 2, record + w2 = job_subscriber._Watcher({jid}, {jid: secret}, {jid: 0}, {jid: token}, {jid: 2}) + if scenario == "old_audit_and_stale_wire": + _receive(publish_event, w2, jid, "dispatch-A", 1, 2, secret) # A's packet replayed to B's subscriber + assert w2.events.empty(), "the subscriber must drop the stale dispatch" + elif scenario == "new_dispatch_received": + _receive(publish_event, w2, jid, token, 2, 2, secret) + assert not w2.events.empty() + assert not (jobs / ("%s.events.log" % jid)).exists(), "remote evidence comes only from the subscriber's audit" + c, w, out = _both_waits(live, env, tmp_path, jobs, jid, token) + assert (c, w) == (expected, expected), (scenario, c, w, out) + + +def test_w8_a_record_without_identity_fails_closed_when_a_token_is_demanded(tmp_path, monkeypatch): + """W8: an audit record from before dispatch_fingerprint existed (verified, + no fingerprint) names no dispatch: rejected when a token is demanded, + accepted when none is (an untokened legacy job), exactly as before.""" + live, env, jobs, job_subscriber, mqtt_common, publish_event, registry = _remote_fixture(tmp_path, monkeypatch) + jid = "legacyjob" + registry.register_job("legacy", job_id=jid, registry_dir=str(jobs), auth_token=None, + dispatch_token="dispatch-L", transport="remote", + broker={"host": "127.0.0.1", "port": 9, "tls": False}) + mqtt_common.append_event(jid, {"event": "received", "source_event": "started", "seq": 1, + "topic": "t", "timestamp": "t", "detail": "", + "dispatch_verified": True}, env["DELEGATE_JOB_LOGS_DIR"]) + c, w, out = _both_waits(live, env, tmp_path, jobs, jid, "dispatch-L") + assert (c, w) == (1, 1), ("token demanded: a record with no identity must not count", out) + c, w, out = _both_waits(live, env, tmp_path, jobs, jid, "") + assert (c, w) == (0, 0), ("no token demanded: compatibility path", out) def test_s7n_the_wrappers_wait_started_accepts_the_real_producers_line(mam_sandbox): @@ -1037,7 +1149,7 @@ def test_s7e_a_started_from_another_dispatch_is_not_evidence(mam_sandbox): "--event", "started", "--expect-dispatch-token", "tok-OLD", "--attempt-id", "1"], check=True, capture_output=True, text=True, env=env, timeout=60) rec = jobs / "job1.json" - d = json.loads(rec.read_text()); d["dispatch_token"] = "tok-1"; d["status"] = "pending" + d = json.loads(rec.read_text()); d["dispatch_token"] = "tok-1" # the re-dispatch; status untouched rec.write_text(json.dumps(d)) res, published = _run_delivery(mam_sandbox, 0, seed_events=False) assert published == ["error"], (published, res.stderr) From 05b0ea4762de3fad013e8986d45db21c4e6aa465 Mon Sep 17 00:00:00 2001 From: Godopu Date: Thu, 24 Sep 2026 06:47:57 +0900 Subject: [PATCH 14/15] fix(issue-23-24): merge review - the environment form of the auto-trust switch reaches the handler; a rate-limited dialog does not hide the next one Two probes from the merge-side codex review (its report was lost to an API outage; the probes and their results were recovered from its transcript): - create_session.sh and resume_session.sh set AUTO_TRUST=1 and then exported MAM_AUTO_TRUST from it, so the documented MAM_AUTO_TRUST=0 without a flag reached handle_startup_dialogs as 1: the operator had turned automatic trust off and it stayed on. Now AUTO_TRUST defaults to the environment value and an explicit --auto-trust/--no-auto-trust wins. - _mam_dialog_pick exited on the first dialog inside its resend cooldown instead of looking further down the screen, so a skipped (--no-auto-trust) or just-answered trust prompt hid the codex update notice below it, which the docs say is always answered. It continues to the next dialog now; S5 and S5b (one send per cooldown, one retry after it) still hold. Regressions: S6c, S5f (startup path), R3b and C3 (resume/create, env only, flag over env). Each fails without the fix. The create skill doc names the environment form and its precedence. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff --- .agents/skills/lib.sh | 5 ++- .../skills/multi-agent-mux-create/SKILL.md | 3 +- .../scripts/create_session.sh | 6 ++- .../scripts/resume_session.sh | 2 +- tests/test_issue23_24_startup_path.py | 26 +++++++++++++ tests/test_issue24_resume_dialogs.py | 39 +++++++++++++++++++ 6 files changed, 76 insertions(+), 5 deletions(-) diff --git a/.agents/skills/lib.sh b/.agents/skills/lib.sh index 94559a6..65c02dc 100644 --- a/.agents/skills/lib.sh +++ b/.agents/skills/lib.sh @@ -2952,7 +2952,10 @@ for idx, entry in enumerate(dialogs): continue last = state.get(idx) if last is not None and 0 <= now - last < cooldown: - sys.exit(1) + # rate-limited: look at the other dialogs on this screen instead of + # giving up (merge review, codex probe: a skipped trust prompt or a + # freshly answered one used to hide the notice below it) + continue if kind == "trust" and not auto_trust: print("skip|%d|" % idx) sys.exit(0) diff --git a/.agents/skills/multi-agent-mux-create/SKILL.md b/.agents/skills/multi-agent-mux-create/SKILL.md index 9fc5617..3c8a378 100644 --- a/.agents/skills/multi-agent-mux-create/SKILL.md +++ b/.agents/skills/multi-agent-mux-create/SKILL.md @@ -116,7 +116,8 @@ The bootstrapped server receives a **sanitized copy of the caller's environment* blocking-modal signatures and their accept keys live in each agent adapter (`lib_py/agents/adapters/*.py`, published as `MAM_STARTUP_DIALOGS`), and they are answered while waiting for the TUI rather than after it. `--no-auto-trust` - leaves directory-trust dialogs for a human to accept and still answers other + (or `MAM_AUTO_TRUST=0` in the environment; an explicit flag wins over the + environment) leaves directory-trust dialogs for a human to accept and still answers other blocking notices (for example codex's update prompt, where Skip changes nothing). Trust matters most for **codex**: trusting a directory makes it load that project's local config, hooks and exec policy, so `--no-auto-trust` is the diff --git a/.agents/skills/multi-agent-mux-create/scripts/create_session.sh b/.agents/skills/multi-agent-mux-create/scripts/create_session.sh index 79e4454..1494ce1 100755 --- a/.agents/skills/multi-agent-mux-create/scripts/create_session.sh +++ b/.agents/skills/multi-agent-mux-create/scripts/create_session.sh @@ -66,8 +66,10 @@ ONBOARD=1 # Issue #24: answer a start-up directory-trust dialog for the --workspace the # operator named. --no-auto-trust leaves those unanswered (notices are always # answered); codex in particular loads project-local config, hooks and exec -# policy once trusted. -AUTO_TRUST=1 +# policy once trusted. MAM_AUTO_TRUST=0 in the environment is the same switch +# (the skill docs say so); an explicit --auto-trust/--no-auto-trust wins over it +# (merge review, codex probe: the export below used to overwrite the caller's 0). +AUTO_TRUST="${MAM_AUTO_TRUST:-1}" while [ $# -gt 0 ]; do case "$1" in diff --git a/.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh b/.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh index 0776864..78a58d0 100755 --- a/.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh +++ b/.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh @@ -30,7 +30,7 @@ DRY_RUN=0 # Issue #24: a resumed CLI shows the same directory-trust dialog as a fresh # one, so resume takes the same switch as create (MAM_AUTO_TRUST is what # handle_startup_dialogs reads). -AUTO_TRUST=1 +AUTO_TRUST="${MAM_AUTO_TRUST:-1}" # the environment form of the same switch; a flag wins while [ $# -gt 0 ]; do case "$1" in diff --git a/tests/test_issue23_24_startup_path.py b/tests/test_issue23_24_startup_path.py index e75ac47..ddcdac6 100644 --- a/tests/test_issue23_24_startup_path.py +++ b/tests/test_issue23_24_startup_path.py @@ -578,6 +578,32 @@ def test_s6_no_auto_trust_leaves_trust_dialogs_alone_but_answers_notices( assert sent == ["send-keys -t test-sess 2"], (res.stderr, sent) +def test_s6c_a_skipped_trust_dialog_does_not_hide_the_notice_below_it(mam_sandbox): + """S6c (merge review, codex probe): with --no-auto-trust and a codex screen + that shows the trust prompt and the update notice together, the trust + prompt is skipped and recorded, and the skip's cooldown must not stop the + picker from answering the notice on the next poll.""" + res, sent = _run_dialogs( + mam_sandbox, "codex", + "Do you trust the contents of this directory?\n > 1. Yes, continue\n" + "Update available\n 1. Update now\n 2. Skip\n 3. Skip until next version", + timeout=3, env={"MAM_AUTO_TRUST": "0", "MAM_DIALOG_RESEND_S": "30"}) + assert sent == ["send-keys -t test-sess 2"], (res.stderr, sent) + assert "no-auto-trust" in res.stderr, res.stderr + + +def test_s5f_a_dialog_inside_its_cooldown_does_not_block_the_next_one(mam_sandbox): + """S5f (merge review, codex probe): two dialogs on one screen, auto-trust on. + The trust prompt is answered first; while it is rate-limited the notice + must still be answered, once each.""" + res, sent = _run_dialogs( + mam_sandbox, "codex", + "Do you trust the contents of this directory?\n > 1. Yes, continue\n" + "Update available\n 1. Update now\n 2. Skip\n 3. Skip until next version", + timeout=3, env={"MAM_DIALOG_RESEND_S": "60"}) + assert sent == ["send-keys -t test-sess Enter", "send-keys -t test-sess 2"], (res.stderr, sent) + + def test_s6b_create_session_no_longer_gates_dialogs_on_claude(mam_sandbox): """S6b (source read): the #24 root cause was the `AGENT = claude` gate at the create_session call site, so the gate's absence is the regression.""" diff --git a/tests/test_issue24_resume_dialogs.py b/tests/test_issue24_resume_dialogs.py index 19e1dd3..f3a12ff 100644 --- a/tests/test_issue24_resume_dialogs.py +++ b/tests/test_issue24_resume_dialogs.py @@ -100,6 +100,45 @@ def test_r3_no_auto_trust_reaches_the_dialog_handler(mam_sandbox, mock_herdr, mo assert _calls(log) == ["sess=test-r3-codex timeout=20 agent=codex auto_trust=0"] +@pytest.mark.parametrize("env_value,flag,expected", [ + ("0", (), "0"), # the documented environment form alone + ("0", ("--auto-trust",), "1"), # an explicit flag wins over the environment + ("1", ("--no-auto-trust",), "0"), +]) +def test_r3b_the_environment_form_of_the_switch_reaches_the_handler_on_resume( + mam_sandbox, mock_herdr, mock_agents, monkeypatch, env_value, flag, expected): + """R3b (merge review, codex probe): MAM_AUTO_TRUST=0 without a flag used to + be overwritten by the script's own export of its default.""" + tmp_path = mam_sandbox + ws = _create_and_stop(tmp_path, "codex", "test-r3b-codex") + log = _record_dialog_calls(tmp_path) + monkeypatch.setenv("MAM_AUTO_TRUST", env_value) + res = _resume(tmp_path, "codex", "test-r3b-codex", ws, extra=flag) + assert res.returncode == 0, res.stdout + res.stderr + assert _calls(log) == ["sess=test-r3b-codex timeout=20 agent=codex auto_trust=%s" % expected] + + +@pytest.mark.parametrize("env_value,flag,expected", [ + ("0", (), "0"), + ("0", ("--auto-trust",), "1"), + ("1", ("--no-auto-trust",), "0"), +]) +def test_c3_the_environment_form_of_the_switch_reaches_the_handler_on_create( + mam_sandbox, mock_herdr, mock_agents, monkeypatch, env_value, flag, expected): + """C3 (merge review, codex probe): the same contract on create_session.""" + tmp_path = mam_sandbox + log = _record_dialog_calls(tmp_path) + ws = str(tmp_path / "ws_c3") + os.makedirs(ws, exist_ok=True) + create = _script(tmp_path, SCRIPTS[0]) + monkeypatch.setenv("MAM_AUTO_TRUST", env_value) + res = subprocess.run(["bash", create, "--workspace", ws, "--agent", "codex", + "--role", "creator", "--session", "test-c3-codex", "--no-onboard", *flag], + cwd=str(tmp_path), capture_output=True, text=True) + assert res.returncode == 0, res.stdout + res.stderr + assert _calls(log) == ["sess=test-c3-codex timeout=15 agent=codex auto_trust=%s" % expected] + + def test_r4_the_shipped_handler_really_runs_for_a_non_claude_resume( mam_sandbox, mock_herdr, mock_agents): """R4 (no override): with the real handle_startup_dialogs, a codex resume From 9fb037c084c34d0458e748967037e7d6fde2f64c Mon Sep 17 00:00:00 2001 From: Godopu Date: Thu, 24 Sep 2026 08:36:35 +0900 Subject: [PATCH 15/15] release(v4.7.2): bump framework and eight skills to 4.7.2 in lockstep; archive PR #35 merge-side reviews Issues #23 and #24: cross-server herdr scope, agent-agnostic start-up dialogs, onboarding delivery judged by the agent's own started event, verified rollback. VERSIONS.md carries the entry; the merge-side reports (round 1: claude/agy/grok; round 2 on 05b0ea4: all four, with identity sidecars, base f4ba205 -> head 05b0ea4, evidence review-tree) are archived under .agents/reports//. The round-1 codex report was lost to the host sleeping mid-review; its two probes are recorded in the changelog. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff --- .../report-adc0ff3c.md | 176 ++++++++++++++++++ .../report-df626811.identity.json | 14 ++ .../report-df626811.md | 99 ++++++++++ .../report-81cd4006.identity.json | 14 ++ .../report-81cd4006.md | 69 +++++++ .../report-471806f8.identity.json | 14 ++ .../reviewer-agy-01/report-471806f8.md | 69 +++++++ .../reviewer-agy-01/report-c786c83f.md | 123 ++++++++++++ .../reviewer-grok-01/report-59e62ce6.md | 52 ++++++ .../report-8352b00e.identity.json | 14 ++ .../reviewer-grok-01/report-8352b00e.md | 39 ++++ .agents/skills/lib.sh | 2 +- .agents/skills/lib_py/loop/eager.py | 2 +- .../skills/multi-agent-mux-create/SKILL.md | 2 +- .../multi-agent-mux-delegate-job/SKILL.md | 2 +- .agents/skills/multi-agent-mux-loop/SKILL.md | 2 +- .../skills/multi-agent-mux-monitor/SKILL.md | 2 +- .../multi-agent-mux-orc-onboard/SKILL.md | 2 +- .../skills/multi-agent-mux-resume/SKILL.md | 2 +- .../skills/multi-agent-mux-status/SKILL.md | 2 +- .agents/skills/multi-agent-mux-stop/SKILL.md | 2 +- BOOTSTRAP.ko.md | 2 +- BOOTSTRAP.md | 2 +- MESSAGING.md | 2 +- README.ko.md | 2 +- README.md | 2 +- VERSIONS.md | 40 ++-- 27 files changed, 727 insertions(+), 26 deletions(-) create mode 100644 .agents/reports/planner-reviewer-claude-01/report-adc0ff3c.md create mode 100644 .agents/reports/planner-reviewer-claude-01/report-df626811.identity.json create mode 100644 .agents/reports/planner-reviewer-claude-01/report-df626811.md create mode 100644 .agents/reports/planner-reviewer-codex-01/report-81cd4006.identity.json create mode 100644 .agents/reports/planner-reviewer-codex-01/report-81cd4006.md create mode 100644 .agents/reports/reviewer-agy-01/report-471806f8.identity.json create mode 100644 .agents/reports/reviewer-agy-01/report-471806f8.md create mode 100644 .agents/reports/reviewer-agy-01/report-c786c83f.md create mode 100644 .agents/reports/reviewer-grok-01/report-59e62ce6.md create mode 100644 .agents/reports/reviewer-grok-01/report-8352b00e.identity.json create mode 100644 .agents/reports/reviewer-grok-01/report-8352b00e.md diff --git a/.agents/reports/planner-reviewer-claude-01/report-adc0ff3c.md b/.agents/reports/planner-reviewer-claude-01/report-adc0ff3c.md new file mode 100644 index 0000000..9f58e15 --- /dev/null +++ b/.agents/reports/planner-reviewer-claude-01/report-adc0ff3c.md @@ -0,0 +1,176 @@ +# 🔍 Review Report — PR #35 병합 전 독립 리뷰 (job `adc0ff3c`) + +- **Reviewer**: `planner-reviewer-claude-01` (role: planner,reviewer) · 2026-09-23 +- **대상**: PR #35 `fix/issue-23-24` **@ `f4ba205`**, base `main 1e78dae` (v4.7.1). 13커밋, 22파일 `+2197/−118`, `git diff --check` 클린. +- **검증 경로**: `/var/folders/.../T/mam-review/loop-20260923T142448Z-609954/1`. **identity 확인**: `HEAD = f4ba205c…fd2f`, `HEAD^{tree} = 88cb4f34…f181` — 브리프의 `head`·`tree` 와 일치. +- **안전 규칙 준수**: 검증 경로·소스 워크트리(`multi-agent-mux-wt/pr-35`)·본 체크아웃(`main 1e78dae`) 모두 수정·커밋·checkout 없음. 실험은 scratchpad 임시 디렉터리(uuid4 이름)에서만, pytest 는 `AGENT_PYTHON_BIN=<제어 루트 .venv>` + `--basetemp=<검증 경로 밖>` 으로 실행. 실제 herdr 서버·에이전트 세션·`.mam`·데몬 조작 없음(이 pane 의 herdr 환경변수를 **읽기만** 했다), push·merge·PR 상태 변경 없음, purge·scrub 실행 없음. + +## 결론 요약 + +**병합을 막을 결함은 없다.** 세 핵심 규칙 — 교차 서버 판정, "이번 dispatch 의 started 만 증거", kill 결과 확인 롤백 — 을 각각 독립 실험으로 재현·확인했고, 새 테스트는 실제 `registry.py`/`publish_event.py` 를 쓰며 홈·브로커·실제 herdr 에 닿지 않는다. 비차단 지적 6건(F1–F6)을 남긴다. 그중 F1 은 **브리프의 codex 수락 키 서술과 코드가 다르다**는 점이라 오케스트레이터가 확인할 필요가 있다. + +## 1. #24 시작 다이얼로그 (항목 1) + +### 1-1. 어댑터 시그니처·수락 키 + +`python -m lib_py.agents facts ` 가 실제로 내놓는 값(검증 경로에서 실행): + +| 에이전트 | `MAM_STARTUP_DIALOGS` | 강한 준비 토큰 | +|---|---|---| +| claude | 기존 사다리 5개(trust Enter / `Yes, try it` Escape / `Yes, proceed` Down+Enter / `Resuming…` Enter / `Press Enter…` Enter)가 그대로 옮겨짐 | `Anthropic\|Assistant\|…\|Haiku` | +| agy | `Do you trust the contents of this project\|Yes, I trust this folder` → `Enter`, trust | `Antigravity` | +| grok | `Do you trust the contents of this directory` → `y`, trust | `Grok\|xAI\|Assistant\|❯\|>>>` | +| codex | `Do you trust…\|1\. Yes, continue` → **`Enter`**, trust · `1\. Update now\|Skip until next version` → **`2`**, notice | `Codex\|Ask Codex to do anything` | +| opencode·hermes | `[]` (다이얼로그 없음) | 있음 → 핸들러가 배너에서 즉시 반환 | + +**F1 (확인 요망, 비차단)**: 브리프는 codex 수락 키를 "`1`+Enter, 업데이트 안내 `3`+Enter" 로 적었으나 코드는 **Enter 단독**(1번 항목이 미리 선택돼 있다는 주석)과 **`2`**("2 = Skip" 이라는 관찰 기록, 2026-09-22) 다. 어느 쪽이 실제 화면인지는 이 리뷰에서 재현할 수 없다(실제 codex 를 띄우는 것은 금지). PR 본문은 codex 를 포함한 4개 에이전트의 실제 herdr 시험이 사람 개입 없이 `started`(토큰 포함)→`completed` 까지 갔다고 기록하므로 **코드 쪽이 실측**으로 보이지만, 브리프 작성자의 관찰과 다르므로 병합 전에 한 번 맞춰 볼 것을 권한다. `_MAM_READY_TOKENS_CLAUDE` 상수는 제거되고 어댑터의 `strong_ready_tokens` 로 대체됐다(값 동일). + +### 1-2. 준비 토큰으로 수락을 판정하는 이유 + +`handle_startup_dialogs`(`lib.sh:2979-`)는 시그니처가 화면에 남아 있어도 같은 인덱스에 대해 `MAM_DIALOG_RESEND_S`(기본 5 s) 안에는 재전송하지 않고(`_mam_dialog_pick` 의 cooldown), 반환 조건은 **오직 준비 토큰**이다. `--source visible` 스냅샷이 수락된 다이얼로그를 한동안 계속 보여 준다는 #24 의 관찰과 맞물려 타당하다 — 옛 코드는 매 폴링마다 Enter 를 다시 보내 이미 넘어간 TUI 에 입력을 흘렸다. `test_c1_tui_readiness.py::test_c1b` 의 단언이 `==30` 에서 `1 ≤ n ≤ 30` 으로 바뀐 것도 같은 이유이며, "한 프레임에 두 번 보내지 않는다"는 원래 가드는 그대로다. + +### 1-3. `--no-auto-trust` / `MAM_AUTO_TRUST=0` + +`_mam_dialog_pick` 은 `kind == "trust"` 이고 auto_trust 가 꺼져 있으면 `skip|idx|` 를 내고, 핸들러는 경고만 남긴다(cooldown 마다 1회). `notice` 는 항상 답한다. create(`create_session.sh:86-94`)와 resume(`resume_session.sh:43-57`)이 **같은 스위치·같은 env 변수(`MAM_AUTO_TRUST`)·같은 호출(`handle_startup_dialogs "$SESSION" N "$AGENT"`)** 을 쓴다. S6/R3 가 고정. + +### 1-4. 자동 수락의 적용 범위(보안) + +pane 은 `new-session -c "$WORKSPACE"` 로 뜨고 신뢰 창은 그 cwd 에 대한 것이므로 실질적으로 `--workspace` 에만 적용된다. 다만 시그니처 정규식은 **디렉터리 이름을 검사하지 않는다** — 화면에 어떤 경로가 적혀 있든 매치되면 수락한다(F5). 시작 직후의 신뢰 창이 다른 디렉터리를 가리킬 경로는 없어 실무 위험은 낮다. + +## 2. #23 교차 서버 스코프 (항목 2) + +`_herdr_caller_server` / `_herdr_call_is_cross_server`(`lib.sh:1403-1447`)를 lib.sh 에서 함수만 추출해 `env -i` 로 9가지 조합을 돌렸다(전부 기대와 일치): + +| `HERDR_SOCKET_PATH` | `HERDR_SESSION` | 대상 | 판정 | +|---|---|---|---| +| `~/.config/herdr/herdr.sock` | – | `mam` | caller=`default`, **CROSS** (보고된 #23 시나리오) | +| 〃 | – | (없음) / `default` | same (앰비언트 sentinel) | +| `~/.config/herdr/sessions/mam/herdr.sock` | – | `mam` | caller=`mam`, same → **pane 배치·workspace 패킹 유지** | +| 〃 | – | `other` | CROSS | +| 〃 | `orch` | `mam` | caller=`orch`(HERDR_SESSION 우선), CROSS | +| `/other/layout/herdr.sock` / 비어 있음 | – | `mam` | caller="" → same (pane 밖: 명시 스코프 존중) | +| `…/sessions/a b/herdr.sock` | – | `mam` | caller=`a b`, CROSS (공백 안전) | + +- 제거는 `mam_herdr` 안에서 **교차일 때만** `env -u HERDR_WORKSPACE_ID -u HERDR_PANE_ID -u HERDR_TAB_ID` 로 이루어지고(`lib.sh:1449-1461`), argv 배열 + `if` 로 판정하므로 `set -e` 호출자를 중단시키지 않는다(S12). +- `lib_py/loop/watcher.py::caller_server/scoped_env` 는 같은 규칙의 파이썬 쌍둥이다. 같은 5가지 입력으로 돌려 bash 와 **동일 결과**를 확인했다(같은 서버·pane 밖·`default`·`None` 대상은 ids 유지, 교차만 제거). +- **실제 레이아웃 근거**: 이 pane(herdr **0.9.1**)의 환경은 `HERDR_SOCKET_PATH=~/.config/herdr/sessions/multi-agent-mux/herdr.sock`, `HERDR_SESSION=multi-agent-mux` 였다 — 규칙이 전제하는 `sessions//herdr.sock` 형태와 일치하고, `HERDR_SESSION` 도 export 된다. 0.8.2 의 "기본 서버 pane 에는 `HERDR_SESSION` 이 없다"는 전제는 PR 의 실측 기록(코드 주석)에 의존하며 여기서 재현할 수 없다. 두 경우 모두 소켓 경로 규칙이 뒷받침하므로 어느 버전이든 판정이 성립한다. +- 인식되지 않는 레이아웃은 ""(= pane 밖) 으로 떨어져 **오늘의 동작을 유지**한다. 잘못된 제거보다 안전한 쪽이다. + +## 3. #23 온보딩 전달 판정 (항목 3) + +### 3-1. 공유 + +`delegate_publish_cmd`·`delegate_job_status`·`delegate_started_recorded`·`delegate_wait_started` 가 `lib.sh:2189-2313` 에 하나씩 있고, create 온보딩(`create_session.sh:622-680`)과 래퍼 `_wait_started`(`multi-agent-mux-delegate-job:647-668`)가 **같은 `delegate_started_recorded`** 를 부른다. 래퍼는 `MAM_JOBS_DIR="$REGISTRY_DIR"` 와 `LOGS_DIR_ABS` 를 넘겨 자기 레지스트리·감사 루트를 그대로 쓴다(`:191`,`:659`). 옛 `_disk_status != pending → 성공` 우회와 구독자 콘솔 grep 은 **삭제**됐다(diff 로 확인). + +### 3-2. 증거 규칙 실측 + +`delegate_started_recorded` 만 추출해 가짜 `events.log`/`events.ndjson` 으로 10가지를 돌렸다(전부 기대와 일치): + +| # | 상태 | 토큰 요구 | 결과 | +|---|---|---|---| +| 1 | 파일 없음 | 있음 | NOT | +| 2 | 이전 dispatch 토큰의 `started` | 있음 | **NOT** (R1: 이전 시도의 증거 거부) | +| 3 | 〃 | 없음 | recorded (토큰 없으면 종전대로) | +| 4 | 이번 토큰의 `started` | 있음 | recorded | +| 5 | 감사 기록 `dispatch_verified:true`, **fingerprint 없음** | 있음 | **NOT** (신원 없는 과거 기록 fail closed) | +| 6 | 〃 | 없음 | recorded | +| 7 | 이전 시도의 fingerprint | 있음 | NOT | +| 8 | 이번 토큰의 fingerprint | 있음 | recorded | +| 9 | fingerprint 맞지만 `dispatch_verified:false` | 있음 | NOT | +| 10 | 깨진 줄 + `progress` 만 | 있음 | NOT (JSON 파싱, 패턴 매칭 아님) | + +fingerprint 는 `sha256(token)` 으로 구독자(`job_subscriber.py:303-306`)와 **같은 해시**임을 확인했다. 레지스트리 `status` 는 어느 경로에서도 증거로 쓰이지 않는다. + +### 3-3. 종료된 잡·#18 정합 + +- create 는 더 이상 에이전트 대신 `started` 를 발행하지 않는다(`:649-657` 주석과 코드; S7). 미확인 시 `delegate_job_status` 가 `completed|error|cancelled` 면 **덮어쓰지 않고** 경고만 한다(`:674-679`; S7m). +- 대기 시간은 `MAM_ONBOARD_ACK_S` → `MAM_INJECT_ACK_GRACE_S` → 잡의 `ack_timeout_sec`(기본 180) 순이다 — #18 의 T=180 과 같은 값이고, heartbeat 계약은 `started --heartbeat 30` 명령을 `delegate_publish_cmd` 가 래퍼와 **인자 단위로 동일**하게 만든다(S7l). +- `inject_instructions`(= `send_keys_safe`) 의 rc 4 는 세 자리 모두 "전달 미상"(`lib.sh:2746` busy 초과, `:2810` delivery unknown, `:2883` Enter 미수락)이라 `started` 대기로 판정을 넘기는 것이 맞고, rc 1(quiesce 실패)·2·3(다이얼로그 차단)은 즉시 실패로 남는다(S8/S8b). + +## 4. #23 실패 처리 (항목 4) + +- `cleanup_herdr_on_error`(`create_session.sh:318-346`): `kill-session` 의 rc 를 버리지 않고 받은 뒤 `has-session` 으로 재확인, 살아 있으면 행을 `running` + `last_visible_status: rollback-failed` + `delegate_job_id: null` 로 표시하고 **exit 43**. bash 3.2 에서 EXIT trap 안의 `exit 43` 이 원래 종료 코드(1)를 덮어씀을 직접 확인했다(`rc=43`). 상태값은 `atomic_yaml.py:18` 의 어휘(`running/terminated/archived/stopped`) 안이고 `last_visible_status` 는 reconcile 이 이미 쓰는 필드다. +- 온보딩 미확인: 세션 보존 + 잡 `error`(이미 종료된 잡은 제외) + 경고 2줄, `ROW_APPENDED` 뒤라 YAML 행은 유효한 `running` 이다. 실패한 잡은 `JOB_TERMINALIZED=1` 로 표시돼 트랩이 `cancelled` 를 겹쳐 발행하지 않는다. +- create SKILL.md(`:101-131`)가 이 규칙(started 기반 판정, rc 4 경고, 세션 보존, `MAM_ONBOARD_ACK_S`, exit 42/43 과 행 상태)을 정확히 서술한다. + +## 5. 기존 테스트 조정 6파일 (항목 5) + +| 파일 | 변경 | 판정 | +|---|---|---| +| `conftest.py` | `mam_sandbox` 에 `MAM_ONBOARD_ACK_S=3` 추가 | 타당. 샌드박스에는 `started` 를 발행할 실제 에이전트가 없어 이것이 없으면 create 테스트마다 180 s 를 소모한다. create 전용 변수라 래퍼의 `MAM_INJECT_ACK_GRACE_S` 를 단언하는 테스트와 간섭하지 않는다 | +| `test_b19_*` | `grep -q 'Yes, try it'` 가 lib.sh 에 있는지 → claude 어댑터의 `('Yes, try it', ['Escape'], 'notice')` 로 위치 이동 | 토큰 목록 두 단언은 그대로. 검증 대상이 이사한 것뿐 | +| `test_c1_tui_readiness.py` | `KEYS_COUNT==30` → `1 ≤ n ≤ 30`, `custom` 에이전트 → `claude` | §1-2 참조. cooldown 도입으로 "매 반복 1회" 가 "프레임당 최대 1회" 로 바뀌었고 원래 가드(2회 금지)는 유지·강화. `custom` 은 어댑터가 없어 다이얼로그 목록이 비므로 이 시험에는 `claude` 가 맞다 | +| `test_issue14_delivery_ack.py` | 레지스트리 상태만 `running` 으로 flip 하던 "ack" 를 **실제 `publish_event.py` + dispatch 토큰의 `started`** 로 교체 | **강화**. 옛 시험은 PR 이 없앤 "상태만 보고 성공" 우회를 통과 조건으로 쓰고 있었다. 새 시험은 로컬 `events.log` 에 토큰이 실린 줄까지 단언한다 | +| `test_issue18_liveness_contract.py` | acceptance5 의 배달 단언을 `recorded 'started'` 하나로 좁힘 | 계약상 옳다(레지스트리 상태는 증거가 아님). 다만 "브로커가 retained `completed` 만 배달" 한 경우가 이제 **실패**한다 — `_skip_if_broker_did_not_deliver` 는 그 잡의 어떤 이벤트도 못 받았을 때만 skip 하므로. 공개 브로커에서 flake 가 늘 수 있는 자리(F4) | +| `test_issue5_phase2a_loop.py` | G9 가드가 코드만 읽도록(`ast` 로 docstring **노드**만 제거, 주석은 unparse 에서 사라짐) + `g9b` 자체 검증 | 약화 아님. 호출 형태(`subprocess` argv 의 `lib.sh` 경로·`source`, 식별자)는 여전히 코드 텍스트에 있어 잡히고, `g9b` 가 "같은 줄의 코드"(R2) 네 가지까지 고정한다. `ast.unparse` 는 3.9+ — venv 3.9.6 이라 통과 | + +## 7. 문서 (항목 7) + +- create SKILL.md `:101-131`: started 기반 판정·rc 4 경고·세션 보존·`MAM_ONBOARD_ACK_S`·`--no-auto-trust`(codex 의 로컬 설정 로드 위험 명시)·`MAM_DIALOG_RESEND_S`·exit 42/43 과 행 상태가 코드와 일치한다. resume SKILL.md 는 에이전트 분기 `case` 를 없애고 같은 호출로 바꿨다. +- **F2 (비차단, 문서 누락)**: 감사 기록의 새 필드 `dispatch_verified`·`dispatch_fingerprint`·`attempt_id` 는 `MESSAGING.md`·delegate-job `SKILL.md` **어디에도 없다**(전수 grep 0건). 두 문서는 `events.ndjson` 을 단계 수준으로만 서술해 필드 목록 자체가 없으므로 모순은 없지만, "래퍼와 create 가 무엇을 증거로 읽는가"가 이번 PR 의 핵심이라 한 문단은 있어야 한다. +- **F3 (비차단, 문구 정밀도)**: SKILL.md 는 `MAM_ONBOARD_ACK_S` 가 래퍼의 `MAM_INJECT_ACK_GRACE_S` 와 "별개" 라고만 적었지만 코드의 우선순위는 `MAM_ONBOARD_ACK_S` → `MAM_INJECT_ACK_GRACE_S` → 잡의 `ack_timeout_sec` 이다(`create_session.sh:658`). 즉 래퍼 변수도 create 의 대기에 **2순위로 영향**을 준다. +- `.claude/skills`: 본 체크아웃의 것은 git 에 추적되지 않는 로컬 심링크(`../../.agents/skills/*`, 10개)라 자동 동기화된다. 검증 경로(워크트리)에는 존재하지 않는다 — 배포 사본 정합 문제 없음. + +## 6. 테스트 (항목 6) + +### 6-1. 검증 경로에서의 실행 + +``` +cd <검증 경로> && AGENT_PYTHON_BIN=<제어 루트 .venv/bin/python> PYTHONDONTWRITEBYTECODE=1 <같은 인터프리터> -m pytest \ + -p no:cacheprovider -q -rfEs --basetemp=<검증 경로 밖> \ + tests/test_issue23_24_startup_path.py tests/test_issue24_resume_dialogs.py tests/test_b19_headless_reconcile_fixes.py \ + tests/test_c1_tui_readiness.py tests/test_issue14_delivery_ack.py tests/test_issue18_liveness_contract.py \ + tests/test_issue5_phase2a_loop.py tests/test_herdr_shim_contract.py tests/test_loop_cli.py +→ 273 passed in 700.58s (0:11:40) failed 0 · errors 0 · skipped 0 +``` + +새 파일 2개(73건)와 조정된 6파일, 그리고 셸 shim 계약·loop CLI 까지 모두 통과. 브로커 의존 시험(issue14·issue18)도 이번 실행에서는 flake 없이 통과했다. + +### 6-2. 수정 없이는 실패하는가 — 확인함 + +검증 경로 사본에서 PR 이 바꾼 소스 12파일(lib.sh·어댑터 5·`__main__.py`·`job_subscriber.py`·`watcher.py`·create/resume 스크립트·래퍼)만 base `1e78dae` 판으로 되돌리고 새 테스트 2파일을 돌렸다: + +``` +60 failed, 12 passed, 1 skipped in 275.50s +``` + +실패 60건은 S1(교차 서버)·S4~S6(다이얼로그)·S7/W(started 증거)·S8(rc 4)·S9/S10(롤백)·R1~R4(resume) 전 영역에 걸친다. 통과한 12건은 "같은 서버는 스코프 유지"(S2/S2b)·"shim 은 바뀌지 않음"(S11)·"호출자가 준 토큰은 덮어쓰지 않음"(S5e) 같은 **불변 조건**들이라 수정과 무관한 것이 맞다. 즉 새 테스트는 이 변경을 정확히 겨냥한다. + +### 6-3. 격리·픽스처·시간 + +| 항목 | 결과 | +|---|---| +| 실제 `.mam/jobs` / `.mam/delegate_job_logs` 전후 | **차이 0 / 차이 0** | +| 실제 herdr 서버·홈 | `mam_sandbox` 가 `HOME`/`HOME_DIR` 를 `tmp_path` 로 바꾸고(`conftest.py:210-211`) herdr 는 mock. 새 파일에서 `hivemq`·실제 소켓 참조 0건. 이 pane 의 herdr 환경변수는 S1d 가 **읽기만** 한다 | +| 실제 생산자 사용 | S7i~S7n·W 계열은 `REGISTRY_PY`/`PUBLISH_PY`(실제 `registry.py`·`publish_event.py`)로 잡을 등록하고 `started` 를 발행한다(`test_issue23_24_startup_path.py:620-660`). "손으로 만든 픽스처가 결함을 가렸다"는 PR 의 교훈이 테스트에 반영돼 있다 | +| 검증 경로 | tracked 파일 변화 없음(HEAD `f4ba205` 유지). 내 실행이 무시되는 `.mam/` 을 만들었다(#32 리뷰 때와 같은 현상, 그대로 둠) | +| 실행 시간 | 새 파일 2개는 대체로 빠르지만 **`test_issue24_resume_dialogs.py` 4건이 각 39~43 s** 다(resume 의 `handle_startup_dialogs … 20` 타임아웃과 mock 준비 대기가 겹친다). 스위트 전체 시간에 +2.7 분을 더한다(F6) | + +## 8. 병합 차단 여부 (항목 8) + +버전 락스텝 bump 부재(`lib.sh:32` `4.7.1`, 오케스트레이터가 4.7.2 로 처리 예정) 외에 **병합을 막을 결함은 없다**. + +| # | 비차단 지적 | 근거 / 권고 | +|---|---|---| +| F1 | **codex 수락 키가 브리프 서술과 다르다.** 코드: 신뢰 창 `Enter`(1번 미리 선택), 업데이트 안내 **`2`**(Skip, 이번만). 브리프: `1`+Enter, `3`+Enter(Skip until next version). S4 의 codex 화면 픽스처(`Update available / 1. Update now / 2. Skip / 3. Skip until next version`)를 보면 3개 항목이 실제로 있고, 코드는 그중 **2** 를 고른다 — "아무것도 바꾸지 않는" 선택이라는 주석과 일치하나 다음 시작 때 다시 뜬다. 실제 시험은 PR 본문대로 통과했으므로 동작은 성립 | 병합 전 브리프 작성자와 한 번 맞출 것; `3` 이 의도라면 어댑터 한 줄 | +| F2 | 감사 기록의 새 필드(`dispatch_verified`·`dispatch_fingerprint`·`attempt_id`)가 MESSAGING.md·delegate-job SKILL.md 에 없다 | 한 문단 추가 권고 | +| F3 | `MAM_INJECT_ACK_GRACE_S` 가 create 의 대기에도 2순위로 영향을 준다는 점이 SKILL.md 에 없다(`create_session.sh:658`) | 문구 보강 | +| F4 | acceptance5 가 "retained `completed` 만 배달" 된 경우를 이제 실패로 본다(계약상 옳음). 공개 브로커에서 flake 가 늘 수 있는 자리 | 관찰되면 skip 조건 재검토 | +| F5 | 다이얼로그 시그니처는 화면의 디렉터리 경로를 검사하지 않는다 — pane 이 `-c $WORKSPACE` 로 뜨므로 실무 위험은 낮다 | 정보 | +| F6 | `test_issue24_resume_dialogs.py` 4건이 각 ~40 s | 타임아웃을 env 로 줄일 수 있으면 개선 | + +## 9. 교차검증 가이드 + +```bash +RP=/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20260923T142448Z-609954/1 +PY=/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.venv/bin/python +git -C "$RP" rev-parse HEAD 'HEAD^{tree}' # f4ba205… / 88cb4f3… +# 교차 서버 규칙 (함수만 추출, env -i) +eval "$(sed -n '/^_herdr_caller_server() {/,/^}/p; /^_herdr_call_is_cross_server() {/,/^}/p' "$RP/.agents/skills/lib.sh")" +env -i HERDR_SOCKET_PATH=/x/.config/herdr/herdr.sock HERDR_SESSION_NAME=mam bash -c "$(declare -f _herdr_caller_server _herdr_call_is_cross_server); _herdr_call_is_cross_server && echo CROSS" +env -i HERDR_SOCKET_PATH=/x/.config/herdr/sessions/mam/herdr.sock HERDR_SESSION_NAME=mam bash -c "$(declare -f _herdr_caller_server _herdr_call_is_cross_server); _herdr_call_is_cross_server || echo same" +# 증거 규칙: 이전 토큰의 started / fingerprint 없는 감사 기록은 토큰 요구 시 거부 (§3-2 의 10 케이스) +# 수정 없이는 실패하는지 (사본에서만): 소스 12파일을 1e78dae 판으로 교체 후 새 테스트 2파일 → 60 failed / 12 passed +``` + +[VERDICT: PASS] diff --git a/.agents/reports/planner-reviewer-claude-01/report-df626811.identity.json b/.agents/reports/planner-reviewer-claude-01/report-df626811.identity.json new file mode 100644 index 0000000..7bfad4b --- /dev/null +++ b/.agents/reports/planner-reviewer-claude-01/report-df626811.identity.json @@ -0,0 +1,14 @@ +{ + "loop_id": "loop-20260923T220355Z-76dd1d", + "base_sha": "f4ba205c9f755d817e2206c7a9cb390a3e61fd2f", + "head_sha": "05b0ea4762de3fad013e8986d45db21c4e6aa465", + "tree_sha": "8f5e8b0402017d2cc809364e60400c4c25b922f3", + "review_commit": "05b0ea4762de3fad013e8986d45db21c4e6aa465", + "reviewer": "planner-reviewer-claude-01", + "job_id": "df626811", + "verdict": "PASS", + "transport": "herdr", + "evidence": "review-tree", + "isolation": "worktree", + "verified_at": "2026-09-23T23:35:19Z" +} \ No newline at end of file diff --git a/.agents/reports/planner-reviewer-claude-01/report-df626811.md b/.agents/reports/planner-reviewer-claude-01/report-df626811.md new file mode 100644 index 0000000..dba4693 --- /dev/null +++ b/.agents/reports/planner-reviewer-claude-01/report-df626811.md @@ -0,0 +1,99 @@ +# 🔍 Review Report — PR #35 델타 리뷰 2회차 (job `df626811`) + +- **Reviewer**: `planner-reviewer-claude-01` (role: planner,reviewer) · 2026-09-24 +- **대상 델타**: `f4ba205..05b0ea4` (1커밋 `05b0ea4`, 6파일 `+76/−5`, `git diff --check` 클린), base `main 1e78dae` (v4.7.1). +- **검증 경로**: `/var/folders/.../T/mam-review/loop-20260923T220355Z-76dd1d/1`. **identity 확인**: `HEAD = 05b0ea47…a465`, `HEAD^{tree} = 8f5e8b04…22f3` — 브리프와 일치. +- **1회차 내 판정**: PASS (job `adc0ff3c`). 이번에는 codex 탐침 2건의 정정이 실제로 닫혔는지와 부작용을 본다. +- **안전 규칙 준수**: 검증 경로·소스 워크트리·본 체크아웃(`main 1e78dae`) 모두 수정·커밋·checkout 없음. 함수 추출 실험과 되돌린 사본 실행은 scratchpad 에서만, pytest 는 `AGENT_PYTHON_BIN` + `--basetemp=<검증 경로 밖>`. 실제 herdr·세션·`.mam`·데몬 조작 없음, push·merge·PR 상태 변경 없음. + +## 결론 요약 + +**두 정정은 탐침을 닫고, 1회차 판정을 뒤집을 요소는 없다.** P2 의 `continue` 는 S5/S5b 의 쿨다운 계약을 그대로 유지하면서 같은 화면의 다음 다이얼로그를 보게 하고, P1 의 `AUTO_TRUST="${MAM_AUTO_TRUST:-1}"` 는 "플래그 > 환경 > 기본 1" 의 우선순위를 문서대로 만든다. 비차단 관찰 2건(G1·G2). + +## 1. P2 — `_mam_dialog_pick` 의 `continue` (항목 1) + +`lib.sh:2954-2958`. 함수만 추출해 codex 어댑터의 다이얼로그 2개(신뢰 창 idx0 → `Enter`, 업데이트 안내 idx1 → `2`)와 **두 창이 함께 있는 화면**으로 7가지 상태를 돌렸다(cooldown 5 s): + +| # | 상태 | 결과 | 의미 | +|---|---|---|---| +| 1 | 기록 없음, 자동 신뢰 on | `send\|0\|Enter` | 신뢰 창이 먼저(어댑터 순서) | +| 2 | 신뢰 창을 1 s 전에 보냄(쿨다운 중), on | **`send\|1\|2`** | **P2 정정**: 쿨다운 중인 창을 건너뛰고 안내에 답한다. 옛 코드는 여기서 `exit 1` 로 아무것도 안 했다 | +| 3 | 기록 없음, 자동 신뢰 off | `skip\|0\|` | 신뢰 창은 사람 몫, skip 기록 | +| 4 | skip 1 s 뒤, off | **`send\|1\|2`** | **P2 정정**: skip 의 쿨다운이 안내를 가리지 않는다 | +| 5 | 두 창 모두 쿨다운 중 | 없음(rc 1) | **S5 유지**: 쿨다운 안에서는 재전송 없음 | +| 6 | 두 창 모두 쿨다운 만료(+5 s) | `send\|0\|Enter` | **S5b 유지**: 만료 후 1회 재시도, 다시 신뢰 창부터 | +| 7 | off, 만료 | `skip\|0\|` | 경고가 쿨다운마다 1회 | + +**키 순서·횟수**: 핸들러는 한 폴링에 하나만 고르므로(`_mam_dialog_pick` 은 첫 actionable 항목에서 종료), 두 창이 한 화면에 있으면 **1회차 폴링에 `Enter`, 다음 폴링(1 s 뒤)에 `2`** 순으로 각 1회 간다. S5f 가 `["Enter", "2"]` 를, S6c 가 `["2"]` + 경고를 그대로 고정한다. 쿨다운은 **인덱스별**(`state[idx]`)이라 서로 간섭하지 않는다. + +## 2. P1 — `AUTO_TRUST="${MAM_AUTO_TRUST:-1}"` (항목 2) + +`create_session.sh:72,87-88,96` 과 `resume_session.sh:33,42-43,57` 이 같은 3단 구조(초기값 ← 환경, 플래그 arm 이 덮어씀, 마지막에 `export`)다. 세 줄을 그대로 옮긴 하네스로 8가지를 돌렸다: + +| 환경 `MAM_AUTO_TRUST` | 플래그 | 핸들러가 받는 값 | 판정 | +|---|---|---|---| +| 없음 | – | `1` (on) | 기본 | +| `0` | – | `0` (**off**) | **P1 정정**: 문서의 환경변수 형식이 이제 닿는다(옛 코드는 `1` 로 덮어썼다) | +| `0` | `--auto-trust` | `1` (on) | 플래그 > 환경 | +| `1` | `--no-auto-trust` | `0` (off) | 플래그 > 환경 | +| 없음 | `--no-auto-trust` | `0` (off) | | +| `yes` / `false` | – | 그대로 전달 → 핸들러 `!= "0"` 규칙으로 **on** | `_mam_dialog_pick` 도 같은 규칙(`MAM_DLG_TRUST != "0"`)이라 두 층이 일치한다 | +| `''`(빈 문자열) | – | `:-1` 이 빈 값을 미설정으로 봐 `1` (on) | 핸들러의 `${MAM_AUTO_TRUST:-1}` 와 같은 해석 | + +즉 "0 만 off, 나머지는 전부 on" 이 스크립트·핸들러·picker 세 층에서 동일하다(`lib.sh:2986`, picker 의 `!= "0"`). create SKILL.md `:119-120` 이 환경변수 형식과 "플래그 우선"을 명시한다. **G1 (비차단)**: `false`/`no` 가 on 으로 해석되는 것은 일관되지만 직관에 반한다 — 문서에 "`0` 만 끈다" 를 한 마디 적어 두면 좋다. resume SKILL.md 는 `:107` 에서 `MAM_AUTO_TRUST=0` 형식은 적지만 "플래그가 환경보다 우선" 은 create 쪽에만 있다(G2, 사소). + +## 3. 테스트 (항목 1·3) + +### 3-1. 수정 없이는 실패하는가 — 확인함 + +검증 경로 사본에서 `lib.sh`·`create_session.sh`·`resume_session.sh` 세 파일만 `f4ba205` 판으로 되돌리고 새 회귀와 이웃 시험을 `-k "s6c or s5f or r3b or c3 or test_s5_ or s5b or test_s6_"` 로 돌렸다: + +``` +4 failed, 7 passed, 70 deselected in 153.65s +FAILED test_s6c_a_skipped_trust_dialog_does_not_hide_the_notice_below_it ← P2 +FAILED test_s5f_a_dialog_inside_its_cooldown_does_not_block_the_next_one ← P2 +FAILED test_r3b_…_on_resume[0-flag0-0] (환경변수만, 플래그 없음) ← P1 +FAILED test_c3_…_on_create[0-flag0-0] (환경변수만, 플래그 없음) ← P1 +``` + +실패 4건이 정확히 두 탐침의 재현 사례다. 통과한 7건 — R3b/C3 의 "플래그가 환경을 이긴다" 4사례(플래그는 옛 코드에서도 동작했다), S5·S5b(쿨다운 계약), S6(신뢰 창 skip + 안내 응답, 한 화면에 하나씩) — 은 정정 전후로 **변하지 않아야 하는** 계약이고, 실제로 변하지 않았다. 즉 `continue` 가 S5/S5b 를 깨지 않았다는 것이 되돌린 사본과 PR 트리 양쪽에서 성립한다(PR 트리 쪽은 §3-2). + +### 3-2. PR 트리(검증 경로)에서의 실행 + +``` +tests/test_issue23_24_startup_path.py tests/test_issue24_resume_dialogs.py tests/test_c1_tui_readiness.py tests/test_b19_headless_reconcile_fixes.py +→ 120 passed in 535.40s (0:08:55) failed 0 · skipped 0 +같은 -k 부분집합(새 회귀 4 + 이웃 7) 단독 → 11 passed in 148.21s +``` + +- 다이얼로그 핸들러를 건드리는 4파일 전부 통과. `c1`·`b19` 의 "프레임당 1회"·`Yes, try it` 가드도 유지된다. +- 실제 `.mam/jobs` 전후 **차이 0**. 검증 경로 tracked 파일 변화 없음(HEAD `05b0ea4`), 본 체크아웃 `main 1e78dae`. +- 시간: 이 호스트에서 다른 스위트 2개가 동시에 돌고 있어 resume 시험 6건이 각 37~41 s 였다(1회차 F6 그대로; R3b 3사례가 더해져 +2 분). + +## 4. 병합 차단 여부 (항목 3) + +1회차 PASS 를 뒤집을 요소는 없다. 델타는 세 줄의 소스 변경(`lib.sh:2958` `continue`, `create_session.sh:72`·`resume_session.sh:33` 의 환경 기본값)과 문서 한 줄, 회귀 4건이며, 두 탐침의 재현 사례만 정확히 실패→통과로 바뀌었다. 버전 락스텝 bump 부재(`4.7.1`, 오케스트레이터가 4.7.2 로 처리 예정) 외에 **병합을 막을 결함은 없다**. + +| # | 비차단 관찰 | 근거 | +|---|---|---| +| G1 | `MAM_AUTO_TRUST` 는 `0` 만 끈다(`false`/`no` 는 on). 세 층(스크립트·핸들러·picker)이 일치하므로 결함은 아니지만 문서에 한 마디 있으면 좋다 | `create_session.sh:72`, `lib.sh:2986`, `_mam_dialog_pick` 의 `!= "0"` | +| G2 | "플래그가 환경보다 우선" 은 create SKILL.md(`:119-120`)에만 있고 resume SKILL.md(`:107`)에는 환경변수 형식만 있다 | 사소 | + +1회차 F1(codex 수락 키와 브리프 서술의 차이)은 이 델타의 범위 밖이며 그대로 열려 있다. + +## 5. 교차검증 가이드 + +```bash +RP=/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20260923T220355Z-76dd1d/1 +PY=/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.venv/bin/python +git -C "$RP" rev-parse HEAD 'HEAD^{tree}' # 05b0ea4… / 8f5e8b0… +# picker 단독: 신뢰 창 쿨다운 중에도 안내에 답하는지 +eval "$(sed -n '/^_mam_dialog_pick() {/,/^}/p' "$RP/.agents/skills/lib.sh")" +D='[["Do you trust the contents of this directory|1\\. Yes, continue",["Enter"],"trust"],["1\\. Update now|Skip until next version",["2"],"notice"]]' +S=$'Do you trust the contents of this directory?\n > 1. Yes, continue\nUpdate available\n 1. Update now\n 2. Skip\n 3. Skip until next version' +_mam_dialog_pick "$S" "$D" 1 "0:1000" 1001 5 # send|1|2 (정정 전: 출력 없음) +_mam_dialog_pick "$S" "$D" 1 "0:1000 1:1000" 1002 5 || echo nothing # S5 유지 +# 되돌린 사본에서 회귀가 실패하는지: lib.sh/create/resume 를 f4ba205 판으로 교체 → -k "s6c or s5f or r3b or c3 ..." → 4 failed / 7 passed +``` + +[VERDICT: PASS] diff --git a/.agents/reports/planner-reviewer-codex-01/report-81cd4006.identity.json b/.agents/reports/planner-reviewer-codex-01/report-81cd4006.identity.json new file mode 100644 index 0000000..55b18a0 --- /dev/null +++ b/.agents/reports/planner-reviewer-codex-01/report-81cd4006.identity.json @@ -0,0 +1,14 @@ +{ + "loop_id": "loop-20260923T220355Z-76dd1d", + "base_sha": "f4ba205c9f755d817e2206c7a9cb390a3e61fd2f", + "head_sha": "05b0ea4762de3fad013e8986d45db21c4e6aa465", + "tree_sha": "8f5e8b0402017d2cc809364e60400c4c25b922f3", + "review_commit": "05b0ea4762de3fad013e8986d45db21c4e6aa465", + "reviewer": "planner-reviewer-codex-01", + "job_id": "81cd4006", + "verdict": "PASS", + "transport": "herdr", + "evidence": "review-tree", + "isolation": "worktree", + "verified_at": "2026-09-23T23:35:19Z" +} \ No newline at end of file diff --git a/.agents/reports/planner-reviewer-codex-01/report-81cd4006.md b/.agents/reports/planner-reviewer-codex-01/report-81cd4006.md new file mode 100644 index 0000000..e93843a --- /dev/null +++ b/.agents/reports/planner-reviewer-codex-01/report-81cd4006.md @@ -0,0 +1,69 @@ +# PR #35 델타 리뷰 — 두 탐침 지적 해소 + +- Job: `81cd4006`; reviewer: `planner-reviewer-codex-01` +- Loop: `loop-20260923T220355Z-76dd1d` +- Base: `f4ba205c9f755d817e2206c7a9cb390a3e61fd2f` +- Head: `05b0ea4762de3fad013e8986d45db21c4e6aa465` +- Tree: `8f5e8b0402017d2cc809364e60400c4c25b922f3` +- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20260923T220355Z-76dd1d/1` + +두 기존 지적이 해결되었다. 이번 델타에서 추가 병합 차단 결함을 발견하지 않았다. 브리프에 명시된 4.7.2 버전 락스텝 변경은 오케스트레이터의 후속 작업으로 남는다. + +## P1: 환경변수 자동 신뢰 거부가 create/resume에서 보존됨 + +근거: `.agents/skills/multi-agent-mux-create/scripts/create_session.sh:72`, `:87`, `:88`, `:96`; `.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh:33`, `:42`, `:43`, `:57`. + +초기값이 `AUTO_TRUST="${MAM_AUTO_TRUST:-1}"`로 바뀌어 환경의 `0`을 보존한다. 이후 명시적 플래그가 값을 덮어쓰므로 문서에 추가된 우선순위와 일치한다. 실제 create/resume 스크립트를 샌드박스의 mock herdr와 실행하는 C3/R3b 회귀가 다음 여섯 사례를 검증했다. + +| 환경값 | 명시 플래그 | create/resume 핸들러에 전달되는 값 | +|---|---|---| +| `0` | 없음 | `0` | +| `0` | `--auto-trust` | `1` | +| `1` | `--no-auto-trust` | `0` | + +추가로 두 스크립트에서 추출한 초기화·인자 처리 코드와 실제 `_mam_dialog_pick` 함수를 연결하여, 미설정·빈 문자열·`0`·`1`·`false`·`2`를 각각 검사했다(총 12사례). 미설정/빈 문자열은 `1`로 기본화되며, 정확히 `0`일 때만 신뢰 창을 건너뛴다. `false`와 `2`는 그대로 전달되고 자동 신뢰가 켜진다. 이는 핸들러의 기존 `!= "0"` 규칙과 일치하며 일반적인 boolean 문자열 파서로 바뀐 것은 아니다. + +역검증: 별도 임시 샌드박스에서 해당 스크립트의 초기화 한 줄만 `AUTO_TRUST=1`로 되돌린 뒤, C3/R3b의 환경값 `0`, 플래그 없음 사례를 호출했다. create와 resume 모두 새 회귀의 assertion이 실패했다. 수정된 HEAD에서는 같은 사례가 통과했다. 기존 P1 지적을 해소한다. + +## P2: 쿨다운 중인 신뢰 창이 뒤의 안내를 가리지 않음 + +근거: `.agents/skills/lib.sh:2953` 및 `:2958`. 쿨다운에 걸린 항목에서 검색 전체를 끝내던 `sys.exit(1)`이 다음 항목을 검사하는 `continue`로 바뀌었다. 항목별 쿨다운 판정과 전송 기록은 유지된다. + +재현 화면은 신뢰 문구와 업데이트 안내를 함께 포함한다. + +```text +Do you trust the contents of this directory? + > 1. Yes, continue +Update available + 1. Update now + 2. Skip + 3. Skip until next version +``` + +S6c는 `MAM_AUTO_TRUST=0`, 쿨다운 30초, 핸들러 실행 3회 조건에서 신뢰 키 없이 `2`만 한 번 전송됨을 검증했다. S5f는 자동 신뢰가 켜진 상태와 쿨다운 60초에서 `Enter`, `2`가 이 순서로 각각 한 번 전송됨을 검증했다. 둘 다 통과했다. 기존 S5/S5b를 포함한 선택 테스트도 통과하여, 쿨다운 중 재전송 억제와 만료 후 재시도 동작이 보존됨을 확인했다. + +역검증: 임시 사본에서 이 `continue`만 이전 `sys.exit(1)`로 되돌렸다. S6c는 실제 전송 키가 `[]`여서 실패했고, S5f는 실제 전송 키가 `[Enter]`뿐이어서 실패했다. 즉 새 테스트는 안내 전송 누락을 실제로 검출한다. 기존 P2 지적을 해소한다. + +## 검증 결과와 범위 + +검증 트리의 HEAD/tree가 브리프와 일치하고 작업 트리가 깨끗함을 확인했다. `git diff f4ba205...HEAD`의 6개 파일을 검토했다. 제품 코드·소스 워크트리·실제 에이전트 세션은 수정하지 않았으며, 역검증 변경은 `/tmp/mam-81cd-revert-*`의 테스트 사본에만 적용했다. + +실행한 선택 테스트: + +```text +tests/test_issue23_24_startup_path.py +tests/test_issue24_resume_dialogs.py +-k 's5 or s6 or environment_form or no_auto_trust_reaches' +``` + +- 결과: **20 passed, 61 deselected in 191.05s**. +- 브리프의 Python 인터프리터를 `AGENT_PYTHON_BIN`과 실행 경로에 함께 지정했다. `PYTHONDONTWRITEBYTECODE=1`, `-p no:cacheprovider`, 검증 경로 밖의 고유 `--basetemp=/tmp/mam-review-81cd4006.*`를 사용했다. +- 수정 역검증 4건 모두 새 회귀 assertion이 예상대로 실패했다: 신뢰 거부+안내, 신뢰 수락+안내, create 환경값, resume 환경값. +- 환경값 경계 검사 12건 통과. +- `git diff --check f4ba205 HEAD`, 변경된 셸 파일 3개의 개별 `bash -n`, 변경된 Python 테스트 파일 2개의 AST 구문 검사 통과. + +직전 `f4ba205` 리뷰에서 기록한 247건 통과는 이전 트리의 결과다. 이번 트리에서 247건 전체를 재실행했다고 주장하지 않으며, 이번 검증은 두 정정과 관련 회귀에 집중했다. 델타는 교차 서버 스코프, dispatch 증거, 롤백 구현을 변경하지 않는다. 새 회귀는 샌드박스의 mock herdr/화면 문자열을 사용하므로 실제 CLI 화면 수용 시험을 새로 수행했다는 의미도 아니다. + +단순 수정으로 두 지적이 닫혔으므로 재설계 에스컬레이션은 필요하지 않다. + +[VERDICT: PASS] diff --git a/.agents/reports/reviewer-agy-01/report-471806f8.identity.json b/.agents/reports/reviewer-agy-01/report-471806f8.identity.json new file mode 100644 index 0000000..d63d510 --- /dev/null +++ b/.agents/reports/reviewer-agy-01/report-471806f8.identity.json @@ -0,0 +1,14 @@ +{ + "loop_id": "loop-20260923T220355Z-76dd1d", + "base_sha": "f4ba205c9f755d817e2206c7a9cb390a3e61fd2f", + "head_sha": "05b0ea4762de3fad013e8986d45db21c4e6aa465", + "tree_sha": "8f5e8b0402017d2cc809364e60400c4c25b922f3", + "review_commit": "05b0ea4762de3fad013e8986d45db21c4e6aa465", + "reviewer": "reviewer-agy-01", + "job_id": "471806f8", + "verdict": "PASS", + "transport": "herdr", + "evidence": "review-tree", + "isolation": "worktree", + "verified_at": "2026-09-23T23:35:19Z" +} \ No newline at end of file diff --git a/.agents/reports/reviewer-agy-01/report-471806f8.md b/.agents/reports/reviewer-agy-01/report-471806f8.md new file mode 100644 index 0000000..70ac1ad --- /dev/null +++ b/.agents/reports/reviewer-agy-01/report-471806f8.md @@ -0,0 +1,69 @@ +# Pre-Merge Independent Delta Review Report: PR #35 (`fix/issue-23-24` Round 2) + +- **Job ID**: `471806f8` +- **Target Agent**: `agy` (Session: `herdr:reviewer-agy-01`) +- **Role**: Reviewer +- **Target Worktree Context**: + - **Loop ID**: `loop-20260923T220355Z-76dd1d` + - **Base Commit**: `f4ba205c9f755d817e2206c7a9cb390a3e61fd2f` (Round 1 target) + - **Head Commit**: `05b0ea4762de3fad013e8986d45db21c4e6aa465` (1 delta commit: +76 / −5) + - **Tree SHA**: `8f5e8b0402017d2cc809364e60400c4c25b922f3` + - **Verification Path**: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20260923T220355Z-76dd1d/1` + +--- + +## 1. Executive Summary + +This second review round examines delta commit `05b0ea4` ("fix(issue-23-24): merge review - the environment form of the auto-trust switch reaches the handler; a rate-limited dialog does not hide the next one"). The commit addresses two specific edge-case probes identified during the merge review of PR #35: +1. **Probe P1**: In `create_session.sh` and `resume_session.sh`, hardcoded `AUTO_TRUST=1` unconditionally overwrote the documented environment switch `MAM_AUTO_TRUST=0`. +2. **Probe P2**: In `lib.sh::_mam_dialog_pick`, hitting a dialog within its resend cooldown triggered `sys.exit(1)`, preventing subsequent modal notices (e.g. codex update skip) on the same screen from being answered. + +Both fixes, their regression test coverage (`test_issue23_24_startup_path.py` and `test_issue24_resume_dialogs.py`), and documentation updates in `multi-agent-mux-create/SKILL.md` were evaluated. All 18 targeted tests and git diff validation passed with zero regressions. + +--- + +## 2. Detailed Verification Against Checklist Items + +### (1) Resolution of Codex Probes P1 & P2 and Side-Effect Analysis +- **P1: Environment Variable Inheritance (`AUTO_TRUST="${MAM_AUTO_TRUST:-1}"`)**: + - Previously, `create_session.sh` and `resume_session.sh` set `AUTO_TRUST=1` and subsequently exported `MAM_AUTO_TRUST="$AUTO_TRUST"`, clobbering caller-provided `MAM_AUTO_TRUST=0` when no CLI flag was supplied. + - Initializing `AUTO_TRUST="${MAM_AUTO_TRUST:-1}"` allows `MAM_AUTO_TRUST=0` in the environment to take effect naturally. + - An explicit CLI flag (`--auto-trust` or `--no-auto-trust`) re-assigns `AUTO_TRUST` to `1` or `0`, correctly asserting command-line precedence over environment variables. + - Regression verified by `test_r3b` (resume) and `test_c3` (create) across all combinations (env=0 alone, env=0 with `--auto-trust`, and env=1 with `--no-auto-trust`). +- **P2: Dialog Inspection Fallthrough via `continue`**: + - In `lib.sh::_mam_dialog_pick`, when a dialog matched on screen is within its cooldown window (`0 <= now - last < cooldown`), changing the behavior from `sys.exit(1)` to `continue` permits evaluation of subsequent dialog signatures in the adapter's list. + - If a trust dialog was skipped via `--no-auto-trust` (recorded in `_MAM_DIALOG_SENT`), or if it was answered on the preceding frame and is now cooling, a co-located notice dialog (such as the codex update prompt) is properly evaluated and answered on subsequent iterations. + - Verified by new test `test_s6c` (skipped trust dialog does not hide notice below it) and `test_s5f` (dialog inside cooldown does not block answering the next dialog). +- **Preservation of Cooldown Protection**: + - `test_s5` and `test_s5b` continue to verify that an individual dialog cannot be resent while its cooldown is active, and can only be retried once the cooldown window expires. + - Multi-dialog ordering and execution count remain deterministic: trust dialog answered first (`Enter`), followed by notice dialog (`2`), each sent exactly once (`test_s5f`). + +### (2) Environment Precedence & Value Semantics +- In shell scripts (`create_session.sh` / `resume_session.sh`): + - `AUTO_TRUST="${MAM_AUTO_TRUST:-1}"` defaults to `1` when unset or empty. + - Any user-specified value (e.g. `0`) is preserved and exported. + - Command-line flags `--auto-trust` and `--no-auto-trust` overwrite `AUTO_TRUST` to `1` and `0` respectively, ensuring CLI options win over environment variables. +- In Python (`_mam_dialog_pick`): + - `auto_trust = os.environ.get("MAM_DLG_TRUST", "1") != "0"` cleanly maps string `"0"` to `False`, while any other non-"0" value defaults to `True`. + - Behavior between shell scripts and Python dialog picker is fully aligned. +- Documentation in `multi-agent-mux-create/SKILL.md` explicitly specifies `MAM_AUTO_TRUST=0` and documents that explicit flags take precedence over the environment. + +### (3) Overall Merge Readiness +- Commit `05b0ea4` reinforces the robustness and reliability of PR #35 without altering any public interfaces or state machine rules verified in Round 1. +- Formatting and git diff checks (`git diff f4ba205...HEAD --check`) reported 0 whitespace or formatting issues. +- Version bump to `v4.7.2` remains scheduled for the orchestrator release commit. +- No blockers or regressions detected. + +--- + +## 3. Test Execution Summary + +| Test Suite / Target | Result | Duration | Notes | +| :--- | :--- | :--- | :--- | +| `test_issue23_24_startup_path.py` (`s6c`, `s5f`, `s5`, `s5b`) | **11 / 11 PASS** | 182s | Probe P2 cooldown fallthrough & multi-dialog handling | +| `test_issue24_resume_dialogs.py` (`r3b`, `c3`, `r3`) | **7 / 7 PASS** | 8s | Probe P1 environment variable inheritance & CLI flag precedence | +| `git diff f4ba205...HEAD --check` | **0 errors** | 0.05s | Clean formatting and no whitespace errors | + +--- + +[VERDICT: PASS] diff --git a/.agents/reports/reviewer-agy-01/report-c786c83f.md b/.agents/reports/reviewer-agy-01/report-c786c83f.md new file mode 100644 index 0000000..9aa55bc --- /dev/null +++ b/.agents/reports/reviewer-agy-01/report-c786c83f.md @@ -0,0 +1,123 @@ +# Pre-Merge Independent Review Report: PR #35 (`fix/issue-23-24`) + +- **Job ID**: `c786c83f` +- **Target Agent**: `agy` (Session: `herdr:reviewer-agy-01`) +- **Role**: Reviewer +- **Target Worktree Context**: + - **Loop ID**: `loop-20260923T142448Z-609954` + - **Base Commit**: `1e78dae6ebcb01773df7c4715c98b1786965a24a` (v4.7.1) + - **Head Commit**: `f4ba205c9f755d817e2206c7a9cb390a3e61fd2f` + - **Tree SHA**: `88cb4f34f1e9f8220a373350cfc600a53735f181` + - **Verification Path**: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20260923T142448Z-609954/1` + +--- + +## 1. Executive Summary + +PR #35 addresses Issue #23 and Issue #24 ("교차 서버 herdr 스코프와 에이전트 무관 시작 다이얼로그, 실제 에이전트가 충족하는 온보딩 전달 판정") across 13 commits and 22 files (+2197 / −118). + +The branch eliminates cross-server scope leaks (`HERDR_WORKSPACE_ID` / `HERDR_PANE_ID` / `HERDR_TAB_ID`), externalizes agent start-up modal handling into declarative adapter facts, standardizes protocol-level `started` acknowledgement for agent onboarding, and implements robust fail-closed rollback handling (exit code 43 on unkillable sessions). + +All verification tasks were performed in the isolated verification path. All 73 tests in the new test suites (`test_issue23_24_startup_path.py`, `test_issue24_resume_dialogs.py`) and all 177 regression tests in the adjusted suites passed without error. + +--- + +## 2. Detailed Verification Against Checklist Items + +### (1) Issue #24 Startup Dialogs +- **Adapter-Driven Declarative Modals**: + - Signatures, response keystrokes, and dialog classifications (`trust` vs. `notice`) are defined per agent in `lib_py/agents/adapters/*.py` (`startup_dialogs` property) and exposed via `lib_py.agents facts` (`MAM_STARTUP_DIALOGS`). + - Supported keys match target agent TUI specifications: + * `agy`: `'Do you trust the contents of this project|Yes, I trust this folder'` -> `['Enter']` (`trust`). + * `grok`: `'Do you trust the contents of this directory'` -> `['y']` (`trust`). + * `codex`: `'Do you trust the contents of this directory|1\\. Yes, continue'` -> `['Enter']` (`trust`); `'1\\. Update now|Skip until next version'` -> `['2']` (`notice`, selects "2. Skip"). + * `claude`: Full legacy ladder (`Enter` for trust/proceed, `Escape` for fullscreen upsell modal to preserve execution flags, `Down`+`Enter` for proceed). +- **Cooldown & Ready Token Gating**: + - Because herdr `--source visible` capture retains modal text on-screen temporarily after acceptance, re-sending keystrokes is rate-limited by `MAM_DIALOG_RESEND_S` (default 5s cooldown). + - TUI readiness is strictly judged by adapter ready tokens (`MAM_STRONG_READY_TOKENS` / `MAM_READY_TOKENS`), never by modal disappearance (`test_s5c`, `test_s5d`). +- **`--no-auto-trust` Flag & Workspace Scope**: + - Setting `--no-auto-trust` (`MAM_AUTO_TRUST=0`) leaves dialogs of kind `trust` unanswered (warning printed to stderr for operator confirmation) while continuing to auto-handle `notice` dialogs (`test_s6`). + - Crucial for `codex` security, where directory trust activates project-local configuration, hooks, and execution policies. + - Automatic trust is only sent to sessions launched inside the designated `--workspace`. +- **Contract Parity across Create and Resume**: + - `create_session.sh` and `resume_session.sh` both invoke `handle_startup_dialogs "$SESSION_NAME" "$AGENT"`, parse `--no-auto-trust`, and share `_mam_load_dialog_facts` (`test_r1`, `test_r2`, `test_r3`, `test_r4`). + +### (2) Issue #23 Cross-Server Herdr Scoping +- **Targeted Scope Variable Stripping**: + - In `lib.sh` (`mam_herdr`), `_herdr_call_is_cross_server` strips `HERDR_WORKSPACE_ID`, `HERDR_PANE_ID`, and `HERDR_TAB_ID` via `env -u ...` **only** when targeting a different server (`caller != target`). + - Same-server calls retain workspace/pane scoping, protecting workspace packing and pane isolation (`test_s2`, `test_s1e`). Calls outside panes retain explicit scopes (`test_s2b`). +- **Caller Server Identification**: + - `_herdr_caller_server` checks `HERDR_SESSION` first. If unset (e.g. herdr 0.8.2 on the default server), it parses `HERDR_SOCKET_PATH`: + * `*/.config/herdr/herdr.sock` -> `default` + * `*/.config/herdr/sessions//herdr.sock` -> `` + * Unknown formats -> `""` (treated safely as non-pane). + - Verified across both default and named session socket layouts on herdr 0.8.2 and 0.9.0 (`test_s1c`, `test_s1d`, `test_s1f`, `test_s1g`, `test_s1h`). +- **Python Watcher Parity**: + - `lib_py/loop/watcher.py` implements identical logic (`caller_server()` and `scoped_env()`) for direct subprocess executions of herdr (`test_s13`). + +### (3) Issue #23 Onboarding Delivery Verification +- **Unified Command Builder & Predicate**: + - `delegate_publish_cmd` in `lib.sh` generates the canonical publish command with `--expect-dispatch-token --attempt-id `. + - `test_s7l` confirms `delegate_publish_cmd` produces identical arguments to the delegate-job wrapper's `_pub_cmd`. + - Both `create_session.sh` and the wrapper (`_wait_started`) share `delegate_started_recorded` from `lib.sh`. +- **Dispatch Attribution & Single-Injection Contract**: + - Local events: `/.events.log` requires `event == "started"` and `data.dispatch_token == `. + - Remote events: `//events.ndjson` requires `source_event == "started"`, `dispatch_verified is True`, and `dispatch_fingerprint == sha256()`. + - Prevents a prior attempt's started record from acknowledging a subsequent re-dispatch (`test_w2`, `test_w7`). + - Legacy records lacking a fingerprint fail closed when a token is demanded (`test_w8`). +- **Elimination of Status Bypass**: + - The previous wrapper fallback (`disk_status != pending -> success`) was completely removed. Both create and wrapper now strictly verify `started` event attribution (`test_w1`, `test_w4`). +- **Terminal State Protection**: + - If a job has already transitioned to a terminal status (`completed`, `error`, `cancelled`), `create_session.sh` leaves the job untouched rather than issuing a conflicting state change (`test_s7f`, `test_s7m`). +- **Liveness Parity**: + - Respects Issue #18 liveness contract (T=180s timeout, configurable via `MAM_ONBOARD_ACK_S`). + +### (4) Issue #23 Rollback & Error Handling +- **Kill Confirmation & Exit Code 43**: + - `cleanup_herdr_on_error` checks the returncode of `kill-session` and affirmatively verifies session termination via `has-session`. + - If the session remains alive, the script updates `agent-sessions.yaml` to `status: running` with `last_visible_status: rollback-failed`, clears `delegate_job_id`, prints manual cleanup instructions, and exits with code **43** (`test_s9`). + - If cleanly killed, it marks `status: terminated`, `last_visible_status: rolled-back`, publishes `cancelled` for the onboarding job, and exits (`test_s10`). +- **Session Preservation on Handshake Timeout**: + - If an onboarding `started` event is not observed within `ONBOARD_ACK_S`, the onboarding job is marked `error`, but the session and its pane are **preserved** (`test_s7d`). Operators can re-submit onboarding via `multi-agent-mux-delegate-job submit` once the agent finishes initializing. + +### (5) Impact on Existing Test Suites (6 Files) +- `tests/conftest.py`: Injects `MAM_ONBOARD_ACK_S=3` into the `mam_sandbox` fixture to prevent sandbox runs without real agents from waiting 180s. +- `tests/test_b19_headless_reconcile_fixes.py`: Relocated modal assertion to inspect `claude.py` adapter facts. +- `tests/test_c1_tui_readiness.py`: Updated `test_c1b_hint_press_enter_sends_enter` to reflect time-based cooldown semantics (`MAM_DIALOG_RESEND_S`), verifying `1 <= count <= 30`. +- `tests/test_issue14_delivery_ack.py`: Updated out-of-band agent acknowledgement to publish a genuine `started` event with token via `publish_event.py`. +- `tests/test_issue18_liveness_contract.py`: Assertions match the protocol requirement (`recorded 'started'`). +- `tests/test_issue5_phase2a_loop.py`: Refined G9 guard using Python AST parsing (`_code_only`) to strip only docstring expression nodes while preserving same-line code statements. Rigorously tested by `test_g9b_the_guard_still_catches_an_invocation_and_ignores_prose`. + +### (6) Test Isolation & Reliability +- New suites (`test_issue23_24_startup_path.py` [1268 lines] and `test_issue24_resume_dialogs.py` [127 lines]) operate completely within isolated sandboxes. +- Real `registry.py` and `publish_event.py` utilities are exercised against isolated directories. +- Zero mutation of control plane `.mam`, live agent sessions, or daemon processes. + +### (7) Documentation Integrity +- `multi-agent-mux-create/SKILL.md` and `multi-agent-mux-resume/SKILL.md` accurately document `--no-auto-trust`, exit codes 42/43, and `MAM_ONBOARD_ACK_S`. +- Event and audit log schemas (`dispatch_fingerprint`, `dispatch_verified`) align with `MESSAGING.md`. + +### (8) Merge Readiness +- Version bump to `v4.7.2` is scheduled for the orchestrator release commit. +- Code style, formatting, and diff integrity checks (`git diff 1e78dae...HEAD --check`) reported 0 issues. +- Zero architectural, functional, or regression defects found. + +--- + +## 3. Test Execution Summary + +| Test Suite | Result | Duration | Notes | +| :--- | :--- | :--- | :--- | +| `tests/test_issue23_24_startup_path.py` | **68 / 68 PASS** | 358s | S1–S13, S4–S6, S7–S10, W1–W8 test matrix | +| `tests/test_issue24_resume_dialogs.py` | **5 / 5 PASS** | 8s | R1–R4 resume dialog parity | +| `tests/test_issue5_phase2a_loop.py` | **91 / 91 PASS** | 3.1s | Loop orchestration & updated G9 AST guard | +| `tests/test_issue18_liveness_contract.py` | **31 / 31 PASS** | 112s | Protocol liveness, subscriber audit & broker fallback | +| `tests/test_c1_tui_readiness.py` | **23 / 23 PASS** | 45s | TUI readiness & rate-limited dialog responses | +| `tests/test_b19_headless_reconcile_fixes.py` | **16 / 16 PASS** | 1.8s | Reconcile fixes & dialog exclusion assertions | +| `tests/test_issue14_delivery_ack.py` | **12 / 12 PASS** | 98s | Delivery acknowledgement contracts | +| `tests/test_version_consistency.py` | **3 / 3 PASS** | 0.2s | Version consistency checks | +| `git diff 1e78dae...HEAD --check` | **0 errors** | 0.05s | Clean whitespace and formatting | + +--- + +[VERDICT: PASS] diff --git a/.agents/reports/reviewer-grok-01/report-59e62ce6.md b/.agents/reports/reviewer-grok-01/report-59e62ce6.md new file mode 100644 index 0000000..748b53c --- /dev/null +++ b/.agents/reports/reviewer-grok-01/report-59e62ce6.md @@ -0,0 +1,52 @@ +# 🔍 Review Report — Job 59e62ce6 (PR #35 @ `f4ba205`) + +- **Reviewer**: `reviewer-grok-01` · 2026-09-22 +- **검증 경로**: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20260923T142448Z-609954/1` +- **identity**: base `1e78dae` head `f4ba205` tree `88cb4f3` (일치) +- **본 체크아웃**: `main` `1e78dae` 유지. 소스 워크트리·herdr 미조작. + +`git diff --check 1e78dae...HEAD` 클린. 13커밋. + +## (1) #24 다이얼로그 + +어댑터 `startup_dialogs` (`facts` 로 `MAM_STARTUP_DIALOGS`): + +| 에이전트 | 키 | kind | +|---|---|---| +| agy | Enter | trust | +| grok | `y` | trust | +| codex trust | Enter (`1. Yes, continue` 선선택) | trust | +| codex 업데이트 | **`2`** (Skip) | notice | +| claude | Enter / Escape / Down+Enter | 기존 사다리 | + +브리프의 “`3`+Enter” 와 다름. 코드·테스트(`test_s4`, `test_s6`)는 **`2`** 를 잠근다. 주석: 0.155.1 화면에서 2 가 Skip. `--source visible` 잔상 때문에 재전송은 `MAM_DIALOG_RESEND_S`(기본 5s) 이고 수락은 ready 토큰만 (`lib.sh` `handle_startup_dialogs`). `--no-auto-trust` / `MAM_AUTO_TRUST=0` 은 trust 만 건너뛰고 notice 는 답함. 신뢰는 `--workspace` 로 연 그 세션의 창. create·resume 둘 다 `handle_startup_dialogs "$SESSION" … "$AGENT"` 와 `MAM_AUTO_TRUST`. + +## (2) #23 교차 서버 + +`_herdr_call_is_cross_server` (`lib.sh:1437-1446`): 대상이 비거나 `default` 이면 스트립 안 함(같은 서버·#7 패킹의 `HERDR_WORKSPACE_ID` 유지). 호출자 서버 없음(pane 밖)도 스트립 안 함. `HERDR_SESSION` 이 있으면 소켓보다 우선. 없으면 `HERDR_SOCKET_PATH` 모양: `…/.config/herdr/herdr.sock` → `default`, `…/.config/herdr/sessions//herdr.sock` → ``. 0.8.2 기본 서버 pane 은 `HERDR_SESSION` 이 없어도 소켓으로 판별. 0.9 는 소켓 규약이 같고, 주석의 0.9 차이는 agent status 미보고(`lib.sh:2792`)이지 소켓 형태가 아님. `watcher.scoped_env` 가 같은 규칙. + +## (3) 온보딩 전달 + +`delegate_publish_cmd` / `delegate_started_recorded` 를 create 와 래퍼 `_wait_started` 가 공유. 증거는 **이번 `dispatch_token`** 의 `events.log` `started`, 또는 감사 `events.ndjson` 의 `dispatch_verified` **그리고** `dispatch_fingerprint == sha256(token)`. 지문 없는 옛 기록은 토큰이 있을 때 거부. 레지스트리 status 는 증거가 아님(래퍼 `:641-646`). 이미 터미널인 잡은 `JOB_TERMINALIZED` 로 두 번째 close 를 안 함. T 기본 180, heartbeat 계약 유지. create 는 더 이상 대리 `started` 를 안 찍음. + +## (4) 실패 처리 + +롤백: `kill-session` rc 후 `has-session`. 살아 있으면 행 `status: running`, `last_visible_status: rollback-failed`, `delegate_job_id` 비움, **exit 43**. 죽었으면 `terminated` / `rolled-back`. 온보딩 미확인은 세션 유지, 잡 `error`, `MAM_ONBOARD_ACK_S` 가 대기. create SKILL.md 와 맞음. + +## (5) 기존 테스트 + +G9 는 AST 에서 docstring 노드만 빼고 나머지 코드를 unparse (`test_issue5_phase2a_loop.py:710-729`). `test_g9b` 가 호출·경로 문자열은 여전히 잡고 주석/독스트링은 놓음을 잠금. 의도 약화 아님. issue14 는 started 증거 계약에 맞춰 조정. + +## (6) 새 테스트 + +`test_issue23_24_startup_path.py`·`test_issue24_resume_dialogs.py` 는 mock herdr / tmp. 실제 herdr·홈 미접촉. 검증 경로에서 `AGENT_PYTHON_BIN` 으로 **177 passed in 461.74s** (위 두 파일 + issue5 phase2a + issue14). + +## (7)–(8) 문서·락스텝 + +create SKILL: `--no-auto-trust`, exit 43, `MAM_ONBOARD_ACK_S`. resume SKILL: `--no-auto-trust` 와 같은 핸들러. **비차단**: `MESSAGING.md` 와 delegate-job SKILL.md 본문에 `dispatch_fingerprint` 단어가 없음. 필드는 `job_subscriber.py` 주석과 감사 레코드에 있다. 락스텝은 4.7.1; 4.7.2 는 오케스트레이터 예정. 병합 차단 아님. + +## 종합 + +#23 교차 서버 스코프와 #24 에이전트 무관 다이얼로그, 실제 `started` 로만 온보딩을 판정하는 계약이 코드·테스트와 맞다. 설계 재작업 불필요. + +[VERDICT: PASS] diff --git a/.agents/reports/reviewer-grok-01/report-8352b00e.identity.json b/.agents/reports/reviewer-grok-01/report-8352b00e.identity.json new file mode 100644 index 0000000..9208c6a --- /dev/null +++ b/.agents/reports/reviewer-grok-01/report-8352b00e.identity.json @@ -0,0 +1,14 @@ +{ + "loop_id": "loop-20260923T220355Z-76dd1d", + "base_sha": "f4ba205c9f755d817e2206c7a9cb390a3e61fd2f", + "head_sha": "05b0ea4762de3fad013e8986d45db21c4e6aa465", + "tree_sha": "8f5e8b0402017d2cc809364e60400c4c25b922f3", + "review_commit": "05b0ea4762de3fad013e8986d45db21c4e6aa465", + "reviewer": "reviewer-grok-01", + "job_id": "8352b00e", + "verdict": "PASS", + "transport": "herdr", + "evidence": "review-tree", + "isolation": "worktree", + "verified_at": "2026-09-23T23:35:19Z" +} \ No newline at end of file diff --git a/.agents/reports/reviewer-grok-01/report-8352b00e.md b/.agents/reports/reviewer-grok-01/report-8352b00e.md new file mode 100644 index 0000000..9c41950 --- /dev/null +++ b/.agents/reports/reviewer-grok-01/report-8352b00e.md @@ -0,0 +1,39 @@ +# 🔍 Review Report — Job 8352b00e (PR #35 델타 `f4ba205...05b0ea4`) + +- **Reviewer**: `reviewer-grok-01` · 2026-09-24 +- **검증 경로**: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20260923T220355Z-76dd1d/1` +- **identity**: head `05b0ea4` tree `8f5e8b0` (일치). 1회차 grok PASS @ `f4ba205` 를 뒤집지 않음. +- **본 체크아웃**: `main` `1e78dae` 유지. + +`git diff --check f4ba205...HEAD` 클린. 커밋 1개. + +## (1) 탐침 두 건 — 닫힘 + +**P1.** `create_session.sh:72` 와 `resume_session.sh:33` 이 `AUTO_TRUST="${MAM_AUTO_TRUST:-1}"` 후 플래그 case 가 덮어쓰고, 그 다음 `export MAM_AUTO_TRUST`. 환경만 `0` 이면 핸들러에 `0`. `--auto-trust` / `--no-auto-trust` 가 이김. R3b·C3 각 3사례 통과. + +**P2.** `_mam_dialog_pick` (`lib.sh:2954-2958`) 이 쿨다운 중이면 `sys.exit(1)` 대신 `continue`. 같은 화면의 다음 다이얼로그를 본다. + +- S6c: `--no-auto-trust` + 신뢰 창과 업데이트 안내가 같이 있으면 키는 `2` 한 번, stderr 에 no-auto-trust. +- S5f: 자동 신뢰면 순서 `Enter` 다음 `2`, 각 1회. + +임시 사본에서 `continue` 를 `sys.exit(1)` 로 되돌리면 S6c·S5f **2 failed** (안내는 안 나감). 정정 트리에서는 통과. + +**부작용.** S5(수락된 창은 쿨다운 안 재전송)·S5b(만료 후 1회) 그대로 통과. 한 다이얼로그만 있으면 루프 끝이 여전히 `sys.exit(1)` 이라 “할 일 없음”. + +## (2) `0`/`1` 외 값 + +핸들러는 `MAM_DLG_TRUST != "0"` (`lib.sh` pick 의 `auto_trust`). 리터럴 `"0"` 만 끔. `"false"`/`"no"`/`"00"` 은 켜짐. 빈 환경은 `${MAM_AUTO_TRUST:-1}` 이 `1`. 문서가 약속한 형식은 `MAM_AUTO_TRUST=0` 이라 일치. create SKILL.md 에 환경 형식과 “명시 플래그가 이김”이 있음. + +## (3) 1회차·락스텝 + +교차 서버 스코프, started-only 온보딩, exit 43 은 이 커밋이 건드리지 않음. 4.7.2 bump 는 오케스트레이터 예정. 병합 차단 아님. + +## 테스트 (검증 경로) + +S5, S5b, S5f, S6, S6c, R3b×3, C3×3: **11 passed in 152.03s**. + +## 종합 + +두 탐침이 코드·회귀와 맞다. 쿨다운은 같은 다이얼로그의 재전송만 막고, 다음 다이얼로그는 막지 않는다. 설계 재작업 불필요. + +[VERDICT: PASS] diff --git a/.agents/skills/lib.sh b/.agents/skills/lib.sh index 65c02dc..c595e21 100644 --- a/.agents/skills/lib.sh +++ b/.agents/skills/lib.sh @@ -29,7 +29,7 @@ export WORKSPACE_ROOT # Framework semantic version. Single runtime source of truth; kept in lockstep # with VERSIONS.md and the 8 SKILL.md frontmatters by tests/test_version_consistency.py. # NOTE: unlike other MAM_* variables this one is intentionally NOT env-overridable. -MAM_VERSION="4.7.1" +MAM_VERSION="4.7.2" export MAM_VERSION AGENT_SESSIONS_YAML="${AGENT_SESSIONS_YAML:-$WORKSPACE_ROOT/.mam/agent-sessions.yaml}" diff --git a/.agents/skills/lib_py/loop/eager.py b/.agents/skills/lib_py/loop/eager.py index ba73b19..4c852c8 100644 --- a/.agents/skills/lib_py/loop/eager.py +++ b/.agents/skills/lib_py/loop/eager.py @@ -13,7 +13,7 @@ import sys from typing import List, Sequence # Framework version locked with framework constants -FRAMEWORK_VERSION = "4.7.1" +FRAMEWORK_VERSION = "4.7.2" CORE_MODULES: Sequence[str] = ( # Standard library modules diff --git a/.agents/skills/multi-agent-mux-create/SKILL.md b/.agents/skills/multi-agent-mux-create/SKILL.md index 3c8a378..427a343 100644 --- a/.agents/skills/multi-agent-mux-create/SKILL.md +++ b/.agents/skills/multi-agent-mux-create/SKILL.md @@ -1,7 +1,7 @@ --- name: multi-agent-mux-create description: "Create a new agent session (claude, antigravity/agy) in a dedicated herdr session for context-preserving long-running work. Always creates a herdr session — never backgrounds with nohup/disown. Writes the new session to .mam/agent-sessions.yaml. Use when you want to start a fresh agent (no prior UUID) for a new project workspace." -version: 4.7.1 +version: 4.7.2 author: godopu license: MIT platforms: [linux, macos] diff --git a/.agents/skills/multi-agent-mux-delegate-job/SKILL.md b/.agents/skills/multi-agent-mux-delegate-job/SKILL.md index 52ef4b3..fa32a9c 100644 --- a/.agents/skills/multi-agent-mux-delegate-job/SKILL.md +++ b/.agents/skills/multi-agent-mux-delegate-job/SKILL.md @@ -1,7 +1,7 @@ --- name: multi-agent-mux-delegate-job description: "Delegate a unit of work to any autonomous agent (claude-code, hermes, agy, grok-build, codex, or a human) and observe it asynchronously over an MQTT event channel. Supported roles include orchestrator, worker, and reviewer." -version: 4.7.1 +version: 4.7.2 author: godopu license: MIT platforms: [linux, macos, windows] diff --git a/.agents/skills/multi-agent-mux-loop/SKILL.md b/.agents/skills/multi-agent-mux-loop/SKILL.md index 7d7ed24..1007730 100644 --- a/.agents/skills/multi-agent-mux-loop/SKILL.md +++ b/.agents/skills/multi-agent-mux-loop/SKILL.md @@ -1,7 +1,7 @@ --- name: multi-agent-mux-loop description: "Run an autonomous planning-execution-review loop using multiple agents (Planner, Creator, Reviewers) in the workspace. Automatically orchestrates plan discussion, code changes, and peer reviews until a unanimous PASS is achieved or the maximum iteration limit is reached." -version: 4.7.1 +version: 4.7.2 author: godopu license: MIT platforms: [linux, macos] diff --git a/.agents/skills/multi-agent-mux-monitor/SKILL.md b/.agents/skills/multi-agent-mux-monitor/SKILL.md index 1ee9a02..a22d594 100644 --- a/.agents/skills/multi-agent-mux-monitor/SKILL.md +++ b/.agents/skills/multi-agent-mux-monitor/SKILL.md @@ -1,7 +1,7 @@ --- name: multi-agent-mux-monitor description: "Run a long-lived reconciler that watches .mam/agent-sessions.yaml against the actual herdr/agent runtime state and reconciles them. Use when you want live visibility into which agent sessions are running, which are dead, which have stale YAML entries, and which have new session ids that haven't been recorded yet. Runs as a persistent loop (`reconcile.sh --subscribe`) that keeps going until it times out, idles out, or is interrupted." -version: 4.7.1 +version: 4.7.2 author: godopu license: MIT platforms: [linux, macos] diff --git a/.agents/skills/multi-agent-mux-orc-onboard/SKILL.md b/.agents/skills/multi-agent-mux-orc-onboard/SKILL.md index 03f3290..a2388b5 100644 --- a/.agents/skills/multi-agent-mux-orc-onboard/SKILL.md +++ b/.agents/skills/multi-agent-mux-orc-onboard/SKILL.md @@ -1,7 +1,7 @@ --- name: multi-agent-mux-orc-onboard description: "Register current or specified orchestrator session UUID into agent-sessions.yaml orchestrator_uuids list to prevent sub-agent discovery capture." -version: 4.7.1 +version: 4.7.2 author: godopu license: MIT platforms: [linux, macos] diff --git a/.agents/skills/multi-agent-mux-resume/SKILL.md b/.agents/skills/multi-agent-mux-resume/SKILL.md index 709e879..385744c 100644 --- a/.agents/skills/multi-agent-mux-resume/SKILL.md +++ b/.agents/skills/multi-agent-mux-resume/SKILL.md @@ -1,7 +1,7 @@ --- name: multi-agent-mux-resume description: "Resume an existing agent (claude, antigravity/agy) conversation by UUID into a herdr session. Reads .mam/agent-sessions.yaml for the saved session/conversation id, spawns (or reuses) a herdr session of the matching name, and runs `claude -r ` or `agy --conversation ` 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.7.1 +version: 4.7.2 author: godopu license: MIT platforms: [linux, macos] diff --git a/.agents/skills/multi-agent-mux-status/SKILL.md b/.agents/skills/multi-agent-mux-status/SKILL.md index 87e9d1d..2123559 100644 --- a/.agents/skills/multi-agent-mux-status/SKILL.md +++ b/.agents/skills/multi-agent-mux-status/SKILL.md @@ -1,7 +1,7 @@ --- name: multi-agent-mux-status description: "Read-only instant snapshot of all agent herdr sessions — name, YAML status, herdr alive, pane cmd/cwd, resume UUID on disk, and any drift. No mutation. Reuses reconcile.sh --dry-run for the diff logic. Use when you want to know 'what's running RIGHT NOW' without spinning up the monitor loop." -version: 4.7.1 +version: 4.7.2 author: godopu license: MIT platforms: [linux, macos] diff --git a/.agents/skills/multi-agent-mux-stop/SKILL.md b/.agents/skills/multi-agent-mux-stop/SKILL.md index b0184ac..0f3e1e9 100644 --- a/.agents/skills/multi-agent-mux-stop/SKILL.md +++ b/.agents/skills/multi-agent-mux-stop/SKILL.md @@ -1,7 +1,7 @@ --- name: multi-agent-mux-stop description: "Stop an agent herdr session (claude, antigravity/agy) and update .mam/agent-sessions.yaml. Default stops gracefully and marks status=stopped with conversation preserved for resume. Does NOT delete on-disk conversation artifacts (jsonl/db) — those are preserved unless --purge-conversation is passed. Use when ending a work session, switching to a different one, or cleaning up before a fresh start." -version: 4.7.1 +version: 4.7.2 author: godopu license: MIT platforms: [linux, macos] diff --git a/BOOTSTRAP.ko.md b/BOOTSTRAP.ko.md index b1215e7..1999289 100644 --- a/BOOTSTRAP.ko.md +++ b/BOOTSTRAP.ko.md @@ -2,7 +2,7 @@ 본 문서는 `herdr_agent_orchestration` 오케스트레이션 및 메시징 백플레인 워크플로우를 새로운 프로젝트에 도입하여 이식하고, 새로운 개발자/에이전트가 초기 가동을 시작할 때 수행해야 하는 환경 설정 및 구축 절차를 안내합니다. -[![Version: v4.7.1](https://img.shields.io/badge/version-v4.7.1-blue.svg)](VERSIONS.md) +[![Version: v4.7.2](https://img.shields.io/badge/version-v4.7.2-blue.svg)](VERSIONS.md) 새로운 에이전트는 이 안내서에 기술된 절차를 순차적으로 실행하여 초기 환경을 안정적으로 설정할 수 있습니다. diff --git a/BOOTSTRAP.md b/BOOTSTRAP.md index 05f6908..5a4e99f 100644 --- a/BOOTSTRAP.md +++ b/BOOTSTRAP.md @@ -2,7 +2,7 @@ This document guides you through the setup and initialization procedures required to adopt the `herdr_agent_orchestration` orchestration and messaging backplane workflow in a new project, enabling a new developer or agent to get up and running quickly. -[![Version: v4.7.1](https://img.shields.io/badge/version-v4.7.1-blue.svg)](VERSIONS.md) +[![Version: v4.7.2](https://img.shields.io/badge/version-v4.7.2-blue.svg)](VERSIONS.md) A new agent can follow the steps in this guide sequentially to establish a stable and reliable initial environment. diff --git a/MESSAGING.md b/MESSAGING.md index 1125620..bfe0947 100644 --- a/MESSAGING.md +++ b/MESSAGING.md @@ -1,6 +1,6 @@ # Messaging System Technical Analysis & Architecture Report -Applies to: MAM v4.7.1 +Applies to: MAM v4.7.2 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. diff --git a/README.ko.md b/README.ko.md index 25a75ea..c8573a6 100644 --- a/README.ko.md +++ b/README.ko.md @@ -2,7 +2,7 @@ Herdr와 MQTT 브로커를 기반으로 구축된 고신뢰성 **다중 에이전트 오케스트레이션 및 메시징 백플레인** 프레임워크입니다. `claude`, `agy`, `hermes`, `grok`, `opencode`, `codex` 등 다중 LLM 백엔드 에이전트 전반에 걸쳐 장시간 수행되는 작업(코드 생성, 리팩토링, 보안 검토 등)을 조정, 격리 및 감사할 수 있도록 설계되었습니다. -[![Version: v4.7.1](https://img.shields.io/badge/version-v4.7.1-blue.svg)](VERSIONS.md) +[![Version: v4.7.2](https://img.shields.io/badge/version-v4.7.2-blue.svg)](VERSIONS.md) --- diff --git a/README.md b/README.md index 8bbf741..2868936 100644 --- a/README.md +++ b/README.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`). -[![Version: v4.7.1](https://img.shields.io/badge/version-v4.7.1-blue.svg)](VERSIONS.md) +[![Version: v4.7.2](https://img.shields.io/badge/version-v4.7.2-blue.svg)](VERSIONS.md) --- diff --git a/VERSIONS.md b/VERSIONS.md index 67100ff..8435155 100644 --- a/VERSIONS.md +++ b/VERSIONS.md @@ -6,8 +6,8 @@ ## 📌 현재 버전 개요 (Current Release) -- **프레임워크 버전**: `v4.7.1` -- **최신 릴리스 일시**: 2026-09-22 (KST) +- **프레임워크 버전**: `v4.7.2` +- **최신 릴리스 일시**: 2026-09-23 (KST) - **기준 브랜치**: `main` - **핵심 아키텍처**: - **Terminal 상태 단방향 불변성 (`engine.py`, Issue #6 Phase 1)**: `completed`, `error`, `cancelled` 터미널 상태 진입 후 후속 이벤트나 업데이트에 의한 덮어쓰기를 엄격히 차단(Terminal Immutability). `registry.py close` 서브커맨드 및 fail-closed 조기 종료 도입. @@ -22,23 +22,41 @@ ## 🧭 스킬 패키지 버전 매트릭스 (Skills Version Matrix) -모든 8개 스킬은 YAML frontmatter 메타데이터(`author`, `version`, `platforms`, `environments`) 표준화를 통해 `v4.7.1`로 동기화되어 배포됩니다. +모든 8개 스킬은 YAML frontmatter 메타데이터(`author`, `version`, `platforms`, `environments`) 표준화를 통해 `v4.7.2`로 동기화되어 배포됩니다. | 스킬명 | 버전 | 역할 및 주요 책임 | 상태 | | :--- | :---: | :--- | :---: | -| **`multi-agent-mux-create`** | `4.7.1` | 에이전트 세션 신규 생성 및 Herdr 컨테이너 격리 스폰 | ✅ 배포 | -| **`multi-agent-mux-stop`** | `4.7.1` | 대화 UUID 원자적 캡처 및 세션 안전 종료 (Graceful Stop) | ✅ 배포 | -| **`multi-agent-mux-resume`** | `4.7.1` | 온디스크 대화 컨텍스트 기반 Tier-1 초고속 세션 복원 | ✅ 배포 | -| **`multi-agent-mux-status`** | `4.7.1` | 실시간 Herdr 세션 및 레지스트리 드리프트 스냅샷 조회 | ✅ 배포 | -| **`multi-agent-mux-monitor`** | `4.7.1` | YAML ↔ 런타임 상태 간 자율 조정자 (Reconciler Loop) | ✅ 배포 | -| **`multi-agent-mux-delegate-job`** | `4.7.1` | MQTT 이벤트 채널 기반 비동기 단위 작업 위임 | ✅ 배포 | -| **`multi-agent-mux-loop`** | `4.7.1` | Planner-Creator-Reviewer 3자 자율 계획·실행·피어리뷰 루프 | ✅ 배포 | -| **`multi-agent-mux-orc-onboard`** | `4.7.1` | 오케스트레이터 UUID 격리 등록 및 서브 세션 오염 방지 | ✅ 배포 | +| **`multi-agent-mux-create`** | `4.7.2` | 에이전트 세션 신규 생성 및 Herdr 컨테이너 격리 스폰 | ✅ 배포 | +| **`multi-agent-mux-stop`** | `4.7.2` | 대화 UUID 원자적 캡처 및 세션 안전 종료 (Graceful Stop) | ✅ 배포 | +| **`multi-agent-mux-resume`** | `4.7.2` | 온디스크 대화 컨텍스트 기반 Tier-1 초고속 세션 복원 | ✅ 배포 | +| **`multi-agent-mux-status`** | `4.7.2` | 실시간 Herdr 세션 및 레지스트리 드리프트 스냅샷 조회 | ✅ 배포 | +| **`multi-agent-mux-monitor`** | `4.7.2` | YAML ↔ 런타임 상태 간 자율 조정자 (Reconciler Loop) | ✅ 배포 | +| **`multi-agent-mux-delegate-job`** | `4.7.2` | MQTT 이벤트 채널 기반 비동기 단위 작업 위임 | ✅ 배포 | +| **`multi-agent-mux-loop`** | `4.7.2` | Planner-Creator-Reviewer 3자 자율 계획·실행·피어리뷰 루프 | ✅ 배포 | +| **`multi-agent-mux-orc-onboard`** | `4.7.2` | 오케스트레이터 UUID 격리 등록 및 서브 세션 오염 방지 | ✅ 배포 | --- ## 📋 버전별 상세 변경 내역 (Changelog) +### 🚀 `v4.7.2` — Issue #23·#24 세션 생성 경로: 교차 서버 herdr 스코프, 에이전트 무관 시작 다이얼로그, 실제 에이전트가 충족하는 온보딩 전달 판정 (2026-09-23, PR #35) + +> **패치 마일스톤 (PATCH Release)**: `create_session.sh` 로 새 세션을 만드는 첫 1분 안의 결함 둘을 고쳤다. 오케스트레이터 pane 이 물려주는 herdr 환경(`HERDR_WORKSPACE_ID`·`HERDR_PANE_ID`·`HERDR_TAB_ID`)이 격리 서버 조회를 오염시켜 온보딩 프롬프트가 전달되지 않고, 전달 판정이 화면 기준이라 살아 있는 에이전트를 실패로 판정한 뒤 롤백까지 실패하던 문제(#23)와, 시작 신뢰(trust) 다이얼로그를 claude 에게만 처리해 agy·grok·codex 가 준비 대기 초과로 온보딩을 건너뛰던 문제(#24)다. 옵션 하나(`--no-auto-trust`)와 종료 코드 둘(42·43)이 늘지만 잘못된 기본 동작을 고치는 수정이라 PATCH 로 올린다. + +#### 🌟 주요 개선 사항 +1. **시작 다이얼로그가 에이전트 무관 (#24, `lib_py/agents/base.py: startup_dialogs`, 어댑터 4종, `lib.sh: handle_startup_dialogs`)**: 모달의 시그니처·수락 키·종류(`trust`/`notice`)를 어댑터가 제공하고(`MAM_STARTUP_DIALOGS`), TUI 준비 대기 중 감지되면 수락한다. agy(Enter)·grok(`y`)·codex(`1. Yes, continue` Enter)의 신뢰 창과 codex 0.155.1 업데이트 안내(Skip)를 처리한다. 수락 판정은 다이얼로그 시그니처가 아니라 준비 토큰으로 한다(`--source visible` 이 수락된 창을 한동안 계속 보여준다). `--no-auto-trust` / `MAM_AUTO_TRUST=0` 은 신뢰 창만 사람에게 남기고 안내는 계속 처리한다(codex 의 trust 는 프로젝트 로컬 설정·훅·exec 정책을 로드한다). resume 도 create 와 같은 스위치·계약이다. +2. **교차 서버 호출의 스코프 제거 (#23, `lib.sh`)**: herdr pane 이 내보내는 세 id 는 발급한 서버에서만 뜻이 있으므로 **다른 서버를 겨냥한 호출에서만** `env -u` 로 제거한다. 호출자 서버는 `HERDR_SESSION` 이 있으면 그것, 없으면 `HERDR_SOCKET_PATH` 의 모양에서 구한다(herdr 0.8.2 는 기본 서버 pane 에 `HERDR_SESSION` 을 내보내지 않는다). +3. **온보딩 전달 판정 = 에이전트 자신의 `started` (#23, `lib.sh: delegate_publish_cmd / delegate_started_recorded / delegate_wait_started`, `create_session.sh`, delegate-job 래퍼)**: create 는 더 이상 에이전트 대신 `started` 를 발행하지 않는다. 발행 명령 빌더 하나가 create 온보딩과 래퍼에 같은 모양(dispatch_token·attempt 포함)을 주고, `started` 판정 하나를 두 대기 지점이 공유한다. 이번 dispatch 의 `started` 만 증거이며(로컬: events.log 의 dispatch_token, 원격: 구독자가 HMAC·토큰 검증 뒤 감사 기록에 남기는 `dispatch_fingerprint`(검증한 토큰의 SHA-256)+attempt), 신원 없는 과거 기록은 fail closed 다. 래퍼의 "레코드 상태만 보고 먼저 성공" 우회는 사라졌다. send_keys 의 rc 4(Enter 수락 미관측)는 경고로 낮추고 에이전트의 `started` 가 결정한다. +4. **실패 처리 (#23, `create_session.sh`)**: 롤백이 kill 결과를 확인하고 `has-session` 으로 재확인한다. 못 죽였으면 행에 `rollback-failed` 를 적고 exit 43. 온보딩이 `MAM_ONBOARD_ACK_S`(기본: 래퍼의 주입 ack 유예 → 잡의 ack timeout 180) 안에 확인되지 않아도 세션은 보존하고 잡만 error 로 표시한다(살아 있는 에이전트를 느린 온보딩 때문에 죽이지 않는다; 이미 종료된 잡은 덮어쓰지 않는다). 롤백 시 온보딩 잡은 `cancelled`, YAML 행은 `terminated`/`rollback-failed` 로 표시한다. TUI 가 준비되지 않고 프로세스도 없으면 exit 42. +5. **문서**: create/resume SKILL.md(`--no-auto-trust`, `started` 기반 판정, exit 42·43, `MAM_ONBOARD_ACK_S`, 미확인 온보딩 시 세션 보존 규칙). + +#### ✅ 품질 검증 +- **신규 테스트** (`tests/test_issue23_24_startup_path.py` 1,268줄, `tests/test_issue24_resume_dialogs.py`): 어댑터 다이얼로그 facts, 준비 토큰 기반 수락, `--no-auto-trust`, 교차 서버 스코프 제거(같은 서버는 유지), 공유 `started` 판정(이전 시도·신원 없는 기록 거부, 종료된 잡 보존), 롤백의 kill 확인·exit 43, 미확인 온보딩 시 세션 보존. 픽스처는 실제 `registry.py`·`publish_event.py` 를 쓴다(작성 워크스페이스 리뷰에서 손으로 만든 픽스처가 결함 3건을 가렸던 교훈). 기존 테스트 6파일 조정(G9 가드는 코드만 읽음). +- **실제 herdr 수용 시험 (작성 워크스페이스)**: 오케스트레이터 pane 의 상속 환경을 그대로 둔 채 격리 서버에 agy·grok·codex·opencode 를 순차 생성 — 4건 모두 사람 개입 없이 TUI 준비, 온보딩 전달, `started`(dispatch_token 포함) → `completed`. +- **리뷰**: 작성 워크스페이스 5인 5라운드(차단 지적 3건 — `HERDR_SESSION` 에만 의존한 교차 서버 판정, `data.dispatch_token` 을 요구했으나 실제 발행 명령엔 없던 온보딩 ack, 래퍼의 레코드 상태 우회와 신원 없는 원격 감사 기록 — 모두 해소) 전원 PASS. 병합 워크스페이스에서 PR head 를 detached 워크트리에 받아 재검토: @f4ba205 claude·agy·grok PASS; codex 는 리뷰 도중 호스트가 반복 절전(pmset 로그)에 들어가 API 스트림이 끊기는 바람에 리포트를 남기지 못했고, 대화 기록에서 복원한 탐침 2건(환경변수 `MAM_AUTO_TRUST=0` 이 스크립트의 기본값 export 에 덮여 핸들러에 1 로 도달, 쿨다운에 걸린 다이얼로그가 같은 화면의 다음 다이얼로그를 가림)을 05b0ea4 로 정정. 델타 리뷰 @05b0ea4 claude·agy·grok·codex 4인 전원 PASS(identity 4건 모두 base f4ba205 → head 05b0ea4, evidence review-tree; codex 는 두 정정을 되돌린 사본에서 새 회귀가 실패함을 역검증). 리포트와 identity 사이드카는 `.agents/reports//`. +- **전체 스위트 (병합 워크스페이스, 자체 venv 가 있는 워크트리 @f4ba205)**: 1111 통과 / 1 skip / 실패 0 (51분 21초, 리뷰 루프와 병행); 05b0ea4 에서 startup-path·resume-dialogs·TUI readiness·headless reconcile·version 스위트 123 통과. +- **별건**: 리뷰 루프가 diff 를 단일 argv 로 넘겨 Linux 의 단일 인자 상한(131,072 바이트)을 넘는 브랜치 diff 에서 `OSError: [Errno 7]` 로 죽고 rc 0 으로 끝난다(수집기 상한 `MAM_DIFF_MAX_BYTES` 기본 200000 이 더 높다). 작성 측이 별도 이슈로 등록. 비차단: 감사 기록의 새 필드(`dispatch_verified`·`dispatch_fingerprint`·`attempt_id`)가 MESSAGING.md·delegate-job SKILL.md 에 없음, `MAM_INJECT_ACK_GRACE_S` 가 create 의 대기에도 2순위로 작용한다는 점이 문서에 없음, `MAM_AUTO_TRUST` 는 `0` 만 끈다는 점과 플래그 우선 규칙이 resume SKILL.md 에 없음, codex 업데이트 안내에 `2`(이번만 건너뛰기)를 고르므로 다음 시작 때 다시 뜸(`3` 이면 다음 버전까지 조용함), 다이얼로그 시그니처는 화면의 디렉터리 경로를 검사하지 않음(pane 이 `-c $WORKSPACE` 로 뜨므로 실무 위험 낮음), `test_issue24_resume_dialogs.py` 4건이 각 ~40 s. + ### 🚀 `v4.7.1` — Issue #25 승격된 계획서 재사용은 `--plan-file` 로만: 암묵적 자동 로드 제거, 빈 계획 리포트는 조용히 넘어가지 않음 (2026-09-22, PR #32) > **패치 마일스톤 (PATCH Release)**: `run_loop.sh` 를 `--plan` 없이 실행하면 러너가 planner 를 자동으로 고르고 `.agents/reports//plan-*.md` 중 mtime 이 가장 최근인 파일을 "이미 수립된 계획서"로 creator 에게 넘기던 문제(Issue #25)를 고쳤다. 저장소에는 다른 이슈의 계획서가 수십 개 커밋돼 있고 체크아웃 직후의 mtime 은 의미가 없어, 무관한 계획이 구현 프롬프트에 실렸다(v4.7.0 병합 루프에서도 Issue #6 계획서가 실린 것을 관측). 옵션이 하나 늘지만 잘못된 기본 동작을 없애는 수정이라 PATCH 로 올린다.