feat(auto-agents): deterministic compliance short-circuit + push-substrate fixes
Three blocker classes surfaced by the 2026-05-13 4-hour live pipeline
test (PR #30 / #28 / #25), all fixed here:
1. `_pr_clone` left `remote.origin.mirror=true` on the bare mirror,
making every `git push --force-with-lease origin <refspec>` fail
with `fatal: --mirror can't be combined with refspecs`. Worktrees
inherited the bad config; worker had to discover + work around it
each cycle, and one cycle's fragile recovery LOST a real commit.
`_disable_mirror_push_semantics` clears the flag at clone time
and on every refresh.
2. Predicate treated `outcome=resolved + head_sha_advanced=False` as
transport-class (UNKNOWN bucket → wasted same-tier retry). It's
actually competence — the worker emitted a complete-looking JSON
while delivering nothing. Now escalates immediately.
3. The dispatcher's own deterministic sections (compliance_gaps +
gate_preflight) misled the worker into emitting `resolved`
whenever both were clean — regardless of real remote CI state.
New module `_implementer_compliance_apply` + dispatcher hook
`_maybe_short_circuit` move trivial compliance fixes (CONTRIBUTORS
line, CHANGELOG stub from PR title, ISSUES CLOSED footer from
prefetched linked_issues) onto the dispatcher's deterministic
side per the auto-agents.md policy: "deterministic Python owns
orchestration; the LLM only handles the actual creative work."
Two short-circuit paths:
- P0 (always-on): compliance clean + preflight clean + remote CI =
success → skip LLM, emit `no_changes_needed`. Eliminates the
hallucination class observed live.
- A (opt-in via IMPLEMENTER_DISPATCHER_AUTO_FIX_COMPLIANCE=1):
compliance has only fixable gaps + preflight clean + CI failing →
dispatcher applies fixes, pushes, emits `resolved`. Worker
reserved for code-bug PRs only.
Plus supporting changes:
- gate_preflight payload includes `remote_ci_state` + `diverges_from_remote_ci`
- Telemetry rows gain `outcome_disputed=True` when resolved+no-push
- `EscalationAction.SUCCESS` accepts `no_changes_needed` outcome
- task-implementor.md procedure re-ordered: read `--field ci` FIRST
- Runtime hook `_dispatch_runtime._maybe_read_short_circuit` consumes
the dispatcher's `_short_circuit_result` stash and synthesizes a
SessionResult instead of spawning the LLM session
- Cycle archive's post_session_result gains `auto_fix_report` +
`short_circuit` fields
Tier B (perf) and Tier C (observability) deferred to follow-up.
Tests: 1464 passed, 3 skipped (+23 from 6315892e).
ISSUES CLOSED: #30 #28
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -397,27 +397,34 @@ This is a **performance** change, not a correctness change: the pre-fetched data
|
||||
|
||||
#### Procedure: `pr_fix` (PR Fix)
|
||||
|
||||
1. **Read the PR.** Run `python3 /tmp/local_tools/tools/implementer_pr_context.py read --pr {work_number} --field description` (apply the three-case contract from step 0b). Then `… --field metadata` for `head_sha` / `head_ref` / `base_ref` / `data_complete`. Set `branch_name = head_ref` from the metadata JSON. If BOTH return empty stdout, fall through to the legacy GET on `{forgejo_url}/api/v1/repos/{forgejo_owner}/{forgejo_repo}/pulls/{work_number}`.
|
||||
**CI-first principle (P6 / 2026-05-13):** the FIRST thing to check is what's actually broken on remote CI. Reading compliance/preflight sections before knowing what's failing tempts the worker into the trap of "compliance is clean → emit resolved" — but compliance being clean is only meaningful when there's no failing code-test. Always read `--field ci` first and let the failing-check list drive everything else.
|
||||
|
||||
2. **Read all reviews.** Run `python3 /tmp/local_tools/tools/implementer_pr_context.py read --pr {work_number} --field reviews` (apply the three-case contract — `[]\n` = no active REQUEST_CHANGES reviews, proceed; populated JSON list = use it with per-review inline comments pre-paginated; empty = fall through). If empty, fall through to the legacy paginated GET on `/pulls/{work_number}/reviews?limit=50&page=N` and then per-review comments.
|
||||
1. **Read the CI failure picture FIRST.** Run `python3 /tmp/local_tools/tools/implementer_pr_context.py read --pr {work_number} --field ci`. The returned JSON has `{status, checks[], status_completed, detail_completed}` whenever the sentinel exists. Each entry in `checks[]` carries the `target_url` and (when the dispatcher could fetch it) the failure-log excerpt — use that. If `status_completed` or `detail_completed` is `false`, the dispatcher's fetch failed for that slice and you should fall through to the legacy GET on `/commits/{head_sha}/statuses?limit=50&page=N` and per-`target_url` webfetch. If stdout is empty entirely, the sentinel doesn't exist (opt-out) — also fall through. **Identify the failing check names** before going further. If `status == "success"` and you got here anyway, something is unusual — read the rest of the sentinel sections to confirm what state the PR is in.
|
||||
|
||||
3. **Read all PR comments.** Run `python3 /tmp/local_tools/tools/implementer_pr_context.py read --pr {work_number} --field comments` (apply the three-case contract). For `pr_fix` / `request_changes_pr` work this returns `pr_comments`; for `issue_impl` it returns `issue_comments` — the script dispatches on `work_type` for you. If empty stdout, fall through to the legacy paginated GET on `/issues/{work_number}/comments?limit=50&page=N`.
|
||||
2. **Read the deterministic check sections.** Run `--field compliance_gaps` and `--field gate_preflight`. Cross-reference against your step 1 CI picture:
|
||||
- If `gate_preflight.diverges_from_remote_ci == true`, the local `--fast` gate says PASS but remote CI says failure — the failing CI job is something `--fast` doesn't run (e.g. `e2e_tests`, `coverage`). Trust the specific failing checks from step 1; **do NOT trust "preflight clean" alone**.
|
||||
- If `compliance_gaps.gaps_open_count > 0`, there's metadata to fill in. But check first whether the failing CI is metadata-related (e.g. a `commit-message-lint` check) or about code (e.g. `unit_tests`). If the latter, fix the code first; compliance metadata is step-2 work.
|
||||
- If `compliance_gaps.masked_checks` is non-empty, do NOT trust the "all gaps closed" verdict even if every value in `gaps` is `true`. The masked checks are unverified — re-run `git status` / `git log -1` in-session before deciding.
|
||||
|
||||
4. **Fetch CI failure details.** Run `python3 /tmp/local_tools/tools/implementer_pr_context.py read --pr {work_number} --field ci`. The returned JSON has `{status, checks[], status_completed, detail_completed}` whenever the sentinel exists. Each entry in `checks[]` carries the `target_url` and (when the dispatcher could fetch it) the failure-log excerpt — use that. If `status_completed` or `detail_completed` is `false` in the returned JSON, the dispatcher's fetch failed for that slice and you should fall through to the legacy GET on `/commits/{head_sha}/statuses?limit=50&page=N` and per-`target_url` webfetch. If stdout is empty entirely, the sentinel doesn't exist (opt-out) — also fall through.
|
||||
3. **Read the PR.** Run `python3 /tmp/local_tools/tools/implementer_pr_context.py read --pr {work_number} --field description` (apply the three-case contract from step 0b). Then `… --field metadata` for `head_sha` / `head_ref` / `base_ref` / `data_complete`. Set `branch_name = head_ref` from the metadata JSON. If BOTH return empty stdout, fall through to the legacy GET on `{forgejo_url}/api/v1/repos/{forgejo_owner}/{forgejo_repo}/pulls/{work_number}`.
|
||||
|
||||
5. **Create isolated clone.** Run `python3 /tmp/local_tools/tools/implementer_workspace.py discover --pr {work_number}`. If stdout's `repo_dir=` line carries a non-empty path, that IS your `{repo_dir}` for steps 6+ — the dispatcher has already cloned the PR's head branch at `head_sha` and checked it out for you. **Skip the `git-isolator-util` call entirely.** Only if `repo_dir=` is empty, call `git-isolator-util` with `create_branch: false` and `branch: {branch_name}` (the PR's head branch, resolved in step 1).
|
||||
4. **Read all reviews.** Run `python3 /tmp/local_tools/tools/implementer_pr_context.py read --pr {work_number} --field reviews` (apply the three-case contract — `[]\n` = no active REQUEST_CHANGES reviews, proceed; populated JSON list = use it with per-review inline comments pre-paginated; empty = fall through). If empty, fall through to the legacy paginated GET on `/pulls/{work_number}/reviews?limit=50&page=N` and then per-review comments.
|
||||
|
||||
6. **Fix the issues.** Address all CI failures and all unresolved reviewer feedback. Never partially address reviewer comments — every `REQUEST_CHANGES` concern must be fully resolved.
|
||||
5. **Read all PR comments.** Run `python3 /tmp/local_tools/tools/implementer_pr_context.py read --pr {work_number} --field comments` (apply the three-case contract). For `pr_fix` / `request_changes_pr` work this returns `pr_comments`; for `issue_impl` it returns `issue_comments` — the script dispatches on `work_type` for you. If empty stdout, fall through to the legacy paginated GET on `/issues/{work_number}/comments?limit=50&page=N`.
|
||||
|
||||
7. **Run quality gates locally** (same 6 gates as above). All must pass before pushing. Fix and re-run as many times as needed.
|
||||
6. **Create isolated clone.** Run `python3 /tmp/local_tools/tools/implementer_workspace.py discover --pr {work_number}`. If stdout's `repo_dir=` line carries a non-empty path, that IS your `{repo_dir}` for steps 7+ — the dispatcher has already cloned the PR's head branch at `head_sha` and checked it out for you. **Skip the `git-isolator-util` call entirely.** Only if `repo_dir=` is empty, call `git-isolator-util` with `create_branch: false` and `branch: {branch_name}` (the PR's head branch, resolved in step 3).
|
||||
|
||||
8. **Commit and push.** Call `git-commit-util` with `force_push_with_lease` operation (see Subagents section).
|
||||
7. **Fix the issues.** Address all CI failures and all unresolved reviewer feedback identified in steps 1-5. Never partially address reviewer comments — every `REQUEST_CHANGES` concern must be fully resolved. Anchor on the SPECIFIC failing-check names from step 1 — if you can't trace your fix back to one of those checks, you're probably not addressing what's actually broken.
|
||||
|
||||
9. **Post attempt comment** on the PR (see "Attempt Comments" section below).
|
||||
8. **Run quality gates locally** (same 6 gates as above). All must pass before pushing. Fix and re-run as many times as needed.
|
||||
|
||||
10. **Clean up.** `rm -rf {repo_dir}`
|
||||
9. **Commit and push.** Call `git-commit-util` with `force_push_with_lease` operation (see Subagents section).
|
||||
|
||||
11. **Exit.**
|
||||
10. **Post attempt comment** on the PR (see "Attempt Comments" section below).
|
||||
|
||||
11. **Clean up.** `rm -rf {repo_dir}`
|
||||
|
||||
12. **Exit.**
|
||||
|
||||
### Attempt Comments
|
||||
|
||||
|
||||
@@ -7,6 +7,88 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
### Added
|
||||
|
||||
- **Deterministic compliance-fix short-circuit + post-live-test
|
||||
hardening (2026-05-13, follow-up to the live 4-hour pipeline test
|
||||
on PR #30 / #28 / #25 / #26 / #20).** The live test exposed three
|
||||
blocker classes: a `remote.origin.mirror=true` clone-time bug that
|
||||
made every worker push fail; a predicate gap where the worker's
|
||||
self-reported `resolved` + no-push fell into the UNKNOWN bucket
|
||||
and wasted retries; and the deepest issue — the worker faithfully
|
||||
followed the new sentinel-routed deterministic-section
|
||||
instructions to emit `resolved` whenever compliance was clean and
|
||||
preflight was clean, regardless of remote CI state. This commit
|
||||
fixes all three plus moves trivial compliance fixes off the LLM
|
||||
entirely.
|
||||
|
||||
- **`_pr_clone._disable_mirror_push_semantics`** clears
|
||||
`remote.origin.mirror=true` on the bare mirror at clone time
|
||||
AND on every refresh (idempotent). Worktrees derived from the
|
||||
mirror no longer need the worker's per-command workaround.
|
||||
|
||||
- **Predicate change** (`_implementer_escalation.decide`):
|
||||
`outcome=resolved` + `head_sha_advanced=False` now escalates
|
||||
immediately rather than burning a same-tier retry on the
|
||||
UNKNOWN bucket. Worker hallucinated-success cases (3 of 5
|
||||
PR #30 attempts in the live test) are now competence-class.
|
||||
|
||||
- **`outcome_disputed` telemetry field** (`_phase4_telemetry`):
|
||||
when worker emits `resolved` but no push happened, the row
|
||||
records `outcome_disputed=True` so analysts can grep for the
|
||||
hallucination rate directly without re-deriving from
|
||||
outcome + head_sha_advanced.
|
||||
|
||||
- **Preflight ↔ remote CI divergence detection**
|
||||
(`_compute_deterministic_sections`): when local `--fast` says
|
||||
all gates pass but remote CI is failing, the gate_preflight
|
||||
payload includes `diverges_from_remote_ci=True` plus the
|
||||
actual remote state. Worker docs (task-implementor.md P6)
|
||||
instruct reading `--field ci` FIRST and not trusting preflight
|
||||
alone when divergence is flagged.
|
||||
|
||||
- **`_implementer_compliance_apply` module + dispatcher short-
|
||||
circuit** (`_maybe_short_circuit` in `dispatch_implementer`).
|
||||
Two skip paths:
|
||||
|
||||
1. **P0 (skip-when-green):** compliance clean + preflight
|
||||
clean + masked_checks empty + remote CI = success → emit
|
||||
synthetic `{"outcome": "no_changes_needed",
|
||||
"_dispatcher_short_circuit": "P0"}`, skip LLM session,
|
||||
release claim. Always active when escalation is enabled
|
||||
(no flag).
|
||||
|
||||
2. **A (auto-fix-compliance):** when only compliance gaps
|
||||
remain, preflight is clean, no masked checks, and
|
||||
`IMPLEMENTER_DISPATCHER_AUTO_FIX_COMPLIANCE=1` is set
|
||||
(default OFF), the dispatcher applies fixes deterministically
|
||||
— CONTRIBUTORS line, CHANGELOG stub (from PR title),
|
||||
ISSUES CLOSED footer (from prefetched linked_issues) — then
|
||||
pushes the commit and emits synthetic `resolved`. Per
|
||||
`.opencode/agents/auto-agents.md`: "deterministic Python
|
||||
owns orchestration; the LLM only handles the actual
|
||||
creative work." Compliance metadata is not creative work.
|
||||
|
||||
Both paths thread through a new runtime hook
|
||||
(`_dispatch_runtime._maybe_read_short_circuit`) that consumes
|
||||
a `_short_circuit_result` stash on the item's context and
|
||||
synthesizes a SessionResult instead of spawning the LLM
|
||||
session. Cycle archive's `post_session_result` gains
|
||||
`auto_fix_report` + `short_circuit` fields for audit trail.
|
||||
`EscalationAction.SUCCESS` accepts `outcome=no_changes_needed`
|
||||
without requiring head_sha_advanced=True.
|
||||
|
||||
- **New env flag:** `IMPLEMENTER_DISPATCHER_AUTO_FIX_COMPLIANCE`
|
||||
(default OFF). Setting to `1` grants the dispatcher commit
|
||||
+ push authority for the three deterministic compliance
|
||||
classes. Off-by-default because it's a real responsibility
|
||||
expansion. Governance precedent: `.opencode/agents/auto-agents.md`
|
||||
explicitly endorses moving non-creative work off the LLM.
|
||||
|
||||
Test suite: 1464 passed, 3 skipped (+23 from previous commit's
|
||||
1441). New tests cover all branches of the new module,
|
||||
short-circuit hook, predicate change, telemetry field,
|
||||
preflight divergence detection, and the runtime
|
||||
consume-stash helper.
|
||||
|
||||
- **Deterministic worker-side improvements (2026-05-13).** Four
|
||||
changes that move judgment off the LLM and into the dispatcher,
|
||||
motivated by the live PR #30 escalation pilot (2026-05-12) where
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
"""Unit tests for ``tools/_implementer_compliance_apply.py``.
|
||||
|
||||
Each apply function operates on a real worktree (via ``tmp_path`` +
|
||||
the conftest ``minimal_git_repo`` fixture). The tests pin the
|
||||
contract Drew agreed to in the 2026-05-13 plan:
|
||||
|
||||
- Idempotent: applying twice doesn't double-write or fail.
|
||||
- Audit-trail: each ApplyResult lists the operations it performed.
|
||||
- Failure-safe: any subprocess error is recorded in ``result.error``
|
||||
and the caller falls through (no partial commit).
|
||||
|
||||
The module's design splits "stage edits" from "commit" from "amend
|
||||
footer" so the dispatcher's short-circuit logic can compose them
|
||||
flexibly. Most tests exercise individual gap appliers directly; a
|
||||
handful exercise the top-level ``apply_compliance_fixes``
|
||||
orchestrator end-to-end.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from .conftest import load_tool_module
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def applier():
|
||||
return load_tool_module("_implementer_compliance_apply")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def repo_with_issues_footer(minimal_git_repo):
|
||||
"""A clean repo whose HEAD commit already has the ISSUES CLOSED
|
||||
footer — exercises the idempotent path of the amender."""
|
||||
import subprocess
|
||||
subprocess.run(
|
||||
["git", "-C", str(minimal_git_repo),
|
||||
"commit", "--amend", "-m",
|
||||
"fix: thing\n\nISSUES CLOSED: #42"],
|
||||
check=True, capture_output=True,
|
||||
)
|
||||
return minimal_git_repo
|
||||
|
||||
|
||||
class TestApplyContributors:
|
||||
def test_creates_file_when_absent(self, applier, minimal_git_repo):
|
||||
contrib = minimal_git_repo / "CONTRIBUTORS.md"
|
||||
assert not contrib.exists()
|
||||
result = applier._apply_contributors(
|
||||
minimal_git_repo, "HAL 9000", "hal9000@cleverthis.com",
|
||||
)
|
||||
assert result.applied is True
|
||||
assert result.error is None
|
||||
assert contrib.exists()
|
||||
text = contrib.read_text(encoding="utf-8")
|
||||
assert "HAL 9000 <hal9000@cleverthis.com>" in text
|
||||
|
||||
def test_appends_when_email_absent(self, applier, minimal_git_repo):
|
||||
contrib = minimal_git_repo / "CONTRIBUTORS.md"
|
||||
contrib.write_text(
|
||||
"# Contributors\n\n- Existing Person <other@example.com>\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
result = applier._apply_contributors(
|
||||
minimal_git_repo, "HAL 9000", "hal9000@cleverthis.com",
|
||||
)
|
||||
assert result.applied is True
|
||||
text = contrib.read_text(encoding="utf-8")
|
||||
# Both the existing and the new entry should be present
|
||||
assert "other@example.com" in text
|
||||
assert "hal9000@cleverthis.com" in text
|
||||
|
||||
def test_idempotent_when_email_already_present(self, applier, minimal_git_repo):
|
||||
contrib = minimal_git_repo / "CONTRIBUTORS.md"
|
||||
original = "# Contributors\n\n- HAL 9000 <hal9000@cleverthis.com>\n"
|
||||
contrib.write_text(original, encoding="utf-8")
|
||||
result = applier._apply_contributors(
|
||||
minimal_git_repo, "HAL 9000", "hal9000@cleverthis.com",
|
||||
)
|
||||
assert result.applied is True
|
||||
# File should be unchanged
|
||||
assert contrib.read_text(encoding="utf-8") == original
|
||||
|
||||
def test_case_insensitive_email_match(self, applier, minimal_git_repo):
|
||||
contrib = minimal_git_repo / "CONTRIBUTORS.md"
|
||||
contrib.write_text(
|
||||
"- HAL 9000 <Hal9000@CleverThis.com>\n", encoding="utf-8",
|
||||
)
|
||||
result = applier._apply_contributors(
|
||||
minimal_git_repo, "HAL 9000", "hal9000@cleverthis.com",
|
||||
)
|
||||
assert result.applied is True
|
||||
# Should NOT duplicate — case-insensitive idempotent
|
||||
text = contrib.read_text(encoding="utf-8")
|
||||
assert text.count("hal9000@cleverthis.com") <= 1
|
||||
assert text.count("Hal9000@CleverThis.com") <= 1
|
||||
|
||||
|
||||
class TestApplyChangelog:
|
||||
def test_creates_file_when_absent(self, applier, minimal_git_repo):
|
||||
result = applier._apply_changelog(
|
||||
minimal_git_repo, "fix(tui): extract @token correctly", 42,
|
||||
)
|
||||
assert result.applied is True
|
||||
text = (minimal_git_repo / "CHANGELOG.md").read_text(encoding="utf-8")
|
||||
assert "## [Unreleased]" in text
|
||||
assert "fix(tui): extract @token correctly (#42)" in text
|
||||
|
||||
def test_inserts_bullet_under_unreleased(self, applier, minimal_git_repo):
|
||||
changelog = minimal_git_repo / "CHANGELOG.md"
|
||||
changelog.write_text(
|
||||
"# Changelog\n\n## [Unreleased]\n\n## [1.0.0]\n- old entry\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
result = applier._apply_changelog(
|
||||
minimal_git_repo, "fix(plan): handle empty state", 99,
|
||||
)
|
||||
assert result.applied is True
|
||||
text = changelog.read_text(encoding="utf-8")
|
||||
# The new bullet should land BETWEEN [Unreleased] and [1.0.0]
|
||||
unreleased_idx = text.find("[Unreleased]")
|
||||
v1_idx = text.find("[1.0.0]")
|
||||
bullet_idx = text.find("fix(plan): handle empty state (#99)")
|
||||
assert unreleased_idx < bullet_idx < v1_idx
|
||||
|
||||
def test_idempotent_when_pr_number_already_present(self, applier, minimal_git_repo):
|
||||
changelog = minimal_git_repo / "CHANGELOG.md"
|
||||
original = (
|
||||
"# Changelog\n\n## [Unreleased]\n"
|
||||
"- fix(tui): extract @token correctly (#42)\n"
|
||||
)
|
||||
changelog.write_text(original, encoding="utf-8")
|
||||
result = applier._apply_changelog(
|
||||
minimal_git_repo, "fix(tui): something else entirely", 42,
|
||||
)
|
||||
assert result.applied is True
|
||||
# File unchanged
|
||||
assert changelog.read_text(encoding="utf-8") == original
|
||||
|
||||
|
||||
class TestAmendCommitFooter:
|
||||
def test_adds_footer_when_missing(self, applier, minimal_git_repo):
|
||||
result = applier._amend_commit_footer(
|
||||
minimal_git_repo, "HAL 9000", "hal9000@cleverthis.com", [42, 43],
|
||||
)
|
||||
assert result.applied is True
|
||||
assert result.error is None
|
||||
# Re-read HEAD message
|
||||
proc = subprocess.run(
|
||||
["git", "-C", str(minimal_git_repo),
|
||||
"log", "-1", "--pretty=%B", "HEAD"],
|
||||
capture_output=True, text=True, check=True,
|
||||
)
|
||||
assert "ISSUES CLOSED: #42 #43" in proc.stdout
|
||||
|
||||
def test_idempotent_when_footer_present(self, applier, repo_with_issues_footer):
|
||||
result = applier._amend_commit_footer(
|
||||
repo_with_issues_footer, "HAL 9000", "hal9000@cleverthis.com", [42],
|
||||
)
|
||||
assert result.applied is True
|
||||
# The "operations" record should signal idempotent path
|
||||
op_outputs = " ".join(out for _, out in result.operations)
|
||||
assert "already present" in op_outputs.lower()
|
||||
|
||||
def test_returns_error_when_linked_issues_empty(self, applier, minimal_git_repo):
|
||||
result = applier._amend_commit_footer(
|
||||
minimal_git_repo, "HAL 9000", "hal9000@cleverthis.com", [],
|
||||
)
|
||||
assert result.applied is False
|
||||
assert result.error is not None
|
||||
assert "linked_issue_numbers" in result.error
|
||||
|
||||
|
||||
class TestApplyComplianceFixesOrchestrator:
|
||||
def test_all_gaps_closed_no_op(self, applier, minimal_git_repo):
|
||||
gaps = {
|
||||
"worktree_clean": True,
|
||||
"changelog_unreleased_nonempty": True,
|
||||
"contributors_has_author": True,
|
||||
"commit_has_issues_closed": True,
|
||||
}
|
||||
report = applier.apply_compliance_fixes(
|
||||
minimal_git_repo, gaps,
|
||||
git_user_name="HAL 9000", git_user_email="hal9000@cleverthis.com",
|
||||
pr_title="fix: thing", pr_number=42,
|
||||
linked_issue_numbers=[42],
|
||||
)
|
||||
# Nothing False → nothing to fix → no commits
|
||||
assert report.all_applied is True
|
||||
assert report.any_committed is False
|
||||
assert report.per_gap == []
|
||||
|
||||
def test_three_gaps_closed_one_commit(self, applier, minimal_git_repo):
|
||||
# contributors + changelog gaps → both filled and committed
|
||||
# together; commit_has_issues_closed handled by the same
|
||||
# commit's message (we embed the footer into the staged
|
||||
# commit message rather than amending).
|
||||
gaps = {
|
||||
"worktree_clean": True,
|
||||
"changelog_unreleased_nonempty": False,
|
||||
"contributors_has_author": False,
|
||||
"commit_has_issues_closed": False,
|
||||
}
|
||||
report = applier.apply_compliance_fixes(
|
||||
minimal_git_repo, gaps,
|
||||
git_user_name="HAL 9000", git_user_email="hal9000@cleverthis.com",
|
||||
pr_title="fix(tui): @token extraction", pr_number=42,
|
||||
linked_issue_numbers=[42],
|
||||
)
|
||||
assert report.all_applied is True
|
||||
assert report.any_committed is True
|
||||
# New HEAD message should have ISSUES CLOSED footer
|
||||
proc = subprocess.run(
|
||||
["git", "-C", str(minimal_git_repo),
|
||||
"log", "-1", "--pretty=%B", "HEAD"],
|
||||
capture_output=True, text=True, check=True,
|
||||
)
|
||||
assert "ISSUES CLOSED: #42" in proc.stdout
|
||||
# Both files should exist
|
||||
assert (minimal_git_repo / "CHANGELOG.md").exists()
|
||||
assert (minimal_git_repo / "CONTRIBUTORS.md").exists()
|
||||
|
||||
def test_only_footer_gap_amends_existing_commit(self, applier, minimal_git_repo):
|
||||
# File-level gaps are already satisfied; only the commit
|
||||
# footer needs to be amended onto the existing HEAD.
|
||||
(minimal_git_repo / "CONTRIBUTORS.md").write_text(
|
||||
"- HAL 9000 <hal9000@cleverthis.com>\n",
|
||||
)
|
||||
(minimal_git_repo / "CHANGELOG.md").write_text(
|
||||
"## [Unreleased]\n- existing entry\n",
|
||||
)
|
||||
original_head = applier._rev_parse_head(minimal_git_repo)
|
||||
gaps = {
|
||||
"worktree_clean": True,
|
||||
"changelog_unreleased_nonempty": True,
|
||||
"contributors_has_author": True,
|
||||
"commit_has_issues_closed": False,
|
||||
}
|
||||
report = applier.apply_compliance_fixes(
|
||||
minimal_git_repo, gaps,
|
||||
git_user_name="HAL 9000", git_user_email="hal9000@cleverthis.com",
|
||||
pr_title="fix: thing", pr_number=42,
|
||||
linked_issue_numbers=[42],
|
||||
)
|
||||
assert report.all_applied is True
|
||||
assert report.any_committed is True
|
||||
new_head = applier._rev_parse_head(minimal_git_repo)
|
||||
# Amend creates a new commit (different SHA) but the work
|
||||
# graph stays at one commit on top of where we started.
|
||||
assert new_head != original_head
|
||||
proc = subprocess.run(
|
||||
["git", "-C", str(minimal_git_repo),
|
||||
"log", "-1", "--pretty=%B", "HEAD"],
|
||||
capture_output=True, text=True, check=True,
|
||||
)
|
||||
assert "ISSUES CLOSED: #42" in proc.stdout
|
||||
|
||||
def test_footer_gap_without_linked_issues_returns_error(
|
||||
self, applier, minimal_git_repo,
|
||||
):
|
||||
# The footer fix needs at least one linked issue. Without
|
||||
# it, the orchestrator records the gap as un-fixable and
|
||||
# ``all_applied`` is False so the caller falls through
|
||||
# to the LLM worker.
|
||||
gaps = {
|
||||
"worktree_clean": True,
|
||||
"changelog_unreleased_nonempty": True,
|
||||
"contributors_has_author": True,
|
||||
"commit_has_issues_closed": False,
|
||||
}
|
||||
report = applier.apply_compliance_fixes(
|
||||
minimal_git_repo, gaps,
|
||||
git_user_name="HAL 9000", git_user_email="hal9000@cleverthis.com",
|
||||
pr_title="fix: thing", pr_number=42,
|
||||
linked_issue_numbers=[],
|
||||
)
|
||||
assert report.all_applied is False
|
||||
# The single per-gap entry should carry the error
|
||||
assert any(
|
||||
r.gap_name == "commit_has_issues_closed" and r.error
|
||||
for r in report.per_gap
|
||||
)
|
||||
|
||||
def test_idempotent_full_run(self, applier, minimal_git_repo):
|
||||
# Run the orchestrator twice with the same gaps. The second
|
||||
# run should detect that the prior fix is already there and
|
||||
# produce no new commits.
|
||||
gaps = {
|
||||
"worktree_clean": True,
|
||||
"changelog_unreleased_nonempty": False,
|
||||
"contributors_has_author": False,
|
||||
"commit_has_issues_closed": False,
|
||||
}
|
||||
first = applier.apply_compliance_fixes(
|
||||
minimal_git_repo, gaps,
|
||||
git_user_name="HAL 9000", git_user_email="hal9000@cleverthis.com",
|
||||
pr_title="fix: thing", pr_number=42,
|
||||
linked_issue_numbers=[42],
|
||||
)
|
||||
assert first.any_committed is True
|
||||
|
||||
# Update gaps to reflect post-fix state (compliance scan
|
||||
# would now show all True). Re-run with no-op gaps.
|
||||
second_gaps = {
|
||||
"worktree_clean": True,
|
||||
"changelog_unreleased_nonempty": True,
|
||||
"contributors_has_author": True,
|
||||
"commit_has_issues_closed": True,
|
||||
}
|
||||
second = applier.apply_compliance_fixes(
|
||||
minimal_git_repo, second_gaps,
|
||||
git_user_name="HAL 9000", git_user_email="hal9000@cleverthis.com",
|
||||
pr_title="fix: thing", pr_number=42,
|
||||
linked_issue_numbers=[42],
|
||||
)
|
||||
assert second.any_committed is False
|
||||
assert second.per_gap == []
|
||||
@@ -104,10 +104,13 @@ class TestHeadShaTriState:
|
||||
max_tier=1,
|
||||
) == esc.EscalationAction.RETRY_POST_FETCH
|
||||
|
||||
def test_resolved_plus_false_is_not_success(self, esc):
|
||||
# Worker claims resolved but verified did NOT push. Falls
|
||||
# through to competence classification — escalate on first
|
||||
# attempt (transport budget 0 for competence).
|
||||
def test_resolved_plus_false_escalates_immediately(self, esc):
|
||||
# Worker claims resolved but verified did NOT push. This is
|
||||
# competence-class — same model on same input will hallucinate
|
||||
# success again. Predicate must skip the same-tier retry
|
||||
# budget entirely and escalate. (A2 fix, 2026-05-13: prior
|
||||
# behaviour was UNKNOWN-bucket retry_same_tier; observed live
|
||||
# 3x and wasted ~25 min of cycle time per occurrence.)
|
||||
action = esc.decide(
|
||||
parsed_json={"outcome": "resolved"},
|
||||
terminal_state="completed",
|
||||
@@ -115,9 +118,58 @@ class TestHeadShaTriState:
|
||||
pr_state="open",
|
||||
transport_retries_used=0,
|
||||
current_tier=0,
|
||||
max_tier=1,
|
||||
max_tier=2,
|
||||
)
|
||||
assert action != esc.EscalationAction.SUCCESS
|
||||
assert action == esc.EscalationAction.ESCALATE, (
|
||||
f"resolved + no-push must escalate, got {action}"
|
||||
)
|
||||
|
||||
def test_resolved_plus_false_at_max_tier_is_exhausted(self, esc):
|
||||
# Same hallucination but already at the ceiling — there's
|
||||
# no higher tier to escalate to.
|
||||
action = esc.decide(
|
||||
parsed_json={"outcome": "resolved"},
|
||||
terminal_state="completed",
|
||||
head_sha_advanced=False,
|
||||
pr_state="open",
|
||||
transport_retries_used=0,
|
||||
current_tier=2,
|
||||
max_tier=2,
|
||||
)
|
||||
assert action == esc.EscalationAction.EXHAUSTED
|
||||
|
||||
def test_resolved_plus_false_bypasses_transport_budget(self, esc):
|
||||
# Critical: even with transport_retries_used=0 (full retry
|
||||
# budget available), resolved+no-push must NOT consume that
|
||||
# budget. It's a competence failure, not a flake.
|
||||
action = esc.decide(
|
||||
parsed_json={"outcome": "resolved"},
|
||||
terminal_state="completed",
|
||||
head_sha_advanced=False,
|
||||
pr_state="open",
|
||||
transport_retries_used=0,
|
||||
current_tier=1,
|
||||
max_tier=2,
|
||||
)
|
||||
assert action == esc.EscalationAction.ESCALATE
|
||||
assert action != esc.EscalationAction.RETRY_SAME_TIER
|
||||
|
||||
def test_no_changes_needed_is_success_without_push(self, esc):
|
||||
# Commit 2 / P0 (2026-05-13): the dispatcher's short-circuit
|
||||
# path emits ``outcome=no_changes_needed`` when it determined
|
||||
# before the LLM that no work was required. By definition
|
||||
# head_sha didn't advance — there was nothing to push. The
|
||||
# predicate must treat this as SUCCESS rather than competence.
|
||||
action = esc.decide(
|
||||
parsed_json={"outcome": "no_changes_needed"},
|
||||
terminal_state="completed",
|
||||
head_sha_advanced=False,
|
||||
pr_state="open",
|
||||
transport_retries_used=0,
|
||||
current_tier=0,
|
||||
max_tier=2,
|
||||
)
|
||||
assert action == esc.EscalationAction.SUCCESS
|
||||
|
||||
def test_none_outside_success_path_does_not_trigger_post_fetch(self, esc):
|
||||
# Post-fetch retry is reserved for the success-outcome path
|
||||
|
||||
@@ -1281,6 +1281,82 @@ class TestDeterministicStanzas:
|
||||
# suppressed — that would be just as misleading.
|
||||
assert "No persistent failures across two pre-flight runs" not in out
|
||||
|
||||
def test_preflight_diverges_from_remote_ci_flag(
|
||||
self, driver, cfg, monkeypatch, minimal_git_repo,
|
||||
):
|
||||
# P3 (2026-05-13): when local preflight reports all gates
|
||||
# PASS but the dispatcher's prefetched remote ci_status says
|
||||
# "failure", the section payload MUST include
|
||||
# ``diverges_from_remote_ci=True``. Without this signal the
|
||||
# worker reads "preflight clean" and trusts it, missing the
|
||||
# failing remote-only gate (e.g. e2e_tests, which --fast
|
||||
# skips). Live failure mode from PR #30 attempt 1 / PR #28
|
||||
# cycle 2.
|
||||
tmp_path = minimal_git_repo
|
||||
monkeypatch.setenv("IMPLEMENTER_ESCALATION_ENABLED", "1")
|
||||
monkeypatch.setenv("IMPLEMENTER_DISPATCHER_GATE_PREFLIGHT", "1")
|
||||
# Stub run_preflight: return all-clean classification
|
||||
monkeypatch.setattr(
|
||||
driver._implementer_gate_preflight,
|
||||
"run_preflight",
|
||||
lambda *a, **k: {
|
||||
"preflight_enabled": True,
|
||||
"gate_statuses": {"unit_tests": "PASS", "lint": "PASS"},
|
||||
"failures_total": 0,
|
||||
"failures_related_to_diff": 0,
|
||||
"failures_unrelated_to_diff": 0,
|
||||
"related": [],
|
||||
"unrelated": [],
|
||||
"runs": [{"run": 1, "returncode": 0, "failures": 0}],
|
||||
},
|
||||
)
|
||||
item = _pr_item()
|
||||
# Remote CI = failure
|
||||
result = type(
|
||||
"FakeResult", (), {"diff": "", "ci_status": {"state": "failure"}},
|
||||
)()
|
||||
handle = type("FakeHandle", (), {"path": str(tmp_path)})()
|
||||
det = driver._compute_deterministic_sections(
|
||||
cfg, item, _pr_group(driver), result, handle,
|
||||
)
|
||||
assert "gate_preflight" in det
|
||||
assert det["gate_preflight"]["remote_ci_state"] == "failure"
|
||||
assert det["gate_preflight"]["diverges_from_remote_ci"] is True
|
||||
|
||||
def test_preflight_no_divergence_when_ci_passes(
|
||||
self, driver, cfg, monkeypatch, minimal_git_repo,
|
||||
):
|
||||
# Mirror positive: preflight clean + remote CI success →
|
||||
# no divergence flag (or explicit False). The worker can
|
||||
# trust both signals.
|
||||
tmp_path = minimal_git_repo
|
||||
monkeypatch.setenv("IMPLEMENTER_ESCALATION_ENABLED", "1")
|
||||
monkeypatch.setenv("IMPLEMENTER_DISPATCHER_GATE_PREFLIGHT", "1")
|
||||
monkeypatch.setattr(
|
||||
driver._implementer_gate_preflight,
|
||||
"run_preflight",
|
||||
lambda *a, **k: {
|
||||
"preflight_enabled": True,
|
||||
"gate_statuses": {"unit_tests": "PASS"},
|
||||
"failures_total": 0,
|
||||
"failures_related_to_diff": 0,
|
||||
"failures_unrelated_to_diff": 0,
|
||||
"related": [],
|
||||
"unrelated": [],
|
||||
"runs": [{"run": 1, "returncode": 0, "failures": 0}],
|
||||
},
|
||||
)
|
||||
item = _pr_item()
|
||||
result = type(
|
||||
"FakeResult", (), {"diff": "", "ci_status": {"state": "success"}},
|
||||
)()
|
||||
handle = type("FakeHandle", (), {"path": str(tmp_path)})()
|
||||
det = driver._compute_deterministic_sections(
|
||||
cfg, item, _pr_group(driver), result, handle,
|
||||
)
|
||||
assert det["gate_preflight"]["remote_ci_state"] == "success"
|
||||
assert det["gate_preflight"]["diverges_from_remote_ci"] is False
|
||||
|
||||
def test_compliance_gaps_kill_switch_disables_compliance_section(
|
||||
self, driver, cfg, monkeypatch, minimal_git_repo,
|
||||
):
|
||||
@@ -1376,6 +1452,287 @@ def _pr_group(driver):
|
||||
return next(g for g in driver.WORK_GROUPS if g.name == "failing_ci_pr")
|
||||
|
||||
|
||||
class TestShortCircuit:
|
||||
"""Commit 2 (2026-05-13): dispatcher-side P0 (skip-when-green)
|
||||
and A (auto-fix-compliance) eliminate the LLM call when the
|
||||
deterministic state can satisfy the cycle alone. Each test
|
||||
builds a context dict + verifies the short-circuit stash AND
|
||||
(where relevant) that the runtime helper consumes it correctly.
|
||||
|
||||
The short-circuit machinery lives in two parts:
|
||||
- ``dispatch_implementer._maybe_short_circuit`` decides + stashes
|
||||
- ``_dispatch_runtime._maybe_read_short_circuit`` consumes
|
||||
|
||||
These tests drive both halves so a refactor to either side
|
||||
breaks at least one test.
|
||||
"""
|
||||
|
||||
def test_p0_skip_when_all_green(
|
||||
self, driver, cfg, monkeypatch, minimal_git_repo,
|
||||
):
|
||||
# All four signals say "done": compliance clean, preflight
|
||||
# clean, no masked checks, remote CI success. Dispatcher must
|
||||
# stash a no_changes_needed result and skip the worker.
|
||||
monkeypatch.setenv("IMPLEMENTER_ESCALATION_ENABLED", "1")
|
||||
item = _pr_item()
|
||||
context = {
|
||||
"result": type("FakeResult", (), {
|
||||
"ci_status": {"state": "success"},
|
||||
"linked_issues": [],
|
||||
"pr_details": {
|
||||
"title": "fix: thing",
|
||||
"head": {"ref": "tests/x"},
|
||||
},
|
||||
})(),
|
||||
"clone_handle": type(
|
||||
"FakeHandle", (), {"path": str(minimal_git_repo)},
|
||||
)(),
|
||||
"_deterministic_sections": {
|
||||
"compliance_gaps": {
|
||||
"gaps": {k: True for k in (
|
||||
"worktree_clean",
|
||||
"changelog_unreleased_nonempty",
|
||||
"contributors_has_author",
|
||||
"commit_has_issues_closed",
|
||||
)},
|
||||
"masked_checks": [],
|
||||
},
|
||||
"gate_preflight": {
|
||||
"preflight_enabled": True,
|
||||
"failures_total": 0,
|
||||
"preflight_timeout": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
item["_dispatcher_implementer_context"] = context
|
||||
driver._maybe_short_circuit(cfg, item, context)
|
||||
stash = context.get("_short_circuit_result")
|
||||
assert stash is not None
|
||||
assert stash["status"] == "completed"
|
||||
assert stash["parsed_json"]["outcome"] == "no_changes_needed"
|
||||
assert stash["parsed_json"]["_dispatcher_short_circuit"] == "P0"
|
||||
|
||||
def test_p0_does_not_fire_when_ci_failing(
|
||||
self, driver, cfg, monkeypatch, minimal_git_repo,
|
||||
):
|
||||
# Compliance clean + preflight clean BUT remote CI failure
|
||||
# → there's still real code work to do. Dispatch the worker.
|
||||
monkeypatch.setenv("IMPLEMENTER_ESCALATION_ENABLED", "1")
|
||||
item = _pr_item()
|
||||
context = {
|
||||
"result": type("FakeResult", (), {
|
||||
"ci_status": {"state": "failure"},
|
||||
"linked_issues": [],
|
||||
"pr_details": {
|
||||
"title": "fix: thing",
|
||||
"head": {"ref": "tests/x"},
|
||||
},
|
||||
})(),
|
||||
"clone_handle": type(
|
||||
"FakeHandle", (), {"path": str(minimal_git_repo)},
|
||||
)(),
|
||||
"_deterministic_sections": {
|
||||
"compliance_gaps": {
|
||||
"gaps": {k: True for k in (
|
||||
"worktree_clean",
|
||||
"changelog_unreleased_nonempty",
|
||||
"contributors_has_author",
|
||||
"commit_has_issues_closed",
|
||||
)},
|
||||
"masked_checks": [],
|
||||
},
|
||||
"gate_preflight": {
|
||||
"preflight_enabled": True,
|
||||
"failures_total": 0,
|
||||
"preflight_timeout": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
item["_dispatcher_implementer_context"] = context
|
||||
driver._maybe_short_circuit(cfg, item, context)
|
||||
# No stash → dispatcher will fall through to LLM worker
|
||||
assert context.get("_short_circuit_result") is None
|
||||
|
||||
def test_auto_fix_disabled_by_default(
|
||||
self, driver, cfg, monkeypatch, minimal_git_repo,
|
||||
):
|
||||
# Auto-fix flag OFF: compliance has gaps but dispatcher
|
||||
# does NOT auto-apply; falls through to worker.
|
||||
monkeypatch.setenv("IMPLEMENTER_ESCALATION_ENABLED", "1")
|
||||
monkeypatch.delenv(
|
||||
"IMPLEMENTER_DISPATCHER_AUTO_FIX_COMPLIANCE", raising=False,
|
||||
)
|
||||
item = _pr_item()
|
||||
context = {
|
||||
"result": type("FakeResult", (), {
|
||||
"ci_status": {"state": "failure"},
|
||||
"linked_issues": [{"number": 42}],
|
||||
"pr_details": {
|
||||
"title": "fix: thing",
|
||||
"head": {"ref": "tests/x"},
|
||||
},
|
||||
})(),
|
||||
"clone_handle": type(
|
||||
"FakeHandle", (), {"path": str(minimal_git_repo)},
|
||||
)(),
|
||||
"_deterministic_sections": {
|
||||
"compliance_gaps": {
|
||||
"gaps": {
|
||||
"worktree_clean": True,
|
||||
"changelog_unreleased_nonempty": False,
|
||||
"contributors_has_author": False,
|
||||
"commit_has_issues_closed": False,
|
||||
},
|
||||
"masked_checks": [],
|
||||
},
|
||||
"gate_preflight": {
|
||||
"preflight_enabled": True,
|
||||
"failures_total": 0,
|
||||
"preflight_timeout": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
item["_dispatcher_implementer_context"] = context
|
||||
driver._maybe_short_circuit(cfg, item, context)
|
||||
assert context.get("_short_circuit_result") is None
|
||||
|
||||
def test_auto_fix_applies_when_flag_on(
|
||||
self, driver, cfg, monkeypatch, minimal_git_repo,
|
||||
):
|
||||
# Flag ON + compliance gaps + preflight clean + CI failure
|
||||
# → dispatcher applies fixes and stashes a resolved result.
|
||||
#
|
||||
# We monkeypatch the apply module's push helper so the test
|
||||
# doesn't actually try to talk to Forgejo. The apply itself
|
||||
# writes real files + makes a real commit in tmp_path.
|
||||
monkeypatch.setenv("IMPLEMENTER_ESCALATION_ENABLED", "1")
|
||||
monkeypatch.setenv(
|
||||
"IMPLEMENTER_DISPATCHER_AUTO_FIX_COMPLIANCE", "1",
|
||||
)
|
||||
# Stub push to succeed without hitting Forgejo
|
||||
monkeypatch.setattr(
|
||||
driver._implementer_compliance_apply,
|
||||
"push_branch",
|
||||
lambda worktree, branch, **kw: (
|
||||
driver._implementer_compliance_apply.ApplyResult(
|
||||
gap_name="push_to_origin", applied=True,
|
||||
)
|
||||
),
|
||||
)
|
||||
item = _pr_item()
|
||||
context = {
|
||||
"result": type("FakeResult", (), {
|
||||
"ci_status": {"state": "failure"},
|
||||
"linked_issues": [{"number": 42}],
|
||||
"pr_details": {
|
||||
"title": "fix: thing",
|
||||
"head": {"ref": "tests/sentinel-bugfix"},
|
||||
},
|
||||
})(),
|
||||
"clone_handle": type(
|
||||
"FakeHandle", (), {"path": str(minimal_git_repo)},
|
||||
)(),
|
||||
"_deterministic_sections": {
|
||||
"compliance_gaps": {
|
||||
"gaps": {
|
||||
"worktree_clean": True,
|
||||
"changelog_unreleased_nonempty": False,
|
||||
"contributors_has_author": False,
|
||||
"commit_has_issues_closed": False,
|
||||
},
|
||||
"masked_checks": [],
|
||||
},
|
||||
"gate_preflight": {
|
||||
"preflight_enabled": True,
|
||||
"failures_total": 0,
|
||||
"preflight_timeout": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
item["_dispatcher_implementer_context"] = context
|
||||
driver._maybe_short_circuit(cfg, item, context)
|
||||
stash = context.get("_short_circuit_result")
|
||||
assert stash is not None
|
||||
assert stash["parsed_json"]["outcome"] == "resolved"
|
||||
assert (
|
||||
stash["parsed_json"]["_dispatcher_short_circuit"] == "A_auto_fix"
|
||||
)
|
||||
# Report stashed for cycle-archive consumers
|
||||
assert "_auto_fix_report" in context
|
||||
assert context["_auto_fix_report"]["any_committed"] is True
|
||||
|
||||
def test_auto_fix_falls_through_on_masked_checks(
|
||||
self, driver, cfg, monkeypatch, minimal_git_repo,
|
||||
):
|
||||
# Masked checks mean git itself failed on at least one
|
||||
# compliance scan — dispatcher must NOT auto-fix because
|
||||
# the signal is untrusted. Falls through to worker.
|
||||
monkeypatch.setenv("IMPLEMENTER_ESCALATION_ENABLED", "1")
|
||||
monkeypatch.setenv(
|
||||
"IMPLEMENTER_DISPATCHER_AUTO_FIX_COMPLIANCE", "1",
|
||||
)
|
||||
item = _pr_item()
|
||||
context = {
|
||||
"result": type("FakeResult", (), {
|
||||
"ci_status": {"state": "failure"},
|
||||
"linked_issues": [{"number": 42}],
|
||||
"pr_details": {
|
||||
"title": "fix: thing",
|
||||
"head": {"ref": "tests/x"},
|
||||
},
|
||||
})(),
|
||||
"clone_handle": type(
|
||||
"FakeHandle", (), {"path": str(minimal_git_repo)},
|
||||
)(),
|
||||
"_deterministic_sections": {
|
||||
"compliance_gaps": {
|
||||
"gaps": {
|
||||
"worktree_clean": True,
|
||||
"changelog_unreleased_nonempty": False,
|
||||
"contributors_has_author": False,
|
||||
"commit_has_issues_closed": True,
|
||||
},
|
||||
"masked_checks": ["worktree_clean"],
|
||||
},
|
||||
"gate_preflight": {
|
||||
"preflight_enabled": True,
|
||||
"failures_total": 0,
|
||||
"preflight_timeout": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
item["_dispatcher_implementer_context"] = context
|
||||
driver._maybe_short_circuit(cfg, item, context)
|
||||
assert context.get("_short_circuit_result") is None
|
||||
|
||||
def test_runtime_consumes_short_circuit_stash(self, driver):
|
||||
# The runtime helper pops the stash and marks it consumed.
|
||||
runtime = load_tool_module("_dispatch_runtime", fresh=True)
|
||||
item = {
|
||||
"_dispatcher_implementer_context": {
|
||||
"_short_circuit_result": {
|
||||
"status": "completed",
|
||||
"raw_response": "stub",
|
||||
"parsed_json": {"outcome": "no_changes_needed"},
|
||||
},
|
||||
},
|
||||
}
|
||||
session = runtime._maybe_read_short_circuit(item)
|
||||
assert session is not None
|
||||
assert session.status == "completed"
|
||||
assert session.parsed_json["outcome"] == "no_changes_needed"
|
||||
# Stash popped, consumed marker set
|
||||
ctx = item["_dispatcher_implementer_context"]
|
||||
assert "_short_circuit_result" not in ctx
|
||||
assert ctx.get("_short_circuit_consumed") is True
|
||||
|
||||
def test_runtime_returns_none_when_no_stash(self, driver):
|
||||
runtime = load_tool_module("_dispatch_runtime", fresh=True)
|
||||
item = {"_dispatcher_implementer_context": {}}
|
||||
session = runtime._maybe_read_short_circuit(item)
|
||||
assert session is None
|
||||
|
||||
|
||||
class TestOutcomeSynthesis:
|
||||
"""``_synthesize_outcome_if_missing`` post-processes worker
|
||||
output so a worker that gave up without emitting the contract
|
||||
|
||||
@@ -393,6 +393,95 @@ class TestEscalationExtras:
|
||||
assert row["outcome_synthesised"] is False
|
||||
|
||||
|
||||
class TestOutcomeDisputed:
|
||||
"""P4 (2026-05-13): worker emits ``outcome=resolved`` but the
|
||||
post-session head_sha didn't advance. The dispatcher's predicate
|
||||
(A2) routes this to ESCALATE; the telemetry row separately
|
||||
records ``outcome_disputed=True`` so analysts can grep for the
|
||||
worker-hallucination rate without re-deriving from outcome +
|
||||
head_sha_advanced."""
|
||||
|
||||
def test_resolved_with_no_push_sets_outcome_disputed(self, telemetry):
|
||||
row = telemetry.extract_phase4_telemetry(
|
||||
cycle_id="cycle-1",
|
||||
pr_number=30,
|
||||
work_group="failing_ci_pr",
|
||||
start_ts="t0",
|
||||
end_ts="t1",
|
||||
wall_clock_seconds=420.0,
|
||||
parsed_json={"outcome": "resolved", "files_touched": []},
|
||||
raw_response="",
|
||||
terminal_state="completed",
|
||||
pre_session_head_sha="deadbeefcafe",
|
||||
post_session_head_sha="deadbeefcafe", # same as pre → no advance
|
||||
)
|
||||
assert row["outcome"] == "resolved"
|
||||
assert row["head_sha_advanced"] is False
|
||||
assert row.get("outcome_disputed") is True
|
||||
|
||||
def test_resolved_with_push_omits_outcome_disputed(self, telemetry):
|
||||
# Worker emitted resolved AND pushed — normal success path,
|
||||
# no dispute flag.
|
||||
row = telemetry.extract_phase4_telemetry(
|
||||
cycle_id="cycle-1",
|
||||
pr_number=30,
|
||||
work_group="failing_ci_pr",
|
||||
start_ts="t0",
|
||||
end_ts="t1",
|
||||
wall_clock_seconds=420.0,
|
||||
parsed_json={"outcome": "resolved"},
|
||||
raw_response="",
|
||||
terminal_state="completed",
|
||||
pre_session_head_sha="deadbeefcafe",
|
||||
post_session_head_sha="newpushedsha",
|
||||
)
|
||||
assert row["outcome"] == "resolved"
|
||||
assert row["head_sha_advanced"] is True
|
||||
assert "outcome_disputed" not in row
|
||||
|
||||
def test_non_resolved_outcome_omits_outcome_disputed(self, telemetry):
|
||||
# The dispute flag is specific to the "claimed success without
|
||||
# delivering" failure mode. A rebase-failed outcome with no
|
||||
# push is not a dispute — it's an honest failure.
|
||||
row = telemetry.extract_phase4_telemetry(
|
||||
cycle_id="cycle-1",
|
||||
pr_number=30,
|
||||
work_group="failing_ci_pr",
|
||||
start_ts="t0",
|
||||
end_ts="t1",
|
||||
wall_clock_seconds=420.0,
|
||||
parsed_json={"outcome": "rebase-failed"},
|
||||
raw_response="",
|
||||
terminal_state="completed",
|
||||
pre_session_head_sha="deadbeefcafe",
|
||||
post_session_head_sha="deadbeefcafe",
|
||||
)
|
||||
assert "outcome_disputed" not in row
|
||||
|
||||
def test_head_sha_advanced_unknown_omits_outcome_disputed(
|
||||
self, telemetry,
|
||||
):
|
||||
# When the dispatcher couldn't determine the post-session
|
||||
# head_sha (transient fetch failure), head_sha_advanced is
|
||||
# None — not False. The dispute flag must NOT fire because
|
||||
# we have no evidence either way.
|
||||
row = telemetry.extract_phase4_telemetry(
|
||||
cycle_id="cycle-1",
|
||||
pr_number=30,
|
||||
work_group="failing_ci_pr",
|
||||
start_ts="t0",
|
||||
end_ts="t1",
|
||||
wall_clock_seconds=420.0,
|
||||
parsed_json={"outcome": "resolved"},
|
||||
raw_response="",
|
||||
terminal_state="completed",
|
||||
pre_session_head_sha="deadbeefcafe",
|
||||
post_session_head_sha=None,
|
||||
)
|
||||
assert row["head_sha_advanced"] is None
|
||||
assert "outcome_disputed" not in row
|
||||
|
||||
|
||||
class TestSubagentMaxDepth:
|
||||
"""``subagent_max_depth`` (added in Tier-1 R2, 2026-05-12) is
|
||||
sourced from the dispatcher's SessionContext, which receives it
|
||||
|
||||
@@ -153,6 +153,63 @@ def test_prepare_pr_worktree_creates_handle_on_success(
|
||||
), f"expected a worktree add, got {invocations}"
|
||||
|
||||
|
||||
def test_prepare_pr_worktree_clears_mirror_push_semantics(
|
||||
clone_mod, cfg, monkeypatch, tmp_path
|
||||
):
|
||||
"""A1 (2026-05-13): ``git clone --mirror`` leaves
|
||||
``remote.origin.mirror=true``, which makes worktree pushes fail
|
||||
with ``fatal: --mirror can't be combined with refspecs``. The
|
||||
dispatcher must clear this on the bare mirror so all worktrees
|
||||
derived from it can push refspec-style.
|
||||
|
||||
Without this fix the worker had to discover and work around the
|
||||
config per cycle — observed live on 2026-05-13 (PR #30 attempt 3
|
||||
lost a real commit during the fragile recovery path).
|
||||
"""
|
||||
monkeypatch.delenv("REVIEW_DISPATCHER_DISABLE_PRECLONE", raising=False)
|
||||
monkeypatch.setenv("REVIEW_DISPATCHER_MIRROR_PATH", str(tmp_path / "mirror.git"))
|
||||
wt_base = tmp_path / "wt"
|
||||
monkeypatch.setenv("REVIEW_DISPATCHER_WORKTREE_BASE", str(wt_base))
|
||||
|
||||
invocations: list[list[str]] = []
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
invocations.append(list(cmd))
|
||||
if cmd[:3] == ["git", "clone", "--mirror"]:
|
||||
mirror = Path(cmd[-1])
|
||||
mirror.mkdir(parents=True, exist_ok=True)
|
||||
(mirror / "HEAD").write_text("ref: refs/heads/master\n")
|
||||
|
||||
class _Result:
|
||||
returncode = 0
|
||||
stdout = b""
|
||||
stderr = b""
|
||||
|
||||
return _Result()
|
||||
|
||||
monkeypatch.setattr(clone_mod.subprocess, "run", fake_run)
|
||||
clone_mod.prepare_pr_worktree(cfg, 30, "abc1234")
|
||||
|
||||
# The config-clearing command must have been invoked at least
|
||||
# once on the bare mirror. Look for the exact shape:
|
||||
# ``git --git-dir <mirror> config remote.origin.mirror false``
|
||||
matching = [
|
||||
c for c in invocations
|
||||
if (
|
||||
c[0] == "git"
|
||||
and "--git-dir" in c
|
||||
and "config" in c
|
||||
and "remote.origin.mirror" in c
|
||||
and "false" in c
|
||||
)
|
||||
]
|
||||
assert matching, (
|
||||
"expected a 'git --git-dir <mirror> config remote.origin.mirror "
|
||||
"false' invocation; got commands: "
|
||||
f"{[' '.join(str(p) for p in c) for c in invocations]}"
|
||||
)
|
||||
|
||||
|
||||
def test_worktree_handle_cleanup_invokes_remove_and_prune(
|
||||
clone_mod, cfg, monkeypatch, tmp_path
|
||||
):
|
||||
|
||||
@@ -909,15 +909,32 @@ def dispatch_one(
|
||||
# so a dry-run cycle (token may be a stub) is still safe.
|
||||
redact_values = [cfg.token] if getattr(cfg, "token", None) else []
|
||||
session_started_at = _now()
|
||||
session = _opencode_worker.run_session_blocking(
|
||||
server_url=cfg.server_url,
|
||||
agent=group.worker_agent,
|
||||
tag=tag,
|
||||
prompt=prompt,
|
||||
timeout_seconds=cfg.worker_timeout_seconds,
|
||||
on_poll=_refresh_heartbeat,
|
||||
redact_values=redact_values,
|
||||
)
|
||||
# Short-circuit hook: a prompt_factory MAY stash a
|
||||
# ``_short_circuit_result`` on the item's
|
||||
# ``_dispatcher_implementer_context`` dict to indicate the
|
||||
# dispatcher has already handled this PR deterministically
|
||||
# and no LLM session is needed. The value is a dict shaped
|
||||
# like a :class:`_opencode_worker.SessionResult` with at
|
||||
# minimum ``status``, ``parsed_json``, and ``raw_response``.
|
||||
# See dispatch_implementer's ``_maybe_short_circuit_with_auto_fix``
|
||||
# for the production callsite.
|
||||
short_circuit = _maybe_read_short_circuit(item)
|
||||
if short_circuit is not None:
|
||||
session = short_circuit
|
||||
logger.info(
|
||||
"%s item #%s short-circuited (no LLM session): %s",
|
||||
group.name, number, short_circuit.status,
|
||||
)
|
||||
else:
|
||||
session = _opencode_worker.run_session_blocking(
|
||||
server_url=cfg.server_url,
|
||||
agent=group.worker_agent,
|
||||
tag=tag,
|
||||
prompt=prompt,
|
||||
timeout_seconds=cfg.worker_timeout_seconds,
|
||||
on_poll=_refresh_heartbeat,
|
||||
redact_values=redact_values,
|
||||
)
|
||||
session_completed_at = _now()
|
||||
if session.status == "completed":
|
||||
terminal_state = "completed"
|
||||
@@ -1173,6 +1190,59 @@ def _now() -> str:
|
||||
return datetime.now(UTC).isoformat()
|
||||
|
||||
|
||||
def _maybe_read_short_circuit(
|
||||
item: dict[str, Any],
|
||||
) -> "_opencode_worker.SessionResult | None":
|
||||
"""Read the prompt-factory's short-circuit stash if present.
|
||||
|
||||
A prompt factory MAY decide that the dispatcher has already
|
||||
handled the work-item deterministically (e.g. by applying
|
||||
compliance fixes directly) and that no LLM session needs to run.
|
||||
It signals this by stashing a ``SessionResult``-shaped object
|
||||
under ``item["_dispatcher_implementer_context"]["_short_circuit_result"]``
|
||||
BEFORE returning from the prompt factory.
|
||||
|
||||
Returns the ``SessionResult`` if present and shaped correctly;
|
||||
``None`` otherwise (in which case the dispatcher spawns the
|
||||
worker as usual). The key is consumed (popped) so a subsequent
|
||||
cycle reading the same item dict does not re-trigger the
|
||||
short-circuit.
|
||||
|
||||
The short-circuit machinery preserves the rest of the post-
|
||||
session pipeline (telemetry, status comment, claim release)
|
||||
— only the LLM session is skipped. Telemetry rows will carry
|
||||
``terminal_state`` from the synthesised result and downstream
|
||||
consumers see the same row shape they expect.
|
||||
"""
|
||||
context = item.get("_dispatcher_implementer_context")
|
||||
if not isinstance(context, dict):
|
||||
return None
|
||||
stash = context.pop("_short_circuit_result", None)
|
||||
if stash is None:
|
||||
return None
|
||||
# Mark consumption so the post-session action's cycle-archive
|
||||
# field can report ``short_circuit=True`` without re-reading the
|
||||
# stash (which is gone after the .pop above).
|
||||
context["_short_circuit_consumed"] = True
|
||||
# Validate shape: must have at least ``status`` and be coercible
|
||||
# to a SessionResult. We accept either a SessionResult instance
|
||||
# directly OR a plain dict (so test code doesn't have to import
|
||||
# the dataclass).
|
||||
if isinstance(stash, _opencode_worker.SessionResult):
|
||||
return stash
|
||||
if isinstance(stash, dict) and "status" in stash:
|
||||
return _opencode_worker.SessionResult(
|
||||
status=stash["status"],
|
||||
wallclock_seconds=float(stash.get("wallclock_seconds", 0.0)),
|
||||
session_id=str(stash.get("session_id", "")),
|
||||
raw_response=str(stash.get("raw_response", "")),
|
||||
parsed_json=stash.get("parsed_json"),
|
||||
error_kind=stash.get("error_kind"),
|
||||
subagent_max_depth=stash.get("subagent_max_depth", 0),
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def ensure_cycle_table(table_name: str) -> None:
|
||||
"""Create or repair the per-driver cycle table.
|
||||
|
||||
|
||||
@@ -0,0 +1,534 @@
|
||||
"""Deterministic applier for PR-compliance-checklist gaps.
|
||||
|
||||
What this is
|
||||
------------
|
||||
|
||||
The implementer's compliance scan (:mod:`_implementer_compliance`)
|
||||
checks four items mechanically:
|
||||
|
||||
- ``worktree_clean`` — sanity check, not fixable
|
||||
- ``changelog_unreleased_nonempty`` — needs an entry under [Unreleased]
|
||||
- ``contributors_has_author`` — needs the author email in CONTRIBUTORS.md
|
||||
- ``commit_has_issues_closed`` — needs ``ISSUES CLOSED: #N`` in HEAD commit
|
||||
|
||||
Three of those (``contributors_has_author``, ``commit_has_issues_closed``,
|
||||
``changelog_unreleased_nonempty``) can be applied without LLM reasoning
|
||||
when the dispatcher has the source data:
|
||||
|
||||
- CONTRIBUTORS line: ``- {GIT_USER_NAME} <{GIT_USER_EMAIL}>``
|
||||
- ISSUES CLOSED footer: derived from the prefetched ``linked_issues``
|
||||
- CHANGELOG bullet: stubbed from the PR title (LLM-quality would be
|
||||
marginally better here, but a PR-title stub is fine for most cases)
|
||||
|
||||
This module exposes :func:`apply_compliance_fixes` which takes a
|
||||
worktree path + the prefetched data + the gap dict and applies the
|
||||
fixes. It does NOT push — pushing is the caller's responsibility
|
||||
(``dispatch_implementer._auto_fix_and_push``) because push failure
|
||||
recovery needs cycle-level context.
|
||||
|
||||
The fixes are deterministic Python (no LLM). Every operation is
|
||||
idempotent: calling :func:`apply_compliance_fixes` twice on a
|
||||
worktree that's already had a fix applied is a no-op for that gap.
|
||||
|
||||
Why this exists
|
||||
---------------
|
||||
|
||||
Live test 2026-05-13 (PR #28 cycle 1) showed the worker uses ~12-14
|
||||
min of LLM time + 4096 server time on what's effectively five bash
|
||||
commands. The same operations run deterministically in <5 seconds
|
||||
with no LLM cost. ``auto-agents.md`` explicitly endorses this
|
||||
pattern: "deterministic Python owns orchestration; the LLM only
|
||||
handles the actual creative work."
|
||||
|
||||
The classes covered here are *not* creative work. Writing a real
|
||||
code fix for a failing test IS creative work and stays on the LLM.
|
||||
|
||||
Failure handling
|
||||
----------------
|
||||
|
||||
Each fix function returns ``(applied: bool, error: str | None)``.
|
||||
:func:`apply_compliance_fixes` collects the results and propagates
|
||||
them to the caller. On any error, the caller (the dispatcher's
|
||||
auto-fix path) should fall through to spawning the LLM worker
|
||||
rather than half-fixing the PR.
|
||||
|
||||
Audit trail
|
||||
-----------
|
||||
|
||||
Each applied fix produces a structured entry in the returned
|
||||
``ApplyReport`` (gap name, command sequence, before/after marker).
|
||||
The dispatcher writes this into the cycle archive so an operator
|
||||
inspecting why a commit appeared can trace it back to the
|
||||
deterministic applier rather than wondering which LLM session
|
||||
produced it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import subprocess
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
_logger = logging.getLogger("implementer_compliance_apply")
|
||||
|
||||
|
||||
@dataclass
|
||||
class ApplyResult:
|
||||
"""Per-gap outcome of an apply attempt.
|
||||
|
||||
``applied``: True when the fix wrote something (or was already
|
||||
satisfied — idempotent). False when an error prevented the
|
||||
apply.
|
||||
|
||||
``error``: Human-readable error string. None on success.
|
||||
|
||||
``operations``: Ordered list of (command, output) pairs the
|
||||
apply ran. Used by the cycle archive for audit-trail.
|
||||
"""
|
||||
gap_name: str
|
||||
applied: bool
|
||||
error: str | None = None
|
||||
operations: list[tuple[str, str]] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ApplyReport:
|
||||
"""Aggregate outcome of all attempted gap fixes.
|
||||
|
||||
``per_gap``: ApplyResult for each gap the caller asked to fix.
|
||||
``all_applied``: True iff every requested gap fix succeeded.
|
||||
``any_committed``: True if the apply created a new git commit
|
||||
(the caller needs to know whether to push). A pure CONTRIBUTORS
|
||||
update needs a commit. An ISSUES CLOSED amend re-uses HEAD's
|
||||
commit (no new SHA). The combined fix is a single new commit
|
||||
on top of HEAD.
|
||||
"""
|
||||
per_gap: list[ApplyResult] = field(default_factory=list)
|
||||
all_applied: bool = True
|
||||
any_committed: bool = False
|
||||
final_head_sha: str = ""
|
||||
|
||||
|
||||
def apply_compliance_fixes(
|
||||
worktree: Path,
|
||||
gaps: dict[str, bool],
|
||||
*,
|
||||
git_user_name: str,
|
||||
git_user_email: str,
|
||||
pr_title: str,
|
||||
pr_number: int,
|
||||
linked_issue_numbers: list[int],
|
||||
) -> ApplyReport:
|
||||
"""Apply deterministic fixes for the False entries in ``gaps``.
|
||||
|
||||
``gaps`` follows the contract from
|
||||
:func:`_implementer_compliance.check_compliance_gaps` — keys are
|
||||
check names, values are bool (True = passing, False = needs fix).
|
||||
|
||||
``linked_issue_numbers`` is the list of issue numbers the PR
|
||||
closes (sourced from the prefetched ``linked_issues``). When
|
||||
multiple issues are linked we include all of them in the
|
||||
``ISSUES CLOSED`` footer. When the list is empty AND the
|
||||
``commit_has_issues_closed`` gap is open, that fix is SKIPPED
|
||||
(no data to apply) and recorded as an error in the report so
|
||||
the caller falls through to the LLM worker.
|
||||
|
||||
The fix order is:
|
||||
1. Apply CONTRIBUTORS append (file edit, staged)
|
||||
2. Apply CHANGELOG stub (file edit, staged)
|
||||
3. Stage and commit those two edits together
|
||||
4. Amend the HEAD commit message to add ISSUES CLOSED footer
|
||||
(works whether step 3 created a new commit or not; amending
|
||||
the worker's previous commit is safe because we're in a
|
||||
per-PR worktree)
|
||||
|
||||
Ordering matters: amend MUST be last because it rewrites HEAD.
|
||||
"""
|
||||
report = ApplyReport()
|
||||
# Stage edits first; commit happens once for the file edits;
|
||||
# amend at the end.
|
||||
staged_changes = False
|
||||
|
||||
if not gaps.get("contributors_has_author", True):
|
||||
result = _apply_contributors(worktree, git_user_name, git_user_email)
|
||||
report.per_gap.append(result)
|
||||
if result.applied and not result.error:
|
||||
staged_changes = True
|
||||
else:
|
||||
report.all_applied = False
|
||||
|
||||
if not gaps.get("changelog_unreleased_nonempty", True):
|
||||
result = _apply_changelog(worktree, pr_title, pr_number)
|
||||
report.per_gap.append(result)
|
||||
if result.applied and not result.error:
|
||||
staged_changes = True
|
||||
else:
|
||||
report.all_applied = False
|
||||
|
||||
# Commit staged file changes (if any) before potentially amending
|
||||
# the message in the next step. Doing it in two phases keeps the
|
||||
# commit-message amend cleanly separable from the content add.
|
||||
if staged_changes:
|
||||
commit_result = _commit_staged(worktree, git_user_name, git_user_email,
|
||||
pr_title, pr_number,
|
||||
linked_issue_numbers)
|
||||
report.per_gap.append(commit_result)
|
||||
if commit_result.applied and not commit_result.error:
|
||||
report.any_committed = True
|
||||
else:
|
||||
report.all_applied = False
|
||||
|
||||
# ISSUES CLOSED amend: applies whether or not we just committed.
|
||||
# If we did commit above, the message already includes the
|
||||
# footer (from `_commit_staged`); the amend is then a no-op
|
||||
# idempotent re-check. If no file changes were needed, this
|
||||
# amends the existing HEAD commit.
|
||||
if not gaps.get("commit_has_issues_closed", True):
|
||||
if not linked_issue_numbers:
|
||||
report.per_gap.append(ApplyResult(
|
||||
gap_name="commit_has_issues_closed",
|
||||
applied=False,
|
||||
error=("no linked_issue_numbers — cannot derive "
|
||||
"ISSUES CLOSED footer deterministically; "
|
||||
"falling through to LLM worker"),
|
||||
))
|
||||
report.all_applied = False
|
||||
elif not staged_changes:
|
||||
# Amend the EXISTING HEAD commit message rather than
|
||||
# creating a new commit. This matches what the worker
|
||||
# does today via the git-commit-util sub-agent.
|
||||
amend_result = _amend_commit_footer(
|
||||
worktree, git_user_name, git_user_email,
|
||||
linked_issue_numbers,
|
||||
)
|
||||
report.per_gap.append(amend_result)
|
||||
if amend_result.applied and not amend_result.error:
|
||||
report.any_committed = True
|
||||
else:
|
||||
report.all_applied = False
|
||||
# else: the new commit from _commit_staged already carries
|
||||
# the ISSUES CLOSED footer; nothing more to do.
|
||||
|
||||
report.final_head_sha = _rev_parse_head(worktree)
|
||||
return report
|
||||
|
||||
|
||||
# ─── Per-gap appliers ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def _apply_contributors(
|
||||
worktree: Path, git_user_name: str, git_user_email: str,
|
||||
) -> ApplyResult:
|
||||
"""Append ``- {name} <{email}>`` to CONTRIBUTORS.md if absent.
|
||||
|
||||
Creates the file with a minimal header when it doesn't exist.
|
||||
Idempotent — if the line is already present the function
|
||||
returns ``applied=True`` without writing.
|
||||
"""
|
||||
result = ApplyResult(gap_name="contributors_has_author", applied=False)
|
||||
contributors = worktree / "CONTRIBUTORS.md"
|
||||
line = f"- {git_user_name} <{git_user_email}>"
|
||||
try:
|
||||
if contributors.exists():
|
||||
text = contributors.read_text(encoding="utf-8")
|
||||
if git_user_email.lower() in text.lower():
|
||||
# already present — idempotent success
|
||||
result.applied = True
|
||||
result.operations.append(("read CONTRIBUTORS.md", "email already present"))
|
||||
return result
|
||||
if not text.endswith("\n"):
|
||||
text += "\n"
|
||||
text += line + "\n"
|
||||
else:
|
||||
text = (
|
||||
"# Contributors\n\n"
|
||||
"Thanks to everyone who has contributed to this project:\n\n"
|
||||
f"{line}\n"
|
||||
)
|
||||
contributors.write_text(text, encoding="utf-8")
|
||||
result.applied = True
|
||||
result.operations.append((f"write CONTRIBUTORS.md (+1 line)", line))
|
||||
except OSError as exc:
|
||||
result.error = f"CONTRIBUTORS.md write failed: {exc}"
|
||||
return result
|
||||
|
||||
|
||||
def _apply_changelog(
|
||||
worktree: Path, pr_title: str, pr_number: int,
|
||||
) -> ApplyResult:
|
||||
"""Insert a stub bullet under [Unreleased] in CHANGELOG.md.
|
||||
|
||||
The stub uses the PR title verbatim. If CHANGELOG.md exists but
|
||||
has no [Unreleased] section, one is inserted at the top below
|
||||
the first ``# Changelog`` header. If CHANGELOG.md does not
|
||||
exist, a minimal one is created.
|
||||
|
||||
The bullet appears as ``- {pr_title} (#{pr_number})`` to give a
|
||||
cross-reference back to the PR. LLM-quality CHANGELOG entries
|
||||
might say more, but the PR-title stub is the dominant pattern
|
||||
in this codebase's history and is correct for most cases.
|
||||
|
||||
Idempotent: if a bullet referencing ``#{pr_number}`` already
|
||||
exists under [Unreleased], no write happens.
|
||||
"""
|
||||
result = ApplyResult(gap_name="changelog_unreleased_nonempty", applied=False)
|
||||
changelog = worktree / "CHANGELOG.md"
|
||||
bullet = f"- {pr_title.strip()} (#{pr_number})"
|
||||
try:
|
||||
if changelog.exists():
|
||||
text = changelog.read_text(encoding="utf-8")
|
||||
# Idempotent guard
|
||||
if f"(#{pr_number})" in text:
|
||||
result.applied = True
|
||||
result.operations.append(("read CHANGELOG.md", "bullet for this PR already present"))
|
||||
return result
|
||||
else:
|
||||
text = "# Changelog\n\n## [Unreleased]\n\n"
|
||||
# Find the [Unreleased] header; insert bullet under it.
|
||||
unreleased_re = re.compile(
|
||||
r"^(##\s*\[Unreleased\][^\n]*\n)",
|
||||
re.MULTILINE | re.IGNORECASE,
|
||||
)
|
||||
m = unreleased_re.search(text)
|
||||
if m:
|
||||
insert_at = m.end()
|
||||
# Insert a blank line + bullet + blank line if the
|
||||
# header is immediately followed by content. Otherwise
|
||||
# just bullet + blank line.
|
||||
tail = text[insert_at:]
|
||||
if tail.startswith("\n"):
|
||||
# there's a blank after the header; just inject
|
||||
new_text = text[:insert_at] + bullet + "\n" + tail
|
||||
else:
|
||||
new_text = text[:insert_at] + "\n" + bullet + "\n" + tail
|
||||
else:
|
||||
# No [Unreleased] section — add one near the top.
|
||||
header_re = re.compile(r"^(#\s+[^\n]+\n)", re.MULTILINE)
|
||||
hm = header_re.search(text)
|
||||
new_section = f"\n## [Unreleased]\n\n{bullet}\n"
|
||||
if hm:
|
||||
new_text = text[:hm.end()] + new_section + text[hm.end():]
|
||||
else:
|
||||
# No top-level header either; prepend everything.
|
||||
new_text = f"# Changelog\n{new_section}\n{text}"
|
||||
changelog.write_text(new_text, encoding="utf-8")
|
||||
result.applied = True
|
||||
result.operations.append(("write CHANGELOG.md (+1 bullet)", bullet))
|
||||
except OSError as exc:
|
||||
result.error = f"CHANGELOG.md write failed: {exc}"
|
||||
return result
|
||||
|
||||
|
||||
# ─── Git operations ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _commit_staged(
|
||||
worktree: Path,
|
||||
git_user_name: str,
|
||||
git_user_email: str,
|
||||
pr_title: str,
|
||||
pr_number: int,
|
||||
linked_issue_numbers: list[int],
|
||||
) -> ApplyResult:
|
||||
"""Stage all changes in the worktree and commit them with a
|
||||
compliance-fix-shaped commit message.
|
||||
|
||||
The commit message embeds the ``ISSUES CLOSED:`` footer pre-
|
||||
populated from ``linked_issue_numbers`` so the subsequent amend
|
||||
step is a no-op when the new commit is the HEAD. If
|
||||
``linked_issue_numbers`` is empty, the message has no footer —
|
||||
the amend step will then detect the missing footer and either
|
||||
add it (if data is available) or leave the gap.
|
||||
"""
|
||||
result = ApplyResult(gap_name="commit_staged_compliance_fixes", applied=False)
|
||||
msg_lines = [
|
||||
f"chore(compliance): apply deterministic compliance fixes for #{pr_number}",
|
||||
"",
|
||||
f"Auto-applied by the implementer dispatcher's compliance scanner.",
|
||||
f"Source PR: #{pr_number} — {pr_title.strip()}",
|
||||
]
|
||||
if linked_issue_numbers:
|
||||
joined = " ".join(f"#{n}" for n in linked_issue_numbers)
|
||||
msg_lines.append("")
|
||||
msg_lines.append(f"ISSUES CLOSED: {joined}")
|
||||
msg = "\n".join(msg_lines) + "\n"
|
||||
try:
|
||||
env = _env_for_commit(git_user_name, git_user_email)
|
||||
_run_git(worktree, ["add", "-A"], env, result, "stage all")
|
||||
_run_git(worktree, ["commit", "-m", msg], env, result, "commit")
|
||||
result.applied = True
|
||||
except _GitError as exc:
|
||||
result.error = f"commit_staged failed: {exc}"
|
||||
return result
|
||||
|
||||
|
||||
def _amend_commit_footer(
|
||||
worktree: Path,
|
||||
git_user_name: str,
|
||||
git_user_email: str,
|
||||
linked_issue_numbers: list[int],
|
||||
) -> ApplyResult:
|
||||
"""Amend HEAD to add an ``ISSUES CLOSED: #N #M`` footer.
|
||||
|
||||
Reads the current message, appends the footer if not present,
|
||||
rewrites via ``git commit --amend``. Idempotent: if the footer
|
||||
is already there with the same issue numbers, no rewrite.
|
||||
"""
|
||||
result = ApplyResult(gap_name="commit_has_issues_closed", applied=False)
|
||||
if not linked_issue_numbers:
|
||||
result.error = "no linked_issue_numbers — cannot amend"
|
||||
return result
|
||||
try:
|
||||
env = _env_for_commit(git_user_name, git_user_email)
|
||||
# Read current message
|
||||
rc, out = _run_git_capture(
|
||||
worktree, ["log", "-1", "--pretty=%B", "HEAD"], env,
|
||||
)
|
||||
if rc != 0:
|
||||
result.error = f"git log -1 returned {rc}"
|
||||
return result
|
||||
current_msg = out.rstrip("\n")
|
||||
joined = " ".join(f"#{n}" for n in linked_issue_numbers)
|
||||
footer = f"ISSUES CLOSED: {joined}"
|
||||
if footer in current_msg:
|
||||
# Already there — idempotent success
|
||||
result.applied = True
|
||||
result.operations.append(("read HEAD message", "footer already present"))
|
||||
return result
|
||||
new_msg = current_msg.rstrip() + "\n\n" + footer + "\n"
|
||||
_run_git(worktree, ["commit", "--amend", "-m", new_msg], env,
|
||||
result, "amend HEAD with footer")
|
||||
result.applied = True
|
||||
except _GitError as exc:
|
||||
result.error = f"amend failed: {exc}"
|
||||
return result
|
||||
|
||||
|
||||
def _rev_parse_head(worktree: Path) -> str:
|
||||
"""Return HEAD's SHA in the worktree, or empty string on error."""
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["git", "-C", str(worktree), "rev-parse", "HEAD"],
|
||||
capture_output=True, text=True, timeout=10, check=False,
|
||||
)
|
||||
if proc.returncode == 0:
|
||||
return proc.stdout.strip()
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
# ─── Internal helpers ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class _GitError(Exception):
|
||||
"""Raised by ``_run_git`` when a subprocess fails."""
|
||||
|
||||
|
||||
def _env_for_commit(name: str, email: str) -> dict[str, str]:
|
||||
"""Build an env override that pins the commit identity AND
|
||||
disables GPG signing (otherwise dev machines with global signing
|
||||
enabled would prompt or fail).
|
||||
"""
|
||||
import os
|
||||
env = {**os.environ}
|
||||
env["GIT_AUTHOR_NAME"] = name
|
||||
env["GIT_AUTHOR_EMAIL"] = email
|
||||
env["GIT_COMMITTER_NAME"] = name
|
||||
env["GIT_COMMITTER_EMAIL"] = email
|
||||
return env
|
||||
|
||||
|
||||
def _run_git(
|
||||
worktree: Path, args: list[str], env: dict[str, str],
|
||||
result: ApplyResult, label: str,
|
||||
) -> None:
|
||||
"""Run a git subcommand; raise ``_GitError`` on non-zero exit.
|
||||
|
||||
Records the operation in ``result.operations`` for audit trail.
|
||||
Also unconditionally adds ``-c commit.gpgsign=false`` to defend
|
||||
against developer-machine global signing configs (the live test
|
||||
fixture pattern).
|
||||
"""
|
||||
cmd = ["git", "-c", "commit.gpgsign=false", "-C", str(worktree), *args]
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
cmd, env=env, capture_output=True, text=True,
|
||||
timeout=30, check=False,
|
||||
)
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError) as exc:
|
||||
result.operations.append((" ".join(cmd[:8]), f"<failed: {exc}>"))
|
||||
raise _GitError(f"{label}: {exc}") from exc
|
||||
out = ((proc.stdout or "") + (proc.stderr or "")).strip()
|
||||
result.operations.append((" ".join(cmd[:8]) + " ...", out[:200]))
|
||||
if proc.returncode != 0:
|
||||
raise _GitError(f"{label}: rc={proc.returncode}: {out[:300]}")
|
||||
|
||||
|
||||
def _run_git_capture(
|
||||
worktree: Path, args: list[str], env: dict[str, str],
|
||||
) -> tuple[int, str]:
|
||||
"""Run a git subcommand and return ``(rc, stdout)``. Stderr
|
||||
discarded — for these read-only commands (``log``, ``rev-parse``)
|
||||
we only care about stdout."""
|
||||
cmd = ["git", "-C", str(worktree), *args]
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
cmd, env=env, capture_output=True, text=True,
|
||||
timeout=10, check=False,
|
||||
)
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||
return -1, ""
|
||||
return proc.returncode, (proc.stdout or "")
|
||||
|
||||
|
||||
# ─── Push helper ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
def push_branch(
|
||||
worktree: Path,
|
||||
branch: str,
|
||||
git_user_name: str,
|
||||
git_user_email: str,
|
||||
) -> ApplyResult:
|
||||
"""Push the worktree's HEAD to ``origin/<branch>``.
|
||||
|
||||
Uses ``--force-with-lease`` so a remote that moved while we
|
||||
were applying gets a "stale info" rejection rather than a
|
||||
silent overwrite. Disables mirror semantics via ``-c
|
||||
remote.origin.mirror=false`` (the worker's per-command
|
||||
workaround, which is robust across worktree configs even
|
||||
after A1's clone-time fix lands).
|
||||
"""
|
||||
result = ApplyResult(gap_name="push_to_origin", applied=False)
|
||||
try:
|
||||
env = _env_for_commit(git_user_name, git_user_email)
|
||||
cmd = [
|
||||
"git", "-c", "remote.origin.mirror=false",
|
||||
"-C", str(worktree), "push",
|
||||
"--force-with-lease",
|
||||
"origin", f"HEAD:refs/heads/{branch}",
|
||||
]
|
||||
proc = subprocess.run(
|
||||
cmd, env=env, capture_output=True, text=True,
|
||||
timeout=60, check=False,
|
||||
)
|
||||
out = ((proc.stdout or "") + (proc.stderr or "")).strip()
|
||||
result.operations.append(("git push --force-with-lease ...", out[:300]))
|
||||
if proc.returncode == 0:
|
||||
result.applied = True
|
||||
else:
|
||||
result.error = f"push rc={proc.returncode}: {out[:300]}"
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError) as exc:
|
||||
result.error = f"push subprocess failed: {exc}"
|
||||
return result
|
||||
|
||||
|
||||
__all__ = (
|
||||
"ApplyResult",
|
||||
"ApplyReport",
|
||||
"apply_compliance_fixes",
|
||||
"push_branch",
|
||||
)
|
||||
@@ -113,7 +113,12 @@ BUDGET_PER_FAILURE_CLASS: Mapping[FailureClass, int] = {
|
||||
# the literal "resolved" is accepted (see PD P0 in the plan): worker
|
||||
# prompt drift could surface "success" / "done" / "ok" which must
|
||||
# NOT short-circuit to SUCCESS.
|
||||
SUCCESS_OUTCOMES = frozenset({"resolved"})
|
||||
# Outcomes that signal the cycle is done — the dispatcher records
|
||||
# them as SUCCESS and stops escalating. ``resolved`` is the worker's
|
||||
# affirmative-finish signal. ``no_changes_needed`` is the
|
||||
# dispatcher's deterministic short-circuit verdict (2026-05-13 P0
|
||||
# path): the PR was already complete before the LLM was even called.
|
||||
SUCCESS_OUTCOMES = frozenset({"resolved", "no_changes_needed"})
|
||||
|
||||
|
||||
# Terminal-state buckets used by :func:`classify_failure`. Mirrors
|
||||
@@ -236,12 +241,31 @@ def decide(
|
||||
if is_success_outcome:
|
||||
if head_sha_advanced is True:
|
||||
return EscalationAction.SUCCESS
|
||||
# ``no_changes_needed`` legitimately produces no push: the
|
||||
# dispatcher's P0 short-circuit determined nothing was wrong
|
||||
# to begin with. Treat as SUCCESS regardless of
|
||||
# head_sha_advanced — there's nothing the worker could
|
||||
# have done that the dispatcher hasn't already verified.
|
||||
if outcome == "no_changes_needed":
|
||||
return EscalationAction.SUCCESS
|
||||
if head_sha_advanced is None:
|
||||
return EscalationAction.RETRY_POST_FETCH
|
||||
# head_sha_advanced is False: worker claims success but did
|
||||
# not actually push. Drop through to the failure-class path
|
||||
# so the action is determined by the (transport vs competence
|
||||
# vs tier-stable) classification of the terminal_state.
|
||||
# NOT actually push. This is a competence failure, not
|
||||
# transport — the worker emitted a complete-looking JSON
|
||||
# while delivering nothing. Same model on same input will
|
||||
# do the same thing; force escalation by reclassifying
|
||||
# as COMPETENCE. Without this branch the drop-through hit
|
||||
# the UNKNOWN bucket (budget=1) and wasted a same-tier
|
||||
# retry, exactly the failure observed live on 2026-05-13
|
||||
# (PR #30 attempts 1 and 3; PR #28 cycle 2 attempt 1).
|
||||
#
|
||||
# Bypass classify_failure entirely so the bookkeeping is
|
||||
# explicit: skip the budget check and go straight to
|
||||
# ESCALATE (or EXHAUSTED at ceiling).
|
||||
if current_tier >= max_tier:
|
||||
return EscalationAction.EXHAUSTED
|
||||
return EscalationAction.ESCALATE
|
||||
|
||||
# Silent worst case: the worker pushed at least one commit and
|
||||
# then failed before emitting a success outcome. Escalating would
|
||||
|
||||
@@ -248,6 +248,16 @@ def extract_phase4_telemetry(
|
||||
# to a real ``bool`` for downstream JSONL consumers that
|
||||
# filter on ``row["outcome_synthesised"] is True``.
|
||||
row["outcome_synthesised"] = bool(outcome_synthesised)
|
||||
# P4: outcome_disputed — the worker emitted a success outcome
|
||||
# but no push reached origin. This is the "worker hallucinates
|
||||
# success" failure class observed live on 2026-05-13 (PR #30
|
||||
# attempts 1/3, PR #28 cycle 2 attempt 1). The predicate now
|
||||
# routes this to ESCALATE (see _implementer_escalation A2 fix),
|
||||
# but the row separately records that the worker's verdict
|
||||
# didn't match reality so an analyst can grep for the lie rate.
|
||||
outcome_lower = (outcome or "").lower()
|
||||
if outcome_lower == "resolved" and head_sha_advanced is False:
|
||||
row["outcome_disputed"] = True
|
||||
return row
|
||||
|
||||
|
||||
|
||||
@@ -291,6 +291,7 @@ def _ensure_mirror(cfg: Any) -> Path | None:
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
_disable_mirror_push_semantics(mirror, env)
|
||||
return mirror
|
||||
head = mirror / "HEAD"
|
||||
try:
|
||||
@@ -310,9 +311,52 @@ def _ensure_mirror(cfg: Any) -> Path | None:
|
||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc:
|
||||
_logger.warning("mirror fetch failed: %s", exc)
|
||||
# Stale-but-usable beats no clone at all.
|
||||
# Defensive: an existing mirror created by an older dispatcher
|
||||
# version may still have ``remote.origin.mirror=true``. Idempotent
|
||||
# to set it to false again (git just no-ops).
|
||||
_disable_mirror_push_semantics(mirror, env)
|
||||
return mirror
|
||||
|
||||
|
||||
def _disable_mirror_push_semantics(mirror: Path, env: dict[str, str]) -> None:
|
||||
"""Clear ``remote.origin.mirror=true`` on the bare mirror so worktrees
|
||||
derived from it can push refspec-style.
|
||||
|
||||
``git clone --mirror`` sets ``remote.origin.mirror=true``, which makes
|
||||
every push to ``origin`` behave as ``git push --mirror`` (push all
|
||||
refs, no refspecs allowed). Worktrees inherit this config, so the
|
||||
worker's natural ``git push --force-with-lease origin HEAD:<branch>``
|
||||
fails with ``fatal: --mirror can't be combined with refspecs``. The
|
||||
worker historically had to discover this and work around it per cycle
|
||||
(PR #30 2026-05-13 case: 1 of 2 workarounds was fragile and the
|
||||
recovery path lost the local commit).
|
||||
|
||||
The mirror's ``+refs/*:refs/*`` fetch refspec is sufficient to keep
|
||||
``fetch`` behaving like a mirror; the ``mirror=true`` flag only
|
||||
governs push semantics, which we never use against the bare mirror
|
||||
itself anyway. Setting it to false is safe and idempotent.
|
||||
|
||||
Errors are logged at WARNING but do not propagate — failure to
|
||||
flip this flag only means the worker has to apply its existing
|
||||
per-command ``-c remote.origin.mirror=false`` workaround.
|
||||
"""
|
||||
try:
|
||||
subprocess.run(
|
||||
["git", "--git-dir", str(mirror),
|
||||
"config", "remote.origin.mirror", "false"],
|
||||
env=env,
|
||||
check=True,
|
||||
timeout=10,
|
||||
capture_output=True,
|
||||
)
|
||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc:
|
||||
_logger.warning(
|
||||
"failed to clear remote.origin.mirror on %s (worker will "
|
||||
"have to use its per-command workaround): %s",
|
||||
mirror, exc,
|
||||
)
|
||||
|
||||
|
||||
# ─── Worktree handle ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -73,6 +73,9 @@ _implementer_label_state = _load_sibling(
|
||||
_implementer_compliance = _load_sibling(
|
||||
"_implementer_compliance", "_implementer_compliance.py"
|
||||
)
|
||||
_implementer_compliance_apply = _load_sibling(
|
||||
"_implementer_compliance_apply", "_implementer_compliance_apply.py"
|
||||
)
|
||||
_implementer_gate_preflight = _load_sibling(
|
||||
"_implementer_gate_preflight", "_implementer_gate_preflight.py"
|
||||
)
|
||||
@@ -158,6 +161,26 @@ OUTCOME_SYNTHESIS_ENV_VAR = "IMPLEMENTER_OUTCOME_SYNTHESIS"
|
||||
# escalation-on case, not an independent feature toggle.
|
||||
COMPLIANCE_GAPS_ENABLED_ENV_VAR = "IMPLEMENTER_COMPLIANCE_GAPS_ENABLED"
|
||||
|
||||
# Auto-fix kill switch. When this flag is non-falsy, the dispatcher
|
||||
# applies trivial PR-compliance fixes (CONTRIBUTORS line, CHANGELOG
|
||||
# stub, ISSUES CLOSED footer) deterministically without spawning the
|
||||
# LLM worker. The worker is still called when there's real code work
|
||||
# to do (failing tests, request-changes review feedback). Off-by-
|
||||
# default because giving the dispatcher commit/push authority is a
|
||||
# real responsibility expansion and operators should opt in.
|
||||
#
|
||||
# When this flag is OFF (default), the worker handles compliance
|
||||
# fixes the way it has historically — slower and more expensive but
|
||||
# with the LLM in the loop as a safety check.
|
||||
#
|
||||
# Operational consequences when set to 1:
|
||||
# - ~5-13 min of LLM time saved per "metadata-only PR" cycle
|
||||
# - Dispatcher pushes commits authored by the configured
|
||||
# ``GIT_USER_NAME`` / ``GIT_USER_EMAIL`` identity
|
||||
# - Cycle archive's ``post_session_result`` gains an
|
||||
# ``auto_fix_report`` entry describing what was applied
|
||||
AUTO_FIX_COMPLIANCE_ENV_VAR = "IMPLEMENTER_DISPATCHER_AUTO_FIX_COMPLIANCE"
|
||||
|
||||
|
||||
def _work_type_for_group(group_name: str) -> str:
|
||||
"""Map dispatcher work-group names to the ``work_type`` constant
|
||||
@@ -268,6 +291,20 @@ def _is_compliance_gaps_enabled() -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _is_auto_fix_compliance_enabled() -> bool:
|
||||
"""Return ``True`` when the dispatcher should apply deterministic
|
||||
compliance fixes (CONTRIBUTORS line, CHANGELOG stub, ISSUES CLOSED
|
||||
footer) without spawning the LLM worker.
|
||||
|
||||
**Default OFF.** Enabling this gives the dispatcher commit/push
|
||||
authority for trivial PR-hygiene changes. Set
|
||||
``IMPLEMENTER_DISPATCHER_AUTO_FIX_COMPLIANCE=1`` to opt in.
|
||||
|
||||
See :data:`AUTO_FIX_COMPLIANCE_ENV_VAR` for full rationale.
|
||||
"""
|
||||
return _env_truthy(AUTO_FIX_COMPLIANCE_ENV_VAR)
|
||||
|
||||
|
||||
def _is_tier2_enabled() -> bool:
|
||||
"""Return ``True`` when Tier 2 (``tier-kimi``) is part of the
|
||||
escalation ladder.
|
||||
@@ -519,6 +556,12 @@ def _prefetch_prompt(
|
||||
det_sections = _compute_deterministic_sections(
|
||||
cfg, item, group, result, clone_handle,
|
||||
)
|
||||
# Stash on the item context so the short-circuit logic
|
||||
# (``_maybe_short_circuit``) can read the deterministic-section
|
||||
# results without re-running compliance / preflight scans.
|
||||
context = item.get("_dispatcher_implementer_context")
|
||||
if isinstance(context, dict):
|
||||
context["_deterministic_sections"] = det_sections
|
||||
|
||||
if not cfg.dry_run:
|
||||
try:
|
||||
@@ -589,6 +632,26 @@ def _compute_deterministic_sections(
|
||||
worktree_path, changed_files,
|
||||
)
|
||||
if isinstance(classification, dict):
|
||||
# P3: cross-check local preflight against the
|
||||
# dispatcher's prefetched remote CI state. If the
|
||||
# local --fast gates say "all clean" but remote CI
|
||||
# is failing, the failing job is something --fast
|
||||
# doesn't exercise (e.g. e2e_tests or coverage).
|
||||
# Surface the divergence so the worker doesn't trust
|
||||
# preflight alone — without this, the worker reads
|
||||
# "preflight clean" + "compliance clean" and emits
|
||||
# `resolved` while CI is still red (the 2026-05-13
|
||||
# PR #30 attempt 1 / PR #28 cycle 2 failure mode).
|
||||
remote_ci_state = _extract_remote_ci_state(result)
|
||||
classification["remote_ci_state"] = remote_ci_state
|
||||
preflight_clean = (
|
||||
classification.get("failures_total", 0) == 0
|
||||
and not classification.get("preflight_timeout", False)
|
||||
)
|
||||
if preflight_clean and remote_ci_state == "failure":
|
||||
classification["diverges_from_remote_ci"] = True
|
||||
else:
|
||||
classification["diverges_from_remote_ci"] = False
|
||||
out["gate_preflight"] = classification
|
||||
except Exception as exc:
|
||||
_logger.warning(
|
||||
@@ -879,6 +942,26 @@ def _render_compliance_pointer_stanza(
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _extract_remote_ci_state(result: Any) -> str:
|
||||
"""Pull the remote CI aggregate state from the prefetch result.
|
||||
|
||||
The dispatcher's :mod:`_implementer_prefetch` fetches the
|
||||
PR's HEAD CI status; this just normalises the value into one
|
||||
of ``"success"``, ``"failure"``, ``"pending"``, or ``"unknown"``.
|
||||
Used by :func:`_compute_deterministic_sections` (P3) to
|
||||
cross-check the local preflight result against what remote CI
|
||||
actually says.
|
||||
"""
|
||||
ci_status = getattr(result, "ci_status", None)
|
||||
if isinstance(ci_status, dict):
|
||||
state = ci_status.get("state")
|
||||
if isinstance(state, str) and state:
|
||||
return state.lower()
|
||||
if isinstance(ci_status, str) and ci_status:
|
||||
return ci_status.lower()
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _collect_changed_files_from_result(result: Any) -> list[str]:
|
||||
"""Best-effort extraction of the PR's changed-file list from the
|
||||
prefetch result. Used by the gate pre-flight to classify gate
|
||||
@@ -916,6 +999,269 @@ def _collect_changed_files_from_result(result: Any) -> list[str]:
|
||||
return paths
|
||||
|
||||
|
||||
def _maybe_short_circuit(
|
||||
cfg: Any, item: dict[str, Any], context: dict[str, Any],
|
||||
) -> None:
|
||||
"""Decide whether the dispatcher can satisfy this PR's cycle
|
||||
without spawning the LLM worker, and if so stash a synthetic
|
||||
``SessionResult`` on ``context["_short_circuit_result"]`` for
|
||||
the dispatch runtime to consume.
|
||||
|
||||
Two short-circuit paths:
|
||||
|
||||
1. **P0 — skip-when-green.** If compliance scan says all checks
|
||||
passed, gate-preflight says no failures, AND remote CI status
|
||||
is ``success``, then this PR is genuinely done and the
|
||||
listing script's "failing CI" snapshot was stale (CI flipped
|
||||
between snapshot and claim). Emit ``no_changes_needed`` and
|
||||
release. Fires whenever escalation is on and conditions hold.
|
||||
|
||||
2. **A — auto-fix-compliance.** If compliance scan reports gaps
|
||||
that the dispatcher can fix deterministically (CONTRIBUTORS,
|
||||
CHANGELOG bullet, ISSUES CLOSED footer), gate-preflight is
|
||||
clean, AND remote CI failure looks attributable only to those
|
||||
missing items, then apply the fixes + push + emit
|
||||
``resolved`` synthetically. Gated by
|
||||
``IMPLEMENTER_DISPATCHER_AUTO_FIX_COMPLIANCE=1`` because it
|
||||
gives the dispatcher commit/push authority.
|
||||
|
||||
Both paths are no-ops when:
|
||||
- dry-run
|
||||
- escalation feature disabled
|
||||
- the item isn't PR-shaped
|
||||
- the prefetch result is missing
|
||||
- any required signal (compliance, preflight, CI) is absent or
|
||||
ambiguous
|
||||
"""
|
||||
if cfg.dry_run:
|
||||
return
|
||||
if not _is_escalation_enabled():
|
||||
return
|
||||
if not isinstance(item.get("head"), dict):
|
||||
return
|
||||
if not isinstance(context, dict):
|
||||
return
|
||||
result = context.get("result")
|
||||
if result is None:
|
||||
return
|
||||
clone_handle = context.get("clone_handle")
|
||||
if clone_handle is None or not getattr(clone_handle, "path", None):
|
||||
return
|
||||
|
||||
pr_number = int(item.get("number") or 0)
|
||||
remote_ci_state = _extract_remote_ci_state(result)
|
||||
|
||||
# Pull the deterministic sections we already computed for the
|
||||
# sentinel. Re-running them would double the compute cost; both
|
||||
# are stashed on the context by ``_prefetch_prompt``.
|
||||
det_sections = context.get("_deterministic_sections") or {}
|
||||
compliance = det_sections.get("compliance_gaps") or {}
|
||||
preflight = det_sections.get("gate_preflight") or {}
|
||||
gaps: dict[str, bool] = compliance.get("gaps") or {}
|
||||
masked_checks = compliance.get("masked_checks") or []
|
||||
if not gaps:
|
||||
# No compliance scan ran — be conservative and dispatch
|
||||
# the worker. Most commonly this means the preclone was
|
||||
# disabled or failed.
|
||||
return
|
||||
|
||||
preflight_clean = bool(
|
||||
preflight
|
||||
and preflight.get("failures_total", 0) == 0
|
||||
and not preflight.get("preflight_timeout", False)
|
||||
)
|
||||
compliance_clean = (
|
||||
all(bool(v) for v in gaps.values()) and not masked_checks
|
||||
)
|
||||
|
||||
# ─── P0: everything green ──────────────────────────────────
|
||||
if (
|
||||
compliance_clean
|
||||
and preflight_clean
|
||||
and remote_ci_state == "success"
|
||||
):
|
||||
_logger.info(
|
||||
"P0 short-circuit for PR #%s: compliance clean + preflight "
|
||||
"clean + remote CI success — skipping LLM worker",
|
||||
pr_number,
|
||||
)
|
||||
context["_short_circuit_result"] = {
|
||||
"status": "completed",
|
||||
"wallclock_seconds": 0.0,
|
||||
"session_id": "",
|
||||
"raw_response": (
|
||||
'{"outcome": "no_changes_needed", "files_touched": [], '
|
||||
'"_dispatcher_short_circuit": "P0"}'
|
||||
),
|
||||
"parsed_json": {
|
||||
"outcome": "no_changes_needed",
|
||||
"files_touched": [],
|
||||
"_dispatcher_short_circuit": "P0",
|
||||
},
|
||||
}
|
||||
return
|
||||
|
||||
# ─── A: auto-fix compliance gaps ───────────────────────────
|
||||
# Only fires when (a) the operator opted in via the flag, (b)
|
||||
# preflight is clean (so failing CI is plausibly about
|
||||
# compliance-shaped checks, not test failures), (c) compliance
|
||||
# has at least one gap, (d) none of the gaps are masked (we
|
||||
# don't have reliable signal to fix what we can't see).
|
||||
if (
|
||||
not _is_auto_fix_compliance_enabled()
|
||||
or masked_checks
|
||||
or compliance_clean
|
||||
or not preflight_clean
|
||||
):
|
||||
return
|
||||
|
||||
# Don't auto-fix when remote CI is passing — there's nothing to
|
||||
# fix in that case (P0 above would have caught the truly green
|
||||
# case; this guards against weird-state cycles where CI is
|
||||
# green but the dispatcher's compliance scan found something).
|
||||
if remote_ci_state == "success":
|
||||
return
|
||||
|
||||
worktree = Path(str(clone_handle.path))
|
||||
pr_title = ""
|
||||
pr_details = getattr(result, "pr_details", None)
|
||||
if isinstance(pr_details, dict):
|
||||
pr_title = str(pr_details.get("title") or "")
|
||||
if not pr_title:
|
||||
pr_title = str(item.get("title") or f"PR #{pr_number}")
|
||||
|
||||
linked = getattr(result, "linked_issues", None) or []
|
||||
linked_issue_numbers: list[int] = []
|
||||
for li in linked:
|
||||
if isinstance(li, dict):
|
||||
n = li.get("number")
|
||||
if isinstance(n, int) and n > 0:
|
||||
linked_issue_numbers.append(n)
|
||||
|
||||
git_user_name = (
|
||||
os.environ.get("GIT_USER_NAME")
|
||||
or getattr(cfg, "git_user_name", "")
|
||||
or "CleverThis"
|
||||
)
|
||||
git_user_email = (
|
||||
os.environ.get("GIT_USER_EMAIL")
|
||||
or getattr(cfg, "git_user_email", "")
|
||||
or "hal9000@cleverthis.com"
|
||||
)
|
||||
branch = ""
|
||||
if isinstance(pr_details, dict):
|
||||
head = pr_details.get("head") or {}
|
||||
if isinstance(head, dict):
|
||||
branch = str(head.get("ref") or "")
|
||||
if not branch:
|
||||
_logger.warning(
|
||||
"auto-fix short-circuit for PR #%s aborted: missing head_ref "
|
||||
"(can't push without the branch name); falling through to worker",
|
||||
pr_number,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
report = _implementer_compliance_apply.apply_compliance_fixes(
|
||||
worktree, gaps,
|
||||
git_user_name=git_user_name,
|
||||
git_user_email=git_user_email,
|
||||
pr_title=pr_title,
|
||||
pr_number=pr_number,
|
||||
linked_issue_numbers=linked_issue_numbers,
|
||||
)
|
||||
except Exception as exc:
|
||||
_logger.warning(
|
||||
"auto-fix apply raised for PR #%s; falling through to worker: %s",
|
||||
pr_number, exc,
|
||||
)
|
||||
return
|
||||
|
||||
if not report.all_applied:
|
||||
_logger.info(
|
||||
"auto-fix could not deterministically resolve all gaps for PR "
|
||||
"#%s (%d failures); falling through to worker",
|
||||
pr_number,
|
||||
sum(1 for r in report.per_gap if r.error),
|
||||
)
|
||||
return
|
||||
|
||||
if not report.any_committed:
|
||||
# All gaps were already satisfied (idempotent re-run). Push
|
||||
# is unnecessary — the worktree is the same as origin.
|
||||
_logger.info(
|
||||
"auto-fix found no actual gaps to fix on PR #%s after recheck "
|
||||
"(idempotent); short-circuit emits no_changes_needed",
|
||||
pr_number,
|
||||
)
|
||||
context["_short_circuit_result"] = {
|
||||
"status": "completed",
|
||||
"raw_response": (
|
||||
'{"outcome": "no_changes_needed", "files_touched": [], '
|
||||
'"_dispatcher_short_circuit": "A_idempotent"}'
|
||||
),
|
||||
"parsed_json": {
|
||||
"outcome": "no_changes_needed",
|
||||
"files_touched": [],
|
||||
"_dispatcher_short_circuit": "A_idempotent",
|
||||
},
|
||||
}
|
||||
return
|
||||
|
||||
# Push the deterministic commit.
|
||||
push_result = _implementer_compliance_apply.push_branch(
|
||||
worktree, branch,
|
||||
git_user_name=git_user_name,
|
||||
git_user_email=git_user_email,
|
||||
)
|
||||
if not push_result.applied:
|
||||
_logger.warning(
|
||||
"auto-fix push failed for PR #%s (%s); falling through to worker",
|
||||
pr_number, push_result.error,
|
||||
)
|
||||
return
|
||||
|
||||
_logger.info(
|
||||
"auto-fix short-circuit for PR #%s: applied %d compliance fix(es) "
|
||||
"+ pushed %s",
|
||||
pr_number,
|
||||
sum(1 for r in report.per_gap if r.applied and not r.error),
|
||||
report.final_head_sha[:12] if report.final_head_sha else "<unknown>",
|
||||
)
|
||||
context["_short_circuit_result"] = {
|
||||
"status": "completed",
|
||||
"raw_response": (
|
||||
'{"outcome": "resolved", "files_touched": ["CHANGELOG.md", '
|
||||
'"CONTRIBUTORS.md"], "_dispatcher_short_circuit": "A_auto_fix", '
|
||||
'"_auto_fix_head_sha": "' + report.final_head_sha + '"}'
|
||||
),
|
||||
"parsed_json": {
|
||||
"outcome": "resolved",
|
||||
"files_touched": [
|
||||
r.gap_name for r in report.per_gap
|
||||
if r.applied and not r.error
|
||||
],
|
||||
"_dispatcher_short_circuit": "A_auto_fix",
|
||||
"_auto_fix_head_sha": report.final_head_sha,
|
||||
},
|
||||
}
|
||||
# Stash the apply report for downstream telemetry / cycle archive
|
||||
# consumers that want the audit trail.
|
||||
context["_auto_fix_report"] = {
|
||||
"all_applied": report.all_applied,
|
||||
"any_committed": report.any_committed,
|
||||
"final_head_sha": report.final_head_sha,
|
||||
"per_gap": [
|
||||
{
|
||||
"gap_name": r.gap_name,
|
||||
"applied": r.applied,
|
||||
"error": r.error,
|
||||
}
|
||||
for r in report.per_gap
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _implementation_prompt_dispatch(
|
||||
cfg: Any, item: dict[str, Any], group: Any
|
||||
) -> str:
|
||||
@@ -970,7 +1316,19 @@ def _implementation_prompt_dispatch(
|
||||
extras = ["release_claim_on_exit: false"]
|
||||
if start_tier > 0:
|
||||
extras.append(f"escalation_tier_hint: `{start_tier}`")
|
||||
return f"{base_prompt}\n\n" + "\n".join(extras) + "\n"
|
||||
final_prompt = f"{base_prompt}\n\n" + "\n".join(extras) + "\n"
|
||||
|
||||
# P0 / A: deterministic short-circuit.
|
||||
# If the prefetch + deterministic sections + remote CI state all
|
||||
# agree that no LLM work is needed (or that the only needed work
|
||||
# is mechanical compliance fixes the dispatcher can apply itself),
|
||||
# stash a synthetic SessionResult on the context. The dispatch
|
||||
# runtime's short-circuit hook consumes it and skips the worker.
|
||||
# Off by default (gated on IMPLEMENTER_DISPATCHER_AUTO_FIX_COMPLIANCE
|
||||
# for the auto-fix path; the no-op skip case fires whenever
|
||||
# escalation is on and conditions are met).
|
||||
_maybe_short_circuit(cfg, item, context)
|
||||
return final_prompt
|
||||
|
||||
|
||||
# ─── Post-session action: cleanup pre-cloned worktree ───────────────────────
|
||||
@@ -1364,6 +1722,17 @@ def _post_session_action(
|
||||
"phase4_telemetry": None,
|
||||
"work_group_name": resolved_group_name,
|
||||
}
|
||||
# Surface the dispatcher's deterministic-fix report (P0 / A) when
|
||||
# the cycle was short-circuited by ``_maybe_short_circuit``. An
|
||||
# operator inspecting cycle archives sees ``auto_fix_report`` ==
|
||||
# None on a normal worker cycle and a populated dict when the
|
||||
# dispatcher applied compliance fixes itself.
|
||||
out["auto_fix_report"] = (
|
||||
context_dict.get("_auto_fix_report") if context_dict else None
|
||||
)
|
||||
out["short_circuit"] = bool(
|
||||
context_dict and context_dict.get("_short_circuit_consumed")
|
||||
)
|
||||
out["phase4_telemetry"] = _record_phase4_telemetry(
|
||||
cfg,
|
||||
item,
|
||||
|
||||
Reference in New Issue
Block a user