Skip to content

Commit f66d683

Browse files
feat(pipeline): add rate limit handling and improve process termination
1 parent a633e2e commit f66d683

4 files changed

Lines changed: 106 additions & 12 deletions

File tree

src/config.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,13 @@ export const TEST_TIMEOUT_MS = Number(process.env.TEST_TIMEOUT_MS || 5 * 60 * 10
5555
export const MATRIX_CONFIG = process.env.MATRIX_CONFIG || '';
5656
export const MATRIX_MAX_ENTRIES = Number(process.env.MATRIX_MAX_ENTRIES || 24);
5757
export const AGENT_TIMEOUT_MS = Number(process.env.AGENT_TIMEOUT_MS || 25 * 60 * 1000);
58+
// Text that flags a provider rate limit in opencode's own log
59+
// (<dataDir>/opencode/log/opencode.log) during the headless agent step; matched
60+
// case-insensitively against each fresh line so the entry aborts early instead of
61+
// burning the rest of AGENT_TIMEOUT_MS waiting on a provider that's already refusing
62+
// requests. opencode/provider-wording dependent (like the label regexes in
63+
// src/capture/usage.ts) — override if your provider phrases it differently.
64+
export const RATE_LIMIT_PATTERN = process.env.RATE_LIMIT_PATTERN || 'rate limit exceeded';
5865
// How long to wait for the (headless) post-edit dev-server build before giving up
5966
// and screenshotting anyway. Generous because the first build of an agent-edited
6067
// app (esp. Blazor) is slow across the bind mount.

src/matrix/matrix.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import * as path from 'path';
44
import type { ChildProcess } from 'child_process';
55
import * as history from '../history.ts';
66
import { WORK, ARTIFACT_DIR } from '../config.ts';
7-
import { killTree } from '../proc/exec.ts';
7+
import { terminateTree } from '../proc/exec.ts';
88
import { killWatcher } from '../proc/watcher.ts';
99
import { createSSE } from '../stream/sse.ts';
1010
import { cleanupAppDir } from './cleanup.ts';
@@ -136,7 +136,7 @@ async function runMatrix(combos: Combo[], { prompt, matrixId, fixed, name }: { p
136136
// instead of running the step (e.g. don't start the agent after Cancel).
137137
const onChild = (child: ChildProcess) => {
138138
currentChild = child;
139-
if (matrixCancelled || cancelledEntries.has(runId)) killTree(child, 'SIGTERM');
139+
if (matrixCancelled || cancelledEntries.has(runId)) terminateTree(child);
140140
};
141141
try {
142142
const result = await runPipeline(cfg, { emit, headless: true, prompt, dataDir, artifactDir, onChild, appDir });
@@ -244,7 +244,7 @@ export function begin(combos: Combo[], { prompt, fixed, name = null }: { prompt:
244244
export function cancel(): { ok: boolean; error?: string } {
245245
if (!matrixRunning) return { ok: false, error: 'no matrix run in progress' };
246246
matrixCancelled = true;
247-
killTree(currentChild, 'SIGTERM');
247+
terminateTree(currentChild);
248248
killWatcher('app'); killWatcher('opencode');
249249
broadcast({ type: 'log', msg: 'cancellation requested — stopping the current step' });
250250
return { ok: true };
@@ -263,7 +263,7 @@ export function cancelEntry(runId: string): { ok: boolean; error?: string } {
263263
}
264264
cancelledEntries.add(runId);
265265
if (entry.status === 'running') {
266-
killTree(currentChild, 'SIGTERM');
266+
terminateTree(currentChild);
267267
killWatcher('app'); killWatcher('opencode');
268268
broadcast({ type: 'log', index: entry.index, msg: 'entry cancellation requested — stopping this run' });
269269
}

src/pipeline/pipeline.ts

Lines changed: 83 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,9 @@ import { parseOpencodeStats } from '../capture/usage.ts';
1111
import { collectToolUsage, installedSkills, summarizeToolUsage } from '../capture/tool-usage.ts';
1212
import {
1313
APP_DIR, LOG_DIR, OPENCODE_PORT, AGENT_TIMEOUT_MS, APP_READY_TIMEOUT_MS, MCP_COMMAND_BY_CLASS,
14-
LOCAL_SKILLS_DIR, OPENCODE_DATA_DIR,
14+
LOCAL_SKILLS_DIR, OPENCODE_DATA_DIR, RATE_LIMIT_PATTERN,
1515
} from '../config.ts';
16-
import { run, capture, type RunOpts } from '../proc/exec.ts';
16+
import { run, capture, terminateTree, type RunOpts } from '../proc/exec.ts';
1717
import { spawnWatcher, killWatcher } from '../proc/watcher.ts';
1818
import { waitForPort, waitForPortFree, waitForAppReady } from '../proc/ports.ts';
1919
import { ensureDirs, sleep, rmrf } from '../proc/fsutil.ts';
@@ -46,6 +46,13 @@ export interface PipelineOpts {
4646
const activeMcpServers = (block: Record<string, any>): string[] =>
4747
Object.entries(block).filter(([, def]) => !def || def.enabled !== false).map(([name]) => name);
4848

49+
// RATE_LIMIT_PATTERN is env-overridable (see config.ts) so a bad override can't crash
50+
// the pipeline — fall back to a literal match of the configured text.
51+
const RATE_LIMIT_RE = (() => {
52+
try { return new RegExp(RATE_LIMIT_PATTERN, 'i'); }
53+
catch (_) { return new RegExp(RATE_LIMIT_PATTERN.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i'); }
54+
})();
55+
4956
// Stages 1–4b are identical for an interactive session and a headless matrix entry.
5057
// Stage 5+ branches: interactive launches `opencode web` (long-lived); headless runs
5158
// `opencode run "<prompt>"` once, parses usage, then screenshots every route.
@@ -66,8 +73,14 @@ export async function runPipeline(
6673

6774
// Report every spawned child to `onChild` (matrix cancel kills whatever is current)
6875
// so Cancel works during scaffold/npm-install too, not only the agent step.
69-
const runStep = (cmd: string, argv: string[], cwd: string, e: Emit, opts: RunOpts = {}) =>
70-
run(cmd, argv, cwd, e, { ...opts, onChild });
76+
const runStep = (cmd: string, argv: string[], cwd: string, e: Emit, opts: RunOpts = {}) => {
77+
const localOnChild = opts.onChild;
78+
const mergedOnChild = (child: ChildProcess) => {
79+
if (onChild) onChild(child);
80+
if (localOnChild) localOnChild(child);
81+
};
82+
return run(cmd, argv, cwd, e, { ...opts, onChild: mergedOnChild });
83+
};
7184

7285
ensureDirs();
7386
// Clean any previous attempt. In matrix mode `appDir` is unique per entry, so
@@ -404,9 +417,72 @@ export async function runPipeline(
404417
'run', ...(process.env.OPENCODE_RUN_ARGS || '').split(' ').filter(Boolean), prompt || '',
405418
...promptImageFiles.flatMap((f) => ['--file', f]),
406419
];
407-
await runStep('opencode', agentArgv, appDir, emit, {
408-
env: ocEnv, timeoutMs: AGENT_TIMEOUT_MS, heartbeatMs: 20000,
409-
});
420+
let rateLimited = false;
421+
let rateLimitLine = '';
422+
let agentChild: ChildProcess | null = null;
423+
let signaled = false;
424+
const opencodeLogPath = path.join(toolDataDir, 'opencode', 'log', 'opencode.log');
425+
let opencodeLogOffset = 0;
426+
try {
427+
if (fs.existsSync(opencodeLogPath)) opencodeLogOffset = fs.statSync(opencodeLogPath).size;
428+
} catch (_) {}
429+
const readFreshOpencodeLog = () => {
430+
try {
431+
const stat = fs.statSync(opencodeLogPath);
432+
// log rotate/truncate: restart from the new beginning
433+
if (stat.size < opencodeLogOffset) opencodeLogOffset = 0;
434+
const bytes = stat.size - opencodeLogOffset;
435+
if (bytes <= 0) return '';
436+
const fd = fs.openSync(opencodeLogPath, 'r');
437+
try {
438+
const buf = Buffer.allocUnsafe(bytes);
439+
const read = fs.readSync(fd, buf, 0, bytes, opencodeLogOffset);
440+
opencodeLogOffset += read;
441+
return read > 0 ? buf.subarray(0, read).toString() : '';
442+
} finally {
443+
fs.closeSync(fd);
444+
}
445+
} catch (_) {
446+
return '';
447+
}
448+
};
449+
const rateLimitWatch = setInterval(() => {
450+
if (rateLimited || signaled) return;
451+
const fresh = readFreshOpencodeLog();
452+
if (!fresh) return;
453+
const lines = fresh.split('\n').map((l) => l.trim()).filter(Boolean);
454+
for (const line of lines) {
455+
if (!RATE_LIMIT_RE.test(line)) continue;
456+
rateLimited = true;
457+
rateLimitLine = line;
458+
emit('log', 'rate limit detected in opencode log; aborting agent early');
459+
if (agentChild) {
460+
signaled = true;
461+
terminateTree(agentChild);
462+
}
463+
break;
464+
}
465+
}, 1000);
466+
rateLimitWatch.unref && rateLimitWatch.unref();
467+
468+
try {
469+
await runStep('opencode', agentArgv, appDir, emit, {
470+
env: ocEnv,
471+
timeoutMs: AGENT_TIMEOUT_MS,
472+
heartbeatMs: 20000,
473+
onChild: (child) => { agentChild = child; },
474+
});
475+
if (rateLimited) {
476+
throw new Error(`opencode rate-limited${rateLimitLine ? `: ${rateLimitLine}` : ''}`);
477+
}
478+
} catch (e: any) {
479+
if (rateLimited) {
480+
throw new Error(`opencode rate-limited${rateLimitLine ? `: ${rateLimitLine}` : ''}`);
481+
}
482+
throw e;
483+
} finally {
484+
clearInterval(rateLimitWatch);
485+
}
410486

411487
// Parse token/cost usage from `opencode stats` (against this entry's data dir).
412488
let entryStats: Stats | null = null;

src/proc/exec.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,17 @@ export function killTree(child: ChildProcess | null | undefined, sig: NodeJS.Sig
1818
catch (_) { try { child.kill(sig); } catch (_) {} }
1919
}
2020

21+
// Gracefully terminate a child's whole process group, then force-kill if it
22+
// ignores/outlives SIGTERM (e.g. stuck mid-request).
23+
export function terminateTree(child: ChildProcess | null | undefined, graceMs = 5000): void {
24+
if (!child) return;
25+
killTree(child, 'SIGTERM');
26+
const t = setTimeout(() => {
27+
if (child.exitCode === null && child.signalCode === null) killTree(child, 'SIGKILL');
28+
}, graceMs);
29+
t.unref && t.unref();
30+
}
31+
2132
// Run a command to completion, streaming its output through `emit`. Optional
2233
// `opts.env` is merged over process.env; `opts.timeoutMs` kills + rejects on hang;
2334
// `opts.heartbeatMs` emits a liveness tick so a long-but-working run (e.g. the agent)
@@ -41,7 +52,7 @@ export function run(cmd: string, argv: string[], cwd: string, emit: Emit, opts:
4152
if (opts.timeoutMs) {
4253
timer = setTimeout(() => {
4354
cleanup();
44-
killTree(child, 'SIGTERM');
55+
terminateTree(child);
4556
reject(new Error(`${cmd} timed out after ${opts.timeoutMs}ms`));
4657
}, opts.timeoutMs);
4758
}

0 commit comments

Comments
 (0)