diff --git a/.github/scripts/keepalive_orchestrator_gate_runner.js b/.github/scripts/keepalive_orchestrator_gate_runner.js index c4b64a42c8..2667f8be04 100644 --- a/.github/scripts/keepalive_orchestrator_gate_runner.js +++ b/.github/scripts/keepalive_orchestrator_gate_runner.js @@ -9,6 +9,194 @@ const { const { evaluateKeepaliveGate } = require('./keepalive_gate.js'); const { ensureRateLimitWrapped } = require('./github-rate-limited-wrapper.js'); +const KEEPALIVE_LABEL = 'agents:keepalive'; +const PAUSE_LABEL = 'agents:paused'; +const NEEDS_HUMAN_LABEL = 'needs-human'; +const NEEDS_ATTENTION_LABEL = 'agent:needs-attention'; +const DRAFT_DISPOSITION_MARKER = ''; + +function normaliseLabelName(label) { + if (!label) { + return ''; + } + if (typeof label === 'string') { + return label.trim().toLowerCase(); + } + return String(label.name || '').trim().toLowerCase(); +} + +function isConcreteAgentLabel(label) { + const value = String(label || '').trim().toLowerCase(); + return /^agent:[a-z0-9_-]+$/.test(value) && value !== NEEDS_ATTENTION_LABEL && value !== 'agent:auto'; +} + +function inferAgentFromBranch(headRef, registry) { + const ref = String(headRef || '').trim().toLowerCase(); + if (!ref || !registry || !registry.agents) { + return ''; + } + const firstSegment = ref.split('/')[0]; + if (!firstSegment) { + return ''; + } + for (const [key, config] of Object.entries(registry.agents)) { + const branchPrefix = String(config?.branch_prefix || '').trim().toLowerCase(); + const prefixSegment = branchPrefix ? branchPrefix.split('/')[0] : ''; + if (prefixSegment && prefixSegment === firstSegment) { + return String(key).trim().toLowerCase(); + } + if (String(key).trim().toLowerCase() === firstSegment) { + return String(key).trim().toLowerCase(); + } + } + return ''; +} + +function hasAutomationSignal(pr, labels) { + const headRef = String(pr?.head?.ref || '').trim().toLowerCase(); + return ( + labels.has('codex-automation') || + labels.has('codex') || + labels.has('autofix') || + Array.from(labels).some((label) => isConcreteAgentLabel(label)) || + headRef.startsWith('codex/') || + headRef.startsWith('claude/') || + /^feat\/\d+/.test(headRef) + ); +} + +function countMarkdownCheckboxes(body) { + const counts = { checked: 0, unchecked: 0 }; + const text = String(body || ''); + const checkboxPattern = /^\s*[-*]\s+\[([ xX])\]/gm; + for (const match of text.matchAll(checkboxPattern)) { + if (String(match[1] || '').trim().toLowerCase() === 'x') { + counts.checked += 1; + } else { + counts.unchecked += 1; + } + } + return counts; +} + +async function addLabelsIfMissing({ github, owner, repo, prNumber, labels, currentLabels, core, summary }) { + const toAdd = labels.filter((label) => label && !currentLabels.has(label.toLowerCase())); + if (!toAdd.length) { + return true; + } + + try { + await github.rest.issues.addLabels({ + owner, + repo, + issue_number: prNumber, + labels: toAdd, + }); + toAdd.forEach((label) => currentLabels.add(label.toLowerCase())); + summary.addRaw(`Self-healed missing PR label(s): ${toAdd.join(', ')}`).addEOL(); + return true; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + core.warning(`Unable to add label(s) to PR #${prNumber}: ${message}`); + summary.addRaw(`Failed to self-heal missing PR label(s): ${toAdd.join(', ')} (${message})`).addEOL(); + return false; + } +} + +async function markDraftReadyForReview({ github, pr, core, summary }) { + const nodeId = String(pr?.node_id || '').trim(); + if (!nodeId || typeof github.graphql !== 'function') { + summary.addRaw('Draft PR could not be converted automatically: missing GraphQL PR node id.').addEOL(); + return false; + } + + try { + await github.graphql( + `mutation($pullRequestId: ID!) { + markPullRequestReadyForReview(input: {pullRequestId: $pullRequestId}) { + pullRequest { + number + isDraft + } + } + }`, + { pullRequestId: nodeId } + ); + summary.addRaw('Draft PR had no unchecked checklist items; marked ready for review.').addEOL(); + return true; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + core.warning(`Unable to mark draft PR ready for review: ${message}`); + summary.addRaw(`Draft PR could not be converted automatically: ${message}`).addEOL(); + return false; + } +} + +async function routeDraftToHuman({ github, owner, repo, prNumber, currentLabels, checkboxCounts, core, summary }) { + await addLabelsIfMissing({ + github, + owner, + repo, + prNumber, + labels: [NEEDS_ATTENTION_LABEL, NEEDS_HUMAN_LABEL, PAUSE_LABEL], + currentLabels, + core, + summary, + }); + + summary + .addRaw( + `Draft PR requires human disposition: checked=${checkboxCounts.checked}, unchecked=${checkboxCounts.unchecked}.` + ) + .addEOL(); + + let comments = []; + try { + comments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number: prNumber, + per_page: 100, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + core.warning(`Unable to scan draft disposition comments for PR #${prNumber}: ${message}`); + } + + const alreadyCommented = (comments || []).some((comment) => + String(comment?.body || '').includes(DRAFT_DISPOSITION_MARKER) + ); + if (alreadyCommented) { + summary.addRaw('Draft disposition comment already exists; not posting a duplicate.').addEOL(); + return; + } + + const body = [ + DRAFT_DISPOSITION_MARKER, + '### Draft PR requires human disposition', + '', + `Keepalive found this PR still in draft with ${checkboxCounts.unchecked} unchecked checklist item(s). Draft PRs must not occupy automation capacity silently.`, + '', + `Applied \`${NEEDS_ATTENTION_LABEL}\`, \`${NEEDS_HUMAN_LABEL}\`, and \`${PAUSE_LABEL}\` so this is visible in automation summaries and human queues.`, + '', + 'Next human action: finish the unchecked acceptance items and mark the PR ready for review, or close/supersede the PR.', + ].join('\n'); + + try { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: prNumber, + body, + }); + summary.addRaw('Posted durable draft disposition comment for human routing.').addEOL(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + core.warning(`Unable to post draft disposition comment for PR #${prNumber}: ${message}`); + summary.addRaw(`Failed to post draft disposition comment: ${message}`).addEOL(); + } +} + /** * Execute keepalive gate evaluation and emit outputs. * @param {{ core: any, github: any, context: any, env: NodeJS.ProcessEnv }} args @@ -96,13 +284,22 @@ async function runKeepaliveGate({ core, github, context, env }) { }); let _defaultAgent = 'codex'; + let _registry = null; try { const { loadAgentRegistry } = require('./agent_registry.js'); - _defaultAgent = loadAgentRegistry().default_agent || 'codex'; + _registry = loadAgentRegistry(); + _defaultAgent = _registry.default_agent || 'codex'; } catch (_) { // Registry unavailable — fall back to codex } - const agentAlias = preGate.primaryAgent || _defaultAgent; + const inferredFromBranch = preGate.primaryAgent + ? '' + : inferAgentFromBranch(pr?.head?.ref, _registry); + const agentAlias = String( + preGate.primaryAgent || inferredFromBranch || _defaultAgent || '' + ) + .trim() + .toLowerCase(); const runCap = Number.isFinite(preGate.runCap) ? preGate.runCap : ''; const activeRuns = Number.isFinite(preGate.activeRuns) ? preGate.activeRuns : ''; const inflightRuns = ''; @@ -156,35 +353,36 @@ async function runKeepaliveGate({ core, github, context, env }) { } else { const labelEntries = Array.isArray(pr.labels) ? pr.labels : []; const currentLabels = new Set( - labelEntries - .map((entry) => { - if (!entry) { - return ''; - } - if (typeof entry === 'string') { - return entry.trim().toLowerCase(); - } - const name = typeof entry?.name === 'string' ? entry.name : ''; - return name.trim().toLowerCase(); - }) - .filter(Boolean) + labelEntries.map(normaliseLabelName).filter(Boolean) ); - if (currentLabels.has('agents:paused')) { + if (currentLabels.has(PAUSE_LABEL)) { addReason('keepalive-paused'); - summary.addRaw('Keepalive paused by agents:paused label.').addEOL(); + summary.addRaw(`Keepalive paused by ${PAUSE_LABEL} label.`).addEOL(); } - const requiredLabels = ['agents:keepalive']; + const requiredLabels = [KEEPALIVE_LABEL]; if (agentAlias) { requiredLabels.push(`agent:${agentAlias}`); } - const missingLabels = requiredLabels.filter((label) => !currentLabels.has(label)); - const unresolvedLabels = requiredLabels.filter((label) => !currentLabels.has(label)); - if (unresolvedLabels.length) { - unresolvedLabels.forEach((label) => addReason(`missing-label:${label}`)); - summary.addRaw(`Missing required keepalive labels: ${unresolvedLabels.join(', ')}`).addEOL(); + if (unresolvedLabels.length && hasAutomationSignal(pr, currentLabels)) { + await addLabelsIfMissing({ + github, + owner, + repo, + prNumber, + labels: unresolvedLabels, + currentLabels, + core, + summary, + }); + } + + const remainingMissingLabels = requiredLabels.filter((label) => !currentLabels.has(label)); + if (remainingMissingLabels.length) { + remainingMissingLabels.forEach((label) => addReason(`missing-label:${label}`)); + summary.addRaw(`Missing required keepalive labels: ${remainingMissingLabels.join(', ')}`).addEOL(); } headSha = String(pr.head?.sha || '').trim(); @@ -194,90 +392,109 @@ async function runKeepaliveGate({ core, github, context, env }) { if (!headSha) { addReason('missing-head-sha'); } + let draftRequiresHuman = false; if (pr.draft) { - addReason('pr-draft'); - } else { - if (headSha) { + const checkboxCounts = countMarkdownCheckboxes(pr.body || ''); + summary + .addRaw( + `Pull request is draft; evaluating disposition (checked=${checkboxCounts.checked}, unchecked=${checkboxCounts.unchecked}).` + ) + .addEOL(); + + const allChecklistWorkComplete = checkboxCounts.checked > 0 && checkboxCounts.unchecked === 0; + if (allChecklistWorkComplete) { + const ready = await markDraftReadyForReview({ github, pr, core, summary }); + if (ready) { + pr.draft = false; + } else { + draftRequiresHuman = true; + await routeDraftToHuman({ github, owner, repo, prNumber, currentLabels, checkboxCounts, core, summary }); + addReason('pr-draft-ready-failed'); + } + } else { + draftRequiresHuman = true; + await routeDraftToHuman({ github, owner, repo, prNumber, currentLabels, checkboxCounts, core, summary }); + addReason('pr-draft-needs-human'); + } + } + if (!draftRequiresHuman && headSha) { + try { + const { data: combined } = await github.rest.repos.getCombinedStatusForRef({ + owner, + repo, + ref: headSha, + }); + const statuses = combined?.statuses || []; + const gateStatuses = statuses.filter((status) => { + const ctx = (status.context || '').toLowerCase(); + return ctx === 'gate / gate' || ctx === 'gate' || ctx.endsWith('/ gate'); + }); + if (gateStatuses.length) { + const statusPreview = gateStatuses + .map((status) => `${String(status.context || 'gate').trim()}=${(status.state || 'unknown').toLowerCase()}`) + .join(', '); + summary.addRaw(`Gate status contexts: ${statusPreview}`).addEOL(); + } else if (combined?.state) { + summary.addRaw(`Gate combined status: ${(combined.state || 'unknown').toLowerCase()}`).addEOL(); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + core.warning(`Unable to evaluate gate status for ${headSha}: ${message}`); + } + } else if (!headSha) { + core.warning('Unable to evaluate gate status: pull request head SHA is unavailable.'); + } + + const normalisedHead = headSha ? headSha.toLowerCase() : ''; + const gateWorkflowIds = ['pr-00-gate.yml', 'pr-00-gate.yaml']; + let gateRunEvaluated = false; + + if (!draftRequiresHuman && headSha) { + for (const workflowId of gateWorkflowIds) { try { - const { data: combined } = await github.rest.repos.getCombinedStatusForRef({ + const response = await github.rest.actions.listWorkflowRuns({ owner, repo, - ref: headSha, - }); - const statuses = combined?.statuses || []; - const gateStatuses = statuses.filter((status) => { - const ctx = (status.context || '').toLowerCase(); - return ctx === 'gate / gate' || ctx === 'gate' || ctx.endsWith('/ gate'); + workflow_id: workflowId, + branch: pr.head?.ref, + per_page: 20, + event: 'pull_request', }); - if (gateStatuses.length) { - const statusPreview = gateStatuses - .map((status) => `${String(status.context || 'gate').trim()}=${(status.state || 'unknown').toLowerCase()}`) - .join(', '); - summary.addRaw(`Gate status contexts: ${statusPreview}`).addEOL(); - } else if (combined?.state) { - summary.addRaw(`Gate combined status: ${(combined.state || 'unknown').toLowerCase()}`).addEOL(); + const runs = response.data?.workflow_runs || []; + if (!runs.length) { + continue; } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - core.warning(`Unable to evaluate gate status for ${headSha}: ${message}`); - } - } else { - core.warning('Unable to evaluate gate status: pull request head SHA is unavailable.'); - } - const normalisedHead = headSha ? headSha.toLowerCase() : ''; - const gateWorkflowIds = ['pr-00-gate.yml', 'pr-00-gate.yaml']; - let gateRunEvaluated = false; - - if (headSha) { - for (const workflowId of gateWorkflowIds) { - try { - const response = await github.rest.actions.listWorkflowRuns({ - owner, - repo, - workflow_id: workflowId, - branch: pr.head?.ref, - per_page: 20, - event: 'pull_request', - }); - const runs = response.data?.workflow_runs || []; - if (!runs.length) { - continue; - } - - const headRun = runs.find((run) => (run.head_sha || '').toLowerCase() === normalisedHead); - if (!headRun) { - summary.addRaw(`Gate workflow ${workflowId} has ${runs.length} run(s) but none for head ${headSha.slice(0, 7)}.`).addEOL(); - continue; - } - - gateRunEvaluated = true; - const status = (headRun.status || '').toLowerCase(); - const conclusion = (headRun.conclusion || '').toLowerCase(); - summary - .addRaw(`Gate workflow ${workflowId} on ${headSha.slice(0, 7)} → status=${status || 'unknown'} conclusion=${conclusion || 'none'}`) - .addEOL(); - - if (status !== 'completed') { - addReason(`gate-run-status:${status || 'unknown'}`); - } else if (conclusion && conclusion !== 'success') { - summary.addRaw(`Gate conclusion ${conclusion} detected; continuing keepalive.`).addEOL(); - } - - break; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - summary.addRaw(`Failed to inspect gate workflow ${workflowId}: ${message}`).addEOL(); + const headRun = runs.find((run) => (run.head_sha || '').toLowerCase() === normalisedHead); + if (!headRun) { + summary.addRaw(`Gate workflow ${workflowId} has ${runs.length} run(s) but none for head ${headSha.slice(0, 7)}.`).addEOL(); + continue; } - } - } - if (!gateRunEvaluated) { - addReason('gate-run-missing'); - } + gateRunEvaluated = true; + const status = (headRun.status || '').toLowerCase(); + const conclusion = (headRun.conclusion || '').toLowerCase(); + summary + .addRaw(`Gate workflow ${workflowId} on ${headSha.slice(0, 7)} → status=${status || 'unknown'} conclusion=${conclusion || 'none'}`) + .addEOL(); + + if (status !== 'completed') { + addReason(`gate-run-status:${status || 'unknown'}`); + } else if (conclusion && conclusion !== 'success') { + summary.addRaw(`Gate conclusion ${conclusion} detected; continuing keepalive.`).addEOL(); + } + break; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + summary.addRaw(`Failed to inspect gate workflow ${workflowId}: ${message}`).addEOL(); + } + } } + if (!draftRequiresHuman && !gateRunEvaluated) { + addReason('gate-run-missing'); + } } if (reasons.length) { @@ -336,4 +553,4 @@ module.exports = { const github = await ensureRateLimitWrapped({ github: rawGithub, core, env }); return runKeepaliveGate({ core, github, context, env }); }, -}; \ No newline at end of file +}; diff --git a/.github/workflows/agents-81-gate-followups.yml b/.github/workflows/agents-81-gate-followups.yml index 8072c18c71..7997b29717 100644 --- a/.github/workflows/agents-81-gate-followups.yml +++ b/.github/workflows/agents-81-gate-followups.yml @@ -881,7 +881,7 @@ jobs: per_page: 100, }); - const workflowFile = 'agents-autofix-loop.yml'; + const workflowFile = 'agents-81-gate-followups.yml'; // Reduce attempts for auto-escalated PRs (they weren't agent-initiated) const isEscalated = labels.includes('autofix:escalated'); const maxAttempts = isEscalated diff --git a/.github/workflows/agents-auto-pilot.yml b/.github/workflows/agents-auto-pilot.yml index 44b2955650..6c1b3b16ad 100644 --- a/.github/workflows/agents-auto-pilot.yml +++ b/.github/workflows/agents-auto-pilot.yml @@ -2742,39 +2742,40 @@ jobs: ? `✅ Added labels: \`agent:${agentKey}\`, \`agents:keepalive\`, \`autofix\`` : '⚠️ Could not add labels (add manually)'; - // Dispatch PR meta workflow to build Automated Status Summary + // Dispatch PR event hub to build Automated Status Summary // (GITHUB_TOKEN actions do not trigger workflow runs automatically) try { await withRetry((client) => client.rest.actions.createWorkflowDispatch({ owner: context.repo.owner, repo: context.repo.repo, - workflow_id: 'agents-pr-meta.yml', + workflow_id: 'agents-80-pr-event-hub.yml', ref: baseBranch, inputs: { pr_number: pr.number.toString(), + handler: 'pr-meta', debug: 'false' } })); - core.info(`Dispatched PR meta update for PR #${pr.number}`); + core.info(`Dispatched PR event hub update for PR #${pr.number}`); } catch (dispatchError) { - core.warning(`Could not dispatch PR meta update: ${dispatchError?.message}`); + core.warning(`Could not dispatch PR event hub update: ${dispatchError?.message}`); } - // Dispatch keepalive workflow since GITHUB_TOKEN labels don't trigger it + // Dispatch gate followups workflow since GITHUB_TOKEN labels don't trigger it try { await withRetry((client) => client.rest.actions.createWorkflowDispatch({ owner: context.repo.owner, repo: context.repo.repo, - workflow_id: 'agents-keepalive-loop.yml', + workflow_id: 'agents-81-gate-followups.yml', ref: baseBranch, inputs: { pr_number: pr.number.toString(), force_retry: 'true' } })); - core.info(`Dispatched keepalive for PR #${pr.number}`); + core.info(`Dispatched gate followups for PR #${pr.number}`); } catch (dispatchError) { - core.warning(`Could not dispatch keepalive: ${dispatchError?.message}`); + core.warning(`Could not dispatch gate followups: ${dispatchError?.message}`); } // Add PR link comment to the issue with full URL for visibility diff --git a/.github/workflows/agents-autofix-dispatcher.yml b/.github/workflows/agents-autofix-dispatcher.yml index 314afd62d2..744639081a 100644 --- a/.github/workflows/agents-autofix-dispatcher.yml +++ b/.github/workflows/agents-autofix-dispatcher.yml @@ -41,18 +41,11 @@ jobs: secrets: ${{ toJSON(secrets) }} github_token: ${{ steps.app_token.outputs.token || github.token }} - - name: Dispatch autofix workflow + - name: Acknowledge consolidated autofix handling uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 with: github-token: ${{ steps.app_token.outputs.token || github.token }} script: | - const { createTokenAwareRetry } = require('./.github/scripts/github-api-with-retry.js'); - const { withRetry } = await createTokenAwareRetry({ - github, - core, - task: 'dispatch-autofix-loop', - }); - const payload = context.payload.client_payload || {}; const prNumber = Number(payload.pr_number); const gateRunId = String(payload.gate_run_id || ''); @@ -65,32 +58,8 @@ jobs: core.setFailed('Missing gate_run_id in payload'); return; } - const owner = context.repo.owner; - const repo = context.repo.repo; - const defaultBranch = context.payload.repository?.default_branch || 'main'; - const ref = `refs/heads/${defaultBranch}`; - try { - await withRetry((client) => - client.request( - 'POST /repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches', - { - owner, - repo, - workflow_id: 'agents-autofix-loop.yml', - ref, - inputs: { - gate_run_id: gateRunId, - pr_number: String(prNumber), - head_sha: headSha, - }, - } - ) - ); - const shaNote = headSha ? `, headSha=${headSha}` : ''; - const trace = `gateRunId=${gateRunId}${shaNote}`; - core.info( - `Triggered agents-autofix-loop for PR #${prNumber} (${trace}).` - ); - } catch (error) { - core.setFailed(`Failed to dispatch autofix workflow: ${error.message}`); - } + const shaNote = headSha ? `, headSha=${headSha}` : ''; + const trace = `gateRunId=${gateRunId}${shaNote}`; + core.info( + `Autofix for PR #${prNumber} is handled by agents-81-gate-followups via Gate workflow_run (${trace}).` + ); diff --git a/.github/workflows/agents-autofix-loop.yml b/.github/workflows/agents-autofix-loop.yml deleted file mode 100644 index 527799ebcb..0000000000 --- a/.github/workflows/agents-autofix-loop.yml +++ /dev/null @@ -1,937 +0,0 @@ -name: Agents Autofix Loop - -on: - workflow_dispatch: - inputs: - gate_run_id: - description: 'Gate workflow_run id to inspect' - required: true - pr_number: - description: 'Pull request number' - required: true - head_sha: - description: 'Head commit SHA (optional override)' - required: false - -permissions: - contents: write - pull-requests: write - actions: write - models: read - -env: - WRITE_TOKEN: >- - ${{ secrets.AGENTS_AUTOMATION_PAT || - secrets.ACTIONS_BOT_PAT || - secrets.SERVICE_BOT_PAT || - github.token }} - MANUAL_GATE_RUN_ID: ${{ inputs.gate_run_id || '' }} - MANUAL_PR_NUMBER: ${{ inputs.pr_number || '' }} - MANUAL_HEAD_SHA: ${{ inputs.head_sha || '' }} - -concurrency: - group: >- - agents-autofix-loop-${{ - inputs.pr_number || - github.run_id - }} - cancel-in-progress: true - -jobs: - prepare: - if: ${{ vars.USE_CONSOLIDATED_WORKFLOWS != 'true' }} - name: Prepare autofix context - runs-on: ubuntu-latest - environment: agent-standard - outputs: - should_run: ${{ steps.evaluate.outputs.should_run }} - pr_number: ${{ steps.evaluate.outputs.pr_number }} - head_ref: ${{ steps.evaluate.outputs.head_ref }} - head_sha: ${{ steps.evaluate.outputs.head_sha }} - appendix: ${{ steps.evaluate.outputs.appendix }} - stop_reason: ${{ steps.evaluate.outputs.stop_reason }} - agent_type: ${{ steps.evaluate.outputs.agent_type }} - attempts: ${{ steps.evaluate.outputs.attempts }} - max_attempts: ${{ steps.evaluate.outputs.max_attempts }} - trigger_reason: ${{ steps.evaluate.outputs.trigger_reason }} - trigger_job: ${{ steps.evaluate.outputs.trigger_job }} - trigger_step: ${{ steps.evaluate.outputs.trigger_step }} - gate_conclusion: ${{ steps.evaluate.outputs.gate_conclusion }} - gate_run_id: ${{ steps.evaluate.outputs.gate_run_id }} - has_high_privilege: ${{ steps.evaluate.outputs.has_high_privilege }} - security_blocked: ${{ steps.security_gate.outputs.blocked }} - security_reason: ${{ steps.security_gate.outputs.reason }} - steps: - # Mint GitHub App token early to use for API calls (avoids rate limits) - - name: Mint GitHub App Token - id: app_token - uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3 - continue-on-error: true - with: - app-id: ${{ secrets.WORKFLOWS_APP_ID || '0' }} - private-key: ${{ secrets.WORKFLOWS_APP_PRIVATE_KEY || 'dummy' }} - owner: ${{ github.repository_owner }} - - - name: Checkout (for security gate) - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - - token: ${{ steps.app_token.outputs.token || github.token }} - sparse-checkout: | - .github/agents/registry.yml - .github/scripts/agent_registry.js - .github/scripts/prompt_injection_guard.js - .github/actions/setup-api-client - .github/scripts/github-api-with-retry.js - .github/scripts/github-rate-limited-wrapper.js - .github/scripts/token_load_balancer.js - sparse-checkout-cone-mode: false - - name: Setup API client - uses: ./.github/actions/setup-api-client - with: - secrets: ${{ toJSON(secrets) }} - github_token: ${{ github.token }} - - - - - name: Security gate - prompt injection guard - id: security_gate - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 - with: - github-token: ${{ env.WRITE_TOKEN }} - script: | - const fs = require('fs'); - const guardPath = './.github/scripts/prompt_injection_guard.js'; - const retryHelperPath = './.github/scripts/github-api-with-retry.js'; - const retryHelpers = fs.existsSync(retryHelperPath) - ? require(retryHelperPath) - : { - withRetry: (fn) => fn(), - paginateWithRetry: (githubInstance, method, params) => - githubInstance.paginate(method, params), - }; - const { withRetry, paginateWithRetry } = retryHelpers; - - // Check if guard exists (may not exist on older branches) - if (!fs.existsSync(guardPath)) { - core.info('Prompt injection guard not found, skipping security check.'); - core.setOutput('blocked', 'false'); - core.setOutput('reason', 'guard-not-found'); - return; - } - - const { evaluatePromptInjectionGuard } = require(guardPath); - const { owner, repo } = context.repo; - const manualInputs = { - runId: (process.env.MANUAL_GATE_RUN_ID || '').trim(), - prNumber: (process.env.MANUAL_PR_NUMBER || '').trim(), - }; - - if (!manualInputs.runId) { - core.setOutput('blocked', 'false'); - core.setOutput('reason', 'missing-gate-run-id'); - return; - } - - const runIdNumber = Number(manualInputs.runId); - if (!Number.isFinite(runIdNumber) || runIdNumber <= 0) { - core.setFailed(`Invalid manual gate_run_id: ${manualInputs.runId}`); - return; - } - - let run; - try { - const response = await withRetry(() => - github.rest.actions.getWorkflowRun({ - owner, - repo, - run_id: runIdNumber, - }) - ); - run = response.data; - } catch (error) { - core.setFailed(`Failed to load workflow_run ${manualInputs.runId}: ${error.message}`); - return; - } - - const manualPrNumber = manualInputs.prNumber ? Number(manualInputs.prNumber) : NaN; - if ( - manualInputs.prNumber && - (!Number.isFinite(manualPrNumber) || manualPrNumber <= 0) - ) { - core.setFailed(`Invalid manual pr_number: ${manualInputs.prNumber}`); - return; - } - - let prNumber = - Array.isArray(run?.pull_requests) && run.pull_requests[0]?.number - ? Number(run.pull_requests[0].number) - : undefined; - if (!prNumber && Number.isFinite(manualPrNumber)) { - prNumber = manualPrNumber; - } - if (!prNumber) { - core.setOutput('blocked', 'false'); - core.setOutput('reason', 'no-pr-context'); - return; - } - - const { data: pr } = await withRetry(() => - github.rest.pulls.get({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: prNumber, - }) - ); - - const result = await evaluatePromptInjectionGuard({ - github, - context, - pr, - actor: run.actor?.login || context.actor, - promptContent: pr.body || '', - core, - }); - - core.setOutput('blocked', String(result.blocked)); - core.setOutput('reason', result.reason); - - if (result.blocked) { - core.setFailed(`Security gate blocked: ${result.reason}`); - } - - - name: Evaluate gate run - id: evaluate - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 - with: - github-token: ${{ env.WRITE_TOKEN }} - script: | - const fs = require('fs'); - const retryHelperPath = './.github/scripts/github-api-with-retry.js'; - const retryHelpers = fs.existsSync(retryHelperPath) - ? require(retryHelperPath) - : { - withRetry: (fn) => fn(), - paginateWithRetry: (githubInstance, method, params) => - githubInstance.paginate(method, params), - }; - const { withRetry, paginateWithRetry } = retryHelpers; - const { owner, repo } = context.repo; - const manualInputs = { - runId: (process.env.MANUAL_GATE_RUN_ID || '').trim(), - prNumber: (process.env.MANUAL_PR_NUMBER || '').trim(), - headSha: (process.env.MANUAL_HEAD_SHA || '').trim(), - }; - - if (!manualInputs.runId) { - core.setFailed('gate_run_id input is required for workflow_dispatch runs'); - return; - } - - const runIdNumber = Number(manualInputs.runId); - if (!Number.isFinite(runIdNumber) || runIdNumber <= 0) { - core.setFailed(`Invalid gate_run_id input: ${manualInputs.runId}`); - return; - } - - let run; - try { - const response = await withRetry(() => - github.rest.actions.getWorkflowRun({ - owner, - repo, - run_id: runIdNumber, - }) - ); - run = response.data; - } catch (error) { - core.setFailed( - `Failed to load Gate run ${manualInputs.runId}: ${error.message}` - ); - return; - } - const outputs = { - should_run: 'false', - pr_number: '', - head_ref: '', - head_sha: '', - appendix: '', - stop_reason: '', - agent_type: '', - attempts: '0', - max_attempts: '2', - trigger_reason: 'unknown', - trigger_job: '', - trigger_step: '', - gate_conclusion: String(run?.conclusion || run?.status || ''), - gate_run_id: String(run?.id || ''), - has_high_privilege: 'false', - }; - - const stop = (reason, stopReason = '') => { - core.info(`Autofix loop skipped: ${reason}.`); - outputs.stop_reason = stopReason || reason; - for (const [key, value] of Object.entries(outputs)) { - core.setOutput(key, value); - } - }; - - outputs.gate_run_id = String(manualInputs.runId || run?.id || ''); - - if ((run.conclusion || '').toLowerCase() === 'success') { - return stop('upstream Gate succeeded'); - } - - if ((run.event || '').toLowerCase() !== 'pull_request') { - return stop(`unsupported event type: ${run.event || 'unknown'}`); - } - - let prInfo = Array.isArray(run.pull_requests) ? run.pull_requests[0] : undefined; - if (manualInputs.prNumber) { - const manualPrNumber = Number(manualInputs.prNumber); - if (!Number.isFinite(manualPrNumber) || manualPrNumber <= 0) { - return stop('invalid manual pr_number input'); - } - prInfo = { number: manualPrNumber }; - } - if (!prInfo?.number) { - return stop('no pull request context on gate run'); - } - - const prNumber = Number(manualInputs.prNumber || prInfo.number); - outputs.pr_number = String(prNumber); - const pr = await withRetry(() => - github.rest.pulls.get({ owner, repo, pull_number: prNumber }) - ); - const prData = pr.data; - - if (!prData || prData.state !== 'open') { - return stop('pull request is not open'); - } - - if (prData.draft) { - return stop('draft pull request'); - } - - const gateHeadSha = (manualInputs.headSha || run.head_sha || '').trim(); - const headSha = prData.head?.sha; - if (!headSha || (gateHeadSha && headSha !== gateHeadSha)) { - return stop('head SHA drifted since Gate started'); - } - - const sameRepo = prData.head?.repo?.full_name === `${owner}/${repo}`; - if (!sameRepo) { - return stop('head repository mismatch (likely fork)'); - } - - const labels = Array.isArray(prData.labels) - ? prData.labels - .map((label) => (label?.name || '').toLowerCase()) - .filter(Boolean) - : []; - - const labelObjects = Array.isArray(prData.labels) ? prData.labels : []; - const agentPrefix = 'agent:'; - let routingLabelCandidates = labelObjects; - let routingAgentKeys = []; - const nonRoutingAgentKeys = new Set(['needs-attention', 'rate-limited', 'retry']); - try { - const { loadAgentRegistry } = require('./.github/scripts/agent_registry.js'); - const registry = loadAgentRegistry(); - const validAgentKeys = new Set( - Object.keys(registry.agents || {}).map((key) => - String(key || '').trim().toLowerCase(), - ), - ); - validAgentKeys.add('auto'); - - const normalizedAgentLabels = labelObjects - .map((label) => { - const normalized = (label?.name || '').trim().toLowerCase(); - return { label, normalized }; - }) - .filter(({ normalized }) => normalized.startsWith(agentPrefix)); - - const routingEntries = normalizedAgentLabels - .map(({ label, normalized }) => ({ - label, - key: normalized.slice(agentPrefix.length), - })) - .filter(({ key }) => key && !nonRoutingAgentKeys.has(key)); - - const registryEntries = routingEntries.filter(({ key }) => validAgentKeys.has(key)); - const entriesForRouting = - registryEntries.length > 0 ? registryEntries : routingEntries; - - routingLabelCandidates = entriesForRouting.map(({ label }) => label); - routingAgentKeys = Array.from( - new Set(entriesForRouting.map(({ key }) => key).filter(Boolean)), - ); - } catch (error) { - routingLabelCandidates = labelObjects.filter((label) => { - const normalized = (label?.name || '').trim().toLowerCase(); - if (!normalized.startsWith(agentPrefix)) { - return false; - } - const key = normalized.slice(agentPrefix.length); - return key && !nonRoutingAgentKeys.has(key); - }); - routingAgentKeys = Array.from( - new Set( - labels - .filter((label) => label.startsWith(agentPrefix)) - .map((label) => label.slice(agentPrefix.length)) - .filter((key) => key && !nonRoutingAgentKeys.has(key)), - ), - ); - } - - const hasExplicitAgentLabel = routingAgentKeys.length > 0; - let agentType = ''; - try { - const { resolveAgentFromLabels } = require('./.github/scripts/agent_registry.js'); - agentType = resolveAgentFromLabels( - routingLabelCandidates.length ? routingLabelCandidates : prData.labels, - ); - } catch (error) { - const fallbackAgent = routingAgentKeys[0]; - agentType = fallbackAgent || 'codex'; - } - - outputs.agent_type = String(agentType || ''); - - const hasHighPrivilege = labels.includes('agent-high-privilege'); - outputs.has_high_privilege = String(hasHighPrivilege); - - const body = prData.body || ''; - const configMatch = body.match(/autofix\s*:\s*(true|false)/i); - let autofixEnabled = configMatch - ? configMatch[1].toLowerCase() === 'true' - : hasExplicitAgentLabel; - - // Auto-escalation: Escalate to Codex CLI when Gate fails - // Triggers if: (1) basic autofix ran but insufficient, OR (2) no basic autofix applied - // Note: We do NOT add agent:codex label here because that triggers external Codex UI - // which would conflict with our internal Codex CLI run. Only add autofix:escalated. - if (!autofixEnabled && !configMatch) { - const hasAutofixLabel = - labels.includes('autofix:applied') || labels.includes('autofix'); - const hasEscalatedLabel = labels.includes('autofix:escalated'); - const gateConclusion = (run.conclusion || '').toLowerCase(); - const gateFailed = gateConclusion === 'failure'; - - // Escalate if Gate failed and we haven't already escalated - if (gateFailed && !hasEscalatedLabel) { - const reason = hasAutofixLabel - ? 'Basic autofix ran but Gate still failing' - : 'No basic autofix (non-Python PR?) and Gate failing'; - core.info(`🔄 Auto-escalation: ${reason}. Escalating to Codex CLI...`); - try { - await withRetry(() => github.rest.issues.addLabels({ - owner, - repo, - issue_number: prNumber, - labels: ['autofix:escalated'], - })); - core.info( - '✅ Added autofix:escalated label - Codex CLI will run in this workflow' - ); - autofixEnabled = true; - } catch (error) { - core.warning(`Failed to add escalation labels: ${error.message}`); - } - } - } - if (!autofixEnabled) { - return stop('autofix disabled for this pull request'); - } - - // Phase 2: Support both Codex and Claude autofix - const supportedAgents = ['codex', 'claude']; - if ((outputs.agent_type || '') && !supportedAgents.includes(outputs.agent_type)) { - return stop( - `unsupported agent type for autofix loop: ${outputs.agent_type}`, - 'unsupported_agent' - ); - } - - const jobs = await paginateWithRetry( - github, - github.rest.actions.listJobsForWorkflowRun, - { - owner, - repo, - run_id: run.id, - per_page: 100, - } - ); - - const workflowFile = 'agents-autofix-loop.yml'; - // Reduce attempts for auto-escalated PRs (they weren't agent-initiated) - const isEscalated = labels.includes('autofix:escalated'); - const maxAttempts = isEscalated - ? 1 - : Number(outputs.max_attempts); - const previousRuns = await paginateWithRetry( - github, - github.rest.actions.listWorkflowRuns, - { - owner, - repo, - workflow_id: workflowFile, - head_sha: gateHeadSha || headSha, - per_page: 100, - status: 'completed', - } - ); - - const attemptCount = previousRuns.length + 1; - outputs.attempts = String(attemptCount); - outputs.max_attempts = String(maxAttempts); - - const failingJobs = []; - let triggerJob = null; - let triggerStep = null; - for (const job of jobs) { - const conclusion = (job.conclusion || job.status || '').toLowerCase(); - if (!conclusion || ['success', 'skipped'].includes(conclusion)) { - continue; - } - - const failingSteps = - Array.isArray(job.steps) - ? job.steps - .filter((step) => { - const stepConclusion = (step.conclusion || step.status || '').toLowerCase(); - return stepConclusion && !['success', 'skipped'].includes(stepConclusion); - }) - .map( - (step) => - `${step.name} (${step.conclusion || step.status || 'unknown'})` - ) - : []; - - const detailLines = [`- ${job.name} (${job.conclusion || job.status || 'unknown'})`]; - if (failingSteps.length > 0) { - detailLines.push(` - steps: ${failingSteps.join('; ')}`); - } - failingJobs.push(detailLines.join('\n')); - - if (!triggerJob) { - triggerJob = job; - const failingStep = Array.isArray(job.steps) - ? job.steps.find((step) => { - const stepConclusion = (step.conclusion || step.status || '').toLowerCase(); - return stepConclusion && !['success', 'skipped'].includes(stepConclusion); - }) - : null; - triggerStep = failingStep || null; - } - } - - const inferTriggerReason = (job, step) => { - const text = [job?.name, step?.name] - .filter(Boolean) - .map((value) => String(value).toLowerCase()) - .join(' '); - - if (!text) return 'unknown'; - if (text.includes('mypy')) return 'mypy'; - if ( - text.includes('lint') || - text.includes('flake8') || - text.includes('ruff') - ) { - return 'lint'; - } - if (text.includes('pytest') || text.includes('test')) return 'pytest'; - return 'unknown'; - }; - - outputs.trigger_reason = inferTriggerReason(triggerJob, triggerStep); - outputs.trigger_job = triggerJob?.name || triggerJob?.id || ''; - outputs.trigger_step = triggerStep?.name || ''; - - const appendixLines = [ - `Gate run: ${run.html_url || run.id}`, - `Conclusion: ${run.conclusion || run.status || 'unknown'}`, - `PR: #${prNumber}`, - `Head SHA: ${headSha}`, - `Autofix attempts for this head: ${attemptCount} / ${maxAttempts}`, - 'Fix scope: src/, tests/, tools/, scripts/, agents/, templates/, .github/', - ]; - - if (failingJobs.length > 0) { - appendixLines.push('Failing jobs:', ...failingJobs); - } else { - appendixLines.push('Failing jobs: none reported.'); - } - - outputs.appendix = appendixLines.join('\n'); - - if (attemptCount > maxAttempts) { - return stop( - `autofix attempt limit reached (${attemptCount} > ${maxAttempts})`, - 'max_attempts' - ); - } - - outputs.should_run = 'true'; - outputs.head_ref = prData.head.ref || ''; - outputs.head_sha = headSha; - - for (const [key, value] of Object.entries(outputs)) { - core.setOutput(key, value); - } - - autofix: - needs: prepare - if: >- - needs.prepare.outputs.should_run == 'true' && - needs.prepare.outputs.agent_type == 'codex' - name: Run Codex autofix - uses: stranske/Workflows/.github/workflows/reusable-codex-run.yml@main - with: - prompt_file: .github/codex/prompts/autofix_from_ci_failure.md - mode: autofix - pr_number: ${{ needs.prepare.outputs.pr_number }} - pr_ref: ${{ needs.prepare.outputs.head_ref }} - appendix: ${{ needs.prepare.outputs.appendix }} - environment: >- - ${{ - needs.prepare.outputs.has_high_privilege == 'true' && - 'agent-high-privilege' || - 'agent-standard' - }} - secrets: - CODEX_AUTH_JSON: ${{ secrets.CODEX_AUTH_JSON }} - WORKFLOWS_APP_ID: ${{ secrets.WORKFLOWS_APP_ID }} - WORKFLOWS_APP_PRIVATE_KEY: ${{ secrets.WORKFLOWS_APP_PRIVATE_KEY }} - - autofix-claude: - needs: prepare - if: >- - needs.prepare.outputs.should_run == 'true' && - needs.prepare.outputs.agent_type == 'claude' - name: Run Claude autofix - uses: stranske/Workflows/.github/workflows/reusable-claude-run.yml@main - with: - prompt_file: .github/codex/prompts/autofix_from_ci_failure.md - mode: autofix - pr_number: ${{ needs.prepare.outputs.pr_number }} - pr_ref: ${{ needs.prepare.outputs.head_ref }} - appendix: ${{ needs.prepare.outputs.appendix }} - environment: >- - ${{ - needs.prepare.outputs.has_high_privilege == 'true' && - 'agent-high-privilege' || - 'agent-standard' - }} - secrets: - CLAUDE_AUTH_JSON: ${{ secrets.CLAUDE_AUTH_JSON }} - CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - WORKFLOWS_APP_ID: ${{ secrets.WORKFLOWS_APP_ID }} - WORKFLOWS_APP_PRIVATE_KEY: ${{ secrets.WORKFLOWS_APP_PRIVATE_KEY }} - - needs-human: - needs: prepare - if: needs.prepare.outputs.stop_reason == 'max_attempts' - name: Flag for human follow-up - runs-on: ubuntu-latest - environment: >- - ${{ - needs.prepare.outputs.has_high_privilege == 'true' && - 'agent-high-privilege' || - 'agent-standard' - }} - steps: - # Mint GitHub App token early to use for API calls (avoids rate limits) - - name: Mint GitHub App Token - id: app_token - uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3 - continue-on-error: true - with: - app-id: ${{ secrets.WORKFLOWS_APP_ID || '0' }} - private-key: ${{ secrets.WORKFLOWS_APP_PRIVATE_KEY || 'dummy' }} - owner: ${{ github.repository_owner }} - - - name: Checkout (for retry helpers) - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - - token: ${{ steps.app_token.outputs.token || github.token }} - sparse-checkout: | - .github/scripts/github-api-with-retry.js - .github/scripts/token_load_balancer.js - sparse-checkout-cone-mode: false - - name: Add needs-human label and comment - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 - with: - github-token: ${{ steps.app_token.outputs.token || github.token }} - script: | - const fs = require('fs'); - const retryHelperPath = './.github/scripts/github-api-with-retry.js'; - const retryHelpers = fs.existsSync(retryHelperPath) - ? require(retryHelperPath) - : { - withRetry: (fn) => fn(), - paginateWithRetry: (githubInstance, method, params) => - githubInstance.paginate(method, params), - }; - const { withRetry, paginateWithRetry } = retryHelpers; - const prNumber = Number('${{ needs.prepare.outputs.pr_number }}'); - if (!prNumber) { - core.info('No PR number available; skipping comment/label.'); - return; - } - - const appendix = `${{ toJSON(needs.prepare.outputs.appendix) }}`.replace(/^"|"$/g, ''); - const attempts = ${{ needs.prepare.outputs.attempts || 0 }}; - const maxAttempts = ${{ needs.prepare.outputs.max_attempts || 0 }}; - - const { owner, repo } = context.repo; - - await withRetry(() => github.rest.issues.addLabels({ - owner, - repo, - issue_number: prNumber, - labels: ['needs-human'], - })).catch((error) => { - core.warning(`Failed to add label: ${error.message}`); - }); - - const body = [ - 'Autofix attempts exhausted for this head.', - `Attempts: ${attempts} / ${maxAttempts}`, - '', - 'Latest Gate summary:', - '```', - appendix || 'No run context available.', - '```', - '', - 'Please investigate manually.', - ].join('\n'); - - await withRetry(() => github.rest.issues.createComment({ - owner, - repo, - issue_number: prNumber, - body, - })); - - metrics: - name: Record autofix metrics - needs: - - prepare - - autofix - - autofix-claude - if: always() - runs-on: ubuntu-latest - environment: >- - ${{ - needs.prepare.outputs.has_high_privilege == 'true' && - 'agent-high-privilege' || - 'agent-standard' - }} - steps: - # Mint GitHub App token early to use for API calls (avoids rate limits) - - name: Mint GitHub App Token - id: app_token - uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3 - continue-on-error: true - with: - app-id: ${{ secrets.WORKFLOWS_APP_ID || '0' }} - private-key: ${{ secrets.WORKFLOWS_APP_PRIVATE_KEY || 'dummy' }} - owner: ${{ github.repository_owner }} - - - name: Checkout (for retry helpers) - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - - token: ${{ steps.app_token.outputs.token || github.token }} - sparse-checkout: | - .github/scripts/github-api-with-retry.js - .github/actions/setup-api-client - .github/scripts/github-rate-limited-wrapper.js - .github/scripts/token_load_balancer.js - sparse-checkout-cone-mode: false - - - name: Setup API client - uses: ./.github/actions/setup-api-client - with: - secrets: ${{ toJSON(secrets) }} - github_token: ${{ github.token }} - - - name: Collect metrics - id: collect - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 - with: - github-token: ${{ env.WRITE_TOKEN }} - script: | - const fs = require('fs'); - const retryHelperPath = './.github/scripts/github-api-with-retry.js'; - const retryHelpers = fs.existsSync(retryHelperPath) - ? require(retryHelperPath) - : { - withRetry: (fn) => fn(), - paginateWithRetry: (githubInstance, method, params) => - githubInstance.paginate(method, params), - }; - const { withRetry, paginateWithRetry } = retryHelpers; - const prNumber = Number('${{ needs.prepare.outputs.pr_number || 0 }}') || 0; - const attemptNumber = Number('${{ needs.prepare.outputs.attempts || 0 }}') || 0; - const attemptLimit = Number('${{ needs.prepare.outputs.max_attempts || 0 }}') || 0; - const headShaBefore = '${{ needs.prepare.outputs.head_sha }}'; - const gateConclusionBefore = - '${{ needs.prepare.outputs.gate_conclusion }}' || 'unknown'; - const gateRunId = '${{ needs.prepare.outputs.gate_run_id }}'; - const triggerReason = '${{ needs.prepare.outputs.trigger_reason || 'unknown' }}'; - const triggerJob = '${{ needs.prepare.outputs.trigger_job }}'; - const triggerStep = '${{ needs.prepare.outputs.trigger_step }}'; - const stopReason = '${{ needs.prepare.outputs.stop_reason }}'; - const codexAutofixResult = '${{ needs.autofix.result }}'; - const claudeAutofixResult = '${{ needs.autofix-claude.result }}'; - const autofixResult = - codexAutofixResult && codexAutofixResult !== 'skipped' - ? codexAutofixResult - : claudeAutofixResult; - - const { owner, repo } = context.repo; - let fixApplied = false; - let headShaAfter = headShaBefore; - let gateResultAfter = gateConclusionBefore || 'unknown'; - - if (prNumber) { - try { - const { data: pr } = await withRetry(() => github.rest.pulls.get({ - owner, - repo, - pull_number: prNumber, - })); - headShaAfter = pr.head?.sha || headShaAfter; - fixApplied = Boolean( - headShaBefore && headShaAfter && headShaBefore !== headShaAfter - ); - - // Remove autofix:escalated label when fixes are applied - // This allows re-escalation to Codex if Gate fails again - if (fixApplied) { - const prLabels = pr.labels?.map((label) => label.name) || []; - if (prLabels.includes('autofix:escalated')) { - try { - await withRetry(() => github.rest.issues.removeLabel({ - owner, - repo, - issue_number: prNumber, - name: 'autofix:escalated', - })); - core.info( - '✅ Removed autofix:escalated label - allows re-escalation if Gate fails ' + - 'again' - ); - } catch (labelError) { - core.warning( - `Failed to remove autofix:escalated label: ${labelError.message}` - ); - } - } - } - - const gateWorkflow = 'pr-00-gate.yml'; - const runs = await paginateWithRetry( - github, - github.rest.actions.listWorkflowRuns, - { - owner, - repo, - workflow_id: gateWorkflow, - head_sha: headShaAfter, - per_page: 20, - } - ); - const latestGateRun = runs[0]; - if (latestGateRun) { - gateResultAfter = latestGateRun.conclusion || latestGateRun.status || 'unknown'; - } else { - gateResultAfter = 'not-found'; - } - } catch (error) { - core.warning(`Failed to resolve PR or gate status: ${error.message}`); - } - } - - const metrics = { - workflow_run_id: gateRunId, - pr_number: prNumber, - attempt_number: attemptNumber, - attempt_limit: attemptLimit, - trigger_reason: triggerReason || 'unknown', - trigger_job: triggerJob, - trigger_step: triggerStep, - fix_applied: fixApplied, - gate_result_after: gateResultAfter || 'unknown', - gate_conclusion_before: gateConclusionBefore || 'unknown', - stop_reason: stopReason || '', - autofix_result: autofixResult || 'unknown', - head_sha_before: headShaBefore, - head_sha_after: headShaAfter, - recorded_at: new Date().toISOString(), - }; - - core.setOutput('metrics_json', JSON.stringify(metrics)); - - - name: Write summary and artifact - env: - METRICS_JSON: ${{ steps.collect.outputs.metrics_json }} - run: | - set -euo pipefail - if [ -z "${METRICS_JSON:-}" ]; then - echo "No metrics JSON captured; skipping summary." - exit 0 - fi - - python - <<'PY' - import json - import os - - metrics = json.loads(os.environ["METRICS_JSON"]) - order = [ - "pr_number", - "attempt_number", - "attempt_limit", - "trigger_reason", - "trigger_job", - "trigger_step", - "fix_applied", - "gate_conclusion_before", - "gate_result_after", - "autofix_result", - "stop_reason", - "workflow_run_id", - "head_sha_before", - "head_sha_after", - "recorded_at", - ] - - lines = ["## Autofix loop metrics", ""] + ["| Field | Value |", "| --- | --- |"] - for key in order: - value = metrics.get(key, "") - lines.append(f"| {key} | `{value}` |") - - summary_path = os.environ.get("GITHUB_STEP_SUMMARY") - if summary_path: - with open(summary_path, "a", encoding="utf-8") as fp: - fp.write("\n".join(lines) + "\n") - - out_path = "autofix-metrics.ndjson" - with open(out_path, "a", encoding="utf-8") as fp: - fp.write(json.dumps(metrics) + "\n") - print(f"Wrote metrics to {out_path}") - PY - - - name: Upload metrics artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: agents-autofix-metrics - path: autofix-metrics.ndjson - retention-days: 30 diff --git a/.github/workflows/agents-bot-comment-handler.yml b/.github/workflows/agents-bot-comment-handler.yml deleted file mode 100644 index e220d2f0f0..0000000000 --- a/.github/workflows/agents-bot-comment-handler.yml +++ /dev/null @@ -1,490 +0,0 @@ -# Bot Comment Handler - Thin caller for consumer repos -# -# Addresses unresolved review comments from bots (Copilot, CodeRabbit, etc.) -# by dispatching the configured agent to fix them. -# -# Triggers: -# - PR labeled with 'autofix:bot-comments' (manual trigger) -# - Gate workflow completion (automatic for agent PRs) -# - Manual dispatch for testing -# -# Agent selection: -# - Uses PR's agent:* label (agent:codex, agent:claude, etc.) -# - Falls back to Codex if no agent label -# -# Workflow file: .github/workflows/agents-bot-comment-handler.yml - -name: Agents Bot Comment Handler - -on: - # Manual trigger via label - pull_request: - types: [labeled] - - # Automatic trigger after Gate completes (for agent PRs) - # Note: branches-ignore is not supported for workflow_run, filtering is done in job logic - workflow_run: - workflows: ["Gate"] - types: [completed] - - # Manual dispatch for testing - workflow_dispatch: - inputs: - pr_number: - description: 'PR number to process' - required: true - type: string - dry_run: - description: 'Preview without making changes' - required: false - type: boolean - default: false - -permissions: - contents: read - pull-requests: write - issues: write - actions: read - -concurrency: - group: >- - bot-comments-${{ github.event.pull_request.number || - github.event.workflow_run.pull_requests[0].number || - inputs.pr_number || - github.run_id }} - cancel-in-progress: true - -jobs: - # Resolve PR number from different trigger types - resolve: - name: Resolve PR - if: vars.USE_CONSOLIDATED_WORKFLOWS != 'true' - runs-on: ubuntu-latest - outputs: - pr_number: ${{ steps.resolve.outputs.pr_number }} - should_run: ${{ steps.resolve.outputs.should_run }} - skip_reason: ${{ steps.resolve.outputs.skip_reason }} - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - sparse-checkout: | - .github/actions/setup-api-client - .github/scripts/github-api-with-retry.js - .github/scripts/terminal_disposition.js - .github/scripts/token_load_balancer.js - sparse-checkout-cone-mode: false - - - name: Setup API client - uses: ./.github/actions/setup-api-client - with: - secrets: ${{ toJSON(secrets) }} - github_token: ${{ github.token }} - - - name: Resolve PR number and check conditions - id: resolve - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 - with: - script: | - const { createTokenAwareRetry } = require('./.github/scripts/github-api-with-retry.js'); - const { withRetry } = await createTokenAwareRetry({ github, core }); - - const eventName = context.eventName; - let prNumber = null; - let shouldRun = false; - let skipReason = 'unmatched-trigger'; - - if (eventName === 'workflow_dispatch') { - prNumber = '${{ inputs.pr_number }}'; - shouldRun = true; - skipReason = ''; - console.log(`Manual dispatch for PR #${prNumber}`); - } - else if (eventName === 'pull_request') { - // Only run if labeled with autofix:bot-comments - const label = context.payload.label?.name; - if (label === 'autofix:bot-comments') { - prNumber = context.payload.pull_request.number; - shouldRun = true; - skipReason = ''; - console.log(`Label trigger for PR #${prNumber}`); - } else { - skipReason = 'non-bot-comment-label'; - console.log(`Ignoring label: ${label}`); - } - } - else if (eventName === 'workflow_run') { - // Only run if Gate succeeded and PR has agent:* label - const workflowRun = context.payload.workflow_run; - - let defaultBranch = context.payload.repository?.default_branch; - if (!defaultBranch) { - const repoResponse = await withRetry((client) => client.rest.repos.get({ - owner: context.repo.owner, - repo: context.repo.repo - })); - defaultBranch = repoResponse.data?.default_branch; - } - if (!defaultBranch) { - console.log('Could not determine default branch; skipping.'); - core.setOutput('skip_reason', 'missing-default-branch'); - core.setOutput('should_run', 'false'); - return; - } - - // Skip default branch - if (workflowRun.head_branch === defaultBranch) { - console.log(`Skipping default branch ${defaultBranch}`); - core.setOutput('skip_reason', 'gate-run-on-default-branch'); - core.setOutput('should_run', 'false'); - return; - } - - if (workflowRun.conclusion !== 'success') { - console.log(`Gate did not succeed (${workflowRun.conclusion}), skipping`); - core.setOutput('skip_reason', 'gate-not-success'); - core.setOutput('should_run', 'false'); - return; - } - - // Get PR from workflow run - const prs = workflowRun.pull_requests; - if (!prs || prs.length === 0) { - console.log('No PR associated with workflow run'); - core.setOutput('skip_reason', 'no-pull-request-associated-with-workflow-run'); - core.setOutput('should_run', 'false'); - return; - } - - prNumber = prs[0].number; - - // Check if PR has agent label - let pr; - try { - const response = await withRetry((client) => client.rest.pulls.get({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: prNumber - })); - pr = response.data; - } catch (error) { - console.log( - `Failed to fetch PR #${prNumber} details, skipping. ` + - `Error: ${error.message || error}` - ); - core.setOutput('pr_number', prNumber || ''); - core.setOutput('skip_reason', 'pr-fetch-failed'); - core.setOutput('should_run', 'false'); - return; - } - - const hasAgentLabel = pr.labels.some(l => /^agent:/.test(l.name)); - if (!hasAgentLabel) { - console.log(`PR #${prNumber} has no agent label, skipping`); - core.setOutput('pr_number', prNumber || ''); - core.setOutput('skip_reason', 'missing-agent-label'); - core.setOutput('should_run', 'false'); - return; - } - - // Skip auto-pilot PRs — auto-pilot manages its own quality flow - const hasAutoPilot = pr.labels.some(l => l.name === 'agents:auto-pilot'); - if (hasAutoPilot) { - console.log(`PR #${prNumber} is auto-pilot managed, skipping bot-comment handler`); - core.setOutput('pr_number', prNumber || ''); - core.setOutput('skip_reason', 'auto-pilot-managed'); - core.setOutput('should_run', 'false'); - return; - } - - shouldRun = true; - skipReason = ''; - console.log(`Gate completion trigger for agent PR #${prNumber}`); - } - - core.setOutput('pr_number', prNumber || ''); - core.setOutput('should_run', shouldRun ? 'true' : 'false'); - core.setOutput('skip_reason', skipReason); - - - name: Write wrapper terminal disposition - if: always() - env: - RESOLVED_PR_NUMBER: ${{ steps.resolve.outputs.pr_number }} - REUSABLE_INVOCATION_EXPECTED: ${{ steps.resolve.outputs.should_run }} - SKIP_REASON: ${{ steps.resolve.outputs.skip_reason }} - run: | - mkdir -p agent-metrics - node <<'NODE' - const fs = require('fs'); - const helperPath = './.github/scripts/terminal_disposition.js'; - const fallback = { - normalizeTerminalDisposition: (value) => value, - formatTerminalDispositionMarkdown: (records) => - records.map((record) => `- ${record.source_type}:${record.source_id} ${record.disposition}`).join('\n'), - }; - const { - normalizeTerminalDisposition, - formatTerminalDispositionMarkdown, - } = fs.existsSync(helperPath) ? require(helperPath) : fallback; - const reusableExpected = - String(process.env.REUSABLE_INVOCATION_EXPECTED || '').toLowerCase() === 'true'; - const prNumber = Number.parseInt(process.env.RESOLVED_PR_NUMBER || '', 10) || null; - const disposition = reusableExpected ? 'reusable-invocation-expected' : 'wrapper-skipped'; - const reason = reusableExpected - ? 'Wrapper resolved an eligible PR and invoked the reusable bot-comment handler.' - : (process.env.SKIP_REASON || 'Wrapper did not find eligible bot-comment work.'); - const record = normalizeTerminalDisposition({ - source_type: 'review-thread', - source_id: prNumber || process.env.GITHUB_RUN_ID || 'unknown', - pr_number: prNumber, - disposition, - reason, - workflow: process.env.GITHUB_WORKFLOW || '', - run_id: process.env.GITHUB_RUN_ID || '', - run_attempt: process.env.GITHUB_RUN_ATTEMPT || '', - artifact_name: `review-thread-terminal-disposition-${process.env.GITHUB_RUN_ID || ''}`, - artifact_family: 'review-thread-terminal-disposition', - actor: process.env.GITHUB_ACTOR || '', - needs_human: false, - dispatch_outcome: reusableExpected ? 'reusable-expected' : 'wrapper-skipped', - }); - fs.writeFileSync( - 'agent-metrics/review-thread-terminal-disposition.ndjson', - `${JSON.stringify(record)}\n` - ); - fs.writeFileSync( - 'terminal-disposition-summary.md', - `${formatTerminalDispositionMarkdown([record])}\n` - ); - NODE - - - name: Upload wrapper terminal disposition - if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: review-thread-terminal-disposition-${{ github.run_id }} - path: | - agent-metrics/review-thread-terminal-disposition.ndjson - terminal-disposition-summary.md - if-no-files-found: error - retention-days: 14 - - # Dismiss ignored-path bot reviews to prevent noisy inline comments - dismiss_ignored: - name: Dismiss ignored bot reviews - # Dismisses the review state (not individual comments) so bot reviews - # on ignored paths don't block merge or clutter the PR timeline. - needs: resolve - if: vars.USE_CONSOLIDATED_WORKFLOWS != 'true' && needs.resolve.outputs.should_run == 'true' - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - sparse-checkout: | - .github/actions/setup-api-client - .github/scripts/github-api-with-retry.js - .github/scripts/token_load_balancer.js - sparse-checkout-cone-mode: false - - - name: Setup API client - uses: ./.github/actions/setup-api-client - with: - secrets: ${{ toJSON(secrets) }} - github_token: ${{ github.token }} - - - name: Dismiss ignored-path bot reviews - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 - env: - BOT_AUTHORS: >- - Copilot,copilot[bot],github-actions[bot], - coderabbitai[bot],chatgpt-codex-connector[bot] - IGNORED_PATHS: '.agents/,scripts/langchain/prompts/,docs/' - with: - script: | - const prNumber = parseInt('${{ needs.resolve.outputs.pr_number }}', 10); - if (!prNumber) { - console.log('No PR number resolved; skipping dismiss step.'); - return; - } - - const { createTokenAwareRetry } = require('./.github/scripts/github-api-with-retry.js'); - const { withRetry } = await createTokenAwareRetry({ github, core }); - - const botAuthors = (process.env.BOT_AUTHORS || '') - .split(',') - .map((value) => value.trim().toLowerCase()) - .filter(Boolean); - const ignoredPaths = (process.env.IGNORED_PATHS || '') - .split(',') - .map((value) => value.trim()) - .filter(Boolean); - - if (botAuthors.length === 0 || ignoredPaths.length === 0) { - console.log('Missing bot authors or ignored paths; skipping dismiss step.'); - return; - } - - const [comments, reviews] = await Promise.all([ - withRetry((client) => client.paginate(client.rest.pulls.listReviewComments, { - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: prNumber, - per_page: 100, - })), - withRetry((client) => client.paginate(client.rest.pulls.listReviews, { - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: prNumber, - per_page: 100, - })), - ]); - - const reviewsById = new Map(reviews.map((review) => [review.id, review])); - const commentsByReview = new Map(); - - for (const comment of comments) { - const reviewId = comment.pull_request_review_id; - if (!reviewId) { - continue; - } - - const entry = commentsByReview.get(reviewId) || { - total: 0, - ignored: 0, - paths: [], - ignoredComments: [], - }; - - entry.total += 1; - const commentPath = comment.path || ''; - entry.paths.push(commentPath); - - if (ignoredPaths.some((prefix) => commentPath.startsWith(prefix))) { - entry.ignored += 1; - entry.ignoredComments.push({ - id: comment.id, - path: commentPath, - login: comment.user?.login || 'unknown', - }); - const botName = comment.user?.login; - console.log( - 'Auto-dismiss candidate: ' + - `comment=${comment.id} ` + - `bot=${botName} path=${commentPath}` - ); - } - - commentsByReview.set(reviewId, entry); - } - - let dismissed = 0; - for (const [reviewId, review] of reviewsById) { - const login = (review.user?.login || '').toLowerCase(); - if (!botAuthors.includes(login)) { - continue; - } - - const entry = commentsByReview.get(reviewId); - if (!entry || entry.total === 0) { - continue; - } - - if (entry.ignored !== entry.total) { - continue; - } - - const uniquePaths = Array.from(new Set(entry.paths)).filter(Boolean); - for (const ignored of entry.ignoredComments) { - console.log( - 'Auto-dismissed review comment: ' + - `comment=${ignored.id} ` + - `bot=${ignored.login} ` + - `path=${ignored.path}` - ); - } - const message = [ - 'Auto-dismissed bot review: all comments target ignored paths.', - `Ignored paths: ${ignoredPaths.join(', ')}`, - `Review paths: ${uniquePaths.join(', ') || '(none)'}`, - ].join('\n'); - - await withRetry((client) => client.rest.pulls.dismissReview({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: prNumber, - review_id: reviewId, - message, - })); - - dismissed += 1; - const paths = uniquePaths.join(', '); - console.log( - `Dismissed review ${reviewId} ` + - `from ${review.user?.login} ` + - `for paths: ${paths}` - ); - } - - console.log(`Dismissed ${dismissed} bot review(s) targeting ignored paths.`); - - # Call the reusable workflow - handle: - name: Handle bot comments - needs: resolve - if: vars.USE_CONSOLIDATED_WORKFLOWS != 'true' && needs.resolve.outputs.should_run == 'true' - uses: stranske/Workflows/.github/workflows/reusable-bot-comment-handler.yml@main - with: - pr_number: ${{ needs.resolve.outputs.pr_number }} - dry_run: ${{ inputs.dry_run == true }} - ignored_paths: '.agents/,scripts/langchain/prompts/,docs/' - secrets: - service_bot_pat: ${{ secrets.SERVICE_BOT_PAT }} - gh_app_client_id: ${{ secrets.GH_APP_CLIENT_ID }} - gh_app_id: ${{ secrets.GH_APP_ID }} - gh_app_private_key: ${{ secrets.GH_APP_PRIVATE_KEY }} - - # Remove the trigger label after processing - cleanup: - name: Cleanup - needs: [resolve, handle] - if: | - always() && - vars.USE_CONSOLIDATED_WORKFLOWS != 'true' && - github.event_name == 'pull_request' && - github.event.label.name == 'autofix:bot-comments' - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - sparse-checkout: | - .github/actions/setup-api-client - .github/scripts/github-api-with-retry.js - .github/scripts/token_load_balancer.js - sparse-checkout-cone-mode: false - - - name: Setup API client - uses: ./.github/actions/setup-api-client - with: - secrets: ${{ toJSON(secrets) }} - github_token: ${{ github.token }} - - - name: Remove trigger label - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 - with: - script: | - const { createTokenAwareRetry } = require('./.github/scripts/github-api-with-retry.js'); - const { withRetry } = await createTokenAwareRetry({ github, core }); - - try { - await withRetry((client) => client.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.payload.pull_request.number, - name: 'autofix:bot-comments' - })); - console.log('Removed autofix:bot-comments label'); - } catch (error) { - console.log(`Could not remove label: ${error.message}`); - } diff --git a/.github/workflows/agents-keepalive-loop.yml b/.github/workflows/agents-keepalive-loop.yml deleted file mode 100644 index f33bdd0570..0000000000 --- a/.github/workflows/agents-keepalive-loop.yml +++ /dev/null @@ -1,1208 +0,0 @@ -name: Agents Keepalive Loop - -on: - workflow_run: - workflows: ["Gate"] - types: [completed] - pull_request: - types: - - labeled - workflow_dispatch: - inputs: - pr_number: - description: 'PR number to force retry' - required: true - type: string - force_retry: - description: 'Force retry even if gate is cancelled/deferred' - required: false - default: true - type: boolean - -permissions: - contents: write - pull-requests: write - actions: write - models: read - -concurrency: - group: >- - keepalive-${{ github.event.workflow_run.pull_requests[0].number || - github.event.pull_request.number || - github.event.inputs.pr_number || - github.run_id }} - cancel-in-progress: false - -jobs: - evaluate: - name: Evaluate keepalive loop - if: vars.USE_CONSOLIDATED_WORKFLOWS != 'true' - runs-on: ubuntu-latest - environment: agent-standard - outputs: - pr_number: ${{ steps.evaluate.outputs.pr_number }} - pr_ref: ${{ steps.evaluate.outputs.pr_ref }} - base_ref: ${{ steps.evaluate.outputs.base_ref }} - head_sha: ${{ steps.evaluate.outputs.head_sha }} - action: ${{ steps.evaluate.outputs.action }} - reason: ${{ steps.evaluate.outputs.reason }} - gate_conclusion: ${{ steps.evaluate.outputs.gate_conclusion }} - iteration: ${{ steps.evaluate.outputs.iteration }} - max_iterations: ${{ steps.evaluate.outputs.max_iterations }} - failure_threshold: ${{ steps.evaluate.outputs.failure_threshold }} - tasks_total: ${{ steps.evaluate.outputs.tasks_total }} - tasks_unchecked: ${{ steps.evaluate.outputs.tasks_unchecked }} - keepalive_enabled: ${{ steps.evaluate.outputs.keepalive_enabled }} - autofix_enabled: ${{ steps.evaluate.outputs.autofix_enabled }} - has_agent_label: ${{ steps.evaluate.outputs.has_agent_label }} - has_high_privilege: ${{ steps.evaluate.outputs.has_high_privilege }} - agent_type: ${{ steps.evaluate.outputs.agent_type }} - # task_appendix is delivered via artifact upload (keepalive-task-appendix-) - # to avoid GitHub's secret scanner censoring the job output. - trace: ${{ steps.evaluate.outputs.trace }} - prompt_mode: ${{ steps.evaluate.outputs.prompt_mode }} - prompt_file: ${{ steps.evaluate.outputs.prompt_file }} - start_ts: ${{ steps.timestamps.outputs.start_ts }} - security_blocked: ${{ steps.security_gate.outputs.blocked }} - security_reason: ${{ steps.security_gate.outputs.reason }} - rate_limit_remaining: ${{ steps.evaluate.outputs.rate_limit_remaining }} - rate_limit_recommendation: ${{ steps.evaluate.outputs.rate_limit_recommendation }} - rounds_without_task_completion: ${{ steps.evaluate.outputs.rounds_without_task_completion }} - force_retry: ${{ steps.evaluate.outputs.force_retry }} - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - - name: Set up Node.js - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 - with: - node-version: 20 - - - name: Setup API client - uses: ./.github/actions/setup-api-client - with: - secrets: ${{ toJSON(secrets) }} - github_token: ${{ github.token }} - - - - - - name: Handle agent:retry label - id: retry_label - if: >- - github.event_name == 'pull_request' && - github.event.action == 'labeled' && - github.event.label.name == 'agent:retry' - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const { createTokenAwareRetry } = require('./.github/scripts/github-api-with-retry.js'); - const { github: retryGithub, withRetry } = await createTokenAwareRetry({ - github, - core, - env: process.env, - task: 'keepalive-loop', - capabilities: ['issues:write'], - }); - const prNumber = context.payload.pull_request.number; - core.info(`agent:retry label detected on PR #${prNumber} - cleaning up labels`); - - // Remove agent:retry label (so it can be used again later) - try { - await withRetry((client) => - client.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - name: 'agent:retry', - }) - ); - core.info('Removed agent:retry label'); - } catch (error) { - if (error.message.includes('rate limit') || error.status === 403) { - core.warning( - `⚠️ Rate limited - could not remove agent:retry label: ` + - `${error.message}` - ); - } else if (!error.message.includes('Label does not exist')) { - core.warning(`Could not remove agent:retry: ${error.message}`); - } - } - - // Remove agent:rate-limited label if present - try { - await withRetry((client) => - client.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - name: 'agent:rate-limited', - }) - ); - core.info('Removed agent:rate-limited label'); - } catch (error) { - if (error.message.includes('rate limit') || error.status === 403) { - core.warning( - `⚠️ Rate limited - could not remove agent:rate-limited label: ` + - `${error.message}` - ); - } else if (!error.message.includes('Label does not exist')) { - // Only warn if it's not just "label doesn't exist" - core.warning(`Could not remove agent:rate-limited: ${error.message}`); - } - } - - core.info('Label cleanup complete - proceeding with keepalive evaluation'); - core.setOutput('force_retry', 'true'); - - - name: Capture timestamps - id: timestamps - run: echo "start_ts=$(date -u +%s)" >> "$GITHUB_OUTPUT" - - name: Security gate - prompt injection guard - id: security_gate - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const { createTokenAwareRetry } = require('./.github/scripts/github-api-with-retry.js'); - const { github: retryGithub, withRetry } = await createTokenAwareRetry({ - github, - core, - env: process.env, - task: 'keepalive-loop', - capabilities: ['pull-requests:read'], - }); - const { evaluatePromptInjectionGuard } = require( - './.github/scripts/prompt_injection_guard.js' - ); - - // Resolve PR from event context - const payload = context.payload || {}; - let prNumber = 0; - let pr = null; - - if (context.eventName === 'pull_request' && payload.pull_request) { - prNumber = payload.pull_request.number; - pr = payload.pull_request; - } else if (context.eventName === 'workflow_run' && payload.workflow_run) { - const prs = payload.workflow_run.pull_requests || []; - if (prs[0]?.number) { - prNumber = prs[0].number; - const { data } = await withRetry((client) => - client.rest.pulls.get({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: prNumber, - }) - ); - pr = data; - } - } - - if (!pr) { - core.setOutput('blocked', 'false'); - core.setOutput('reason', 'no-pr-context'); - return; - } - - const result = await evaluatePromptInjectionGuard({ - github, - context, - pr, - actor: context.actor, - promptContent: pr.body || '', - core, - }); - - core.setOutput('blocked', String(result.blocked)); - core.setOutput('reason', result.reason); - - if (result.blocked) { - core.setFailed(`Security gate blocked: ${result.reason}`); - } - - - name: Evaluate keepalive state - id: evaluate - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 - env: - INPUT_PR_NUMBER: ${{ github.event.inputs.pr_number || '' }} - INPUT_FORCE_RETRY: >- - ${{ steps.retry_label.outputs.force_retry || - (github.event_name == 'pull_request' && - github.event.action == 'labeled' && - github.event.label.name == 'agent:retry') || - github.event.inputs.force_retry || - 'false' }} - HAS_CODEX_AUTH: ${{ secrets.CODEX_AUTH_JSON != '' }} - HAS_CLAUDE_AUTH: ${{ secrets.CLAUDE_AUTH_JSON != '' }} - HAS_CLAUDE_OAUTH: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN != '' }} - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const { evaluateKeepaliveLoop } = require('./.github/scripts/keepalive_loop.js'); - const options = { github, context, core }; - // Pass workflow_dispatch inputs if present - const inputPrNumber = process.env.INPUT_PR_NUMBER; - let forceRetry = process.env.INPUT_FORCE_RETRY === 'true'; - if (inputPrNumber) { - options.overridePrNumber = parseInt(inputPrNumber, 10); - } - // Check if agent:retry label exists on PR (works for all event types) - // Use token-aware retry to avoid rate limit failures on GITHUB_TOKEN - if (!forceRetry) { - const { owner, repo } = context.repo; - // Resolve PR number from override, workflow_run, or pull_request event - const prNumberForLabelCheck = - options.overridePrNumber || - (context.payload?.workflow_run?.pull_requests?.[0]?.number) || - (context.payload?.pull_request?.number); - if (prNumberForLabelCheck) { - try { - const retryMod = require( - './.github/scripts/github-api-with-retry.js' - ); - const { withRetry } = await retryMod.createTokenAwareRetry({ - github, - core, - env: process.env, - task: 'keepalive-label-check', - capabilities: ['issues:read'], - }); - const labelsResponse = await withRetry((client) => - client.rest.issues.listLabelsOnIssue({ - owner, - repo, - issue_number: prNumberForLabelCheck, - }) - ); - const hasRetryLabel = Array.isArray(labelsResponse.data) && - labelsResponse.data.some(label => label.name === 'agent:retry'); - if (hasRetryLabel) { - forceRetry = true; - core.info( - `agent:retry label found on PR #${prNumberForLabelCheck} - ` + - `enabling force retry` - ); - } - } catch (error) { - core.warning( - `Could not check labels for PR #${prNumberForLabelCheck}: ` + - `${error.message}` - ); - } - } - } - if (forceRetry) { - options.forceRetry = true; - } - const result = await evaluateKeepaliveLoop(options); - const output = { - pr_number: String(result.prNumber || ''), - pr_ref: String(result.prRef || ''), - base_ref: String(result.baseRef || ''), - head_sha: String(result.headSha || ''), - action: result.action || '', - reason: result.reason || '', - gate_conclusion: result.gateConclusion || '', - iteration: String(result.iteration ?? ''), - max_iterations: String(result.maxIterations ?? ''), - failure_threshold: String(result.failureThreshold ?? ''), - tasks_total: String(result.checkboxCounts?.total ?? ''), - tasks_unchecked: String(result.checkboxCounts?.unchecked ?? ''), - keepalive_enabled: String(result.keepaliveEnabled ?? ''), - autofix_enabled: String(result.config?.autofix_enabled ?? ''), - has_agent_label: String(result.hasAgentLabel ?? ''), - has_high_privilege: String(result.hasHighPrivilege ?? 'false'), - agent_type: String(result.agentType || ''), - agent_routing_mode: String(result.agentRoutingMode || ''), - delegation_reason: String(result.delegationReason || ''), - delegation_should_switch: String(result.delegationShouldSwitch ?? 'false'), - trace: String(result.config?.trace || result.state?.trace || ''), - prompt_mode: String(result.promptMode || 'normal'), - prompt_file: String( - result.promptFile || '.github/codex/prompts/keepalive_next_task.md' - ), - // Rate limit status - rate_limit_remaining: String(result.rateLimitStatus?.totalRemaining ?? ''), - rate_limit_recommendation: String(result.rateLimitStatus?.recommendation ?? ''), - // Progress review tracking - rounds_without_task_completion: String(result.roundsWithoutTaskCompletion ?? '0'), - // Pass through forceRetry so the summary step can reset counters - force_retry: String(result.forceRetry ?? false), - }; - for (const [key, value] of Object.entries(output)) { - core.setOutput(key, value); - } - - // Task appendix: Write directly to file to avoid GitHub's secret scanner - // blocking job outputs (which happens with long/repetitive content). - // Writing to the artifact file here (before setting the task_appendix output - // via core.setOutput below) ensures the content reaches the artifact even if - // that output value gets censored. - const fs = require('fs'); - const path = require('path'); - const artifactsDir = '/tmp/keepalive-artifacts'; - const appendixPath = path.join(artifactsDir, 'task-appendix.txt'); - - // Ensure artifacts directory exists - fs.mkdirSync(artifactsDir, { recursive: true }); - - // Write the task appendix directly to the artifact file - if (result.taskAppendix && result.taskAppendix.length > 0) { - const opts = { encoding: 'utf8' }; - fs.writeFileSync(appendixPath, result.taskAppendix + '\n', opts); - const len = result.taskAppendix.length; - core.info(`Task appendix written to file (${len} chars)`); - } else { - // Create an empty file if there is no appendix content - fs.closeSync(fs.openSync(appendixPath, 'w')); - core.info('Empty task appendix file created'); - } - - // task_appendix is delivered exclusively via the artifact upload. - // Do NOT call core.setOutput('task_appendix', ...) here — the - // secret scanner warning fires at the step-output level, not just - // the job-output level, so even a step output would re-introduce - // the "Skip output since it may contain secret" noise. - - - name: Verify task appendix artifact - if: > - steps.evaluate.outputs.action == 'run' || - steps.evaluate.outputs.action == 'fix' || - steps.evaluate.outputs.action == 'conflict' - run: | - if [ ! -f /tmp/keepalive-artifacts/task-appendix.txt ]; then - echo "ERROR: Task appendix file not created by evaluate step" - exit 1 - fi - echo "Task appendix ready ($(wc -c < /tmp/keepalive-artifacts/task-appendix.txt) bytes)" - - - name: Upload task appendix artifact - if: > - steps.evaluate.outputs.action == 'run' || - steps.evaluate.outputs.action == 'fix' || - steps.evaluate.outputs.action == 'conflict' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: keepalive-task-appendix-${{ steps.evaluate.outputs.pr_number }} - path: /tmp/keepalive-artifacts/task-appendix.txt - retention-days: 1 - - preflight: - name: Verify secrets available - needs: evaluate - if: | - needs.evaluate.outputs.action == 'run' || - needs.evaluate.outputs.action == 'fix' || - needs.evaluate.outputs.action == 'conflict' - runs-on: ubuntu-latest - environment: >- - ${{ - needs.evaluate.outputs.has_high_privilege == 'true' && - 'agent-high-privilege' || - 'agent-standard' - }} - outputs: - secrets_ok: ${{ steps.check.outputs.secrets_ok }} - steps: - - name: Check secrets - id: check - env: - HAS_CODEX_AUTH: ${{ secrets.CODEX_AUTH_JSON != '' }} - HAS_CLAUDE_AUTH: ${{ secrets.CLAUDE_AUTH_JSON != '' }} - HAS_CLAUDE_OAUTH: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN != '' }} - AGENT_TYPE: ${{ needs.evaluate.outputs.agent_type || 'codex' }} - HAS_APP_ID: >- - ${{ secrets.KEEPALIVE_APP_ID != '' || - secrets.WORKFLOWS_APP_ID != '' }} - HAS_APP_KEY: >- - ${{ secrets.KEEPALIVE_APP_PRIVATE_KEY != '' || - secrets.WORKFLOWS_APP_PRIVATE_KEY != '' }} - run: | - echo "Requested agent: $AGENT_TYPE" - echo "CODEX_AUTH_JSON present: $HAS_CODEX_AUTH" - echo "CLAUDE_AUTH_JSON present: $HAS_CLAUDE_AUTH" - echo "CLAUDE_CODE_OAUTH_TOKEN present: $HAS_CLAUDE_OAUTH" - echo "KEEPALIVE_APP or WORKFLOWS_APP present: $HAS_APP_ID" - echo "WORKFLOWS_APP_PRIVATE_KEY present: $HAS_APP_KEY" - agent_auth_ok=false - case "$AGENT_TYPE" in - claude) - if [ "$HAS_CLAUDE_AUTH" = "true" ] || [ "$HAS_CLAUDE_OAUTH" = "true" ]; then - agent_auth_ok=true - fi - ;; - codex) - if [ "$HAS_CODEX_AUTH" = "true" ]; then - agent_auth_ok=true - fi - ;; - *) - if [ "$HAS_CODEX_AUTH" = "true" ] || \ - [ "$HAS_CLAUDE_AUTH" = "true" ] || \ - [ "$HAS_CLAUDE_OAUTH" = "true" ]; then - agent_auth_ok=true - fi - ;; - esac - if [ "$agent_auth_ok" != "true" ] && [ "$HAS_APP_ID" = "true" ]; then - agent_auth_ok=true - fi - if [ "$agent_auth_ok" != "true" ]; then - case "$AGENT_TYPE" in - claude) - missing_msg="Missing credentials for agent 'claude'." - missing_msg="$missing_msg Set CLAUDE_CODE_OAUTH_TOKEN or CLAUDE_AUTH_JSON," - missing_msg="$missing_msg or configure KEEPALIVE/WORKFLOWS_APP credentials." - ;; - codex) - missing_msg="Missing credentials for agent 'codex'." - missing_msg="$missing_msg Set CODEX_AUTH_JSON," - missing_msg="$missing_msg or configure KEEPALIVE/WORKFLOWS_APP credentials." - ;; - *) - missing_msg="Missing credentials for agent '${AGENT_TYPE}'." - missing_msg="$missing_msg Configure that agent's credentials," - missing_msg="$missing_msg or KEEPALIVE/WORKFLOWS_APP credentials." - ;; - esac - echo "::error::$missing_msg" - echo "secrets_ok=false" >> "$GITHUB_OUTPUT" - exit 1 - fi - echo "secrets_ok=true" >> "$GITHUB_OUTPUT" - - test-job: - name: Test job creation - needs: evaluate - runs-on: ubuntu-latest - steps: - - run: | - echo "Test job ran!" - echo "Action was ${{ needs.evaluate.outputs.action }}" - echo "Agent was ${{ needs.evaluate.outputs.agent_type }}" - - # Mark agent as running before starting the actual work - # This provides real-time visibility that the agent is actively engaged - mark-running: - name: Mark agent running - needs: - - evaluate - - preflight - if: | - needs.evaluate.outputs.action == 'run' || - needs.evaluate.outputs.action == 'fix' || - needs.evaluate.outputs.action == 'conflict' - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - - name: Set up Node.js - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 - with: - node-version: 20 - - - name: Setup API client - uses: ./.github/actions/setup-api-client - with: - secrets: ${{ toJSON(secrets) }} - github_token: ${{ github.token }} - - - - - name: Update summary with running status - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const { markAgentRunning } = require('./.github/scripts/keepalive_loop.js'); - const runUrl = - `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}` + - `/actions/runs/${context.runId}`; - const inputs = { - pr_number: '${{ needs.evaluate.outputs.pr_number }}', - agent_type: '${{ needs.evaluate.outputs.agent_type }}', - iteration: '${{ needs.evaluate.outputs.iteration }}', - max_iterations: '${{ needs.evaluate.outputs.max_iterations }}', - tasks_total: '${{ needs.evaluate.outputs.tasks_total }}', - tasks_unchecked: '${{ needs.evaluate.outputs.tasks_unchecked }}', - trace: '${{ needs.evaluate.outputs.trace }}', - run_url: runUrl, - }; - await markAgentRunning({ github, context, core, inputs }); - - # Route to appropriate agent based on agent:* label - # Supports: agent:codex -> CLI Codex, agent:claude -> Claude agent - # Future: agent:gemini, etc. will have their own jobs - run-codex: - name: Keepalive next task (Codex) - needs: - - evaluate - - mark-running - # Only run for agent:codex label when action is run/fix/conflict - if: | - needs.evaluate.outputs.agent_type == 'codex' && - (needs.evaluate.outputs.action == 'run' || - needs.evaluate.outputs.action == 'fix' || - needs.evaluate.outputs.action == 'conflict') - uses: stranske/Workflows/.github/workflows/reusable-codex-run.yml@main - secrets: inherit - with: - skip: >- - ${{ needs.evaluate.outputs.action != 'run' && - needs.evaluate.outputs.action != 'fix' && - needs.evaluate.outputs.action != 'conflict' }} - prompt_file: ${{ needs.evaluate.outputs.prompt_file }} - mode: keepalive - pr_number: ${{ needs.evaluate.outputs.pr_number }} - pr_ref: ${{ needs.evaluate.outputs.pr_ref }} - base_ref: ${{ needs.evaluate.outputs.base_ref }} - # task_appendix delivered via artifact; input left empty to avoid secret scanner - appendix: '' - iteration: ${{ needs.evaluate.outputs.iteration }} - environment: >- - ${{ - needs.evaluate.outputs.has_high_privilege == 'true' && - 'agent-high-privilege' || - 'agent-standard' - }} - - run-claude: - name: Keepalive next task (Claude) - needs: - - evaluate - - mark-running - if: | - needs.evaluate.outputs.agent_type == 'claude' && - (needs.evaluate.outputs.action == 'run' || - needs.evaluate.outputs.action == 'fix' || - needs.evaluate.outputs.action == 'conflict') - uses: stranske/Workflows/.github/workflows/reusable-claude-run.yml@main - secrets: inherit - with: - skip: >- - ${{ needs.evaluate.outputs.action != 'run' && - needs.evaluate.outputs.action != 'fix' && - needs.evaluate.outputs.action != 'conflict' }} - prompt_file: ${{ needs.evaluate.outputs.prompt_file }} - mode: keepalive - pr_number: ${{ needs.evaluate.outputs.pr_number }} - pr_ref: ${{ needs.evaluate.outputs.pr_ref }} - base_ref: ${{ needs.evaluate.outputs.base_ref }} - appendix: '' - iteration: ${{ needs.evaluate.outputs.iteration }} - environment: >- - ${{ - needs.evaluate.outputs.has_high_privilege == 'true' && - 'agent-high-privilege' || - 'agent-standard' - }} - - # Progress review: LLM-based check when agent is active but not completing tasks - # This catches "productive but unfocused" patterns where agent works on tangential items - progress-review: - name: Review agent progress alignment - needs: evaluate - if: needs.evaluate.outputs.action == 'review' - runs-on: ubuntu-latest - environment: >- - ${{ - needs.evaluate.outputs.has_high_privilege == 'true' && - 'agent-high-privilege' || - 'agent-standard' - }} - outputs: - recommendation: ${{ steps.review.outputs.recommendation }} - alignment_score: ${{ steps.review.outputs.alignment_score }} - feedback: ${{ steps.review.outputs.feedback }} - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - - name: Set up Node.js - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 - with: - node-version: 20 - - - name: Setup API client - uses: ./.github/actions/setup-api-client - with: - secrets: ${{ toJSON(secrets) }} - github_token: ${{ github.token }} - - - - - name: Setup Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 - with: - python-version: '3.12' - - - name: Install dependencies - run: pip install pydantic langchain-openai langchain-anthropic - - - name: Get recent commits - id: commits - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const prNumber = Number('${{ needs.evaluate.outputs.pr_number }}'); - const { createTokenAwareRetry } = - require('./.github/scripts/github-api-with-retry.js'); - const { - github: retryGithub, - withRetry, - paginateWithRetry, - } = await createTokenAwareRetry({ - github, - core, - env: process.env, - task: 'keepalive-loop', - capabilities: ['pull-requests:read'], - }); - const { data: commits } = await withRetry((client) => - client.rest.pulls.listCommits({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: prNumber, - per_page: 30, - }) - ); - const messages = commits.map(c => c.commit.message.split('\n')[0]); - const prFiles = await paginateWithRetry( - retryGithub.rest.pulls.listFiles, - { - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: prNumber, - per_page: 100, - } - ); - const files = [...new Set(prFiles.map(file => file.filename))]; - core.setOutput('messages', JSON.stringify(messages)); - core.setOutput('files', JSON.stringify(files.slice(0, 50))); - - - - name: Extract acceptance criteria - id: criteria - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const prNumber = Number('${{ needs.evaluate.outputs.pr_number }}'); - const { createTokenAwareRetry } = require('./.github/scripts/github-api-with-retry.js'); - const { github: retryGithub, withRetry } = await createTokenAwareRetry({ - github, - core, - env: process.env, - task: 'keepalive-loop', - capabilities: ['pull-requests:read'], - }); - const { data: pr } = await withRetry((client) => - client.rest.pulls.get({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: prNumber, - }) - ); - // Extract acceptance criteria from PR body - const body = pr.body || ''; - const criteria = []; - const lines = body.split('\n'); - const startIndex = lines.findIndex((line) => - /^#{2,6}\s*Acceptance\s+criteria\b/i.test(line.trim()) - ); - if (startIndex !== -1) { - for (let i = startIndex + 1; i < lines.length; i += 1) { - const trimmed = lines[i].trim(); - if (/^#{1,6}\s*(\S|$)/.test(trimmed)) { - break; - } - const match = lines[i].match(/^\s*[-*+]\s*(?:\[[ xX]\]\s*)?(.+)/); - if (match) { - criteria.push(match[1].trim()); - } - } - } - core.setOutput('criteria', JSON.stringify(criteria)); - - - name: Run progress review - id: review - env: - OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - CLAUDE_API_STRANSKE: ${{ secrets.CLAUDE_API_STRANSKE }} - CRITERIA: ${{ steps.criteria.outputs.criteria }} - COMMITS: ${{ steps.commits.outputs.messages }} - FILES: ${{ steps.commits.outputs.files }} - ROUNDS: ${{ needs.evaluate.outputs.rounds_without_task_completion }} - run: | - mapfile -t criteria_array < <(printf '%s\n' "$CRITERIA" | jq -r '.[]') - mapfile -t commits_array < <(printf '%s\n' "$COMMITS" | jq -r '.[]') - mapfile -t files_array < <(printf '%s\n' "$FILES" | jq -r '.[]') - - args=("scripts/langchain/progress_reviewer.py") - - for c in "${criteria_array[@]}"; do - args+=("--acceptance-criteria" "$c") - done - - for c in "${commits_array[@]}"; do - args+=("--recent-commits" "$c") - done - - for f in "${files_array[@]}"; do - args+=("--files-changed" "$f") - done - - args+=("--rounds-without-completion" "$ROUNDS" "--json") - - python "${args[@]}" > review_result.json || true - - # Parse results - if [ -f review_result.json ]; then - echo "recommendation=$(jq -r '.recommendation // "REDIRECT"' review_result.json)" \ - >> "$GITHUB_OUTPUT" - echo "alignment_score=$(jq -r '.alignment_score // 5' review_result.json)" \ - >> "$GITHUB_OUTPUT" - echo "feedback=$(jq -r '.feedback_for_agent // ""' review_result.json)" \ - >> "$GITHUB_OUTPUT" - echo "summary=$(jq -r '.summary // ""' review_result.json)" >> "$GITHUB_OUTPUT" - - cat review_result.json >> "$GITHUB_STEP_SUMMARY" - else - echo "recommendation=REDIRECT" >> "$GITHUB_OUTPUT" - echo "alignment_score=5" >> "$GITHUB_OUTPUT" - printf '%s\n' \ - "feedback=Unable to analyze progress. Please review acceptance criteria." \ - >> "$GITHUB_OUTPUT" - fi - - - - name: Evaluate whether to post review - id: review_guard - run: | - node .github/scripts/should-post-review.js review_result.json - - - name: Post review feedback to PR - if: steps.review_guard.outputs.should_post_review == 'true' - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 - env: - REVIEW_FEEDBACK: ${{ steps.review.outputs.feedback }} - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const prNumber = Number('${{ needs.evaluate.outputs.pr_number }}'); - const recommendation = '${{ steps.review.outputs.recommendation }}'; - const alignmentScore = '${{ steps.review.outputs.alignment_score }}'; - const rounds = '${{ needs.evaluate.outputs.rounds_without_task_completion }}'; - const feedback = process.env.REVIEW_FEEDBACK || ''; - const { createTokenAwareRetry } = require('./.github/scripts/github-api-with-retry.js'); - const { github: retryGithub, withRetry } = await createTokenAwareRetry({ - github, - core, - env: process.env, - task: 'keepalive-loop', - capabilities: ['issues:write'], - }); - - const emojiMap = { - 'CONTINUE': '✅', - 'REDIRECT': '⚠️', - 'STOP': '🛑' - }; - const emoji = emojiMap[recommendation] || '❓'; - - const body = [ - `## ${emoji} Progress Review (Round ${rounds})`, - '', - `**Recommendation:** ${recommendation}`, - `**Alignment Score:** ${alignmentScore}/10`, - '', - '### Feedback', - feedback || 'No specific feedback.', - '', - '---', - `_This review was triggered because the agent has been working for ${rounds} ` + - `rounds without completing any task checkboxes._`, - '_The review evaluates whether recent work is advancing toward ' + - 'the acceptance criteria._', - ].join('\n'); - - await withRetry((client) => - client.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - body, - }) - ); - - // If STOP recommended, remove agent label to prevent further runs - if (recommendation === 'STOP') { - core.warning('Progress review recommends STOP - agent work appears unaligned'); - const agentType = '${{ needs.evaluate.outputs.agent_type }}'; - if (agentType) { - const agentLabel = `agent:${agentType}`; - try { - await withRetry((client) => - client.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - name: agentLabel, - }) - ); - core.info(`Removed ${agentLabel} label after STOP recommendation`); - } catch (e) { - core.info(`Could not remove ${agentLabel} label: ${e.message}`); - } - } - } - - summary: - name: Update keepalive summary - needs: - - evaluate - - run-codex - - run-claude - # Run if PR exists, handle skipped/failed agent jobs gracefully - # run-codex will be skipped when action != run/fix/conflict, which is expected - if: | - always() && - needs.evaluate.outputs.pr_number != '' && - needs.evaluate.outputs.pr_number != '0' - runs-on: ubuntu-latest - environment: >- - ${{ - needs.evaluate.outputs.has_high_privilege == 'true' && - 'agent-high-privilege' || - 'agent-standard' - }} - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - - name: Setup API client - uses: ./.github/actions/setup-api-client - with: - secrets: ${{ toJSON(secrets) }} - github_token: ${{ github.token }} - - - - - name: Emit keepalive metrics - id: keepalive-metrics - env: - PR_NUMBER: ${{ needs.evaluate.outputs.pr_number }} - ACTION: ${{ needs.evaluate.outputs.action }} - REASON: ${{ needs.evaluate.outputs.reason }} - GATE_CONCLUSION: ${{ needs.evaluate.outputs.gate_conclusion }} - ITERATION: ${{ needs.evaluate.outputs.iteration }} - MAX_ITERATIONS: ${{ needs.evaluate.outputs.max_iterations }} - TASKS_TOTAL: ${{ needs.evaluate.outputs.tasks_total }} - TASKS_UNCHECKED: ${{ needs.evaluate.outputs.tasks_unchecked }} - START_TS: ${{ needs.evaluate.outputs.start_ts }} - run: | - set -euo pipefail - - now=$(date -u +%s) - if [[ "${START_TS:-}" =~ ^[0-9]+$ ]]; then - duration=$(( now - START_TS )) - if [ "$duration" -lt 0 ]; then duration=0; fi - else - duration=0 - fi - - tasks_total=${TASKS_TOTAL:-0} - tasks_unchecked=${TASKS_UNCHECKED:-0} - if ! [[ "$tasks_total" =~ ^-?[0-9]+$ ]]; then tasks_total=0; fi - if ! [[ "$tasks_unchecked" =~ ^-?[0-9]+$ ]]; then tasks_unchecked=0; fi - tasks_completed=$(( tasks_total - tasks_unchecked )) - if [ "$tasks_completed" -lt 0 ]; then tasks_completed=0; fi - - metrics_json=$(jq -n \ - --arg pr "${PR_NUMBER:-0}" \ - --arg iteration "${ITERATION:-0}" \ - --arg action "${ACTION:-}" \ - --arg stop_reason "${REASON:-}" \ - --arg gate_conclusion "${GATE_CONCLUSION:-}" \ - --arg tasks_total "$tasks_total" \ - --arg tasks_completed "$tasks_completed" \ - --arg duration "$duration" \ - '{ - pr_number: ($pr | tonumber? // 0), - iteration_count: ($iteration | tonumber? // 0), - action: $action, - stop_reason: $stop_reason, - gate_conclusion: $gate_conclusion, - tasks_total: ($tasks_total | tonumber? // 0), - tasks_completed: ($tasks_completed | tonumber? // 0), - duration_seconds: ($duration | tonumber? // 0) - }') - - { - echo '### Keepalive metrics' - echo '' - echo '| Field | Value |' - echo '| --- | --- |' - echo "| pr_number | $(echo "$metrics_json" | jq -r '.pr_number') |" - echo "| iteration_count | $(echo "$metrics_json" | jq -r '.iteration_count') |" - echo "| action | $(echo "$metrics_json" | jq -r '.action') |" - echo "| stop_reason | $(echo "$metrics_json" | jq -r '.stop_reason') |" - echo "| gate_conclusion | $(echo "$metrics_json" | jq -r '.gate_conclusion') |" - echo "| tasks_total | $(echo "$metrics_json" | jq -r '.tasks_total') |" - echo "| tasks_completed | $(echo "$metrics_json" | jq -r '.tasks_completed') |" - echo "| duration_seconds | $(echo "$metrics_json" | jq -r '.duration_seconds') |" - } >> "$GITHUB_STEP_SUMMARY" - - echo "$metrics_json" >> keepalive-metrics.ndjson - - - name: Upload keepalive metrics artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: keepalive-metrics - path: keepalive-metrics.ndjson - retention-days: 30 - if-no-files-found: error - - - name: Auto-reconcile task checkboxes - if: | - needs.run-codex.outputs.changes-made == 'true' || - needs.run-claude.outputs.changes-made == 'true' - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 - env: - LLM_COMPLETED_TASKS: >- - ${{ - needs.run-codex.outputs.llm-completed-tasks || - needs.run-claude.outputs.llm-completed-tasks || - '[]' - }} - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const { autoReconcileTasks } = require('./.github/scripts/keepalive_loop.js'); - - const prNumber = Number('${{ needs.evaluate.outputs.pr_number }}') || 0; - const beforeSha = '${{ needs.evaluate.outputs.head_sha }}'; // SHA before agent ran - const headSha = - '${{ needs.run-codex.outputs.commit-sha }}' || - '${{ needs.run-claude.outputs.commit-sha }}'; - - // LLM analysis metadata - const llmProvider = - '${{ needs.run-codex.outputs.llm-provider }}' || - '${{ needs.run-claude.outputs.llm-provider }}' || - ''; - const llmConfidence = - '${{ needs.run-codex.outputs.llm-confidence }}' || - '${{ needs.run-claude.outputs.llm-confidence }}' || - ''; - const llmAnalysisRun = - '${{ needs.run-codex.outputs.llm-analysis-run }}' === 'true' || - '${{ needs.run-claude.outputs.llm-analysis-run }}' === 'true'; - - // Parse LLM completed tasks if available - // Use env var to avoid JS string escaping issues - let llmCompletedTasks = []; - const llmTasksJson = process.env.LLM_COMPLETED_TASKS || '[]'; - try { - llmCompletedTasks = JSON.parse(llmTasksJson); - if (llmCompletedTasks.length > 0) { - core.info(`LLM analysis found ${llmCompletedTasks.length} completed task(s)`); - if (llmProvider) { - core.info(`LLM provider: ${llmProvider} (confidence: ${llmConfidence})`); - } - } - } catch (e) { - core.debug(`Failed to parse LLM tasks: ${e.message}`); - } - - if (!prNumber || !beforeSha || !headSha) { - core.info('Missing required inputs for task reconciliation'); - return; - } - - core.info(`Auto-reconciling tasks for PR #${prNumber}`); - core.info(`Comparing ${beforeSha.slice(0, 7)} → ${headSha.slice(0, 7)}`); - - const result = await autoReconcileTasks({ - github, context, prNumber, baseSha: beforeSha, headSha, llmCompletedTasks, core - }); - - if (result.updated) { - core.info(`✅ ${result.details}`); - core.notice(`Auto-checked ${result.tasksChecked} task(s) based on analysis`); - } else { - core.info(`ℹ️ ${result.details}`); - } - - // Output for step summary and downstream reporting - core.setOutput('tasks_checked', result.tasksChecked); - core.setOutput('reconciliation_details', result.details); - core.setOutput('llm_provider', llmProvider); - core.setOutput('llm_confidence', llmConfidence); - core.setOutput('llm_analysis_run', llmAnalysisRun); - core.setOutput('llm_tasks_count', llmCompletedTasks.length); - core.setOutput('commit_tasks_count', result.sources?.commit || 0); - - - name: Update summary comment - id: update-summary - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 - env: - AGENT_SUMMARY: >- - ${{ - needs.run-codex.outputs.final-message-summary || - needs.run-claude.outputs.final-message-summary || - needs.run-codex.outputs.error-summary || - needs.run-claude.outputs.error-summary || - '' - }} - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const { updateKeepaliveLoopSummary } = - require('./.github/scripts/keepalive_loop.js'); - - const claudeResult = '${{ needs.run-claude.result }}'; - const codexResult = '${{ needs.run-codex.result }}'; - const runResult = - claudeResult && claudeResult !== 'skipped' ? claudeResult : codexResult; - - const agentExitCode = - '${{ needs.run-codex.outputs.exit-code }}' || - '${{ needs.run-claude.outputs.exit-code }}'; - const agentChangesMade = - '${{ needs.run-codex.outputs.changes-made }}' || - '${{ needs.run-claude.outputs.changes-made }}'; - const agentCommitSha = - '${{ needs.run-codex.outputs.commit-sha }}' || - '${{ needs.run-claude.outputs.commit-sha }}'; - const agentFilesChanged = - '${{ needs.run-codex.outputs.files-changed }}' || - '${{ needs.run-claude.outputs.files-changed }}'; - - const llmProvider = - '${{ needs.run-codex.outputs.llm-provider }}' || - '${{ needs.run-claude.outputs.llm-provider }}' || - ''; - const llmModel = - '${{ needs.run-codex.outputs.llm-model }}' || - '${{ needs.run-claude.outputs.llm-model }}' || - ''; - const llmConfidence = - '${{ needs.run-codex.outputs.llm-confidence }}' || - '${{ needs.run-claude.outputs.llm-confidence }}' || - ''; - const llmAnalysisRun = - '${{ needs.run-codex.outputs.llm-analysis-run }}' === 'true' || - '${{ needs.run-claude.outputs.llm-analysis-run }}' === 'true'; - const inputs = { - pr_number: Number('${{ needs.evaluate.outputs.pr_number }}') || 0, - action: '${{ needs.evaluate.outputs.action }}', - reason: '${{ needs.evaluate.outputs.reason }}', - gate_conclusion: '${{ needs.evaluate.outputs.gate_conclusion }}', - iteration: Number('${{ needs.evaluate.outputs.iteration }}') || 0, - max_iterations: Number('${{ needs.evaluate.outputs.max_iterations }}') || 0, - failure_threshold: Number('${{ needs.evaluate.outputs.failure_threshold }}') || 3, - rounds_without_task_completion: - Number('${{ needs.evaluate.outputs.rounds_without_task_completion }}') || 0, - tasks_total: Number('${{ needs.evaluate.outputs.tasks_total }}') || 0, - tasks_unchecked: Number('${{ needs.evaluate.outputs.tasks_unchecked }}') || 0, - keepalive_enabled: '${{ needs.evaluate.outputs.keepalive_enabled }}', - autofix_enabled: '${{ needs.evaluate.outputs.autofix_enabled }}', - agent_type: '${{ needs.evaluate.outputs.agent_type }}', - trace: '${{ needs.evaluate.outputs.trace }}', - // Agent run result - check which agent ran - run_result: runResult, - // Agent output details for visibility - agent_exit_code: agentExitCode, - agent_changes_made: agentChangesMade, - agent_commit_sha: agentCommitSha, - agent_files_changed: agentFilesChanged, - agent_summary: process.env.AGENT_SUMMARY || '', - // LLM analysis details for task completion reporting - llm_provider: llmProvider, - llm_model: llmModel, - llm_confidence: llmConfidence, - llm_analysis_run: llmAnalysisRun, - force_retry: '${{ needs.evaluate.outputs.force_retry }}', - }; - await updateKeepaliveLoopSummary({ github, context, core, inputs }); - - # Mint KEEPALIVE_APP token for rate limit notification - # This has a separate rate limit pool from GITHUB_TOKEN - - name: Mint KEEPALIVE_APP token - id: keepalive_app_token - if: | - failure() && - steps.update-summary.outputs.rate_limit_hit == 'true' && - env.KEEPALIVE_APP_ID != '' && - env.KEEPALIVE_APP_PRIVATE_KEY != '' - uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3 - continue-on-error: true - env: - KEEPALIVE_APP_ID: ${{ secrets.KEEPALIVE_APP_ID }} - KEEPALIVE_APP_PRIVATE_KEY: ${{ secrets.KEEPALIVE_APP_PRIVATE_KEY }} - with: - app-id: ${{ secrets.KEEPALIVE_APP_ID }} - private-key: ${{ secrets.KEEPALIVE_APP_PRIVATE_KEY }} - owner: ${{ github.repository_owner }} - - # Handle rate limit failures by notifying the PR with KEEPALIVE_APP token - # This token has a separate rate limit pool (5000/hr) from the exhausted GITHUB_TOKEN - - name: Notify PR of rate limit failure - if: | - failure() && - steps.update-summary.outputs.rate_limit_hit == 'true' && - steps.keepalive_app_token.outputs.token != '' - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 - with: - github-token: ${{ steps.keepalive_app_token.outputs.token }} - script: | - const { - postRateLimitNotification - } = require('./.github/scripts/keepalive_loop.js'); - - const prNumber = Number('${{ steps.update-summary.outputs.pr_number }}') || 0; - const errorMessage = '${{ steps.update-summary.outputs.rate_limit_error }}'; - const resetTime = '${{ steps.update-summary.outputs.rate_limit_reset }}'; - const remaining = Number( - '${{ steps.update-summary.outputs.rate_limit_remaining }}' - ) || 0; - const action = - '${{ steps.update-summary.outputs.action }}' || - '${{ needs.evaluate.outputs.action }}'; - const reason = - '${{ steps.update-summary.outputs.reason }}' || - '${{ needs.evaluate.outputs.reason }}'; - - if (!prNumber) { - core.warning('No PR number available for rate limit notification'); - return; - } - - core.info(`Attempting to notify PR #${prNumber} about rate limit`); - - const result = await postRateLimitNotification({ - github, - context, - core, - prNumber, - errorMessage, - resetTime, - remaining, - action, - reason, - }); - - if (result.skipped) { - core.info('Rate limit notification skipped (recent notification exists)'); - } else if (result.posted || result.labeled) { - core.info( - `Rate limit notification: posted=${result.posted}, labeled=${result.labeled}` - ); - } else { - core.warning(`Failed to notify PR: ${result.error}`); - } diff --git a/.github/workflows/agents-pr-meta.yml b/.github/workflows/agents-pr-meta.yml deleted file mode 100644 index ca5660c0ae..0000000000 --- a/.github/workflows/agents-pr-meta.yml +++ /dev/null @@ -1,146 +0,0 @@ -# Thin caller for PR meta management - delegates to Workflows repo reusable workflow -# Detects keepalive comments on PRs and dispatches agent continuation -# -# This is the CRITICAL workflow for keepalive to function: -# - Listens for comments on PRs -# - Detects when Codex posts "round complete" markers -# - Dispatches orchestrator to continue agent work -# -# Copy this file to: .github/workflows/agents-pr-meta.yml -# -# Required secrets: -# - SERVICE_BOT_PAT: PAT for service bot account -# - ACTIONS_BOT_PAT: PAT for workflow dispatch -# - AGENTS_AUTOMATION_PAT: Alternative PAT for agent automation (optional) -name: Agents PR Meta - -on: - issue_comment: - types: [created] - pull_request: - types: [opened, synchronize, reopened, edited] - # Re-evaluate keepalive when Gate completes - handles race condition where - # human comment arrives before Gate finishes - workflow_run: - workflows: ["Gate"] - types: [completed] - workflow_dispatch: - inputs: - pr_number: - description: 'PR number to process' - required: false - type: string - debug: - description: 'Enable debug logging' - required: false - type: boolean - default: false - -permissions: - actions: write - checks: read - contents: read - issues: write - pull-requests: write - -concurrency: - group: >- - ${{ github.event_name == 'issue_comment' && - format('agents-pr-meta-comment-{0}', github.event.comment.id) || - github.event_name == 'pull_request' && - format('agents-pr-meta-pr-{0}', github.event.pull_request.number) || - format('agents-pr-meta-run-{0}', github.run_id) }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} - -jobs: - # Resolve PR context for issue_comment events - resolve_pr: - if: | - vars.USE_CONSOLIDATED_WORKFLOWS != 'true' && - github.event_name == 'issue_comment' && - github.event.issue.pull_request - runs-on: ubuntu-latest - outputs: - pr_number: ${{ steps.resolve.outputs.pr_number }} - comment_id: ${{ steps.resolve.outputs.comment_id }} - comment_body: ${{ steps.resolve.outputs.comment_body }} - steps: - - name: Resolve PR context - id: resolve - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 - with: - script: | - const pr = context.payload.issue; - const comment = context.payload.comment; - core.setOutput('pr_number', pr.number); - core.setOutput('comment_id', comment.id); - // Encode comment body as Base64 to safely handle multiline and special characters. - // The reusable workflow at stranske/Workflows/.github/workflows/reusable-20-pr-meta.yml - // expects this value to be Base64-encoded and is responsible for decoding it. - core.setOutput('comment_body', Buffer.from(comment.body || '').toString('base64')); - - # Call reusable PR meta workflow for comment events - pr_meta_comment: - needs: resolve_pr - if: | - vars.USE_CONSOLIDATED_WORKFLOWS != 'true' && - github.event_name == 'issue_comment' && - github.event.issue.pull_request - uses: stranske/Workflows/.github/workflows/reusable-20-pr-meta.yml@main - with: - pr_number: ${{ fromJSON(needs.resolve_pr.outputs.pr_number) }} - comment_id: ${{ needs.resolve_pr.outputs.comment_id }} - comment_body: ${{ needs.resolve_pr.outputs.comment_body }} - event_name: 'issue_comment' - event_action: ${{ github.event.action }} - # Only pass if explicitly set - omitting lets reusable workflow use its default - allowed_keepalive_logins: ${{ vars.ALLOWED_KEEPALIVE_LOGINS }} - secrets: inherit - - # Call reusable PR meta workflow for PR events - pr_meta_pr: - if: vars.USE_CONSOLIDATED_WORKFLOWS != 'true' && github.event_name == 'pull_request' - uses: stranske/Workflows/.github/workflows/reusable-20-pr-meta.yml@main - with: - pr_number: ${{ github.event.pull_request.number }} - event_name: 'pull_request' - event_action: ${{ github.event.action }} - secrets: inherit - - # Resolve PR context for workflow_run events (Gate completion) - resolve_pr_from_workflow_run: - if: vars.USE_CONSOLIDATED_WORKFLOWS != 'true' && github.event_name == 'workflow_run' - runs-on: ubuntu-latest - outputs: - pr_number: ${{ steps.resolve.outputs.pr_number }} - should_run: ${{ steps.resolve.outputs.should_run }} - steps: - - name: Resolve PR from workflow_run - id: resolve - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 - with: - script: | - const run = context.payload.workflow_run; - const prs = run && Array.isArray(run.pull_requests) ? run.pull_requests : []; - if (!prs.length) { - core.info('No pull_request associated with workflow_run; skipping PR meta handling.'); - core.setOutput('should_run', 'false'); - return; - } - core.setOutput('pr_number', prs[0].number); - core.setOutput('should_run', 'true'); - - # Call reusable PR meta workflow for workflow_run events - pr_meta_workflow_run: - needs: resolve_pr_from_workflow_run - if: | - vars.USE_CONSOLIDATED_WORKFLOWS != 'true' && - github.event_name == 'workflow_run' && - needs.resolve_pr_from_workflow_run.outputs.should_run == 'true' - uses: stranske/Workflows/.github/workflows/reusable-20-pr-meta.yml@main - with: - pr_number: ${{ fromJSON(needs.resolve_pr_from_workflow_run.outputs.pr_number) }} - event_name: 'workflow_run' - event_action: ${{ github.event.workflow_run.conclusion }} - allow_replay: true - secrets: inherit diff --git a/.github/workflows/agents-verify-to-issue-v2.yml b/.github/workflows/agents-verify-to-issue-v2.yml deleted file mode 100644 index 68481105d9..0000000000 --- a/.github/workflows/agents-verify-to-issue-v2.yml +++ /dev/null @@ -1,508 +0,0 @@ -name: Create Issue from Verification (Enhanced) - -# Creates a well-structured follow-up issue from verification feedback -# Uses multi-round LLM analysis to produce agent-ready issues with: -# - Clear Why section explaining the context -# - Specific, actionable tasks derived from verification concerns -# - Testable acceptance criteria from original issue -# - Background context in collapsible sections -# -# Trigger: Add `verify:create-issue` label to a merged PR -on: - pull_request_target: - types: [labeled] - -permissions: - contents: read - pull-requests: write - issues: write - models: read - -env: - PYTHON_VERSION: "3.12" - -jobs: - create-issue: - if: |- - vars.USE_CONSOLIDATED_WORKFLOWS != 'true' && - github.event.label.name == 'verify:create-issue' - runs-on: ubuntu-latest - steps: - - name: Check PR is merged - id: check-merged - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 - with: - github-token: ${{ github.token }} - script: | - const pr = context.payload.pull_request; - if (!pr.merged) { - core.setFailed('PR must be merged before creating follow-up issue'); - return; - } - core.setOutput('merged', 'true'); - core.setOutput('pr_number', pr.number); - core.setOutput('pr_title', pr.title); - - - name: Select GitHub token - id: select-token - if: steps.check-merged.outputs.merged == 'true' - env: - OWNER_PR_PAT: ${{ secrets.OWNER_PR_PAT }} - SERVICE_BOT_PAT: ${{ secrets.SERVICE_BOT_PAT }} - GITHUB_TOKEN: ${{ github.token }} - run: | - if [ -n "$OWNER_PR_PAT" ]; then - echo "token=$OWNER_PR_PAT" >> "$GITHUB_OUTPUT" - echo "source=owner-pat" >> "$GITHUB_OUTPUT" - elif [ -n "$SERVICE_BOT_PAT" ]; then - echo "token=$SERVICE_BOT_PAT" >> "$GITHUB_OUTPUT" - echo "source=service-bot" >> "$GITHUB_OUTPUT" - else - echo "token=$GITHUB_TOKEN" >> "$GITHUB_OUTPUT" - echo "source=github-token" >> "$GITHUB_OUTPUT" - fi - - - name: Checkout repository - if: steps.check-merged.outputs.merged == 'true' - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - repository: stranske/Workflows - token: ${{ steps.select-token.outputs.token }} - sparse-checkout: | - .github/actions/setup-api-client - .github/agents/registry.yml - .github/scripts/agent_registry.js - .github/scripts/github-api-with-retry.js - .github/scripts/terminal_disposition.js - .github/scripts/token_load_balancer.js - scripts/langchain - tools - sparse-checkout-cone-mode: false - - - name: Setup API client - uses: ./.github/actions/setup-api-client - with: - secrets: ${{ toJSON(secrets) }} - github_token: ${{ github.token }} - - - - name: Set up Python - if: steps.check-merged.outputs.merged == 'true' - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 - with: - python-version: ${{ env.PYTHON_VERSION }} - - - name: Install dependencies - if: steps.check-merged.outputs.merged == 'true' - run: | - pip install langchain langchain-openai langchain-anthropic requests - - - name: Collect verification and original issue data - id: collect - if: steps.check-merged.outputs.merged == 'true' - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 - with: - github-token: ${{ steps.select-token.outputs.token }} - script: | - const fs = require('fs'); - const retryHelperPath = './.github/scripts/github-api-with-retry.js'; - const retryHelpers = fs.existsSync(retryHelperPath) - ? require(retryHelperPath) - : { - withRetry: (fn) => fn(), - paginateWithRetry: (githubInstance, method, params) => - githubInstance.paginate(method, params), - }; - const { withRetry } = retryHelpers; - const prNumber = context.payload.pull_request.number; - - // Get all PR comments - const { data: comments } = await withRetry(() => github.rest.issues.listComments({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - per_page: 100 - })); - - // Find verification comment(s) - both evaluate and compare - const verifyComments = comments.filter(c => - c.body.includes('## PR Verification Report') || - c.body.includes('## PR Verification Comparison') || - c.body.includes('### Concerns') || - c.body.includes('Verdict:') || - c.body.includes('### ⚠️ Issues Detected') - ); - - if (verifyComments.length === 0) { - core.setFailed( - 'No verification comment found. ' + - 'Add verify:evaluate or verify:compare label first.' - ); - return; - } - - // Combine all verification comments - const verificationText = verifyComments.map(c => c.body).join('\n\n---\n\n'); - fs.writeFileSync('verification_data.txt', verificationText); - core.info(`Found ${verifyComments.length} verification comment(s)`); - - // Extract linked issue from PR body - const prBody = context.payload.pull_request.body || ''; - const issueMatches = prBody.match(/(close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s*#(\d+)/gi); - let linkedIssueNumber = null; - - if (issueMatches) { - const match = issueMatches[0].match(/#(\d+)/); - if (match) { - linkedIssueNumber = parseInt(match[1]); - } - } - - // Also check PR title for issue reference - if (!linkedIssueNumber) { - const titleMatch = context.payload.pull_request.title.match(/#(\d+)/); - if (titleMatch) { - linkedIssueNumber = parseInt(titleMatch[1]); - } - } - - let originalIssueBody = ''; - let originalIssueTitle = ''; - - if (linkedIssueNumber) { - core.info(`Found linked issue #${linkedIssueNumber}`); - try { - const { data: issue } = await withRetry(() => github.rest.issues.get({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: linkedIssueNumber - })); - originalIssueBody = issue.body || ''; - originalIssueTitle = issue.title || ''; - core.setOutput('original_issue_number', linkedIssueNumber); - core.setOutput('original_issue_title', originalIssueTitle); - } catch (error) { - core.warning(`Could not fetch issue #${linkedIssueNumber}: ${error.message}`); - } - } else { - core.warning('No linked issue found in PR body or title'); - } - - fs.writeFileSync('original_issue.txt', originalIssueBody); - - // Set outputs - core.setOutput('has_original_issue', linkedIssueNumber ? 'true' : 'false'); - - - name: Generate follow-up issue - id: generate - if: steps.check-merged.outputs.merged == 'true' - env: - OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - CLAUDE_API_STRANSKE: ${{ secrets.CLAUDE_API_STRANSKE }} - GITHUB_TOKEN: ${{ steps.select-token.outputs.token }} - ORIGINAL_ISSUE_NUMBER: ${{ steps.collect.outputs.original_issue_number }} - ORIGINAL_ISSUE_TITLE: ${{ steps.collect.outputs.original_issue_title }} - PR_NUMBER: ${{ steps.check-merged.outputs.pr_number }} - run: | - # Generate using Python script - python scripts/langchain/followup_issue_generator.py \ - --verification-comment verification_data.txt \ - --original-issue original_issue.txt \ - --original-issue-number "${ORIGINAL_ISSUE_NUMBER:-0}" \ - --original-issue-title "${ORIGINAL_ISSUE_TITLE:-}" \ - --pr-number "${PR_NUMBER}" \ - --json \ - --output followup_issue.json - - # Extract title and body for GitHub Actions - echo "issue_title=$(jq -r '.title' followup_issue.json)" >> "$GITHUB_OUTPUT" - - # Use delimiter for multi-line body - { - echo 'issue_body<> "$GITHUB_OUTPUT" - - - name: Fallback to simple extraction - id: fallback - if: steps.generate.outcome == 'failure' && steps.check-merged.outputs.merged == 'true' - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 - with: - github-token: ${{ steps.select-token.outputs.token }} - script: | - // Fallback to original simple extraction if Python script fails - const fs = require('fs'); - const prNumber = context.payload.pull_request.number; - const prTitle = context.payload.pull_request.title; - const prUrl = context.payload.pull_request.html_url; - - let verificationText = ''; - try { - verificationText = fs.readFileSync('verification_data.txt', 'utf8'); - } catch (error) { - core.warning('Could not read verification data'); - } - - // Extract concerns using regex - const concernsRegex = /### Concerns\s*\n([\s\S]*?)(?=###|##|$)/i; - const concernsMatch = verificationText.match(concernsRegex); - let concerns = concernsMatch - ? concernsMatch[1].trim() - : 'No specific concerns extracted.'; - - // Extract low scores - const scoreMatches = [...verificationText.matchAll(/(\w+):\s*(\d+)\/10/gi)]; - const lowScores = scoreMatches - .filter(m => parseInt(m[2]) < 7) - .map(m => `- ${m[1]}: ${m[2]}/10`); - - // Extract verdict - const verdictMatch = verificationText.match(/Verdict:\s*\*?\*?(\w+)\*?\*?/i); - const verdict = verdictMatch ? verdictMatch[1] : 'Unknown'; - - // Build issue body with proper indentation for YAML compatibility - const taskItems = concerns.split('\n') - .filter(l => l.trim()) - .map(c => `- [ ] ${c.replace(/^[-*]\s*/, '')}`) - .join('\n'); - - const lowScoreSection = lowScores.length > 0 - ? '\n## Implementation Notes\n\nLow scores to address:\n' + - lowScores.join('\n') - : ''; - - const issueBody = [ - '## Why', - '', - `PR #${prNumber} was verified with verdict **${verdict}**. ` + - 'This follow-up issue tracks the remaining concerns.', - '', - '## Scope', - '', - `Address verification concerns from [PR #${prNumber}](${prUrl}).`, - '', - '## Tasks', - '', - taskItems, - '', - '## Acceptance Criteria', - '', - '- [ ] All verification concerns addressed or documented', - '- [ ] Tests updated if needed', - '- [ ] Re-verification passes', - lowScoreSection, - '', - '---', - '*Auto-generated by verify-to-issue workflow (fallback mode)*' - ].join('\n'); - - core.setOutput( - 'issue_title', - `[Follow-up] Address verification concerns from PR #${prNumber}` - ); - - // Use environment file for multi-line output - const envFile = process.env.GITHUB_OUTPUT; - const delimiter = 'EOF_BODY'; - fs.appendFileSync(envFile, `issue_body<<${delimiter}\n${issueBody}\n${delimiter}\n`); - - - name: Create follow-up issue - id: create-issue - if: steps.check-merged.outputs.merged == 'true' - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 - env: - ISSUE_TITLE: >- - ${{ steps.generate.outputs.issue_title || - steps.fallback.outputs.issue_title }} - ISSUE_BODY: >- - ${{ steps.generate.outputs.issue_body || - steps.fallback.outputs.issue_body }} - with: - github-token: ${{ steps.select-token.outputs.token }} - script: | - const fs = require('fs'); - const retryHelperPath = './.github/scripts/github-api-with-retry.js'; - const retryHelpers = fs.existsSync(retryHelperPath) - ? require(retryHelperPath) - : { - withRetry: (fn) => fn(), - paginateWithRetry: (githubInstance, method, params) => - githubInstance.paginate(method, params), - }; - const { withRetry } = retryHelpers; - - let agentKey = 'codex'; - try { - const { resolveAgentFromLabels } = require('./.github/scripts/agent_registry.js'); - const prLabels = context.payload.pull_request?.labels || []; - agentKey = resolveAgentFromLabels(prLabels, { - registryPath: './.github/agents/registry.yml', - }); - } catch (err) { - core.warning( - `Failed to resolve agent from PR labels; defaulting to codex: ${err.message}` - ); - agentKey = 'codex'; - } - const normalized = String(agentKey || 'codex').trim().toLowerCase() || 'codex'; - const agentLabel = `agent:${normalized}`; - const fromLabel = `from:${normalized}`; - - const title = process.env.ISSUE_TITLE; - const body = process.env.ISSUE_BODY; - - if (!title || !body) { - core.setFailed('Failed to generate issue title or body'); - return; - } - - const issue = await withRetry(() => github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: body, - labels: ['follow-up', agentLabel, fromLabel, 'agents:optimize'] - })); - - core.info(`Created issue #${issue.data.number}`); - core.setOutput('issue_number', issue.data.number); - core.setOutput('issue_url', issue.data.html_url); - - - name: Comment on original PR - if: steps.check-merged.outputs.merged == 'true' - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 - env: - ISSUE_NUMBER: ${{ steps.create-issue.outputs.issue_number }} - ISSUE_URL: ${{ steps.create-issue.outputs.issue_url }} - with: - github-token: ${{ steps.select-token.outputs.token }} - script: | - const fs = require('fs'); - const retryHelperPath = './.github/scripts/github-api-with-retry.js'; - const retryHelpers = fs.existsSync(retryHelperPath) - ? require(retryHelperPath) - : { - withRetry: (fn) => fn(), - paginateWithRetry: (githubInstance, method, params) => - githubInstance.paginate(method, params), - }; - const { withRetry } = retryHelpers; - - const issueNumber = process.env.ISSUE_NUMBER; - const issueUrl = process.env.ISSUE_URL; - - const body = [ - '📋 **Follow-up issue created:** #' + issueNumber, - '', - 'Verification concerns have been analyzed and structured into a follow-up issue.', - '', - '**Next steps:**', - '1. Review the generated issue', - '2. Add `agents:apply-suggestions` label to format for agent work', - '3. Add an `agent:*` label (e.g., `agent:codex`) to assign to an agent', - '', - '> Or work on it manually - the choice is yours!' - ].join('\n'); - - await withRetry(() => github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.payload.pull_request.number, - body: body - })); - - - name: Record terminal disposition - if: always() && steps.check-merged.outputs.merged == 'true' - env: - ORIGINAL_ISSUE_NUMBER: ${{ steps.collect.outputs.original_issue_number }} - ORIGINAL_ISSUE_TITLE: ${{ steps.collect.outputs.original_issue_title }} - FOLLOWUP_ISSUE_NUMBER: ${{ steps.create-issue.outputs.issue_number }} - FOLLOWUP_ISSUE_URL: ${{ steps.create-issue.outputs.issue_url }} - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 - with: - github-token: ${{ steps.select-token.outputs.token }} - script: | - const fs = require('fs'); - const { - normalizeTerminalDisposition, - formatTerminalDispositionMarkdown, - } = require('./.github/scripts/terminal_disposition.js'); - - const prNumber = context.payload.pull_request.number; - const sourceIssue = process.env.ORIGINAL_ISSUE_NUMBER || ''; - const sourceType = sourceIssue ? 'source-issue' : 'merged-pr'; - const sourceId = sourceIssue || String(prNumber); - const followupIssue = process.env.FOLLOWUP_ISSUE_NUMBER || ''; - - const record = normalizeTerminalDisposition({ - source_type: sourceType, - source_id: sourceId, - source_title: process.env.ORIGINAL_ISSUE_TITLE || context.payload.pull_request.title, - pr_number: prNumber, - issue_number: sourceIssue || undefined, - disposition: followupIssue ? 'follow-up-created' : 'no-follow-up-created', - reason: followupIssue - ? 'Verification follow-up issue was created.' - : 'Workflow completed without a follow-up issue output.', - followup_issue_number: followupIssue || undefined, - followup_issue_url: process.env.FOLLOWUP_ISSUE_URL || undefined, - workflow: context.workflow, - run_id: context.runId, - run_attempt: context.runAttempt, - artifact_name: `verifier-terminal-disposition-${context.runId}`, - artifact_family: 'verifier-terminal-disposition', - actor: context.actor, - }); - - fs.mkdirSync('agent-metrics', { recursive: true }); - fs.writeFileSync( - 'agent-metrics/verifier-terminal-disposition.ndjson', - `${JSON.stringify(record)}\n` - ); - const markdown = formatTerminalDispositionMarkdown([record]); - fs.writeFileSync('terminal-disposition-summary.md', `${markdown}\n`); - await core.summary - .addHeading('Terminal Disposition') - .addRaw(markdown) - .write(); - - - name: Upload terminal disposition artifact - if: always() && steps.check-merged.outputs.merged == 'true' - uses: actions/upload-artifact@65ecb0ca2d3e252f7b82842cd0489c883189f7d0 # v7 - with: - name: verifier-terminal-disposition-${{ github.run_id }} - path: | - agent-metrics/verifier-terminal-disposition.ndjson - terminal-disposition-summary.md - retention-days: 14 - - - name: Remove trigger label - if: steps.check-merged.outputs.merged == 'true' - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 - continue-on-error: true - with: - github-token: ${{ steps.select-token.outputs.token }} - script: | - const fs = require('fs'); - const retryHelperPath = './.github/scripts/github-api-with-retry.js'; - const retryHelpers = fs.existsSync(retryHelperPath) - ? require(retryHelperPath) - : { - withRetry: (fn) => fn(), - paginateWithRetry: (githubInstance, method, params) => - githubInstance.paginate(method, params), - }; - const { withRetry } = retryHelpers; - - try { - await withRetry(() => github.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.payload.pull_request.number, - name: 'verify:create-issue' - })); - core.info('Removed verify:create-issue label'); - } catch (error) { - core.warning('Could not remove label: ' + error.message); - } diff --git a/.github/workflows/agents-verify-to-issue.yml b/.github/workflows/agents-verify-to-issue.yml deleted file mode 100644 index 012d4f141d..0000000000 --- a/.github/workflows/agents-verify-to-issue.yml +++ /dev/null @@ -1,262 +0,0 @@ -name: Create Issue from Verification (DEPRECATED) - -# DEPRECATED: Use agents-verify-to-issue-v2.yml instead -# This workflow is disabled to prevent duplicate issue creation. -# The v2 version creates well-structured issues with tasks and acceptance criteria. - -on: - pull_request_target: - types: [labeled] - -permissions: - contents: read - pull-requests: write - issues: write - -jobs: - create-issue: - # DISABLED: v2 workflow handles this now to prevent duplicate issues - if: false && github.event.label.name == 'verify:create-issue' - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - - name: Setup API client - uses: ./.github/actions/setup-api-client - with: - secrets: ${{ toJSON(secrets) }} - github_token: ${{ github.token }} - - - name: Check PR is merged - id: check-merged - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 - with: - script: | - const pr = context.payload.pull_request; - if (!pr.merged) { - core.setFailed('PR must be merged before creating follow-up issue'); - return; - } - core.setOutput('merged', 'true'); - - - name: Find and extract verification feedback - id: extract - if: steps.check-merged.outputs.merged == 'true' - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 - with: - script: | - const { createTokenAwareRetry } = require('./.github/scripts/github-api-with-retry.js'); - const { paginateWithRetry } = await createTokenAwareRetry({ - github, - core, - env: process.env, - task: 'agents-verify-to-issue', - capabilities: ['issues:read'], - }); - - const comments = await paginateWithRetry( - github.rest.issues.listComments, - { - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.payload.pull_request.number, - per_page: 100 - } - ); - - // Look for verification report comment - const verifyComment = comments.find(c => - c.body.includes('## PR Verification Report') || - c.body.includes('## PR Verification Comparison') || - c.body.includes('### Concerns') || - c.body.includes('Verdict:') - ); - - if (!verifyComment) { - core.setFailed( - 'No verification comment found on this PR. ' + - 'Add verify:evaluate or verify:compare label first.' - ); - return; - } - - const comment = verifyComment.body; - core.info('Found verification comment'); - - // Extract CONCERNS section - const concernsMatch = comment.match(/### Concerns\s*\n([\s\S]*?)(?=###|##|$)/i); - let concerns = concernsMatch ? concernsMatch[1].trim() : ''; - - // Also try alternate formats - if (!concerns) { - const altMatch = comment.match(/\*\*Concerns:\*\*\s*([\s\S]*?)(?=\*\*|##|$)/i); - concerns = altMatch ? altMatch[1].trim() : ''; - } - - // Extract low scores (anything < 7/10) - const scoreMatches = [...comment.matchAll(/(\w+):\s*(\d+)\/10/gi)]; - const lowScores = scoreMatches - .filter(m => parseInt(m[2]) < 7) - .map(m => `- ${m[1]}: ${m[2]}/10`); - - // Extract verdict - const verdictMatch = comment.match(/Verdict:\s*\*?\*?(\w+)\*?\*?/i); - const verdict = verdictMatch ? verdictMatch[1] : 'Unknown'; - - // Build summary - let summary = ''; - if (concerns) { - summary += '### Concerns from Verification\n\n' + concerns + '\n\n'; - } - if (lowScores.length > 0) { - summary += '### Scores Below 7/10\n\n' + lowScores.join('\n') + '\n\n'; - } - if (!summary) { - summary = - 'No specific concerns extracted from verification report.\n\n' + - 'Please review the original verification comment for details.'; - } - - // Set outputs using environment file (handles multi-line content) - const fs = require('fs'); - const envFile = process.env.GITHUB_OUTPUT; - - // Use delimiter for multi-line output - const delimiter = 'EOF_' + Math.random().toString(36).substring(2); - fs.appendFileSync( - envFile, - `concerns_summary<<${delimiter}\n${summary}\n${delimiter}\n` - ); - - core.setOutput('verdict', verdict); - core.setOutput('has_concerns', (concerns || lowScores.length > 0) ? 'true' : 'false'); - - - name: Create follow-up issue - id: create-issue - if: steps.check-merged.outputs.merged == 'true' - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 - env: - VERDICT: ${{ steps.extract.outputs.verdict }} - CONCERNS_SUMMARY: ${{ steps.extract.outputs.concerns_summary }} - with: - script: | - const { createTokenAwareRetry } = require('./.github/scripts/github-api-with-retry.js'); - const { withRetry } = await createTokenAwareRetry({ - github, - core, - env: process.env, - task: 'agents-verify-to-issue', - capabilities: ['issues:write'], - }); - const prNumber = context.payload.pull_request.number; - const prTitle = context.payload.pull_request.title; - const prUrl = context.payload.pull_request.html_url; - const concernsSummary = process.env.CONCERNS_SUMMARY || 'No concerns extracted.'; - const verdict = process.env.VERDICT || 'Unknown'; - - const issueBody = [ - '## Follow-up from PR #' + prNumber, - '', - 'Original PR: [#' + prNumber + ' - ' + prTitle + '](' + prUrl + ')', - 'Verification Verdict: ' + verdict, - '', - '---', - '', - concernsSummary, - '', - '## Suggested Tasks', - '', - '- [ ] Review the concerns identified above', - '- [ ] Address each issue or document why it is not applicable', - '- [ ] Update tests if needed', - '- [ ] Consider re-verification after changes', - '', - '---', - '', - '## Context', - '', - 'This issue was created from verification feedback on a merged PR.', - '', - '
', - 'How to use this issue', - '', - '1. Add `agents:optimize` label to get AI-suggested improvements', - '2. Add `agents:apply-suggestions` to format for agent work', - '3. Add an `agent:*` label (e.g., `agent:codex`) to assign to an agent', - '', - 'Or work on it manually - the choice is yours!', - '', - '
', - '', - '---', - '*Auto-generated by verify-to-issue workflow*' - ].join('\n'); - - const issue = await withRetry((client) => client.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: '[Follow-up] Address verification concerns from PR #' + prNumber, - body: issueBody, - labels: ['follow-up', 'agents:optimize'] - })); - - core.info('Created issue #' + issue.data.number); - core.setOutput('issue_number', issue.data.number); - core.setOutput('issue_url', issue.data.html_url); - - - name: Comment on original PR - if: steps.check-merged.outputs.merged == 'true' - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 - env: - ISSUE_NUMBER: ${{ steps.create-issue.outputs.issue_number }} - with: - script: | - const { createTokenAwareRetry } = require('./.github/scripts/github-api-with-retry.js'); - const { withRetry } = await createTokenAwareRetry({ - github, - core, - env: process.env, - task: 'agents-verify-to-issue', - capabilities: ['issues:write'], - }); - const issueNumber = process.env.ISSUE_NUMBER; - const body = - '📋 Follow-up issue created: #' + - issueNumber + - '\n\nVerification concerns have been captured in the new issue for tracking.'; - - await withRetry((client) => client.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.payload.pull_request.number, - body: body - })); - - - name: Remove trigger label - if: steps.check-merged.outputs.merged == 'true' - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 - continue-on-error: true - with: - script: | - try { - const { - createTokenAwareRetry, - } = require('./.github/scripts/github-api-with-retry.js'); - const { withRetry } = await createTokenAwareRetry({ - github, - core, - env: process.env, - task: 'agents-verify-to-issue', - capabilities: ['issues:write'], - }); - await withRetry((client) => client.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.payload.pull_request.number, - name: 'verify:create-issue' - })); - core.info('Removed verify:create-issue label'); - } catch (error) { - core.warning('Could not remove label: ' + error.message); - } diff --git a/WORKFLOW_USER_GUIDE.md b/WORKFLOW_USER_GUIDE.md index a81673b1ec..2a84a88428 100644 --- a/WORKFLOW_USER_GUIDE.md +++ b/WORKFLOW_USER_GUIDE.md @@ -505,7 +505,7 @@ Orchestrates entire pipeline: 1. PR must be merged 2. PR must have verification comment (from `verify:evaluate` or `verify:compare`) 3. Verification comment must contain concerns or low scores -4. Check `agents-verify-to-issue-v2.yml` logs +4. Check `agents-80-pr-event-hub.yml` logs --- diff --git a/docs/CODEX_TOKEN_REFRESH.md b/docs/CODEX_TOKEN_REFRESH.md index 05b4b7501d..397b7a594b 100644 --- a/docs/CODEX_TOKEN_REFRESH.md +++ b/docs/CODEX_TOKEN_REFRESH.md @@ -39,7 +39,7 @@ gh secret set CODEX_AUTH_JSON < ~/.codex/auth.json ### 3. Verify ```bash -gh workflow run agents-keepalive-loop.yml +gh workflow run agents-81-gate-followups.yml -f pr_number= -f force_retry=true # Check logs for new expiration date ``` diff --git a/docs/LABELS.md b/docs/LABELS.md index bf77f82760..3aa7c41e80 100644 --- a/docs/LABELS.md +++ b/docs/LABELS.md @@ -339,7 +339,7 @@ These labels trigger the post-merge verifier workflow on a merged PR. **Use Case:** User-triggered creation of follow-up work from verification feedback. Replaces automatic issue creation which was too aggressive. -**Workflow:** `agents-verify-to-issue-v2.yml` +**Workflow:** `agents-80-pr-event-hub.yml` --- @@ -377,7 +377,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` --- @@ -396,7 +396,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` --- @@ -410,7 +410,7 @@ These labels are used for categorization but do not trigger workflows. **Effect:** Indicates this issue was created as follow-up to another issue or PR. -**Applied by:** `agents-verify-to-issue-v2.yml` workflow +**Applied by:** `agents-80-pr-event-hub.yml` workflow ---