From 64d8aeccb49b72e6c9240df678e1c2b29f68b85a Mon Sep 17 00:00:00 2001 From: Noah Lindner Date: Sat, 11 Jul 2026 12:21:26 -0400 Subject: [PATCH 1/3] engine deps on @bevyl-ai/agent-tools; kit owns rotation + gateway config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The engine is bundled by Bun.build at install time, so the kit dep is inlined into the single deployed file — the dependency-free VM artifact survives (verified: bundle has zero external imports). Changes: - review-sweep: drop the inline rotateGateway; rotation moves into reviewPr's limit branch where the REAL failure text is in scope, via the kit's maybeRotateGateway (pool/cooldown from config.env via new opts). The kit's signature match is tighter than isRateLimited by design: a transient 429 still ends the sweep but no longer walks the ring. - cli: adopt the kit's writeCodexGatewayConfig (trustDir opt); exe-cli drops its copy. - rotation mechanics tests move to the kit with the code. bun.lock refresh pending the 0.2.0 npm publish. --- package.json | 5 ++-- packages/exe-cli/src/index.ts | 28 +---------------------- src/cli.ts | 4 ++-- src/review-sweep.test.ts | 43 ----------------------------------- src/review-sweep.ts | 41 +++++++++------------------------ 5 files changed, 17 insertions(+), 104 deletions(-) diff --git a/package.json b/package.json index ad2d9ea..1e684bd 100644 --- a/package.json +++ b/package.json @@ -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/*" @@ -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" }, "devDependencies": { "@types/bun": "^1.3.14", diff --git a/packages/exe-cli/src/index.ts b/packages/exe-cli/src/index.ts index b50cebe..e296514 100644 --- a/packages/exe-cli/src/index.ts +++ b/packages/exe-cli/src/index.ts @@ -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' @@ -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"', diff --git a/src/cli.ts b/src/cli.ts index f93c395..89ae0b1 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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)) @@ -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) { diff --git a/src/review-sweep.test.ts b/src/review-sweep.test.ts index 31d43cb..6d72ab6 100644 --- a/src/review-sweep.test.ts +++ b/src/review-sweep.test.ts @@ -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' }) -}) diff --git a/src/review-sweep.ts b/src/review-sweep.ts index 8e4d49d..9a9058f 100755 --- a/src/review-sweep.ts +++ b/src/review-sweep.ts @@ -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 { @@ -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)) @@ -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.reason, 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 @@ -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') @@ -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') From ae9a116cbaa7557012ec247869926fcfa84d7f3a Mon Sep 17 00:00:00 2001 From: Noah Lindner Date: Sat, 11 Jul 2026 12:23:30 -0400 Subject: [PATCH 2/3] lockfile: @bevyl-ai/agent-tools@0.2.0 from npm --- bun.lock | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/bun.lock b/bun.lock index b724844..ed8f68a 100644 --- a/bun.lock +++ b/bun.lock @@ -5,9 +5,10 @@ "": { "name": "stupify", "dependencies": { + "@bevyl-ai/agent-tools": "^0.2.0", "@clack/prompts": "^1.2.0", - "@stupify/exe-cli": "workspace:*", - "@stupify/exe-host": "workspace:*", + "@stupify/exe-cli": "0.4.5", + "@stupify/exe-host": "0.4.5", "picocolors": "^1.1.1", }, "devDependencies": { @@ -26,6 +27,8 @@ }, }, "packages": { + "@bevyl-ai/agent-tools": ["@bevyl-ai/agent-tools@0.2.0", "", {}, "sha512-dO9+jMUzZ/aIps4s3Xh+lt9FjHb2XBcSW/itmFg0L5c+2ssV7qXcSuos2uAIIe5dtxb3qb0W0cfAqAHjt2YnDQ=="], + "@clack/core": ["@clack/core@1.4.1", "", { "dependencies": { "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-FILJa1gGKEFTGZAJE9RpVhrjKz3c3h4ar60dSv6cGuDqufQ84YEIS3GAGvZiN+H6yaLbbvTFNejjCC4tXpZEuw=="], "@clack/prompts": ["@clack/prompts@1.5.1", "", { "dependencies": { "@clack/core": "1.4.1", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-zccHj2z2oCCO4yrDiRSlFOxWerGqRiysP7a5jPK6uoI9URKAquwY42Dd/iUP8JWHxEzdRe4TlbvZCo8z1/mhrw=="], From cb1a9b10f06924902e8a870ba7f9cdaa6261eb1e Mon Sep 17 00:00:00 2001 From: Noah Lindner Date: Sat, 11 Jul 2026 12:29:07 -0400 Subject: [PATCH 3/3] rotation matcher gets the FULL codex output, not the truncated excerpt failureReason() picks one signal line and truncates to 220 chars for the log; feeding that to the kit's stricter isQuotaWall could silently skip a real rotation. Carry the raw output on the limit result and match on that. (Reviewer catch on #42.) --- src/review-sweep.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/review-sweep.ts b/src/review-sweep.ts index 9a9058f..9b250ba 100755 --- a/src/review-sweep.ts +++ b/src/review-sweep.ts @@ -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 @@ -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 } @@ -829,7 +829,7 @@ function reviewPr(cfg: Config, pr: Pr, priorThread: string, diff: string, firstR // 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.reason, pool: cfg.gatewayPool.split(',').map((h) => h.trim()).filter(Boolean), cooldownMs: cfg.rotateCooldownMs }) + 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' }