diff --git a/.agents/reports/creator-agy-01/report-155f5092.md b/.agents/reports/creator-agy-01/report-155f5092.md new file mode 100644 index 0000000..0215dec --- /dev/null +++ b/.agents/reports/creator-agy-01/report-155f5092.md @@ -0,0 +1,60 @@ +# Job 155f5092 Final Report: Resolution of Follow-Up Findings T1 & T2 + +- **Job ID**: 155f5092 +- **Reviewer Target**: `planner-reviewer-codex-01` (`ff721ed1`) +- **Status**: Completed (Both regression test findings T1 and T2 resolved and mutation-verified) + +--- + +## 1. Summary of Changes + +### T1 (P2): Invalid-metadata assertions only exercise "file not found" +- **Problem**: In `test_codex_verify_artifact_metadata_fail_closed`, the fixture file was placed outside the discovery path without the finder override being active for cases 1–5. Consequently, `verify_artifact` returned `False` on file existence before reaching metadata parsing, which allowed the old buggy verifier (from commit `76f10b2`) to pass the test. +- **Fix**: + - In `tests/test_a4_adapter_contract.py`, updated `test_codex_verify_artifact_metadata_fail_closed` to use pytest `monkeypatch`. + - Installed `adapter._find_rollout` monkeypatch override *before* running all assertions. + - Added an explicit assertion verifying that `adapter._find_rollout(uuid, ctx)` resolves the fixture file before evaluating malformed contents. + - Parameterized invalid metadata cases: + - `{}` (empty dict - R4 defect) + - `{"payload": {"cwd": ws}}` (missing `payload.id`) + - `{"payload": {"id": uuid}}` (missing `payload.cwd`) + - `{"payload": {"id": "other-id", "cwd": ws}}` (mismatched session ID) + - `{"payload": {"id": uuid, "cwd": "/wrong/path"}}` (mismatched workspace cwd) + - `"not-valid-json"` (malformed JSON syntax) + - `[]` (non-dict JSON) + - `{"payload": "not-a-dict"}` (non-dict payload) + - Added database/transcript metadata inconsistency test: DB has conflicting cwd, asserting fail-closed behavior even if rollout matches workspace. + - Retained a valid control case with matching DB cwd and transcript cwd, asserting `True`. +- **Mutation Verification**: + - Bound `old_verify_artifact` from commit `76f10b2` in memory: test **FAILED** with `AssertionError: Expected False for payload: {} (assert True is False)`. + - With current implementation: test **PASSED**. + +### T2 (P2): Unmanaged-session test never supplies a session to drift-B +- **Problem**: `test_reconcile_drift_b_unmanaged_session_ignored` used a minimal custom mock script that did not support the `herdr ls -F "#{session_name}|#{session_created}"` or `herdr list-panes -F` query shapes used by `reconcile.sh`, meaning drift-B was never evaluated for the session. +- **Fix**: + - In `tests/test_tier1_unit.py`, updated `test_reconcile_drift_b_unmanaged_session_ignored` to use the contract-compatible `mock_herdr` fixture. + - Seeded `mock_herdr_state.json` with two sessions running `codex` in the workspace: + 1. Unmanaged candidate: `"personal-session"` (unmanaged name, no `MAM_MANAGED` env marker). + 2. Managed positive control: `"test-proj-creator-codex"` (standard managed name pattern). + - Asserted that both sessions are discoverable via `herdr ls -F "#{session_name}|#{session_created}"` and `herdr list-panes -t -F "#{pane_pid}|#{pane_current_path}|#{pane_current_command}"` before running reconcile. + - Ran `reconcile.sh --once --emit-diff`. + - Asserted that `"personal-session"` is ignored (not present in `drifts` as class B, not in `agent-sessions.yaml`). + - Asserted that `"test-proj-creator-codex"` is registered as drift class B and added to `agent-sessions.yaml`. +- **Mutation Verification**: + - Temporarily restored the old fallback (`if not agent: if pm_check and pm_check.get("cmd") in ...: agent = ...`): test **FAILED** with `AssertionError: assert not any(d.get("name") == "personal-session" for d in drifts)` (because `"personal-session"` was incorrectly registered). + - With current implementation (fallback removed): test **PASSED**. + +--- + +## 2. Validation & Verification + +1. `git diff --check`: Clean (exit code 0). +2. T1 Mutation Check: + - Against `76f10b2` `verify_artifact`: FAILED (`AssertionError: Expected False for payload: {}`). + - Against current `verify_artifact`: PASSED. +3. T2 Mutation Check: + - Against `76f10b2` fallback in `reconcile.sh`: FAILED (`assert not True` on personal-session registration). + - Against current `reconcile.sh`: PASSED. +4. Focused Test Execution: + - `pytest tests/test_a4_adapter_contract.py tests/test_tier1_unit.py -q -k "codex or adapter or facts_bridge or delegate_agent or cli_bridge"`: **39 passed, 61 deselected in 46.23s**. + - `pytest tests/test_tier1_unit.py::test_reconcile_drift_b_unmanaged_session_ignored tests/test_version_consistency.py tests/test_tier2_component.py::test_comp_codex_create_and_reconcile_and_stop -q`: **4 passed in 40.15s**. diff --git a/.agents/reports/planner-reviewer-codex-01/report-ca31d443.md b/.agents/reports/planner-reviewer-codex-01/report-ca31d443.md new file mode 100644 index 0000000..fc15ca0 --- /dev/null +++ b/.agents/reports/planner-reviewer-codex-01/report-ca31d443.md @@ -0,0 +1,48 @@ +# Follow-up review — ca31d443 + +Reviewed the cumulative corrections on `support-codex`, HEAD `76f10b2`, with particular attention to T1 and T2 from job `ff721ed1`. Both remaining findings are resolved. No new blocking findings were identified in this review. No production files were edited by this reviewer. + +## Findings closed + +### T1: Invalid-metadata regression now exercises the verifier + +The test installs the rollout finder override before every invalid case and explicitly verifies that the fixture resolves and exists. It covers missing identity/workspace metadata, mismatched identity/workspace, malformed JSON and payload types, database/transcript cwd disagreement, and a valid database/transcript control case. Pytest monkeypatch restores the singleton adapter after the test. + +Independent mutation verification: loaded `CodexAgentAdapter.verify_artifact` from `76f10b2` and bound it to the registry adapter in memory, then ran the revised test in a temporary directory. The test correctly failed with `Expected False for payload: {}`. The same revised test passes with the current implementation. This directly closes the previous false-positive regression coverage. + +### T2: Unmanaged-session regression now reaches drift-B + +The revised test uses the existing mock Herdr fixture, seeds both unmanaged and managed agent records, asserts listing and pane metadata visibility, and checks both emitted drifts and resulting YAML. The managed positive control must register while the unmanaged session must remain absent. + +Independent mutation verification: instantiated the actual sandbox and mock Herdr fixtures in a temporary directory, restored only that sandbox's reconcile script to `76f10b2`, and ran the revised test. It correctly failed at `tests/test_tier1_unit.py:1236`: `assert not any(d.get("name") == unmanaged_name for d in drifts)`. This confirms failure occurs at the intended unmanaged-session behavior, rather than during setup or visibility checks. The test passes with the current reconciler. + +## Prior production findings + +The four production correction files have the same Git blob hashes as in the prior review: + +- Codex adapter: `1e7a5c74f968f56521108b95a707fbe07768a07e` +- Reconciler: `da806b7237e21ee55b9e1b5ea7bec1c72d570f79` +- Status: `d668bbdea0704ea6ddd5c33b3ddba3a8184b0f48` +- Stop: `d14ffeb7208caa299737cafc268cc338ff4addd6` + +Accordingly, the prior acceptance of R1–R6 stands: control-key stop transport, empty/stale DB fallback, workspace-scoped discovery, metadata validation, stale-row status handling, and managed-session identification. T1/T2 now provide effective regression protection for the two previously uncovered cases. + +## Validation + +Current run: + +`./.venv/bin/python -m pytest tests/test_a4_adapter_contract.py tests/test_tier1_unit.py::test_reconcile_drift_b_unmanaged_session_ignored tests/test_version_consistency.py -q` + +Result: **36 passed in 10.71 seconds**, exit 0. + +- Both independent old-code mutation checks failed at the intended defect assertions, as detailed above. +- `git diff --check main`: clean. +- Inspection confirmed the remaining cumulative changes match the previously reviewed fixes, with the two regression-test corrections described above. + +Validation scope: the prior follow-up independently passed 43 focused adapter/lifecycle/status/version tests on the same production fixes. The initial review independently passed all 462 baseline tests on `76f10b2`. Those are historical results, not a claim that this review reran the full suite on the current working tree. This round reran the affected adapter tests, corrected reconciler test, and version checks; only the regression tests changed since the prior accepted production fixes. + +## Disposition + +T1 and T2 are closed; all findings raised by this reviewer are resolved. No planner escalation is needed. This verdict covers the reviewed working-tree changes and does not itself merge or commit them. + +[VERDICT: PASS] diff --git a/.agents/skills/lib_py/agents/adapters/codex.py b/.agents/skills/lib_py/agents/adapters/codex.py index c8a0bb3..1e7a5c7 100644 --- a/.agents/skills/lib_py/agents/adapters/codex.py +++ b/.agents/skills/lib_py/agents/adapters/codex.py @@ -73,17 +73,33 @@ class CodexAgentAdapter(BaseAgentAdapter): return False if ctx.epoch and os.path.getmtime(path) < ctx.epoch: return False - found_cwd = db_cwd - if not found_cwd: - try: - with open(path) as f: - first = f.readline() - found_cwd = json.loads(first).get('payload', {}).get('cwd') - except Exception: + + # 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 - if found_cwd and workspace_key(found_cwd) != workspace_key(ctx.cwd): + 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 - return True def purge_artifacts(self, uuid: str, ctx: DiscoveryContext) -> List[str]: purged = [] @@ -126,7 +142,13 @@ class CodexAgentAdapter(BaseAgentAdapter): 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) @@ -135,24 +157,53 @@ class CodexAgentAdapter(BaseAgentAdapter): 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 "" - rows = conn.execute( - f"SELECT id, cwd FROM threads {order_clause} LIMIT 50" - ).fetchall() + + # 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() - candidates = [] - for uuid, cwd in rows: - if ctx.workspace and cwd and workspace_key(cwd) != workspace_key(ctx.workspace): - continue - if uuid and self.verify_artifact(uuid, ctx): - candidates.append(uuid) - return candidates 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) - candidates = [] - paths = sorted(glob.glob(f"{root}/sessions/*/*/*/rollout-*.jsonl"), key=os.path.getmtime, reverse=True)[:50] + paths = sorted(glob.glob(f"{root}/sessions/*/*/*/rollout-*.jsonl"), key=os.path.getmtime, reverse=True) for path in paths: - uuid = os.path.basename(path)[:-6][-36:] - if uuid and self.verify_artifact(uuid, ctx): - candidates.append(uuid) + 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 diff --git a/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh b/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh index 0c2e844..da806b7 100755 --- a/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh +++ b/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh @@ -547,10 +547,6 @@ if herdr_confirmed: except Exception: pass - if not agent: - if pm_check and pm_check.get('cmd') in ('claude', 'agy', 'hermes', 'grok', 'opencode', 'codex'): - agent = pm_check['cmd'] - if not agent: continue diff --git a/.agents/skills/multi-agent-mux-status/scripts/status.sh b/.agents/skills/multi-agent-mux-status/scripts/status.sh index 4f08c3e..d668bbd 100755 --- a/.agents/skills/multi-agent-mux-status/scripts/status.sh +++ b/.agents/skills/multi-agent-mux-status/scripts/status.sh @@ -139,8 +139,6 @@ def resume_on_disk(s): conn.close() if r and r[0] and os.path.exists(r[0]): return 'yes' - if r: - return 'yes' except Exception: pass m = glob.glob(f"{codex_home}/sessions/*/*/*/rollout-*-{u}.jsonl") @@ -340,8 +338,6 @@ def resume_on_disk(s): conn.close() if r and r[0] and os.path.exists(r[0]): return 'yes' - if r: - return 'yes' except Exception: pass m = glob.glob(f"{codex_home}/sessions/*/*/*/rollout-*-{u}.jsonl") diff --git a/.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh b/.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh index 910a17e..d14ffeb 100755 --- a/.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh +++ b/.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh @@ -198,7 +198,11 @@ graceful_stop() { pane_pid=$(herdr list-panes -t "$SESSION_NAME" -F '#{pane_pid}' 2>/dev/null | head -1 || true) exitkey="$("$(_delegate_py_bin)" -m lib_py.agents exit-key "$AGENT" 2>/dev/null || echo "/exit")" echo "graceful: send-keys '$exitkey' to $SESSION_NAME" - send_keys_safe "$SESSION_NAME" "$exitkey" "stop$$" || echo "graceful: safe delivery failed (rc=$?) — falling back to kill chain" + if [ "$exitkey" = "Ctrl+C" ] || [ "$exitkey" = "C-c" ]; then + herdr send-keys -t "$SESSION_NAME" C-c 2>/dev/null || true + else + send_keys_safe "$SESSION_NAME" "$exitkey" "stop$$" || echo "graceful: safe delivery failed (rc=$?) — falling back to kill chain" + fi _wait_session_gone "$SESSION_NAME" 5 || true if ! herdr has-session -t "$SESSION_NAME" 2>/dev/null; then echo "graceful: exited cleanly" diff --git a/tests/conftest.py b/tests/conftest.py index 4dc9e74..e7708a3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -655,7 +655,7 @@ elif cmd1 == "agent": rollout_file = os.path.join(sessions_dir, f"rollout-001-{session_uuid}.jsonl") now_ts = int(time.time()) with open(rollout_file, "w") as rf: - rf.write(json.dumps({"payload": {"cwd": ws_abs, "id": session_uuid}}) + "\\\\n") + rf.write(json.dumps({"payload": {"cwd": ws_abs, "id": session_uuid}}) + "\\n") conn.execute("INSERT OR REPLACE INTO threads (id, cwd, rollout_path, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", (session_uuid, ws_abs, rollout_file, now_ts, now_ts)) conn.commit() conn.close() diff --git a/tests/test_a4_adapter_contract.py b/tests/test_a4_adapter_contract.py index f88ae9e..812c3ce 100644 --- a/tests/test_a4_adapter_contract.py +++ b/tests/test_a4_adapter_contract.py @@ -995,7 +995,172 @@ def test_codex_reconcile_full_block_integration(tmp_path): assert valid_candidates == ['u-target'] +def test_codex_verify_artifact_metadata_fail_closed(tmp_path, monkeypatch): + """R4 / T1: Verify verify_artifact fails closed on missing/invalid metadata, id mismatch, or db inconsistency.""" + import json, sqlite3 + from lib_py.agents.registry import get_adapter + from lib_py.agents.base import DiscoveryContext + ws = str(tmp_path / "ws") + home = str(tmp_path / "home") + os.makedirs(ws, exist_ok=True) + os.makedirs(home, exist_ok=True) + adapter = get_adapter('codex') + ctx = DiscoveryContext(workspace=ws, agent_name='codex', home_dir=home) + + uuid = "12345678-1234-1234-1234-123456789abc" + rollout_file = tmp_path / "rollout.jsonl" + rollout_file.write_text("{}\n") + + # Install finder override using pytest monkeypatch before all cases + monkeypatch.setattr(adapter, "_find_rollout", lambda u, c: str(rollout_file)) + + # Assert finder resolves the fixture before testing malformed contents + resolved_path = adapter._find_rollout(uuid, ctx) + assert resolved_path == str(rollout_file) + assert os.path.exists(resolved_path) + + # Parameterized invalid metadata cases + invalid_payloads = [ + "{}", # Empty dict (R4 finding) + json.dumps({"payload": {"cwd": ws}}), # Missing payload.id + json.dumps({"payload": {"id": uuid}}), # Missing payload.cwd + json.dumps({"payload": {"id": "other-id", "cwd": ws}}), # Mismatched session id + json.dumps({"payload": {"id": uuid, "cwd": "/wrong/path"}}), # Mismatched workspace + "not-valid-json", # Non-JSON content + json.dumps([]), # Non-dict JSON + json.dumps({"payload": "not-a-dict"}), # Non-dict payload + ] + + for inv in invalid_payloads: + rollout_file.write_text(inv + "\n") + assert adapter.verify_artifact(uuid, ctx) is False, f"Expected False for payload: {inv}" + + # Database / transcript metadata inconsistency check + db_path = tmp_path / "state_5.sqlite" + conn = sqlite3.connect(str(db_path)) + conn.execute("CREATE TABLE threads (id TEXT PRIMARY KEY, cwd TEXT, rollout_path TEXT)") + conn.execute("INSERT INTO threads VALUES (?, ?, ?)", (uuid, "/conflicting/db/cwd", str(rollout_file))) + conn.commit() + conn.close() + + monkeypatch.setattr(adapter, "_state_db", lambda c: str(db_path)) + # Even if transcript has valid ws, DB cwd conflict must fail closed + rollout_file.write_text(json.dumps({"payload": {"id": uuid, "cwd": ws}}) + "\n") + assert adapter.verify_artifact(uuid, ctx) is False + + # Valid control case (matching DB cwd and transcript cwd) + conn = sqlite3.connect(str(db_path)) + conn.execute("UPDATE threads SET cwd=? WHERE id=?", (ws, uuid)) + conn.commit() + conn.close() + assert adapter.verify_artifact(uuid, ctx) is True +def test_codex_discover_empty_stale_db_fallback(tmp_path): + """R2: Verify discover falls back to JSONL transcripts when DB is empty or has stale rows.""" + import json, sqlite3, time + from lib_py.agents.registry import get_adapter + from lib_py.agents.base import DiscoveryContext + ws = str(tmp_path / "ws") + home = str(tmp_path / "home") + os.makedirs(ws, exist_ok=True) + os.makedirs(home, exist_ok=True) + codex_home = tmp_path / "home" / ".codex" + os.makedirs(codex_home, exist_ok=True) + + adapter = get_adapter('codex') + ctx = DiscoveryContext(workspace=ws, agent_name='codex', home_dir=home) + + # Create a valid date-nested rollout on disk + uuid_valid = "11111111-1111-1111-1111-111111111111" + sessions_dir = codex_home / "sessions" / "2026" / "09" / "05" + sessions_dir.mkdir(parents=True, exist_ok=True) + valid_file = sessions_dir / f"rollout-2026-09-05T00-00-00-{uuid_valid}.jsonl" + valid_file.write_text(json.dumps({"payload": {"id": uuid_valid, "cwd": ws}}) + "\n") + + # Empty DB: threads table exists with 0 rows + db_path = codex_home / "state_5.sqlite" + conn = sqlite3.connect(str(db_path)) + conn.execute("CREATE TABLE threads (id TEXT PRIMARY KEY, cwd TEXT, rollout_path TEXT, updated_at INTEGER)") + conn.commit() + conn.close() + + # Should fall through to JSONL file and discover uuid_valid + found = adapter.discover(ctx) + assert uuid_valid in found + + # Stale DB: insert row pointing to nonexistent file + conn = sqlite3.connect(str(db_path)) + conn.execute("INSERT INTO threads (id, cwd, rollout_path, updated_at) VALUES (?, ?, ?, ?)", + ("stale-uuid", ws, str(codex_home / "nonexistent.jsonl"), int(time.time()))) + conn.commit() + conn.close() + + found_stale = adapter.discover(ctx) + assert uuid_valid in found_stale +def test_codex_discover_workspace_scoping_over_50_foreign_rows(tmp_path): + """R3: Verify global LIMIT does not hide target-workspace threads behind >50 foreign threads.""" + import json, sqlite3, time + from pathlib import Path + from lib_py.agents.registry import get_adapter + from lib_py.agents.base import DiscoveryContext + ws_target = str(tmp_path / "ws_target") + ws_foreign = str(tmp_path / "ws_foreign") + home = str(tmp_path / "home") + os.makedirs(ws_target, exist_ok=True) + os.makedirs(ws_foreign, exist_ok=True) + os.makedirs(home, exist_ok=True) + + adapter = get_adapter('codex') + ctx_target = DiscoveryContext(workspace=ws_target, agent_name='codex', home_dir=home) + + codex_home = Path(home) / ".codex" + sessions_dir = codex_home / "sessions" / "2026" / "09" / "05" + sessions_dir.mkdir(parents=True, exist_ok=True) + db_path = codex_home / "state_5.sqlite" + conn = sqlite3.connect(str(db_path)) + conn.execute("CREATE TABLE IF NOT EXISTS threads (id TEXT PRIMARY KEY, cwd TEXT, rollout_path TEXT, updated_at INTEGER)") + + now = int(time.time()) + # Seed 1 valid target thread + target_uuid = "target-1234-1234-1234-123456789abc" + target_file = sessions_dir / f"rollout-001-{target_uuid}.jsonl" + target_file.write_text(json.dumps({"payload": {"id": target_uuid, "cwd": ws_target}}) + "\n") + conn.execute("INSERT INTO threads (id, cwd, rollout_path, updated_at) VALUES (?, ?, ?, ?)", + (target_uuid, ws_target, str(target_file), now - 100)) + + # Seed 60 newer foreign threads + for i in range(60): + f_uuid = f"foreign-{i:04d}-1234-1234-123456789abc" + f_file = sessions_dir / f"rollout-001-{f_uuid}.jsonl" + f_file.write_text(json.dumps({"payload": {"id": f_uuid, "cwd": ws_foreign}}) + "\n") + conn.execute("INSERT INTO threads (id, cwd, rollout_path, updated_at) VALUES (?, ?, ?, ?)", + (f_uuid, ws_foreign, str(f_file), now + i)) + conn.commit() + conn.close() + + candidates = adapter.discover(ctx_target) + assert target_uuid in candidates + + +def test_codex_discover_symlink_canonical_alias(tmp_path): + """R3: Verify discover resolves symlink workspace aliases correctly.""" + from lib_py.agents.registry import get_adapter + from lib_py.agents.base import DiscoveryContext + real_ws = tmp_path / "real_ws" + real_ws.mkdir() + symlink_ws = tmp_path / "symlink_ws" + symlink_ws.symlink_to(real_ws, target_is_directory=True) + home = str(tmp_path / "home") + os.makedirs(home, exist_ok=True) + + adapter = get_adapter('codex') + ctx = DiscoveryContext(workspace=str(symlink_ws), agent_name='codex', home_dir=home) + + uuid = "sym-1234-1234-1234-123456789abc" + _seed_codex_session(home, uuid, str(real_ws), 2000.0) + + candidates = adapter.discover(ctx) + assert uuid in candidates diff --git a/tests/test_tier1_unit.py b/tests/test_tier1_unit.py index 0c4f96c..f9b50a2 100644 --- a/tests/test_tier1_unit.py +++ b/tests/test_tier1_unit.py @@ -1092,8 +1092,156 @@ def test_orc_onboard_codex_flag_tolerant_regex(): assert res.stdout.strip() == "12345678-1234-1234-1234-123456789abc" +def test_stop_session_codex_sends_c_c_chord_not_text_prompt(mam_sandbox, mock_herdr, mock_agents): + """R1: Verify graceful stop sends C-c key chord rather than text prompt for codex.""" + import subprocess, json + # 1. Create a codex session + create_script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh" + res = subprocess.run([ + "bash", str(create_script), + "--workspace", str(mam_sandbox), + "--agent", "codex", + "--role", "creator" + ], capture_output=True, text=True, cwd=str(mam_sandbox)) + assert res.returncode == 0, res.stderr + + yaml_path = mam_sandbox / ".mam" / "agent-sessions.yaml" + import yaml + with open(yaml_path) as f: + data = yaml.safe_load(f) + session_name = data["herdr_sessions"][0]["name"] + + # 2. Stop the session + stop_script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-stop" / "scripts" / "stop_session.sh" + res_stop = subprocess.run([ + "bash", str(stop_script), + "--session", session_name, + "--agent", "codex" + ], capture_output=True, text=True, cwd=str(mam_sandbox)) + assert res_stop.returncode == 0, res_stop.stderr + + # 3. Verify mock_herdr recorded C-c send-keys call and did NOT record Ctrl+C prompt + with open(mock_herdr) as f: + state = json.load(f) + calls = state.get("calls", []) + pane_send_keys = [c for c in calls if "send-keys" in c and "C-c" in c] + agent_prompts = [c for c in calls if "prompt" in c and any("Ctrl+C" in str(arg) for arg in c)] + assert len(pane_send_keys) >= 1 + assert len(agent_prompts) == 0 +def test_status_resume_on_disk_codex_stale_and_valid(mam_sandbox): + """R5: Verify status.sh resume_on_disk reports MISSING for stale DB rows and yes for existing files.""" + import sqlite3, json, subprocess + codex_home = mam_sandbox / ".codex" + codex_home.mkdir(parents=True, exist_ok=True) + db_path = codex_home / "state_5.sqlite" + conn = sqlite3.connect(str(db_path)) + conn.execute("CREATE TABLE threads (id TEXT PRIMARY KEY, cwd TEXT, rollout_path TEXT)") + + # 1. Stale row: rollout_path does not exist + stale_uuid = "stale-1234-1234-1234-123456789abc" + conn.execute("INSERT INTO threads (id, cwd, rollout_path) VALUES (?, ?, ?)", + (stale_uuid, str(mam_sandbox), str(codex_home / "nonexistent.jsonl"))) + + # 2. Valid row: rollout_path exists + valid_uuid = "valid-1234-1234-1234-123456789abc" + valid_file = codex_home / f"rollout-{valid_uuid}.jsonl" + valid_file.write_text(json.dumps({"payload": {"id": valid_uuid, "cwd": str(mam_sandbox)}}) + "\n") + conn.execute("INSERT INTO threads (id, cwd, rollout_path) VALUES (?, ?, ?)", + (valid_uuid, str(mam_sandbox), str(valid_file))) + conn.commit() + conn.close() + + yaml_path = mam_sandbox / ".mam" / "agent-sessions.yaml" + yaml_path.write_text(f"""herdr_sessions: + - name: test-stale-codex + status: running + codex_session_id_own: {stale_uuid} + pane: + cwd: {mam_sandbox} + - name: test-valid-codex + status: running + codex_session_id_own: {valid_uuid} + pane: + cwd: {mam_sandbox} +""") + script_path = mam_sandbox / "skills" / "multi-agent-mux-status" / "scripts" / "status.sh" + env = {**os.environ, "CODEX_HOME": str(codex_home)} + res = subprocess.run(["bash", str(script_path), "--json"], capture_output=True, text=True, env=env) + assert res.returncode == 0 + data = json.loads(res.stdout) + details = {d["name"]: d["resume_state"] for d in data["sessions_detail"]} + assert details["test-stale-codex"] == "MISSING" + assert details["test-valid-codex"] == "yes" +def test_reconcile_drift_b_unmanaged_session_ignored(mam_sandbox, mock_herdr): + """R6 / T2: Verify drift-B ignores unmanaged in-workspace sessions while registering managed positive control.""" + import subprocess, json, yaml + reconcile_script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-monitor" / "scripts" / "reconcile.sh" + assert reconcile_script.exists() + # Create dummy yaml with no sessions + yaml_path = mam_sandbox / ".mam" / "agent-sessions.yaml" + yaml_path.parent.mkdir(parents=True, exist_ok=True) + yaml_path.write_text("herdr_sessions: []\n") + + unmanaged_name = "personal-session" + managed_name = "test-proj-creator-codex" + + # Seed mock_herdr with an unmanaged session and a managed control session running codex in workspace + state = { + "workspaces": [{"workspace_id": "w1", "label": "default", "cwd": str(mam_sandbox)}], + "agents": { + unmanaged_name: { + "name": unmanaged_name, + "status": "running", + "cwd": str(mam_sandbox), + "command": "codex", + "pid": 12345 + }, + managed_name: { + "name": managed_name, + "status": "running", + "cwd": str(mam_sandbox), + "command": "codex", + "pid": 54321 + } + }, + "calls": [] + } + with open(mock_herdr, "w") as f: + json.dump(state, f) + + # Assert candidate is visible to listing and pane metadata lookup before running reconcile + r_ls = subprocess.run(["herdr", "ls", "-F", "#{session_name}|#{session_created}"], capture_output=True, text=True) + assert r_ls.returncode == 0 + assert unmanaged_name in r_ls.stdout + assert managed_name in r_ls.stdout + + r_pm = subprocess.run(["herdr", "list-panes", "-t", unmanaged_name, "-F", "#{pane_pid}|#{pane_current_path}|#{pane_current_command}"], capture_output=True, text=True) + assert r_pm.returncode == 0 + assert f"12345|{str(mam_sandbox)}|codex" in r_pm.stdout + + res = subprocess.run(["bash", str(reconcile_script), "--once", "--emit-diff"], + capture_output=True, text=True, cwd=str(mam_sandbox), + env={**os.environ, "WORKSPACE_ROOT": str(mam_sandbox)}) + assert res.returncode == 0, res.stderr + + out = json.loads(res.stdout) + drifts = out.get("drifts", []) + + # Personal session must NOT be registered as drift B + assert not any(d.get("name") == unmanaged_name for d in drifts) + + # Managed positive control MUST be registered as drift B + b_registered = [d.get("name") for d in drifts if d.get("class") == "B"] + assert managed_name in b_registered + + # Verify YAML updated with managed session but without unmanaged session + with open(yaml_path) as f: + data = yaml.safe_load(f) + registered_names = [s.get("name") for s in data.get("herdr_sessions", [])] + assert unmanaged_name not in registered_names + assert managed_name in registered_names diff --git a/tests/test_tier2_component.py b/tests/test_tier2_component.py index 1c25bdd..dd632a7 100644 --- a/tests/test_tier2_component.py +++ b/tests/test_tier2_component.py @@ -1252,6 +1252,8 @@ def test_comp_opencode_create_and_reconcile_and_stop(mam_sandbox, mock_herdr, mo # Run reconcile.sh to trigger drift-C materialization reconcile_script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-monitor" / "scripts" / "reconcile.sh" res_rec = subprocess.run(["bash", str(reconcile_script), "--once", "--emit-diff"], capture_output=True, text=True, cwd=str(mam_sandbox), env={**os.environ, "WORKSPACE_ROOT": str(mam_sandbox)}) + print("RECONCILE STDOUT:\n", res_rec.stdout) + print("RECONCILE STDERR:\n", res_rec.stderr) assert res_rec.returncode == 0, res_rec.stderr with open(yaml_path) as f: