-
Notifications
You must be signed in to change notification settings - Fork 0
Run the review as a continuation loop on the kit's untilDone #75
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
f022af7
eb0ef51
9157b3f
fd62f3b
579a873
4678557
80ffb79
5aa4078
ff02c1c
c66bb15
6a9cb88
611920f
491acd1
666eceb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,14 +1,4 @@ | ||
| // Running Codex over one PR's diff through the kit's app-server session, and classifying the result. The verdict | ||
| // is a `review_verdict` TOOL CALL the kit validates against ReviewOutput mid-turn (a bad shape goes back to the | ||
| // model as the tool error); the model's text is never read. | ||
| import { | ||
| AppServerSession, | ||
| isQuotaWall, | ||
| isRateLimited, | ||
| maybeRotateGateway, | ||
| scrubSecrets, | ||
| tool, | ||
| } from '@bevyl-ai/agent-tools' | ||
| import { codexThread, isQuotaWall, isRateLimited, maybeRotateGateway, text } from '@bevyl-ai/agent-tools' | ||
|
|
||
| import { SECOND_PASS_PROMPT } from '../hand-written-prompts' | ||
| import { type Config, log, logRaw } from './config' | ||
|
|
@@ -17,123 +7,97 @@ import { reviewPrompt } from './prompt' | |
| import { type Pr } from './prs' | ||
| import { parseReview, ReviewOutput, type ReviewVerdict } from './verdict' | ||
|
|
||
| /** The outcome of running Codex over one PR — classified but NOT acted on; review-pr.ts posts/converges from it. */ | ||
| export type ReviewOutcome = | ||
| | { kind: 'limit'; reason: string } // plan/credit exhaustion — the caller launches no more reviews this sweep | ||
| | { kind: 'fail'; reason: string } // Codex couldn't produce a review (down, timeout, stalled, never submitted) | ||
| | ReviewVerdict | ||
| export type ReviewOutcome = { kind: 'limit'; reason: string } | { kind: 'fail'; reason: string } | ReviewVerdict | ||
|
|
||
| const TURN_TIMEOUT_MS = 1_200_000 | ||
|
|
||
| function callFailed(raw: string): ReviewOutcome { | ||
| const reason = raw.replaceAll('`', ' ').replaceAll(/\s+/g, ' ').trim().slice(0, 220) || 'codex turn failed' | ||
| // isQuotaWall covers a 502 'ChatGPT account unavailable' (dead login) — the pool must walk past it too. | ||
| if (isRateLimited(raw) || isQuotaWall(raw)) { | ||
| return { kind: 'limit', reason } | ||
| } | ||
| return { kind: 'fail', reason } | ||
| } | ||
|
|
||
| const nearest = (lines: Set<number>, line: number): string => | ||
| [...lines] | ||
| .toSorted((a, b) => Math.abs(a - line) - Math.abs(b - line)) | ||
| .slice(0, 8) | ||
| .toSorted((a, b) => a - b) | ||
| .join(', ') | ||
|
|
||
| // What the schema can't say is thrown here so the MODEL corrects it, instead of the runner demoting the finding | ||
| // after the fact: the verdict is only accepted once the second pass is running (a first-turn call would skip | ||
| // the ownership challenge), an anchor must be a right-side line this diff touches (the only lines GitHub | ||
| // threads on), and a convergence verdict carries no findings (parseReview). | ||
| const verdictTool = (diff: string, secondPass: () => boolean, submit: (verdict: ReviewVerdict) => void) => { | ||
| const valid = diffRightLines(diff) | ||
| return tool( | ||
| 'review_verdict', | ||
| 'Submit the review verdict. Call once, after the second pass.', | ||
| ReviewOutput, | ||
| (data) => { | ||
| if (!secondPass()) { | ||
| throw new Error('not yet: finish the review, do the second pass when asked, then call review_verdict') | ||
| } | ||
| for (const f of data.findings) { | ||
| const lines = valid.get(f.path) | ||
| if (lines === undefined) { | ||
| throw new Error(`${f.path} is not in this diff`) | ||
| } | ||
| if (!lines.has(f.line)) { | ||
| throw new Error( | ||
| `${f.path}:${f.line} is not a line this diff touches; nearest touched lines: ${nearest(lines, f.line)}`, | ||
| ) | ||
| } | ||
| } | ||
| submit(parseReview(data)) | ||
| return Promise.resolve('noted') | ||
| }, | ||
| ) | ||
| } | ||
|
|
||
| /** Run Codex over one PR's diff and classify the result. Does NO gh I/O and NO posting — the caller owns those. */ | ||
| export async function runReview( | ||
| cfg: Config, | ||
| pr: Pr, | ||
| priorThread: string, | ||
| diff: string, | ||
| workDir?: string, | ||
| ): Promise<ReviewOutcome> { | ||
| const valid = diffRightLines(diff) | ||
| const got: { verdict: ReviewVerdict | null } = { verdict: null } | ||
| const turns = [reviewPrompt(cfg, pr, priorThread, diff), SECOND_PASS_PROMPT] | ||
| const session = new AppServerSession( | ||
| { | ||
| cwd: workDir ?? cfg.repoDir, | ||
| title: `#${pr.number}`, | ||
| model: cfg.codexModel || undefined, | ||
| effort: cfg.codexEffort, | ||
| // A reviewer reads. The per-TURN policy is what codex enforces; the kit's turn default is full access, so | ||
| // the thread-level string alone would leave both attacker-controlled turns able to write and reach the network. | ||
| threadSandbox: 'read-only', | ||
| turnSandboxPolicy: { type: 'readOnly' }, | ||
| turnTimeoutMs: TURN_TIMEOUT_MS, | ||
| }, | ||
| [ | ||
| verdictTool( | ||
| diff, | ||
| () => turns.length === 0, // both prompts handed out → the second pass is the running turn | ||
| (verdict) => { | ||
| got.verdict = verdict | ||
| }, | ||
| ), | ||
| ], | ||
| (event) => { | ||
| if (event.log) { | ||
| logRaw(` codex: ${event.log}\n`) | ||
| } | ||
| }, | ||
| { | ||
| scrubEnv: scrubSecrets, | ||
| // Self-heal a quota wall: advance ~/.codex/config.toml to the next CODEX_GATEWAY_POOL account (the ring | ||
| // bunion and earshot rotate on too). Codex re-reads the file per session, so the next review lands on it. | ||
| // The kit walks the ring only on a real wall, never a transient 429. | ||
| onTurnError: (error) => { | ||
| const rot = maybeRotateGateway({ | ||
| reason: String(error), | ||
| pool: cfg.gatewayPool, | ||
| cooldownMs: cfg.rotateCooldownMs, | ||
| }) | ||
| if (rot.rotated) { | ||
| log(` codex gateway rotated: ${rot.from} → ${rot.to}`) | ||
| } | ||
| }, | ||
| }, | ||
| ) | ||
| let session: Awaited<ReturnType<typeof codexThread>> | null = null | ||
| try { | ||
| await session.runTurns(() => turns.shift() ?? null) | ||
| session = await codexThread({ | ||
| workingDirectory: workDir ?? cfg.repoDir, | ||
| codexPath: cfg.codexPath, | ||
| ...(cfg.codexModel ? { model: cfg.codexModel } : {}), | ||
| modelReasoningEffort: cfg.codexEffort, | ||
| tools: (server) => | ||
| server.registerTool( | ||
| 'review_verdict', | ||
| { | ||
| description: 'Submit your verdict. You may call it again to revise; the last call wins.', | ||
| annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false }, | ||
| inputSchema: ReviewOutput.shape, | ||
| }, | ||
| (data) => { | ||
| got.verdict = null | ||
| for (const f of data.findings) { | ||
| const lines = valid.get(f.path) | ||
| if (lines === undefined) { | ||
| throw new Error(`${f.path} is not in this diff`) | ||
| } | ||
| if (!lines.has(f.line)) { | ||
| throw new Error( | ||
| `${f.path}:${f.line} is not a line this diff touches; nearest touched lines: ${nearest(lines, f.line)}`, | ||
| ) | ||
| } | ||
| } | ||
| got.verdict = parseReview(data) | ||
| return Promise.resolve(text('noted')) | ||
| }, | ||
| ), | ||
| }) | ||
| const { thread } = session | ||
| const prompts = [ | ||
| reviewPrompt(cfg, pr, priorThread, diff), | ||
| `${SECOND_PASS_PROMPT}\n\nIf that changes your verdict, call review_verdict again. Otherwise you are done.`, | ||
| ] | ||
| for (let turn = 1; turn <= cfg.maxTurns; turn++) { | ||
| const prompt = | ||
| prompts[turn - 1] ?? | ||
| `Continuation, turn ${turn} of ${cfg.maxTurns}, same thread. Resume from where you left off; finish by calling review_verdict.` | ||
| // oxlint-disable-next-line no-await-in-loop -- turns are sequential on one thread by definition | ||
| const { items, usage } = await thread.run(prompt, { signal: AbortSignal.timeout(TURN_TIMEOUT_MS) }) | ||
| for (const item of items) { | ||
| if (item.type === 'mcp_tool_call') { | ||
| logRaw(` codex: ⚙ ${item.tool}${item.error ? ` — ${item.error.message}` : ''}\n`) | ||
| } | ||
| } | ||
| const last = items.findLast((item) => item.type === 'mcp_tool_call' && item.tool === 'review_verdict') | ||
| if (last?.type === 'mcp_tool_call' && last.error) { | ||
| got.verdict = null | ||
| } | ||
| if (usage) { | ||
| logRaw(` codex: turn ${turn} — ${usage.input_tokens + usage.output_tokens} tokens\n`) | ||
| } | ||
| if (turn >= 2 && got.verdict !== null) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 · conf 0.99 · a schema-invalid |
||
| break | ||
| } | ||
| } | ||
| } catch (error) { | ||
| // The kit spawns codex in start() before runTurns' own try/finally, so a failed handshake would leave the | ||
| // child alive under a minute cron. Delete this once the kit's start() stops the process it spawned on failure. | ||
| session.stop() | ||
| const raw = error instanceof Error ? error.message : String(error) | ||
| logRaw(`${raw}\n`) | ||
| return callFailed(raw) | ||
| const rot = maybeRotateGateway({ reason: raw, pool: cfg.gatewayPool, cooldownMs: cfg.rotateCooldownMs }) | ||
| if (rot.rotated) { | ||
| log(` codex gateway rotated: ${rot.from} → ${rot.to}`) | ||
| } | ||
| const reason = raw.replaceAll('`', ' ').replaceAll(/\s+/g, ' ').trim().slice(0, 220) || 'codex turn failed' | ||
| return isRateLimited(raw) || isQuotaWall(raw) ? { kind: 'limit', reason } : { kind: 'fail', reason } | ||
| } finally { | ||
| session?.close() | ||
| } | ||
| return got.verdict ?? { kind: 'fail', reason: 'codex finished without calling review_verdict' } | ||
| return got.verdict ?? { kind: 'fail', reason: `no verdict after ${cfg.maxTurns} turns` } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟠 · conf 0.94 ·
src/sweep/codex.ts:79a rejected second-pass revision leaves the turn-1 verdict in
got, and this break accepts that stale verdict just because the turn ended. An invalid anchor/schema call can therefore discard a newly found issue and post the old clean result instead of continuing; completion must not treat a prior accepted call as done after a laterreview_verdicterror.