@@ -7,6 +7,22 @@ import { generateJobId, upsertJob, jobLogPath, jobDataPath } from "./state.mjs";
77
88const SESSION_ID_ENV = "OPENCODE_COMPANION_SESSION_ID" ;
99
10+ // Hard ceiling for any single tracked job. 30 minutes is generous enough for
11+ // long OpenCode turns but bounded so a hung runner cannot keep the companion
12+ // process alive forever. Override via OPENCODE_COMPANION_JOB_TIMEOUT_MS.
13+ const DEFAULT_JOB_TIMEOUT_MS = 30 * 60 * 1000 ;
14+
15+ function resolveJobTimeoutMs ( options = { } ) {
16+ if ( Number . isFinite ( options . timeoutMs ) && options . timeoutMs > 0 ) {
17+ return options . timeoutMs ;
18+ }
19+ const fromEnv = Number ( process . env . OPENCODE_COMPANION_JOB_TIMEOUT_MS ) ;
20+ if ( Number . isFinite ( fromEnv ) && fromEnv > 0 ) {
21+ return fromEnv ;
22+ }
23+ return DEFAULT_JOB_TIMEOUT_MS ;
24+ }
25+
1026/**
1127 * Get the current Claude session ID from environment.
1228 * @returns {string|undefined }
@@ -41,9 +57,10 @@ export function createJobRecord(workspacePath, type, meta = {}) {
4157 * @param {string } workspacePath
4258 * @param {object } job
4359 * @param {(ctx: { report: Function, log: Function }) => Promise<object> } runner
60+ * @param {{ timeoutMs?: number } } [options]
4461 * @returns {Promise<object> } the job result
4562 */
46- export async function runTrackedJob ( workspacePath , job , runner ) {
63+ export async function runTrackedJob ( workspacePath , job , runner , options = { } ) {
4764 // Mark as running
4865 upsertJob ( workspacePath , { id : job . id , status : "running" , pid : process . pid } ) ;
4966
@@ -61,9 +78,35 @@ export async function runTrackedJob(workspacePath, job, runner) {
6178 appendLine ( logFile , `[${ new Date ( ) . toISOString ( ) } ] ${ message } ` ) ;
6279 } ;
6380
81+ // Race the runner against a hard wall-clock timeout so a hung runner
82+ // (dropped SSE stream, wedged post-response handler, unresolved downstream
83+ // fetch) cannot leave the job in `running` forever. See issue #41.
84+ const timeoutMs = resolveJobTimeoutMs ( options ) ;
85+ let timeoutHandle = null ;
86+ const timeoutPromise = new Promise ( ( _resolve , reject ) => {
87+ timeoutHandle = setTimeout ( ( ) => {
88+ reject (
89+ new Error (
90+ `Tracked job ${ job . id } exceeded the ${ Math . round ( timeoutMs / 1000 ) } s hard timeout. ` +
91+ "The runner did not produce a terminal status. " +
92+ "Set OPENCODE_COMPANION_JOB_TIMEOUT_MS to adjust."
93+ )
94+ ) ;
95+ } , timeoutMs ) ;
96+ timeoutHandle . unref ?. ( ) ;
97+ } ) ;
98+
99+ const clearTimer = ( ) => {
100+ if ( timeoutHandle ) {
101+ clearTimeout ( timeoutHandle ) ;
102+ timeoutHandle = null ;
103+ }
104+ } ;
105+
64106 try {
65107 report ( "starting" , `Job ${ job . id } started` ) ;
66- const result = await runner ( { report, log } ) ;
108+ const result = await Promise . race ( [ runner ( { report, log } ) , timeoutPromise ] ) ;
109+ clearTimer ( ) ;
67110
68111 // Mark as completed
69112 upsertJob ( workspacePath , {
@@ -81,9 +124,11 @@ export async function runTrackedJob(workspacePath, job, runner) {
81124 report ( "completed" , `Job ${ job . id } completed` ) ;
82125 return result ;
83126 } catch ( err ) {
127+ clearTimer ( ) ;
84128 upsertJob ( workspacePath , {
85129 id : job . id ,
86130 status : "failed" ,
131+ phase : "failed" ,
87132 completedAt : new Date ( ) . toISOString ( ) ,
88133 errorMessage : err . message ,
89134 } ) ;
0 commit comments