fix(agents): pin post-discovered session ids on a verified completed; agy never takes another workspace's or session's conversation (Issue #40)

- 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>
This commit is contained in:
GodopuandClaude Opus 5.5 committed 2026-10-20 23:40:03 +09:00
1 parent ff8b381520
commit feccfd5e03
19 files changed
+1128 -338

No files matched your search

+12
View File
@@ -2467,6 +2467,18 @@ delegate_wait_terminal() {
--job "$job_id" --registry-dir "$reg_dir" --timeout "$timeout_s" --watch-pid "$$"
}
# delegate_autopin_completed <job_id> [dispatch_token] [registry_dir]
# Issue #40: once this side has seen the job `completed`, pin the session's
# post-discovered id (lib_py.agents.autopin decides and writes; remote, human
# and pre-assigned sessions are left alone, one job/token/attempt is handled
# once). Reports on stderr; never changes the job's result or this exit code.
delegate_autopin_completed() {
local job_id="$1" token="${2:-}" reg_dir="${3:-${MAM_JOBS_DIR:-${WORKSPACE_ROOT:-$PWD}/.mam/jobs}}"
PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" HOME_DIR="${HOME_DIR:-$HOME}" LIB_SH="$SKILL_DIR/lib.sh" \
"$(_delegate_py_bin)" -m lib_py.agents.autopin --job "$job_id" --registry-dir "$reg_dir" \
--yaml "$AGENT_SESSIONS_YAML" ${token:+--expect-dispatch-token "$token"} >&2 || true
}
# delegate_onboard_exit <status>
# Issue #50: create's exit code for an onboarding status (0, 44 or 45).
delegate_onboard_exit() {
+114 -15
View File
@@ -1,6 +1,43 @@
import os, sys, json, shutil
from typing import Optional, Any
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext, _ro_connect, conversation_in_use
import os, sys, json, shutil, subprocess
from typing import Optional, Any, Dict, List
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext, _ro_connect, capture_result, conversation_in_use
from lib_py.verify_session import verify_session_uuid, workspace_key
def _holder_pids(paths: List[str]) -> Optional[List[int]]:
"""PIDs holding any of `paths` open (lsof -t), [] when none does, None
when lsof cannot answer (missing, failed, timed out)."""
existing = [p for p in paths if p and os.path.exists(p)]
if not existing:
return []
lsof = shutil.which("lsof")
if not lsof:
return None
try:
r = subprocess.run([lsof, "-t"] + existing, capture_output=True, text=True, timeout=10)
except Exception:
return None
pids = sorted({int(t) for t in r.stdout.split() if t.isdigit()})
# lsof exits 1 both when nothing holds the files and on errors.
if r.returncode not in (0, 1) or (r.returncode == 1 and not pids and r.stderr.strip()):
return None
return pids
def _process_parents() -> Optional[Dict[int, int]]:
"""pid -> ppid of every running process (ps), None when ps fails."""
try:
out = subprocess.run(["ps", "-eo", "pid=,ppid="], capture_output=True, text=True, timeout=10)
except Exception:
return None
if out.returncode != 0:
return None
parents: Dict[int, int] = {}
for line in out.stdout.splitlines():
parts = line.split()
if len(parts) == 2 and parts[0].isdigit() and parts[1].isdigit():
parents[int(parts[0])] = int(parts[1])
return parents or None
class AgyAgentAdapter(BaseAgentAdapter):
@property
@@ -58,18 +95,15 @@ class AgyAgentAdapter(BaseAgentAdapter):
if ctx.epoch and os.path.getmtime(path) < ctx.epoch:
return False
if ctx.mode == "discover":
lc = f"{ctx.home_dir}/.gemini/antigravity-cli/cache/last_conversations.json"
cache_match = False
if os.path.exists(lc):
try:
with open(lc) as f:
lc_data = json.load(f)
cache_match = (lc_data.get(ctx.cwd) == uuid)
except Exception:
cache_match = False
if not cache_match:
if uuid in (ctx.row.get("_sibling_claimed_uuids") or []):
return False
# Issue #40: the cache maps each workspace to the conversation last
# opened there, so an id it maps to another workspace is that
# workspace's; and an id another row records is never this row's,
# whether or not the cache names it for this cwd.
here = workspace_key(ctx.cwd)
if any(v == uuid and workspace_key(k) != here for k, v in self._last_conversations(ctx).items()):
return False
if uuid in (ctx.row.get("_sibling_claimed_uuids") or []):
return False
try:
conn = _ro_connect(path)
r = conn.execute("SELECT count(*) FROM steps").fetchone()
@@ -83,6 +117,71 @@ class AgyAgentAdapter(BaseAgentAdapter):
def _cli_root(self, ctx: DiscoveryContext) -> str:
return f"{ctx.home_dir}/.gemini/antigravity-cli"
def _last_conversations(self, ctx: DiscoveryContext) -> dict:
"""cache/last_conversations.json: {cwd: id of the conversation last opened there}."""
try:
with open(f"{self._cli_root(ctx)}/cache/last_conversations.json") as f:
data = json.load(f)
return data if isinstance(data, dict) else {}
except Exception:
return {}
def capture_active_session_id(self, ctx: DiscoveryContext) -> dict:
"""Issue #40: the cache only proposes an id - it holds one id per cwd,
rewritten whenever any agy opens a conversation there, so it cannot tell
two sessions in one cwd apart. The id is this session's only when its
presence lock (or DB) is held open by the row's pane process or one of
its descendants. Anything unprovable is not pinned (no cache-only fallback)."""
here = workspace_key(ctx.cwd)
proposed = sorted({v for k, v in self._last_conversations(ctx).items()
if v and workspace_key(k) == here})
if not proposed:
return capture_result("no-candidate", reason="last_conversations.json names no conversation for this cwd")
if len(proposed) > 1:
return capture_result("ambiguous", reason="last_conversations.json maps this cwd to several conversations",
candidates=proposed)
uuid = proposed[0]
if uuid in (ctx.row.get("_sibling_claimed_uuids") or []):
return capture_result("sibling-claimed", reason=f"{uuid} is recorded by another session", candidates=[uuid])
if not verify_session_uuid(ctx.cwd, self.name, uuid, row=ctx.row, home_dir=ctx.home_dir, mode="discover"):
return capture_result("rejected", reason=f"{uuid} failed verification (missing, older than the session, or empty)",
candidates=[uuid])
status, reason = self._owner_check(uuid, ctx)
if status != "ok":
return capture_result(status, reason=reason, candidates=[uuid])
return capture_result("captured", session_id=uuid, candidates=[uuid])
def _owner_check(self, uuid: str, ctx: DiscoveryContext):
"""('ok', '') when a holder of the conversation's presence lock or DB is
the row's pane process or its descendant; otherwise
('unverifiable-owner' | 'owner-mismatch', reason)."""
try:
pane_pid = int((ctx.row.get("pane") or {}).get("pid") or 0)
except (TypeError, ValueError):
pane_pid = 0
if pane_pid <= 1:
return "unverifiable-owner", "the row records no pane pid"
db = self.artifact_path(uuid, ctx)
paths = [f"{self._cli_root(ctx)}/presence/{uuid}.lock", db, f"{db}-wal", f"{db}-shm"]
holders = _holder_pids(paths)
if holders is None:
return "unverifiable-owner", "lsof is unavailable or failed"
if not holders:
return "unverifiable-owner", f"no process holds {uuid}'s presence lock or DB"
if pane_pid in holders:
return "ok", ""
parents = _process_parents()
if parents is None or pane_pid not in parents:
return "unverifiable-owner", f"pane process {pane_pid} cannot be found"
for pid in holders:
seen = set()
while pid > 1 and pid not in seen:
if pid == pane_pid:
return "ok", ""
seen.add(pid)
pid = parents.get(pid, 0)
return "owner-mismatch", f"{uuid} is held by pid(s) {holders}, none in pane process {pane_pid}'s tree"
def _artifact_paths(self, uuid: str, ctx: DiscoveryContext) -> list:
"""Every on-disk piece of one conversation, DB first: the SQLite
sidecars, the brain dir, the title annotation and the presence lock."""
@@ -1,6 +1,6 @@
import os, re, json, glob, math, stat, time, shutil, calendar, subprocess
from typing import Optional, Any
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext, conversation_in_use, scan_names
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext, PRE_ASSIGNED, conversation_in_use, scan_names
from lib_py.verify_session import workspace_key
@@ -184,6 +184,11 @@ class ClaudeAgentAdapter(BaseAgentAdapter):
def own_key(self) -> str:
return 'claude_session_id_own'
@property
def id_lifecycle(self) -> str:
# create hands the CLI its id (--session-id); nothing to capture (#40)
return PRE_ASSIGNED
@property
def ready_tokens(self) -> str:
return 'Anthropic|Assistant|Chat|Welcome|Claude Code|Opus|Sonnet|Haiku'
@@ -1,7 +1,7 @@
import os, json, glob, shutil
from typing import Optional, Any
from urllib.parse import quote
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext, scan_names
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext, PRE_ASSIGNED, scan_names
from lib_py.verify_session import workspace_key
@@ -14,6 +14,11 @@ class GrokAgentAdapter(BaseAgentAdapter):
def own_key(self) -> str:
return 'grok_session_id_own'
@property
def id_lifecycle(self) -> str:
# create hands the CLI its id (--session-id); nothing to capture (#40)
return PRE_ASSIGNED
@property
def ready_tokens(self) -> str:
return 'Grok|xAI|Assistant|❯|>>>'
+261
View File
@@ -0,0 +1,261 @@
"""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())
+41
View File
@@ -169,6 +169,22 @@ def db_rows_naming(db: str, uuid: str, columns=(), id_tables=(), connect=None) -
conn.close()
return found
# Session-id lifecycles (Issue #40): the id is handed to the CLI before it
# starts, or the CLI writes it to disk after its first turn.
PRE_ASSIGNED = 'pre_assigned'
POST_DISCOVERED = 'post_discovered'
def capture_result(status: str, session_id: Optional[str] = None, reason: str = "",
candidates: Optional[List[str]] = None) -> Dict[str, Any]:
"""What capture_active_session_id found (Issue #40). `status` is
'captured' (session_id set) or why nothing may be pinned: 'no-candidate',
'ambiguous', 'sibling-claimed', 'rejected', 'unverifiable-owner',
'owner-mismatch'."""
return {"status": status, "session_id": session_id, "reason": reason,
"candidates": list(candidates or [])}
class SpawnSpec:
def __init__(self, agent_name: str, session_name: str, command: str, env: Optional[Dict[str, str]] = None):
self.agent_name = agent_name
@@ -358,3 +374,28 @@ class BaseAgentAdapter:
def discover(self, ctx: DiscoveryContext) -> list:
raise NotImplementedError
@property
def id_lifecycle(self) -> str:
"""PRE_ASSIGNED: the id is given to the CLI at spawn (nothing to capture).
POST_DISCOVERED: the CLI writes it after its first turn (Issue #40)."""
return POST_DISCOVERED
def capture_active_session_id(self, ctx: DiscoveryContext) -> Dict[str, Any]:
"""This session's id, decided only when it is unambiguous (Issue #40):
exactly one discover() candidate that verify_session_uuid accepts and
no other row records (ctx.row['_sibling_claimed_uuids']). Read-only."""
claimed = set(ctx.row.get("_sibling_claimed_uuids") or [])
found: List[str] = []
for uuid in self.discover(ctx):
if not uuid or uuid in claimed or uuid in found:
continue
if verify_session_uuid(ctx.cwd, self.name, uuid, row=ctx.row, home_dir=ctx.home_dir,
claude_dir=ctx.claude_dir, mode="discover"):
found.append(uuid)
if not found:
return capture_result("no-candidate", reason="no verified candidate newer than the session")
if len(found) > 1:
return capture_result("ambiguous", reason=f"{len(found)} candidates newer than the session",
candidates=found)
return capture_result("captured", session_id=found[0], candidates=found)
+18
View File
@@ -676,6 +676,7 @@ class LoopRunner:
if status == "completed":
if self.verbose:
log_success(f"Job '{job_id}' completed successfully.")
self._autopin(job_id)
return True
elif status in ("error", "cancelled"):
log_error(f"Job '{job_id}' finished with status '{status}'.")
@@ -686,6 +687,23 @@ class LoopRunner:
log_error(f"Job '{job_id}' timed out after {max_wait}s.")
return False
def _autopin(self, job_id: str) -> None:
"""Issue #40 recovery path: the handler the wrapper already ran for this
job (one job/token/attempt is handled once), run like the wrapper from
the control root's skills - lib_py.loop imports no sibling but
delegation (G9). Never fails the wait; the handler reports on stderr."""
skills = self.workspace_root / ".agents" / "skills"
if not (skills / "lib_py" / "agents" / "autopin.py").exists():
skills = Path(__file__).resolve().parents[2]
yaml_path = os.environ.get("AGENT_SESSIONS_YAML") or str(self.mam_dir / "agent-sessions.yaml")
env = dict(os.environ)
env["PYTHONPATH"] = str(skills) + (os.pathsep + env["PYTHONPATH"] if env.get("PYTHONPATH") else "")
try:
subprocess.run([sys.executable, "-m", "lib_py.agents.autopin", "--registry-dir", str(self.jobs_dir),
"--job", job_id, "--yaml", yaml_path], env=env, timeout=300)
except Exception as exc:
log_warn(f"autopin for job '{job_id}' failed: {exc}")
def _relay_while_submitting(self, label: str, since: str, stop: threading.Event) -> None:
"""Issue #30 (review R2): the wrapper runs until its job ends and its
output is captured, so a blocked agent would only reach the loop log
@@ -220,6 +220,7 @@ bash .agents/skills/multi-agent-mux-create/scripts/worktree_prepare.sh \
- **Don't use `nohup`/`disown`/`setsid` for the agent itself** — those background the agent outside herdr. The whole point of this skill is *the herdr session is the supervisor*. `nohup` is OK only for *launching the wrapper* (which itself creates the herdr session via `herdr new-session -d`).
- **Claude Session ID Auto-Assignment**: Fresh `claude` sessions automatically generate a new UUID (`mam_gen_uuid`) passed via `claude --session-id <uuid>`. `claude_session_id_own` is stored in `.mam/agent-sessions.yaml` with `session_id_source: assigned` and `session_id_verified: false`.
- **First Message Materialization**: The transcript file `.jsonl` is created on disk when the first user prompt is sent. The monitor loop (`reconcile.sh`) verifies the transcript and promotes `session_id_verified: true` and `last_visible_status: pinned`.
- **Post-discovered ids (agy, hermes, opencode, codex)**: these rows start with no id (`session_id_source: pending-discovery`). With `--onboard`, create pins the id itself once the onboarding job completes (Issue #40, `lib_py.agents.autopin`), monitor or not; later, every completed delegate job does the same until the id is set.
- **Wrapper script MUST NOT be created via `hermes profile alias`** — that command writes a `hermes -p <profile>` wrapper that destroys the herdr behavior. Create wrappers manually (see `lab-landing-page-creator-claude` template).
- **Always use the workspace-relative path** in herdr `cwd` — relative paths break when herdr respawns in a different shell context.
@@ -751,6 +751,9 @@ If you delegate any part of it with multi-agent-mux-delegate-job submit, add --p
case "$ONBOARD_STATUS" in
completed)
echo "onboarding completed: job $DELEGATE_JOB_ID ($(( $(date +%s) - ONBOARD_T0 ))s)"
# Issue #40: the first turn is done, so a post-discovered id is on disk -
# pin it now instead of waiting for a monitor (worktree creators run none).
delegate_autopin_completed "$DELEGATE_JOB_ID" "$DELEGATE_JOB_TOKEN" "$task_reg_dir"
;;
*)
# The session is kept, as for a missing handshake above: the row says
@@ -451,6 +451,11 @@ EOF
finalize_unfinished "$JOB_ID" "$job_status" "$sub_rc" "direct"
job_status="${FINAL_JOB_STATUS:-$job_status}"
# Issue #40: this subscriber verified the `completed` (or the disk did) -
# pin the session's post-discovered id now, daemon or not.
if [ "$job_status" = "completed" ]; then
delegate_autopin_completed "$JOB_ID" "$_disp_tok" "$REGISTRY_DIR"
fi
# Last stdout line: the persistent audit-log dir for this job (see SKILL.md
# "Audit Logs"). Callers can scrape `tail -n1` to find it.
@@ -161,6 +161,8 @@ YAML: no such session
→ report: "lab-landing-page-creator-claude: session id materialized b3a7...c2f"
```
- **C-ambiguous. Multiple candidates detected**: If multiple candidate transcripts match an unassigned session, the monitor avoids random pinning, reports `C-ambiguous`, and sets `last_visible_status: "ambiguous: N candidates"`.
- **Post-discovered ids (agy, hermes, opencode, codex; Issue #40)**: drift C asks the adapter's `capture_active_session_id`, the decision the completed handler (`lib_py.agents.autopin`) also uses. A candidate must be newer than the session, pass verification and be recorded by no other row. For agy, `last_conversations.json[cwd]` only proposes the id: the conversation's presence lock (or DB) must be held by the row's pane process or a descendant, and an id the cache maps to another workspace is rejected. The conversations folder is no longer scanned. A pin records `session_id_source: discovered` and `session_id_verified: true`.
- **Pinned without a monitor**: the side that verified a job's `completed` (the delegate-job wrapper, `create --onboard`, the loop's job wait) pins the session's post-discovered id at once, so worktree creators (`MAM_NO_SUBSCRIBER_DAEMON=1`) need no reconcile. One job/token/attempt is handled once; a failed or ambiguous capture leaves `last_visible_status: "autopin <status> (job <id>): <reason>"` and the job's result unchanged.
## Pitfalls
@@ -420,7 +420,7 @@ import yaml
from lib_py.verify_session import verify_session_uuid, workspace_key
from lib_py.agents.registry import get_adapter
from lib_py.agents.base import DiscoveryContext, _ro_connect
from lib_py.agents import autopin
def _slug(path):
if not path:
@@ -554,17 +554,9 @@ def pane_meta(session, srv):
from lib_py.agents.registry import get_adapter, own_key as _get_own_key, agent_of_row
def _pin_and_verify_resume(s, agent, cwd, uuid, degraded=False):
own_key_field = _get_own_key(agent)
if own_key_field:
s[own_key_field] = uuid
s['last_visible_status'] = 'pinned'
resume_cmd = ['bash', os.path.join(skills_dir, 'multi-agent-mux-resume', 'scripts', 'resume_session.sh'),
'--workspace', cwd, '--agent', agent, '--session', s['name'], '--dry-run']
res = subprocess.run(resume_cmd, capture_output=True, text=True)
if res.returncode == 0:
s['last_visible_status'] = 'resume_verified'
else:
s['last_visible_status'] = f"resume dry-run failed: {res.stderr.strip() or res.stdout.strip()}"
# the completed handler's pin (Issue #40): own id, source and verified together
autopin.pin_row(s, agent, uuid)
s['last_visible_status'] = autopin.resume_dry_run(skills_dir, cwd, agent, s['name'])
id_name = 'session' if agent == 'claude' else 'conversation'
if not degraded:
@@ -783,314 +775,40 @@ for s in d.get('herdr_sessions', []):
else:
_pin_and_verify_resume(s, 'claude', cwd, uuid, degraded=True)
# === drift C (agy): agy 새 session id materialize (per-row own id) ===
# === drift C (agy), drift C (hermes), drift C (opencode), drift C (codex) ===
# Post-discovered ids (Issue #40): the adapter's capture_active_session_id
# decides - the same decision the completed handler (lib_py.agents.autopin)
# makes - so the two paths cannot drift apart. Exactly one verified candidate
# newer than the session that no other row records; agy additionally needs its
# presence lock held by this row's pane (its cwd cache alone proves nothing).
for s in d.get('herdr_sessions', []):
if row_agent(s) != 'agy':
agent = row_agent(s)
if agent not in ('agy', 'hermes', 'opencode', 'codex'):
continue
if s.get('status') != 'running':
continue
if s.get('agy_conversation_id_own'):
if s.get(OWN_KEY_BY_AGENT[agent]):
continue
cwd = (s.get('pane') or {}).get('cwd', '')
if not cwd:
continue
conv_dir = f"{home}/.gemini/antigravity-cli/conversations"
if not os.path.isdir(conv_dir):
continue
dbs = sorted(glob.glob(f"{conv_dir}/*.db"), key=os.path.getmtime, reverse=True)
sibling_claimed = [
other.get('agy_conversation_id_own')
for other in d.get('herdr_sessions', [])
if other is not s
and (other.get('pane') or {}).get('cwd') == cwd
and other.get('status') not in ('stopped', 'terminated')
and other.get('agy_conversation_id_own')
]
s_eval = dict(s)
s_eval['_sibling_claimed_uuids'] = sibling_claimed
valid_candidates = []
for db in dbs:
uuid = os.path.basename(db)[:-3]
if verify_session_uuid(cwd, 'agy', uuid, s_eval, mode="discover"):
valid_candidates.append(uuid)
if len(valid_candidates) > 1:
res = autopin.capture(s, agent, d, home_dir=home)
if res['status'] == 'ambiguous':
n = len(res['candidates'])
drifts.append({'class': 'C-ambiguous', 'name': s['name'],
'msg': f"{s['name']}: {len(valid_candidates)} candidate transcripts newer than session epoch; not pinning"})
s['last_visible_status'] = f"ambiguous: {len(valid_candidates)} candidates"
'msg': f"{s['name']}: {n} candidate transcripts newer than session epoch; not pinning"})
s['last_visible_status'] = f"ambiguous: {n} candidates"
actions.append(f"ambiguous candidates: {s['name']}")
if len(valid_candidates) == 1:
uuid = valid_candidates[0]
cmd = ['bash', '-c', f'source "{lib_sh}" && verify_tui_viewport "{s["name"]}" "agy" "{cwd}"']
elif res['status'] == 'captured':
uuid = res['session_id']
cmd = ['bash', '-c', f'source "{lib_sh}" && verify_tui_viewport "{s["name"]}" "{agent}" "{cwd}"']
rc = subprocess.run(cmd).returncode
if rc == 0:
_pin_and_verify_resume(s, 'agy', cwd, uuid, degraded=False)
_pin_and_verify_resume(s, agent, cwd, uuid, degraded=False)
elif rc == 1:
drifts.append({'class': 'C-warn', 'name': s['name'], 'msg': f"{s['name']}: TUI viewport mismatch for candidate {uuid} — not pinned, will retry next cycle"})
else:
_pin_and_verify_resume(s, 'agy', cwd, uuid, degraded=True)
# === drift C (hermes): hermes 새 session id materialize (per-row own id) ===
for s in d.get('herdr_sessions', []):
if row_agent(s) != 'hermes':
continue
if s.get('status') != 'running':
continue
if s.get('hermes_conversation_id_own'):
continue
cwd = (s.get('pane') or {}).get('cwd', '')
if not cwd:
continue
hdb = f"{home}/.hermes/state.db"
if not os.path.exists(hdb):
continue
epoch_threshold = s.get('herdr_session_epoch', 0)
sibling_claimed = [
other.get('hermes_conversation_id_own')
for other in d.get('herdr_sessions', [])
if other is not s
and (other.get('pane') or {}).get('cwd') == cwd
and other.get('status') not in ('stopped', 'terminated')
and other.get('hermes_conversation_id_own')
]
s_eval = dict(s)
s_eval['_sibling_claimed_uuids'] = sibling_claimed
valid_candidates = []
try:
conn = _ro_connect(hdb)
rows = conn.execute(
"SELECT id FROM sessions WHERE cwd=? AND started_at >= ? ORDER BY started_at DESC LIMIT 20",
(cwd, epoch_threshold)
).fetchall()
conn.close()
for (uuid,) in rows:
if uuid in (sibling_claimed or []):
continue
if verify_session_uuid(cwd, 'hermes', uuid, s_eval, mode="discover"):
valid_candidates.append(uuid)
except Exception:
pass
if len(valid_candidates) > 1:
drifts.append({'class': 'C-ambiguous', 'name': s['name'],
'msg': f"{s['name']}: {len(valid_candidates)} candidate transcripts newer than session epoch; not pinning"})
s['last_visible_status'] = f"ambiguous: {len(valid_candidates)} candidates"
actions.append(f"ambiguous candidates: {s['name']}")
if len(valid_candidates) == 1:
uuid = valid_candidates[0]
cmd = ['bash', '-c', f'source "{lib_sh}" && verify_tui_viewport "{s["name"]}" "hermes" "{cwd}"']
rc = subprocess.run(cmd).returncode
if rc == 0:
_pin_and_verify_resume(s, 'hermes', cwd, uuid, degraded=False)
elif rc == 1:
drifts.append({'class': 'C-warn', 'name': s['name'], 'msg': f"{s['name']}: TUI viewport mismatch for candidate {uuid} — not pinned, will retry next cycle"})
else:
_pin_and_verify_resume(s, 'hermes', cwd, uuid, degraded=True)
# === drift C (opencode): opencode 새 session id materialize (per-row own id) ===
for s in d.get('herdr_sessions', []):
if row_agent(s) != 'opencode':
continue
if s.get('status') != 'running':
continue
if s.get('opencode_session_id_own'):
continue
cwd = (s.get('pane') or {}).get('cwd', '')
if not cwd:
continue
# Resolve DB path via shared OpenCodeAgentAdapter helper with fallbacks
odb = None
try:
op_adapter = get_adapter('opencode')
if op_adapter:
odb = op_adapter._resolve_db(DiscoveryContext(workspace=cwd, agent_name='opencode', home_dir=home))
except Exception:
pass
if not odb:
opencode_base = f"{home}/.local/share/opencode"
candidates = [
f"{opencode_base}/opencode.db",
f"{opencode_base}/storage.db",
]
for cand in candidates:
if os.path.exists(cand):
odb = cand
break
if not odb and os.path.isdir(f"{opencode_base}/project"):
for root, _, files in os.walk(f"{opencode_base}/project"):
for f in files:
if f.endswith(".db"):
odb = os.path.join(root, f)
break
if odb and os.path.exists(odb):
break
if not odb or not os.path.exists(odb):
continue
epoch_threshold = s.get('herdr_session_epoch', 0)
sibling_claimed = [
other.get('opencode_session_id_own')
for other in d.get('herdr_sessions', [])
if other is not s
and (other.get('pane') or {}).get('cwd') == cwd
and other.get('status') not in ('stopped', 'terminated')
and other.get('opencode_session_id_own')
]
s_eval = dict(s)
s_eval['_sibling_claimed_uuids'] = sibling_claimed
valid_candidates = []
try:
# read-only: nothing is created or removed beside the agent's db (Issue #60)
conn = _ro_connect(odb)
cursor = conn.cursor()
# opencode 2.x keeps sessions in session_v2 (a migrated 1.x `session`
# table is no longer written).
cursor.execute("PRAGMA table_info(session_v2)")
cols = [col[1] for col in cursor.fetchall()]
if cols:
cwd_col = "directory" if "directory" in cols else ("cwd" if "cwd" in cols else None)
time_col = "time_created" if "time_created" in cols else ("created_at" if "created_at" in cols else ("started_at" if "started_at" in cols else None))
query = "SELECT id FROM session_v2"
params = []
where_clauses = []
if "parent_id" in cols:
# a subagent's session shares the directory and is newer
where_clauses.append("parent_id IS NULL")
if cwd_col and cwd:
where_clauses.append(f"{cwd_col} = ?")
params.append(cwd)
if time_col and epoch_threshold:
if time_col == "time_created":
where_clauses.append(f"{time_col} >= ?")
params.append(int(float(epoch_threshold) * 1000))
else:
where_clauses.append(f"{time_col} >= ?")
params.append(epoch_threshold)
if where_clauses:
query += " WHERE " + " AND ".join(where_clauses)
if time_col:
query += f" ORDER BY {time_col} DESC"
query += " LIMIT 20"
rows = cursor.execute(query, params).fetchall()
conn.close()
for (uuid,) in rows:
if uuid in (sibling_claimed or []):
continue
if verify_session_uuid(cwd, 'opencode', uuid, s_eval, mode="discover"):
valid_candidates.append(uuid)
else:
conn.close()
except Exception:
pass
if len(valid_candidates) > 1:
drifts.append({'class': 'C-ambiguous', 'name': s['name'],
'msg': f"{s['name']}: {len(valid_candidates)} candidate transcripts newer than session epoch; not pinning"})
s['last_visible_status'] = f"ambiguous: {len(valid_candidates)} candidates"
actions.append(f"ambiguous candidates: {s['name']}")
if len(valid_candidates) == 1:
uuid = valid_candidates[0]
cmd = ['bash', '-c', f'source "{lib_sh}" && verify_tui_viewport "{s["name"]}" "opencode" "{cwd}"']
rc = subprocess.run(cmd).returncode
if rc == 0:
_pin_and_verify_resume(s, 'opencode', cwd, uuid, degraded=False)
elif rc == 1:
drifts.append({'class': 'C-warn', 'name': s['name'], 'msg': f"{s['name']}: TUI viewport mismatch for candidate {uuid} — not pinned, will retry next cycle"})
else:
_pin_and_verify_resume(s, 'opencode', cwd, uuid, degraded=True)
# === drift C (codex): codex 새 session id materialize (per-row own id) ===
for s in d.get('herdr_sessions', []):
if row_agent(s) != 'codex':
continue
if s.get('status') != 'running':
continue
if s.get('codex_session_id_own'):
continue
cwd = (s.get('pane') or {}).get('cwd', '')
if not cwd:
continue
codex_home = os.environ.get('CODEX_HOME') or f"{home}/.codex"
epoch_threshold = s.get('herdr_session_epoch', 0)
sibling_claimed = [
other.get('codex_session_id_own')
for other in d.get('herdr_sessions', [])
if other is not s
and (other.get('pane') or {}).get('cwd') == cwd
and other.get('status') not in ('stopped', 'terminated')
and other.get('codex_session_id_own')
]
s_eval = dict(s)
s_eval['_sibling_claimed_uuids'] = sibling_claimed
valid_candidates = []
try:
# Rev.2: state DB first (indexed, single query) — dynamic filename,
# no hardcoded suffix (see §1.5 / §2 _state_db()).
state_matches = glob.glob(f"{codex_home}/state_*.sqlite")
state_db = max(state_matches, key=os.path.getmtime) if state_matches else None
rows = []
if state_db and os.path.exists(state_db):
try:
conn = _ro_connect(state_db)
cols = [c[1] for c in conn.execute("PRAGMA table_info(threads)").fetchall()]
order_col = "updated_at" if "updated_at" in cols else ("created_at" if "created_at" in cols else None)
if order_col:
rows = conn.execute(
f"SELECT id, {order_col} FROM threads WHERE cwd=? ORDER BY {order_col} DESC LIMIT 20",
(cwd,)
).fetchall()
else:
rows = [(r[0], int(time.time())) for r in conn.execute("SELECT id FROM threads WHERE cwd=? LIMIT 20", (cwd,)).fetchall()]
conn.close()
except Exception:
rows = []
if not rows:
# Fallback: bounded glob over the date-nested tree
paths = sorted(
glob.glob(f"{codex_home}/sessions/*/*/*/rollout-*.jsonl"),
key=os.path.getmtime, reverse=True
)[:50]
rows = [(os.path.basename(p)[:-6][-36:], os.path.getmtime(p)) for p in paths]
for uuid, ts in rows:
if epoch_threshold and ts and ts < epoch_threshold:
continue
if uuid in (sibling_claimed or []):
continue
if verify_session_uuid(cwd, 'codex', uuid, s_eval, mode="discover"):
valid_candidates.append(uuid)
except Exception:
pass
if len(valid_candidates) > 1:
drifts.append({'class': 'C-ambiguous', 'name': s['name'],
'msg': f"{s['name']}: {len(valid_candidates)} candidate transcripts newer than session epoch; not pinning"})
s['last_visible_status'] = f"ambiguous: {len(valid_candidates)} candidates"
actions.append(f"ambiguous candidates: {s['name']}")
elif len(valid_candidates) == 1:
uuid = valid_candidates[0]
cmd = ['bash', '-c', f'source "{lib_sh}" && verify_tui_viewport "{s["name"]}" "codex" "{cwd}"']
rc = subprocess.run(cmd).returncode
if rc == 0:
_pin_and_verify_resume(s, 'codex', cwd, uuid, degraded=False)
elif rc == 1:
drifts.append({'class': 'C-warn', 'name': s['name'], 'msg': f"{s['name']}: TUI viewport mismatch for candidate {uuid} — not pinned, will retry next cycle"})
else:
_pin_and_verify_resume(s, 'codex', cwd, uuid, degraded=True)
_pin_and_verify_resume(s, agent, cwd, uuid, degraded=True)
result = {
'timestamp': now_iso,