@@ -4,10 +4,13 @@ import path from 'path'
44import { nanoid } from 'nanoid'
55import logger from '@shared/logger'
66import { getShellEnvironment , getUserShell } from './shellEnvHelper'
7+ import { terminateProcessTree } from './processTree'
78import { resolveSessionDir } from './sessionPaths'
89
910// Configuration with environment variable support
10- const getConfig = ( ) => ( {
11+ const FOREGROUND_PREVIEW_CHARS = 12000
12+
13+ export const getBackgroundExecConfig = ( ) => ( {
1114 backgroundMs : parseInt ( process . env . PI_BASH_YIELD_MS || '10000' , 10 ) ,
1215 timeoutSec : parseInt ( process . env . PI_BASH_TIMEOUT_SEC || '1800' , 10 ) ,
1316 cleanupMs : parseInt ( process . env . PI_BASH_JOB_TTL_MS || '1800000' , 10 ) ,
@@ -21,6 +24,8 @@ const getConfig = () => ({
2124 offloadThresholdChars : 10000 // Offload to file when output exceeds this
2225} )
2326
27+ const getConfig = getBackgroundExecConfig
28+
2429export interface SessionMeta {
2530 sessionId : string
2631 command : string
@@ -31,8 +36,22 @@ export interface SessionMeta {
3136 exitCode ?: number
3237 outputLength : number
3338 offloaded : boolean
39+ timedOut ?: boolean
40+ }
41+
42+ export interface SessionCompletionResult {
43+ status : 'done' | 'error' | 'killed'
44+ output : string
45+ exitCode : number | null
46+ offloaded : boolean
47+ outputFilePath ?: string
48+ timedOut : boolean
3449}
3550
51+ export type WaitForCompletionOrYieldResult =
52+ | { kind : 'running' ; sessionId : string }
53+ | { kind : 'completed' ; result : SessionCompletionResult }
54+
3655interface BackgroundSession {
3756 sessionId : string
3857 conversationId : string
@@ -54,6 +73,7 @@ interface BackgroundSession {
5473 resolveClose : ( ) => void
5574 closeSettled : boolean
5675 killTimeoutId ?: NodeJS . Timeout
76+ timedOut : boolean
5777}
5878
5979interface StartSessionResult {
@@ -67,6 +87,7 @@ interface PollResult {
6787 exitCode ?: number
6888 offloaded ?: boolean
6989 outputFilePath ?: string
90+ timedOut ?: boolean
7091}
7192
7293interface LogResult {
@@ -76,6 +97,7 @@ interface LogResult {
7697 exitCode ?: number
7798 offloaded ?: boolean
7899 outputFilePath ?: string
100+ timedOut ?: boolean
79101}
80102
81103export class BackgroundExecSessionManager {
@@ -93,6 +115,7 @@ export class BackgroundExecSessionManager {
93115 options ?: {
94116 timeout ?: number
95117 env ?: Record < string , string >
118+ outputPrefix ?: string
96119 }
97120 ) : Promise < StartSessionResult > {
98121 const config = getConfig ( )
@@ -105,7 +128,9 @@ export class BackgroundExecSessionManager {
105128 fs . mkdirSync ( sessionDir , { recursive : true } )
106129 }
107130
108- const outputFilePath = sessionDir ? path . join ( sessionDir , `bgexec_${ sessionId } .log` ) : null
131+ const outputFilePath = sessionDir
132+ ? this . createOutputFilePath ( sessionDir , sessionId , options ?. outputPrefix )
133+ : null
109134
110135 const child = spawn ( shell , [ ...args , command ] , {
111136 cwd,
@@ -114,6 +139,7 @@ export class BackgroundExecSessionManager {
114139 ...shellEnv ,
115140 ...options ?. env
116141 } ,
142+ detached : process . platform !== 'win32' ,
117143 stdio : [ 'pipe' , 'pipe' , 'pipe' ]
118144 } )
119145
@@ -140,7 +166,8 @@ export class BackgroundExecSessionManager {
140166 stderrEof : false ,
141167 closePromise,
142168 resolveClose,
143- closeSettled : false
169+ closeSettled : false ,
170+ timedOut : false
144171 }
145172
146173 this . setupOutputHandling ( session , config )
@@ -176,7 +203,8 @@ export class BackgroundExecSessionManager {
176203 pid : session . child . pid ,
177204 exitCode : session . exitCode ,
178205 outputLength : session . totalOutputLength ,
179- offloaded : this . hasPersistedOutput ( session , getConfig ( ) )
206+ offloaded : this . hasPersistedOutput ( session , getConfig ( ) ) ,
207+ timedOut : session . timedOut
180208 } ) )
181209 }
182210
@@ -195,7 +223,8 @@ export class BackgroundExecSessionManager {
195223 output,
196224 exitCode : session . exitCode ,
197225 offloaded : true ,
198- outputFilePath : session . outputFilePath
226+ outputFilePath : session . outputFilePath ,
227+ timedOut : session . timedOut
199228 }
200229 }
201230
@@ -204,7 +233,8 @@ export class BackgroundExecSessionManager {
204233 status : session . status ,
205234 output,
206235 exitCode : session . exitCode ,
207- offloaded : false
236+ offloaded : false ,
237+ timedOut : session . timedOut
208238 }
209239 }
210240
@@ -234,10 +264,65 @@ export class BackgroundExecSessionManager {
234264 totalLength : session . totalOutputLength ,
235265 exitCode : session . exitCode ,
236266 offloaded : isOffloaded ,
237- outputFilePath : session . outputFilePath || undefined
267+ outputFilePath : session . outputFilePath || undefined ,
268+ timedOut : session . timedOut
269+ }
270+ }
271+
272+ async waitForCompletionOrYield (
273+ conversationId : string ,
274+ sessionId : string ,
275+ yieldMs = getConfig ( ) . backgroundMs
276+ ) : Promise < WaitForCompletionOrYieldResult > {
277+ const session = this . getSession ( conversationId , sessionId )
278+ session . lastAccessedAt = Date . now ( )
279+
280+ if ( session . status !== 'running' ) {
281+ return {
282+ kind : 'completed' ,
283+ result : await this . getCompletionResult ( conversationId , sessionId )
284+ }
285+ }
286+
287+ let yieldTimer : NodeJS . Timeout | null = null
288+
289+ try {
290+ await Promise . race ( [
291+ session . closePromise ,
292+ new Promise ( ( resolve ) => {
293+ yieldTimer = setTimeout ( resolve , Math . max ( 0 , yieldMs ) )
294+ } )
295+ ] )
296+ } finally {
297+ if ( yieldTimer ) {
298+ clearTimeout ( yieldTimer )
299+ }
300+ }
301+
302+ if ( session . status !== 'running' ) {
303+ return {
304+ kind : 'completed' ,
305+ result : await this . getCompletionResult ( conversationId , sessionId )
306+ }
307+ }
308+
309+ return {
310+ kind : 'running' ,
311+ sessionId
238312 }
239313 }
240314
315+ async getCompletionResult (
316+ conversationId : string ,
317+ sessionId : string ,
318+ previewChars = FOREGROUND_PREVIEW_CHARS
319+ ) : Promise < SessionCompletionResult > {
320+ const session = this . getSession ( conversationId , sessionId )
321+ session . lastAccessedAt = Date . now ( )
322+ await this . waitForSessionDrain ( session )
323+ return this . buildCompletionResult ( session , previewChars )
324+ }
325+
241326 write ( conversationId : string , sessionId : string , data : string , eof = false ) : void {
242327 const session = this . getSession ( conversationId , sessionId )
243328
@@ -446,31 +531,15 @@ export class BackgroundExecSessionManager {
446531 clearTimeout ( session . killTimeoutId )
447532 }
448533
449- const gracefulKill = new Promise < void > ( ( resolve ) => {
450- const timeout = setTimeout ( ( ) => {
451- resolve ( )
452- } , 2000 )
453-
454- session . child . once ( 'close' , ( ) => {
455- clearTimeout ( timeout )
456- resolve ( )
457- } )
458-
459- try {
460- session . child . kill ( 'SIGTERM' )
461- } catch {
462- resolve ( )
463- }
464- } )
465-
466- await gracefulKill
534+ if ( reason === 'timeout' ) {
535+ session . timedOut = true
536+ }
537+ session . status = 'killed'
467538
468- if ( session . status === 'running' ) {
469- try {
470- session . child . kill ( 'SIGKILL' )
471- } catch ( error ) {
472- logger . warn ( `[BackgroundExec] Failed to force kill session ${ session . sessionId } :` , error )
473- }
539+ const closed = await terminateProcessTree ( session . child , { graceMs : 2000 } )
540+ if ( ! closed && ! session . closeSettled ) {
541+ session . exitCode = undefined
542+ await this . finalizeSession ( session , null , 'SIGKILL' )
474543 }
475544
476545 await session . closePromise
@@ -682,6 +751,37 @@ export class BackgroundExecSessionManager {
682751 )
683752 }
684753
754+ private buildCompletionResult (
755+ session : BackgroundSession ,
756+ previewChars : number
757+ ) : SessionCompletionResult {
758+ const config = getConfig ( )
759+ const offloaded = this . hasPersistedOutput ( session , config )
760+ const output =
761+ offloaded && session . outputFilePath
762+ ? this . getRecentOutputFromSession ( session , previewChars )
763+ : this . getRecentOutput ( session . outputBuffer , previewChars )
764+
765+ return {
766+ status : session . status === 'running' ? 'killed' : session . status ,
767+ output,
768+ exitCode : session . exitCode ?? null ,
769+ offloaded,
770+ outputFilePath : session . outputFilePath || undefined ,
771+ timedOut : session . timedOut
772+ }
773+ }
774+
775+ private createOutputFilePath (
776+ sessionDir : string ,
777+ sessionId : string ,
778+ outputPrefix ?: string
779+ ) : string {
780+ const rawPrefix = outputPrefix ?. trim ( ) || 'bgexec'
781+ const safePrefix = rawPrefix . replace ( / [ ^ a - z A - Z 0 - 9 _ - ] / g, '_' )
782+ return path . join ( sessionDir , `${ safePrefix } _${ sessionId } .log` )
783+ }
784+
685785 private resolveUtf8ByteRange (
686786 fd : number ,
687787 fileSize : number ,
0 commit comments