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
127 changes: 127 additions & 0 deletions modules/software-engineer/__tests__/lib/ci-classify.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { describe, it, expect } from 'vitest';
import { classifyJobDeterministic, classifyDeterministic, INFRA_LOG_PATTERNS } from '../../lib/ci-classify';

describe('classifyJobDeterministic', () => {
it('empty steps → infra (cancelled/startup_failure at 0 steps)', () => {
expect(classifyJobDeterministic({ name: 'build', steps: [], conclusion: 'cancelled' })).toBe('infra');
});

it('cancelled with no completed steps → infra', () => {
expect(
classifyJobDeterministic({
name: 'build',
conclusion: 'cancelled',
steps: [{ status: 'queued', conclusion: null }, { status: 'in_progress', conclusion: null }],
}),
).toBe('infra');
});

it('startup_failure with no completed steps → infra', () => {
expect(
classifyJobDeterministic({
name: 'build',
conclusion: 'startup_failure',
steps: [{ status: 'queued', conclusion: null }],
}),
).toBe('infra');
});

it('cancelled but with a completed step → ambiguous (not a pure infra cancel)', () => {
expect(
classifyJobDeterministic({
name: 'build',
conclusion: 'cancelled',
steps: [{ status: 'completed', conclusion: 'success' }, { status: 'in_progress', conclusion: null }],
}),
).toBe('ambiguous');
});

it('each INFRA_LOG_PATTERNS entry, when matched in the log tail, resolves to infra', () => {
const samples = [
'Failed to resolve action download info for actions/checkout',
'Error: Service Unavailable',
'Failed to download some index files',
'the self-hosted runner has been lost',
'the self-hosted runner has been removed',
'timeout waiting for job — no steps ran',
];
// Two samples cover the 'lost'/'removed' alternation of one pattern, so samples outnumber patterns.
expect(samples.length).toBeGreaterThanOrEqual(INFRA_LOG_PATTERNS.length);
for (const logTail of samples) {
expect(
classifyJobDeterministic({
name: 'build',
conclusion: 'failure',
steps: [{ status: 'completed', conclusion: 'failure' }],
logTail,
}),
).toBe('infra');
}
});

it('a normal failed job with real steps and no infra pattern → ambiguous', () => {
expect(
classifyJobDeterministic({
name: 'test',
conclusion: 'failure',
steps: [
{ status: 'completed', conclusion: 'success' },
{ status: 'completed', conclusion: 'failure' },
],
logTail: 'AssertionError: expected 1 to equal 2',
}),
).toBe('ambiguous');
});
});

describe('classifyDeterministic', () => {
it('all failing checks resolve to infra → external, with a reason per check', () => {
const result = classifyDeterministic({
failingChecks: [
{ name: 'build', job: { name: 'build', steps: [], conclusion: 'cancelled' } },
{ name: 'test', job: { name: 'test', steps: [], conclusion: 'startup_failure' } },
],
baseFailingCheckNames: new Set(),
});
expect(result.verdict).toBe('external');
expect(result.reasons).toHaveLength(2);
expect(result.ambiguousChecks).toHaveLength(0);
});

it('a check name present in baseFailingCheckNames → external, reason cites main', () => {
const result = classifyDeterministic({
failingChecks: [{ name: 'pnpm-audit', job: null }],
baseFailingCheckNames: new Set(['pnpm-audit']),
});
expect(result.verdict).toBe('external');
expect(result.reasons[0]).toMatch(/red on main/);
});

it('a mix of infra + one non-matching check → ambiguous, with only the unresolved check listed', () => {
const result = classifyDeterministic({
failingChecks: [
{ name: 'build', job: { name: 'build', steps: [], conclusion: 'cancelled' } },
{ name: 'lint', job: { name: 'lint', steps: [{ status: 'completed', conclusion: 'failure' }], conclusion: 'failure' } },
],
baseFailingCheckNames: new Set(),
});
expect(result.verdict).toBe('ambiguous');
expect(result.ambiguousChecks).toEqual(['lint']);
expect(result.reasons).toHaveLength(1);
});

it('empty failingChecks → external (vacuous, defensive)', () => {
const result = classifyDeterministic({ failingChecks: [], baseFailingCheckNames: new Set() });
expect(result.verdict).toBe('external');
expect(result.ambiguousChecks).toHaveLength(0);
});

it('a check with no job info and not on the base-red list → ambiguous', () => {
const result = classifyDeterministic({
failingChecks: [{ name: 'custom-check', job: null }],
baseFailingCheckNames: new Set(),
});
expect(result.verdict).toBe('ambiguous');
expect(result.ambiguousChecks).toEqual(['custom-check']);
});
});
88 changes: 88 additions & 0 deletions modules/software-engineer/lib/ci-classify.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// @ts-nocheck
/**
* CI-failure classification (issue #54). Pure functions — no I/O — so the decision table is
* unit-testable, mirroring lib/pr-status.ts. Decides whether a red PR's failing checks are
* ADDRESSABLE by an in-repo code change, or EXTERNAL (infra incident, repo-wide upstream breakage) —
* so pr-monitor can skip spending a bounded CI-fix pass on something no diff can fix.
*
* Two cheap, deterministic signals are checked first (no model call, no cost):
* - the same check name is currently red on the base branch's HEAD → repo-wide, not this PR's diff.
* - the job's steps/conclusion or log tail match a known infrastructure-failure shape.
* Only a check that fails BOTH signals is "ambiguous" and falls through to a one-turn model read
* (see workers/pr-monitor.ts's classifyCiFailure, which does the I/O and calls these).
*/

/** Log-line shapes seen from real GitHub Actions infra incidents — not application failures. */
export const INFRA_LOG_PATTERNS = [
/Failed to resolve action download info/i,
/Service Unavailable/i,
/Failed to download.*index files|index files failed to download/i,
/runner has been (lost|removed)/i,
/timeout.*no steps? (ran|executed)/i,
];

export interface JobSignal {
name: string;
steps: Array<{ status: string; conclusion: string | null }>;
conclusion: string | null;
/** Fetched only when the empty-steps/conclusion check doesn't already decide it. */
logTail?: string;
}

/** A single job's deterministic verdict. 'ambiguous' means "no infra signal found" — it may still be
* a real, addressable failure; the caller falls through to a model read for these. */
export function classifyJobDeterministic(job: JobSignal): 'infra' | 'ambiguous' {
if (!job) return 'ambiguous';
if (!job.steps?.length) return 'infra'; // cancelled/startup_failure before any step ran
if (
['cancelled', 'startup_failure'].includes(String(job.conclusion)) &&
job.steps.every((s) => s.status !== 'completed')
) {
return 'infra';
}
if (job.logTail && INFRA_LOG_PATTERNS.some((p) => p.test(job.logTail))) return 'infra';
return 'ambiguous';
}

export interface ClassifyCheckInput {
name: string;
job?: JobSignal | null;
}

export interface ClassifyInput {
failingChecks: ClassifyCheckInput[];
/** Check names currently red on the base branch's HEAD. */
baseFailingCheckNames: Set<string>;
}

export type CiVerdict = 'external' | 'addressable' | 'ambiguous';

export interface ClassifyResult {
verdict: CiVerdict;
reasons: string[];
/** Failing check names that resolved neither to "also red on main" nor to an infra signal — these
* need the model (or, absent that, are treated as addressable — see the fail-safe note below). */
ambiguousChecks: string[];
}

/** Classify every failing check using ONLY the cheap deterministic signals. A check resolves
* 'external' (via one of the two signals) or stays ambiguous — never "addressable" here, since
* a deterministic pass has no way to positively confirm a fix is possible, only to rule out
* external causes. The verdict is 'external' only when every failing check resolved that way. */
export function classifyDeterministic(input: ClassifyInput): ClassifyResult {
const reasons: string[] = [];
const ambiguousChecks: string[] = [];
for (const c of input.failingChecks ?? []) {
if (input.baseFailingCheckNames?.has(c.name)) {
reasons.push(`"${c.name}" is also red on main — repo-wide, not this PR's diff`);
continue;
}
if (c.job && classifyJobDeterministic(c.job) === 'infra') {
reasons.push(`"${c.name}" failed with infrastructure signals (empty/cancelled steps or a known infra log pattern)`);
continue;
}
ambiguousChecks.push(c.name);
}
const verdict: CiVerdict = ambiguousChecks.length === 0 ? 'external' : 'ambiguous';
return { verdict, reasons, ambiguousChecks };
}
33 changes: 33 additions & 0 deletions modules/software-engineer/lib/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,5 +199,38 @@ export function githubClient(token: string) {
listCheckRuns(owner: string, name: string, ref: string) {
return j(`/repos/${owner}/${name}/commits/${encodeURIComponent(ref)}/check-runs?per_page=100`);
},
/** Actions workflow runs attached to a commit — used to map a Checks-API failing check back to
* its Actions job for step-level and log-tail signals the Checks API doesn't expose. */
listWorkflowRunsForCommit(owner: string, name: string, sha: string) {
return j(`/repos/${owner}/${name}/actions/runs?head_sha=${encodeURIComponent(sha)}&per_page=50`);
},
/** Jobs (with .steps[]) for one workflow run. */
listWorkflowRunJobs(owner: string, name: string, runId: number | string) {
return j(`/repos/${owner}/${name}/actions/runs/${runId}/jobs?per_page=100`);
},
/** Plain-text log tail for one job, capped at `maxBytes`. The endpoint 302s to a short-lived blob
* URL; fetch follows the redirect. Caller is responsible for secret-redaction before this text is
* stored or sent to a model — this client never logs or persists it itself. Best-effort: any
* fetch failure (expired job, permissions) returns ''. */
async getJobLogTail(owner: string, name: string, jobId: number | string, maxBytes = 8192): Promise<string> {
try {
const r = await fetch(`${BASE}/repos/${owner}/${name}/actions/jobs/${jobId}/logs`, { headers });
if (!r.ok) return '';
const text = await r.text();
return text.length > maxBytes ? text.slice(-maxBytes) : text;
} catch {
return '';
}
},
/** Latest commit sha on a branch — used to compare a failing check against the base branch's
* current head (repo-wide breakage vs this PR's diff). */
async getBranchHeadSha(owner: string, name: string, branch: string): Promise<string | null> {
try {
const b = await j(`/repos/${owner}/${name}/branches/${encodeURIComponent(branch)}`);
return b?.commit?.sha ?? null;
} catch {
return null;
}
},
};
}
4 changes: 4 additions & 0 deletions modules/software-engineer/lib/model-select.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@
* and the review-KB are single no-tools JSON turns built around the Claude Agent SDK's noTools seam,
* which the Codex CLI has no equivalent for. An invalid/unauthorised codex resolution falls back to
* claude rather than failing the run — routing must never be the reason a run dies.
*
* `ci-classify` (pr-monitor's pre-fix-pass CI classifier) is a cheap utility phase, same category as
* `triage`/`reflect` — not in ESCALATION_PHASES or CODEX_PHASES. It always resolves to the plain
* claude default unless a project maps `phaseModels['ci-classify']` to something cheaper.
*/

export type Engine = 'claude' | 'codex';
Expand Down
Loading
Loading