@@ -11,9 +11,9 @@ import { parseOpencodeStats } from '../capture/usage.ts';
1111import { collectToolUsage , installedSkills , summarizeToolUsage } from '../capture/tool-usage.ts' ;
1212import {
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' ;
1717import { spawnWatcher , killWatcher } from '../proc/watcher.ts' ;
1818import { waitForPort , waitForPortFree , waitForAppReady } from '../proc/ports.ts' ;
1919import { ensureDirs , sleep , rmrf } from '../proc/fsutil.ts' ;
@@ -46,6 +46,13 @@ export interface PipelineOpts {
4646const 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 ;
0 commit comments