- 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>
262 lines
12 KiB
Python
262 lines
12 KiB
Python
"""Auto-pin of a post-discovered session id on a verified local `completed` (Issue #40).
|
|
|
|
Class A agents (agy, opencode, codex, hermes) write their conversation id to
|
|
disk only after their first turn, so create records the row without one. The
|
|
side that verified a job's `completed` - the delegate-job wrapper, create
|
|
--onboard, and the loop's wait_for_job as a recovery path - calls
|
|
handle_completed(). It captures the id through the adapter
|
|
(capture_active_session_id) and pins it with pin_row() under atomic_dump_yaml.
|
|
reconcile's drift C calls the same capture() and pin_row(), so the two paths
|
|
reach one decision.
|
|
|
|
One (job_id, dispatch_token, attempt_id) is handled once: a job-scoped flock
|
|
and a marker in the job's directory make a second call (wrapper + loop
|
|
recovery) a no-op. Remote and human jobs have no local disk or pane and are
|
|
skipped; the agent's own publish_event.py never writes the control plane
|
|
(#15/#20/#21). A failed pin never changes the job's result: the reason is
|
|
written on the row and returned.
|
|
|
|
CLI: python -m lib_py.agents.autopin --registry-dir DIR --job ID
|
|
[--yaml AGENT_SESSIONS_YAML] [--expect-dispatch-token TOKEN]
|
|
"""
|
|
|
|
import argparse
|
|
import fcntl
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
from typing import Any, Callable, Dict, List, Optional
|
|
|
|
from lib_py.agents.base import DiscoveryContext, POST_DISCOVERED, capture_result
|
|
from lib_py.agents.registry import _ADAPTERS, agent_of_row, get_adapter
|
|
|
|
OWN_KEYS = tuple(a.own_key for a in _ADAPTERS.values())
|
|
MARKER = "autopin.json"
|
|
|
|
|
|
def claimed_elsewhere(row: Dict[str, Any], state: Dict[str, Any]) -> List[str]:
|
|
"""Every id another row records: an id already some session's is never this one's."""
|
|
ids: List[str] = []
|
|
for other in state.get("herdr_sessions") or []:
|
|
if other is row or other.get("name") == row.get("name"):
|
|
continue
|
|
for key in OWN_KEYS:
|
|
v = other.get(key)
|
|
if v and v not in ids:
|
|
ids.append(v)
|
|
return ids
|
|
|
|
|
|
def capture(row: Dict[str, Any], agent: str, state: Dict[str, Any], home_dir: Optional[str] = None) -> Dict[str, Any]:
|
|
"""The adapter's decision for this row (read-only). Besides the capture
|
|
statuses: 'pre-assigned' and 'already-set' (nothing to capture), 'skipped'."""
|
|
adapter = get_adapter(agent)
|
|
if adapter is None:
|
|
return capture_result("skipped", reason=f"unknown agent {agent!r}")
|
|
if adapter.id_lifecycle != POST_DISCOVERED:
|
|
return capture_result("pre-assigned")
|
|
if row.get(adapter.own_key):
|
|
return capture_result("already-set", session_id=row[adapter.own_key])
|
|
cwd = (row.get("pane") or {}).get("cwd") or ""
|
|
if not cwd:
|
|
return capture_result("no-candidate", reason="the row has no pane cwd")
|
|
row_eval = dict(row)
|
|
row_eval["_sibling_claimed_uuids"] = claimed_elsewhere(row, state)
|
|
ctx = DiscoveryContext(workspace=cwd, agent_name=agent, home_dir=home_dir,
|
|
epoch=row.get("herdr_session_epoch") or 0, row=row_eval, mode="discover")
|
|
return adapter.capture_active_session_id(ctx)
|
|
|
|
|
|
def pin_row(row: Dict[str, Any], agent: str, uuid: str) -> None:
|
|
"""Record a verified id: own key, source and verified flag, in one change."""
|
|
row[get_adapter(agent).own_key] = uuid
|
|
row["session_id_source"] = "discovered"
|
|
row["session_id_verified"] = True
|
|
row["last_visible_status"] = "pinned"
|
|
|
|
|
|
def _skills_dir() -> str:
|
|
return os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
|
|
|
|
def resume_dry_run(skills_dir: str, cwd: str, agent: str, name: str, env: Optional[Dict[str, str]] = None) -> str:
|
|
"""last_visible_status after a pin: 'resume_verified' or why the dry-run failed."""
|
|
cmd = ["bash", os.path.join(skills_dir, "multi-agent-mux-resume", "scripts", "resume_session.sh"),
|
|
"--workspace", cwd, "--agent", agent, "--session", name, "--dry-run"]
|
|
res = subprocess.run(cmd, capture_output=True, text=True, env=env)
|
|
if res.returncode == 0:
|
|
return "resume_verified"
|
|
return f"resume dry-run failed: {res.stderr.strip() or res.stdout.strip()}"
|
|
|
|
|
|
def tui_viewport(lib_sh: str, name: str, agent: str, cwd: str, env: Optional[Dict[str, str]] = None) -> int:
|
|
"""lib.sh verify_tui_viewport: 0 match, 1 mismatch, other = no evidence."""
|
|
script = 'source "$1" && verify_tui_viewport "$2" "$3" "$4"'
|
|
return subprocess.run(["bash", "-c", script, "_", lib_sh, name, agent, cwd], env=env).returncode
|
|
|
|
|
|
def _load_record(registry_dir: str, job_id: str) -> Optional[Dict[str, Any]]:
|
|
try:
|
|
with open(os.path.join(registry_dir, f"{job_id}.json"), encoding="utf-8") as f:
|
|
rec = json.load(f)
|
|
return rec if isinstance(rec, dict) else None
|
|
except (OSError, ValueError):
|
|
return None
|
|
|
|
|
|
_MUTATION: Optional[Callable[[Dict[str, Any]], bool]] = None
|
|
|
|
|
|
def _atomic_update(yaml_path: str, mutate: Callable[[Dict[str, Any]], bool]) -> None:
|
|
"""Run `mutate(d)` as an atomic_dump_yaml mutation in this process; it
|
|
returns False to leave the registry untouched."""
|
|
from lib_py import atomic_yaml
|
|
global _MUTATION
|
|
saved = {k: os.environ.get(k) for k in ("YAML_PATH", "AGENT_SESSIONS_MUTATION")}
|
|
_MUTATION = mutate
|
|
os.environ["YAML_PATH"] = yaml_path
|
|
# this very module (it is __main__ under `python -m`), not a second copy
|
|
os.environ["AGENT_SESSIONS_MUTATION"] = (
|
|
"import sys as _sys\n"
|
|
f"if not _sys.modules[{__name__!r}]._MUTATION(d):\n"
|
|
" raise SystemExit(0)\n")
|
|
try:
|
|
atomic_yaml.atomic_dump_yaml_main()
|
|
except SystemExit as exc:
|
|
if exc.code not in (0, None):
|
|
raise RuntimeError(str(exc.code))
|
|
finally:
|
|
_MUTATION = None
|
|
for k, v in saved.items():
|
|
if v is None:
|
|
os.environ.pop(k, None)
|
|
else:
|
|
os.environ[k] = v
|
|
|
|
|
|
|
|
def _subprocess_env(yaml_path: str, registry_dir: str) -> Dict[str, str]:
|
|
"""The control plane the lib.sh checks run against: the registry's own."""
|
|
env = dict(os.environ)
|
|
env.setdefault("AGENT_SESSIONS_YAML", yaml_path)
|
|
env.setdefault("MAM_JOBS_DIR", registry_dir)
|
|
mam = os.path.dirname(yaml_path)
|
|
if os.path.basename(mam) == ".mam":
|
|
env.setdefault("WORKSPACE_ROOT", os.path.dirname(mam))
|
|
return env
|
|
|
|
|
|
def _pin_session(name: str, job_id: str, yaml_path: str, home_dir: Optional[str],
|
|
viewport: Callable[[str, str, str], int], resume: Callable[[str, str, str], str]) -> Dict[str, Any]:
|
|
out: Dict[str, Any] = {}
|
|
|
|
def mutate(d: Dict[str, Any]) -> bool:
|
|
row = next((s for s in d.get("herdr_sessions") or [] if s.get("name") == name), None)
|
|
if row is None:
|
|
out.update(capture_result("skipped", reason=f"no session row {name!r}"))
|
|
return False
|
|
if row.get("status") != "running":
|
|
out.update(capture_result("skipped", reason=f"session {name!r} is {row.get('status')}"))
|
|
return False
|
|
agent = agent_of_row(row)
|
|
res = capture(row, agent or "", d, home_dir=home_dir)
|
|
cwd = (row.get("pane") or {}).get("cwd") or ""
|
|
if res["status"] == "captured":
|
|
rc = viewport(name, agent, cwd)
|
|
if rc == 1:
|
|
res = capture_result("viewport-mismatch", reason="the pane does not show this workspace",
|
|
candidates=res["candidates"])
|
|
else:
|
|
pin_row(row, agent, res["session_id"])
|
|
row["last_visible_status"] = resume(name, agent, cwd)
|
|
res = dict(res, status="pinned", degraded=(rc != 0))
|
|
out.update(res)
|
|
return True
|
|
out.update(res)
|
|
if res["status"] in ("pre-assigned", "already-set", "skipped"):
|
|
return False
|
|
row["last_visible_status"] = f"autopin {res['status']} (job {job_id}): {res['reason']}"
|
|
return True
|
|
|
|
_atomic_update(yaml_path, mutate)
|
|
return out
|
|
|
|
|
|
def handle_completed(registry_dir: str, job_id: str, yaml_path: Optional[str] = None,
|
|
expect_token: Optional[str] = None, home_dir: Optional[str] = None,
|
|
viewport: Optional[Callable[[str, str, str], int]] = None,
|
|
resume: Optional[Callable[[str, str, str], str]] = None) -> Dict[str, Any]:
|
|
"""Pin the job's session id if the job completed locally and the row still
|
|
lacks a post-discovered id. Returns the result (also kept in the marker)."""
|
|
rec = _load_record(registry_dir, job_id)
|
|
if rec is None:
|
|
return capture_result("skipped", reason=f"no job record {job_id}")
|
|
if rec.get("status") != "completed":
|
|
return capture_result("skipped", reason=f"job {job_id} is {rec.get('status')}")
|
|
token = rec.get("dispatch_token") or ""
|
|
if expect_token and token != expect_token:
|
|
return capture_result("skipped", reason="stale dispatch token")
|
|
session = str(rec.get("agent_session") or "")
|
|
if (rec.get("transport") == "remote" or session.startswith("remote:")
|
|
or rec.get("agent") == "human" or session in ("", "human", "herdr:human")):
|
|
return capture_result("skipped", reason="not a local agent session")
|
|
name = session[len("herdr:"):] if session.startswith("herdr:") else session
|
|
yaml_path = yaml_path or os.environ.get("AGENT_SESSIONS_YAML") or os.path.join(
|
|
os.path.dirname(os.path.abspath(registry_dir)), "agent-sessions.yaml")
|
|
if not (os.path.exists(yaml_path) or os.path.exists(os.path.splitext(yaml_path)[0] + ".db")):
|
|
return capture_result("skipped", reason=f"no session registry at {yaml_path}")
|
|
|
|
key = f"{job_id}:{token}:{rec.get('attempt_id')}"
|
|
job_dir = os.path.join(registry_dir, job_id)
|
|
os.makedirs(job_dir, exist_ok=True)
|
|
env = _subprocess_env(yaml_path, registry_dir)
|
|
if viewport is None:
|
|
lib_sh = os.environ.get("LIB_SH") or os.path.join(_skills_dir(), "lib.sh")
|
|
viewport = lambda n, a, c: tui_viewport(lib_sh, n, a, c, env=env) # noqa: E731
|
|
if resume is None:
|
|
resume = lambda n, a, c: resume_dry_run(_skills_dir(), c, a, n, env=env) # noqa: E731
|
|
|
|
with open(os.path.join(job_dir, "autopin.lock"), "w") as lock:
|
|
fcntl.flock(lock, fcntl.LOCK_EX)
|
|
marker = os.path.join(job_dir, MARKER)
|
|
try:
|
|
with open(marker, encoding="utf-8") as f:
|
|
done = json.load(f)
|
|
if done.get("key") == key:
|
|
return dict(done.get("result") or {}, deduplicated=True)
|
|
except (OSError, ValueError):
|
|
pass
|
|
try:
|
|
result = _pin_session(name, job_id, yaml_path, home_dir, viewport, resume)
|
|
except Exception as exc:
|
|
result = capture_result("error", reason=f"{type(exc).__name__}: {exc}")
|
|
result["session"] = name
|
|
fd, tmp = tempfile.mkstemp(dir=job_dir, prefix=".autopin.", suffix=".tmp")
|
|
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
json.dump({"key": key, "at": int(time.time()), "result": result}, f)
|
|
os.replace(tmp, marker)
|
|
return result
|
|
|
|
|
|
def main(argv: Optional[List[str]] = None) -> int:
|
|
p = argparse.ArgumentParser(prog="python -m lib_py.agents.autopin")
|
|
p.add_argument("--registry-dir", required=True)
|
|
p.add_argument("--job", required=True)
|
|
p.add_argument("--yaml", default=None, help="agent-sessions.yaml (default: beside the registry)")
|
|
p.add_argument("--expect-dispatch-token", default=None)
|
|
a = p.parse_args(argv)
|
|
res = handle_completed(a.registry_dir, a.job, yaml_path=a.yaml, expect_token=a.expect_dispatch_token)
|
|
if res.get("status") == "pinned":
|
|
print(f"autopin: {res['session']} pinned {res['session_id']} (job {a.job})", file=sys.stderr)
|
|
elif res.get("status") not in ("skipped", "pre-assigned", "already-set") and not res.get("deduplicated"):
|
|
print(f"autopin: {res.get('session')} not pinned - {res['status']}: {res.get('reason')} (job {a.job})",
|
|
file=sys.stderr)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|