Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/sync-manifest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ workflows:
- stranske/Template

- source: .github/workflows/autofix.yml
description: "Autofix workflow - automatically fixes lint/format issues"
description: "Autofix workflow - repairs failed/timed-out checks; cancellations do not trigger repairs"

- source: .github/workflows/pr-46-dependency-repair-contract.yml
description: "Dependency repair contract - keeps bot PRs bot-owned and validates agent repair promotion provenance"
Expand All @@ -60,7 +60,7 @@ workflows:
description: "PR event hub - consolidates PR meta, bot comments, and verify-to-issue handlers"

- source: .github/workflows/agents-81-gate-followups.yml
description: "Gate followups hub - consolidates keepalive and autofix followups"
description: "Gate followups hub - keepalive and autofix; budgets count Gate failures with failed jobs"

- source: .github/workflows/agents-keepalive-sweep.yml
description: "Keepalive sweep - periodic level-based resync; dispatches the loop for open agent PRs so silent zero-commit stalls resurface"
Expand Down
190 changes: 135 additions & 55 deletions .github/workflows/agents-autofix-loop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -354,8 +354,10 @@ jobs:
outputs.gate_conclusion = String(run?.conclusion || run?.status || '');
outputs.gate_run_id = String(run?.id || manualInputs.runId || '');

if ((run.conclusion || '').toLowerCase() === 'success') {
return stop('upstream Gate succeeded');
const isFailure = (value) =>
['failure', 'timed_out'].includes(String(value || '').toLowerCase());
if (!isFailure(run.conclusion)) {
return stop('upstream Gate has no counted failure', 'gate_not_failed');
}

if ((run.event || '').toLowerCase() !== 'pull_request') {
Expand Down Expand Up @@ -494,54 +496,6 @@ jobs:
? 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,
Expand All @@ -553,7 +507,8 @@ jobs:
}
);

const workflowFile = 'agents-autofix-loop.yml';
// Count failed Gate executions, not unrelated follow-up workflow runs.
const workflowFile = run.workflow_id;
// Reduce attempts for auto-escalated PRs (they weren't agent-initiated)
const isEscalated = labels.includes('autofix:escalated');
const maxAttempts = isEscalated
Expand All @@ -572,7 +527,82 @@ jobs:
}
);

const attemptCount = previousRuns.length + 1;
const listJobsForGateAttempt = async (runId, attemptNumber, latestJobs = null) => {
const currentAttempt = Math.max(1, Number(run.run_attempt) || 1);
if (runId === run.id && attemptNumber === currentAttempt && latestJobs) {
return latestJobs;
}
const listAttemptJobs = github.rest.actions.listJobsForWorkflowRunAttempt;
if (typeof listAttemptJobs === 'function') {
return await paginateWithRetry(github, listAttemptJobs, {
owner,
repo,
run_id: runId,
attempt_number: attemptNumber,
per_page: 100,
});
}
if (attemptNumber === 1) {
return await paginateWithRetry(
github,
github.rest.actions.listJobsForWorkflowRun,
{
owner,
repo,
run_id: runId,
per_page: 100,
}
);
}
return [];
};

const countFailingAttemptsForRun = async (gateRun, latestJobs = null) => {
const maxAttempt = Math.max(1, Number(gateRun.run_attempt) || 1);
let count = 0;
for (let attempt = 1; attempt <= maxAttempt; attempt += 1) {
const attemptJobs = await listJobsForGateAttempt(
gateRun.id,
attempt,
gateRun.id === run.id ? latestJobs : null
);
if (attemptJobs.some((job) => isFailure(job.conclusion))) {
count += 1;
}
}
return count;
};

const gateRunsById = new Map();
for (const candidate of previousRuns) {
if (candidate.head_sha !== run.head_sha || !isFailure(candidate.conclusion)) {
continue;
}
const existing = gateRunsById.get(candidate.id);
const candidateAttempt = Number(candidate.run_attempt) || 1;
const existingAttempt = existing ? Number(existing.run_attempt) || 1 : 0;
if (!existing || candidateAttempt >= existingAttempt) {
gateRunsById.set(candidate.id, candidate);
}
}
gateRunsById.set(run.id, {
...run,
run_attempt: Math.max(
Number(run.run_attempt) || 1,
Number(gateRunsById.get(run.id)?.run_attempt) || 0
),
});

let attemptsWithFailingJobs = 0;
let gateAttemptsExamined = 0;
for (const gateRun of gateRunsById.values()) {
gateAttemptsExamined += Math.max(1, Number(gateRun.run_attempt) || 1);
attemptsWithFailingJobs += await countFailingAttemptsForRun(
gateRun,
gateRun.id === run.id ? jobs : null
);
}
const attemptCount = attemptsWithFailingJobs;
outputs.attempts = String(attemptCount);
outputs.max_attempts = String(maxAttempts);

Expand All @@ -581,7 +611,7 @@ jobs:
let triggerStep = null;
for (const job of jobs) {
const conclusion = (job.conclusion || job.status || '').toLowerCase();
if (!conclusion || ['success', 'skipped'].includes(conclusion)) {
if (!isFailure(conclusion)) {
continue;
}

Expand All @@ -590,7 +620,7 @@ jobs:
? job.steps
.filter((step) => {
const stepConclusion = (step.conclusion || step.status || '').toLowerCase();
return stepConclusion && !['success', 'skipped'].includes(stepConclusion);
return isFailure(stepConclusion);
})
.map(
(step) =>
Expand All @@ -609,7 +639,7 @@ jobs:
const failingStep = Array.isArray(job.steps)
? job.steps.find((step) => {
const stepConclusion = (step.conclusion || step.status || '').toLowerCase();
return stepConclusion && !['success', 'skipped'].includes(stepConclusion);
return isFailure(stepConclusion);
})
: null;
triggerStep = failingStep || null;
Expand Down Expand Up @@ -645,6 +675,7 @@ jobs:
`PR: #${prNumber}`,
`Head SHA: ${headSha}`,
`Autofix attempts for this head: ${attemptCount} / ${maxAttempts}`,
`Gate runs examined: ${gateRunsById.size}; gate attempts examined: ${gateAttemptsExamined}; attempts with failing jobs: ${attemptsWithFailingJobs}`,
'Fix scope: src/, tests/, tools/, scripts/, agents/, templates/, .github/',
];

Expand All @@ -656,6 +687,55 @@ jobs:

outputs.appendix = appendixLines.join('\n');

if (failingJobs.length === 0) {
return stop('Gate has no failing jobs', 'no_failing_jobs');
}
Comment thread
stranske marked this conversation as resolved.
Comment thread
stranske marked this conversation as resolved.

// Auto-escalation: only after confirming real failed/timed-out Gate jobs.
// 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 gateFailed = isFailure((run.conclusion || '').toLowerCase());

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'
);
}

if (attemptCount > maxAttempts) {
return stop(
`autofix attempt limit reached (${attemptCount} > ${maxAttempts})`,
Expand Down
8 changes: 4 additions & 4 deletions .github/workflows/autofix.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ jobs:
custom-predicate: >-
(event_name == 'pull_request_target' && action == 'labeled' &&
(label.name == 'autofix' || label.name == 'autofix:clean')) ||
(event_name == 'workflow_run' && workflow_run.conclusion == 'failure' &&
(event_name == 'workflow_run' && (workflow_run.conclusion == 'failure' || workflow_run.conclusion == 'timed_out') &&
length(workflow_run.pull_requests) > 0)

- name: Checkout for API helpers
Expand Down Expand Up @@ -124,7 +124,7 @@ jobs:
);

const failedChecks = (data.check_runs || []).filter(
(cr) => (cr.conclusion || '').toLowerCase() === 'failure'
(cr) => ['failure', 'timed_out'].includes((cr.conclusion || '').toLowerCase())
);

const relevantFailures = failedChecks.filter((cr) => {
Comment thread
stranske marked this conversation as resolved.
Expand Down Expand Up @@ -157,7 +157,7 @@ jobs:
{ maxRetries: 3 }
);
const failedJobs = jobs.filter(
(job) => (job.conclusion || '').toLowerCase() === 'failure'
(job) => ['failure', 'timed_out'].includes((job.conclusion || '').toLowerCase())
);
const relevantFailures = failedJobs.filter((job) => {
const name = String(job.name || '').toLowerCase();
Expand Down Expand Up @@ -222,7 +222,7 @@ jobs:
const triggerHeadSha = String(run?.head_sha || run?.head_commit?.id || '');
const runId = Number(run?.id || 0);

if (run.conclusion !== 'failure') {
if (!['failure', 'timed_out'].includes(run.conclusion)) {
core.info(
`${workflowName} concluded '${run.conclusion}' — no autofix needed`
);
Expand Down
8 changes: 4 additions & 4 deletions config/template-drift-allowlist.txt
Original file line number Diff line number Diff line change
Expand Up @@ -189,11 +189,11 @@ fingerprint_refreshed = 2026-08-24
[pair.17]
main = .github/workflows/autofix.yml
template = templates/consumer-repo/.github/workflows/autofix.yml
main_sha256 = 81f2268c29cc8b5da388a44eab46a8613df153d9e4e83449ab3a5070c03558a7
template_sha256 = 88cf48708d972137ef8e9fb3efc2323ee93c8cab16eaba0fc22c8c11ea6695c4
divergence = Intentional divergence reviewed 2026-08-23: the Workflows source runs its local reusable autofix workflow and uses the repository's CI concurrency/context contract; the consumer template calls the published reusable workflow with SHA-pinned actions, SERVICE_BOT_PAT, and workflow-run PR context recovery. Do not align wholesale because the consumer is a deployable wrapper rather than the source implementation.
main_sha256 = 96c8131203431b7da850474e06fa169a2580fa5fa4759674be712ea408f17d16
template_sha256 = 73536de641ae21b89b5de24848a850e2753f5bc4d608f90498b44007fc2b8cfe
divergence = Timeout handling 2026-09-14: four identical conclusion-filter edits applied to source and consumer autofix; both still exclude cancellations. Verified the exact same transformation against origin/main on both sides. Fingerprints refreshed; existing divergence and its review date are unchanged. Prior divergence: Intentional divergence reviewed 2026-08-23: the Workflows source runs its local reusable autofix workflow and uses the repository's CI concurrency/context contract; the consumer template calls the published reusable workflow with SHA-pinned actions, SERVICE_BOT_PAT, and workflow-run PR context recovery. Do not align wholesale because the consumer is a deployable wrapper rather than the source implementation.
divergence_reviewed = 2026-08-23
fingerprint_refreshed = 2026-08-23
fingerprint_refreshed = 2026-09-14

[pair.18]
main = .github/workflows/maint-coverage-guard.yml
Expand Down
7 changes: 7 additions & 0 deletions docs/WORKFLOW_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,13 @@ _Inline Gate helper_
- **`agents-autofix-rebase.yml`** — See Agents section; merges the base branch when Gate flags stale branches, preventing common “needs rebase” failures from blocking progress.
- **`agents-bot-comment-autolabel.yml`** / **`agents-bot-comment-handler.yml`** — Automatically harvest trusted bot review comments and apply them via the autofix pathways without manual labelling.

Autofix budgets count only same-head Gate runs concluded `failure` or `timed_out`
with at least one job carrying either conclusion. Cancelled, skipped, successful,
and jobless runs do not consume the budget or trigger `needs-human`. The heavy
loop reports the number of Gate failures examined and the attempts with failing
jobs; a current Gate without a failed job cannot escalate even after earlier
failures. Consumer Gate Followups uses the same rule.

#### Autofix & Lint Coordination
1. **Gate emits signals** — `pr-00-gate.yml` attaches artifacts describing the failing job plus `mergeable_state`. When lint/format/typecheck/test jobs fail it dispatches `autofix_gate_failure`; when the PR is dirty/behind it dispatches `autofix_rebase_needed`.
2. **CI autofix first pass (`autofix.yml`)** — Runs Ruff/formatters/tests where possible and pushes fixes directly to the branch so same-run Gate retries can pass without escalation.
Expand Down
Loading
Loading