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:
1 parent
ff8b381520
commit
feccfd5e03
19 files changed
+1128
-338
No files matched your search
@@ -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() {
|
||||
|
||||
@@ -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|❯|>>>'
|
||||
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user