diff --git a/modules/software-engineer/__tests__/lib/ci-classify.test.ts b/modules/software-engineer/__tests__/lib/ci-classify.test.ts new file mode 100644 index 00000000..37119b11 --- /dev/null +++ b/modules/software-engineer/__tests__/lib/ci-classify.test.ts @@ -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']); + }); +}); diff --git a/modules/software-engineer/lib/ci-classify.ts b/modules/software-engineer/lib/ci-classify.ts new file mode 100644 index 00000000..9ac2762e --- /dev/null +++ b/modules/software-engineer/lib/ci-classify.ts @@ -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; +} + +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 }; +} diff --git a/modules/software-engineer/lib/github.ts b/modules/software-engineer/lib/github.ts index ec44a476..382335a3 100644 --- a/modules/software-engineer/lib/github.ts +++ b/modules/software-engineer/lib/github.ts @@ -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 { + 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 { + try { + const b = await j(`/repos/${owner}/${name}/branches/${encodeURIComponent(branch)}`); + return b?.commit?.sha ?? null; + } catch { + return null; + } + }, }; } diff --git a/modules/software-engineer/lib/model-select.ts b/modules/software-engineer/lib/model-select.ts index a38b68c7..9d5135a5 100644 --- a/modules/software-engineer/lib/model-select.ts +++ b/modules/software-engineer/lib/model-select.ts @@ -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'; diff --git a/modules/software-engineer/workers/pr-monitor.ts b/modules/software-engineer/workers/pr-monitor.ts index 5126ef9d..acc0d845 100644 --- a/modules/software-engineer/workers/pr-monitor.ts +++ b/modules/software-engineer/workers/pr-monitor.ts @@ -9,16 +9,149 @@ * Runs on a cron (scan all) or for a single run (webhook nudge). */ import { createClient } from '@supabase/supabase-js'; -import { getProject } from '../lib/credentials.js'; +import { getProject, getCodeRepos } from '../lib/credentials.js'; import { enqueuePhase } from '../lib/enqueue.js'; import { githubClient } from '../lib/github.js'; import { redactToken } from '../lib/git.js'; -import { listRunPrs, upsertRunPr } from '../lib/run-state.js'; +import { listRunPrs, upsertRunPr, recordPhaseStart, recordPhaseEnd, writeEvent } from '../lib/run-state.js'; import { dispatchProject, dispatchAll } from '../lib/dispatch.js'; import { isTrustedFeedbackAuthor } from '../lib/feedback-authz.js'; import { approveSpec, approveRunReviewLearnings } from '../lib/memory.js'; import { syncMemoryToRepo } from '../lib/memory-git.js'; import { summarizeChecks } from '../lib/pr-status.js'; +import { classifyDeterministic } from '../lib/ci-classify.js'; +import { InProcessRunner } from '../lib/agent-session.js'; +import { resolvePhaseModel } from '../lib/model-select.js'; + +const CHECK_FAILURE_CONCLUSIONS = ['failure', 'timed_out', 'cancelled', 'action_required']; + +/** + * Classify a red PR's failing checks BEFORE spending a bounded CI-fix pass (issue #54). Cheap + * deterministic signals first (base-branch comparison, job step/log infra patterns) — free, no model + * call. Only checks that survive both fall through to one no-tools model turn. Fail-SAFE: any + * exception here returns 'addressable' (the pre-#54 behaviour), never 'external' — a broken + * classifier must not be the reason a run starves in `watching` forever. + */ +async function classifyCiFailure(supabase, ctx, gh, project, run, failingChecks) { + const FAIL_SAFE = { verdict: 'addressable', reasons: [], addressableSummary: null }; + if (!failingChecks.length) return { verdict: 'external', reasons: [], addressableSummary: null }; + try { + // Resolve each failing check's Actions job (name match on the check's commit's workflow runs). + const jobsBySha = new Map(); + async function jobsForSha(owner, name, sha) { + const key = `${owner}/${name}@${sha}`; + if (jobsBySha.has(key)) return jobsBySha.get(key); + const jobs = []; + try { + const runsRes = await gh.listWorkflowRunsForCommit(owner, name, sha); + for (const wr of runsRes?.workflow_runs ?? []) { + const jobsRes = await gh.listWorkflowRunJobs(owner, name, wr.id).catch(() => null); + for (const job of jobsRes?.jobs ?? []) jobs.push(job); + } + } catch { /* no deterministic job signal available — falls through to ambiguous */ } + jobsBySha.set(key, jobs); + return jobs; + } + + const resolved = []; + for (const c of failingChecks) { + const jobs = await jobsForSha(c.repoOwner, c.repoName, c.sha); + const match = jobs.find((j) => j.name === c.name) ?? null; + resolved.push({ + name: c.name, repoOwner: c.repoOwner, repoName: c.repoName, + jobId: match?.id ?? null, + job: match ? { name: match.name, steps: match.steps ?? [], conclusion: match.conclusion ?? null } : null, + }); + } + + // Base-branch red check names, once per distinct repo among the failing checks. + const baseFailingCheckNames = new Set(); + const codeRepos = await getCodeRepos(supabase, run.project_id).catch(() => []); + const seenRepos = new Set(); + for (const c of failingChecks) { + const key = `${c.repoOwner}/${c.repoName}`; + if (seenRepos.has(key)) continue; + seenRepos.add(key); + try { + const repoCfg = codeRepos.find((r) => r.repoOwner === c.repoOwner && r.repoName === c.repoName); + const base = repoCfg?.baseBranch || (await gh.defaultBranch(c.repoOwner, c.repoName).catch(() => null)); + if (!base) continue; + const headSha = await gh.getBranchHeadSha(c.repoOwner, c.repoName, base); + if (!headSha) continue; + const baseChecks = await gh.listCheckRuns(c.repoOwner, c.repoName, headSha).catch(() => null); + for (const bc of baseChecks?.check_runs ?? []) { + if (bc?.status === 'completed' && CHECK_FAILURE_CONCLUSIONS.includes(String(bc?.conclusion))) { + baseFailingCheckNames.add(bc.name); + } + } + } catch { /* best-effort — a repo's base-branch comparison failing just leaves it ambiguous */ } + } + + const det1 = classifyDeterministic({ failingChecks: resolved, baseFailingCheckNames }); + if (det1.verdict !== 'ambiguous') return { verdict: det1.verdict, reasons: det1.reasons, addressableSummary: null }; + + // Fetch log tails for only the still-ambiguous checks, then re-run the (still free) deterministic + // pass with logs attached — an infra log pattern can resolve a check without a model call. + const ambiguous1 = new Set(det1.ambiguousChecks); + const withLogs = []; + for (const c of resolved) { + if (!ambiguous1.has(c.name)) continue; + let logTail; + if (c.jobId != null) { + const raw = await gh.getJobLogTail(c.repoOwner, c.repoName, c.jobId, 8192).catch(() => ''); + logTail = redactToken(raw, project.githubToken); + } + withLogs.push({ ...c, job: c.job ? { ...c.job, logTail } : c.job, logTail }); + } + const det2 = classifyDeterministic({ failingChecks: withLogs, baseFailingCheckNames: new Set() }); + const reasons = [...det1.reasons, ...det2.reasons]; + if (det2.verdict !== 'ambiguous') return { verdict: det2.verdict, reasons, addressableSummary: null }; + + // Still ambiguous — one no-tools model turn over just the unresolved checks' logs. + const stillAmbiguous = withLogs.filter((c) => det2.ambiguousChecks.includes(c.name)); + if (!project?.modelCred) return FAIL_SAFE; // no credential to run the classifier turn — fail open + await recordPhaseStart(supabase, run, 'ci-classify'); + const prompt = [ + `You are classifying CI failures on a pull request BEFORE a fix pass is attempted.`, + `For EACH check below, decide whether it looks ADDRESSABLE by a code change on this branch,`, + `or EXTERNAL (an infrastructure incident, flaky runner, or an unrelated upstream failure that no`, + `in-repo change could fix).`, + ``, + `Respond with ONLY one JSON object: {"verdicts":[{"check":"","addressable":true|false,"reason":""}]}`, + ``, + ...stillAmbiguous.flatMap((c) => [ + `--- CHECK: ${c.name} (${c.repoOwner}/${c.repoName}) ---`, + c.logTail ? c.logTail.slice(-4000) : '(no log tail available)', + ]), + ].join('\n'); + const { model } = resolvePhaseModel(project, run, 'ci-classify'); + const runner = new InProcessRunner(); + const result = await runner.runPhase({ + cwd: '/tmp', prompt, model, + credential: { kind: project.modelCredKind, value: project.modelCred }, + noTools: true, + }); + await recordPhaseEnd(supabase, run, 'ci-classify', result?.error ? 'failed' : 'passed', result?.error, { + model, engine: 'claude', input: result?.tokensInput, output: result?.tokensOutput, + cacheRead: result?.tokensCacheRead, cacheCreation: result?.tokensCacheCreation, cost: result?.costUSD, + }); + if (result?.error) return FAIL_SAFE; + const m = /\{[\s\S]*\}/.exec(result?.text ?? ''); + if (!m) return FAIL_SAFE; + let parsed; + try { parsed = JSON.parse(m[0]); } catch { return FAIL_SAFE; } + const verdicts = Array.isArray(parsed?.verdicts) ? parsed.verdicts : null; + if (!verdicts) return FAIL_SAFE; + const addressableOnes = verdicts.filter((v) => v?.addressable === true); + if (addressableOnes.length > 0) { + const summaryLines = verdicts.map((v) => `"${v.check}": ${v.addressable ? 'addressable' : 'external'} — ${v.reason ?? ''}`); + return { verdict: 'addressable', reasons, addressableSummary: [`CI TRIAGE (deterministic + model):`, ...reasons.map((r) => `- ${r}`), ...summaryLines.map((s) => `- ${s}`)].join('\n') }; + } + return { verdict: 'external', reasons: [...reasons, ...verdicts.map((v) => `"${v.check}": ${v.reason ?? 'classified external by the model'}`)], addressableSummary: null }; + } catch { + return FAIL_SAFE; + } +} const sb = (ctx) => ctx?.supabase ?? @@ -42,6 +175,7 @@ async function reconcile(supabase, ctx, run) { let allMerged = true, anyClosedUnmerged = false, firstUrl = null; let latestActionable = 0; let anyFailingCi = false; // any open PR whose checks have SETTLED red (→ candidate for a CI-fix pass) + const failingChecks = []; // raw failing check-runs across all open PRs, for classifyCiFailure for (const p of prs) { firstUrl = firstUrl || p.pr_url; try { @@ -64,8 +198,16 @@ async function reconcile(supabase, ctx, run) { for (const c of inline ?? []) if (c.created_at && isTrustedFeedbackAuthor(c.user?.login, project, run)) latestActionable = Math.max(latestActionable, new Date(c.created_at).getTime()); // CI health — settled-red counts (still-running does not, to avoid churn). if (pr.head?.sha) { - const checks = summarizeChecks((await gh.listCheckRuns(p.repo_owner, p.repo_name, pr.head.sha).catch(() => null))?.check_runs); - if (checks.failing > 0 && checks.pending === 0) anyFailingCi = true; + const checkRunsResult = await gh.listCheckRuns(p.repo_owner, p.repo_name, pr.head.sha).catch(() => null); + const checks = summarizeChecks(checkRunsResult?.check_runs); + if (checks.failing > 0 && checks.pending === 0) { + anyFailingCi = true; + for (const c of checkRunsResult?.check_runs ?? []) { + if (c?.status === 'completed' && CHECK_FAILURE_CONCLUSIONS.includes(String(c?.conclusion))) { + failingChecks.push({ repoOwner: p.repo_owner, repoName: p.repo_name, name: c.name, sha: pr.head.sha }); + } + } + } } } } catch { allMerged = false; } @@ -118,11 +260,27 @@ async function reconcile(supabase, ctx, run) { // as auto-merge below.) const CI_FIX_CAP = 3; if (run.kind !== 'external_pr' && anyFailingCi && (run.ci_fix_attempts ?? 0) < CI_FIX_CAP) { + // Classify BEFORE spending a fix pass (issue #54): an infra incident or a repo-wide upstream + // break (e.g. a new advisory failing an audit gate) burns a fix-pass attempt and model spend for + // nothing no in-repo change can fix it. Cheap deterministic signals first; a model read only for + // whatever's still ambiguous after that. + const classification = await classifyCiFailure(supabase, ctx, gh, project, run, failingChecks); + if (classification.verdict === 'external') { + const note = `CI failure classified external: ${classification.reasons.join('; ') || 'no addressable signal found'} — waiting instead of spending a fix pass`; + try { + await writeEvent(supabase, run, 'pr-monitor', 0, 'status', { + event: 'ci_classify', verdict: 'external', reasons: classification.reasons, + checks: failingChecks.map((c) => c.name), + }); + } catch { /* best-effort audit record */ } + await supabase.from('se_runs').update({ ...patch, status: 'watching', pr_state: 'open', pr_url: firstUrl, error: note }).eq('id', run.id); + return { runId: run.id, action: 'ci-external-skip' }; + } // Escalation ladder: the first CI-fix ran on the mapped model and CI is still red — latch the // run onto the project's escalation model (when configured) for the remaining code phases. const escalate = (run.ci_fix_attempts ?? 0) >= 1 && !run.model_escalated && !!project.escalationModel; await supabase.from('se_runs').update({ ...patch, status: 'changes_requested', pr_state: 'open', current_phase: 'revise', ci_fix_attempts: (run.ci_fix_attempts ?? 0) + 1, ...(escalate ? { model_escalated: true } : {}), pr_url: firstUrl }).eq('id', run.id); - await enqueuePhase(ctx, run.id, 'revise', { reason: 'ci' }); + await enqueuePhase(ctx, run.id, 'revise', { reason: 'ci', classification: classification.addressableSummary ?? undefined }); return { runId: run.id, action: 'ci-fix' }; } diff --git a/modules/software-engineer/workers/revise.ts b/modules/software-engineer/workers/revise.ts index f3616103..55687a35 100644 --- a/modules/software-engineer/workers/revise.ts +++ b/modules/software-engineer/workers/revise.ts @@ -83,8 +83,14 @@ export default async function revise(job, ctx) { const commitId = await resolveCommitIdentity(supabase, project, token); ws = await makeMultiWorkspace(codeRepos, token, run.branch_name, commitId, true); + // classification (issue #54): the platform's ci-classify pass already flagged which checks look + // addressable, and why. Prepending it focuses the agent on those checks instead of re-diagnosing + // everything from scratch. Absent for a `revise` job enqueued before this field existed, or for + // any non-CI reason — the prompt is unchanged in that case. + const classification = typeof job?.data?.classification === 'string' ? job.data.classification.trim() : ''; const prompt = ciMode ? [ + ...(classification ? [`CI TRIAGE NOTE (from the platform, before you start):`, classification, ``] : []), `The CI checks on your open pull request(s)${run.issue_number ? ` for issue #${run.issue_number}` : ''} are FAILING.`, `Reproduce and fix them by editing the code in the relevant WRITABLE repo(s) in your workspace.`, `Run the repo's own checks (typecheck, lint, tests, security review) exactly as its CLAUDE.md`,