- BaseAgentAdapter: id_lifecycle (pre_assigned: claude, grok / post_discovered) and capture_active_session_id - exactly one verified candidate newer than the session that no other row records, else a reason (no-candidate, ambiguous, ...). - agy: verify_artifact (discover) rejects an id last_conversations.json maps to another workspace and always excludes ids other rows record (a cache hit no longer bypasses it). capture uses the cache only as a proposal: the conversation's presence lock or DB must be held by the row's pane process or a descendant (lsof/ps); anything unprovable is not pinned. - lib_py/agents/autopin.py: handle_completed, run by the side that verified the job's completed - the delegate-job wrapper (direct), create --onboard and the loop's wait_for_job (recovery, as a subprocess: G9). Local herdr jobs only; one (job, dispatch token, attempt) is handled once (job-scoped flock + marker in the job dir); writes go through atomic_dump_yaml; a pin records session_id_source=discovered / session_id_verified=true; a failure leaves the reason on the row and never changes the job's result. - reconcile.sh drift C for agy, hermes, opencode and codex calls the same capture (the agy *.db glob is gone); _pin_and_verify_resume uses pin_row. - Tests: tests/test_issue40_auto_pin.py; issue64 :407 now expects False; the static reconcile-source checks follow the move to the adapters; the two tier2 drift-C component tests create with --no-onboard (onboarding now pins). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
600 lines
28 KiB
Python
600 lines
28 KiB
Python
"""Issue #40: a post-discovered session id (agy, opencode, codex, hermes) is
|
|
pinned once a local job's `completed` is verified, by the receiving side
|
|
(lib_py.agents.autopin), and agy never records another workspace's - or
|
|
another session's - conversation.
|
|
|
|
Completion conditions:
|
|
1) an unpinned agy row never records the id of a conversation that only
|
|
another workspace updated: test_agy_verify_rejects_id_cached_for_another_workspace,
|
|
test_reconcile_does_not_pin_another_workspaces_agy_conversation,
|
|
test_autopin_does_not_pin_another_workspaces_agy_conversation
|
|
2) the first completed job pins the session's own id, no daemon:
|
|
test_completed_onboarding_pins_agy_without_daemon (+ reconcile, lib.sh and
|
|
create --onboard variants), test_completed_first_job_pins_opencode_codex_hermes
|
|
3) one pin per job/token/attempt; ambiguity pins nothing and leaves a reason:
|
|
test_wrapper_and_loop_recovery_pin_once, test_ambiguous_candidates_leave_reason_without_pin
|
|
4) pre-assigned rows and rows with an id are untouched:
|
|
test_preassigned_and_existing_ids_are_untouched
|
|
|
|
Everything runs against temporary homes, registries and a mock herdr.
|
|
"""
|
|
import json
|
|
import os
|
|
import sqlite3
|
|
import subprocess
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
import yaml
|
|
|
|
REPO = Path(__file__).resolve().parents[1]
|
|
SKILLS = REPO / ".agents" / "skills"
|
|
|
|
UA = "a4000001-0000-4000-8000-000000000001"
|
|
UB = "b4000002-0000-4000-8000-000000000002"
|
|
UC = "c4000003-0000-4000-8000-000000000003"
|
|
|
|
|
|
# --------------------------------------------------------------------------- helpers
|
|
|
|
def _agy_conv(home, uuid, mtime=None):
|
|
db = Path(home) / ".gemini" / "antigravity-cli" / "conversations" / f"{uuid}.db"
|
|
db.parent.mkdir(parents=True, exist_ok=True)
|
|
conn = sqlite3.connect(str(db))
|
|
conn.execute("CREATE TABLE steps (id INTEGER)")
|
|
conn.execute("INSERT INTO steps VALUES (1)")
|
|
conn.commit()
|
|
conn.close()
|
|
if mtime is not None:
|
|
os.utime(db, (mtime, mtime))
|
|
return db
|
|
|
|
|
|
def _agy_cache(home, mapping):
|
|
lc = Path(home) / ".gemini" / "antigravity-cli" / "cache" / "last_conversations.json"
|
|
lc.parent.mkdir(parents=True, exist_ok=True)
|
|
lc.write_text(json.dumps({str(k): v for k, v in mapping.items()}))
|
|
|
|
|
|
def _row(name, agent, cwd, epoch, pid=4242, **extra):
|
|
own = {"agy": "agy_conversation_id_own", "opencode": "opencode_session_id_own",
|
|
"codex": "codex_session_id_own", "hermes": "hermes_conversation_id_own",
|
|
"claude": "claude_session_id_own", "grok": "grok_session_id_own"}[agent]
|
|
row = {"name": name, "status": "running", "role": "creator", "agent": agent,
|
|
"herdr_session_epoch": epoch, own: None,
|
|
"session_id_source": "pending-discovery", "session_id_verified": False,
|
|
"last_visible_status": "unverified",
|
|
"pane": {"cwd": str(cwd), "pid": pid, "cmd": agent}}
|
|
row.update(extra)
|
|
return row
|
|
|
|
|
|
def _control_plane(root, rows):
|
|
mam = Path(root) / ".mam"
|
|
(mam / "jobs").mkdir(parents=True, exist_ok=True)
|
|
yaml_path = mam / "agent-sessions.yaml"
|
|
yaml_path.write_text(yaml.safe_dump({"herdr_sessions": rows}, sort_keys=False))
|
|
return yaml_path, mam / "jobs"
|
|
|
|
|
|
def _job(jobs_dir, job_id, session, status="completed", token="tok-1", attempt=1,
|
|
transport="herdr", agent="agy"):
|
|
rec = {"job_id": job_id, "status": status, "agent": agent, "agent_session": session,
|
|
"dispatch_token": token, "attempt_id": attempt, "transport": transport}
|
|
(Path(jobs_dir) / f"{job_id}.json").write_text(json.dumps(rec))
|
|
|
|
|
|
def _rows(yaml_path):
|
|
return {r["name"]: r for r in yaml.safe_load(Path(yaml_path).read_text())["herdr_sessions"]}
|
|
|
|
|
|
def _handle(jobs_dir, job_id, yaml_path, home, **kw):
|
|
from lib_py.agents import autopin
|
|
kw.setdefault("viewport", lambda name, agent, cwd: 0)
|
|
kw.setdefault("resume", lambda name, agent, cwd: "resume_verified")
|
|
return autopin.handle_completed(str(jobs_dir), job_id, yaml_path=str(yaml_path), home_dir=str(home), **kw)
|
|
|
|
|
|
@pytest.fixture
|
|
def owners(monkeypatch):
|
|
"""agy presence ownership without lsof/ps: {uuid: [holder pids]} and a
|
|
pid -> ppid table the test fills in."""
|
|
from lib_py.agents.adapters import agy
|
|
held, parents = {}, {}
|
|
|
|
def holders(paths):
|
|
if held.get("__lsof__") is None and "__lsof__" in held:
|
|
return None
|
|
return sorted({p for path in paths for u, pids in held.items() if u in path for p in pids})
|
|
|
|
monkeypatch.setattr(agy, "_holder_pids", holders)
|
|
monkeypatch.setattr(agy, "_process_parents", lambda: dict(parents) or None)
|
|
return held, parents
|
|
|
|
|
|
@pytest.fixture
|
|
def fake_opencode(tmp_path, monkeypatch):
|
|
"""No real opencode is ever asked for its paths: the adapter falls back
|
|
to <home>/.local/share/opencode/opencode.db."""
|
|
bindir = tmp_path / "fakebin"
|
|
bindir.mkdir(exist_ok=True)
|
|
exe = bindir / "opencode"
|
|
exe.write_text("#!/bin/sh\nexit 1\n")
|
|
exe.chmod(0o755)
|
|
monkeypatch.setenv("PATH", f"{bindir}:{os.environ.get('PATH', '')}")
|
|
monkeypatch.delenv("XDG_DATA_HOME", raising=False)
|
|
return exe
|
|
|
|
|
|
def _seed_class_a(agent, home, ws, ws_other, sid, other_sid, started):
|
|
"""One session of `agent` in ws (sid) and one in another workspace (other_sid), both new."""
|
|
if agent == "opencode":
|
|
db = Path(home) / ".local" / "share" / "opencode" / "opencode.db"
|
|
db.parent.mkdir(parents=True, exist_ok=True)
|
|
conn = sqlite3.connect(str(db))
|
|
conn.execute("CREATE TABLE IF NOT EXISTS session_v2 (id TEXT PRIMARY KEY, parent_id TEXT, directory TEXT, time_created INTEGER)")
|
|
for u, w, t in ((sid, ws, started), (other_sid, ws_other, started + 5)):
|
|
conn.execute("INSERT OR REPLACE INTO session_v2 VALUES (?, NULL, ?, ?)", (u, str(w), int(t * 1000)))
|
|
conn.commit()
|
|
conn.close()
|
|
elif agent == "hermes":
|
|
db = Path(home) / ".hermes" / "state.db"
|
|
db.parent.mkdir(parents=True, exist_ok=True)
|
|
conn = sqlite3.connect(str(db))
|
|
conn.execute("CREATE TABLE IF NOT EXISTS sessions (id TEXT PRIMARY KEY, cwd TEXT, started_at REAL)")
|
|
for u, w, t in ((sid, ws, started), (other_sid, ws_other, started + 5)):
|
|
conn.execute("INSERT OR REPLACE INTO sessions VALUES (?, ?, ?)", (u, str(w), t))
|
|
conn.commit()
|
|
conn.close()
|
|
elif agent == "codex":
|
|
codex = Path(home) / ".codex"
|
|
sess = codex / "sessions" / "2026" / "10" / "20"
|
|
sess.mkdir(parents=True, exist_ok=True)
|
|
conn = sqlite3.connect(str(codex / "state_5.sqlite"))
|
|
conn.execute("CREATE TABLE IF NOT EXISTS threads (id TEXT PRIMARY KEY, cwd TEXT, rollout_path TEXT, created_at INTEGER, updated_at INTEGER)")
|
|
for u, w, t in ((sid, ws, started), (other_sid, ws_other, started + 5)):
|
|
rollout = sess / f"rollout-2026-10-20T00-00-00-{u}.jsonl"
|
|
rollout.write_text(json.dumps({"payload": {"id": u, "cwd": str(w)}}) + "\n")
|
|
conn.execute("INSERT OR REPLACE INTO threads VALUES (?, ?, ?, ?, ?)", (u, str(w), str(rollout), int(t), int(t)))
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
|
|
# --------------------------------------------------------------------------- adapter contract
|
|
|
|
def test_adapter_id_lifecycle_classification():
|
|
from lib_py.agents.base import POST_DISCOVERED, PRE_ASSIGNED
|
|
from lib_py.agents.registry import get_adapter
|
|
assert {a: get_adapter(a).id_lifecycle for a in ("agy", "opencode", "codex", "hermes", "claude", "grok")} == {
|
|
"agy": POST_DISCOVERED, "opencode": POST_DISCOVERED, "codex": POST_DISCOVERED,
|
|
"hermes": POST_DISCOVERED, "claude": PRE_ASSIGNED, "grok": PRE_ASSIGNED}
|
|
|
|
|
|
# --------------------------------------------------------------------------- 1) another workspace's agy id
|
|
|
|
def test_agy_verify_rejects_id_cached_for_another_workspace(tmp_path):
|
|
from lib_py.verify_session import verify_session_uuid
|
|
home, ws_a, ws_b = tmp_path / "home", tmp_path / "ws_a", tmp_path / "ws_b"
|
|
ws_a.mkdir()
|
|
ws_b.mkdir()
|
|
epoch = int(time.time()) - 100
|
|
_agy_conv(home, UB)
|
|
_agy_cache(home, {ws_b: UB})
|
|
row_a = _row("ws-a-creator-agy", "agy", ws_a, epoch)
|
|
assert verify_session_uuid(str(ws_a), "agy", UB, row=row_a, home_dir=str(home), mode="discover") is False
|
|
# the same id is fine for the workspace the cache names
|
|
row_b = _row("ws-b-creator-agy", "agy", ws_b, epoch)
|
|
assert verify_session_uuid(str(ws_b), "agy", UB, row=row_b, home_dir=str(home), mode="discover") is True
|
|
|
|
|
|
def _mock_agent(state_file, name, cwd, pid, epoch):
|
|
state = json.loads(Path(state_file).read_text())
|
|
state.setdefault("agents", {})[name] = {"pid": pid, "cwd": str(cwd), "command": "agy", "agent": "agy",
|
|
"created": epoch, "buffer": f"Antigravity {cwd}\n>"}
|
|
Path(state_file).write_text(json.dumps(state))
|
|
|
|
|
|
def _reconcile(sandbox):
|
|
script = sandbox / ".agents" / "skills" / "multi-agent-mux-monitor" / "scripts" / "reconcile.sh"
|
|
res = subprocess.run(["bash", str(script), "--once", "--emit-diff"], capture_output=True, text=True,
|
|
cwd=str(sandbox), env={**os.environ, "WORKSPACE_ROOT": str(sandbox)}, timeout=120)
|
|
assert res.returncode == 0, res.stderr
|
|
return res
|
|
|
|
|
|
def test_reconcile_does_not_pin_another_workspaces_agy_conversation(mam_sandbox, mock_herdr):
|
|
"""The issue's example: proj-A's agy session is unpinned, only proj-B's
|
|
conversation changed after A started. reconcile used to glob every *.db
|
|
and record B's id on A."""
|
|
ws_a, ws_b = mam_sandbox / "proj-a", mam_sandbox / "proj-b"
|
|
ws_a.mkdir()
|
|
ws_b.mkdir()
|
|
epoch = int(time.time()) - 100
|
|
_agy_conv(mam_sandbox, UB)
|
|
_agy_cache(mam_sandbox, {ws_b: UB})
|
|
name = "proj-a-creator-agy"
|
|
yaml_path = mam_sandbox / ".mam" / "agent-sessions.yaml"
|
|
yaml_path.write_text(yaml.safe_dump({"herdr_sessions": [_row(name, "agy", ws_a, epoch, pid=os.getpid())]}))
|
|
_mock_agent(mock_herdr, name, ws_a, os.getpid(), epoch)
|
|
|
|
_reconcile(mam_sandbox)
|
|
row = _rows(yaml_path)[name]
|
|
assert row["status"] == "running"
|
|
assert row["agy_conversation_id_own"] is None
|
|
|
|
|
|
def test_autopin_does_not_pin_another_workspaces_agy_conversation(tmp_path, owners):
|
|
home, ws_a, ws_b = tmp_path / "home", tmp_path / "ws_a", tmp_path / "ws_b"
|
|
ws_a.mkdir()
|
|
ws_b.mkdir()
|
|
epoch = int(time.time()) - 100
|
|
_agy_conv(home, UB)
|
|
_agy_cache(home, {ws_b: UB})
|
|
held, _ = owners
|
|
held[UB] = [4242] # even a holder that looks like A's pane does not make B's id A's
|
|
yaml_path, jobs = _control_plane(tmp_path / "ctl", [_row("a-agy", "agy", ws_a, epoch, pid=4242)])
|
|
_job(jobs, "j1", "herdr:a-agy")
|
|
res = _handle(jobs, "j1", yaml_path, home)
|
|
assert res["status"] == "no-candidate"
|
|
row = _rows(yaml_path)["a-agy"]
|
|
assert row["agy_conversation_id_own"] is None
|
|
assert "autopin no-candidate (job j1)" in row["last_visible_status"]
|
|
|
|
|
|
# --------------------------------------------------------------------------- 2) first completed job pins
|
|
|
|
def test_completed_onboarding_pins_agy_without_daemon(tmp_path, owners, monkeypatch):
|
|
"""A's onboarding completed; B's conversation was updated meanwhile (and
|
|
later than A's). A gets A's id, owned by A's pane."""
|
|
monkeypatch.setenv("MAM_NO_SUBSCRIBER_DAEMON", "1")
|
|
home, ws_a, ws_b = tmp_path / "home", tmp_path / "ws_a", tmp_path / "ws_b"
|
|
ws_a.mkdir()
|
|
ws_b.mkdir()
|
|
now = time.time()
|
|
epoch = int(now) - 100
|
|
_agy_conv(home, UA, mtime=now - 10)
|
|
_agy_conv(home, UB, mtime=now)
|
|
_agy_cache(home, {ws_a: UA, ws_b: UB})
|
|
held, _ = owners
|
|
held[UA], held[UB] = [5001], [6001]
|
|
yaml_path, jobs = _control_plane(tmp_path / "ctl", [_row("a-agy", "agy", ws_a, epoch, pid=5001)])
|
|
_job(jobs, "onb1", "herdr:a-agy")
|
|
|
|
res = _handle(jobs, "onb1", yaml_path, home)
|
|
assert res["status"] == "pinned", res
|
|
row = _rows(yaml_path)["a-agy"]
|
|
assert row["agy_conversation_id_own"] == UA
|
|
assert row["session_id_source"] == "discovered"
|
|
assert row["session_id_verified"] is True
|
|
assert row["last_visible_status"] == "resume_verified"
|
|
|
|
|
|
@pytest.mark.parametrize("agent,sid,other", [("opencode", "ses_own_a", "ses_other_b"),
|
|
("codex", UA, UB), ("hermes", "h-own-a", "h-other-b")])
|
|
def test_completed_first_job_pins_opencode_codex_hermes(tmp_path, monkeypatch, fake_opencode, agent, sid, other):
|
|
home, ws, ws_other = tmp_path / "home", tmp_path / "ws", tmp_path / "ws_other"
|
|
ws.mkdir()
|
|
ws_other.mkdir()
|
|
monkeypatch.setenv("HOME", str(home))
|
|
epoch = int(time.time()) - 100
|
|
_seed_class_a(agent, home, ws, ws_other, sid, other, started=epoch + 10)
|
|
yaml_path, jobs = _control_plane(tmp_path / "ctl", [_row(f"w-creator-{agent}", agent, ws, epoch)])
|
|
_job(jobs, "first", f"herdr:w-creator-{agent}", agent=agent)
|
|
|
|
res = _handle(jobs, "first", yaml_path, home)
|
|
assert res["status"] == "pinned", res
|
|
row = _rows(yaml_path)[f"w-creator-{agent}"]
|
|
from lib_py.agents.registry import own_key
|
|
assert row[own_key(agent)] == sid
|
|
assert (row["session_id_source"], row["session_id_verified"]) == ("discovered", True)
|
|
|
|
|
|
def _fake_lsof(bindir, uuid, pid):
|
|
exe = Path(bindir) / "lsof"
|
|
exe.write_text(f'#!/bin/sh\nfor a in "$@"; do case "$a" in *{uuid}*) echo {pid}; exit 0;; esac; done\nexit 1\n')
|
|
exe.chmod(0o755)
|
|
|
|
|
|
def test_reconcile_pins_agy_owned_by_the_pane(mam_sandbox, mock_herdr):
|
|
"""drift C uses the same capture: A's cached id, held by A's pane, is pinned;
|
|
B's newer conversation in another workspace is not considered."""
|
|
ws_a, ws_b = mam_sandbox / "proj-a", mam_sandbox / "proj-b"
|
|
ws_a.mkdir()
|
|
ws_b.mkdir()
|
|
now = time.time()
|
|
epoch = int(now) - 100
|
|
_agy_conv(mam_sandbox, UA, mtime=now - 10)
|
|
_agy_conv(mam_sandbox, UB, mtime=now)
|
|
_agy_cache(mam_sandbox, {ws_a: UA, ws_b: UB})
|
|
_fake_lsof(mam_sandbox / "bin", UA, os.getpid())
|
|
name = "proj-a-creator-agy"
|
|
yaml_path = mam_sandbox / ".mam" / "agent-sessions.yaml"
|
|
yaml_path.write_text(yaml.safe_dump({"herdr_sessions": [_row(name, "agy", ws_a, epoch, pid=os.getpid())]}))
|
|
_mock_agent(mock_herdr, name, ws_a, os.getpid(), epoch)
|
|
|
|
_reconcile(mam_sandbox)
|
|
row = _rows(yaml_path)[name]
|
|
assert row["agy_conversation_id_own"] == UA
|
|
assert (row["session_id_source"], row["session_id_verified"]) == ("discovered", True)
|
|
|
|
|
|
def test_lib_sh_helper_pins_through_the_cli(mam_sandbox, mock_herdr, fake_opencode):
|
|
"""The wrapper's and create's entry point: lib.sh delegate_autopin_completed
|
|
runs `python -m lib_py.agents.autopin` against the sandbox control plane."""
|
|
ws, ws_other = mam_sandbox / "proj", mam_sandbox / "other"
|
|
ws.mkdir()
|
|
ws_other.mkdir()
|
|
epoch = int(time.time()) - 100
|
|
_seed_class_a("opencode", mam_sandbox, ws, ws_other, "ses_own", "ses_other", started=epoch + 10)
|
|
yaml_path = mam_sandbox / ".mam" / "agent-sessions.yaml"
|
|
name = "proj-creator-opencode"
|
|
yaml_path.write_text(yaml.safe_dump({"herdr_sessions": [_row(name, "opencode", ws, epoch)]}))
|
|
jobs = mam_sandbox / ".mam" / "jobs"
|
|
jobs.mkdir(parents=True, exist_ok=True)
|
|
_job(jobs, "jcli", f"herdr:{name}", agent="opencode")
|
|
|
|
lib_sh = mam_sandbox / ".agents" / "skills" / "lib.sh"
|
|
res = subprocess.run(["bash", "-c", f'source "{lib_sh}" && delegate_autopin_completed jcli tok-1 "{jobs}"'],
|
|
capture_output=True, text=True, env=dict(os.environ), timeout=120)
|
|
assert res.returncode == 0, res.stderr
|
|
row = _rows(yaml_path)[name]
|
|
assert row["opencode_session_id_own"] == "ses_own", res.stderr
|
|
marker = json.loads((jobs / "jcli" / "autopin.json").read_text())
|
|
assert marker["key"] == "jcli:tok-1:1" and marker["result"]["status"] == "pinned"
|
|
# a stale dispatch token is not this attempt's completion
|
|
_job(jobs, "jstale", f"herdr:{name}", agent="opencode", token="tok-new")
|
|
subprocess.run(["bash", "-c", f'source "{lib_sh}" && delegate_autopin_completed jstale tok-old "{jobs}"'],
|
|
capture_output=True, text=True, env=dict(os.environ), timeout=120)
|
|
assert not (jobs / "jstale" / "autopin.json").exists()
|
|
|
|
|
|
def test_create_onboard_pins_without_daemon(mam_sandbox, mock_herdr, mock_agents):
|
|
"""create --onboard with MAM_NO_SUBSCRIBER_DAEMON=1 (mam_sandbox): the
|
|
mock agent completes the onboarding and create runs the handler itself."""
|
|
create = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh"
|
|
res = subprocess.run(["bash", str(create), "--workspace", str(mam_sandbox), "--agent", "opencode",
|
|
"--role", "creator", "--onboard"],
|
|
capture_output=True, text=True, cwd=str(mam_sandbox), timeout=300)
|
|
assert res.returncode == 0, res.stderr
|
|
yaml_path = mam_sandbox / ".mam" / "agent-sessions.yaml"
|
|
(row,) = _rows(yaml_path).values()
|
|
job = row["delegate_job_id"]
|
|
marker = json.loads((mam_sandbox / ".mam" / "jobs" / job / "autopin.json").read_text())
|
|
# create's async reconcile may get there first; either way the handler ran
|
|
assert marker["result"]["status"] in ("pinned", "already-set"), marker
|
|
assert row["opencode_session_id_own"]
|
|
assert (row["session_id_source"], row["session_id_verified"]) == ("discovered", True)
|
|
|
|
|
|
# --------------------------------------------------------------------------- agy: the cwd cache proves nothing
|
|
|
|
def test_agy_same_cwd_sibling_never_cross_pins(tmp_path, owners):
|
|
"""Two agy sessions in one cwd; the cache names U, whose presence lock A's
|
|
pane holds. Only A may get U - before and after A is pinned."""
|
|
from lib_py.verify_session import verify_session_uuid
|
|
home, ws = tmp_path / "home", tmp_path / "ws"
|
|
ws.mkdir()
|
|
epoch = int(time.time()) - 100
|
|
_agy_conv(home, UA)
|
|
_agy_cache(home, {ws: UA})
|
|
held, parents = owners
|
|
held[UA] = [5001]
|
|
parents.update({5001: 1, 6001: 1})
|
|
yaml_path, jobs = _control_plane(tmp_path / "ctl", [_row("a-agy", "agy", ws, epoch, pid=5001),
|
|
_row("b-agy", "agy", ws, epoch, pid=6001)])
|
|
_job(jobs, "jb1", "herdr:b-agy")
|
|
assert _handle(jobs, "jb1", yaml_path, home)["status"] == "owner-mismatch"
|
|
_job(jobs, "ja1", "herdr:a-agy")
|
|
assert _handle(jobs, "ja1", yaml_path, home)["status"] == "pinned"
|
|
_job(jobs, "jb2", "herdr:b-agy")
|
|
assert _handle(jobs, "jb2", yaml_path, home)["status"] == "sibling-claimed"
|
|
rows = _rows(yaml_path)
|
|
assert rows["a-agy"]["agy_conversation_id_own"] == UA
|
|
assert rows["b-agy"]["agy_conversation_id_own"] is None
|
|
# a cache hit no longer bypasses the sibling exclusion
|
|
row_b = dict(rows["b-agy"], _sibling_claimed_uuids=[UA])
|
|
assert verify_session_uuid(str(ws), "agy", UA, row=row_b, home_dir=str(home), mode="discover") is False
|
|
|
|
|
|
@pytest.mark.parametrize("holders,parents,expected", [
|
|
(None, {}, "unverifiable-owner"), # lsof missing or failed
|
|
([], {}, "unverifiable-owner"), # nobody holds the lock
|
|
([7001], {7001: 1, 5001: 1}, "owner-mismatch"), # held outside the pane's tree
|
|
([7001], {7001: 7000, 7000: 5001, 5001: 1}, "captured"), # a descendant of the pane
|
|
])
|
|
def test_agy_capture_requires_presence_owned_by_pane(tmp_path, owners, holders, parents, expected):
|
|
from lib_py.agents.autopin import capture
|
|
home, ws = tmp_path / "home", tmp_path / "ws"
|
|
ws.mkdir()
|
|
epoch = int(time.time()) - 100
|
|
_agy_conv(home, UA)
|
|
_agy_cache(home, {ws: UA})
|
|
held, ppid = owners
|
|
if holders is None:
|
|
held["__lsof__"] = None
|
|
else:
|
|
held[UA] = holders
|
|
ppid.update(parents)
|
|
row = _row("a-agy", "agy", ws, epoch, pid=5001)
|
|
res = capture(row, "agy", {"herdr_sessions": [row]}, home_dir=str(home))
|
|
assert res["status"] == expected, res
|
|
assert (res["session_id"] == UA) == (expected == "captured")
|
|
|
|
|
|
def test_agy_holder_pids_reads_lsof(tmp_path, monkeypatch):
|
|
"""The real helper: lsof's pids, [] when it finds no holder, None without lsof."""
|
|
from lib_py.agents.adapters import agy
|
|
f = tmp_path / "x.lock"
|
|
f.write_text("")
|
|
bindir = tmp_path / "bin"
|
|
bindir.mkdir()
|
|
monkeypatch.setenv("PATH", str(bindir))
|
|
assert agy._holder_pids([str(f)]) is None
|
|
_fake_lsof(bindir, "x.lock", 4321)
|
|
assert agy._holder_pids([str(f)]) == [4321]
|
|
assert agy._holder_pids([str(tmp_path / "missing.lock")]) == []
|
|
(bindir / "lsof").write_text("#!/bin/sh\nexit 1\n")
|
|
assert agy._holder_pids([str(f)]) == []
|
|
|
|
|
|
# --------------------------------------------------------------------------- 3) once per job, ambiguity
|
|
|
|
def _count_captures(monkeypatch):
|
|
from lib_py.agents import autopin
|
|
calls = []
|
|
real = autopin.capture
|
|
|
|
def counting(*a, **kw):
|
|
calls.append(a[0].get("name"))
|
|
return real(*a, **kw)
|
|
|
|
monkeypatch.setattr(autopin, "capture", counting)
|
|
return calls
|
|
|
|
|
|
def _loop_runner(mam_dir, workspace_root=REPO):
|
|
from lib_py.loop.runner import LoopRunner
|
|
r = LoopRunner.__new__(LoopRunner)
|
|
r.workspace_root = Path(workspace_root)
|
|
r.mam_dir, r.jobs_dir = Path(mam_dir), Path(mam_dir) / "jobs"
|
|
r.failed_submit_jobs, r._stall_seen = set(), {}
|
|
r.verbose, r.interrupted = False, False
|
|
return r
|
|
|
|
|
|
def test_wrapper_and_loop_recovery_pin_once(tmp_path, monkeypatch, fake_opencode):
|
|
home, ws, ws_other = tmp_path / "home", tmp_path / "ws", tmp_path / "ws_other"
|
|
ws.mkdir()
|
|
ws_other.mkdir()
|
|
epoch = int(time.time()) - 100
|
|
_seed_class_a("opencode", home, ws, ws_other, "ses_own", "ses_other", started=epoch + 10)
|
|
yaml_path, jobs = _control_plane(tmp_path / "ctl", [_row("w-opencode", "opencode", ws, epoch)])
|
|
monkeypatch.setenv("AGENT_SESSIONS_YAML", str(yaml_path))
|
|
monkeypatch.setenv("HOME_DIR", str(home))
|
|
_job(jobs, "dup", "herdr:w-opencode", agent="opencode")
|
|
calls = _count_captures(monkeypatch)
|
|
|
|
first = _handle(jobs, "dup", yaml_path, home) # the wrapper's subscriber terminal
|
|
assert first["status"] == "pinned"
|
|
written = yaml_path.stat().st_mtime_ns
|
|
marker = (jobs / "dup" / "autopin.json").read_text()
|
|
# loop recovery (a subprocess of the same handler): deduplicated, nothing rewritten
|
|
assert _loop_runner(tmp_path / "ctl" / ".mam").wait_for_job("dup", max_wait=5) is True
|
|
assert (jobs / "dup" / "autopin.json").read_text() == marker
|
|
again = _handle(jobs, "dup", yaml_path, home)
|
|
assert again.get("deduplicated") is True and again["status"] == "pinned"
|
|
assert calls == ["w-opencode"]
|
|
assert yaml_path.stat().st_mtime_ns == written
|
|
assert _rows(yaml_path)["w-opencode"]["opencode_session_id_own"] == "ses_own"
|
|
|
|
|
|
def test_loop_recovery_pins_when_the_wrapper_did_not(mam_sandbox, mock_herdr, fake_opencode):
|
|
"""wait_for_job's recovery call alone pins the id (the wrapper's call was missed)."""
|
|
ws, ws_other = mam_sandbox / "proj", mam_sandbox / "other"
|
|
ws.mkdir()
|
|
ws_other.mkdir()
|
|
epoch = int(time.time()) - 100
|
|
_seed_class_a("opencode", mam_sandbox, ws, ws_other, "ses_own", "ses_other", started=epoch + 10)
|
|
yaml_path = mam_sandbox / ".mam" / "agent-sessions.yaml"
|
|
name = "proj-creator-opencode"
|
|
yaml_path.write_text(yaml.safe_dump({"herdr_sessions": [_row(name, "opencode", ws, epoch)]}))
|
|
jobs = mam_sandbox / ".mam" / "jobs"
|
|
jobs.mkdir(parents=True, exist_ok=True)
|
|
_job(jobs, "jloop", f"herdr:{name}", agent="opencode")
|
|
|
|
assert _loop_runner(mam_sandbox / ".mam", workspace_root=mam_sandbox).wait_for_job("jloop", max_wait=5) is True
|
|
row = _rows(yaml_path)[name]
|
|
assert row["opencode_session_id_own"] == "ses_own"
|
|
assert json.loads((jobs / "jloop" / "autopin.json").read_text())["result"]["status"] == "pinned"
|
|
|
|
|
|
def test_ambiguous_candidates_leave_reason_without_pin(tmp_path, monkeypatch, fake_opencode):
|
|
home, ws = tmp_path / "home", tmp_path / "ws"
|
|
ws.mkdir()
|
|
epoch = int(time.time()) - 100
|
|
_seed_class_a("opencode", home, ws, ws, "ses_one", "ses_two", started=epoch + 10) # both in ws
|
|
yaml_path, jobs = _control_plane(tmp_path / "ctl", [_row("w-opencode", "opencode", ws, epoch)])
|
|
_job(jobs, "amb", "herdr:w-opencode", agent="opencode")
|
|
calls = _count_captures(monkeypatch)
|
|
|
|
res = _handle(jobs, "amb", yaml_path, home)
|
|
assert res["status"] == "ambiguous" and sorted(res["candidates"]) == ["ses_one", "ses_two"]
|
|
row = _rows(yaml_path)["w-opencode"]
|
|
assert row["opencode_session_id_own"] is None
|
|
assert row["last_visible_status"].startswith("autopin ambiguous (job amb): 2 candidates")
|
|
assert _handle(jobs, "amb", yaml_path, home).get("deduplicated") is True
|
|
assert calls == ["w-opencode"]
|
|
|
|
|
|
# --------------------------------------------------------------------------- 4) untouched rows, skipped jobs
|
|
|
|
def test_preassigned_and_existing_ids_are_untouched(tmp_path, owners, fake_opencode):
|
|
home, ws = tmp_path / "home", tmp_path / "ws"
|
|
ws.mkdir()
|
|
epoch = int(time.time()) - 100
|
|
_agy_conv(home, UB)
|
|
_agy_cache(home, {ws: UB})
|
|
held, _ = owners
|
|
held[UB] = [5003]
|
|
_seed_class_a("opencode", home, ws, ws, "ses_x", "ses_y", started=epoch + 10)
|
|
rows = [
|
|
_row("c-claude", "claude", ws, epoch, claude_session_id_own=UA, session_id_source="assigned"),
|
|
_row("g-grok", "grok", ws, epoch, grok_session_id_own=UC, session_id_source="assigned"),
|
|
_row("p-agy", "agy", ws, epoch, pid=5003, agy_conversation_id_own="agy-pinned-id",
|
|
session_id_source="discovered", session_id_verified=True, last_visible_status="resume_verified"),
|
|
_row("o-opencode", "opencode", ws, epoch, opencode_session_id_own="ses_kept"),
|
|
]
|
|
yaml_path, jobs = _control_plane(tmp_path / "ctl", rows)
|
|
before = _rows(yaml_path)
|
|
for i, r in enumerate(rows):
|
|
_job(jobs, f"k{i}", f"herdr:{r['name']}", agent=r["agent"])
|
|
assert _handle(jobs, f"k{i}", yaml_path, home)["status"] in ("pre-assigned", "already-set")
|
|
assert _rows(yaml_path) == before
|
|
|
|
|
|
@pytest.mark.parametrize("change,reason", [
|
|
({"transport": "remote", "session": "remote:box"}, "not a local agent session"),
|
|
({"agent": "human", "session": "human"}, "not a local agent session"),
|
|
({"status": "error"}, "job j is error"),
|
|
({"status": "running"}, "job j is running"),
|
|
])
|
|
def test_ineligible_jobs_pin_nothing(tmp_path, owners, change, reason):
|
|
home, ws = tmp_path / "home", tmp_path / "ws"
|
|
ws.mkdir()
|
|
epoch = int(time.time()) - 100
|
|
_agy_conv(home, UA)
|
|
_agy_cache(home, {ws: UA})
|
|
owners[0][UA] = [5001]
|
|
yaml_path, jobs = _control_plane(tmp_path / "ctl", [_row("a-agy", "agy", ws, epoch, pid=5001)])
|
|
_job(jobs, "j", change.get("session", "herdr:a-agy"), status=change.get("status", "completed"),
|
|
transport=change.get("transport", "herdr"), agent=change.get("agent", "agy"))
|
|
res = _handle(jobs, "j", yaml_path, home)
|
|
assert (res["status"], res["reason"]) == ("skipped", reason)
|
|
assert _rows(yaml_path)["a-agy"]["agy_conversation_id_own"] is None
|
|
|
|
|
|
def test_stale_dispatch_token_pins_nothing(tmp_path, owners):
|
|
home, ws = tmp_path / "home", tmp_path / "ws"
|
|
ws.mkdir()
|
|
_agy_conv(home, UA)
|
|
_agy_cache(home, {ws: UA})
|
|
owners[0][UA] = [5001]
|
|
yaml_path, jobs = _control_plane(tmp_path / "ctl", [_row("a-agy", "agy", ws, int(time.time()) - 100, pid=5001)])
|
|
_job(jobs, "j", "herdr:a-agy", token="tok-2")
|
|
res = _handle(jobs, "j", yaml_path, home, expect_token="tok-1")
|
|
assert (res["status"], res["reason"]) == ("skipped", "stale dispatch token")
|
|
assert not (jobs / "j" / "autopin.json").exists()
|
|
|
|
|
|
def test_call_sites_and_publisher_boundary():
|
|
"""The wrapper's direct path and create --onboard call the handler on
|
|
`completed`; the agent-run publisher never touches the session registry."""
|
|
wrapper = (SKILLS / "multi-agent-mux-delegate-job" / "multi-agent-mux-delegate-job").read_text()
|
|
assert 'delegate_autopin_completed "$JOB_ID" "$_disp_tok" "$REGISTRY_DIR"' in wrapper
|
|
create = (SKILLS / "multi-agent-mux-create" / "scripts" / "create_session.sh").read_text()
|
|
assert 'delegate_autopin_completed "$DELEGATE_JOB_ID" "$DELEGATE_JOB_TOKEN"' in create
|
|
for f in ("publish_event.py", "publisher.py"):
|
|
src = (SKILLS / "lib_py" / "delegation" / f).read_text()
|
|
assert "atomic_dump_yaml" not in src and "agent-sessions" not in src and "autopin" not in src
|