summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorhathach <[email protected]>2026-08-27 12:21:04 +0700
committerhathach <[email protected]>2026-08-27 12:21:04 +0700
commit208f82efe8b9dc7ca3eb210dcbf455a14c97cf3c (patch)
treea31c3ec5d98e67e90ca306f066135110ea25333a
parent2b9ddd8b00eb57225fd9caeecd0da1a7954aee06 (diff)
pr-babysit: overlap a fast review lane with the CI watch
Review findings are validated, fixed, and pushed without waiting on CI; checkoutDir decouples the PR checkout from the session cwd. File-less CI failures are scoped by a dedicated agent, paths canonicalized and existence-checked via git ls-files, overlapping groups merged. Per-id reply/resolve accounting retries failures and holds the green exit until all outward work is drained.
-rw-r--r--.claude/workflows/pr-babysit.js362
1 files changed, 256 insertions, 106 deletions
diff --git a/.claude/workflows/pr-babysit.js b/.claude/workflows/pr-babysit.js
index 406213a5f..2731ed254 100644
--- a/.claude/workflows/pr-babysit.js
+++ b/.claude/workflows/pr-babysit.js
@@ -1,47 +1,55 @@
export const meta = {
name: 'pr-babysit',
- description: 'Drive a PR to green: pr-monitor triage (CI + bot reviews), port-dev fixes for validated findings, driver-reviewer verification, one commit+push per cycle',
+ description: 'Drive a PR to green: a fast review lane (validate bot findings, fix, push without waiting on CI) overlapped with a CI-watch lane; code-writer fixes, code-verifier verification, at most one push per lane per cycle',
whenToUse: 'After opening a PR, from a checkout of the PR branch. Default is a dry run (fixes left uncommitted, nothing posted); passing autoPush: true is the explicit authorization for pushes and PR comments.',
phases: [{ title: 'Triage' }, { title: 'Fix' }, { title: 'Verify' }, { title: 'Push' }],
}
-// args: { pr: number, maxCycles?: number, autoPush?: boolean (default false = dry run) }
+// args: { pr: number, maxCycles?: number, autoPush?: boolean (default false = dry run),
+// checkoutDir?: string (PR branch checkout; default: the session working dir) }
if (typeof args === 'string') { try { args = JSON.parse(args) } catch { /* not JSON: shape check below reports it */ } }
if (!args || !args.pr) {
- throw new Error('args must be { pr: number, maxCycles?, autoPush? }; run from a checkout of the PR branch')
+ throw new Error('args must be { pr: number, maxCycles?, autoPush?, checkoutDir? }; run from the PR branch checkout or point checkoutDir at it')
}
args.pr = Number(args.pr)
if (!Number.isInteger(args.pr) || args.pr <= 0) {
throw new Error('args.pr must be a positive integer PR number')
}
+const checkoutDir = args.checkoutDir || '.'
+if (typeof checkoutDir !== 'string' || checkoutDir.includes("'")) {
+ throw new Error('checkoutDir must be a plain path string')
+}
+const IN_CHECKOUT = checkoutDir === '.' ? 'The working tree IS the PR checkout. '
+ : `The PR branch checkout is at ${checkoutDir} - run every git/build/file command there, not in the session directory. `
const maxCycles = args.maxCycles ?? 3
if (!Number.isInteger(maxCycles) || maxCycles < 1) {
throw new Error('maxCycles must be an integer >= 1')
}
-const TRIAGE = {
+const CI = {
type: 'object', additionalProperties: false,
- required: ['ci', 'findings', 'replies', 'done'],
+ required: ['status', 'infraRerun', 'realFailures'],
properties: {
- ci: {
- type: 'object', additionalProperties: false,
- required: ['status', 'infraRerun', 'realFailures'],
- properties: {
- status: { type: 'string', enum: ['green', 'red', 'running'] },
- infraRerun: { type: 'array', items: { type: 'string' } },
- realFailures: {
- type: 'array',
- items: {
- type: 'object', additionalProperties: false,
- required: ['check', 'firstError', 'files'],
- properties: {
- check: { type: 'string' }, firstError: { type: 'string' },
- files: { type: 'array', items: { type: 'string' } },
- },
- },
+ status: { type: 'string', enum: ['green', 'red', 'running'] },
+ infraRerun: { type: 'array', items: { type: 'string' } },
+ realFailures: {
+ type: 'array',
+ items: {
+ type: 'object', additionalProperties: false,
+ required: ['check', 'firstError', 'files', 'rigSide'],
+ properties: {
+ check: { type: 'string' }, firstError: { type: 'string' },
+ files: { type: 'array', items: { type: 'string' } },
+ rigSide: { type: 'boolean' },
},
},
},
+ },
+}
+const REVIEWS = {
+ type: 'object', additionalProperties: false,
+ required: ['findings', 'replies', 'done'],
+ properties: {
findings: {
type: 'array',
items: {
@@ -84,6 +92,19 @@ const OP = {
required: ['pass', 'detail'],
properties: { pass: { type: 'boolean' }, detail: { type: 'string' } },
}
+const SCOPE = {
+ type: 'object', additionalProperties: false,
+ required: ['files'],
+ properties: { files: { type: 'array', items: { type: 'string' } } },
+}
+const OPIDS = {
+ type: 'object', additionalProperties: false,
+ required: ['pass', 'detail', 'doneIds'],
+ properties: {
+ pass: { type: 'boolean' }, detail: { type: 'string' },
+ doneIds: { type: 'array', items: { type: 'integer' } },
+ },
+}
// Marking a review thread resolved has no REST endpoint — it needs the
// GraphQL resolveReviewThread mutation. Shared recipe handed to the posting
@@ -107,120 +128,249 @@ const postReplyRecipe = (noun) =>
const history = []
const repliedIds = new Set() // issue comments can't be thread-resolved, so they re-harvest every cycle — never reply twice
-for (let cycle = 1; cycle <= maxCycles; cycle++) {
- const t = await agent(
- `Triage PR #${args.pr}. If checks are still running, wait for them first (gh pr checks ${args.pr} --watch as a BACKGROUND Bash task; the foreground timeout is capped at 10 min). ` +
- 'Then follow your triage procedure: classify CI failures, re-run infra ones, harvest and adversarially validate bot review findings, draft replies for invalid/stale ones.',
- { label: `triage#${cycle}`, phase: 'Triage', agentType: 'pr-monitor', schema: TRIAGE },
- )
- if (!t) {
- history.push({ cycle, error: 'pr-monitor died' })
- return { pass: false, cycles: cycle, history, reason: 'pr-monitor-died' }
- }
- const entry = { cycle, triage: t }
- history.push(entry)
- // Post drafted replies to REFUTED findings as soon as triage produces them —
- // decoupled from fixing/pushing so done/unactionable cycles still post.
- // Reply AND resolve the thread. Outward-facing, so gated on autoPush.
- const freshReplies = t.replies.filter(r => !repliedIds.has(r.commentId))
- if (freshReplies.length > 0 && args.autoPush === true) {
- const posted = await agent(
- `Reply to and resolve these refuted review comments on PR #${args.pr}. For each: ${postReplyRecipe('reply')}` +
- `Replies: ${JSON.stringify(freshReplies)}. pass=true only if every reply was posted and every inline thread resolved; detail = what went where.`,
- { label: `replies#${cycle}`, phase: 'Push', model: 'sonnet', schema: OP },
- )
- // attempted counts as replied: better to drop a failed reply than spam duplicates
- freshReplies.forEach(r => repliedIds.add(r.commentId))
- if (!posted || !posted.pass) log(`cycle ${cycle}: refuted reply/resolve incomplete — ${posted ? posted.detail : 'agent died'}`)
- }
-
- if (t.done) {
- log(`cycle ${cycle}: PR is green with no unresolved valid findings`)
- return { pass: true, cycles: cycle, history }
+// Canonicalize a repo-relative path for set/collision comparison: resolve ./..
+// segments, unify separators; '' for anything that escapes the repo or uses
+// characters no repo path does (also makes the path shell-safe to interpolate).
+const canon = (p) => {
+ const s = String(p).trim().replace(/\\/g, '/')
+ // Absolute (CI-runner) paths: reject rather than corrupt into a bogus relative
+ // path — the file-less group then routes through the scoper, which recovers the
+ // real repo path and is existence-checked.
+ if (s.startsWith('/')) return ''
+ const out = []
+ for (const seg of s.split('/')) {
+ if (!seg || seg === '.') continue
+ if (seg === '..') { if (out.pop() === undefined) return '' } else out.push(seg)
}
+ const c = out.join('/')
+ return /^[A-Za-z0-9._+/-]+$/.test(c) ? c : ''
+}
- // Group actionable work by top-level scope (plain JS — no model tokens).
+// Group actionable notes by top-level scope (plain JS — no model tokens).
+const groupWork = (notes) => {
const groups = new Map()
- const groupOf = (key) => {
+ for (const n of notes) {
+ const key = (canon(n.scopeFile) || n.scopeFile).split('/').slice(0, 3).join('/')
if (!groups.has(key)) groups.set(key, { key, files: new Set(), notes: [] })
- return groups.get(key)
- }
- for (const f of t.findings.filter(x => x.verdict === 'valid')) {
- const g = groupOf(f.file.split('/').slice(0, 3).join('/'))
- g.files.add(f.file)
- g.notes.push(`${f.file}:${f.line} [${f.source}] ${f.claim} — hint: ${f.fixHint}`)
+ const g = groups.get(key)
+ n.files.forEach(f => { const c = canon(f); if (c) g.files.add(c) })
+ g.notes.push(n.text)
}
- for (const rf of t.ci.realFailures) {
- const g = groupOf((rf.files[0] || rf.check).split('/').slice(0, 3).join('/'))
- rf.files.forEach(x => g.files.add(x))
- g.notes.push(`CI ${rf.check}: ${rf.firstError}`)
- }
- const work = [...groups.values()]
+ return [...groups.values()]
+}
- if (work.length === 0) {
- if (t.ci.status === 'running' || t.ci.infraRerun.length > 0) {
- log(`cycle ${cycle}: only infra re-runs in flight — next cycle waits on them`)
- continue
+// Fix + verify one work list; returns { ok, fixes } — ok only if every group
+// was scoped, fixed by a live worker, AND passed code-verifier verification.
+const fixAndVerify = async (workIn) => {
+ // code-writer's contract needs an explicit file set: a group whose notes named no
+ // files (a CI failure whose log yielded no paths) is scoped by a dedicated agent
+ // first; if that fails too, the group is withheld (ok=false → human review) rather
+ // than dispatched with an invalid scope.
+ const fileless = workIn.filter(w => w.files.size === 0)
+ await parallel(fileless.map(w => () =>
+ agent(
+ `${IN_CHECKOUT}Determine which repo files must change to address these notes (read the code; if a note is a CI failure, read its CI log too):\n- ${w.notes.join('\n- ')}\n` +
+ 'files = repo-relative paths; empty only if genuinely undeterminable.',
+ { label: `scope:${w.key}`, phase: 'Fix', model: 'sonnet', schema: SCOPE },
+ ).then(s => s && s.files.forEach(f => { const c = canon(f); if (c) w.files.add(c) }))))
+ // Scoped paths are model output: keep only what git ls-files confirms exists.
+ // The check is executed (by a mechanical agent) and intersected here — a dead
+ // checker drops every candidate, so unconfirmed groups fall through to withheld.
+ const candidates = [...new Set(fileless.flatMap(w => [...w.files]))]
+ if (candidates.length > 0) {
+ const v = await agent(
+ `${IN_CHECKOUT}Run exactly: git ls-files -- ${candidates.join(' ')}\nReturn files = the paths that command printed, verbatim — no additions, no substitutions.`,
+ { label: 'scope:verify', phase: 'Fix', model: 'haiku', schema: SCOPE },
+ )
+ const exists = new Set((v ? v.files : []).map(canon))
+ for (const w of fileless) for (const f of [...w.files])
+ if (!exists.has(f)) { w.files.delete(f); log(`scope:${w.key}: dropped ${f} — not confirmed as a repo file`) }
+ }
+ const unscoped = workIn.filter(w => w.files.size === 0)
+ for (const w of unscoped) log(`fix for ${w.key}: no file scope determinable — withheld for human review`)
+ // Scoping can make groups overlap (two checks resolving to the same file); merge
+ // intersecting groups (to closure) so two fixers never edit one file concurrently.
+ const work = []
+ for (let g of workIn.filter(w => w.files.size > 0)) {
+ for (let i; (i = work.findIndex(m => [...g.files].some(f => m.files.has(f)))) >= 0;) {
+ const [m] = work.splice(i, 1)
+ g.files.forEach(f => m.files.add(f)); m.notes.push(...g.notes); m.key = `${m.key}+${g.key}`
+ g = m
}
- log(`cycle ${cycle}: nothing actionable`)
- return { pass: false, cycles: cycle, history, reason: 'unactionable' }
+ work.push(g)
}
-
+ const scopeOf = (w) => [...w.files].join(', ')
const fixes = await pipeline(
work,
w => agent(
- `Fix the following issues on the current PR branch (the working tree IS the PR checkout).\n` +
- `Scope: ${[...w.files].join(', ')}\nIssues:\n- ${w.notes.join('\n- ')}`,
- { label: `fix:${w.key}`, phase: 'Fix', agentType: 'port-dev', effort: 'xhigh', schema: DEV },
+ `Fix the following issues on the PR branch. ${IN_CHECKOUT}\n` +
+ `Scope: ${scopeOf(w)}\nIssues:\n- ${w.notes.join('\n- ')}`,
+ { label: `fix:${w.key}`, phase: 'Fix', agentType: 'code-writer', schema: DEV },
),
(fix, w) => fix && agent(
- `Verify the uncommitted changes for ${[...w.files].join(', ')} (use git diff -- <files>, and read any newly created untracked files directly) address these issues:\n- ${w.notes.join('\n- ')}\n` +
+ `${IN_CHECKOUT}Verify the uncommitted changes for ${scopeOf(w)} (use git diff -- <the files above>, and read any newly created untracked files directly) address these issues:\n- ${w.notes.join('\n- ')}\n` +
'Return {"addresses": bool, "reason": string}.',
- { label: `check:${w.key}`, phase: 'Verify', agentType: 'driver-reviewer', effort: 'xhigh', schema: CHECK },
+ { label: `check:${w.key}`, phase: 'Verify', agentType: 'code-verifier', schema: CHECK },
).then(v => ({ ...fix, addresses: !!(v && v.addresses), checkReason: v ? v.reason : 'verifier died' })),
)
- const aliveFixes = fixes.filter(Boolean)
- if (aliveFixes.length < work.length) log(`${work.length - aliveFixes.length} fix group(s) lost to dead workers`)
- entry.fixes = aliveFixes
-
- if (args.autoPush !== true) {
- log('autoPush not set: fixes left uncommitted in the working tree (dry run)')
- return { pass: false, cycles: cycle, history, dryRun: true }
- }
-
- // Verification gates the push: never push a cycle containing an unverified
- // fix or the partial edits of a dead worker.
- const unverified = aliveFixes.filter(f => f.addresses !== true)
- if (aliveFixes.length < work.length || unverified.length > 0) {
- for (const f of unverified) log(`fix for ${f.item}: failed verification — ${f.checkReason}`)
- log(`cycle ${cycle}: fixes left uncommitted for human review — not pushing unverified changes`)
- return { pass: false, cycles: cycle, history, reason: 'fix-verification-failed' }
- }
+ const alive = fixes.filter(Boolean)
+ if (alive.length < work.length) log(`${work.length - alive.length} fix group(s) lost to dead workers`)
+ const unverified = alive.filter(f => f.addresses !== true)
+ for (const f of unverified) log(`fix for ${f.item}: failed verification — ${f.checkReason}`)
+ return { ok: unscoped.length === 0 && alive.length === work.length && unverified.length === 0, fixes: alive }
+}
+// Verification gates every push: never push unverified or partial edits.
+const commitAndPush = async (cycle, what) => {
const push = await agent(
- `On the current PR branch: commit ALL working-tree changes as ONE commit (imperative message summarizing the cycle-${cycle} fixes for PR #${args.pr}, repo commit conventions), ` +
+ `${IN_CHECKOUT}On the PR branch: commit ALL working-tree changes as ONE commit (imperative message summarizing the cycle-${cycle} ${what} fixes for PR #${args.pr}, repo commit conventions), ` +
"then push to the PR's remote branch. pass=true only if commit AND push succeeded; detail = pushed SHA.",
- { label: `push#${cycle}`, phase: 'Push', model: 'sonnet', schema: OP },
+ { label: `push#${cycle}-${what}`, phase: 'Push', model: 'sonnet', schema: OP },
+ )
+ return push && push.pass ? push : null
+}
+
+for (let cycle = 1; cycle <= maxCycles; cycle++) {
+ // Two independent lanes, launched together. The review lane never waits on
+ // CI: it validates, fixes, and pushes while the CI lane is still watching.
+ const ciPromise = agent(
+ `Watch CI for PR #${args.pr} per your procedure; wait for pending checks.`,
+ { label: `ci#${cycle}`, phase: 'Triage', agentType: 'pr-ci-watcher', schema: CI },
+ ).catch(e => { log(`cycle ${cycle}: pr-ci-watcher errored — ${e && e.message}`); return null })
+ // Every early return below leaves the loop while the CI lane is still
+ // running: settle it first so no CI agent outlives the workflow.
+ const stopWith = async (result) => { await ciPromise; return result }
+
+ const r = await agent(
+ `Validate the bot review findings on PR #${args.pr} per your procedure. ${IN_CHECKOUT}`,
+ { label: `reviews#${cycle}`, phase: 'Triage', agentType: 'pr-review-validator', schema: REVIEWS },
)
- if (!push || !push.pass) {
- log(`cycle ${cycle}: push failed — stopping`)
- return { pass: false, cycles: cycle, history, reason: 'push-failed' }
+ if (!r) {
+ history.push({ cycle, error: 'pr-review-validator died' })
+ return await stopWith({ pass: false, cycles: cycle, history, reason: 'review-validator-died' })
+ }
+ const entry = { cycle, reviews: r }
+ history.push(entry)
+ // Outward reply/resolve attempts this cycle that did not fully complete; a green
+ // PR must not terminate the loop while any remain, or the retry never happens.
+ let pendingReplies = 0
+
+ // Post drafted replies to REFUTED findings immediately. Outward-facing,
+ // so gated on autoPush.
+ const freshReplies = r.replies.filter(x => !repliedIds.has(x.commentId))
+ if (freshReplies.length > 0 && args.autoPush === true) {
+ const posted = await agent(
+ `Reply to and resolve these refuted review comments on PR #${args.pr}. For each: ${postReplyRecipe('reply')}` +
+ 'If a thread already carries an identical reply of ours (a prior attempt that posted but failed to resolve), do not repost — just resolve it. ' +
+ `Replies: ${JSON.stringify(freshReplies)}. pass=true only if every reply was posted and every inline thread resolved; detail = what went where. ` +
+ 'doneIds = the commentIds fully handled: reply posted (or already present) AND (thread resolved, or an issue comment with no thread to resolve).',
+ { label: `replies#${cycle}`, phase: 'Push', model: 'sonnet', schema: OPIDS },
+ )
+ // Per-id accounting, matching the resolve path: only fully handled ids are marked
+ // replied; a failed reply/resolve stays fresh and retries next cycle (the prompt's
+ // already-present check keeps the retry from duplicating the reply).
+ for (const id of (posted && posted.doneIds) || []) repliedIds.add(id)
+ pendingReplies += freshReplies.filter(x => !repliedIds.has(x.commentId)).length
+ if (!posted || !posted.pass) log(`cycle ${cycle}: refuted reply/resolve incomplete — ${posted ? posted.detail : 'agent died'}`)
}
- // The valid bot findings were fixed and pushed — answer each inline comment
- // with what changed and resolve its thread. CI-failure work has no comment.
- const fixed = t.findings.filter(x => x.verdict === 'valid')
- if (fixed.length > 0) {
+ // ---- review lane: fix + push without waiting for CI ----
+ const validFindings = r.findings.filter(x => x.verdict === 'valid')
+ let reviewPushed = false
+ if (validFindings.length > 0) {
+ const work = groupWork(validFindings.map(f => ({
+ scopeFile: f.file, files: [f.file],
+ text: `${f.file}:${f.line} [${f.source}] ${f.claim} — hint: ${f.fixHint}`,
+ })))
+ const { ok, fixes } = await fixAndVerify(work)
+ entry.reviewFixes = fixes
+ if (args.autoPush !== true) {
+ log('autoPush not set: review-lane fixes left uncommitted (dry run)')
+ return await stopWith({ pass: false, cycles: cycle, history, dryRun: true })
+ }
+ if (!ok) {
+ log(`cycle ${cycle}: review-lane fixes left uncommitted for human review — not pushing unverified changes`)
+ return await stopWith({ pass: false, cycles: cycle, history, reason: 'fix-verification-failed' })
+ }
+ const push = await commitAndPush(cycle, 'review')
+ if (!push) {
+ log(`cycle ${cycle}: review-lane push failed — stopping`)
+ return await stopWith({ pass: false, cycles: cycle, history, reason: 'push-failed' })
+ }
+ reviewPushed = true
const resolved = await agent(
`The fixes for PR #${args.pr}'s valid review findings were just committed and pushed (${push.detail}). ` +
`For each finding below: ${postReplyRecipe('fix note')}` +
'Each reply states the finding is fixed in the pushed commit, with one line on the change. ' +
- `Findings: ${JSON.stringify(fixed.map(f => ({ commentId: f.commentId, file: f.file, line: f.line, claim: f.claim, fixHint: f.fixHint })))}. ` +
- 'pass=true only if every reply was posted and every thread resolved; detail = what went where.',
- { label: `resolve#${cycle}`, phase: 'Push', model: 'sonnet', schema: OP },
+ `Findings: ${JSON.stringify(validFindings.map(f => ({ commentId: f.commentId, file: f.file, line: f.line, claim: f.claim, fixHint: f.fixHint })))}. ` +
+ 'pass=true only if every reply was posted and every thread resolved; detail = what went where. ' +
+ 'doneIds = the commentIds fully handled: reply posted AND (thread resolved, or an issue comment with no thread to resolve).',
+ { label: `resolve#${cycle}`, phase: 'Push', model: 'sonnet', schema: OPIDS },
)
+ // Per-id accounting: a fully handled finding never re-replies (an issue comment
+ // has no thread to resolve, so it re-harvests as stale next cycle and would get
+ // a duplicate "fixed" note); an unfinished one stays out of repliedIds so its
+ // reply/resolve is retried next cycle instead of silently abandoned.
+ for (const id of (resolved && resolved.doneIds) || []) repliedIds.add(id)
+ pendingReplies += validFindings.filter(f => !repliedIds.has(f.commentId)).length
if (!resolved || !resolved.pass) log(`cycle ${cycle}: fixed reply/resolve incomplete — ${resolved ? resolved.detail : 'agent died'}`)
}
+
+ // ---- CI lane result ----
+ const c = await ciPromise
+ entry.ci = c
+ if (!c) {
+ log(`cycle ${cycle}: pr-ci-watcher died — re-arming`)
+ continue
+ }
+ if (reviewPushed) {
+ // The push restarted CI: this cycle's CI verdict is superseded. Re-arm;
+ // next cycle's ci#N watches the fresh run.
+ log(`cycle ${cycle}: review-lane push superseded the CI run — re-arming`)
+ continue
+ }
+ const rigSide = c.realFailures.filter(rf => rf.rigSide)
+ for (const rf of rigSide) log(`cycle ${cycle}: rig-side CI failure (not fixing): ${rf.check} — ${rf.firstError.slice(0, 120)}`)
+ const fixable = c.realFailures.filter(rf => !rf.rigSide)
+ if (fixable.length > 0) {
+ const work = groupWork(fixable.map(rf => ({
+ scopeFile: rf.files[0] || rf.check, files: rf.files,
+ text: `CI ${rf.check}: ${rf.firstError}`,
+ })))
+ const { ok, fixes } = await fixAndVerify(work)
+ entry.ciFixes = fixes
+ if (args.autoPush !== true) {
+ log('autoPush not set: CI-lane fixes left uncommitted (dry run)')
+ return { pass: false, cycles: cycle, history, dryRun: true }
+ }
+ if (!ok) {
+ log(`cycle ${cycle}: CI-lane fixes left uncommitted for human review — not pushing unverified changes`)
+ return { pass: false, cycles: cycle, history, reason: 'fix-verification-failed' }
+ }
+ if (!(await commitAndPush(cycle, 'ci'))) {
+ log(`cycle ${cycle}: CI-lane push failed — stopping`)
+ return { pass: false, cycles: cycle, history, reason: 'push-failed' }
+ }
+ continue // pushed: fresh CI run next cycle
+ }
+ if (r.done && c.status === 'green') {
+ if (pendingReplies > 0) {
+ log(`cycle ${cycle}: PR green but ${pendingReplies} reply/resolve unfinished — re-arming to retry`)
+ continue
+ }
+ log(`cycle ${cycle}: PR is green with no unresolved valid findings`)
+ return { pass: true, cycles: cycle, history }
+ }
+ if (r.done && rigSide.length > 0 && fixable.length === 0 && c.infraRerun.length === 0 && c.status !== 'running') {
+ log(`cycle ${cycle}: CI red only from rig-side failures — human/rig attention needed, nothing to fix in the PR`)
+ return { pass: false, cycles: cycle, history, reason: 'ci-red-rig-side' }
+ }
+ if (c.status === 'running' || c.infraRerun.length > 0) {
+ log(`cycle ${cycle}: CI still settling (${c.infraRerun.length} infra re-run(s)) — re-arming`)
+ continue
+ }
+ log(`cycle ${cycle}: nothing actionable`)
+ return { pass: false, cycles: cycle, history, reason: 'unactionable' }
}
return { pass: false, cycles: maxCycles, history, reason: 'maxCycles reached' }