Files
multi-agent-mux/.agents/skills/lib_py/agents/adapters/codex.py
T
GodopuandClaude Opus 5.5 bf0f5c1014 fix(issue-30-33): review 5ec8ef33 - a multi-line codex draft is not an empty box, blocked notices reach the loop while the job runs, and both clocks are labelled on every close path
- R1 (P1): without rules the input region runs from the last prompt line to
  the end of the pane; only a last line matching the adapter's footer pattern
  (codex: `  <model> · ~/<cwd> · …`) is dropped, so a draft on a second line
  keeps the nudge withheld.
- R2: while the synchronous submit runs, a relay thread finds the job it
  registers (same session, created since the submit) and prints its
  stalled:/stall_watch: notices; before, they appeared only after the job.
- R3: the broker-unavailable disk wait that runs into the deadline is the wall
  clock: wall_clock_timeout (caller rc stays 3).
- R4: a state_change_seq change seen right before the judgement restarts the
  idle window (decision 9: S seconds of continuous idle).

Regression tests are the reviewer's reproductions, each with its passing
twin; the wrapper-level #30 tests now use this environment's broker instead
of the #18 harness's public default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 14:01:53 +00:00

240 lines
9.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import os, json, glob, sqlite3, subprocess
from typing import Optional, Any, List
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext
from lib_py.verify_session import workspace_key
class CodexAgentAdapter(BaseAgentAdapter):
@property
def name(self) -> str:
return 'codex'
@property
def own_key(self) -> str:
return 'codex_session_id_own'
@property
def ready_tokens(self) -> str:
return 'Codex|Ask Codex to do anything'
@property
def exit_key(self) -> str:
return 'Ctrl+C'
@property
def delegate_agent_key(self) -> str:
return 'codex-cli'
@property
def startup_dialogs(self) -> list:
# #24: the trust prompt lists "1. Yes, continue" preselected, so Enter
# accepts. Trust here also loads this project's local config, hooks and
# exec policy, which is why --no-auto-trust exists.
#
# The 0.155.1 update notice (observed 2026-09-22) blocks the session the
# same way; "2" is Skip, the choice that changes nothing.
return [
('Do you trust the contents of this directory|1\\. Yes, continue',
['Enter'], 'trust'),
('1\\. Update now|Skip until next version', ['2'], 'notice'),
]
@property
def identity_cache_fields(self) -> tuple:
return ('session_id',)
@property
def input_prompt(self) -> Optional[str]:
# herdr 0.9.1 capture: one `› Ask Codex to do anything` line, no rules.
return '›'
@property
def input_placeholder(self) -> Optional[str]:
return 'Ask Codex to do anything'
@property
def input_stop_pattern(self) -> Optional[str]:
# The status footer under the input line: ` <model> <effort> · <cwd> · …`
# (the ` · ~/` or ` · /` of the cwd keeps a draft line with dots from matching)
return r'^\s+\S.*\s\u00b7\s[~/]'
@property
def resident_children(self) -> tuple:
# Kept alive while idle (measured 2026-09-25).
return ('codex-code-mode-host',)
def _codex_home(self, ctx: DiscoveryContext) -> str:
return os.environ.get('CODEX_HOME') or f"{ctx.home_dir}/.codex"
def _state_db(self, ctx: DiscoveryContext) -> Optional[str]:
matches = glob.glob(f"{self._codex_home(ctx)}/state_*.sqlite")
if not matches:
return None
return max(matches, key=os.path.getmtime)
def _find_rollout_via_db(self, uuid: str, ctx: DiscoveryContext):
"""Returns (rollout_path, cwd) from the state DB, or (None, None)."""
db = self._state_db(ctx)
if not db or not os.path.exists(db):
return None, None
try:
conn = sqlite3.connect(db)
row = conn.execute("SELECT rollout_path, cwd FROM threads WHERE id=?", (uuid,)).fetchone()
conn.close()
if row and row[0] and os.path.exists(row[0]):
return row[0], row[1]
except Exception:
pass
return None, None
def _find_rollout(self, uuid: str, ctx: DiscoveryContext) -> Optional[str]:
path, _ = self._find_rollout_via_db(uuid, ctx)
if path:
return path
matches = glob.glob(f"{self._codex_home(ctx)}/sessions/*/*/*/rollout-*-{uuid}.jsonl")
return matches[0] if matches else None
def artifact_path(self, uuid: str, ctx: DiscoveryContext) -> str:
return self._find_rollout(uuid, ctx) or f"{self._codex_home(ctx)}/sessions/rollout-{uuid}.jsonl"
def verify_artifact(self, uuid: str, ctx: DiscoveryContext) -> bool:
db_path, db_cwd = self._find_rollout_via_db(uuid, ctx)
path = db_path or self._find_rollout(uuid, ctx)
if not path or not os.path.exists(path):
return False
if ctx.epoch and os.path.getmtime(path) < ctx.epoch:
return False
# Fail closed on invalid metadata or mismatched id / cwd (R4)
try:
with open(path) as f:
first = f.readline().strip()
meta = json.loads(first)
if not isinstance(meta, dict):
return False
payload = meta.get('payload')
if not isinstance(payload, dict):
return False
file_id = payload.get('id')
file_cwd = payload.get('cwd')
if not file_id or file_id != uuid:
return False
if not file_cwd:
return False
if db_cwd and workspace_key(db_cwd) != workspace_key(file_cwd):
return False
target_cwd = ctx.cwd or ctx.workspace
if not target_cwd:
return False
if workspace_key(file_cwd) != workspace_key(target_cwd):
return False
return True
except Exception:
return False
def purge_artifacts(self, uuid: str, ctx: DiscoveryContext) -> List[str]:
purged = []
path = self._find_rollout(uuid, ctx)
try:
res = subprocess.run(['codex', 'delete', uuid, '--force'],
capture_output=True, text=True, timeout=5)
if res.returncode == 0:
if path:
purged.append(path)
return purged
except Exception:
pass
if path and os.path.exists(path):
os.remove(path)
purged.append(path)
db = self._state_db(ctx)
if db and os.path.exists(db):
try:
conn = sqlite3.connect(db)
conn.execute("DELETE FROM threads WHERE id=?", (uuid,))
conn.commit()
conn.close()
purged.append(f"state db row for thread: {uuid}")
except Exception:
pass
return purged
def spawn_spec(self, binary: str, session_uuid: str = "", use_wrapper: bool = False) -> str:
return f"{binary} --dangerously-bypass-approvals-and-sandbox"
def resume_spec(self, binary: str, session_uuid: str, materialized: bool = False) -> str:
if materialized and session_uuid:
return f"{binary} resume --dangerously-bypass-approvals-and-sandbox {session_uuid}"
return f"{binary} --dangerously-bypass-approvals-and-sandbox"
def auth_ok(self, run_cmd: Optional[Any] = None) -> bool:
home = os.environ.get('HOME_DIR') or os.environ.get('HOME') or os.path.expanduser('~')
codex_home = os.environ.get('CODEX_HOME') or f"{home}/.codex"
return os.path.exists(f"{codex_home}/auth.json") or bool(os.environ.get('OPENAI_API_KEY'))
def discover(self, ctx: DiscoveryContext) -> List[str]:
target_ws = ctx.workspace or ctx.cwd
target_key = workspace_key(target_ws) if target_ws else ""
db = self._state_db(ctx)
candidates = []
seen = set()
if db and os.path.exists(db):
try:
conn = sqlite3.connect(db)
cur = conn.cursor()
cur.execute("PRAGMA table_info(threads)")
cols = {r[1] for r in cur.fetchall()}
order_col = "updated_at" if "updated_at" in cols else ("created_at" if "created_at" in cols else None)
order_clause = f"ORDER BY {order_col} DESC" if order_col else ""
# Query database with workspace scope before bounding (R3)
if "cwd" in cols and target_ws:
direct_cwds = list({target_ws, os.path.realpath(target_ws)})
placeholders = ",".join("?" * len(direct_cwds))
cur.execute(f"SELECT id, cwd FROM threads WHERE cwd IN ({placeholders}) {order_clause} LIMIT 50", direct_cwds)
for uuid, cwd in cur.fetchall():
if uuid and uuid not in seen and self.verify_artifact(uuid, ctx):
candidates.append(uuid)
seen.add(uuid)
# Page through batches if needed to catch symlink aliases / canonical paths
if len(candidates) < 50:
cur.execute(f"SELECT id, cwd FROM threads {order_clause}")
while True:
batch = cur.fetchmany(100)
if not batch:
break
for uuid, cwd in batch:
if uuid in seen:
continue
if target_key and cwd and workspace_key(cwd) != target_key:
continue
if uuid and self.verify_artifact(uuid, ctx):
candidates.append(uuid)
seen.add(uuid)
if len(candidates) >= 50:
break
if len(candidates) >= 50:
break
conn.close()
except Exception:
pass
# R2: If database yielded usable candidates, return them; otherwise fall through to transcript discovery
if candidates:
return candidates
root = self._codex_home(ctx)
paths = sorted(glob.glob(f"{root}/sessions/*/*/*/rollout-*.jsonl"), key=os.path.getmtime, reverse=True)
for path in paths:
base = os.path.basename(path)
if base.endswith(".jsonl") and len(base) >= 42:
uuid = base[:-6][-36:]
if uuid and uuid not in seen and self.verify_artifact(uuid, ctx):
candidates.append(uuid)
seen.add(uuid)
if len(candidates) >= 50:
break
return candidates