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
@@ -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