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
1 change: 1 addition & 0 deletions DEPLOY.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ Every knob is a line in `~/.stupify/config.env`, read fresh each sweep. A one-sh
| `SCOPE` | `auto` | `auto` reviews every non-draft, non-bot PR; `label` only labelled ones |
| `REVIEW_LABEL` | `codex-review` | force-include label: oversized diffs and bot PRs opt in with it |
| `DIFF_LINE_CAP` | `20000` | skip bigger diffs unless labelled |
| `MAX_TURNS` | `6`, min 2 | codex turns per review; past it the attempt fails and retries later |
| `MAX_PRS` | `15` | reviews per sweep, counted after dedup skips |
| `MAX_REVIEWS_PER_DAY` | `0` (off) | hard daily ceiling |
| `FAIL_RETRY_MIN` | `60` | wait before retrying a head whose review failed |
Expand Down
206 changes: 204 additions & 2 deletions bun.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
"check": "bun run typecheck && bun run lint && bun run fmt:check && bun run build"
},
"dependencies": {
"@bevyl-ai/agent-tools": "0.13.0",
"@bevyl-ai/agent-tools": "0.14.2",
"zod": "^4.4.3"
},
"devDependencies": {
Expand Down
23 changes: 7 additions & 16 deletions src/review-sweep.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,15 @@
#!/usr/bin/env bun
// stupify — one review sweep, top to bottom. A cron runs this file every minute on the reviewer box, with
// config.env beside it (DEPLOY.md). Every non-draft, non-bot open PR under DIFF_LINE_CAP is reviewed against the
// target repo's .review/ (REVIEW-PROMPT.md + RUBRIC.md + CORPUS.md), once per head: a posted review carries a
// hidden `<!-- stupify:<sha> -->` marker, and a push moves the sha. Each review is fed the PR's existing review
// thread, so it converges instead of repeating.

import { join } from 'node:path'

import { acquireLock, releaseLock } from '@bevyl-ai/agent-tools'

import { loadConfig, log, refreshRepo } from './sweep/config'
import { type PriorState, prReviews } from './sweep/github'
import { runCandidatePool } from './sweep/pool'
import { hasMachinery } from './sweep/prompt'
import { inScope, listPrs } from './sweep/prs'
import { collectCandidates, loadSweepState } from './sweep/sweep'
import { sweepState } from './sweep/state'
import { collectCandidates } from './sweep/sweep'

const cfg = loadConfig()
const lockPath = join(cfg.stateDir, 'sweep.lock')
Expand All @@ -22,14 +18,13 @@ if (!acquireLock(lockPath)) {
process.exit(0)
}
process.on('exit', () => {
releaseLock(lockPath) // only if still ours: a later sweep that judged us crashed and stole it now owns it
releaseLock(lockPath)
})

if (!refreshRepo(cfg)) {
process.exit(1)
}
// The target repo's own .review/ wins; otherwise the global taste under STUPIFY_HOME/.review. Select on the full
// three-file set, so a partial repo .review/ falls back instead of dead-ending at "no machinery".

const repoReview = join(cfg.repoDir, cfg.reviewDir)
cfg.reviewDir = hasMachinery(repoReview) ? repoReview : cfg.homeReviewDir
if (!hasMachinery(cfg.reviewDir)) {
Expand All @@ -44,11 +39,7 @@ if (prs === null) {
process.exit(1)
}
const queue = prs.filter((pr) => inScope(pr, cfg))
const state = loadSweepState(cfg)
const priorByPr = new Map<number, PriorState | null>()
for (const pr of queue) {
priorByPr.set(pr.number, prReviews(cfg, pr))
}
const candidates = collectCandidates(cfg, queue, priorByPr, state)
const state = sweepState(cfg)
const candidates = collectCandidates(cfg, queue, state)
const reviewed = await runCandidatePool(cfg, candidates, state)
log(`sweep done — scope=${cfg.scope} reviewed=${reviewed}`)
178 changes: 71 additions & 107 deletions src/sweep/codex.ts
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'
Expand All @@ -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) {

Copy link
Copy Markdown

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:79

a 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 later review_verdict error.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 · conf 0.99 · src/sweep/codex.ts:82

a schema-invalid review_verdict revision is rejected by MCP before the handler can clear got; this break then accepts the turn-1 verdict and can post stale clean output after the second pass found an issue. Completion needs to reject the prior verdict when the latest review_verdict item in this turn failed validation.

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` }
}
Loading