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
7 changes: 5 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@stupify/cli",
"version": "0.4.5",
"description": "A code reviewer that talks like an idiot and catches real bugs corpus-grounded, anti-slop, runs on Codex.",
"description": "A code reviewer that talks like an idiot and catches real bugs \u2014 corpus-grounded, anti-slop, runs on Codex.",
"type": "module",
"workspaces": [
"packages/*"
Expand Down Expand Up @@ -53,7 +53,8 @@
"@stupify/exe-cli": "0.4.5",
"@stupify/exe-host": "0.4.5",
"@clack/prompts": "^1.2.0",
"picocolors": "^1.1.1"
"picocolors": "^1.1.1",
"@bevyl-ai/agent-tools": "^0.2.0"
Comment thread
exe-dev-github-integration[bot] marked this conversation as resolved.
},
"devDependencies": {
"@types/bun": "^1.3.14",
Expand Down
28 changes: 1 addition & 27 deletions packages/exe-cli/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { spawnSync } from 'node:child_process'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { existsSync, mkdirSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'

Expand Down Expand Up @@ -145,32 +145,6 @@ export function llmIntegrationFor(runExe: (args: string[]) => ExeResult = exe):
return parseExeIntegrations(r.out).find((i) => i.type === 'llm' && i.config?.providers?.openai?.enabled === true)?.name ?? null
}

export interface CodexGatewayOptions {
home: string
gatewayHost?: string
codexHome?: string
}

export function writeCodexGatewayConfig(opts: CodexGatewayOptions): void {
const dir = opts.codexHome ?? process.env.CODEX_HOME ?? join(homedir(), '.codex')
const file = join(dir, 'config.toml')
const existing = existsSync(file) ? readFileSync(file, 'utf8') : ''
if (existing.includes('model_provider')) return
mkdirSync(dir, { recursive: true })
const gatewayHost = opts.gatewayHost ?? 'llm.int.exe.xyz'
const block = `model_provider = "exe-llm"

[model_providers.exe-llm]
name = "exe-llm"
base_url = "https://${gatewayHost}/v1"
requires_openai_auth = false

[projects."${join(opts.home, 'repo')}"]
trust_level = "trusted"
`
writeFileSync(file, existing ? `${block}\n${existing}` : block)
}

export function exeSetupScript(command: string, codexHost?: string): string {
return [
'export PATH="$HOME/.bun/bin:/usr/local/bin:$PATH"',
Expand Down
4 changes: 2 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ import {
validHost,
validRepo,
vmNameFor as packageVmNameFor,
writeCodexGatewayConfig,
} from '@stupify/exe-cli'
import { writeCodexGatewayConfig } from '@bevyl-ai/agent-tools'
import pc from 'picocolors'

const PKG_DIR = dirname(fileURLToPath(import.meta.url))
Expand Down Expand Up @@ -373,7 +373,7 @@ async function setup(argv: { repo?: string; host?: string; codexHost?: string; y
.filter(Boolean)
.join('\n')
writeFileSync(join(HOME, 'config.env'), cfg + '\n')
if (host) writeCodexGatewayConfig({ home: HOME, gatewayHost: argv.codexHost }) // exe.dev VM: route Codex through the no-key exe-llm gateway
if (host) writeCodexGatewayConfig({ gatewayHost: argv.codexHost, trustDir: join(HOME, 'repo') }) // exe.dev VM: route Codex through the no-key exe-llm gateway
try {
installCron({ stateDir: STATE, engineFile: join(HOME, 'review-sweep.ts'), ghHost: host, removeMarker: 'review-sweep.ts' })
} catch (e) {
Expand Down
43 changes: 0 additions & 43 deletions src/review-sweep.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,46 +298,3 @@ test('the prefix is large enough to be cache-eligible (well past the ~1024-token
expect(approxTokens).toBeGreaterThan(1024)
})

// Gateway rotation: on a quota wall the sweep advances ~/.codex/config.toml to the next CODEX_GATEWAY_POOL
// entry — the same env contract bunion/earshot rotate on. Real files in a temp dir, no mocks.
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { rotateGateway } from './review-sweep'

const POOL = 'llm.int.exe.xyz,llm-3.int.exe.xyz,llm-4.int.exe.xyz'
const TOML = 'model_provider = "exe-llm"\n[model_providers.exe-llm]\nbase_url = "https://llm.int.exe.xyz/v1"\n'

const rotateSetup = (pool = POOL, toml = TOML) => {
const dir = mkdtempSync(join(tmpdir(), 'rotate-'))
mkdirSync(join(dir, 'state'))
const configPath = join(dir, 'config.toml')
writeFileSync(configPath, toml)
const c: Config = { ...cfg(), stateDir: join(dir, 'state'), gatewayPool: pool, rotateCooldownMs: 600_000 }
return { c, configPath }
}

test('rotateGateway advances the ring, stamps the cooldown, and leaves the rest of the config alone', () => {
const { c, configPath } = rotateSetup()
expect(rotateGateway(c, configPath, 1000)).toEqual({ rotated: true, from: 'llm.int.exe.xyz', to: 'llm-3.int.exe.xyz' })
const toml = readFileSync(configPath, 'utf8')
expect(toml).toContain('base_url = "https://llm-3.int.exe.xyz/v1"')
expect(toml).toContain('model_provider = "exe-llm"')
// the ring wraps from the last entry back to the first (cooldown elapsed)
writeFileSync(configPath, TOML.replace('llm.int.exe.xyz', 'llm-4.int.exe.xyz'))
expect(rotateGateway(c, configPath, 700_000)).toEqual({ rotated: true, from: 'llm-4.int.exe.xyz', to: 'llm.int.exe.xyz' })
})

test('rotateGateway cooldown suppresses back-to-back rotations, then re-arms', () => {
const { c, configPath } = rotateSetup()
expect(rotateGateway(c, configPath, 1000).rotated).toBe(true)
expect(rotateGateway(c, configPath, 500_000)).toEqual({ rotated: false, why: 'rotated recently — cooling down' })
expect(rotateGateway(c, configPath, 700_000).rotated).toBe(true)
})

test('rotateGateway is off without a pool, and never touches a config outside the pool', () => {
const off = rotateSetup('')
expect(rotateGateway(off.c, off.configPath, 1000).rotated).toBe(false)
expect(readFileSync(off.configPath, 'utf8')).toBe(TOML)
const foreign = rotateSetup(POOL, TOML.replace('llm.int.exe.xyz', 'llm-2.int.exe.xyz'))
expect(rotateGateway(foreign.c, foreign.configPath, 1000)).toEqual({ rotated: false, why: 'no pool gateway in codex config' })
})
45 changes: 13 additions & 32 deletions src/review-sweep.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
* `flock` dependency. Every knob lives in config.env next to this file (read fresh each run). Run: `bun review-sweep.ts`.
*/
import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
import { homedir } from 'node:os'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import {
Expand All @@ -38,6 +37,7 @@ import {
} from '@stupify/exe-host'

export { isRateLimited, pidAlive } from '@stupify/exe-host'
import { maybeRotateGateway } from '@bevyl-ai/agent-tools'

const KIT_DIR = dirname(fileURLToPath(import.meta.url))

Expand Down Expand Up @@ -761,7 +761,7 @@ const hasMachinery = (dir: string): boolean =>
/** The outcome of running codex over one PR — classified but NOT acted on. The sweep posts/converges from this;
* the ad-hoc `stupify review` prints it or `--post`s it. */
export type ReviewOutcome =
| { kind: 'limit'; reason: string } // plan/credit exhaustion — the caller should STOP, not retry every PR
| { kind: 'limit'; reason: string; raw: string } // plan/credit exhaustion — caller STOPS; raw = full codex output for the rotation matcher (reason is a truncated excerpt that can miss the quota signature)
| { kind: 'fail'; reason: string } // codex couldn't produce a review (down, timeout, wrote nothing)
| { kind: 'noop'; tokens: number | null } // codex emitted the no-new-issues token → stay silent
| { kind: 'fixed'; tokens: number | null } // codex emitted the fixed token → prior findings resolved
Expand Down Expand Up @@ -795,7 +795,7 @@ export function runReview(cfg: Config, pr: Pr, priorThread: string, diff: string
const review = cx.ok && existsSync(outPath) ? readFileSync(outPath, 'utf8').trim() : ''
if (review.length === 0) {
const reason = failureReason(cx.combined)
return isRateLimited(cx.combined) ? { kind: 'limit', reason } : { kind: 'fail', reason }
return isRateLimited(cx.combined) ? { kind: 'limit', reason, raw: cx.combined } : { kind: 'fail', reason }
}
const tokens = parseTokens(cx.combined)
if (isFixedReview(review)) return { kind: 'fixed', tokens }
Expand Down Expand Up @@ -824,7 +824,16 @@ function reviewPr(cfg: Config, pr: Pr, priorThread: string, diff: string, firstR
const r = runReview(cfg, pr, priorThread, diff, dismissed)
if (r.kind === 'limit' || r.kind === 'fail') {
log(` review FAILED for #${pr.number} — ${r.reason}`)
return r.kind === 'limit' ? 'limit' : null // 'limit' tells the sweep to STOP — the rest will fail the same way
if (r.kind === 'limit') {
// Self-heal: advance ~/.codex/config.toml to the next CODEX_GATEWAY_POOL account (the shared ring —
// same kit + env contract bunion/earshot rotate on). Codex re-reads the file each sweep, so the next
// sweep lands on the fresh account. The kit's signature match is tighter than isRateLimited by design:
// a transient 429 ends this sweep but doesn't walk the ring.
const rot = maybeRotateGateway({ reason: r.raw, pool: cfg.gatewayPool.split(',').map((h) => h.trim()).filter(Boolean), cooldownMs: cfg.rotateCooldownMs })
if (rot.rotated) log(` codex gateway rotated: ${rot.from} → ${rot.to}`)
return 'limit'
}
return null
}
if (r.kind === 'noop') {
// Clean. A one-time LGTM on a PR stupify has never flagged (so "reviewed + good" is visible). On a PR it HAS
Expand Down Expand Up @@ -962,32 +971,6 @@ function reviewOne(cfg: Config, ref: string, post: boolean): void {
console.log(`posted to ${slug}#${number} ✅ (${findings.length} inline)`)
}

/** Self-heal the wall isRateLimited just hit: advance `base_url` in ~/.codex/config.toml to the next gateway in
* CODEX_GATEWAY_POOL (an ordered ring of interchangeable ChatGPT-account gateways — the same env contract
* bunion and earshot rotate on via @bevyl-ai/agent-tools; inlined here to keep this engine dependency-free).
* Codex re-reads the file each sweep, so the next sweep lands on the fresh account — no probing, no restarts.
* A dead account fails fast and advances the ring again, so the pool converges on whichever has quota. The
* cooldown keeps a fully-drained pool cycling calmly, one step per wall. Unset pool = rotation off. */
export function rotateGateway(cfg: Config, configPath?: string, now = Date.now()): { rotated: true; from: string; to: string } | { rotated: false; why: string } {
const pool = cfg.gatewayPool.split(',').map((h) => h.trim()).filter(Boolean)
if (pool.length < 2) return { rotated: false, why: pool.length === 0 ? 'CODEX_GATEWAY_POOL unset — rotation off' : 'pool has a single entry' }
const path = configPath ?? join(process.env.CODEX_HOME ?? join(homedir(), '.codex'), 'config.toml')
if (!existsSync(path)) return { rotated: false, why: `no codex config at ${path}` }
const config = readFileSync(path, 'utf8')
const i = pool.findIndex((host) => config.includes(host)) // never touches hosts outside the pool, so other providers are safe
if (i === -1) return { rotated: false, why: 'no pool gateway in codex config' }
const stampPath = join(cfg.stateDir, 'gateway-rotated-at')
if (existsSync(stampPath)) {
const last = Number(readFileSync(stampPath, 'utf8'))
if (Number.isFinite(last) && now - last < cfg.rotateCooldownMs) return { rotated: false, why: 'rotated recently — cooling down' }
}
const from = pool[i] as string
const to = pool[(i + 1) % pool.length] as string
writeFileSync(path, config.replaceAll(from, to))
writeFileSync(stampPath, String(now))
return { rotated: true, from, to }
}

/** codex prints `tokens used` then the count on the next line — read the last such pair. */
function parseTokens(out: string): number | null {
const lines = out.split('\n')
Expand Down Expand Up @@ -1160,8 +1143,6 @@ function main(): void {
setCommitStatus(cfg, commitStatuses, pr, 'pending', `stupify is reviewing ${lines} diff lines`)
const used = reviewPr(cfg, pr, prior.memory, diff, firstReview, prior.openThreadIds, prior.dismissed)
if (used === 'limit') {
const r = rotateGateway(cfg) // self-heal: next sweep runs on the next CODEX_GATEWAY_POOL account (no-op if unset)
if (r.rotated) log(`codex gateway rotated: ${r.from} → ${r.to}`)
log('codex plan is rate-limited — ending this sweep early (the rest would fail the same way); retries next sweep')
setStatusPr(cfg, status, pr, 'failed', 'codex plan is rate-limited; ending sweep early', lines)
setStatusStage(cfg, status, 'blocked', 'codex plan is rate-limited')
Expand Down