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 <noreply@anthropic.com>
1022 lines
49 KiB
Python
1022 lines
49 KiB
Python
"""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 sys
|
||
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", "HERDR_SOCKET_PATH"):
|
||
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"}
|
||
|
||
# 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):
|
||
"""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_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/<name>/) 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."""
|
||
repo = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||
res = subprocess.run(
|
||
[sys.executable, "-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."""
|
||
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"
|
||
|
||
# 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):
|
||
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 ("",)
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 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)
|
||
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 []
|
||
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(
|
||
[sys.executable, "-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
|
||
|
||
|
||
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
|
||
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
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 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])
|
||
|
||
|
||
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)
|
||
# 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
|
||
|
||
|
||
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")
|
||
if seed_events:
|
||
_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_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 + 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 == [], (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):
|
||
"""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_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")
|
||
reordered = {"data": {"attempt_id": 1, "dispatch_token": "tok-1"}, "event": "started",
|
||
"job_id": "job1"}
|
||
(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):
|
||
"""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 == 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. 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):
|
||
"""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 published == ["error"], published
|
||
assert "did not acknowledge the onboarding prompt" in res.stderr
|
||
|
||
|
||
@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 <status> <note>",
|
||
"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
|