@@ -8,6 +8,7 @@ import type { ChatCompletionMessageParam } from "openai/resources/chat/completio
88import { launchNotifyScript } from "./common/notify" ;
99import { buildThinkingRequestOptions } from "./common/openai-thinking" ;
1010import { DEEPSEEK_V4_MODELS } from "./common/model-capabilities" ;
11+ import { readTextFileWithMetadata } from "./common/file-utils" ;
1112import {
1213 getCompactPrompt ,
1314 getDefaultSkillPrompt ,
@@ -61,6 +62,7 @@ export type {
6162const MAX_SESSION_ENTRIES = 50 ;
6263const MAX_PROJECT_CODE_LENGTH = 64 ;
6364const PROJECT_CODE_HASH_LENGTH = 16 ;
65+ const BACKGROUND_FAILURE_LOG_TAIL_CHARS = 4000 ;
6466const DEFAULT_COMPACT_PROMPT_TOKEN_THRESHOLD = 128 * 1024 ;
6567const DEEPSEEK_V4_COMPACT_PROMPT_TOKEN_THRESHOLD = 512 * 1024 ;
6668
@@ -331,6 +333,7 @@ export class SessionManager {
331333 private activePromptController : AbortController | null = null ;
332334 private readonly sessionControllers = new Map < string , AbortController > ( ) ;
333335 private readonly processTimeoutControls = new Map < string , ProcessTimeoutControl > ( ) ;
336+ private readonly liveProcessKeys = new Set < string > ( ) ;
334337 private readonly toolExecutor : ToolExecutor ;
335338 private readonly mcpManager = new McpManager ( ) ;
336339 private mcpToolDefinitions : ToolDefinition [ ] = [ ] ;
@@ -396,6 +399,7 @@ export class SessionManager {
396399 sessionController . abort ( ) ;
397400 }
398401 }
402+ this . killLiveProcesses ( ) ;
399403 this . sessionControllers . clear ( ) ;
400404 this . processTimeoutControls . clear ( ) ;
401405 this . mcpManager . disconnect ( ) ;
@@ -1548,7 +1552,9 @@ ${skillMd}
15481552 const killedPids : number [ ] = [ ] ;
15491553 const failedPids : number [ ] = [ ] ;
15501554 for ( const pid of processIds ) {
1551- this . processTimeoutControls . delete ( this . getProcessControlKey ( sessionId , pid ) ) ;
1555+ const processControlKey = this . getProcessControlKey ( sessionId , pid ) ;
1556+ this . processTimeoutControls . delete ( processControlKey ) ;
1557+ this . liveProcessKeys . delete ( processControlKey ) ;
15521558 if ( killProcessTree ( pid , "SIGKILL" ) ) {
15531559 killedPids . push ( pid ) ;
15541560 continue ;
@@ -1915,21 +1921,11 @@ ${skillMd}
19151921 const processIds = options . processIds ?? [ ] ;
19161922 for ( const pid of processIds ) {
19171923 const processControlKey = this . getProcessControlKey ( sessionId , pid ) ;
1918- if ( ! this . processTimeoutControls . has ( processControlKey ) ) {
1924+ if ( ! this . processTimeoutControls . has ( processControlKey ) && ! this . liveProcessKeys . has ( processControlKey ) ) {
19191925 continue ;
19201926 }
19211927
1922- const killedGroup = killProcessTree ( pid , "SIGKILL" ) ;
1923- if ( killedGroup ) {
1924- this . processTimeoutControls . delete ( processControlKey ) ;
1925- continue ;
1926- }
1927- try {
1928- process . kill ( pid , "SIGKILL" ) ;
1929- } catch {
1930- // ignore process-kill failures during cleanup
1931- }
1932- this . processTimeoutControls . delete ( processControlKey ) ;
1928+ this . killTrackedProcess ( processControlKey , pid ) ;
19331929 }
19341930
19351931 clearSessionState ( sessionId ) ;
@@ -2195,6 +2191,7 @@ ${skillMd}
21952191 onProcessExit : ( pid ) => this . removeSessionProcess ( sessionId , pid ) ,
21962192 onProcessStdout : ( pid , chunk ) => this . onProcessStdout ?.( Number ( pid ) , chunk ) ,
21972193 onProcessTimeoutControl : ( pid , control ) => this . setSessionProcessTimeoutControl ( sessionId , pid , control ) ,
2194+ onBackgroundProcessComplete : ( completion ) => this . addBackgroundProcessCompletionMessage ( sessionId , completion ) ,
21982195 onBeforeFileMutation : ( filePath ) => this . prepareFileMutationCheckpoint ( sessionId , filePath ) ,
21992196 onAfterFileMutation : ( filePath ) => this . recordFileMutationCheckpoint ( sessionId , filePath ) ,
22002197 shouldStop : ( ) => this . isInterrupted ( sessionId ) ,
@@ -2456,6 +2453,7 @@ ${skillMd}
24562453
24572454 private addSessionProcess ( sessionId : string , processId : string | number , command : string ) : void {
24582455 const now = new Date ( ) . toISOString ( ) ;
2456+ this . liveProcessKeys . add ( this . getProcessControlKey ( sessionId , processId ) ) ;
24592457 this . updateSessionEntry ( sessionId , ( entry ) => {
24602458 const processes = new Map ( entry . processes ?? [ ] ) ;
24612459 processes . set ( String ( processId ) , { startTime : now , command } ) ;
@@ -2467,9 +2465,86 @@ ${skillMd}
24672465 } ) ;
24682466 }
24692467
2468+ private addBackgroundProcessCompletionMessage (
2469+ sessionId : string ,
2470+ completion : {
2471+ command : string ;
2472+ outputPath : string ;
2473+ ok : boolean ;
2474+ exitCode : number | null ;
2475+ signal : string | null ;
2476+ error ?: string ;
2477+ completedAtMs : number ;
2478+ startedAtMs : number ;
2479+ }
2480+ ) : void {
2481+ const status = completion . ok ? "completed" : "failed" ;
2482+ const exitText =
2483+ completion . exitCode !== null
2484+ ? `exit code ${ completion . exitCode } `
2485+ : completion . signal
2486+ ? `signal ${ completion . signal } `
2487+ : completion . error || "unknown status" ;
2488+ const durationMs = Math . max ( 0 , completion . completedAtMs - completion . startedAtMs ) ;
2489+ const baseContent =
2490+ `Background command "${ completion . command } " ${ status } with ${ exitText } ` +
2491+ `after ${ this . formatBackgroundDuration ( durationMs ) } . Output: ${ completion . outputPath } ` ;
2492+ const logTail = completion . ok ? null : this . buildBackgroundFailureLogTailSlice ( completion . outputPath ) ;
2493+ const content = logTail ? `${ baseContent } \n${ logTail } ` : baseContent ;
2494+ this . addSessionSystemMessage ( sessionId , content , true ) ;
2495+ }
2496+
2497+ private buildBackgroundFailureLogTailSlice ( outputPath : string ) : string | null {
2498+ const tail = this . readTextFileTail ( outputPath , BACKGROUND_FAILURE_LOG_TAIL_CHARS ) ;
2499+ if ( ! tail || ! tail . content ) {
2500+ return null ;
2501+ }
2502+ const prefix = tail . truncated ? `(${ tail . totalBytes } bytes)...\n` : "" ;
2503+ return [
2504+ `<background_task_failure_log path="${ outputPath } ">` ,
2505+ `${ prefix } ${ tail . content } ` ,
2506+ "</background_task_failure_log>" ,
2507+ ] . join ( "\n" ) ;
2508+ }
2509+
2510+ private readTextFileTail (
2511+ filePath : string ,
2512+ maxChars : number
2513+ ) : { content : string ; totalBytes : number ; truncated : boolean } | null {
2514+ try {
2515+ const stat = fs . statSync ( filePath ) ;
2516+ if ( ! stat . isFile ( ) || stat . size <= 0 ) {
2517+ return null ;
2518+ }
2519+ const content = readTextFileWithMetadata ( filePath ) . content ;
2520+ return {
2521+ content : content . slice ( - maxChars ) . trimEnd ( ) ,
2522+ totalBytes : stat . size ,
2523+ truncated : content . length > maxChars ,
2524+ } ;
2525+ } catch {
2526+ return null ;
2527+ }
2528+ }
2529+
2530+ private formatBackgroundDuration ( durationMs : number ) : string {
2531+ if ( durationMs < 1000 ) {
2532+ return `${ durationMs } ms` ;
2533+ }
2534+ const seconds = Math . round ( durationMs / 1000 ) ;
2535+ if ( seconds < 60 ) {
2536+ return `${ seconds } s` ;
2537+ }
2538+ const minutes = Math . floor ( seconds / 60 ) ;
2539+ const remainingSeconds = seconds % 60 ;
2540+ return remainingSeconds > 0 ? `${ minutes } m ${ remainingSeconds } s` : `${ minutes } m` ;
2541+ }
2542+
24702543 private removeSessionProcess ( sessionId : string , processId : string | number ) : void {
24712544 const now = new Date ( ) . toISOString ( ) ;
2472- this . processTimeoutControls . delete ( this . getProcessControlKey ( sessionId , processId ) ) ;
2545+ const processControlKey = this . getProcessControlKey ( sessionId , processId ) ;
2546+ this . processTimeoutControls . delete ( processControlKey ) ;
2547+ this . liveProcessKeys . delete ( processControlKey ) ;
24732548 this . updateSessionEntry ( sessionId , ( entry ) => {
24742549 const processes = new Map ( entry . processes ?? [ ] ) ;
24752550 processes . delete ( String ( processId ) ) ;
@@ -2532,6 +2607,37 @@ ${skillMd}
25322607 return `${ sessionId } :${ String ( processId ) } ` ;
25332608 }
25342609
2610+ private killLiveProcesses ( ) : void {
2611+ for ( const processControlKey of Array . from ( this . liveProcessKeys ) ) {
2612+ const processId = this . getProcessIdFromControlKey ( processControlKey ) ;
2613+ if ( processId === null ) {
2614+ this . liveProcessKeys . delete ( processControlKey ) ;
2615+ continue ;
2616+ }
2617+ this . killTrackedProcess ( processControlKey , processId ) ;
2618+ }
2619+ }
2620+
2621+ private killTrackedProcess ( processControlKey : string , processId : number ) : void {
2622+ const killedGroup = killProcessTree ( processId , "SIGKILL" ) ;
2623+ if ( ! killedGroup ) {
2624+ try {
2625+ process . kill ( processId , "SIGKILL" ) ;
2626+ } catch {
2627+ // Ignore process-kill failures during cleanup.
2628+ }
2629+ }
2630+ this . processTimeoutControls . delete ( processControlKey ) ;
2631+ this . liveProcessKeys . delete ( processControlKey ) ;
2632+ }
2633+
2634+ private getProcessIdFromControlKey ( processControlKey : string ) : number | null {
2635+ const separatorIndex = processControlKey . lastIndexOf ( ":" ) ;
2636+ const rawProcessId = separatorIndex >= 0 ? processControlKey . slice ( separatorIndex + 1 ) : processControlKey ;
2637+ const processId = Number ( rawProcessId ) ;
2638+ return Number . isInteger ( processId ) && processId > 0 ? processId : null ;
2639+ }
2640+
25352641 private getProcessIds ( processes : Map < string , SessionProcessEntry > | null ) : number [ ] {
25362642 if ( ! processes ) {
25372643 return [ ] ;
0 commit comments