diff --git a/.github/scripts/__tests__/keepalive-loop.test.js b/.github/scripts/__tests__/keepalive-loop.test.js index bae554b18..83c35bcce 100644 --- a/.github/scripts/__tests__/keepalive-loop.test.js +++ b/.github/scripts/__tests__/keepalive-loop.test.js @@ -9,6 +9,7 @@ const path = require('path'); const { countCheckboxes, parseConfig, + resolvePrNumber, evaluateKeepaliveLoop, updateKeepaliveLoopSummary, markAgentRunning, @@ -182,6 +183,18 @@ test.after(() => { } }); +test('resolvePrNumber accepts base-defined pull_request_target events', async () => { + const context = buildContext(123, 9001, { eventName: 'pull_request_target' }); + + const prNumber = await resolvePrNumber({ + github: buildGithubStub(), + context, + core: buildCore(), + }); + + assert.equal(prNumber, 123); +}); + test('countCheckboxes tallies checked and unchecked tasks', () => { const counts = countCheckboxes('- [ ] one\n- [x] two\n- [X] three\n- [ ] four'); assert.deepEqual(counts, { total: 4, checked: 2, unchecked: 2 }); diff --git a/.github/scripts/__tests__/source-context.test.js b/.github/scripts/__tests__/source-context.test.js index 7b3c58f71..38c8687cc 100644 --- a/.github/scripts/__tests__/source-context.test.js +++ b/.github/scripts/__tests__/source-context.test.js @@ -73,6 +73,56 @@ test('extractIssueNumberFromPull keeps existing issue resolution behavior', () = ); }); +test('extractIssueNumberFromPull prefers an explicit body link over inferred branch and title issues', () => { + assert.equal( + extractIssueNumberFromPull({ + body: 'Related to #222', + head: { ref: 'codex/issue-111-stale-branch' }, + title: 'Resolve issue #333', + }), + 222, + ); +}); + +test('extractIssueNumberFromPull prefers one closing link over secondary body references', () => { + assert.equal( + extractIssueNumberFromPull({ + body: 'Related to #111 for context.\nCloses #222', + head: { ref: 'codex/issue-222-fix' }, + title: 'Resolve issue #222', + }), + 222, + ); +}); + +test('extractIssueNumberFromPull rejects ambiguous non-closing body references', () => { + assert.equal( + extractIssueNumberFromPull({ + body: 'Related to #111.\nReferences issue #222.', + head: { ref: 'codex/issue-333-fallback' }, + title: 'Resolve issue #333', + }), + null, + ); +}); + +test('extractIssueNumberFromPull rejects distinct meta issue markers', () => { + assert.equal( + extractIssueNumberFromPull({ + body: '\n', + head: { ref: 'codex/issue-111-fallback' }, + title: 'Resolve issue #111', + }), + null, + ); + assert.equal( + extractIssueNumberFromPull({ + body: '\n', + }), + 111, + ); +}); + test('extractIssueNumberFromPull ignores PR references in workflow source templates', () => { const context = resolvePrSourceContext({ body: ` diff --git a/.github/scripts/keepalive_loop.js b/.github/scripts/keepalive_loop.js index 21e61b9fb..2e2fb1fab 100644 --- a/.github/scripts/keepalive_loop.js +++ b/.github/scripts/keepalive_loop.js @@ -1834,7 +1834,10 @@ async function resolvePrNumber({ github, context, core, payload: overridePayload return overridePayload.workflow_run.pull_requests[0].number; } - if (eventName === 'pull_request' && payload.pull_request) { + if ( + (eventName === 'pull_request' || eventName === 'pull_request_target') + && payload.pull_request + ) { return payload.pull_request.number; } @@ -5397,6 +5400,7 @@ module.exports = { parseConfig, buildTaskAppendix, extractSourceSection, + resolvePrNumber, evaluateKeepaliveLoop, markAgentRunning, updateKeepaliveLoopSummary, diff --git a/.github/scripts/source_context.js b/.github/scripts/source_context.js index ed9c1f64d..586e1e489 100644 --- a/.github/scripts/source_context.js +++ b/.github/scripts/source_context.js @@ -233,11 +233,64 @@ function extractIssueNumberFromText(text) { return issueNumbers.size > 0 ? Array.from(issueNumbers)[0] : null; } +function extractClosingIssueNumbersFromText(text) { + const value = String(text || ''); + const issueNumbers = new Set(); + for (const match of value.matchAll(/#([0-9]+)/g)) { + const before = value.slice(Math.max(0, match.index - 100), match.index); + const token = before.split(/\s/).pop() || ''; + if (token.includes('/')) { + continue; + } + const prefix = before + .replace(/\r\n?/g, '\n') + .replace(/[_\[\]()`~]/g, ' ') + .trim() + .replace(/[>*]/g, ' ') + .replace(/\s+/g, ' '); + if ( + !/\b(?:close[sd]?|closing|fix(?:e[sd])?|fixing|resolve[sd]?|resolving)(?:\s+(?:source\s+issue|github\s+issue|issue))?\s*[:#-]?\s*$/i.test( + prefix + ) + ) { + continue; + } + const parsed = Number.parseInt(match[1], 10); + if (!Number.isNaN(parsed)) { + issueNumbers.add(parsed); + } + } + return issueNumbers; +} + function extractIssueNumberFromPull(pull = {}) { const bodyText = String(pull?.body || ''); - const metaMatch = bodyText.match(//i); - if (metaMatch) { - return Number.parseInt(metaMatch[1], 10); + const metaIssueNumbers = new Set( + Array.from(bodyText.matchAll(//gi), (match) => + Number.parseInt(match[1], 10), + ), + ); + if (metaIssueNumbers.size > 1) { + return null; + } + if (metaIssueNumbers.size === 1) { + return Array.from(metaIssueNumbers)[0]; + } + + const closingIssueNumbers = extractClosingIssueNumbersFromText(bodyText); + if (closingIssueNumbers.size === 1) { + return Array.from(closingIssueNumbers)[0]; + } + if (closingIssueNumbers.size > 1) { + return null; + } + + const bodyIssueNumbers = extractIssueNumbersFromText(bodyText); + if (bodyIssueNumbers.size === 1) { + return Array.from(bodyIssueNumbers)[0]; + } + if (bodyIssueNumbers.size > 1) { + return null; } const branch = String(pull?.head?.ref || ''); @@ -251,7 +304,7 @@ function extractIssueNumberFromPull(pull = {}) { return titleNumber; } - return extractIssueNumberFromText(bodyText); + return null; } function parseHtmlMarker(body, name) { @@ -489,6 +542,7 @@ module.exports = { normalizeSourceType, extractIssueNumberFromText, extractIssueNumbersFromText, + extractClosingIssueNumbersFromText, extractIssueNumberFromPull, parseWorkflowSourceBlock, parseDependencyRepairPromotionSource, diff --git a/docs/LABELS.md b/docs/LABELS.md index 40edd5aef..afedafe94 100644 --- a/docs/LABELS.md +++ b/docs/LABELS.md @@ -10,11 +10,11 @@ This document describes all labels that trigger automated workflows or affect CI | `autofix:clean` | PR labeled | Triggers clean-mode autofix (more aggressive) | `agent:codex` | Issue or PR labeled | Routes the issue or PR to the Codex agent | `agent:claude` | Issue or PR labeled | Routes the issue or PR to the Claude Code agent -| `agent:cursor` | Issue or PR labeled | Routes the issue or PR to the Cursor agent (`cursor-agent` CLI) -| `agent:gemini` | Issue or PR labeled | Routes the issue or PR to the Gemini agent (`gemini` CLI) +| `agent:cursor` | Issue or PR labeled | Registered routing label; no consumer Gate-followup runner is currently wired +| `agent:gemini` | Issue or PR labeled | Registered routing label; no consumer Gate-followup runner is currently wired | `agent:aider` | Issue or PR labeled | Routes the issue or PR to the Aider agent for cheap, low-complexity tasks — runner lands in a follow-up phase | `agent:auto` | Issue or PR labeled | Delegates routing to the auto-delegation policy; do not combine with concrete `agent:` labels -| `agent:retry` | PR labeled | Requests one re-dispatch of the matching keepalive runner +| `agent:retry` | PR labeled | Consolidated consumers require a manual Gate-followups dispatch; the root/non-consolidated keepalive workflow forces a retry and clears recovery labels | `agent:rate-limited` | Auto-applied | Marks a PR as backing off from a rate-limit failure | ~~`agent:codex-invite`~~ | *(deprecated)* | No workflow, script, or tool references this label by name; the generic `agent:-invite` mechanism in `reusable-agents-issue-bridge.yml` still works but this specific label is unmaintained — see detail section below | `agent:needs-attention` | Auto-applied | Indicates agent needs human intervention @@ -100,13 +100,13 @@ This document describes all labels that trigger automated workflows or affect CI 2. Validates that a valid agent assignee is present 3. If validated, enables automated code generation for the issue 4. Creates a `codex/issue-` branch for agent work -5. On PRs, routes keepalive dispatch to `reusable-codex-run.yml` per `.github/agents/registry.yml` +5. On PRs, routes keepalive evaluation through the local sweep and the registry-backed shared runner **Prerequisites:** - Issue must have a valid agent assignee (configured in repository settings) - Issue should have clear requirements in the description -**Workflow:** `agents-63-issue-intake.yml` (Agents 63 Issue Intake); on PRs, `agents-keepalive-loop.yml` routes work via `.github/agents/registry.yml`. +**Workflow:** `agents-issue-intake.yml` (Agents Issue Intake); on PRs, `agents-keepalive-sweep.yml` routes evaluation to the shared registry-backed implementation. --- @@ -128,7 +128,7 @@ This document describes all labels that trigger automated workflows or affect CI **Lifecycle:** Applied at issue claim / PR creation by an opener that already has Claude capacity. Removed when work completes (PR merged or issue closed). Co-applied with `agents:keepalive` on the PR so keepalive is enabled. -**Workflow:** `agents-bot-comment-handler.yml`, `agents-auto-label.yml`, `agents-keepalive-loop.yml`, `agents-guard.yml`, `reusable-pr-context.yml`; runner is `reusable-claude-run.yml` per `.github/agents/registry.yml`. +**Workflow:** `agents-auto-label.yml`, `agents-81-gate-followups.yml`, `agents-guard.yml`, `reusable-pr-context.yml`; runner is `reusable-claude-run.yml` per `.github/agents/registry.yml`. --- @@ -139,15 +139,15 @@ This document describes all labels that trigger automated workflows or affect CI **Trigger:** When applied to an issue or PR **Effect:** -1. Routes the issue or PR to the Cursor agent, a parallel surface to `agent:codex` and `agent:claude` -2. On PRs, keepalive dispatches work via `reusable-cursor-run.yml` per `.github/agents/registry.yml` -3. Branch prefix `cursor/issue-` is used for agent work (see `.github/agents/registry.yml`) +1. Identifies Cursor as the intended route in registry-aware automation +2. Does **not** dispatch a consumer PR keepalive runner: `agents-81-gate-followups.yml` currently has runner jobs only for Codex and Claude +3. Branch prefix `cursor/issue-` is reserved for Cursor work (see `.github/agents/registry.yml`) **Prerequisites:** - Repository has a valid `CURSOR_API_KEY` secret (per `.github/agents/registry.yml`) - Issue or PR should have clear requirements -**Workflow:** `agents-keepalive-loop.yml`, `agents-autofix-loop.yml`; runner is `reusable-cursor-run.yml` per `.github/agents/registry.yml`. +**Workflow:** Registry-aware workflows may recognize the label, but the consumer Gate-followup workflow has no Cursor runner job. Do not use this label to promise PR keepalive execution until that job is delivered from the canonical Workflows source. --- @@ -158,14 +158,14 @@ This document describes all labels that trigger automated workflows or affect CI **Trigger:** When applied to an issue or PR **Effect:** -1. Routes the issue or PR to the Gemini agent, a parallel surface to `agent:codex`/`agent:claude`/`agent:cursor` -2. On PRs, keepalive dispatches work via `reusable-gemini-run.yml` per `.github/agents/registry.yml` -3. Branch prefix `gemini/issue-` is used for agent work +1. Identifies Gemini as the intended route in registry-aware automation +2. Does **not** dispatch a consumer PR keepalive runner: `agents-81-gate-followups.yml` currently has runner jobs only for Codex and Claude +3. Branch prefix `gemini/issue-` is reserved for Gemini work **Prerequisites:** - Repository has a valid `GEMINI_API_KEY` secret (per `.github/agents/registry.yml`) -**Workflow:** `agents-keepalive-loop.yml`; runner is `reusable-gemini-run.yml` per `.github/agents/registry.yml`. +**Workflow:** Registry-aware workflows may recognize the label, but the consumer Gate-followup workflow has no Gemini runner job. Do not use this label to promise PR keepalive execution until that job is delivered from the canonical Workflows source. --- @@ -206,20 +206,23 @@ runner and registry entry ship, applying this label will not dispatch a runner. **Applies to:** Pull Requests -**Trigger:** When applied to (or re-applied to) a PR +**Trigger:** Topology-dependent: + +- Consolidated consumer (`agents-81-gate-followups.yml`): the label is a recovery marker and does not trigger a retry by itself +- Root/non-consolidated (`agents-keepalive-loop.yml`): a `pull_request:labeled` event triggers a forced retry **Effect:** -1. Signals keepalive to force a re-dispatch of the matching runner on its next tick -2. Removed by `agents-keepalive-loop.yml` at the top of the resulting run so the label is reusable -3. Co-removed: any stale `agent:rate-limited` label is also removed at the same time +1. Records that a retry was requested in both topologies +2. Consolidated consumer: does not set `force_retry` or remove recovery labels merely by being applied +3. Root/non-consolidated: sets `force_retry=true` and attempts to remove both `agent:retry` and `agent:rate-limited` **Prerequisites:** - PR has a concrete `agent:codex` or `agent:claude` label so a runner can be re-dispatched - PR has `agents:keepalive` -**Lifecycle:** Applied by `agents-auto-pilot.yml` on dispatch failure handling, by openers/closers during quick recovery, or manually to force one keepalive re-run. Consumed and cleaned by `agents-keepalive-loop.yml`. +**Recovery:** In a consolidated consumer, force a bounded retry by manually dispatching `agents-81-gate-followups.yml` with `pr_number=` and `force_retry=true`; after the run is confirmed, remove stale recovery labels manually if they remain. In the root/non-consolidated topology, applying `agent:retry` invokes the label handler automatically. -**Workflow:** Applied by `agents-auto-pilot.yml`; consumed/cleaned by `agents-keepalive-loop.yml`. +**Workflow:** `agents-81-gate-followups.yml` accepts the explicit `force_retry` dispatch input for consolidated consumers. `.github/workflows/agents-keepalive-loop.yml` implements label-driven retry and cleanup for the root/non-consolidated topology. --- @@ -232,14 +235,14 @@ runner and registry entry ship, applying this label will not dispatch a runner. **Effect:** 1. Marks the PR as currently backed off due to API/runner rate limits 2. Used with the matching concrete `agent:` label to flag backoff for the current route; switch to `agent:auto` only after removing the concrete routing label -3. Removed by `agents-keepalive-loop.yml` during the `agent:retry` labeled run, before keepalive evaluation +3. Remains an observability/backoff marker until automation or an operator explicitly removes it **Prerequisites:** - Applied automatically; no manual action required -**Lifecycle:** Applied by `agents-auto-pilot.yml` when a dispatch hits a rate limit. Cleaned by the retry-label handler in `agents-keepalive-loop.yml` when `agent:retry` is processed. Does not by itself trigger a runner. +**Lifecycle:** Applied when a dispatch hits a rate limit. It does not by itself trigger a runner, and the current consumer Gate-followup wrapper does not promise label cleanup. After a successful forced retry, remove it manually if it remains. -**Workflow:** Applied by `agents-auto-pilot.yml`; consumed/cleaned by `agents-keepalive-loop.yml`. +**Workflow:** Producer workflows apply the marker; recovery uses an explicit `agents-81-gate-followups.yml` dispatch with `force_retry=true`. --- @@ -306,7 +309,9 @@ runner and registry entry ship, applying this label will not dispatch a runner. 2. Used in conjunction with `agent:codex` to signal readiness 3. May trigger the next step in the agent automation pipeline -**Workflow:** `agents-70-orchestrator.yml` (Agents 70 Orchestrator) +**Workflow:** `agents-issue-intake.yml` handles ordinary agent intake. The +separate `agents:auto-pilot` route dispatches Agents 71 and the Agents 72 +wrapper when end-to-end automation is requested. --- @@ -529,7 +534,7 @@ These labels trigger the post-merge verifier workflow on a merged PR. **To Resume:** Remove the `agents:paused` label. -**Workflow:** `agents-keepalive-loop.yml` +**Workflow:** `agents-81-gate-followups.yml` --- @@ -548,7 +553,7 @@ These labels trigger the post-merge verifier workflow on a merged PR. - PR must have an `agent:*` label - Gate workflow must pass -**Workflow:** `agents-keepalive-loop.yml` +**Workflow:** `agents-81-gate-followups.yml` --- @@ -645,9 +650,8 @@ Prefixed labels such as `verify:runtime-ac` are treated the same as **Consumers:** `.github/scripts/runtime_ac_merge_guard.js`, `.github/workflows/agents-73-codex-belt-conveyor.yml`, -`.github/workflows/reusable-70-orchestrator-main.yml`, -`.github/workflows/maint-71-merge-sync-prs.yml`, -`templates/consumer-repo/.github/workflows/agents-81-gate-followups.yml`. +`.github/workflows/agents-81-gate-followups.yml`. Agents 73 is callable-only; +Agents 81 is the active local guarded merge route. --- @@ -664,7 +668,8 @@ Prefixed labels such as `verify:runtime-ac` are treated the same as **Consumers:** `.github/scripts/keepalive_loop.js`, `.github/scripts/merge_manager.js`, -`.github/workflows/reusable-70-orchestrator-main.yml`. +`.github/workflows/agents-auto-pilot.yml`, +`.github/workflows/agents-81-gate-followups.yml`. --- @@ -676,11 +681,13 @@ Prefixed labels such as `verify:runtime-ac` are treated the same as **Effect:** 1. Records that an issue has active belt work in progress. -2. Is removed by the conveyor after the active work hands off or completes. +2. Is cleared by Agents 81 after it successfully merges the linked completed PR. +3. Is also cleared by Agents 73 when that callable-only recovery path is invoked. **Consumers:** `.github/workflows/agents-71-codex-belt-dispatcher.yml`, `.github/workflows/agents-72-codex-belt-worker.yml`, -`.github/workflows/agents-73-codex-belt-conveyor.yml`. +`.github/workflows/agents-81-gate-followups.yml`, and the callable-only +`.github/workflows/agents-73-codex-belt-conveyor.yml` recovery component. --- @@ -748,9 +755,9 @@ These labels are used for categorization but do not trigger workflows. | (none) | `agent:auto` | Delegates routing to `agent_delegation_policy.js` | `agent:codex` | `agent:auto` | Invalid mixed routing; remove `agent:codex` before using `agent:auto` | `agent:claude` | `agent:auto` | Invalid mixed routing; remove `agent:claude` before using `agent:auto` -| `agent:` + `agents:keepalive` | `agent:retry` | Forces one re-dispatch; keepalive removes the label at the top of its run -| `agent:retry` | (removed by `agents-keepalive-loop.yml`) | Co-removes any stale `agent:rate-limited` -| `agent:rate-limited` | `agent:retry` | Retry-label handler removes stale `agent:rate-limited` before keepalive evaluation +| `agent:` + `agents:keepalive` | `agent:retry` | Consolidated: records a recovery request; root/non-consolidated: forces the keepalive retry and cleanup handler +| `agent:retry` | Manual Gate-followups dispatch | Consolidated only: forces the retry and does not imply automatic label cleanup +| `agent:rate-limited` | Successful forced retry | Remove stale recovery labels manually if automation leaves them behind | `agent:codex` | `agent:codex-invite` | Selects issue-bridge `invite` mode for `agent:codex` when `force_mode: false`; with the default forced input mode, the requested mode still wins | `agent:codex` | `status:ready` | Agent begins processing | `agent:needs-attention` | (removed) | Agent resumes processing diff --git a/templates/consumer-repo/.github/scripts/keepalive_loop.js b/templates/consumer-repo/.github/scripts/keepalive_loop.js index 21e61b9fb..2e2fb1fab 100644 --- a/templates/consumer-repo/.github/scripts/keepalive_loop.js +++ b/templates/consumer-repo/.github/scripts/keepalive_loop.js @@ -1834,7 +1834,10 @@ async function resolvePrNumber({ github, context, core, payload: overridePayload return overridePayload.workflow_run.pull_requests[0].number; } - if (eventName === 'pull_request' && payload.pull_request) { + if ( + (eventName === 'pull_request' || eventName === 'pull_request_target') + && payload.pull_request + ) { return payload.pull_request.number; } @@ -5397,6 +5400,7 @@ module.exports = { parseConfig, buildTaskAppendix, extractSourceSection, + resolvePrNumber, evaluateKeepaliveLoop, markAgentRunning, updateKeepaliveLoopSummary, diff --git a/templates/consumer-repo/.github/scripts/source_context.js b/templates/consumer-repo/.github/scripts/source_context.js index ed9c1f64d..586e1e489 100644 --- a/templates/consumer-repo/.github/scripts/source_context.js +++ b/templates/consumer-repo/.github/scripts/source_context.js @@ -233,11 +233,64 @@ function extractIssueNumberFromText(text) { return issueNumbers.size > 0 ? Array.from(issueNumbers)[0] : null; } +function extractClosingIssueNumbersFromText(text) { + const value = String(text || ''); + const issueNumbers = new Set(); + for (const match of value.matchAll(/#([0-9]+)/g)) { + const before = value.slice(Math.max(0, match.index - 100), match.index); + const token = before.split(/\s/).pop() || ''; + if (token.includes('/')) { + continue; + } + const prefix = before + .replace(/\r\n?/g, '\n') + .replace(/[_\[\]()`~]/g, ' ') + .trim() + .replace(/[>*]/g, ' ') + .replace(/\s+/g, ' '); + if ( + !/\b(?:close[sd]?|closing|fix(?:e[sd])?|fixing|resolve[sd]?|resolving)(?:\s+(?:source\s+issue|github\s+issue|issue))?\s*[:#-]?\s*$/i.test( + prefix + ) + ) { + continue; + } + const parsed = Number.parseInt(match[1], 10); + if (!Number.isNaN(parsed)) { + issueNumbers.add(parsed); + } + } + return issueNumbers; +} + function extractIssueNumberFromPull(pull = {}) { const bodyText = String(pull?.body || ''); - const metaMatch = bodyText.match(//i); - if (metaMatch) { - return Number.parseInt(metaMatch[1], 10); + const metaIssueNumbers = new Set( + Array.from(bodyText.matchAll(//gi), (match) => + Number.parseInt(match[1], 10), + ), + ); + if (metaIssueNumbers.size > 1) { + return null; + } + if (metaIssueNumbers.size === 1) { + return Array.from(metaIssueNumbers)[0]; + } + + const closingIssueNumbers = extractClosingIssueNumbersFromText(bodyText); + if (closingIssueNumbers.size === 1) { + return Array.from(closingIssueNumbers)[0]; + } + if (closingIssueNumbers.size > 1) { + return null; + } + + const bodyIssueNumbers = extractIssueNumbersFromText(bodyText); + if (bodyIssueNumbers.size === 1) { + return Array.from(bodyIssueNumbers)[0]; + } + if (bodyIssueNumbers.size > 1) { + return null; } const branch = String(pull?.head?.ref || ''); @@ -251,7 +304,7 @@ function extractIssueNumberFromPull(pull = {}) { return titleNumber; } - return extractIssueNumberFromText(bodyText); + return null; } function parseHtmlMarker(body, name) { @@ -489,6 +542,7 @@ module.exports = { normalizeSourceType, extractIssueNumberFromText, extractIssueNumbersFromText, + extractClosingIssueNumbersFromText, extractIssueNumberFromPull, parseWorkflowSourceBlock, parseDependencyRepairPromotionSource, diff --git a/templates/consumer-repo/.github/workflows/agents-81-gate-followups.yml b/templates/consumer-repo/.github/workflows/agents-81-gate-followups.yml index d3c34901d..ca885518b 100644 --- a/templates/consumer-repo/.github/workflows/agents-81-gate-followups.yml +++ b/templates/consumer-repo/.github/workflows/agents-81-gate-followups.yml @@ -4,9 +4,12 @@ on: workflow_run: workflows: ["Gate"] types: [completed] - pull_request: + # Keep privileged PR wakeups on the base-defined workflow. The guarded jobs + # never execute or check out the pull request head. + pull_request_target: types: - labeled + - synchronize workflow_dispatch: inputs: pr_number: @@ -59,7 +62,11 @@ env: jobs: evaluate: name: Evaluate keepalive loop - if: vars.USE_CONSOLIDATED_WORKFLOWS == 'true' || github.event_name == 'workflow_dispatch' + if: >- + ${{ + (vars.USE_CONSOLIDATED_WORKFLOWS == 'true' || github.event_name == 'workflow_dispatch') && + (github.event_name != 'pull_request_target' || github.event.action != 'synchronize') + }} runs-on: ubuntu-latest environment: agent-standard outputs: @@ -241,7 +248,7 @@ jobs: let prNumber = 0; let pr = null; - if (context.eventName === 'pull_request' && payload.pull_request) { + if (context.eventName === 'pull_request_target' && payload.pull_request) { prNumber = payload.pull_request.number; pr = payload.pull_request; } else if (context.eventName === 'workflow_run' && payload.workflow_run) { @@ -1166,7 +1173,11 @@ jobs: prepare: name: Prepare autofix context - if: vars.USE_CONSOLIDATED_WORKFLOWS == 'true' + if: >- + ${{ + vars.USE_CONSOLIDATED_WORKFLOWS == 'true' && + (github.event_name != 'pull_request_target' || github.event.action != 'synchronize') + }} runs-on: ubuntu-latest environment: agent-standard outputs: @@ -1716,13 +1727,18 @@ jobs: - prepare - autofix - autofix-claude - if: always() + if: >- + ${{ + always() && + (github.event_name != 'pull_request_target' || github.event.action != 'synchronize') + }} runs-on: ubuntu-latest environment: agent-standard steps: - name: Checkout (for retry helpers) uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ github.event.repository.default_branch }} sparse-checkout: | .github/scripts/error_classifier.js .github/scripts/github-api-with-retry.js @@ -1975,12 +1991,17 @@ jobs: name: Merge automerge-labelled agent PRs # #2270 (FO-5): in-repo guarded merger for consumer repos. Runs on the same # events as the rest of this workflow (Gate completion / label / dispatch) and - # scans open PRs carrying the `automerge` label. Mirrors the reusable-70 + # evaluates the triggering PR when it carries the `automerge` label. Mirrors the reusable-70 # automerge sweep guards (combined status + all check-runs success) and adds - # the bootstrap-only and unchecked-task refusals. Replaces the guardless native - # auto-merge previously enabled by the belt worker (removed in #2270). - if: ${{ github.event_name != 'pull_request' || github.event.label.name == 'automerge' }} + # exact-head, review-window, review-thread, bootstrap-only, and unchecked-task + # refusals. Replaces the guardless native auto-merge previously enabled by the + # belt worker (removed in #2270). + if: >- + ${{ github.event_name != 'pull_request_target' || + github.event.action == 'synchronize' || + github.event.label.name == 'automerge' }} runs-on: ubuntu-latest + timeout-minutes: 30 environment: agent-standard permissions: contents: write @@ -1991,10 +2012,12 @@ jobs: - name: Checkout (for retry helpers) uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ github.event.repository.default_branch }} sparse-checkout: | .github/scripts/error_classifier.js .github/scripts/github-api-with-retry.js .github/scripts/runtime_ac_merge_guard.js + .github/scripts/source_context.js .github/scripts/token_load_balancer.js sparse-checkout-cone-mode: false - name: Merge labelled agent PRs (guarded) @@ -2006,7 +2029,16 @@ jobs: const fs = require('fs'); const label = 'automerge'; const retryPath = './.github/scripts/github-api-with-retry.js'; - const { assertRuntimeAcMergeAllowed } = require('./.github/scripts/runtime_ac_merge_guard.js'); + const { + GENERATED_DELIVERY_HOLD_LABEL, + assertRuntimeAcMergeAllowed, + runtimeAcRequirement, + } = require('./.github/scripts/runtime_ac_merge_guard.js'); + const { + extractClosingIssueNumbersFromText, + extractIssueNumberFromPull, + extractIssueNumbersFromText, + } = require('./.github/scripts/source_context.js'); const { createTokenAwareRetry } = fs.existsSync(retryPath) ? require(retryPath) : { @@ -2053,6 +2085,11 @@ jobs: // enables guardless native auto-merge. const placeholder = /^agents\/[a-z][a-z0-9_-]*-\d+\.md$/i; const okConclusions = ['success', 'neutral', 'skipped']; + const reviewWindowMs = 7 * 60 * 1000; + let reviewSleepBudgetMs = reviewWindowMs + 60 * 1000; + const reviewWindowMarker = '/i); - if (meta) return Number(meta[1]); - const closes = body.match(/\b(?:closes|fixes|resolves)\s+#(\d+)/i); - if (closes) return Number(closes[1]); - return null; + function inferIssue(pr) { + if (issueReferenceFailure(pr)) return 0; + return extractIssueNumberFromPull(pr || {}); + } + + function issueReferenceFailure(pr) { + const body = String(pr?.body || ''); + const metaIssueNumbers = new Set( + Array.from(body.matchAll(//gi), (match) => + Number.parseInt(match[1], 10) + ) + ); + if (metaIssueNumbers.size > 1) { + return 'Ambiguous linked issue references; refusing to merge blind.'; + } + if (metaIssueNumbers.size === 1) return ''; + const bodyIssueNumbers = extractIssueNumbersFromText(body); + if (bodyIssueNumbers.size <= 1) return ''; + const closingIssueNumbers = extractClosingIssueNumbersFromText(body); + return closingIssueNumbers.size === 1 + ? '' + : 'Ambiguous linked issue references; refusing to merge blind.'; + } + + async function linkedIssueTaskSnapshot(pr) { + const issueNumber = inferIssue(pr); + if (!issueNumber) { + return { issueNumber: 0, body: '', updatedAt: '', failure: '' }; + } + try { + const { data: linked } = await withRetry((client) => client.rest.issues.get({ + owner, + repo, + issue_number: issueNumber, + })); + const updatedAt = String(linked?.updated_at || ''); + if (!updatedAt) { + return { + issueNumber, + body: String(linked?.body || ''), + updatedAt: '', + failure: `Linked issue #${issueNumber} has no version; refusing to merge blind.`, + }; + } + return { + issueNumber, + body: String(linked?.body || ''), + updatedAt, + failure: '', + }; + } catch (error) { + return { + issueNumber, + body: '', + updatedAt: '', + failure: `Unable to read linked issue #${issueNumber}; refusing to merge blind: ${error.message || error}`, + }; + } + } + + function uncheckedTaskFailure(pr, linkedIssueSnapshot) { + const referenceFailure = issueReferenceFailure(pr); + if (referenceFailure) return referenceFailure; + let unchecked = countUnchecked(pr?.body); + const linkedIssue = inferIssue(pr); + if (linkedIssue) { + if ( + !linkedIssueSnapshot + || Number(linkedIssueSnapshot.issueNumber || 0) !== linkedIssue + ) { + return `Unable to bind linked issue #${linkedIssue}; refusing to merge blind.`; + } + if (linkedIssueSnapshot.failure) { + return linkedIssueSnapshot.failure; + } + unchecked += countUnchecked(linkedIssueSnapshot.body); + } + return unchecked > 0 + ? 'Refusing auto-merge: linked issue/PR has unchecked tasks.' + : ''; + } + + async function loadPullRequest(prNumber) { + let latest = null; + for (let attempt = 0; attempt < 2; attempt += 1) { + const { data: pr } = await withRetry((client) => client.rest.pulls.get({ + owner, + repo, + pull_number: prNumber, + })); + latest = pr; + const mergeableState = String(pr?.mergeable_state || '').toLowerCase(); + if (mergeableState && mergeableState !== 'unknown') { + return pr; + } + if (attempt === 0) { + await new Promise((resolve) => setTimeout(resolve, 2000)); + } + } + return latest; } - const issues = await paginateWithRetry(github.rest.issues.listForRepo, { + async function reviewWindowObservation(prNumber, headSha, { forceReset = false } = {}) { + try { + if (!authenticatedLogin) { + const { data: authenticated } = await withRetry((client) => { + return client.rest.users.getAuthenticated(); + }); + authenticatedLogin = String(authenticated?.login || '').toLowerCase(); + } + if (!authenticatedLogin) { + return { failure: 'Unable to identify the review-window state owner.' }; + } + + const comments = await paginateWithRetry(github.rest.issues.listComments, { + owner, + repo, + issue_number: prNumber, + per_page: 100, + }); + let latestHeadTransitionAtMs = 0; + let transitionCursor = null; + do { + const transitionResult = await withRetry((client) => client.graphql(` + query($owner: String!, $repo: String!, $prNumber: Int!, $cursor: String) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $prNumber) { + timelineItems( + first: 100, + after: $cursor, + itemTypes: [HEAD_REF_FORCE_PUSHED_EVENT] + ) { + nodes { + ... on HeadRefForcePushedEvent { + createdAt + afterCommit { oid } + } + } + pageInfo { hasNextPage endCursor } + } + } + } + } + `, { owner, repo, prNumber, cursor: transitionCursor })); + const transitions = transitionResult?.repository?.pullRequest?.timelineItems; + if (!transitions) { + return { failure: 'Unable to read persisted head transitions.' }; + } + for (const event of transitions.nodes || []) { + const transitionedHeadSha = String(event?.afterCommit?.oid || ''); + if (transitionedHeadSha !== headSha) continue; + const eventAtMs = Date.parse(event.createdAt || ''); + if (!Number.isFinite(eventAtMs)) { + return { failure: 'Persisted head-transition time is invalid.' }; + } + latestHeadTransitionAtMs = Math.max(latestHeadTransitionAtMs, eventAtMs); + } + transitionCursor = transitions.pageInfo?.hasNextPage + ? transitions.pageInfo.endCursor + : null; + } while (transitionCursor); + const stateComment = comments + .filter((comment) => { + const author = String(comment?.user?.login || '').toLowerCase(); + return author === authenticatedLogin + && String(comment?.body || '').includes(reviewWindowMarker); + }) + .sort((left, right) => Number(right.id || 0) - Number(left.id || 0))[0]; + const marker = `${reviewWindowMarker}head=${headSha} -->`; + if (!forceReset && stateComment && String(stateComment.body || '').includes(marker)) { + const observedAtMs = Date.parse( + stateComment.updated_at || stateComment.created_at || '' + ); + if (!Number.isFinite(observedAtMs)) { + return { failure: 'Stored review-window observation time is invalid.' }; + } + if (observedAtMs >= latestHeadTransitionAtMs) { + return { observedAtMs }; + } + } + + const body = [ + marker, + 'Automated exact-head review-window state. Each eligible merge wakeup re-observes the current head.', + ].join('\n'); + const response = stateComment + ? await withRetry((client) => client.rest.issues.updateComment({ + owner, + repo, + comment_id: stateComment.id, + body, + })) + : await withRetry((client) => client.rest.issues.createComment({ + owner, + repo, + issue_number: prNumber, + body, + })); + const observedAtMs = Date.parse( + response?.data?.updated_at || response?.data?.created_at || '' + ); + return Number.isFinite(observedAtMs) + ? { observedAtMs } + : { failure: 'Unable to persist the exact-head review-window start.' }; + } catch (error) { + return { + failure: `Unable to persist the exact-head review-window state: ${error.message || error}`, + }; + } + } + + function reviewObservationKey(prNumber, headSha) { + return `${prNumber}:${headSha}`; + } + + async function primeReviewWindows(candidateIssues) { + let maxRemainingMs = 0; + for (const issue of candidateIssues) { + if (!issue?.pull_request) continue; + const prNumber = Number(issue.number); + const pr = await loadPullRequest(prNumber); + const headSha = pr?.head?.sha || ''; + if (!headSha) continue; + const observationKey = reviewObservationKey(prNumber, headSha); + const observation = reviewWindowObservations.get(observationKey) + || await reviewWindowObservation(prNumber, headSha); + reviewWindowObservations.set(observationKey, observation); + if (Number.isFinite(observation.observedAtMs)) { + maxRemainingMs = Math.max( + maxRemainingMs, + reviewWindowMs - (Date.now() - observation.observedAtMs) + ); + } + } + + if (maxRemainingMs <= 0) return; + const requestedSleepMs = maxRemainingMs + 1000; + if (requestedSleepMs > reviewSleepBudgetMs) { + core.warning('Review-window wait exceeds the bounded run budget; deferring.'); + return; + } + reviewSleepBudgetMs -= requestedSleepMs; + core.info( + `Waiting ${Math.ceil(maxRemainingMs / 1000)}s for all exact-head review windows.` + ); + await new Promise((resolve) => setTimeout(resolve, requestedSleepMs)); + } + + async function activeReviewThreadFailure(prNumber) { + try { + let cursor = null; + let activeThreads = 0; + do { + const result = await withRetry((client) => client.graphql( + `query($owner: String!, $repo: String!, $number: Int!, $cursor: String) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + reviewThreads(first: 100, after: $cursor) { + nodes { isResolved isOutdated } + pageInfo { hasNextPage endCursor } + } + } + } + }`, + { owner, repo, number: prNumber, cursor } + )); + const threads = result?.repository?.pullRequest?.reviewThreads; + if (!threads) { + return 'Unable to read review threads; refusing to merge blind.'; + } + activeThreads += (threads.nodes || []).filter( + (thread) => !thread.isResolved && !thread.isOutdated + ).length; + cursor = threads.pageInfo?.hasNextPage ? threads.pageInfo.endCursor : null; + } while (cursor); + return activeThreads > 0 + ? `${activeThreads} active non-outdated review thread(s) remain.` + : ''; + } catch (error) { + return `Unable to read review threads: ${error.message || error}`; + } + } + + async function checkStateFailure(headSha) { + try { + const { data: combined } = await withRetry((client) => { + return client.rest.repos.getCombinedStatusForRef({ + owner, + repo, + ref: headSha, + }); + }); + if (!combined || combined.state !== 'success') { + const statusCount = Number(combined?.total_count ?? 0); + if (!combined || statusCount > 0 || combined.state !== 'pending') { + return 'Required status checks have not all succeeded.'; + } + } + const checkRuns = await paginateWithRetry( + github.rest.checks.listForRef, + { owner, repo, ref: headSha, per_page: 100 } + ); + if (Number(combined?.total_count || 0) === 0 && checkRuns.length === 0) { + return 'No commit statuses or check runs found.'; + } + for (const run of checkRuns) { + const conclusion = (run.conclusion || '').toLowerCase(); + const statusName = run.name || run.id || 'unknown check'; + if (!conclusion && run.status !== 'completed') { + return `${statusName} pending`; + } + if (conclusion && !okConclusions.includes(conclusion)) { + return `${statusName} concluded ${conclusion}`; + } + } + return ''; + } catch (error) { + return `Unable to revalidate checks: ${error.message || error}`; + } + } + + async function reviewGuardFailure(prNumber, pr) { + let guardedPr = pr; + const originalHead = pr.head?.sha || ''; + if (!originalHead) { + return { pr: guardedPr, failure: 'Missing head SHA for the review window.' }; + } + const observationKey = reviewObservationKey(prNumber, originalHead); + const observation = reviewWindowObservations.get(observationKey) + || await reviewWindowObservation(prNumber, originalHead); + if (observation.failure || !Number.isFinite(observation.observedAtMs)) { + return { + pr: guardedPr, + failure: observation.failure || 'Unable to establish the exact-head review window.', + }; + } + const remainingMs = reviewWindowMs - (Date.now() - observation.observedAtMs); + if (remainingMs > 0) { + const requestedSleepMs = remainingMs + 1000; + if (requestedSleepMs > reviewSleepBudgetMs) { + return { + pr: guardedPr, + failure: 'Review window not elapsed; deferring to a later run.', + }; + } + reviewSleepBudgetMs -= requestedSleepMs; + core.info(`Waiting ${Math.ceil(remainingMs / 1000)}s for the exact-head review window.`); + await new Promise((resolve) => setTimeout(resolve, requestedSleepMs)); + const refreshedPr = await loadPullRequest(prNumber); + const refreshedHead = refreshedPr?.head?.sha || ''; + if (!originalHead || refreshedHead !== originalHead) { + return { pr: refreshedPr || guardedPr, failure: 'Head changed during the review window.' }; + } + const refreshedMergeableState = String(refreshedPr.mergeable_state || '').toLowerCase(); + if (refreshedPr.draft || refreshedMergeableState !== 'clean') { + return { + pr: refreshedPr, + failure: `Post-window mergeable state ${refreshedMergeableState || '(unknown)'} is not clean.`, + }; + } + const refreshedLabels = (refreshedPr.labels || []).map( + (item) => String(item.name || '').toLowerCase() + ); + if (!refreshedLabels.includes(label)) { + return { + pr: refreshedPr, + failure: 'Post-window pull request no longer carries the automerge label.', + }; + } + guardedPr = refreshedPr; + } + + return { + pr: guardedPr, + failure: await activeReviewThreadFailure(prNumber), + }; + } + + async function finalMergeGuardFailure(prNumber, expectedHead) { + try { + const finalObservation = await reviewWindowObservation(prNumber, expectedHead); + if (finalObservation.failure || !Number.isFinite(finalObservation.observedAtMs)) { + return finalObservation.failure + || 'Unable to revalidate the exact-head review window before merge.'; + } + const finalReviewRemainingMs = reviewWindowMs + - (Date.now() - finalObservation.observedAtMs); + if (finalReviewRemainingMs > 0) { + return 'A newer head transition restarted the review window before merge.'; + } + + const finalPr = await loadPullRequest(prNumber); + if (!finalPr) { + return 'Unable to load the final pull request snapshot.'; + } + const finalHead = finalPr?.head?.sha || ''; + if (!finalHead || finalHead !== expectedHead) { + return 'Head changed before the merge request.'; + } + const { data: finalRepoInfo } = await withRetry((client) => { + return client.rest.repos.get({ owner, repo }); + }); + const finalDefaultBranch = finalRepoInfo?.default_branch || ''; + if (!finalDefaultBranch) { + return 'Repository default branch not available in the final snapshot.'; + } + const finalBase = finalPr?.base?.ref || ''; + if (!finalBase || finalBase !== finalDefaultBranch) { + return `Final base branch ${finalBase || '(unknown)'} does not match ${finalDefaultBranch}.`; + } + const finalMergeableState = String(finalPr.mergeable_state || '').toLowerCase(); + if (finalPr.draft || finalMergeableState !== 'clean') { + return `Final mergeable state ${finalMergeableState || '(unknown)'} is not clean.`; + } + const finalLabels = (finalPr.labels || []).map( + (item) => String(item.name || '').toLowerCase() + ); + if (!finalLabels.includes(label)) { + return 'Pull request no longer carries the automerge label.'; + } + if (finalLabels.includes(GENERATED_DELIVERY_HOLD_LABEL)) { + return `Generated delivery hold ${GENERATED_DELIVERY_HOLD_LABEL} was added before merge.`; + } + const finalRuntimeRequirement = runtimeAcRequirement(finalPr.labels || []); + if (finalRuntimeRequirement.required) { + return `Runtime acceptance label(s) ${finalRuntimeRequirement.labels.join(', ')} were added before merge.`; + } + const finalLinkedIssue = inferIssue(finalPr); + const finalLinkedIssueSnapshot = await linkedIssueTaskSnapshot(finalPr); + const finalTaskFailure = uncheckedTaskFailure(finalPr, finalLinkedIssueSnapshot); + if (finalTaskFailure) { + return finalTaskFailure; + } + const finalCheckFailure = await checkStateFailure(expectedHead); + if (finalCheckFailure) { + return `Final check-state validation failed: ${finalCheckFailure}`; + } + const finalThreadFailure = await activeReviewThreadFailure(prNumber); + if (finalThreadFailure) { + return finalThreadFailure; + } + + // Bind both mutable target snapshots at the last network boundary. + // The merge API binds the head SHA but does not bind the base branch. + const [ + mergeBoundaryPr, + mergeBoundaryRepoResponse, + mergeBoundaryLinkedIssueSnapshot, + mergeBoundaryCheckFailure, + mergeBoundaryThreadFailure, + ] = await Promise.all([ + loadPullRequest(prNumber), + withRetry((client) => client.rest.repos.get({ owner, repo })), + finalLinkedIssue + ? linkedIssueTaskSnapshot(finalPr) + : Promise.resolve({ issueNumber: 0, body: '', updatedAt: '', failure: '' }), + checkStateFailure(expectedHead), + activeReviewThreadFailure(prNumber), + ]); + if (!mergeBoundaryPr) { + return 'Unable to load the merge-boundary pull request snapshot.'; + } + if (mergeBoundaryCheckFailure) { + return `Merge-boundary check-state validation failed: ${mergeBoundaryCheckFailure}`; + } + if (mergeBoundaryThreadFailure) { + return mergeBoundaryThreadFailure; + } + const mergeBoundaryHead = mergeBoundaryPr?.head?.sha || ''; + if (!mergeBoundaryHead || mergeBoundaryHead !== expectedHead) { + return 'Head changed at the final merge boundary.'; + } + const mergeBoundaryDefaultBranch = + mergeBoundaryRepoResponse?.data?.default_branch || ''; + if (!mergeBoundaryDefaultBranch) { + return 'Repository default branch not available at the final merge boundary.'; + } + const mergeBoundaryBase = mergeBoundaryPr?.base?.ref || ''; + if (!mergeBoundaryBase || mergeBoundaryBase !== mergeBoundaryDefaultBranch) { + return `Merge-boundary base ${mergeBoundaryBase || '(unknown)'} does not match ${mergeBoundaryDefaultBranch}.`; + } + const mergeBoundaryMergeableState = String( + mergeBoundaryPr.mergeable_state || '' + ).toLowerCase(); + if (mergeBoundaryPr.draft || mergeBoundaryMergeableState !== 'clean') { + return `Merge-boundary state ${mergeBoundaryMergeableState || '(unknown)'} is not clean.`; + } + const mergeBoundaryLabels = (mergeBoundaryPr.labels || []).map( + (item) => String(item.name || '').toLowerCase() + ); + if (!mergeBoundaryLabels.includes(label)) { + return 'Pull request lost the automerge label at the final merge boundary.'; + } + if (mergeBoundaryLabels.includes(GENERATED_DELIVERY_HOLD_LABEL)) { + return `Generated delivery hold ${GENERATED_DELIVERY_HOLD_LABEL} was added at the final merge boundary.`; + } + const mergeBoundaryRuntimeRequirement = runtimeAcRequirement( + mergeBoundaryPr.labels || [] + ); + if (mergeBoundaryRuntimeRequirement.required) { + return `Runtime acceptance label(s) ${mergeBoundaryRuntimeRequirement.labels.join(', ')} were added at the final merge boundary.`; + } + const mergeBoundaryLinkedIssue = inferIssue(mergeBoundaryPr); + if (mergeBoundaryLinkedIssue !== finalLinkedIssue) { + return 'Linked issue changed at the final merge boundary.'; + } + const mergeBoundaryTaskFailure = uncheckedTaskFailure( + mergeBoundaryPr, + mergeBoundaryLinkedIssueSnapshot + ); + if (mergeBoundaryTaskFailure) { + return `Final merge-boundary task validation failed: ${mergeBoundaryTaskFailure}`; + } + if ( + mergeBoundaryLinkedIssueSnapshot.body !== finalLinkedIssueSnapshot.body + || mergeBoundaryLinkedIssueSnapshot.updatedAt !== finalLinkedIssueSnapshot.updatedAt + ) { + return 'Linked issue changed during final merge validation.'; + } + return ''; + } catch (error) { + return `Unable to complete final merge guard: ${error.message || error}`; + } + } + + const listedIssues = await paginateWithRetry(github.rest.issues.listForRepo, { owner, repo, state: 'open', labels: label, per_page: 100, }); + const triggeringPrNumber = Number( + context.payload.pull_request?.number + || context.payload.workflow_run?.pull_requests?.[0]?.number + || context.payload.inputs?.pr_number + || 0 + ); + if (triggeringPrNumber <= 0) { + core.warning('No triggering PR number; refusing a repository-wide merge scan.'); + } + const issues = listedIssues.filter( + (issue) => Number(issue.number) === triggeringPrNumber + ); const rows = []; const merged = []; @@ -2096,13 +2661,35 @@ jobs: return; } + // Every merge-capable wakeup establishes a fresh observation before it can + // consume review-window state. This deliberately does not rely on a pending + // synchronize run: GitHub may replace that pending run when another event + // enters the same cancel-in-progress:false concurrency group. + const triggeringPr = await loadPullRequest(triggeringPrNumber); + const triggeringHeadSha = triggeringPr?.head?.sha || ''; + if (!triggeringHeadSha) { + core.setFailed('Unable to establish the triggering exact head.'); + return; + } + const triggeringObservation = await reviewWindowObservation( + triggeringPrNumber, + triggeringHeadSha, + { forceReset: true } + ); + reviewWindowObservations.set( + reviewObservationKey(triggeringPrNumber, triggeringHeadSha), + triggeringObservation + ); + + await primeReviewWindows(issues); + for (const issue of issues) { if (!issue || !issue.pull_request) continue; const prNumber = Number(issue.number); let note = ''; let status = 'skipped'; try { - const { data: pr } = await withRetry((client) => client.rest.pulls.get({ owner, repo, pull_number: prNumber })); + let pr = await loadPullRequest(prNumber); if (!pr) { note = 'Unable to load pull request data.'; } else { @@ -2118,40 +2705,20 @@ jobs: note = `Base branch ${baseRef} does not match ${defaultBranch}.`; } else if (pr.draft) { note = 'Draft pull requests are not eligible for auto-merge.'; - } else if (['blocked', 'dirty', 'draft'].includes(mergeableState)) { - note = `Mergeable state ${mergeableState} prevents auto-merge.`; + } else if (mergeableState !== 'clean') { + note = `Mergeable state ${mergeableState || '(unknown)'} is not clean.`; } else { const headSha = (pr.head && pr.head.sha) || ''; if (!headSha) { note = 'Missing head SHA for pull request.'; } else { - const { data: combined } = await withRetry((client) => client.rest.repos.getCombinedStatusForRef({ owner, repo, ref: headSha })); - if (!combined || combined.state !== 'success') { - note = 'Required status checks have not all succeeded.'; + const reviewGuard = await reviewGuardFailure(prNumber, pr); + pr = reviewGuard.pr; + if (reviewGuard.failure) { + note = reviewGuard.failure; } else { - let checksOk = true; - let failingCheck = ''; - try { - const { data: checks } = await withRetry((client) => client.rest.checks.listForRef({ owner, repo, ref: headSha, per_page: 100 })); - const checkRuns = Array.isArray(checks.check_runs) ? checks.check_runs : []; - for (const run of checkRuns) { - const conclusion = (run.conclusion || '').toLowerCase(); - const statusName = run.name || run.id || 'unknown check'; - if (!conclusion && run.status !== 'completed') { - checksOk = false; - failingCheck = `${statusName} pending`; - break; - } - if (conclusion && !okConclusions.includes(conclusion)) { - checksOk = false; - failingCheck = `${statusName} concluded ${conclusion}`; - break; - } - } - } catch (error) { - checksOk = false; - failingCheck = `Unable to list check runs: ${error.message || error}`; - } + let failingCheck = await checkStateFailure(headSha); + let checksOk = !failingCheck; // Bootstrap-only refusal (mirrors belt conveyor + reusable-70 sweep). let bootstrapOnly = false; @@ -2167,27 +2734,18 @@ jobs: // Unchecked-task refusal: inspect the PR body and, when resolvable, // the linked issue body. Either carrying an open "- [ ]" blocks merge. - let tasksUnchecked = false; + let taskFailure = ''; if (checksOk && !bootstrapOnly) { - let unchecked = countUnchecked(pr.body); - const inferred = inferIssue(pr.body); - if (inferred) { - try { - const { data: linked } = await withRetry((client) => client.rest.issues.get({ owner, repo, issue_number: inferred })); - unchecked += countUnchecked(linked.body); - } catch (error) { - core.warning(`Unable to read linked issue #${inferred} for PR #${prNumber}: ${error.message || error}`); - } - } - tasksUnchecked = unchecked > 0; + const linkedIssueSnapshot = await linkedIssueTaskSnapshot(pr); + taskFailure = uncheckedTaskFailure(pr, linkedIssueSnapshot); } if (!checksOk) { note = failingCheck || 'Checks have not completed successfully.'; } else if (bootstrapOnly) { note = failingCheck || 'Refusing auto-merge: bootstrap-only placeholder PR (no work landed).'; - } else if (tasksUnchecked) { - note = 'Refusing auto-merge: linked issue/PR has unchecked tasks.'; + } else if (taskFailure) { + note = taskFailure; } else { try { await assertRuntimeAcMergeAllowed({ @@ -2199,11 +2757,42 @@ jobs: withRetry, source: 'agents-81-gate-followups guarded merge', }); - const response = await withRetry((client) => client.rest.pulls.merge({ owner, repo, pull_number: prNumber, merge_method: 'squash' })); + const finalMergeFailure = await finalMergeGuardFailure(prNumber, headSha); + if (finalMergeFailure) { + throw new Error(finalMergeFailure); + } + const response = await withRetry((client) => client.rest.pulls.merge({ + owner, + repo, + pull_number: prNumber, + merge_method: 'squash', + sha: headSha, + })); if (response && response.data && response.data.merged) { status = 'merged'; note = `Merged via ${response.data.merge_method || 'squash'}.`; merged.push(prNumber); + let linkedIssue = 0; + try { + const cleanupPr = await loadPullRequest(prNumber); + linkedIssue = inferIssue(cleanupPr); + } catch (error) { + core.warning(`Merged PR #${prNumber}, but could not reload its final linked issue: ${error.message || error}`); + } + if (linkedIssue) { + try { + await withRetry((client) => client.rest.issues.removeLabel({ + owner, + repo, + issue_number: linkedIssue, + name: 'status:in-progress', + })); + } catch (error) { + if (error.status !== 404) { + core.warning(`Merged PR #${prNumber}, but could not clear status:in-progress from issue #${linkedIssue}: ${error.message || error}`); + } + } + } } else { status = 'error'; note = 'Merge API returned an unexpected response.'; diff --git a/templates/consumer-repo/docs/CI_SYSTEM_GUIDE.md b/templates/consumer-repo/docs/CI_SYSTEM_GUIDE.md index 545dd8947..8b004c92f 100644 --- a/templates/consumer-repo/docs/CI_SYSTEM_GUIDE.md +++ b/templates/consumer-repo/docs/CI_SYSTEM_GUIDE.md @@ -57,23 +57,40 @@ provides: ### Agent Automation System -The Workflows repo includes a sophisticated agent automation system: +The Workflows repo provides the shared implementations behind these consumer entry points: | Component | Purpose | |-----------|---------| -| **Agents 63 Issue Intake** | Converts labeled issues into agent work items | -| **Agents 70 Orchestrator** | Central control for readiness, bootstrap, keepalive | -| **Agents 71-73 Codex Belt** | Dispatcher → Worker → Conveyor pipeline for PRs | -| **Keepalive System** | Monitors stalled agent PRs and nudges them | +| **Agents Issue Intake** (`agents-issue-intake.yml`) | Thin caller that forwards syntactically valid assignment labels to the shared issue bridge | +| **Agents 71 Dispatcher + Agents 72 Worker wrapper** (`agents-71-codex-belt-dispatcher.yml`, `agents-72-codex-belt-worker-dispatch.yml`) | Auto-pilot issue queue and bounded worker dispatch | +| **Agents 80 PR Event Hub** (`agents-80-pr-event-hub.yml`) | Consolidates PR, comment, and Gate events for PR metadata and follow-ups | +| **Agents 81 Gate Followups** (`agents-81-gate-followups.yml`) | Owns consumer keepalive evaluation, supported runner dispatch, and guarded post-Gate delivery | +| **Verifier** (`agents-verifier.yml`) | Runs explicit post-merge evaluation and comparison lanes | | **Autofix** | Automatic formatting fixes on PRs | ### Key Features - **Readiness probes**: Validates agent availability before work -- **Bootstrap**: Creates branches and PRs from labeled issues -- **Keepalive**: Monitors agent PRs and posts reminder comments -- **Conveyor**: Auto-merges successful PRs and cleans up -- **Watchdog**: Detects stalled automation +- **Bootstrap**: Creates ready-for-review branches and PRs from labeled issues +- **Keepalive**: Evaluates the current Gate/task state and dispatches bounded supported-agent follow-ups +- **Guarded closeout**: Merges only after the unchanged exact head passes checks, review-thread, review-window, and merge-state gates + +Agents 81 fails closed unless the PR is ready, targets the default branch, has a +`clean` merge state, has held the same head for at least seven minutes, has no +active non-outdated review threads, and has successful statuses and check runs. +Every merge-capable wakeup first resets the current head's durable observation, +so a queued `synchronize` run that GitHub replaces cannot expose an older marker +for the same head. This may conservatively extend the wait but cannot shorten it. +If Gate completes before the window expires, the merge job waits only for the +remaining interval and then re-fetches the PR; a changed head restarts the +normal Gate path. Immediately before merging, it rechecks persisted head-transition +events and restarts the window if the same SHA returned during the wait. That lookup +runs before the final PR, check, task, and review-thread snapshots so its pagination +cannot make those live guards stale. The merge request is bound to the validated head SHA. Branch +protection remains responsible for any required approving-review policy. +Privileged label and synchronize wakeups use `pull_request_target`, so GitHub loads +the workflow from the trusted default branch; the merge job never checks out or +executes pull-request-head code. --- @@ -128,19 +145,26 @@ Issue created → agent:codex label added ↓ Issue Intake validates ↓ - Bootstrap creates branch + PR + Bootstrap creates ready branch + PR ↓ Agent works on the code ↓ CI runs on changes ↓ - ┌─────────────────┴─────────────────┐ - │ │ - CI passes CI fails - │ │ -Conveyor merges Keepalive nudges agent + Agents 81 evaluates Gate + ↓ + ┌───────────┴───────────┐ + │ │ + tasks complete work or repair remains + │ │ + guarded exact-head closer supported runner dispatch ``` +The retired consumer orchestrator and automatic conveyor are not local entry points. Agents 73 has +no local caller in the current consumer topology. Agents 81 owns guarded delivery and clears the +linked issue's `status:in-progress` label after a successful merge; it does not merge merely because +CI is green. + --- ## Troubleshooting diff --git a/templates/consumer-repo/docs/LABELS.md b/templates/consumer-repo/docs/LABELS.md index 40edd5aef..afedafe94 100644 --- a/templates/consumer-repo/docs/LABELS.md +++ b/templates/consumer-repo/docs/LABELS.md @@ -10,11 +10,11 @@ This document describes all labels that trigger automated workflows or affect CI | `autofix:clean` | PR labeled | Triggers clean-mode autofix (more aggressive) | `agent:codex` | Issue or PR labeled | Routes the issue or PR to the Codex agent | `agent:claude` | Issue or PR labeled | Routes the issue or PR to the Claude Code agent -| `agent:cursor` | Issue or PR labeled | Routes the issue or PR to the Cursor agent (`cursor-agent` CLI) -| `agent:gemini` | Issue or PR labeled | Routes the issue or PR to the Gemini agent (`gemini` CLI) +| `agent:cursor` | Issue or PR labeled | Registered routing label; no consumer Gate-followup runner is currently wired +| `agent:gemini` | Issue or PR labeled | Registered routing label; no consumer Gate-followup runner is currently wired | `agent:aider` | Issue or PR labeled | Routes the issue or PR to the Aider agent for cheap, low-complexity tasks — runner lands in a follow-up phase | `agent:auto` | Issue or PR labeled | Delegates routing to the auto-delegation policy; do not combine with concrete `agent:` labels -| `agent:retry` | PR labeled | Requests one re-dispatch of the matching keepalive runner +| `agent:retry` | PR labeled | Consolidated consumers require a manual Gate-followups dispatch; the root/non-consolidated keepalive workflow forces a retry and clears recovery labels | `agent:rate-limited` | Auto-applied | Marks a PR as backing off from a rate-limit failure | ~~`agent:codex-invite`~~ | *(deprecated)* | No workflow, script, or tool references this label by name; the generic `agent:-invite` mechanism in `reusable-agents-issue-bridge.yml` still works but this specific label is unmaintained — see detail section below | `agent:needs-attention` | Auto-applied | Indicates agent needs human intervention @@ -100,13 +100,13 @@ This document describes all labels that trigger automated workflows or affect CI 2. Validates that a valid agent assignee is present 3. If validated, enables automated code generation for the issue 4. Creates a `codex/issue-` branch for agent work -5. On PRs, routes keepalive dispatch to `reusable-codex-run.yml` per `.github/agents/registry.yml` +5. On PRs, routes keepalive evaluation through the local sweep and the registry-backed shared runner **Prerequisites:** - Issue must have a valid agent assignee (configured in repository settings) - Issue should have clear requirements in the description -**Workflow:** `agents-63-issue-intake.yml` (Agents 63 Issue Intake); on PRs, `agents-keepalive-loop.yml` routes work via `.github/agents/registry.yml`. +**Workflow:** `agents-issue-intake.yml` (Agents Issue Intake); on PRs, `agents-keepalive-sweep.yml` routes evaluation to the shared registry-backed implementation. --- @@ -128,7 +128,7 @@ This document describes all labels that trigger automated workflows or affect CI **Lifecycle:** Applied at issue claim / PR creation by an opener that already has Claude capacity. Removed when work completes (PR merged or issue closed). Co-applied with `agents:keepalive` on the PR so keepalive is enabled. -**Workflow:** `agents-bot-comment-handler.yml`, `agents-auto-label.yml`, `agents-keepalive-loop.yml`, `agents-guard.yml`, `reusable-pr-context.yml`; runner is `reusable-claude-run.yml` per `.github/agents/registry.yml`. +**Workflow:** `agents-auto-label.yml`, `agents-81-gate-followups.yml`, `agents-guard.yml`, `reusable-pr-context.yml`; runner is `reusable-claude-run.yml` per `.github/agents/registry.yml`. --- @@ -139,15 +139,15 @@ This document describes all labels that trigger automated workflows or affect CI **Trigger:** When applied to an issue or PR **Effect:** -1. Routes the issue or PR to the Cursor agent, a parallel surface to `agent:codex` and `agent:claude` -2. On PRs, keepalive dispatches work via `reusable-cursor-run.yml` per `.github/agents/registry.yml` -3. Branch prefix `cursor/issue-` is used for agent work (see `.github/agents/registry.yml`) +1. Identifies Cursor as the intended route in registry-aware automation +2. Does **not** dispatch a consumer PR keepalive runner: `agents-81-gate-followups.yml` currently has runner jobs only for Codex and Claude +3. Branch prefix `cursor/issue-` is reserved for Cursor work (see `.github/agents/registry.yml`) **Prerequisites:** - Repository has a valid `CURSOR_API_KEY` secret (per `.github/agents/registry.yml`) - Issue or PR should have clear requirements -**Workflow:** `agents-keepalive-loop.yml`, `agents-autofix-loop.yml`; runner is `reusable-cursor-run.yml` per `.github/agents/registry.yml`. +**Workflow:** Registry-aware workflows may recognize the label, but the consumer Gate-followup workflow has no Cursor runner job. Do not use this label to promise PR keepalive execution until that job is delivered from the canonical Workflows source. --- @@ -158,14 +158,14 @@ This document describes all labels that trigger automated workflows or affect CI **Trigger:** When applied to an issue or PR **Effect:** -1. Routes the issue or PR to the Gemini agent, a parallel surface to `agent:codex`/`agent:claude`/`agent:cursor` -2. On PRs, keepalive dispatches work via `reusable-gemini-run.yml` per `.github/agents/registry.yml` -3. Branch prefix `gemini/issue-` is used for agent work +1. Identifies Gemini as the intended route in registry-aware automation +2. Does **not** dispatch a consumer PR keepalive runner: `agents-81-gate-followups.yml` currently has runner jobs only for Codex and Claude +3. Branch prefix `gemini/issue-` is reserved for Gemini work **Prerequisites:** - Repository has a valid `GEMINI_API_KEY` secret (per `.github/agents/registry.yml`) -**Workflow:** `agents-keepalive-loop.yml`; runner is `reusable-gemini-run.yml` per `.github/agents/registry.yml`. +**Workflow:** Registry-aware workflows may recognize the label, but the consumer Gate-followup workflow has no Gemini runner job. Do not use this label to promise PR keepalive execution until that job is delivered from the canonical Workflows source. --- @@ -206,20 +206,23 @@ runner and registry entry ship, applying this label will not dispatch a runner. **Applies to:** Pull Requests -**Trigger:** When applied to (or re-applied to) a PR +**Trigger:** Topology-dependent: + +- Consolidated consumer (`agents-81-gate-followups.yml`): the label is a recovery marker and does not trigger a retry by itself +- Root/non-consolidated (`agents-keepalive-loop.yml`): a `pull_request:labeled` event triggers a forced retry **Effect:** -1. Signals keepalive to force a re-dispatch of the matching runner on its next tick -2. Removed by `agents-keepalive-loop.yml` at the top of the resulting run so the label is reusable -3. Co-removed: any stale `agent:rate-limited` label is also removed at the same time +1. Records that a retry was requested in both topologies +2. Consolidated consumer: does not set `force_retry` or remove recovery labels merely by being applied +3. Root/non-consolidated: sets `force_retry=true` and attempts to remove both `agent:retry` and `agent:rate-limited` **Prerequisites:** - PR has a concrete `agent:codex` or `agent:claude` label so a runner can be re-dispatched - PR has `agents:keepalive` -**Lifecycle:** Applied by `agents-auto-pilot.yml` on dispatch failure handling, by openers/closers during quick recovery, or manually to force one keepalive re-run. Consumed and cleaned by `agents-keepalive-loop.yml`. +**Recovery:** In a consolidated consumer, force a bounded retry by manually dispatching `agents-81-gate-followups.yml` with `pr_number=` and `force_retry=true`; after the run is confirmed, remove stale recovery labels manually if they remain. In the root/non-consolidated topology, applying `agent:retry` invokes the label handler automatically. -**Workflow:** Applied by `agents-auto-pilot.yml`; consumed/cleaned by `agents-keepalive-loop.yml`. +**Workflow:** `agents-81-gate-followups.yml` accepts the explicit `force_retry` dispatch input for consolidated consumers. `.github/workflows/agents-keepalive-loop.yml` implements label-driven retry and cleanup for the root/non-consolidated topology. --- @@ -232,14 +235,14 @@ runner and registry entry ship, applying this label will not dispatch a runner. **Effect:** 1. Marks the PR as currently backed off due to API/runner rate limits 2. Used with the matching concrete `agent:` label to flag backoff for the current route; switch to `agent:auto` only after removing the concrete routing label -3. Removed by `agents-keepalive-loop.yml` during the `agent:retry` labeled run, before keepalive evaluation +3. Remains an observability/backoff marker until automation or an operator explicitly removes it **Prerequisites:** - Applied automatically; no manual action required -**Lifecycle:** Applied by `agents-auto-pilot.yml` when a dispatch hits a rate limit. Cleaned by the retry-label handler in `agents-keepalive-loop.yml` when `agent:retry` is processed. Does not by itself trigger a runner. +**Lifecycle:** Applied when a dispatch hits a rate limit. It does not by itself trigger a runner, and the current consumer Gate-followup wrapper does not promise label cleanup. After a successful forced retry, remove it manually if it remains. -**Workflow:** Applied by `agents-auto-pilot.yml`; consumed/cleaned by `agents-keepalive-loop.yml`. +**Workflow:** Producer workflows apply the marker; recovery uses an explicit `agents-81-gate-followups.yml` dispatch with `force_retry=true`. --- @@ -306,7 +309,9 @@ runner and registry entry ship, applying this label will not dispatch a runner. 2. Used in conjunction with `agent:codex` to signal readiness 3. May trigger the next step in the agent automation pipeline -**Workflow:** `agents-70-orchestrator.yml` (Agents 70 Orchestrator) +**Workflow:** `agents-issue-intake.yml` handles ordinary agent intake. The +separate `agents:auto-pilot` route dispatches Agents 71 and the Agents 72 +wrapper when end-to-end automation is requested. --- @@ -529,7 +534,7 @@ These labels trigger the post-merge verifier workflow on a merged PR. **To Resume:** Remove the `agents:paused` label. -**Workflow:** `agents-keepalive-loop.yml` +**Workflow:** `agents-81-gate-followups.yml` --- @@ -548,7 +553,7 @@ These labels trigger the post-merge verifier workflow on a merged PR. - PR must have an `agent:*` label - Gate workflow must pass -**Workflow:** `agents-keepalive-loop.yml` +**Workflow:** `agents-81-gate-followups.yml` --- @@ -645,9 +650,8 @@ Prefixed labels such as `verify:runtime-ac` are treated the same as **Consumers:** `.github/scripts/runtime_ac_merge_guard.js`, `.github/workflows/agents-73-codex-belt-conveyor.yml`, -`.github/workflows/reusable-70-orchestrator-main.yml`, -`.github/workflows/maint-71-merge-sync-prs.yml`, -`templates/consumer-repo/.github/workflows/agents-81-gate-followups.yml`. +`.github/workflows/agents-81-gate-followups.yml`. Agents 73 is callable-only; +Agents 81 is the active local guarded merge route. --- @@ -664,7 +668,8 @@ Prefixed labels such as `verify:runtime-ac` are treated the same as **Consumers:** `.github/scripts/keepalive_loop.js`, `.github/scripts/merge_manager.js`, -`.github/workflows/reusable-70-orchestrator-main.yml`. +`.github/workflows/agents-auto-pilot.yml`, +`.github/workflows/agents-81-gate-followups.yml`. --- @@ -676,11 +681,13 @@ Prefixed labels such as `verify:runtime-ac` are treated the same as **Effect:** 1. Records that an issue has active belt work in progress. -2. Is removed by the conveyor after the active work hands off or completes. +2. Is cleared by Agents 81 after it successfully merges the linked completed PR. +3. Is also cleared by Agents 73 when that callable-only recovery path is invoked. **Consumers:** `.github/workflows/agents-71-codex-belt-dispatcher.yml`, `.github/workflows/agents-72-codex-belt-worker.yml`, -`.github/workflows/agents-73-codex-belt-conveyor.yml`. +`.github/workflows/agents-81-gate-followups.yml`, and the callable-only +`.github/workflows/agents-73-codex-belt-conveyor.yml` recovery component. --- @@ -748,9 +755,9 @@ These labels are used for categorization but do not trigger workflows. | (none) | `agent:auto` | Delegates routing to `agent_delegation_policy.js` | `agent:codex` | `agent:auto` | Invalid mixed routing; remove `agent:codex` before using `agent:auto` | `agent:claude` | `agent:auto` | Invalid mixed routing; remove `agent:claude` before using `agent:auto` -| `agent:` + `agents:keepalive` | `agent:retry` | Forces one re-dispatch; keepalive removes the label at the top of its run -| `agent:retry` | (removed by `agents-keepalive-loop.yml`) | Co-removes any stale `agent:rate-limited` -| `agent:rate-limited` | `agent:retry` | Retry-label handler removes stale `agent:rate-limited` before keepalive evaluation +| `agent:` + `agents:keepalive` | `agent:retry` | Consolidated: records a recovery request; root/non-consolidated: forces the keepalive retry and cleanup handler +| `agent:retry` | Manual Gate-followups dispatch | Consolidated only: forces the retry and does not imply automatic label cleanup +| `agent:rate-limited` | Successful forced retry | Remove stale recovery labels manually if automation leaves them behind | `agent:codex` | `agent:codex-invite` | Selects issue-bridge `invite` mode for `agent:codex` when `force_mode: false`; with the default forced input mode, the requested mode still wins | `agent:codex` | `status:ready` | Agent begins processing | `agent:needs-attention` | (removed) | Agent resumes processing diff --git a/templates/consumer-repo/docs/SETUP_CHECKLIST.md b/templates/consumer-repo/docs/SETUP_CHECKLIST.md index 1da1b8fa3..4f92b5dea 100644 --- a/templates/consumer-repo/docs/SETUP_CHECKLIST.md +++ b/templates/consumer-repo/docs/SETUP_CHECKLIST.md @@ -170,7 +170,7 @@ Create these labels in **Settings** → **Labels** (exact names required): | Label | Color | Description | Required For | |-------|-------|-------------|--------------| | `agent:codex` | `#0052CC` | Assigns Codex agent to issue | Issue intake, keepalive | -| `agent:retry` | `#D93F0B` | Retries keepalive loop for agent PRs | Keepalive recovery | +| `agent:retry` | `#D93F0B` | Optional recovery-request marker; does not trigger a retry by itself | Operator visibility | | `agent:needs-attention` | `#D93F0B` | Agent needs human help | Error recovery | | `agents:keepalive` | `#0E8A16` | Enables keepalive automation | PR keepalive loops | | `agents:auto-pilot` | `#0052CC` | Triggers end-to-end auto-pilot pipeline | Issue automation | @@ -212,7 +212,7 @@ REPO="stranske/" # Create required labels gh label create "agent:codex" --color "0052CC" --description "Assigns Codex agent" --repo "$REPO" 2>/dev/null || echo "agent:codex exists" -gh label create "agent:retry" --color "D93F0B" --description "Retries keepalive loop" --repo "$REPO" 2>/dev/null || echo "agent:retry exists" +gh label create "agent:retry" --color "D93F0B" --description "Marks a requested keepalive recovery" --repo "$REPO" 2>/dev/null || echo "agent:retry exists" gh label create "agent:needs-attention" --color "D93F0B" --description "Agent needs human help" --repo "$REPO" 2>/dev/null || echo "agent:needs-attention exists" gh label create "agents:keepalive" --color "0E8A16" --description "Enables keepalive automation" --repo "$REPO" 2>/dev/null || echo "agents:keepalive exists" gh label create "agents:auto-pilot" --color "0052CC" --description "Runs full auto-pilot issue pipeline" --repo "$REPO" 2>/dev/null || echo "agents:auto-pilot exists" @@ -496,7 +496,7 @@ entry points used by this repository: | Workflow | Purpose | Critical for Keepalive | |----------|---------|------------------------| | `pr-00-gate.yml` | Required CI and exact-head enforcement | **YES** | -| `agents-issue-intake.yml` | Registered-label and manual agent intake | No | +| `agents-issue-intake.yml` | Assignment-label and manual agent intake | No | | `agents-auto-pilot.yml` | End-to-end issue automation | No | | `agents-71-codex-belt-dispatcher.yml` | Selects queued Codex work | No | | `agents-72-codex-belt-worker-dispatch.yml` | Dispatches the callable worker | No | @@ -786,6 +786,9 @@ tasks are complete or the iteration limit is reached. **Verification checklist**: - [ ] `agents-80-pr-event-hub.yml` exists with PR event triggers - [ ] `agents-81-gate-followups.yml` listens for Gate completion +- [ ] Repository variable `USE_CONSOLIDATED_WORKFLOWS` is `true` so Agents 81 + evaluates automatic Gate follow-ups (`workflow_dispatch` intentionally + bypasses this variable for manual recovery) - [ ] `agents-keepalive-sweep.yml` exists with a schedule trigger - [ ] `.github/codex/AGENT_INSTRUCTIONS.md` exists - [ ] `.github/codex/prompts/keepalive_next_task.md` exists @@ -801,6 +804,9 @@ tasks are complete or the iteration limit is reached. **Troubleshooting**: - "gate-not-concluded": Gate has not finished; wait or inspect the Gate workflow - "head changed": Restart the exact-head review and Gate window +- Gate completes but no follow-up: Confirm `USE_CONSOLIDATED_WORKFLOWS` is + `true`; a false or missing value skips automatic Agents 81 evaluation, while + `workflow_dispatch` still permits a manual recovery run - Missing codex files: Add from `templates/consumer-repo/.github/codex/` --- @@ -811,6 +817,7 @@ tasks are complete or the iteration limit is reached. when the `autofix` or `autofix:clean` label is added to a PR. **Workflows involved**: + | Workflow | Role | |----------|------| | `autofix.yml` | Thin caller that triggers on label, delegates to reusable workflow | @@ -1020,7 +1027,7 @@ Autofix can repair a labeled PR before Gate is evaluated again. | Symptom | Cause | Fix | |---------|-------|-----| | "Module not found" errors | Missing JS scripts | Add scripts from template | -| Gate completes but no follow-up | Agents 81 did not receive the Gate run | Inspect its `workflow_run` trigger and summary | +| Gate completes but no follow-up | `USE_CONSOLIDATED_WORKFLOWS` is not `true`, so Agents 81 skipped the automatic Gate run | Set the repository variable to `true`, then inspect its `workflow_run` trigger and summary; `workflow_dispatch` bypasses the variable for manual recovery | | Follow-up reports `head changed` | The PR moved after Gate ran | Restart review and Gate on the new exact head | | Follow-up reports `gate-not-concluded` | Gate is still running | Wait for the exact-head Gate conclusion | diff --git a/tests/docs/test_consumer_ci_system_guide.py b/tests/docs/test_consumer_ci_system_guide.py new file mode 100644 index 000000000..53efe7d5b --- /dev/null +++ b/tests/docs/test_consumer_ci_system_guide.py @@ -0,0 +1,54 @@ +from pathlib import Path + +GUIDE = Path("templates/consumer-repo/docs/CI_SYSTEM_GUIDE.md") + + +def test_consumer_ci_guide_matches_current_agent_entrypoints() -> None: + text = GUIDE.read_text(encoding="utf-8") + assert text.count("agents-80-pr-event-hub.yml") == 1 + + for current_surface in ( + "agents-issue-intake.yml", + "agents-71-codex-belt-dispatcher.yml", + "agents-72-codex-belt-worker-dispatch.yml", + "agents-80-pr-event-hub.yml", + "agents-81-gate-followups.yml", + "agents-verifier.yml", + ): + assert current_surface in text + + for retired_claim in ( + "Agents 63 Issue Intake", + "Agents 70 Orchestrator", + "Agents 71-73 Codex Belt", + "Conveyor merges", + ): + assert retired_claim not in text + + assert "Bootstrap creates ready branch + PR" in text + assert "guarded exact-head closer" in text + assert "same head for at least seven minutes" in text + assert "active non-outdated review threads" in text + assert "bound to the validated head SHA" in text + + +def test_consumer_operator_docs_match_gate_followup_topology() -> None: + labels = Path("docs/LABELS.md").read_text(encoding="utf-8") + template_labels = Path("templates/consumer-repo/docs/LABELS.md").read_text(encoding="utf-8") + setup = Path("templates/consumer-repo/docs/SETUP_CHECKLIST.md").read_text(encoding="utf-8") + + assert labels == template_labels + for retired_surface in ( + "agents-63-issue-intake.yml", + "agents-70-orchestrator.yml", + ): + assert retired_surface not in labels + + assert "no Cursor runner job" in labels + assert "no Gemini runner job" in labels + assert "Consolidated consumer: does not set `force_retry`" in labels + assert "Root/non-consolidated: sets `force_retry=true`" in labels + assert ".github/workflows/agents-keepalive-loop.yml" in labels + assert "Optional recovery-request marker" in setup + assert "USE_CONSOLIDATED_WORKFLOWS` is `true`" in setup + assert "cleared by Agents 81 after it successfully merges" in labels diff --git a/tests/workflows/test_workflow_agents_consolidation.py b/tests/workflows/test_workflow_agents_consolidation.py index 234aadd48..be467845b 100644 --- a/tests/workflows/test_workflow_agents_consolidation.py +++ b/tests/workflows/test_workflow_agents_consolidation.py @@ -165,6 +165,175 @@ def test_external_merge_lanes_require_runtime_ac_guard(): ) +def test_consumer_guarded_merge_binds_exact_head_and_review_gate(): + workflow_path = Path("templates/consumer-repo/.github/workflows/agents-81-gate-followups.yml") + text = workflow_path.read_text(encoding="utf-8") + triggers = _workflow_on_section(yaml.safe_load(text)) + assert "pull_request" not in triggers + assert triggers.get("pull_request_target", {}).get("types") == ["labeled", "synchronize"] + guarded_merge = text.split("guarded-merge:", 1)[1] + + for contract in ( + "timeout-minutes: 30", + "mergeableState !== 'clean'", + "reviewWindowMs = 7 * 60 * 1000", + "reviewSleepBudgetMs = reviewWindowMs + 60 * 1000", + "agents-guarded-merge-review-window:v1", + "github.event_name != 'pull_request_target'", + "github.event.action == 'synchronize'", + "ref: ${{ github.event.repository.default_branch }}", + "async function reviewWindowObservation(prNumber, headSha, { forceReset = false } = {})", + "async function primeReviewWindows(candidateIssues)", + "const observation = reviewWindowObservations.get(observationKey)", + "await primeReviewWindows(issues)", + "for all exact-head review windows", + "client.rest.users.getAuthenticated", + "github.rest.issues.listComments", + "itemTypes: [HEAD_REF_FORCE_PUSHED_EVENT]", + "... on HeadRefForcePushedEvent", + "afterCommit { oid }", + "event?.afterCommit?.oid || ''", + "transitionedHeadSha !== headSha", + "latestHeadTransitionAtMs", + "observedAtMs >= latestHeadTransitionAtMs", + "client.rest.issues.updateComment", + "client.rest.issues.createComment", + "const observation = reviewWindowObservations.get(observationKey)", + "await reviewWindowObservation(prNumber, originalHead)", + "Date.now() - observation.observedAtMs", + "Review window not elapsed; deferring to a later run.", + "reviewThreads(first: 100, after: $cursor)", + "!thread.isResolved && !thread.isOutdated", + "setTimeout(resolve, requestedSleepMs)", + "async function loadPullRequest(prNumber)", + "mergeableState !== 'unknown'", + "setTimeout(resolve, 2000)", + "let pr = await loadPullRequest(prNumber)", + "const refreshedPr = await loadPullRequest(prNumber)", + "const finalPr = await loadPullRequest(prNumber)", + "Head changed during the review window", + "Post-window pull request no longer carries the automerge label.", + "const finalMergeFailure = await finalMergeGuardFailure(prNumber, headSha)", + "const { data: finalRepoInfo }", + "client.rest.repos.get({ owner, repo })", + "finalDefaultBranch = finalRepoInfo?.default_branch || ''", + "finalBase !== finalDefaultBranch", + "Final base branch ${finalBase || '(unknown)'} does not match ${finalDefaultBranch}.", + "Pull request no longer carries the automerge label.", + "finalLabels.includes(GENERATED_DELIVERY_HOLD_LABEL)", + "runtimeAcRequirement(finalPr.labels || [])", + "Runtime acceptance label(s)", + "async function linkedIssueTaskSnapshot(pr)", + "const updatedAt = String(linked?.updated_at || '')", + "function uncheckedTaskFailure(pr, linkedIssueSnapshot)", + "extractIssueNumberFromPull", + "extractIssueNumbersFromText", + "extractClosingIssueNumbersFromText", + "require('./.github/scripts/source_context.js')", + "function issueReferenceFailure(pr)", + "const metaIssueNumbers = new Set(", + "Ambiguous linked issue references; refusing to merge blind.", + "return extractIssueNumberFromPull(pr || {})", + "const linkedIssue = inferIssue(pr)", + "const finalLinkedIssue = inferIssue(finalPr)", + "const finalLinkedIssueSnapshot = await linkedIssueTaskSnapshot(finalPr)", + "const finalTaskFailure = uncheckedTaskFailure(finalPr, finalLinkedIssueSnapshot)", + "async function checkStateFailure(headSha)", + "statusCount > 0 || combined.state !== 'pending'", + "No commit statuses or check runs found.", + "const finalCheckFailure = await checkStateFailure(expectedHead)", + "Final check-state validation failed", + "const finalThreadFailure = await activeReviewThreadFailure(prNumber)", + "mergeBoundaryLinkedIssueSnapshot,", + "mergeBoundaryCheckFailure,", + "mergeBoundaryThreadFailure,", + "] = await Promise.all([", + "? linkedIssueTaskSnapshot(finalPr)", + "checkStateFailure(expectedHead)", + "activeReviewThreadFailure(prNumber)", + "Merge-boundary check-state validation failed", + "mergeBoundaryHead !== expectedHead", + "mergeBoundaryDefaultBranch", + "mergeBoundaryBase !== mergeBoundaryDefaultBranch", + "mergeBoundaryMergeableState", + "mergeBoundaryPr.draft || mergeBoundaryMergeableState !== 'clean'", + "const mergeBoundaryLabels = (mergeBoundaryPr.labels || []).map(", + "!mergeBoundaryLabels.includes(label)", + "mergeBoundaryLabels.includes(GENERATED_DELIVERY_HOLD_LABEL)", + "const mergeBoundaryRuntimeRequirement = runtimeAcRequirement(", + "if (mergeBoundaryRuntimeRequirement.required)", + "const mergeBoundaryLinkedIssue = inferIssue(mergeBoundaryPr)", + "mergeBoundaryLinkedIssue !== finalLinkedIssue", + "Linked issue changed at the final merge boundary.", + "const mergeBoundaryTaskFailure = uncheckedTaskFailure(", + "const linkedIssueSnapshot = await linkedIssueTaskSnapshot(pr)", + "taskFailure = uncheckedTaskFailure(pr, linkedIssueSnapshot)", + "mergeBoundaryLinkedIssueSnapshot.body !== finalLinkedIssueSnapshot.body", + "mergeBoundaryLinkedIssueSnapshot.updatedAt !== finalLinkedIssueSnapshot.updatedAt", + "Linked issue changed during final merge validation.", + "const finalObservation = await reviewWindowObservation(prNumber, expectedHead)", + "const finalReviewRemainingMs = reviewWindowMs", + "A newer head transition restarted the review window before merge.", + "const triggeringPrNumber = Number(", + "No triggering PR number; refusing a repository-wide merge scan.", + "const triggeringPr = await loadPullRequest(triggeringPrNumber)", + "const triggeringHeadSha = triggeringPr?.head?.sha || ''", + "const triggeringObservation = await reviewWindowObservation(", + "reviewObservationKey(triggeringPrNumber, triggeringHeadSha)", + "(issue) => Number(issue.number) === triggeringPrNumber", + "sha: headSha", + "const cleanupPr = await loadPullRequest(prNumber)", + "inferIssue(cleanupPr)", + "name: 'status:in-progress'", + ): + assert contract in guarded_merge + assert re.search(r"paginateWithRetry\(\s*github\.rest\.checks\.listForRef", guarded_merge) + assert "inferIssue(pr?.body || '') || 0" not in guarded_merge + assert ".github/scripts/source_context.js" in guarded_merge + assert guarded_merge.index("mergeBoundaryPr,") > guarded_merge.index("const finalThreadFailure") + assert guarded_merge.index("client.rest.pulls.merge") > guarded_merge.index( + "mergeBoundaryBase !== mergeBoundaryDefaultBranch" + ) + assert guarded_merge.index("const finalTaskFailure") < guarded_merge.index("mergeBoundaryPr,") + assert guarded_merge.index("const mergeBoundaryTaskFailure") > guarded_merge.index( + "const mergeBoundaryLinkedIssue" + ) + assert guarded_merge.index( + "Linked issue changed during final merge validation." + ) > guarded_merge.index("const mergeBoundaryTaskFailure") + assert guarded_merge.index("const mergeBoundaryLinkedIssue") > guarded_merge.index( + "mergeBoundaryPr," + ) + assert guarded_merge.index("mergeBoundaryCheckFailure,") > guarded_merge.index( + "mergeBoundaryPr," + ) + assert guarded_merge.index( + "Merge-boundary check-state validation failed" + ) > guarded_merge.index("] = await Promise.all([") + assert guarded_merge.index("client.rest.pulls.merge") > guarded_merge.index( + "Linked issue changed during final merge validation." + ) + assert text.count("github.event.action != 'synchronize'") >= 3 + assert "github.event_name != 'pull_request'" not in text + assert guarded_merge.count("{ forceReset: true }") == 1 + assert ( + guarded_merge.index("if (!issues.length)") + < guarded_merge.index("const triggeringObservation") + < guarded_merge.index("await primeReviewWindows(issues)") + ) + assert ( + guarded_merge.rindex("await assertRuntimeAcMergeAllowed") + < guarded_merge.rindex("const finalMergeFailure") + < guarded_merge.rindex("client.rest.pulls.merge") + ) + assert ( + guarded_merge.index("const finalObservation") + < guarded_merge.index("const finalPr") + < guarded_merge.index("const finalCheckFailure") + < guarded_merge.index("const finalThreadFailure") + ) + + def test_generated_sync_prs_are_excluded_from_autofix_lanes(): workflow_paths = [ WORKFLOWS_DIR / "agents-autofix-loop.yml",