2026-02-01 16:51:11 +09:00
2025-12-11 15:45:37 +09:00
import type { PluginInput } from "@opencode-ai/plugin"
2026-04-28 15:27:35 +09:00
import type { ModelFallbackControllerAccessor } from "../../hooks/model-fallback"
2026-04-04 22:13:05 -07:00
import { isAgentNotFoundError , FALLBACK_AGENT , buildFallbackBody } from "./spawner"
2026-02-01 16:51:11 +09:00
import type {
BackgroundTask ,
2026-04-28 15:27:35 +09:00
BackgroundTaskAttempt ,
2026-02-01 16:51:11 +09:00
LaunchInput ,
ResumeInput ,
} from "./types"
2026-02-13 17:40:44 +09:00
import { TaskHistory } from "./task-history"
2026-02-18 18:02:42 +09:00
import {
log ,
getAgentToolRestrictions ,
normalizePromptTools ,
normalizeSDKResponse ,
promptWithModelSuggestionRetry ,
resolveInheritedPromptTools ,
2026-02-18 19:55:36 +02:00
createInternalAgentTextPart ,
2026-02-18 18:02:42 +09:00
} from "../../shared"
2026-03-25 09:28:59 +01:00
import { applySessionPromptParams } from "../../shared/session-prompt-params-helpers"
2026-02-14 14:30:30 +09:00
import { setSessionTools } from "../../shared/session-tools-store"
2026-02-04 15:25:41 +09:00
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
2026-02-01 16:47:50 +09:00
import { ConcurrencyManager } from "./concurrency"
2026-02-01 16:51:11 +09:00
import type { BackgroundTaskConfig , TmuxConfig } from "../../config/schema"
import { isInsideTmux } from "../../shared/tmux"
2026-02-19 04:41:00 +02:00
import {
shouldRetryError ,
hasMoreFallbacks ,
} from "../../shared/model-error-classifier"
2026-02-01 19:01:30 +09:00
import {
POLLING_INTERVAL_MS ,
TASK_CLEANUP_DELAY_MS ,
2026-03-17 15:17:34 +09:00
TASK_TTL_MS ,
2026-04-28 15:27:35 +09:00
type QueueItem ,
2026-02-01 19:01:30 +09:00
} from "./constants"
2026-02-01 16:51:11 +09:00
2025-12-16 23:01:48 +09:00
import { subagentSessions } from "../claude-code-session-state"
2026-01-09 02:24:43 +09:00
import { getTaskToastManager } from "../task-toast-manager"
2026-02-22 11:58:57 +09:00
import { formatDuration } from "./duration-formatter"
2026-04-03 17:43:33 +09:00
import {
buildBackgroundTaskNotificationText ,
type BackgroundTaskNotificationTask ,
} from "./background-task-notification-template"
2026-02-22 11:58:57 +09:00
import {
isAbortedSessionError ,
extractErrorName ,
extractErrorMessage ,
getSessionErrorMessage ,
isRecord ,
} from "./error-classifier"
import { tryFallbackRetry } from "./fallback-retry-handler"
2026-04-28 15:27:35 +09:00
import {
bindAttemptSession ,
ensureCurrentAttempt ,
findAttemptBySession ,
finalizeAttempt ,
getCurrentAttempt ,
startAttempt ,
} from "./attempt-lifecycle"
2026-02-22 11:58:57 +09:00
import { registerManagerForCleanup , unregisterManagerForCleanup } from "./process-cleanup"
2026-04-16 11:59:11 +08:00
import { setContinuationMarkerSource } from "../../features/run-continuation-state"
2026-03-08 02:23:33 +09:00
import {
findNearestMessageExcludingCompaction ,
resolvePromptContextFromSessionMessages ,
} from "./compaction-aware-message-resolver"
2026-02-22 15:30:15 +09:00
import { handleSessionIdleBackgroundEvent } from "./session-idle-event-handler"
2026-02-22 12:14:26 +09:00
import { MESSAGE_STORAGE } from "../hook-message-injector"
import { join } from "node:path"
2026-05-04 16:30:06 +09:00
import { pruneStaleTasksAndNotifications , type SessionStatusMap } from "./task-poller"
2026-02-22 11:58:57 +09:00
import { checkAndInterruptStaleTasks } from "./task-poller"
2026-03-09 11:39:04 +09:00
import { removeTaskToastTracking } from "./remove-task-toast-tracking"
2026-04-04 01:19:52 +09:00
import { abortWithTimeout } from "./abort-with-timeout"
2026-03-31 17:04:07 -07:00
import {
MIN_SESSION_GONE_POLLS ,
verifySessionExists as verifySessionStillExists ,
} from "./session-existence"
2026-03-18 13:56:11 +09:00
import { isActiveSessionStatus , isTerminalSessionStatus } from "./session-status-classifier"
2026-03-17 16:31:18 +09:00
import {
detectRepetitiveToolUse ,
recordToolCall ,
resolveCircuitBreakerSettings ,
2026-03-18 14:18:38 +09:00
type CircuitBreakerSettings ,
2026-03-17 16:31:18 +09:00
} from "./loop-detector"
2026-03-11 17:46:04 +09:00
import {
createSubagentDepthLimitError ,
getMaxSubagentDepth ,
resolveSubagentSpawnContext ,
type SubagentSpawnContext ,
} from "./subagent-spawn-limits"
2026-02-01 16:51:11 +09:00
type OpencodeClient = PluginInput [ "client" ]
interface MessagePartInfo {
2026-03-17 16:31:18 +09:00
id? : string
2026-02-01 16:51:11 +09:00
sessionID? : string
type ? : string
tool? : string
2026-03-17 13:40:46 -06:00
state ? : { status? : string ; input? : Record < string , unknown > }
2026-02-01 16:51:11 +09:00
}
interface EventProperties {
sessionID? : string
info ? : { id? : string }
[ key : string ] : unknown
}
interface Event {
type : string
properties? : EventProperties
}
2026-03-17 16:31:18 +09:00
function resolveMessagePartInfo ( properties : EventProperties | undefined ) : MessagePartInfo | undefined {
if ( ! properties || typeof properties !== "object" ) {
return undefined
}
const nestedPart = properties . part
if ( nestedPart && typeof nestedPart === "object" ) {
return nestedPart as MessagePartInfo
}
return properties as MessagePartInfo
}
2026-02-01 16:51:11 +09:00
interface Todo {
content : string
status : string
priority : string
id : string
}
2026-05-02 03:01:03 +09:00
function formatAttemptModelSummary ( attempt : Pick < BackgroundTaskAttempt , "providerId" | "modelId" > | undefined ) : string | undefined {
if ( ! attempt ? . providerId || ! attempt . modelId ) {
2026-04-28 15:27:35 +09:00
return undefined
}
2026-05-02 03:01:03 +09:00
return ` ${ attempt . providerId } / ${ attempt . modelId } `
2026-04-28 15:27:35 +09:00
}
function getPreviousAttempt ( task : BackgroundTask , attemptID : string | undefined ) : BackgroundTaskAttempt | undefined {
if ( ! attemptID || ! task . attempts || task . attempts . length === 0 ) {
return undefined
}
2026-05-02 03:01:03 +09:00
const attemptIndex = task . attempts . findIndex ( ( attempt ) = > attempt . attemptId === attemptID )
2026-04-28 15:27:35 +09:00
if ( attemptIndex <= 0 ) {
return undefined
}
return task . attempts [ attemptIndex - 1 ]
}
function cloneAttempts ( task : BackgroundTask ) : BackgroundTaskAttempt [ ] | undefined {
if ( ! task . attempts ) {
return undefined
}
return task . attempts . map ( ( attempt ) = > ( { . . . attempt } ) )
}
function buildLocalSessionUrl ( directory : string , sessionID : string ) : string {
const encodedDirectory = Buffer . from ( directory ) . toString ( "base64url" )
return ` http://127.0.0.1:4096/ ${ encodedDirectory } /session/ ${ sessionID } `
2026-02-01 16:51:11 +09:00
}
2026-01-18 14:29:46 +09:00
2026-02-01 16:51:11 +09:00
export interface SubagentSessionCreatedEvent {
sessionID : string
parentID : string
title : string
}
2026-01-26 12:02:37 +09:00
2026-02-01 16:51:11 +09:00
export type OnSubagentSessionCreated = ( event : SubagentSessionCreatedEvent ) = > Promise < void >
2026-01-26 12:02:37 +09:00
2026-03-17 15:17:34 +09:00
const MAX_TASK_REMOVAL_RESCHEDULES = 6
2026-05-02 03:01:03 +09:00
export interface BackgroundManagerConfig {
pluginContext : PluginInput
config? : BackgroundTaskConfig
tmuxConfig? : TmuxConfig
onSubagentSessionCreated? : OnSubagentSessionCreated
onShutdown ? : ( ) = > void | Promise < void >
enableParentSessionNotifications? : boolean
modelFallbackControllerAccessor? : ModelFallbackControllerAccessor
2026-05-07 17:47:53 +09:00
log? : typeof log
2026-05-02 03:01:03 +09:00
}
2025-12-11 15:45:37 +09:00
export class BackgroundManager {
2026-02-22 11:58:57 +09:00
2026-01-14 23:11:38 -08:00
2026-02-01 16:51:11 +09:00
private tasks : Map < string , BackgroundTask >
2026-04-28 18:00:04 +09:00
private tasksByParentSession : Map < string , Set < string > >
2026-02-01 16:51:11 +09:00
private notifications : Map < string , BackgroundTask [ ] >
2026-02-25 16:26:31 +09:00
private pendingNotifications : Map < string , string [ ] >
2026-02-01 16:51:11 +09:00
private pendingByParent : Map < string , Set < string > > // Track pending tasks per parent for batching
2025-12-11 15:45:37 +09:00
private client : OpencodeClient
2025-12-11 18:13:02 +09:00
private directory : string
2025-12-27 23:06:44 +09:00
private pollingInterval? : ReturnType < typeof setInterval >
2026-02-17 03:06:40 +09:00
private pollingInFlight = false
2026-01-07 01:24:47 +09:00
private concurrencyManager : ConcurrencyManager
2026-01-14 15:09:32 -08:00
private shutdownTriggered = false
2026-01-17 17:40:58 +09:00
private config? : BackgroundTaskConfig
2026-01-25 15:34:10 +09:00
private tmuxEnabled : boolean
2026-02-01 16:51:11 +09:00
private onSubagentSessionCreated? : OnSubagentSessionCreated
2026-03-11 21:30:04 +09:00
private onShutdown ? : ( ) = > void | Promise < void >
2026-02-01 16:51:11 +09:00
private queuesByKey : Map < string , QueueItem [ ] > = new Map ( )
private processingKeys : Set < string > = new Set ( )
private completionTimers : Map < string , ReturnType < typeof setTimeout > > = new Map ( )
2026-04-03 17:43:33 +09:00
private completedTaskSummaries : Map < string , BackgroundTaskNotificationTask [ ] > = new Map ( )
2026-02-06 16:01:54 +09:00
private idleDeferralTimers : Map < string , ReturnType < typeof setTimeout > > = new Map ( )
2026-02-08 13:05:06 +09:00
private notificationQueueByParent : Map < string , Promise < void > > = new Map ( )
2026-04-04 18:39:26 +09:00
private observedOutputSessions : Set < string > = new Set ( )
2026-04-04 18:48:03 +09:00
private observedIncompleteTodosBySession : Map < string , boolean > = new Map ( )
2026-03-11 17:46:04 +09:00
private rootDescendantCounts : Map < string , number >
2026-03-13 12:37:33 +09:00
private preStartDescendantReservations : Set < string >
2026-02-16 00:58:33 +02:00
private enableParentSessionNotifications : boolean
2026-04-28 15:27:35 +09:00
private modelFallbackControllerAccessor? : ModelFallbackControllerAccessor
2026-05-07 17:47:53 +09:00
private logger : typeof log
2026-05-04 16:30:06 +09:00
private loggedSessionStatusUnavailable = false
2026-02-13 17:40:44 +09:00
readonly taskHistory = new TaskHistory ( )
2026-03-18 14:18:38 +09:00
private cachedCircuitBreakerSettings? : CircuitBreakerSettings
2026-01-18 14:29:46 +09:00
2026-05-02 03:01:03 +09:00
constructor ( config : BackgroundManagerConfig ) {
const { pluginContext , . . . options } = config
2026-02-01 16:51:11 +09:00
this . tasks = new Map ( )
2026-04-28 18:00:04 +09:00
this . tasksByParentSession = new Map ( )
2026-02-01 16:51:11 +09:00
this . notifications = new Map ( )
2026-02-25 16:26:31 +09:00
this . pendingNotifications = new Map ( )
2026-02-01 16:51:11 +09:00
this . pendingByParent = new Map ( )
2026-05-02 03:01:03 +09:00
this . client = pluginContext . client
this . directory = pluginContext . directory
this . concurrencyManager = new ConcurrencyManager ( options . config )
this . config = options . config
2026-01-26 12:02:37 +09:00
this . tmuxEnabled = options ? . tmuxConfig ? . enabled ? ? false
this . onSubagentSessionCreated = options ? . onSubagentSessionCreated
2026-01-29 18:29:47 +09:00
this . onShutdown = options ? . onShutdown
2026-03-11 17:46:04 +09:00
this . rootDescendantCounts = new Map ( )
2026-03-13 12:37:33 +09:00
this . preStartDescendantReservations = new Set ( )
2026-02-16 00:58:33 +02:00
this . enableParentSessionNotifications = options ? . enableParentSessionNotifications ? ? true
2026-04-28 15:27:35 +09:00
this . modelFallbackControllerAccessor = options ? . modelFallbackControllerAccessor
2026-05-07 17:47:53 +09:00
this . logger = options ? . log ? ? log
2026-01-14 15:09:32 -08:00
this . registerProcessCleanup ( )
2025-12-11 15:45:37 +09:00
}
2026-04-03 21:42:10 +09:00
private async abortSessionWithLogging ( sessionID : string , reason : string ) : Promise < void > {
try {
2026-04-04 01:19:52 +09:00
await abortWithTimeout ( this . client , sessionID )
2026-04-03 21:42:10 +09:00
} catch ( error ) {
log ( ` [background-agent] Failed to abort session during ${ reason } : ` , {
sessionID ,
error ,
} )
}
}
2026-03-11 17:46:04 +09:00
async assertCanSpawn ( parentSessionID : string ) : Promise < SubagentSpawnContext > {
2026-04-12 02:30:01 +09:00
const spawnContext = await resolveSubagentSpawnContext ( this . client , parentSessionID , this . directory )
2026-03-11 17:46:04 +09:00
const maxDepth = getMaxSubagentDepth ( this . config )
if ( spawnContext . childDepth > maxDepth ) {
throw createSubagentDepthLimitError ( {
childDepth : spawnContext.childDepth ,
maxDepth ,
parentSessionID ,
rootSessionID : spawnContext.rootSessionID ,
} )
}
return spawnContext
}
2026-03-11 18:44:20 +09:00
async reserveSubagentSpawn ( parentSessionID : string ) : Promise < {
spawnContext : SubagentSpawnContext
descendantCount : number
commit : ( ) = > number
rollback : ( ) = > void
} > {
const spawnContext = await this . assertCanSpawn ( parentSessionID )
const descendantCount = this . registerRootDescendant ( spawnContext . rootSessionID )
let settled = false
return {
spawnContext ,
descendantCount ,
commit : ( ) = > {
settled = true
return descendantCount
} ,
rollback : ( ) = > {
if ( settled ) return
settled = true
this . unregisterRootDescendant ( spawnContext . rootSessionID )
} ,
}
}
2026-03-11 17:46:04 +09:00
private registerRootDescendant ( rootSessionID : string ) : number {
const nextCount = ( this . rootDescendantCounts . get ( rootSessionID ) ? ? 0 ) + 1
this . rootDescendantCounts . set ( rootSessionID , nextCount )
return nextCount
}
2026-03-11 18:44:20 +09:00
private unregisterRootDescendant ( rootSessionID : string ) : void {
const currentCount = this . rootDescendantCounts . get ( rootSessionID ) ? ? 0
if ( currentCount <= 1 ) {
this . rootDescendantCounts . delete ( rootSessionID )
return
}
this . rootDescendantCounts . set ( rootSessionID , currentCount - 1 )
}
2026-03-13 12:37:33 +09:00
private markPreStartDescendantReservation ( task : BackgroundTask ) : void {
this . preStartDescendantReservations . add ( task . id )
}
private settlePreStartDescendantReservation ( task : BackgroundTask ) : void {
this . preStartDescendantReservations . delete ( task . id )
}
private rollbackPreStartDescendantReservation ( task : BackgroundTask ) : void {
if ( ! this . preStartDescendantReservations . delete ( task . id ) ) {
return
}
2026-05-02 03:01:03 +09:00
if ( ! task . rootSessionId ) {
2026-03-13 12:37:33 +09:00
return
}
2026-05-02 03:01:03 +09:00
this . unregisterRootDescendant ( task . rootSessionId )
2026-03-13 12:37:33 +09:00
}
2026-04-28 18:00:04 +09:00
private addTask ( task : BackgroundTask ) : void {
this . tasks . set ( task . id , task )
2026-05-02 03:01:03 +09:00
if ( ! task . parentSessionId ) {
2026-04-28 18:00:04 +09:00
return
}
2026-05-02 03:01:03 +09:00
const taskIDs = this . tasksByParentSession . get ( task . parentSessionId ) ? ? new Set < string > ( )
2026-04-28 18:00:04 +09:00
taskIDs . add ( task . id )
2026-05-02 03:01:03 +09:00
this . tasksByParentSession . set ( task . parentSessionId , taskIDs )
2026-04-28 18:00:04 +09:00
}
private removeTask ( task : BackgroundTask ) : void {
this . tasks . delete ( task . id )
2026-05-02 03:01:03 +09:00
this . removeTaskFromParentIndex ( task . id , task . parentSessionId )
2026-04-28 18:00:04 +09:00
}
private updateTaskParent ( task : BackgroundTask , parentSessionID : string ) : void {
2026-05-02 03:01:03 +09:00
if ( task . parentSessionId === parentSessionID ) {
2026-04-28 18:00:04 +09:00
return
}
2026-05-02 03:01:03 +09:00
this . removeTaskFromParentIndex ( task . id , task . parentSessionId )
task . parentSessionId = parentSessionID
2026-04-28 18:00:04 +09:00
const taskIDs = this . tasksByParentSession . get ( parentSessionID ) ? ? new Set < string > ( )
taskIDs . add ( task . id )
this . tasksByParentSession . set ( parentSessionID , taskIDs )
}
private removeTaskFromParentIndex ( taskID : string , parentSessionID : string | undefined ) : void {
if ( ! parentSessionID ) {
return
}
const taskIDs = this . tasksByParentSession . get ( parentSessionID )
if ( ! taskIDs ) {
return
}
taskIDs . delete ( taskID )
if ( taskIDs . size === 0 ) {
this . tasksByParentSession . delete ( parentSessionID )
}
}
2025-12-11 15:45:37 +09:00
async launch ( input : LaunchInput ) : Promise < BackgroundTask > {
2026-01-10 13:00:25 +08:00
log ( "[background-agent] launch() called with:" , {
agent : input.agent ,
model : input.model ,
description : input.description ,
2026-05-02 03:01:03 +09:00
parentSessionID : input.parentSessionId ,
2026-01-10 13:00:25 +08:00
} )
2025-12-14 01:22:28 +09:00
if ( ! input . agent || input . agent . trim ( ) === "" ) {
throw new Error ( "Agent parameter is required" )
}
2026-05-06 17:41:41 +09:00
input = { . . . input , agent : input.agent.trim ( ) . replace ( /^[\\/"']+|[\\/"']+$/g , "" ) . trim ( ) }
if ( ! input . agent ) {
throw new Error ( "Agent parameter is required after sanitization" )
}
2026-05-02 03:01:03 +09:00
const spawnReservation = await this . reserveSubagentSpawn ( input . parentSessionId )
2026-02-01 16:51:11 +09:00
2026-03-11 18:44:20 +09:00
try {
log ( "[background-agent] spawn guard passed" , {
2026-05-02 03:01:03 +09:00
parentSessionID : input.parentSessionId ,
2026-03-11 18:44:20 +09:00
rootSessionID : spawnReservation.spawnContext.rootSessionID ,
childDepth : spawnReservation.spawnContext.childDepth ,
descendantCount : spawnReservation.descendantCount ,
} )
2026-01-18 14:33:42 +09:00
2026-03-11 18:44:20 +09:00
// Create task immediately with status="pending"
const task : BackgroundTask = {
id : ` bg_ ${ crypto . randomUUID ( ) . slice ( 0 , 8 ) } ` ,
status : "pending" ,
queuedAt : new Date ( ) ,
2026-05-02 03:01:03 +09:00
rootSessionId : spawnReservation.spawnContext.rootSessionID ,
2026-03-11 18:44:20 +09:00
// Do NOT set startedAt - will be set when running
// Do NOT set sessionID - will be set when running
description : input.description ,
prompt : input.prompt ,
agent : input.agent ,
spawnDepth : spawnReservation.spawnContext.childDepth ,
2026-05-02 03:01:03 +09:00
parentSessionId : input.parentSessionId ,
parentMessageId : input.parentMessageId ,
2026-04-28 10:48:05 +09:00
teamRunId : input.teamRunId ,
2026-03-11 18:44:20 +09:00
parentModel : input.parentModel ,
parentAgent : input.parentAgent ,
parentTools : input.parentTools ,
model : input.model ,
fallbackChain : input.fallbackChain ,
attemptCount : 0 ,
category : input.category ,
}
2026-04-28 15:27:35 +09:00
const firstAttempt = startAttempt ( task , input . model )
2026-01-18 14:39:11 +09:00
2026-04-28 18:00:04 +09:00
this . addTask ( task )
2026-05-02 03:01:03 +09:00
this . taskHistory . record ( input . parentSessionId , { id : task.id , agent : input.agent , description : input.description , status : "pending" , category : input.category } )
2026-01-18 14:33:42 +09:00
2026-03-11 18:44:20 +09:00
// Track for batched notifications immediately (pending state)
2026-05-02 03:01:03 +09:00
if ( input . parentSessionId ) {
const pending = this . pendingByParent . get ( input . parentSessionId ) ? ? new Set ( )
2026-03-11 18:44:20 +09:00
pending . add ( task . id )
2026-05-02 03:01:03 +09:00
this . pendingByParent . set ( input . parentSessionId , pending )
2026-03-11 18:44:20 +09:00
}
2026-01-18 14:33:42 +09:00
2026-03-11 18:44:20 +09:00
// Add to queue
const key = this . getConcurrencyKeyFromInput ( input )
const queue = this . queuesByKey . get ( key ) ? ? [ ]
2026-05-02 03:01:03 +09:00
queue . push ( { task , input , attemptID : firstAttempt.attemptId } )
2026-03-11 18:44:20 +09:00
this . queuesByKey . set ( key , queue )
2026-01-18 14:33:42 +09:00
2026-03-11 18:44:20 +09:00
log ( "[background-agent] Task queued:" , { taskId : task.id , key , queueLength : queue.length } )
2026-01-18 14:33:42 +09:00
2026-03-11 18:44:20 +09:00
const toastManager = getTaskToastManager ( )
if ( toastManager ) {
toastManager . addTask ( {
id : task.id ,
description : input.description ,
agent : input.agent ,
isBackground : true ,
status : "queued" ,
skills : input.skills ,
} )
}
2026-01-19 10:22:34 +09:00
2026-03-11 18:44:20 +09:00
spawnReservation . commit ( )
2026-03-13 12:37:33 +09:00
this . markPreStartDescendantReservation ( task )
2026-01-18 14:33:42 +09:00
2026-04-16 11:59:11 +08:00
// Signal CLI run mode that background tasks are active
2026-05-05 03:53:44 +09:00
this . updateBackgroundTaskMarker ( input . parentSessionId )
2026-04-16 11:59:11 +08:00
2026-03-11 18:44:20 +09:00
// Trigger processing (fire-and-forget)
2026-04-07 15:24:02 +09:00
void this . processKey ( key )
2026-03-11 18:44:20 +09:00
return { . . . task }
} catch ( error ) {
spawnReservation . rollback ( )
throw error
}
2026-01-18 14:33:42 +09:00
}
private async processKey ( key : string ) : Promise < void > {
2026-02-01 16:51:11 +09:00
if ( this . processingKeys . has ( key ) ) {
2026-01-18 14:36:06 +09:00
return
}
2026-02-01 16:51:11 +09:00
this . processingKeys . add ( key )
2026-01-18 14:36:06 +09:00
try {
2026-02-01 16:51:11 +09:00
const queue = this . queuesByKey . get ( key )
2026-01-18 14:36:06 +09:00
while ( queue && queue . length > 0 ) {
2026-03-13 13:12:59 +09:00
const item = queue . shift ( )
if ( ! item ) {
continue
}
2026-01-18 14:36:06 +09:00
await this . concurrencyManager . acquire ( key )
2026-03-09 12:34:37 +09:00
if ( item . task . status === "cancelled" || item . task . status === "error" || item . task . status === "interrupt" ) {
2026-03-13 12:37:33 +09:00
this . rollbackPreStartDescendantReservation ( item . task )
2026-01-18 14:36:06 +09:00
this . concurrencyManager . release ( key )
continue
}
try {
2026-02-01 16:51:11 +09:00
await this . startTask ( item )
2026-01-18 14:36:06 +09:00
} catch ( error ) {
log ( "[background-agent] Error starting task:" , error )
2026-03-13 12:37:33 +09:00
this . rollbackPreStartDescendantReservation ( item . task )
2026-04-07 15:24:02 +09:00
// Mark task as error so the parent polling loop detects the failure
// instead of leaving it in a zombie "running" state with no prompt sent
2026-04-28 15:27:35 +09:00
if ( item . task . currentAttemptID ) {
finalizeAttempt ( item . task , item . task . currentAttemptID , "error" , error instanceof Error ? error.message : String ( error ) )
} else {
item . task . status = "error"
item . task . error = error instanceof Error ? error.message : String ( error )
item . task . completedAt = new Date ( )
}
2026-04-07 15:24:02 +09:00
2026-03-09 12:34:37 +09:00
if ( item . task . concurrencyKey ) {
this . concurrencyManager . release ( item . task . concurrencyKey )
item . task . concurrencyKey = undefined
} else {
2026-02-01 21:24:52 +09:00
this . concurrencyManager . release ( key )
}
2026-04-07 15:24:02 +09:00
removeTaskToastTracking ( item . task . id )
// Abort the orphaned session if one was created before the error
2026-05-02 03:01:03 +09:00
if ( item . task . sessionId ) {
await this . abortSessionWithLogging ( item . task . sessionId , "startTask error cleanup" )
2026-04-07 15:24:02 +09:00
}
2026-04-16 12:02:43 +08:00
// Update continuation marker for CLI run mode
2026-05-05 03:53:44 +09:00
this . updateBackgroundTaskMarker ( item . task . parentSessionId )
2026-04-16 12:02:43 +08:00
2026-04-07 15:24:02 +09:00
this . markForNotification ( item . task )
2026-05-02 03:01:03 +09:00
this . enqueueNotificationForParent ( item . task . parentSessionId , ( ) = > this . notifyParentSession ( item . task ) ) . catch ( err = > {
2026-04-07 15:24:02 +09:00
log ( "[background-agent] Failed to notify on startTask error:" , err )
} )
2026-01-18 14:36:06 +09:00
}
}
} finally {
2026-02-01 16:51:11 +09:00
this . processingKeys . delete ( key )
}
}
private async startTask ( item : QueueItem ) : Promise < void > {
const { task , input } = item
2026-05-02 03:01:03 +09:00
const attemptID = item . attemptID ? ? ensureCurrentAttempt ( task , input . model ) . attemptId
2026-02-01 16:51:11 +09:00
log ( "[background-agent] Starting task:" , {
taskId : task.id ,
agent : input.agent ,
model : input.model ,
} )
const concurrencyKey = this . getConcurrencyKeyFromInput ( input )
const parentSession = await this . client . session . get ( {
2026-05-02 03:01:03 +09:00
path : { id : input.parentSessionId } ,
2026-04-12 02:30:01 +09:00
query : { directory : this.directory } ,
2026-02-01 16:51:11 +09:00
} ) . catch ( ( err ) = > {
log ( ` [background-agent] Failed to get parent session: ${ err } ` )
return null
} )
const parentDirectory = parentSession ? . data ? . directory ? ? this . directory
log ( ` [background-agent] Parent dir: ${ parentSession ? . data ? . directory } , using: ${ parentDirectory } ` )
const createResult = await this . client . session . create ( {
body : {
2026-05-02 03:01:03 +09:00
parentID : input.parentSessionId ,
2026-02-01 19:44:22 +09:00
title : ` ${ input . description } (@ ${ input . agent } subagent) ` ,
2026-03-11 16:40:30 +09:00
. . . ( input . sessionPermission ? { permission : input.sessionPermission } : { } ) ,
2026-05-08 20:41:05 +08:00
. . . ( input . model
? {
model : {
id : input.model.modelID ,
providerID : input.model.providerID ,
. . . ( input . model . variant ? { variant : input.model.variant } : { } ) ,
} ,
}
: { } ) ,
2026-02-23 02:43:01 +09:00
} as Record < string , unknown > ,
2026-02-01 16:51:11 +09:00
query : {
directory : parentDirectory ,
} ,
} )
if ( createResult . error ) {
throw new Error ( ` Failed to create background session: ${ createResult . error } ` )
}
2026-02-01 21:24:52 +09:00
if ( ! createResult . data ? . id ) {
throw new Error ( "Failed to create background session: API returned no session ID" )
}
2026-02-01 16:51:11 +09:00
const sessionID = createResult . data . id
2026-03-13 13:12:59 +09:00
if ( task . status === "cancelled" ) {
2026-04-03 21:42:10 +09:00
await this . abortSessionWithLogging ( sessionID , "cancelled pre-start cleanup" )
2026-03-13 13:12:59 +09:00
this . concurrencyManager . release ( concurrencyKey )
return
}
2026-05-04 12:51:18 +09:00
await input . onSessionCreated ? . ( sessionID )
2026-03-13 12:37:33 +09:00
this . settlePreStartDescendantReservation ( task )
2026-02-01 16:51:11 +09:00
subagentSessions . add ( sessionID )
log ( "[background-agent] tmux callback check" , {
hasCallback : ! ! this . onSubagentSessionCreated ,
tmuxEnabled : this.tmuxEnabled ,
isInsideTmux : isInsideTmux ( ) ,
sessionID ,
2026-05-02 03:01:03 +09:00
parentID : input.parentSessionId ,
2026-02-01 16:51:11 +09:00
} )
2026-04-28 10:48:05 +09:00
if ( ! input . suppressTmuxSpawn && this . onSubagentSessionCreated && this . tmuxEnabled && isInsideTmux ( ) ) {
2026-02-01 16:51:11 +09:00
log ( "[background-agent] Invoking tmux callback NOW" , { sessionID } )
await this . onSubagentSessionCreated ( {
sessionID ,
2026-05-02 03:01:03 +09:00
parentID : input.parentSessionId ,
2026-02-01 16:51:11 +09:00
title : input.description ,
} ) . catch ( ( err ) = > {
log ( "[background-agent] Failed to spawn tmux pane:" , err )
} )
log ( "[background-agent] tmux callback completed, waiting 200ms" )
await new Promise ( r = > setTimeout ( r , 200 ) )
} else {
2026-04-28 10:48:05 +09:00
log ( "[background-agent] SKIP tmux callback - conditions not met" , {
suppressTmuxSpawn : ! ! input . suppressTmuxSpawn ,
} )
2026-02-01 16:51:11 +09:00
}
2026-04-04 14:19:18 +09:00
if ( this . tasks . get ( task . id ) ? . status === "cancelled" ) {
await this . abortSessionWithLogging ( sessionID , "cancelled during tmux setup" )
2026-04-04 15:40:21 +09:00
subagentSessions . delete ( sessionID )
2026-05-02 03:01:03 +09:00
if ( task . rootSessionId ) {
this . unregisterRootDescendant ( task . rootSessionId )
2026-04-04 15:40:21 +09:00
}
2026-04-04 14:19:18 +09:00
this . concurrencyManager . release ( concurrencyKey )
return
}
2026-04-28 15:27:35 +09:00
const boundAttempt = bindAttemptSession ( task , attemptID , sessionID , input . model )
if ( ! boundAttempt ) {
await this . abortSessionWithLogging ( sessionID , "stale attempt binding cleanup" )
subagentSessions . delete ( sessionID )
2026-05-02 03:01:03 +09:00
if ( task . rootSessionId ) {
this . unregisterRootDescendant ( task . rootSessionId )
2026-04-28 15:27:35 +09:00
}
this . concurrencyManager . release ( concurrencyKey )
return
}
2026-02-01 16:51:11 +09:00
task . progress = {
toolCalls : 0 ,
lastUpdate : new Date ( ) ,
}
task . concurrencyKey = concurrencyKey
task . concurrencyGroup = concurrencyKey
2026-04-28 15:27:35 +09:00
if ( task . retryNotification ) {
const attemptNumber = boundAttempt . attemptNumber
2026-04-28 21:43:09 +09:00
const retrySessionUrl = buildLocalSessionUrl ( parentDirectory , sessionID )
2026-05-02 03:01:03 +09:00
const previousAttempt = getPreviousAttempt ( task , boundAttempt . attemptId )
const failedSessionID = previousAttempt ? . sessionId ? ? task . retryNotification . previousSessionID
2026-04-28 15:27:35 +09:00
const failedSessionLine = failedSessionID
? ` \ n- Failed session: \` ${ failedSessionID } \` `
: ""
const failedModel = formatAttemptModelSummary ( previousAttempt ) ? ? task . retryNotification . failedModel
const failedModelLine = failedModel
? ` \ n- Failed model: \` ${ failedModel } \` `
: ""
const failedError = previousAttempt ? . error ? ? task . retryNotification . failedError
const failedErrorLine = failedError
? ` \ n- Error: ${ failedError } `
: ""
const retryModel = formatAttemptModelSummary ( boundAttempt ) ? ? task . retryNotification . nextModel
this . queuePendingNotification (
2026-05-02 03:01:03 +09:00
task . parentSessionId ,
2026-04-28 15:27:35 +09:00
` <system-reminder>
[BACKGROUND TASK RETRY SESSION READY]
**ID:** \` ${ task . id } \`
**Description:** ${ task . description }
**Retry attempt:** ${ attemptNumber }
**Retry session:** \` ${ sessionID } \`
**Retry link:** ${ retrySessionUrl } ${ failedSessionLine } ${ failedModelLine } ${ failedErrorLine } ${ retryModel ? ` \ n- Model: \` ${ retryModel } \` ` : "" }
The fallback retry session is now created and can be inspected directly.
</system-reminder> `
)
task . retryNotification = undefined
}
2026-05-02 03:01:03 +09:00
this . taskHistory . record ( input . parentSessionId , { id : task.id , sessionID , agent : input.agent , description : input.description , status : "running" , category : input.category , startedAt : task.startedAt } )
2026-02-01 16:51:11 +09:00
this . startPolling ( )
log ( "[background-agent] Launching task:" , { taskId : task.id , sessionID , agent : input.agent } )
const toastManager = getTaskToastManager ( )
if ( toastManager ) {
toastManager . updateTask ( task . id , "running" )
2026-01-09 02:24:43 +09:00
}
2026-02-01 16:51:11 +09:00
log ( "[background-agent] Calling prompt (fire-and-forget) for launch with:" , {
sessionID ,
agent : input.agent ,
model : input.model ,
hasSkillContent : ! ! input . skillContent ,
promptLength : input.prompt.length ,
} )
2026-02-07 13:42:20 +01:00
// Fire-and-forget prompt via promptAsync (no response body needed)
2026-03-18 14:21:27 +01:00
// OpenCode prompt payload accepts model provider/model IDs and top-level variant only.
// Temperature/topP and provider-specific options are applied through chat.params.
2026-02-01 16:51:11 +09:00
const launchModel = input . model
2026-03-18 14:21:27 +01:00
? {
providerID : input.model.providerID ,
modelID : input.model.modelID ,
}
2026-02-01 16:51:11 +09:00
: undefined
const launchVariant = input . model ? . variant
2026-03-18 14:21:27 +01:00
if ( input . model ) {
applySessionPromptParams ( sessionID , input . model )
}
2026-04-04 21:57:37 -07:00
const promptBody = {
agent : input.agent ,
. . . ( launchModel ? { model : launchModel } : { } ) ,
. . . ( launchVariant ? { variant : launchVariant } : { } ) ,
system : input.skillContent ,
tools : ( ( ) = > {
const tools = {
task : false ,
call_omo_agent : true ,
question : false ,
. . . getAgentToolRestrictions ( input . agent ) ,
}
setSessionTools ( sessionID , tools )
return tools
} ) ( ) ,
parts : [ createInternalAgentTextPart ( input . prompt ) ] ,
}
2026-02-01 16:51:11 +09:00
promptWithModelSuggestionRetry ( this . client , {
path : { id : sessionID } ,
2026-04-04 21:57:37 -07:00
body : promptBody ,
2026-03-27 19:57:57 +09:00
} ) . catch ( async ( error ) = > {
2026-04-04 21:57:37 -07:00
// Retry with fallback agent if the original agent was unregistered (e.g., after a model switch)
if ( isAgentNotFoundError ( error ) && input . agent !== FALLBACK_AGENT ) {
log ( "[background-agent] Agent not found, retrying with fallback agent" , {
original : input.agent ,
fallback : FALLBACK_AGENT ,
taskId : task.id ,
} )
try {
2026-04-04 22:13:05 -07:00
const fallbackBody = buildFallbackBody ( promptBody , FALLBACK_AGENT )
setSessionTools ( sessionID , fallbackBody . tools as Record < string , boolean > )
2026-04-04 21:57:37 -07:00
await promptWithModelSuggestionRetry ( this . client , {
path : { id : sessionID } ,
2026-04-04 22:13:05 -07:00
body : fallbackBody ,
2026-04-04 21:57:37 -07:00
} )
2026-04-04 22:13:05 -07:00
task . agent = FALLBACK_AGENT
2026-04-04 21:57:37 -07:00
return
} catch ( retryError ) {
log ( "[background-agent] Fallback agent also failed:" , retryError )
}
}
2026-02-01 16:51:11 +09:00
log ( "[background-agent] promptAsync error:" , error )
2026-04-28 21:43:09 +09:00
const resolvedTask = this . resolveTaskAttemptBySession ( sessionID )
const existingTask = resolvedTask ? . task
if ( resolvedTask && ! resolvedTask . isCurrent ) {
log ( "[background-agent] Ignoring prompt error from stale attempt session" , {
sessionID ,
currentAttemptID : resolvedTask.task.currentAttemptID ,
attemptID : resolvedTask.attemptID ,
} )
return
}
2026-02-01 16:51:11 +09:00
if ( existingTask ) {
2026-04-28 15:27:35 +09:00
const errorInfo = {
name : extractErrorName ( error ) ,
message : extractErrorMessage ( error ) ,
}
if ( await this . tryFallbackRetry ( existingTask , errorInfo , "promptAsync.launch" ) ) {
return
}
const errorMessage = errorInfo . message ? ? ( error instanceof Error ? error.message : String ( error ) )
const terminalError = errorMessage . includes ( "agent.name" ) || errorMessage . includes ( "undefined" ) || isAgentNotFoundError ( error )
? ` Agent " ${ input . agent } " not found. Make sure the agent is registered in your opencode.json or provided by a plugin. `
: errorMessage
if ( existingTask . currentAttemptID ) {
finalizeAttempt ( existingTask , existingTask . currentAttemptID , "interrupt" , terminalError )
2026-02-01 16:51:11 +09:00
} else {
2026-04-28 15:27:35 +09:00
existingTask . status = "interrupt"
existingTask . error = terminalError
existingTask . completedAt = new Date ( )
2026-02-01 16:51:11 +09:00
}
2026-05-02 03:01:03 +09:00
if ( existingTask . rootSessionId ) {
this . unregisterRootDescendant ( existingTask . rootSessionId )
2026-03-20 12:51:21 -04:00
}
2026-02-01 16:51:11 +09:00
if ( existingTask . concurrencyKey ) {
this . concurrencyManager . release ( existingTask . concurrencyKey )
existingTask . concurrencyKey = undefined
}
2026-03-09 11:39:04 +09:00
removeTaskToastTracking ( existingTask . id )
2026-02-04 13:14:18 +09:00
// Abort the session to prevent infinite polling hang
2026-03-27 19:57:57 +09:00
// Awaited to prevent dangling promise during subagent teardown (Bun/WebKit SIGABRT)
2026-04-03 21:42:10 +09:00
await this . abortSessionWithLogging ( sessionID , "launch error cleanup" )
2026-02-04 13:14:18 +09:00
2026-02-01 16:51:11 +09:00
this . markForNotification ( existingTask )
2026-05-02 03:01:03 +09:00
this . enqueueNotificationForParent ( existingTask . parentSessionId , ( ) = > this . notifyParentSession ( existingTask ) ) . catch ( err = > {
2026-02-01 16:51:11 +09:00
log ( "[background-agent] Failed to notify on error:" , err )
} )
}
} )
2025-12-11 15:45:37 +09:00
}
getTask ( id : string ) : BackgroundTask | undefined {
2026-02-01 16:51:11 +09:00
return this . tasks . get ( id )
2025-12-11 15:45:37 +09:00
}
getTasksByParentSession ( sessionID : string ) : BackgroundTask [ ] {
2026-04-28 18:00:04 +09:00
const taskIDs = this . tasksByParentSession . get ( sessionID )
if ( ! taskIDs ) {
const result : BackgroundTask [ ] = [ ]
for ( const task of this . tasks . values ( ) ) {
2026-05-02 03:01:03 +09:00
if ( task . parentSessionId === sessionID ) {
2026-04-28 18:00:04 +09:00
result . push ( task )
}
2026-02-01 16:51:11 +09:00
}
2026-04-28 18:00:04 +09:00
return result
2026-02-01 16:51:11 +09:00
}
2026-04-28 18:00:04 +09:00
const tasks : BackgroundTask [ ] = [ ]
for ( const taskID of taskIDs ) {
const task = this . tasks . get ( taskID )
if ( task ) {
tasks . push ( task )
}
}
return tasks
2025-12-11 15:45:37 +09:00
}
2026-04-16 11:59:11 +08:00
private updateBackgroundTaskMarker ( parentSessionID : string ) : void {
const tasks = this . getTasksByParentSession ( parentSessionID )
const activeTasks = tasks . filter ( t = > t . status === "running" || t . status === "pending" )
if ( activeTasks . length > 0 ) {
setContinuationMarkerSource (
this . directory , parentSessionID , "background-task" , "active" ,
` ${ activeTasks . length } background task(s) active ` ,
)
} else {
setContinuationMarkerSource (
this . directory , parentSessionID , "background-task" , "idle" ,
)
}
}
2025-12-19 01:56:38 +09:00
getAllDescendantTasks ( sessionID : string ) : BackgroundTask [ ] {
2026-02-01 16:51:11 +09:00
const result : BackgroundTask [ ] = [ ]
const directChildren = this . getTasksByParentSession ( sessionID )
for ( const child of directChildren ) {
result . push ( child )
2026-05-02 03:01:03 +09:00
if ( child . sessionId ) {
const descendants = this . getAllDescendantTasks ( child . sessionId )
2026-02-01 16:51:11 +09:00
result . push ( . . . descendants )
}
}
return result
2025-12-19 01:56:38 +09:00
}
2025-12-11 15:45:37 +09:00
findBySession ( sessionID : string ) : BackgroundTask | undefined {
2026-02-01 16:51:11 +09:00
for ( const task of this . tasks . values ( ) ) {
2026-05-02 03:01:03 +09:00
if ( task . sessionId === sessionID ) {
2026-02-01 16:51:11 +09:00
return task
}
2026-04-28 15:27:35 +09:00
if ( findAttemptBySession ( task , sessionID ) ) {
return task
}
2026-02-01 16:51:11 +09:00
}
return undefined
2026-01-18 14:29:46 +09:00
}
2026-04-28 15:27:35 +09:00
private resolveTaskAttemptBySession ( sessionID : string ) : { task : BackgroundTask ; attemptID? : string ; isCurrent : boolean } | undefined {
const task = this . findBySession ( sessionID )
if ( ! task ) {
return undefined
}
const attempt = findAttemptBySession ( task , sessionID )
if ( ! attempt ) {
return {
task ,
attemptID : undefined ,
2026-05-02 03:01:03 +09:00
isCurrent : task.sessionId === sessionID ,
2026-04-28 15:27:35 +09:00
}
}
return {
task ,
2026-05-02 03:01:03 +09:00
attemptID : attempt.attemptId ,
isCurrent : task.currentAttemptID === attempt . attemptId ,
2026-04-28 15:27:35 +09:00
}
}
2026-02-01 16:51:11 +09:00
private getConcurrencyKeyFromInput ( input : LaunchInput ) : string {
if ( input . model ) {
return ` ${ input . model . providerID } / ${ input . model . modelID } `
}
return input . agent
}
/**
2026-02-06 16:01:54 +09:00
* Track a task created elsewhere (e.g., from task) for notification tracking.
2026-02-01 16:51:11 +09:00
* This allows tasks created by other tools to receive the same toast/prompt notifications.
*/
2026-01-15 10:53:08 -08:00
async trackTask ( input : {
2026-01-09 02:24:43 +09:00
taskId : string
2026-05-02 03:01:03 +09:00
sessionId : string
parentSessionId : string
2026-01-09 02:24:43 +09:00
description : string
agent? : string
2026-01-09 15:53:36 +09:00
parentAgent? : string
2026-01-14 15:09:32 -08:00
concurrencyKey? : string
} ) : Promise < BackgroundTask > {
2026-02-01 16:51:11 +09:00
const existingTask = this . tasks . get ( input . taskId )
2026-01-14 22:40:16 -08:00
if ( existingTask ) {
2026-02-01 16:51:11 +09:00
// P2 fix: Clean up old parent's pending set BEFORE changing parent
// Otherwise cleanupPendingByParent would use the new parent ID
2026-05-02 03:01:03 +09:00
const parentChanged = input . parentSessionId !== existingTask . parentSessionId
2026-01-15 00:16:35 -08:00
if ( parentChanged ) {
2026-02-01 16:51:11 +09:00
this . cleanupPendingByParent ( existingTask ) // Clean from OLD parent
2026-05-02 03:01:03 +09:00
this . updateTaskParent ( existingTask , input . parentSessionId )
2026-01-14 22:40:16 -08:00
}
if ( input . parentAgent !== undefined ) {
existingTask . parentAgent = input . parentAgent
}
if ( ! existingTask . concurrencyGroup ) {
existingTask . concurrencyGroup = input . concurrencyKey ? ? existingTask . agent
}
2026-05-02 03:01:03 +09:00
if ( existingTask . sessionId ) {
subagentSessions . add ( existingTask . sessionId )
2026-01-19 10:35:47 +09:00
}
2026-01-14 22:40:16 -08:00
this . startPolling ( )
2026-02-01 16:51:11 +09:00
// Track for batched notifications if task is pending or running
2026-01-18 14:39:11 +09:00
if ( existingTask . status === "pending" || existingTask . status === "running" ) {
2026-05-02 03:01:03 +09:00
const pending = this . pendingByParent . get ( input . parentSessionId ) ? ? new Set ( )
2026-02-01 16:51:11 +09:00
pending . add ( existingTask . id )
2026-05-02 03:01:03 +09:00
this . pendingByParent . set ( input . parentSessionId , pending )
2026-01-15 00:16:35 -08:00
} else if ( ! parentChanged ) {
2026-02-01 16:51:11 +09:00
// Only clean up if parent didn't change (already cleaned above if it did)
this . cleanupPendingByParent ( existingTask )
2026-01-14 23:51:19 -08:00
}
2026-01-14 22:40:16 -08:00
2026-05-02 03:01:03 +09:00
log ( "[background-agent] External task already registered:" , { taskId : existingTask.id , sessionID : existingTask.sessionId , status : existingTask.status } )
2026-01-14 22:40:16 -08:00
return existingTask
}
2026-02-06 16:01:54 +09:00
const concurrencyGroup = input . concurrencyKey ? ? input . agent ? ? "task"
2026-01-14 22:40:16 -08:00
2026-02-01 16:51:11 +09:00
// Acquire concurrency slot if a key is provided
2026-01-14 15:09:32 -08:00
if ( input . concurrencyKey ) {
await this . concurrencyManager . acquire ( input . concurrencyKey )
}
2026-01-09 02:24:43 +09:00
const task : BackgroundTask = {
id : input.taskId ,
2026-05-02 03:01:03 +09:00
sessionId : input.sessionId ,
parentSessionId : input.parentSessionId ,
parentMessageId : "" ,
2026-01-09 02:24:43 +09:00
description : input.description ,
prompt : "" ,
2026-02-06 16:01:54 +09:00
agent : input.agent || "task" ,
2026-01-09 02:24:43 +09:00
status : "running" ,
startedAt : new Date ( ) ,
progress : {
toolCalls : 0 ,
lastUpdate : new Date ( ) ,
} ,
2026-01-09 15:53:36 +09:00
parentAgent : input.parentAgent ,
2026-01-14 15:09:32 -08:00
concurrencyKey : input.concurrencyKey ,
2026-01-14 22:40:16 -08:00
concurrencyGroup ,
2026-01-09 02:24:43 +09:00
}
2026-04-28 18:00:04 +09:00
this . addTask ( task )
2026-05-02 03:01:03 +09:00
subagentSessions . add ( input . sessionId )
2026-01-09 02:24:43 +09:00
this . startPolling ( )
2026-05-02 03:01:03 +09:00
this . taskHistory . record ( input . parentSessionId , { id : task.id , sessionID : input.sessionId , agent : input.agent || "task" , description : input.description , status : "running" , startedAt : task.startedAt } )
2026-01-09 02:24:43 +09:00
2026-05-02 03:01:03 +09:00
if ( input . parentSessionId ) {
const pending = this . pendingByParent . get ( input . parentSessionId ) ? ? new Set ( )
2026-02-01 16:51:11 +09:00
pending . add ( task . id )
2026-05-02 03:01:03 +09:00
this . pendingByParent . set ( input . parentSessionId , pending )
2026-01-18 14:39:11 +09:00
}
2026-01-10 13:00:25 +08:00
2026-05-02 03:01:03 +09:00
log ( "[background-agent] Registered external task:" , { taskId : task.id , sessionID : input.sessionId } )
2026-01-09 02:24:43 +09:00
return task
}
async resume ( input : ResumeInput ) : Promise < BackgroundTask > {
2026-02-01 16:51:11 +09:00
const existingTask = this . findBySession ( input . sessionId )
2026-01-09 02:24:43 +09:00
if ( ! existingTask ) {
throw new Error ( ` Task not found for session: ${ input . sessionId } ` )
}
2026-05-02 03:01:03 +09:00
if ( ! existingTask . sessionId ) {
2026-02-01 16:51:11 +09:00
throw new Error ( ` Task has no sessionID: ${ existingTask . id } ` )
}
if ( existingTask . status === "running" ) {
log ( "[background-agent] Resume skipped - task already running:" , {
taskId : existingTask.id ,
2026-05-02 03:01:03 +09:00
sessionID : existingTask.sessionId ,
2026-02-01 16:51:11 +09:00
} )
return existingTask
}
2026-02-16 15:56:40 +09:00
const completionTimer = this . completionTimers . get ( existingTask . id )
if ( completionTimer ) {
clearTimeout ( completionTimer )
this . completionTimers . delete ( existingTask . id )
}
2026-02-01 16:51:11 +09:00
// Re-acquire concurrency using the persisted concurrency group
const concurrencyKey = existingTask . concurrencyGroup ? ? existingTask . agent
await this . concurrencyManager . acquire ( concurrencyKey )
existingTask . concurrencyKey = concurrencyKey
existingTask . concurrencyGroup = concurrencyKey
existingTask . status = "running"
existingTask . completedAt = undefined
existingTask . error = undefined
2026-05-02 03:01:03 +09:00
this . updateTaskParent ( existingTask , input . parentSessionId )
existingTask . parentMessageId = input . parentMessageId
2026-02-01 16:51:11 +09:00
existingTask . parentModel = input . parentModel
existingTask . parentAgent = input . parentAgent
2026-02-14 14:30:30 +09:00
if ( input . parentTools ) {
existingTask . parentTools = input . parentTools
}
2026-02-01 16:51:11 +09:00
// Reset startedAt on resume to prevent immediate completion
// The MIN_IDLE_TIME_MS check uses startedAt, so resumed tasks need fresh timing
existingTask . startedAt = new Date ( )
existingTask . progress = {
toolCalls : existingTask.progress?.toolCalls ? ? 0 ,
2026-03-17 16:31:18 +09:00
toolCallWindow : existingTask.progress?.toolCallWindow ,
countedToolPartIDs : existingTask.progress?.countedToolPartIDs ,
2026-02-01 16:51:11 +09:00
lastUpdate : new Date ( ) ,
}
2026-01-09 02:24:43 +09:00
this . startPolling ( )
2026-05-02 03:01:03 +09:00
if ( existingTask . sessionId ) {
subagentSessions . add ( existingTask . sessionId )
2026-01-19 10:35:47 +09:00
}
2026-01-09 02:24:43 +09:00
2026-05-02 03:01:03 +09:00
if ( input . parentSessionId ) {
const pending = this . pendingByParent . get ( input . parentSessionId ) ? ? new Set ( )
2026-02-01 16:51:11 +09:00
pending . add ( existingTask . id )
2026-05-02 03:01:03 +09:00
this . pendingByParent . set ( input . parentSessionId , pending )
2026-01-09 02:24:43 +09:00
}
2026-02-01 16:51:11 +09:00
const toastManager = getTaskToastManager ( )
if ( toastManager ) {
toastManager . addTask ( {
id : existingTask.id ,
description : existingTask.description ,
agent : existingTask.agent ,
isBackground : true ,
} )
}
2026-05-02 03:01:03 +09:00
log ( "[background-agent] Resuming task:" , { taskId : existingTask.id , sessionID : existingTask.sessionId } )
2026-02-01 16:51:11 +09:00
log ( "[background-agent] Resuming task - calling prompt (fire-and-forget) with:" , {
2026-05-02 03:01:03 +09:00
sessionID : existingTask.sessionId ,
2026-02-01 16:51:11 +09:00
agent : existingTask.agent ,
model : existingTask.model ,
promptLength : input.prompt.length ,
} )
2026-02-07 13:42:20 +01:00
// Fire-and-forget prompt via promptAsync (no response body needed)
2026-03-18 14:21:27 +01:00
// Resume uses the same PromptInput contract as launch: model IDs plus top-level variant.
2026-02-01 16:51:11 +09:00
const resumeModel = existingTask . model
2026-03-18 14:21:27 +01:00
? {
providerID : existingTask.model.providerID ,
modelID : existingTask.model.modelID ,
}
2026-02-01 16:51:11 +09:00
: undefined
const resumeVariant = existingTask . model ? . variant
2026-03-18 14:21:27 +01:00
if ( existingTask . model ) {
2026-05-02 03:01:03 +09:00
applySessionPromptParams ( existingTask . sessionId ! , existingTask . model )
2026-03-18 14:21:27 +01:00
}
2026-02-07 13:42:20 +01:00
this . client . session . promptAsync ( {
2026-05-02 03:01:03 +09:00
path : { id : existingTask.sessionId } ,
2026-02-01 16:51:11 +09:00
body : {
2026-03-25 11:44:01 +09:00
agent : existingTask.agent ,
2026-02-01 16:51:11 +09:00
. . . ( resumeModel ? { model : resumeModel } : { } ) ,
. . . ( resumeVariant ? { variant : resumeVariant } : { } ) ,
2026-02-14 14:30:30 +09:00
tools : ( ( ) = > {
const tools = {
task : false ,
call_omo_agent : true ,
question : false ,
2026-02-17 01:02:04 +00:00
. . . getAgentToolRestrictions ( existingTask . agent ) ,
2026-02-14 14:30:30 +09:00
}
2026-05-02 03:01:03 +09:00
setSessionTools ( existingTask . sessionId ! , tools )
2026-02-14 14:30:30 +09:00
return tools
} ) ( ) ,
2026-02-21 16:24:18 +09:00
parts : [ createInternalAgentTextPart ( input . prompt ) ] ,
2026-02-01 16:51:11 +09:00
} ,
2026-03-27 19:57:57 +09:00
} ) . catch ( async ( error ) = > {
2026-02-01 16:51:11 +09:00
log ( "[background-agent] resume prompt error:" , error )
2026-04-28 15:27:35 +09:00
const errorInfo = {
name : extractErrorName ( error ) ,
message : extractErrorMessage ( error ) ,
}
if ( await this . tryFallbackRetry ( existingTask , errorInfo , "promptAsync.resume" ) ) {
return
}
2026-02-09 18:25:54 +09:00
existingTask . status = "interrupt"
2026-04-28 15:27:35 +09:00
const errorMessage = errorInfo . message ? ? ( error instanceof Error ? error.message : String ( error ) )
2026-02-01 16:51:11 +09:00
existingTask . error = errorMessage
existingTask . completedAt = new Date ( )
2026-05-02 03:01:03 +09:00
if ( existingTask . rootSessionId ) {
this . unregisterRootDescendant ( existingTask . rootSessionId )
2026-03-20 12:51:21 -04:00
}
2026-02-01 16:51:11 +09:00
// Release concurrency on error to prevent slot leaks
if ( existingTask . concurrencyKey ) {
this . concurrencyManager . release ( existingTask . concurrencyKey )
existingTask . concurrencyKey = undefined
}
2026-02-04 13:14:18 +09:00
2026-03-09 11:39:04 +09:00
removeTaskToastTracking ( existingTask . id )
2026-02-04 13:14:18 +09:00
// Abort the session to prevent infinite polling hang
2026-03-27 19:57:57 +09:00
// Awaited to prevent dangling promise during subagent teardown (Bun/WebKit SIGABRT)
2026-05-02 03:01:03 +09:00
if ( existingTask . sessionId ) {
await this . abortSessionWithLogging ( existingTask . sessionId , "resume error cleanup" )
2026-02-04 13:14:18 +09:00
}
2026-02-01 16:51:11 +09:00
this . markForNotification ( existingTask )
2026-05-02 03:01:03 +09:00
this . enqueueNotificationForParent ( existingTask . parentSessionId , ( ) = > this . notifyParentSession ( existingTask ) ) . catch ( err = > {
2026-02-01 16:51:11 +09:00
log ( "[background-agent] Failed to notify on resume error:" , err )
} )
} )
2026-01-09 02:24:43 +09:00
return existingTask
}
2026-02-01 16:51:11 +09:00
private async checkSessionTodos ( sessionID : string ) : Promise < boolean > {
2026-04-04 18:48:03 +09:00
const observedIncompleteTodos = this . observedIncompleteTodosBySession . get ( sessionID )
if ( observedIncompleteTodos !== undefined ) {
return observedIncompleteTodos
}
2026-02-01 16:51:11 +09:00
try {
const response = await this . client . session . todo ( {
path : { id : sessionID } ,
} )
2026-02-16 18:20:19 +09:00
const todos = normalizeSDKResponse ( response , [ ] as Todo [ ] , { preferResponseOnMissingData : true } )
2026-04-04 18:48:03 +09:00
if ( ! todos || todos . length === 0 ) {
this . observedIncompleteTodosBySession . set ( sessionID , false )
return false
}
2026-02-01 16:51:11 +09:00
const incomplete = todos . filter (
( t ) = > t . status !== "completed" && t . status !== "cancelled"
)
2026-04-04 18:48:03 +09:00
const hasIncompleteTodos = incomplete . length > 0
this . observedIncompleteTodosBySession . set ( sessionID , hasIncompleteTodos )
return hasIncompleteTodos
2026-04-03 21:42:10 +09:00
} catch ( error ) {
log ( "[background-agent] Failed to check session todos:" , {
sessionID ,
error ,
} )
2026-02-01 16:51:11 +09:00
return false
}
}
2026-04-04 18:39:26 +09:00
private markSessionOutputObserved ( sessionID : string ) : void {
this . observedOutputSessions . add ( sessionID )
}
private clearSessionOutputObserved ( sessionID : string ) : void {
this . observedOutputSessions . delete ( sessionID )
}
2026-04-04 18:48:03 +09:00
private clearSessionTodoObservation ( sessionID : string ) : void {
this . observedIncompleteTodosBySession . delete ( sessionID )
}
2026-04-04 18:39:26 +09:00
private hasOutputSignalFromPart ( partInfo : MessagePartInfo | undefined ) : boolean {
if ( ! partInfo ? . sessionID ) return false
if ( partInfo . tool ) return true
if ( partInfo . type === "tool" || partInfo . type === "tool_result" ) return true
if ( partInfo . type === "text" || partInfo . type === "reasoning" ) return true
const field = typeof ( partInfo as { field? : unknown } ) . field === "string"
? ( partInfo as { field? : string } ) . field
: undefined
return field === "text" || field === "reasoning"
}
2026-02-01 16:51:11 +09:00
handleEvent ( event : Event ) : void {
2025-12-11 15:45:37 +09:00
const props = event . properties
2026-02-19 04:41:00 +02:00
if ( event . type === "message.updated" ) {
const info = props ? . info
if ( ! info || typeof info !== "object" ) return
const sessionID = ( info as Record < string , unknown > ) [ "sessionID" ]
const role = ( info as Record < string , unknown > ) [ "role" ]
2026-04-04 18:39:26 +09:00
if ( typeof sessionID !== "string" ) return
if ( role === "tool" ) {
this . markSessionOutputObserved ( sessionID )
}
if ( role !== "assistant" ) return
2026-02-19 04:41:00 +02:00
2026-04-28 15:27:35 +09:00
const resolved = this . resolveTaskAttemptBySession ( sessionID )
if ( ! resolved ? . isCurrent ) return
const { task } = resolved
if ( task . status !== "running" ) return
2026-02-19 04:41:00 +02:00
const assistantError = ( info as Record < string , unknown > ) [ "error" ]
if ( ! assistantError ) return
const errorInfo = {
2026-02-22 11:58:57 +09:00
name : extractErrorName ( assistantError ) ,
message : extractErrorMessage ( assistantError ) ,
2026-02-19 04:41:00 +02:00
}
2026-04-03 17:12:18 +09:00
void this . tryFallbackRetry ( task , errorInfo , "message.updated" ) . catch ( ( error ) = > {
log ( "[background-agent] Error handling message.updated fallback retry:" , {
error ,
taskId : task.id ,
} )
} )
2026-02-19 04:41:00 +02:00
}
2026-02-15 14:26:25 +09:00
if ( event . type === "message.part.updated" || event . type === "message.part.delta" ) {
2026-03-17 16:31:18 +09:00
const partInfo = resolveMessagePartInfo ( props )
2025-12-11 15:45:37 +09:00
const sessionID = partInfo ? . sessionID
if ( ! sessionID ) return
2026-04-28 15:27:35 +09:00
const resolved = this . resolveTaskAttemptBySession ( sessionID )
if ( ! resolved ? . isCurrent ) return
const { task } = resolved
2025-12-11 15:45:37 +09:00
2026-04-04 18:39:26 +09:00
if ( this . hasOutputSignalFromPart ( partInfo ) ) {
this . markSessionOutputObserved ( sessionID )
}
2026-02-06 16:01:54 +09:00
// Clear any pending idle deferral timer since the task is still active
const existingTimer = this . idleDeferralTimers . get ( task . id )
if ( existingTimer ) {
clearTimeout ( existingTimer )
this . idleDeferralTimers . delete ( task . id )
}
2026-02-14 13:32:17 +09:00
if ( ! task . progress ) {
task . progress = {
toolCalls : 0 ,
lastUpdate : new Date ( ) ,
2025-12-11 15:45:37 +09:00
}
2026-02-14 13:32:17 +09:00
}
task . progress . lastUpdate = new Date ( )
if ( partInfo ? . type === "tool" || partInfo ? . tool ) {
2026-03-18 13:56:11 +09:00
const countedToolPartIDs = task . progress . countedToolPartIDs ? ? new Set < string > ( )
2026-03-17 16:31:18 +09:00
const shouldCountToolCall =
! partInfo . id ||
partInfo . state ? . status !== "running" ||
2026-03-18 13:56:11 +09:00
! countedToolPartIDs . has ( partInfo . id )
2026-03-17 16:31:18 +09:00
if ( ! shouldCountToolCall ) {
return
}
if ( partInfo . id && partInfo . state ? . status === "running" ) {
2026-03-18 13:56:11 +09:00
countedToolPartIDs . add ( partInfo . id )
task . progress . countedToolPartIDs = countedToolPartIDs
2026-03-17 16:31:18 +09:00
}
2025-12-11 15:45:37 +09:00
task . progress . toolCalls += 1
task . progress . lastTool = partInfo . tool
2026-04-28 21:43:09 +09:00
const circuitBreaker = this . cachedCircuitBreakerSettings ? ? resolveCircuitBreakerSettings ( this . config )
this . cachedCircuitBreakerSettings = circuitBreaker
if ( partInfo . tool ) {
task . progress . toolCallWindow = recordToolCall (
2026-03-17 13:40:46 -06:00
task . progress . toolCallWindow ,
partInfo . tool ,
circuitBreaker ,
partInfo . state ? . input
)
if ( circuitBreaker . enabled ) {
const loopDetection = detectRepetitiveToolUse ( task . progress . toolCallWindow )
if ( loopDetection . triggered ) {
2026-03-18 14:32:27 +09:00
log ( "[background-agent] Circuit breaker: consecutive tool usage detected" , {
2026-03-17 13:40:46 -06:00
taskId : task.id ,
agent : task.agent ,
sessionID ,
toolName : loopDetection.toolName ,
repeatedCount : loopDetection.repeatedCount ,
} )
void this . cancelTask ( task . id , {
source : "circuit-breaker" ,
2026-03-18 14:32:27 +09:00
reason : ` Subagent called ${ loopDetection . toolName } ${ loopDetection . repeatedCount } consecutive times (threshold: ${ circuitBreaker . consecutiveThreshold } ). This usually indicates an infinite loop. The task was automatically cancelled to prevent excessive token usage. ` ,
2026-03-17 13:40:46 -06:00
} )
return
}
}
2026-03-17 16:31:18 +09:00
}
2026-03-16 10:28:38 +09:00
2026-03-17 16:31:18 +09:00
const maxToolCalls = circuitBreaker . maxToolCalls
2026-03-16 10:28:38 +09:00
if ( task . progress . toolCalls >= maxToolCalls ) {
log ( "[background-agent] Circuit breaker: tool call limit reached" , {
taskId : task.id ,
toolCalls : task.progress.toolCalls ,
maxToolCalls ,
agent : task.agent ,
sessionID ,
} )
void this . cancelTask ( task . id , {
source : "circuit-breaker" ,
reason : ` Subagent exceeded maximum tool call limit ( ${ maxToolCalls } ). This usually indicates an infinite loop. The task was automatically cancelled to prevent excessive token usage. ` ,
} )
}
2025-12-11 15:45:37 +09:00
}
}
2026-04-04 18:48:03 +09:00
if ( event . type === "todo.updated" ) {
const sessionID = typeof props ? . sessionID === "string" ? props.sessionID : undefined
const todos = Array . isArray ( props ? . todos ) ? props.todos : undefined
if ( ! sessionID || ! todos ) return
const hasIncompleteTodos = todos . some ( ( todo ) = > {
if ( ! todo || typeof todo !== "object" ) return false
const status = ( todo as { status? : unknown } ) . status
return status !== "completed" && status !== "cancelled"
} )
this . observedIncompleteTodosBySession . set ( sessionID , hasIncompleteTodos )
return
}
2025-12-11 17:42:33 +09:00
if ( event . type === "session.idle" ) {
2026-02-22 15:30:15 +09:00
if ( ! props || typeof props !== "object" ) return
handleSessionIdleBackgroundEvent ( {
properties : props as Record < string , unknown > ,
2026-04-28 15:27:35 +09:00
findBySession : ( id ) = > {
const resolved = this . resolveTaskAttemptBySession ( id )
return resolved ? . isCurrent ? resolved.task : undefined
} ,
2026-02-22 15:30:15 +09:00
idleDeferralTimers : this.idleDeferralTimers ,
validateSessionHasOutput : ( id ) = > this . validateSessionHasOutput ( id ) ,
checkSessionTodos : ( id ) = > this . checkSessionTodos ( id ) ,
tryCompleteTask : ( task , source ) = > this . tryCompleteTask ( task , source ) ,
emitIdleEvent : ( sessionID ) = > this . handleEvent ( { type : "session.idle" , properties : { sessionID } } ) ,
2025-12-15 23:54:59 +09:00
} )
2025-12-11 15:45:37 +09:00
}
2026-02-12 18:26:03 +09:00
if ( event . type === "session.error" ) {
const sessionID = typeof props ? . sessionID === "string" ? props.sessionID : undefined
if ( ! sessionID ) return
2026-04-28 15:27:35 +09:00
const resolved = this . resolveTaskAttemptBySession ( sessionID )
if ( ! resolved ? . isCurrent ) return
const { task } = resolved
if ( task . status !== "running" ) return
2026-02-12 18:26:03 +09:00
2026-02-19 04:41:00 +02:00
const errorObj = props ? . error as { name? : string ; message? : string } | undefined
const errorName = errorObj ? . name
2026-02-22 11:58:57 +09:00
const errorMessage = props ? getSessionErrorMessage ( props ) : undefined
2026-02-12 18:26:03 +09:00
2026-02-19 04:41:00 +02:00
const errorInfo = { name : errorName , message : errorMessage }
2026-04-03 17:12:18 +09:00
void this . handleSessionErrorEvent ( {
errorInfo ,
errorMessage ,
2026-02-19 04:41:00 +02:00
errorName ,
2026-04-03 17:12:18 +09:00
task ,
} ) . catch ( ( error ) = > {
log ( "[background-agent] Error handling session.error event:" , {
error ,
taskId : task.id ,
} )
2026-03-11 18:20:20 +09:00
} )
2026-04-03 17:12:18 +09:00
return
2026-02-12 18:26:03 +09:00
}
2025-12-11 15:45:37 +09:00
if ( event . type === "session.deleted" ) {
2025-12-11 17:42:33 +09:00
const info = props ? . info
2025-12-11 16:56:16 +09:00
if ( ! info || typeof info . id !== "string" ) return
const sessionID = info . id
2026-04-04 18:39:26 +09:00
this . clearSessionOutputObserved ( sessionID )
2026-04-04 18:48:03 +09:00
this . clearSessionTodoObservation ( sessionID )
2025-12-11 15:45:37 +09:00
2026-02-07 19:10:49 +09:00
const tasksToCancel = new Map < string , BackgroundTask > ( )
2026-04-28 15:27:35 +09:00
const directTask = this . resolveTaskAttemptBySession ( sessionID )
if ( directTask ? . isCurrent ) {
tasksToCancel . set ( directTask . task . id , directTask . task )
2025-12-11 15:45:37 +09:00
}
2026-02-07 19:10:49 +09:00
for ( const descendant of this . getAllDescendantTasks ( sessionID ) ) {
tasksToCancel . set ( descendant . id , descendant )
2026-01-31 16:26:01 +09:00
}
2026-02-06 16:01:54 +09:00
2026-02-27 03:00:39 +09:00
this . pendingNotifications . delete ( sessionID )
2026-03-11 20:12:12 +09:00
if ( tasksToCancel . size === 0 ) {
this . clearTaskHistoryWhenParentTasksGone ( sessionID )
return
}
const parentSessionsToClear = new Set < string > ( )
2026-02-07 19:10:49 +09:00
2026-03-11 18:20:20 +09:00
const deletedSessionIDs = new Set < string > ( [ sessionID ] )
for ( const task of tasksToCancel . values ( ) ) {
2026-05-02 03:01:03 +09:00
if ( task . sessionId ) {
deletedSessionIDs . add ( task . sessionId )
2026-03-11 18:20:20 +09:00
}
}
2026-02-07 19:10:49 +09:00
for ( const task of tasksToCancel . values ( ) ) {
2026-05-02 03:01:03 +09:00
parentSessionsToClear . add ( task . parentSessionId )
2026-03-11 20:12:12 +09:00
2026-02-07 19:10:49 +09:00
if ( task . status === "running" || task . status === "pending" ) {
void this . cancelTask ( task . id , {
source : "session.deleted" ,
reason : "Session deleted" ,
2026-03-11 18:20:20 +09:00
} ) . then ( ( ) = > {
2026-05-02 03:01:03 +09:00
if ( deletedSessionIDs . has ( task . parentSessionId ) ) {
this . pendingNotifications . delete ( task . parentSessionId )
2026-03-11 18:20:20 +09:00
}
2026-02-07 19:10:49 +09:00
} ) . catch ( err = > {
2026-05-02 03:01:03 +09:00
if ( deletedSessionIDs . has ( task . parentSessionId ) ) {
this . pendingNotifications . delete ( task . parentSessionId )
2026-03-11 18:20:20 +09:00
}
2026-02-07 19:10:49 +09:00
log ( "[background-agent] Failed to cancel task on session.deleted:" , { taskId : task.id , error : err } )
} )
}
2026-02-27 03:00:39 +09:00
}
2026-03-11 20:12:12 +09:00
for ( const parentSessionID of parentSessionsToClear ) {
this . clearTaskHistoryWhenParentTasksGone ( parentSessionID )
}
2026-03-11 17:46:04 +09:00
this . rootDescendantCounts . delete ( sessionID )
2026-02-04 15:25:41 +09:00
SessionCategoryRegistry . remove ( sessionID )
2025-12-11 15:45:37 +09:00
}
2026-02-19 04:41:00 +02:00
if ( event . type === "session.status" ) {
const sessionID = props ? . sessionID as string | undefined
const status = props ? . status as { type ? : string ; message? : string } | undefined
if ( ! sessionID || status ? . type !== "retry" ) return
2026-04-28 15:27:35 +09:00
const resolved = this . resolveTaskAttemptBySession ( sessionID )
if ( ! resolved ? . isCurrent ) return
const { task } = resolved
if ( task . status !== "running" ) return
2026-02-19 04:41:00 +02:00
const errorMessage = typeof status . message === "string" ? status.message : undefined
const errorInfo = { name : "SessionRetry" , message : errorMessage }
2026-04-03 17:12:18 +09:00
void this . tryFallbackRetry ( task , errorInfo , "session.status" ) . catch ( ( error ) = > {
log ( "[background-agent] Error handling session.status fallback retry:" , {
error ,
taskId : task.id ,
} )
} )
2026-02-19 04:41:00 +02:00
}
}
2026-04-03 17:12:18 +09:00
private async handleSessionErrorEvent ( args : {
task : BackgroundTask
errorInfo : { name? : string ; message? : string }
errorName : string | undefined
errorMessage : string | undefined
} ) : Promise < void > {
const { task , errorInfo , errorMessage , errorName } = args
2026-05-02 03:01:03 +09:00
if ( ! task . fallbackChain && task . sessionId ) {
const sessionFallbackChain = this . modelFallbackControllerAccessor ? . getSessionFallbackChain ( task . sessionId )
2026-04-28 15:27:35 +09:00
if ( sessionFallbackChain ? . length ) {
task . fallbackChain = sessionFallbackChain
}
}
2026-04-04 22:13:05 -07:00
// Agent-not-found errors are handled by the prompt catch block with agent fallback.
// Do not also trigger model fallback retry — that would race with the agent retry.
if ( isAgentNotFoundError ( { message : errorInfo.message } as Error ) ) {
log ( "[background-agent] Skipping session.error fallback for agent-not-found (handled by prompt catch)" , {
taskId : task.id ,
errorMessage : errorInfo.message?.slice ( 0 , 100 ) ,
} )
return
}
2026-04-03 17:12:18 +09:00
if ( await this . tryFallbackRetry ( task , errorInfo , "session.error" ) ) {
return
}
const errorMsg = errorMessage ? ? "Session error"
const canRetry =
shouldRetryError ( errorInfo ) &&
! ! task . fallbackChain &&
hasMoreFallbacks ( task . fallbackChain , task . attemptCount ? ? 0 )
log ( "[background-agent] Session error - no retry:" , {
taskId : task.id ,
errorName ,
errorMessage : errorMsg?.slice ( 0 , 100 ) ,
hasFallbackChain : ! ! task . fallbackChain ,
canRetry ,
} )
2026-04-28 10:48:05 +09:00
const sessionId = task . sessionId
if ( sessionId ) {
const sessionStillAlive = await this . verifySessionExists ( sessionId )
if ( sessionStillAlive ) {
2026-05-07 17:47:53 +09:00
this . logger ( "[background-agent] session.error received but session still alive, treating as transient:" , {
2026-04-28 10:48:05 +09:00
taskId : task.id ,
sessionId ,
errorMessage : errorMsg?.slice ( 0 , 200 ) ,
} )
return
}
}
2026-04-28 15:27:35 +09:00
if ( task . currentAttemptID ) {
finalizeAttempt ( task , task . currentAttemptID , "error" , errorMsg )
} else {
task . status = "error"
task . error = errorMsg
task . completedAt = new Date ( )
}
2026-05-02 03:01:03 +09:00
if ( task . rootSessionId ) {
this . unregisterRootDescendant ( task . rootSessionId )
2026-04-03 17:12:18 +09:00
}
2026-05-02 03:01:03 +09:00
this . taskHistory . record ( task . parentSessionId , { id : task.id , sessionID : task.sessionId , agent : task.agent , description : task.description , status : "error" , category : task.category , startedAt : task.startedAt , completedAt : task.completedAt } )
2026-04-03 17:12:18 +09:00
if ( task . concurrencyKey ) {
this . concurrencyManager . release ( task . concurrencyKey )
task . concurrencyKey = undefined
}
const completionTimer = this . completionTimers . get ( task . id )
if ( completionTimer ) {
clearTimeout ( completionTimer )
this . completionTimers . delete ( task . id )
}
const idleTimer = this . idleDeferralTimers . get ( task . id )
if ( idleTimer ) {
clearTimeout ( idleTimer )
this . idleDeferralTimers . delete ( task . id )
}
this . cleanupPendingByParent ( task )
this . clearNotificationsForTask ( task . id )
const toastManager = getTaskToastManager ( )
if ( toastManager ) {
toastManager . removeTask ( task . id )
}
this . scheduleTaskRemoval ( task . id )
2026-05-02 03:01:03 +09:00
if ( task . sessionId ) {
SessionCategoryRegistry . remove ( task . sessionId )
2026-04-03 17:12:18 +09:00
}
2026-04-16 12:02:43 +08:00
// Update continuation marker for CLI run mode
2026-05-05 03:53:44 +09:00
if ( task . parentSessionId ) {
this . updateBackgroundTaskMarker ( task . parentSessionId )
2026-04-16 12:02:43 +08:00
}
2026-04-03 17:12:18 +09:00
this . markForNotification ( task )
2026-05-02 03:01:03 +09:00
this . enqueueNotificationForParent ( task . parentSessionId , ( ) = > this . notifyParentSession ( task ) ) . catch ( err = > {
2026-04-03 17:12:18 +09:00
log ( "[background-agent] Error in notifyParentSession for errored task:" , { taskId : task.id , error : err } )
} )
}
2026-02-19 04:41:00 +02:00
private tryFallbackRetry (
task : BackgroundTask ,
errorInfo : { name? : string ; message? : string } ,
source : string ,
2026-04-03 17:12:18 +09:00
) : Promise < boolean > {
2026-05-02 03:01:03 +09:00
const previousSessionID = task . sessionId
2026-02-22 11:58:57 +09:00
const result = tryFallbackRetry ( {
task ,
errorInfo ,
2026-02-19 04:41:00 +02:00
source ,
2026-02-22 11:58:57 +09:00
concurrencyManager : this.concurrencyManager ,
client : this.client ,
idleDeferralTimers : this.idleDeferralTimers ,
queuesByKey : this.queuesByKey ,
processKey : ( key : string ) = > this . processKey ( key ) ,
2026-04-28 15:27:35 +09:00
onRetrying : ( { task , source } ) = > {
const currentAttempt = getCurrentAttempt ( task )
2026-05-02 03:01:03 +09:00
const previousAttempt = getPreviousAttempt ( task , currentAttempt ? . attemptId )
2026-04-28 15:27:35 +09:00
const sourceText = source ? ` via ${ source } ` : ""
2026-05-02 03:01:03 +09:00
const failedSessionLine = previousAttempt ? . sessionId ? ` \ n- Failed session: \` ${ previousAttempt . sessionId } \` ` : ""
2026-04-28 15:27:35 +09:00
const failedModel = formatAttemptModelSummary ( previousAttempt )
const failedModelLine = failedModel ? ` \ n- Failed model: \` ${ failedModel } \` ` : ""
const failedErrorLine = previousAttempt ? . error ? ` \ n- Error: ${ previousAttempt . error } ` : ""
const nextModel = formatAttemptModelSummary ( currentAttempt )
this . queuePendingNotification (
2026-05-02 03:01:03 +09:00
task . parentSessionId ,
2026-04-28 15:27:35 +09:00
` <system-reminder>
[BACKGROUND TASK RETRYING]
**ID:** \` ${ task . id } \`
**Description:** ${ task . description } ${ sourceText } ${ failedSessionLine } ${ failedModelLine } ${ failedErrorLine } ${ nextModel ? ` \ n- Next model: \` ${ nextModel } \` ` : "" }
The task was re-queued on a fallback model after a retryable failure.
</system-reminder> `
)
} ,
2026-02-19 04:41:00 +02:00
} )
2026-04-03 17:12:18 +09:00
return result . then ( ( retried ) = > {
if ( retried && previousSessionID ) {
2026-04-04 18:39:26 +09:00
this . clearSessionOutputObserved ( previousSessionID )
2026-04-04 18:48:03 +09:00
this . clearSessionTodoObservation ( previousSessionID )
2026-04-03 17:12:18 +09:00
subagentSessions . delete ( previousSessionID )
}
return retried
} )
2025-12-11 15:45:37 +09:00
}
markForNotification ( task : BackgroundTask ) : void {
2026-05-02 03:01:03 +09:00
const queue = this . notifications . get ( task . parentSessionId ) ? ? [ ]
2026-02-01 16:51:11 +09:00
queue . push ( task )
2026-05-02 03:01:03 +09:00
this . notifications . set ( task . parentSessionId , queue )
2025-12-11 15:45:37 +09:00
}
getPendingNotifications ( sessionID : string ) : BackgroundTask [ ] {
2026-02-01 16:51:11 +09:00
return this . notifications . get ( sessionID ) ? ? [ ]
2025-12-11 15:45:37 +09:00
}
clearNotifications ( sessionID : string ) : void {
2026-02-01 16:51:11 +09:00
this . notifications . delete ( sessionID )
2025-12-11 15:45:37 +09:00
}
2026-02-25 16:26:31 +09:00
queuePendingNotification ( sessionID : string | undefined , notification : string ) : void {
if ( ! sessionID ) return
const existingNotifications = this . pendingNotifications . get ( sessionID ) ? ? [ ]
existingNotifications . push ( notification )
this . pendingNotifications . set ( sessionID , existingNotifications )
}
injectPendingNotificationsIntoChatMessage ( output : { parts : Array < { type : string ; text? : string ; [ key : string ] : unknown } > } , sessionID : string ) : void {
const pendingNotifications = this . pendingNotifications . get ( sessionID )
if ( ! pendingNotifications || pendingNotifications . length === 0 ) {
return
}
this . pendingNotifications . delete ( sessionID )
const notificationContent = pendingNotifications . join ( "\n\n" )
const firstTextPartIndex = output . parts . findIndex ( ( part ) = > part . type === "text" )
if ( firstTextPartIndex === - 1 ) {
output . parts . unshift ( createInternalAgentTextPart ( notificationContent ) )
return
}
const originalText = output . parts [ firstTextPartIndex ] . text ? ? ""
output . parts [ firstTextPartIndex ] . text = ` ${ notificationContent } \ n \ n--- \ n \ n ${ originalText } `
}
2026-02-01 16:51:11 +09:00
/**
* Validates that a session has actual assistant/tool output before marking complete.
* Prevents premature completion when session.idle fires before agent responds.
*/
private async validateSessionHasOutput ( sessionID : string ) : Promise < boolean > {
2026-04-04 18:39:26 +09:00
if ( this . observedOutputSessions . has ( sessionID ) ) {
return true
}
2026-02-01 16:51:11 +09:00
try {
const response = await this . client . session . messages ( {
path : { id : sessionID } ,
} )
2026-02-16 18:20:19 +09:00
const messages = normalizeSDKResponse ( response , [ ] as Array < { info ? : { role? : string } } > , { preferResponseOnMissingData : true } )
2026-02-01 16:51:11 +09:00
// Check for at least one assistant or tool message
const hasAssistantOrToolMessage = messages . some (
( m : { info ? : { role? : string } } ) = >
m . info ? . role === "assistant" || m . info ? . role === "tool"
)
if ( ! hasAssistantOrToolMessage ) {
log ( "[background-agent] No assistant/tool messages found in session:" , sessionID )
return false
}
// OpenCode API uses different part types than Anthropic's API:
// - "reasoning" with .text property (thinking/reasoning content)
// - "tool" with .state.output property (tool call results)
// - "text" with .text property (final text output)
// - "step-start"/"step-finish" (metadata, no content)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const hasContent = messages . some ( ( m : any ) = > {
if ( m . info ? . role !== "assistant" && m . info ? . role !== "tool" ) return false
const parts = m . parts ? ? [ ]
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return parts . some ( ( p : any ) = >
// Text content (final output)
( p . type === "text" && p . text && p . text . trim ( ) . length > 0 ) ||
// Reasoning content (thinking blocks)
( p . type === "reasoning" && p . text && p . text . trim ( ) . length > 0 ) ||
// Tool calls (indicates work was done)
p . type === "tool" ||
// Tool results (output from executed tools) - important for tool-only tasks
( p . type === "tool_result" && p . content &&
( typeof p . content === "string" ? p . content . trim ( ) . length > 0 : p.content.length > 0 ) )
)
} )
if ( ! hasContent ) {
log ( "[background-agent] Messages exist but no content found in session:" , sessionID )
return false
}
2026-04-04 18:39:26 +09:00
this . markSessionOutputObserved ( sessionID )
2026-02-01 16:51:11 +09:00
return true
} catch ( error ) {
log ( "[background-agent] Error validating session output:" , error )
// On error, allow completion to proceed (don't block indefinitely)
return true
}
2025-12-11 15:45:37 +09:00
}
2026-02-01 16:51:11 +09:00
private clearNotificationsForTask ( taskId : string ) : void {
for ( const [ sessionID , tasks ] of this . notifications . entries ( ) ) {
const filtered = tasks . filter ( ( t ) = > t . id !== taskId )
if ( filtered . length === 0 ) {
this . notifications . delete ( sessionID )
} else {
this . notifications . set ( sessionID , filtered )
}
}
2026-01-14 14:08:53 +08:00
}
2026-02-01 16:51:11 +09:00
/**
* Remove task from pending tracking for its parent session.
* Cleans up the parent entry if no pending tasks remain.
*/
private cleanupPendingByParent ( task : BackgroundTask ) : void {
2026-05-02 03:01:03 +09:00
if ( ! task . parentSessionId ) return
const pending = this . pendingByParent . get ( task . parentSessionId )
2026-02-01 16:51:11 +09:00
if ( pending ) {
pending . delete ( task . id )
if ( pending . size === 0 ) {
2026-05-02 03:01:03 +09:00
this . pendingByParent . delete ( task . parentSessionId )
2026-02-01 16:51:11 +09:00
}
}
}
2026-03-11 20:12:12 +09:00
private clearTaskHistoryWhenParentTasksGone ( parentSessionID : string | undefined ) : void {
if ( ! parentSessionID ) return
if ( this . getTasksByParentSession ( parentSessionID ) . length > 0 ) return
this . taskHistory . clearSession ( parentSessionID )
2026-03-11 21:16:37 +09:00
this . completedTaskSummaries . delete ( parentSessionID )
2026-03-11 20:12:12 +09:00
}
2026-03-17 15:17:34 +09:00
private scheduleTaskRemoval ( taskId : string , rescheduleCount = 0 ) : void {
2026-03-11 20:12:12 +09:00
const existingTimer = this . completionTimers . get ( taskId )
if ( existingTimer ) {
clearTimeout ( existingTimer )
this . completionTimers . delete ( taskId )
}
const timer = setTimeout ( ( ) = > {
this . completionTimers . delete ( taskId )
const task = this . tasks . get ( taskId )
2026-03-17 15:17:34 +09:00
if ( ! task ) return
2026-05-02 03:01:03 +09:00
if ( task . parentSessionId ) {
const siblings = this . getTasksByParentSession ( task . parentSessionId )
2026-03-17 15:17:34 +09:00
const runningOrPendingSiblings = siblings . filter (
sibling = > sibling . id !== taskId && ( sibling . status === "running" || sibling . status === "pending" ) ,
)
const completedAtTimestamp = task . completedAt ? . getTime ( )
const reachedTaskTtl = completedAtTimestamp !== undefined && ( Date . now ( ) - completedAtTimestamp ) >= TASK_TTL_MS
if ( runningOrPendingSiblings . length > 0 && rescheduleCount < MAX_TASK_REMOVAL_RESCHEDULES && ! reachedTaskTtl ) {
this . scheduleTaskRemoval ( taskId , rescheduleCount + 1 )
return
2026-03-11 20:12:12 +09:00
}
}
2026-03-17 15:17:34 +09:00
this . clearNotificationsForTask ( taskId )
2026-04-28 18:00:04 +09:00
this . removeTask ( task )
2026-05-02 03:01:03 +09:00
this . clearTaskHistoryWhenParentTasksGone ( task . parentSessionId )
if ( task . sessionId ) {
subagentSessions . delete ( task . sessionId )
SessionCategoryRegistry . remove ( task . sessionId )
2026-03-17 15:17:34 +09:00
}
log ( "[background-agent] Removed completed task from memory:" , taskId )
2026-03-11 20:12:12 +09:00
} , TASK_CLEANUP_DELAY_MS )
this . completionTimers . set ( taskId , timer )
}
2026-02-03 12:11:45 +09:00
async cancelTask (
taskId : string ,
2026-02-03 16:56:40 +09:00
options ? : { source? : string ; reason? : string ; abortSession? : boolean ; skipNotification? : boolean }
2026-02-03 12:11:45 +09:00
) : Promise < boolean > {
2026-02-01 16:51:11 +09:00
const task = this . tasks . get ( taskId )
2026-02-03 12:11:45 +09:00
if ( ! task || ( task . status !== "running" && task . status !== "pending" ) ) {
2026-02-01 16:51:11 +09:00
return false
}
2026-02-03 12:11:45 +09:00
const source = options ? . source ? ? "cancel"
const abortSession = options ? . abortSession !== false
const reason = options ? . reason
if ( task . status === "pending" ) {
const key = task . model
? ` ${ task . model . providerID } / ${ task . model . modelID } `
: task . agent
const queue = this . queuesByKey . get ( key )
if ( queue ) {
const index = queue . findIndex ( item = > item . task . id === taskId )
if ( index !== - 1 ) {
queue . splice ( index , 1 )
if ( queue . length === 0 ) {
this . queuesByKey . delete ( key )
}
2026-02-01 16:51:11 +09:00
}
}
2026-03-13 12:37:33 +09:00
this . rollbackPreStartDescendantReservation ( task )
2026-02-03 12:11:45 +09:00
log ( "[background-agent] Cancelled pending task:" , { taskId , key } )
2026-02-01 16:51:11 +09:00
}
2026-03-20 12:51:21 -04:00
const wasRunning = task . status === "running"
2026-04-28 15:27:35 +09:00
if ( task . currentAttemptID ) {
finalizeAttempt ( task , task . currentAttemptID , "cancelled" , reason )
} else {
task . status = "cancelled"
task . completedAt = new Date ( )
if ( reason ) {
task . error = reason
}
}
2026-05-02 03:01:03 +09:00
if ( wasRunning && task . rootSessionId ) {
this . unregisterRootDescendant ( task . rootSessionId )
2026-03-20 12:51:21 -04:00
}
2026-05-02 03:01:03 +09:00
this . taskHistory . record ( task . parentSessionId , { id : task.id , sessionID : task.sessionId , agent : task.agent , description : task.description , status : "cancelled" , category : task.category , startedAt : task.startedAt , completedAt : task.completedAt } )
2026-02-03 12:11:45 +09:00
if ( task . concurrencyKey ) {
this . concurrencyManager . release ( task . concurrencyKey )
task . concurrencyKey = undefined
}
const existingTimer = this . completionTimers . get ( task . id )
if ( existingTimer ) {
clearTimeout ( existingTimer )
this . completionTimers . delete ( task . id )
}
2026-02-01 16:51:11 +09:00
2026-02-06 16:01:54 +09:00
const idleTimer = this . idleDeferralTimers . get ( task . id )
if ( idleTimer ) {
clearTimeout ( idleTimer )
this . idleDeferralTimers . delete ( task . id )
}
2026-05-02 03:01:03 +09:00
if ( abortSession && task . sessionId ) {
2026-03-27 19:57:57 +09:00
// Awaited to prevent dangling promise during subagent teardown (Bun/WebKit SIGABRT)
2026-05-02 03:01:03 +09:00
await this . abortSessionWithLogging ( task . sessionId , ` task cancellation ( ${ source } ) ` )
2026-02-04 15:25:41 +09:00
2026-05-02 03:01:03 +09:00
SessionCategoryRegistry . remove ( task . sessionId )
2026-02-03 12:11:45 +09:00
}
2026-03-09 11:39:04 +09:00
removeTaskToastTracking ( task . id )
2026-04-16 11:59:11 +08:00
// Update continuation marker for CLI run mode
2026-05-05 03:53:44 +09:00
if ( task . parentSessionId ) {
this . updateBackgroundTaskMarker ( task . parentSessionId )
2026-04-16 11:59:11 +08:00
}
2026-02-03 16:56:40 +09:00
if ( options ? . skipNotification ) {
2026-03-12 11:04:35 +09:00
this . cleanupPendingByParent ( task )
2026-03-11 20:12:12 +09:00
this . scheduleTaskRemoval ( task . id )
2026-02-03 16:56:40 +09:00
log ( ` [background-agent] Task cancelled via ${ source } (notification skipped): ` , task . id )
return true
}
this . markForNotification ( task )
2026-02-03 12:11:45 +09:00
try {
2026-05-02 03:01:03 +09:00
await this . enqueueNotificationForParent ( task . parentSessionId , ( ) = > this . notifyParentSession ( task ) )
2026-02-03 12:11:45 +09:00
log ( ` [background-agent] Task cancelled via ${ source } : ` , task . id )
} catch ( err ) {
log ( "[background-agent] Error in notifyParentSession for cancelled task:" , { taskId : task.id , error : err } )
}
return true
}
/**
* Cancels a pending task by removing it from queue and marking as cancelled.
* Does NOT abort session (no session exists yet) or release concurrency slot (wasn't acquired).
*/
cancelPendingTask ( taskId : string ) : boolean {
const task = this . tasks . get ( taskId )
if ( ! task || task . status !== "pending" ) {
return false
}
2026-02-01 16:51:11 +09:00
2026-02-03 12:11:45 +09:00
void this . cancelTask ( taskId , { source : "cancelPendingTask" , abortSession : false } )
2026-02-01 16:51:11 +09:00
return true
2026-01-19 10:18:10 +09:00
}
2025-12-11 17:12:45 +09:00
private startPolling ( ) : void {
if ( this . pollingInterval ) return
this . pollingInterval = setInterval ( ( ) = > {
this . pollRunningTasks ( )
2026-02-01 19:01:30 +09:00
} , POLLING_INTERVAL_MS )
2025-12-27 23:06:44 +09:00
this . pollingInterval . unref ( )
2025-12-11 17:12:45 +09:00
}
private stopPolling ( ) : void {
if ( this . pollingInterval ) {
clearInterval ( this . pollingInterval )
this . pollingInterval = undefined
}
}
2026-01-14 15:09:32 -08:00
private registerProcessCleanup ( ) : void {
2026-02-22 11:58:57 +09:00
registerManagerForCleanup ( this )
2026-01-10 13:00:25 +08:00
}
2026-01-14 23:11:38 -08:00
private unregisterProcessCleanup ( ) : void {
2026-02-22 11:58:57 +09:00
unregisterManagerForCleanup ( this )
2026-01-14 23:11:38 -08:00
}
2026-02-01 16:51:11 +09:00
/**
* Get all running tasks (for compaction hook)
*/
getRunningTasks ( ) : BackgroundTask [ ] {
return Array . from ( this . tasks . values ( ) ) . filter ( t = > t . status === "running" )
}
/**
2026-02-09 11:45:20 +09:00
* Get all non-running tasks still in memory (for compaction hook)
2026-02-01 16:51:11 +09:00
*/
2026-02-09 11:45:20 +09:00
getNonRunningTasks ( ) : BackgroundTask [ ] {
2026-02-01 16:51:11 +09:00
return Array . from ( this . tasks . values ( ) ) . filter ( t = > t . status !== "running" )
}
/**
* Safely complete a task with race condition protection.
* Returns true if task was successfully completed, false if already completed by another path.
*/
private async tryCompleteTask ( task : BackgroundTask , source : string ) : Promise < boolean > {
// Guard: Check if task is still running (could have been completed by another path)
if ( task . status !== "running" ) {
log ( "[background-agent] Task already completed, skipping:" , { taskId : task.id , status : task.status , source } )
return false
}
// Atomically mark as completed to prevent race conditions
2026-04-28 15:27:35 +09:00
if ( task . currentAttemptID ) {
finalizeAttempt ( task , task . currentAttemptID , "completed" )
} else {
task . status = "completed"
task . completedAt = new Date ( )
}
2026-05-02 03:01:03 +09:00
this . taskHistory . record ( task . parentSessionId , { id : task.id , sessionID : task.sessionId , agent : task.agent , description : task.description , status : "completed" , category : task.category , startedAt : task.startedAt , completedAt : task.completedAt } )
2026-02-01 16:51:11 +09:00
2026-05-02 03:01:03 +09:00
if ( task . rootSessionId ) {
this . unregisterRootDescendant ( task . rootSessionId )
2026-03-20 12:51:21 -04:00
}
2026-03-09 11:39:04 +09:00
removeTaskToastTracking ( task . id )
2026-02-01 16:51:11 +09:00
// Release concurrency BEFORE any async operations to prevent slot leaks
if ( task . concurrencyKey ) {
this . concurrencyManager . release ( task . concurrencyKey )
task . concurrencyKey = undefined
}
this . markForNotification ( task )
2026-02-06 16:01:54 +09:00
const idleTimer = this . idleDeferralTimers . get ( task . id )
if ( idleTimer ) {
clearTimeout ( idleTimer )
this . idleDeferralTimers . delete ( task . id )
}
2026-05-02 03:01:03 +09:00
if ( task . sessionId ) {
2026-03-27 19:57:57 +09:00
// Awaited to prevent dangling promise during subagent teardown (Bun/WebKit SIGABRT)
2026-05-02 03:01:03 +09:00
await this . abortSessionWithLogging ( task . sessionId , ` task completion ( ${ source } ) ` )
2026-02-04 15:25:41 +09:00
2026-05-02 03:01:03 +09:00
SessionCategoryRegistry . remove ( task . sessionId )
2026-02-01 16:51:11 +09:00
}
2026-04-16 11:59:11 +08:00
// Update continuation marker for CLI run mode
2026-05-05 03:53:44 +09:00
if ( task . parentSessionId ) {
this . updateBackgroundTaskMarker ( task . parentSessionId )
2026-04-16 11:59:11 +08:00
}
2026-02-01 16:51:11 +09:00
try {
2026-05-02 03:01:03 +09:00
await this . enqueueNotificationForParent ( task . parentSessionId , ( ) = > this . notifyParentSession ( task ) )
2026-02-01 16:51:11 +09:00
log ( ` [background-agent] Task completed via ${ source } : ` , task . id )
} catch ( err ) {
log ( "[background-agent] Error in notifyParentSession:" , { taskId : task.id , error : err } )
// Concurrency already released, notification failed but task is complete
}
return true
}
private async notifyParentSession ( task : BackgroundTask ) : Promise < void > {
2026-02-22 11:58:57 +09:00
const duration = formatDuration ( task . startedAt ? ? new Date ( ) , task . completedAt )
2026-02-01 16:51:11 +09:00
log ( "[background-agent] notifyParentSession called for task:" , task . id )
// Show toast notification
const toastManager = getTaskToastManager ( )
if ( toastManager ) {
toastManager . showCompletionToast ( {
id : task.id ,
description : task.description ,
duration ,
} )
}
2026-05-02 03:01:03 +09:00
if ( ! this . completedTaskSummaries . has ( task . parentSessionId ) ) {
this . completedTaskSummaries . set ( task . parentSessionId , [ ] )
2026-03-11 20:39:03 +09:00
}
2026-05-02 03:01:03 +09:00
this . completedTaskSummaries . get ( task . parentSessionId ) ! . push ( {
2026-03-11 20:39:03 +09:00
id : task.id ,
description : task.description ,
2026-03-27 15:48:07 +09:00
status : task.status ,
error : task.error ,
2026-04-28 15:27:35 +09:00
attempts : cloneAttempts ( task ) ,
2026-03-11 20:39:03 +09:00
} )
2026-02-01 16:51:11 +09:00
// Update pending tracking and check if all tasks complete
2026-05-02 03:01:03 +09:00
const pendingSet = this . pendingByParent . get ( task . parentSessionId )
2026-02-08 13:05:06 +09:00
let allComplete = false
let remainingCount = 0
2026-02-01 16:51:11 +09:00
if ( pendingSet ) {
pendingSet . delete ( task . id )
2026-02-08 13:05:06 +09:00
remainingCount = pendingSet . size
allComplete = remainingCount === 0
if ( allComplete ) {
2026-05-02 03:01:03 +09:00
this . pendingByParent . delete ( task . parentSessionId )
2026-02-01 16:51:11 +09:00
}
2026-02-08 13:05:06 +09:00
} else {
2026-03-11 18:20:20 +09:00
remainingCount = Array . from ( this . tasks . values ( ) )
2026-05-02 03:01:03 +09:00
. filter ( t = > t . parentSessionId === task . parentSessionId && t . id !== task . id && ( t . status === "running" || t . status === "pending" ) )
2026-03-11 18:20:20 +09:00
. length
allComplete = remainingCount === 0
2026-02-01 16:51:11 +09:00
}
2026-02-16 00:58:33 +02:00
const completedTasks = allComplete
2026-05-02 03:01:03 +09:00
? ( this . completedTaskSummaries . get ( task . parentSessionId ) ? ? [ { id : task.id , description : task.description , status : task.status , error : task.error , attempts : cloneAttempts ( task ) } ] )
2026-02-16 00:58:33 +02:00
: [ ]
2026-03-11 20:39:03 +09:00
if ( allComplete ) {
2026-05-02 03:01:03 +09:00
this . completedTaskSummaries . delete ( task . parentSessionId )
2026-03-11 20:39:03 +09:00
}
2026-03-11 18:20:20 +09:00
const statusText = task . status === "completed"
? "COMPLETED"
: task . status === "interrupt"
? "INTERRUPTED"
: task . status === "error"
? "ERROR"
: "CANCELLED"
2026-04-03 17:43:33 +09:00
const notification = buildBackgroundTaskNotificationText ( {
task ,
duration ,
statusText ,
allComplete ,
remainingCount ,
completedTasks ,
} )
2026-02-01 16:51:11 +09:00
2026-02-16 00:58:33 +02:00
let agent : string | undefined = task . parentAgent
let model : { providerID : string ; modelID : string } | undefined
2026-02-18 18:02:42 +09:00
let tools : Record < string , boolean > | undefined = task . parentTools
2026-04-08 17:15:02 +09:00
let promptContext : ReturnType < typeof resolvePromptContextFromSessionMessages > = null
2026-02-16 00:58:33 +02:00
2026-02-17 01:36:52 +09:00
if ( this . enableParentSessionNotifications ) {
try {
2026-05-02 03:01:03 +09:00
const messagesResp = await this . client . session . messages ( { path : { id : task.parentSessionId } } )
2026-02-17 01:36:52 +09:00
const messages = normalizeSDKResponse ( messagesResp , [ ] as Array < {
2026-02-18 18:02:42 +09:00
info ? : {
agent? : string
model ? : { providerID : string ; modelID : string }
modelID? : string
providerID? : string
tools? : Record < string , boolean | "allow" | "deny" | "ask" >
}
2026-02-17 01:36:52 +09:00
} > )
2026-04-08 17:15:02 +09:00
promptContext = resolvePromptContextFromSessionMessages (
2026-03-08 02:23:33 +09:00
messages ,
2026-05-02 03:01:03 +09:00
task . parentSessionId ,
2026-03-08 02:23:33 +09:00
)
const normalizedTools = isRecord ( promptContext ? . tools )
? normalizePromptTools ( promptContext . tools )
: undefined
if ( promptContext ? . agent || promptContext ? . model || normalizedTools ) {
agent = promptContext ? . agent ? ? task . parentAgent
model = promptContext ? . model ? . providerID && promptContext . model . modelID
? { providerID : promptContext.model.providerID , modelID : promptContext.model.modelID }
2026-02-19 04:41:00 +02:00
: undefined
2026-03-08 02:23:33 +09:00
tools = normalizedTools ? ? tools
2026-02-16 00:58:33 +02:00
}
2026-02-17 01:36:52 +09:00
} catch ( error ) {
2026-02-22 11:58:57 +09:00
if ( isAbortedSessionError ( error ) ) {
2026-02-17 01:36:52 +09:00
log ( "[background-agent] Parent session aborted while loading messages; using messageDir fallback:" , {
taskId : task.id ,
2026-05-02 03:01:03 +09:00
parentSessionID : task.parentSessionId ,
2026-02-17 01:36:52 +09:00
} )
}
2026-05-02 03:01:03 +09:00
const messageDir = join ( MESSAGE_STORAGE , task . parentSessionId )
2026-03-08 02:23:33 +09:00
const currentMessage = messageDir
2026-05-02 03:01:03 +09:00
? findNearestMessageExcludingCompaction ( messageDir , task . parentSessionId )
2026-03-08 02:23:33 +09:00
: null
2026-02-17 01:36:52 +09:00
agent = currentMessage ? . agent ? ? task . parentAgent
model = currentMessage ? . model ? . providerID && currentMessage ? . model ? . modelID
? { providerID : currentMessage.model.providerID , modelID : currentMessage.model.modelID }
: undefined
2026-02-18 18:02:42 +09:00
tools = normalizePromptTools ( currentMessage ? . tools ) ? ? tools
2026-02-01 16:51:11 +09:00
}
2026-02-16 00:58:33 +02:00
2026-05-02 03:01:03 +09:00
const resolvedTools = resolveInheritedPromptTools ( task . parentSessionId , tools )
2026-02-18 18:02:42 +09:00
2026-02-17 01:36:52 +09:00
log ( "[background-agent] notifyParentSession context:" , {
2026-02-05 11:31:54 +09:00
taskId : task.id ,
2026-02-17 01:36:52 +09:00
resolvedAgent : agent ,
resolvedModel : model ,
2026-02-05 11:31:54 +09:00
} )
2026-02-17 01:36:52 +09:00
2026-03-27 15:48:07 +09:00
const isTaskFailure = task . status === "error" || task . status === "cancelled" || task . status === "interrupt"
const shouldReply = allComplete || isTaskFailure
2026-04-08 17:15:02 +09:00
const variant = promptContext ? . model ? . variant
2026-04-08 13:23:08 +09:00
2026-02-17 01:36:52 +09:00
try {
await this . client . session . promptAsync ( {
2026-05-02 03:01:03 +09:00
path : { id : task.parentSessionId } ,
2026-02-17 01:36:52 +09:00
body : {
2026-03-27 15:48:07 +09:00
noReply : ! shouldReply ,
2026-02-17 01:36:52 +09:00
. . . ( agent !== undefined ? { agent } : { } ) ,
. . . ( model !== undefined ? { model } : { } ) ,
2026-04-08 13:23:08 +09:00
. . . ( variant !== undefined ? { variant } : { } ) ,
2026-02-19 04:41:00 +02:00
. . . ( resolvedTools ? { tools : resolvedTools } : { } ) ,
2026-02-18 19:55:36 +02:00
parts : [ createInternalAgentTextPart ( notification ) ] ,
2026-02-17 01:36:52 +09:00
} ,
} )
log ( "[background-agent] Sent notification to parent session:" , {
2026-02-16 00:58:33 +02:00
taskId : task.id ,
2026-02-17 01:36:52 +09:00
allComplete ,
2026-03-27 15:48:07 +09:00
isTaskFailure ,
noReply : ! shouldReply ,
2026-02-16 00:58:33 +02:00
} )
2026-02-17 01:36:52 +09:00
} catch ( error ) {
2026-02-22 11:58:57 +09:00
if ( isAbortedSessionError ( error ) ) {
2026-02-17 01:36:52 +09:00
log ( "[background-agent] Parent session aborted while sending notification; continuing cleanup:" , {
taskId : task.id ,
2026-05-02 03:01:03 +09:00
parentSessionID : task.parentSessionId ,
2026-02-17 01:36:52 +09:00
} )
2026-05-02 03:01:03 +09:00
this . queuePendingNotification ( task . parentSessionId , notification )
2026-02-17 01:36:52 +09:00
} else {
log ( "[background-agent] Failed to send notification:" , error )
}
2026-02-16 00:58:33 +02:00
}
2026-02-17 01:36:52 +09:00
} else {
log ( "[background-agent] Parent session notifications disabled, skipping prompt injection:" , {
taskId : task.id ,
2026-05-02 03:01:03 +09:00
parentSessionID : task.parentSessionId ,
2026-02-17 01:36:52 +09:00
} )
2026-02-05 11:31:54 +09:00
}
2026-02-01 16:51:11 +09:00
2026-03-11 20:12:12 +09:00
if ( task . status !== "running" && task . status !== "pending" ) {
this . scheduleTaskRemoval ( task . id )
2026-02-02 20:00:15 +09:00
}
2026-02-01 16:51:11 +09:00
}
private hasRunningTasks ( ) : boolean {
for ( const task of this . tasks . values ( ) ) {
if ( task . status === "running" ) return true
}
return false
}
2026-01-02 22:25:49 +09:00
private pruneStaleTasksAndNotifications ( ) : void {
2026-02-22 11:58:57 +09:00
pruneStaleTasksAndNotifications ( {
tasks : this.tasks ,
notifications : this.notifications ,
2026-03-27 16:06:38 +09:00
taskTtlMs : this.config?.taskTtlMs ,
2026-02-22 11:58:57 +09:00
onTaskPruned : ( taskId , task , errorMessage ) = > {
const wasPending = task . status === "pending"
log ( "[background-agent] Pruning stale task:" , { taskId , status : task.status , age : Math.round ( ( ( wasPending ? task . queuedAt ? . getTime ( ) : task . startedAt ? . getTime ( ) ) ? ( Date . now ( ) - ( wasPending ? task . queuedAt ! . getTime ( ) : task . startedAt ! . getTime ( ) ) ) : 0 ) / 1000 ) + "s" } )
2026-01-02 22:25:49 +09:00
task . status = "error"
2026-01-19 10:19:48 +09:00
task . error = errorMessage
2026-01-02 22:25:49 +09:00
task . completedAt = new Date ( )
2026-05-02 03:01:03 +09:00
if ( ! wasPending && task . rootSessionId ) {
this . unregisterRootDescendant ( task . rootSessionId )
2026-03-20 12:51:21 -04:00
}
2026-05-02 03:01:03 +09:00
this . taskHistory . record ( task . parentSessionId , { id : task.id , sessionID : task.sessionId , agent : task.agent , description : task.description , status : "error" , category : task.category , startedAt : task.startedAt , completedAt : task.completedAt } )
2026-01-09 02:24:43 +09:00
if ( task . concurrencyKey ) {
this . concurrencyManager . release ( task . concurrencyKey )
2026-01-14 15:09:32 -08:00
task . concurrencyKey = undefined
2026-01-07 01:24:47 +09:00
}
2026-03-09 11:39:04 +09:00
removeTaskToastTracking ( task . id )
2026-03-11 18:20:20 +09:00
const existingTimer = this . completionTimers . get ( taskId )
if ( existingTimer ) {
clearTimeout ( existingTimer )
this . completionTimers . delete ( taskId )
}
const idleTimer = this . idleDeferralTimers . get ( taskId )
if ( idleTimer ) {
clearTimeout ( idleTimer )
this . idleDeferralTimers . delete ( taskId )
}
2026-02-12 18:26:03 +09:00
if ( wasPending ) {
const key = task . model
? ` ${ task . model . providerID } / ${ task . model . modelID } `
: task . agent
const queue = this . queuesByKey . get ( key )
if ( queue ) {
const index = queue . findIndex ( ( item ) = > item . task . id === taskId )
if ( index !== - 1 ) {
queue . splice ( index , 1 )
if ( queue . length === 0 ) {
this . queuesByKey . delete ( key )
}
}
}
}
2026-03-11 20:12:12 +09:00
this . cleanupPendingByParent ( task )
2026-04-16 12:02:43 +08:00
// Update continuation marker for CLI run mode
2026-05-05 03:53:44 +09:00
if ( task . parentSessionId ) {
this . updateBackgroundTaskMarker ( task . parentSessionId )
2026-04-16 12:02:43 +08:00
}
2026-03-11 18:20:20 +09:00
this . markForNotification ( task )
2026-05-02 03:01:03 +09:00
this . enqueueNotificationForParent ( task . parentSessionId , ( ) = > this . notifyParentSession ( task ) ) . catch ( err = > {
2026-03-11 18:20:20 +09:00
log ( "[background-agent] Error in notifyParentSession for stale-pruned task:" , { taskId : task.id , error : err } )
} )
2026-02-22 11:58:57 +09:00
} ,
} )
2026-01-02 22:25:49 +09:00
}
2026-02-14 17:59:01 +09:00
private async checkAndInterruptStaleTasks (
2026-05-04 16:30:06 +09:00
allStatuses : SessionStatusMap | undefined ,
2026-02-14 17:59:01 +09:00
) : Promise < void > {
2026-02-22 11:58:57 +09:00
await checkAndInterruptStaleTasks ( {
tasks : this.tasks.values ( ) ,
client : this.client ,
2026-04-12 02:30:01 +09:00
directory : this.directory ,
2026-02-22 11:58:57 +09:00
config : this.config ,
concurrencyManager : this.concurrencyManager ,
2026-05-02 03:01:03 +09:00
notifyParentSession : ( task ) = > this . enqueueNotificationForParent ( task . parentSessionId , ( ) = > this . notifyParentSession ( task ) ) ,
2026-02-22 11:58:57 +09:00
sessionStatuses : allStatuses ,
} )
2026-01-17 17:40:58 +09:00
}
2026-03-27 15:43:01 +09:00
private async verifySessionExists ( sessionID : string ) : Promise < boolean > {
2026-04-12 02:30:01 +09:00
return verifySessionStillExists ( this . client , sessionID , this . directory )
2026-03-27 15:43:01 +09:00
}
private async failCrashedTask ( task : BackgroundTask , errorMessage : string ) : Promise < void > {
2026-04-28 15:27:35 +09:00
if ( task . currentAttemptID ) {
finalizeAttempt ( task , task . currentAttemptID , "error" , errorMessage )
} else {
task . status = "error"
task . error = errorMessage
task . completedAt = new Date ( )
}
2026-05-02 03:01:03 +09:00
if ( task . rootSessionId ) {
this . unregisterRootDescendant ( task . rootSessionId )
2026-03-27 15:43:01 +09:00
}
2026-05-02 03:01:03 +09:00
this . taskHistory . record ( task . parentSessionId , { id : task.id , sessionID : task.sessionId , agent : task.agent , description : task.description , status : "error" , category : task.category , startedAt : task.startedAt , completedAt : task.completedAt } )
2026-03-27 15:43:01 +09:00
if ( task . concurrencyKey ) {
this . concurrencyManager . release ( task . concurrencyKey )
task . concurrencyKey = undefined
}
const completionTimer = this . completionTimers . get ( task . id )
if ( completionTimer ) {
clearTimeout ( completionTimer )
this . completionTimers . delete ( task . id )
}
const idleTimer = this . idleDeferralTimers . get ( task . id )
if ( idleTimer ) {
clearTimeout ( idleTimer )
this . idleDeferralTimers . delete ( task . id )
}
this . cleanupPendingByParent ( task )
this . clearNotificationsForTask ( task . id )
removeTaskToastTracking ( task . id )
this . scheduleTaskRemoval ( task . id )
2026-05-02 03:01:03 +09:00
if ( task . sessionId ) {
SessionCategoryRegistry . remove ( task . sessionId )
2026-03-27 15:43:01 +09:00
}
2026-04-16 11:59:11 +08:00
// Update continuation marker for CLI run mode
2026-05-05 03:53:44 +09:00
if ( task . parentSessionId ) {
this . updateBackgroundTaskMarker ( task . parentSessionId )
2026-04-16 11:59:11 +08:00
}
2026-03-27 15:43:01 +09:00
this . markForNotification ( task )
2026-05-02 03:01:03 +09:00
this . enqueueNotificationForParent ( task . parentSessionId , ( ) = > this . notifyParentSession ( task ) ) . catch ( err = > {
2026-03-27 15:43:01 +09:00
log ( "[background-agent] Error in notifyParentSession for crashed task:" , { taskId : task.id , error : err } )
} )
}
2025-12-11 17:12:45 +09:00
private async pollRunningTasks ( ) : Promise < void > {
2026-02-17 03:06:40 +09:00
if ( this . pollingInFlight ) return
this . pollingInFlight = true
try {
2026-04-28 21:43:09 +09:00
this . pruneStaleTasksAndNotifications ( )
2026-01-02 22:25:49 +09:00
2026-05-04 16:30:06 +09:00
let allStatuses : SessionStatusMap | undefined
const sessionStatusMethod = this . client ? . session ? . status
if ( typeof sessionStatusMethod !== "function" ) {
if ( ! this . loggedSessionStatusUnavailable ) {
log ( "[background-agent] Unable to poll session statuses:" , {
reason : "session.status unavailable" ,
} )
this . loggedSessionStatusUnavailable = true
}
} else {
try {
const statusResult = await this . client . session . status ( )
allStatuses = normalizeSDKResponse ( statusResult , { } )
} catch ( error ) {
if ( ! this . loggedSessionStatusUnavailable ) {
log ( "[background-agent] Error polling session statuses:" , { error } )
this . loggedSessionStatusUnavailable = true
}
}
}
2025-12-11 17:38:01 +09:00
2026-04-28 21:43:09 +09:00
await this . checkAndInterruptStaleTasks ( allStatuses )
2026-02-14 17:59:01 +09:00
2026-04-28 21:43:09 +09:00
for ( const task of this . tasks . values ( ) ) {
if ( task . status !== "running" ) continue
2026-05-02 03:01:03 +09:00
const sessionID = task . sessionId
2026-04-28 21:43:09 +09:00
if ( ! sessionID ) continue
try {
2026-05-04 16:30:06 +09:00
const sessionStatus = allStatuses ? . [ sessionID ]
2026-04-28 21:43:09 +09:00
// Handle retry before checking running state
if ( sessionStatus ? . type === "retry" ) {
const retryMessage = typeof ( sessionStatus as { message? : string } ) . message === "string"
? ( sessionStatus as { message? : string } ) . message
: undefined
const errorInfo = { name : "SessionRetry" , message : retryMessage }
if ( await this . tryFallbackRetry ( task , errorInfo , "polling:session.status" ) ) {
continue
}
}
// Only skip completion when session status is actively running.
// Unknown or terminal statuses (like "interrupted") fall through to completion.
if ( sessionStatus && isActiveSessionStatus ( sessionStatus . type ) ) {
log ( "[background-agent] Session still running, relying on event-based progress:" , {
taskId : task.id ,
sessionID ,
sessionStatus : sessionStatus.type ,
toolCalls : task.progress?.toolCalls ? ? 0 ,
} )
2026-02-19 04:41:00 +02:00
continue
}
2026-04-28 21:43:09 +09:00
if ( sessionStatus && isTerminalSessionStatus ( sessionStatus . type ) ) {
await this . tryCompleteTask ( task , ` polling (terminal session status: ${ sessionStatus . type } ) ` )
continue
}
2026-03-08 23:42:11 +09:00
2026-04-28 21:43:09 +09:00
if ( sessionStatus && sessionStatus . type !== "idle" ) {
log ( "[background-agent] Unknown session status, treating as potentially idle:" , {
taskId : task.id ,
sessionID ,
sessionStatus : sessionStatus.type ,
} )
}
2026-03-18 13:56:11 +09:00
2026-05-04 16:30:06 +09:00
if ( allStatuses === undefined ) {
continue
}
2026-04-28 21:43:09 +09:00
// Session is idle or no longer in status response (completed/disappeared)
2026-05-04 16:30:06 +09:00
const sessionGoneFromStatus = allStatuses !== undefined && ! sessionStatus
2026-04-28 21:43:09 +09:00
const sessionGoneThresholdReached = sessionGoneFromStatus
&& ( task . consecutiveMissedPolls ? ? 0 ) >= MIN_SESSION_GONE_POLLS
const completionSource = sessionStatus ? . type === "idle"
? "polling (idle status)"
: "polling (session gone from status)"
const hasValidOutput = await this . validateSessionHasOutput ( sessionID )
if ( ! hasValidOutput ) {
if ( sessionGoneThresholdReached ) {
const sessionExists = await this . verifySessionExists ( sessionID )
if ( ! sessionExists ) {
log ( "[background-agent] Session no longer exists (crashed), marking task as error:" , task . id )
await this . failCrashedTask ( task , "Subagent session no longer exists (process likely crashed). The session disappeared without producing any output." )
continue
}
2026-03-18 13:56:11 +09:00
2026-04-28 21:43:09 +09:00
task . consecutiveMissedPolls = 0
2026-03-27 15:43:01 +09:00
}
2026-04-28 21:43:09 +09:00
log ( "[background-agent] Polling idle/gone but no valid output yet, waiting:" , task . id )
continue
2026-03-27 15:43:01 +09:00
}
2026-03-08 23:42:11 +09:00
2026-04-28 21:43:09 +09:00
// Re-check status after async operation
if ( task . status !== "running" ) continue
2026-03-08 23:42:11 +09:00
2026-04-28 21:43:09 +09:00
const hasIncompleteTodos = await this . checkSessionTodos ( sessionID )
if ( hasIncompleteTodos ) {
log ( "[background-agent] Task has incomplete todos via polling, waiting:" , task . id )
continue
}
2026-03-08 23:42:11 +09:00
2026-04-28 21:43:09 +09:00
await this . tryCompleteTask ( task , completionSource )
} catch ( error ) {
log ( "[background-agent] Poll error for task:" , { taskId : task.id , error } )
}
2025-12-11 17:12:45 +09:00
}
2026-04-28 21:43:09 +09:00
if ( ! this . hasRunningTasks ( ) ) {
this . stopPolling ( )
}
2026-02-17 03:06:40 +09:00
} finally {
this . pollingInFlight = false
}
2025-12-11 17:12:45 +09:00
}
2026-01-14 15:09:32 -08:00
2026-02-01 16:51:11 +09:00
/**
* Shutdown the manager gracefully.
* Cancels all pending concurrency waiters and clears timers.
* Should be called when the plugin is unloaded.
*/
2026-03-11 21:30:04 +09:00
async shutdown ( ) : Promise < void > {
2026-01-14 15:09:32 -08:00
if ( this . shutdownTriggered ) return
this . shutdownTriggered = true
log ( "[background-agent] Shutting down BackgroundManager" )
this . stopPolling ( )
2026-03-13 10:56:44 +09:00
const trackedSessionIDs = new Set < string > ( )
2026-04-03 17:12:18 +09:00
const abortRequests : Array < { sessionID : string ; promise : Promise < unknown > } > = [ ]
2026-01-14 15:09:32 -08:00
2026-02-01 16:51:11 +09:00
// Abort all running sessions to prevent zombie processes (#1240)
for ( const task of this . tasks . values ( ) ) {
2026-05-02 03:01:03 +09:00
if ( task . sessionId ) {
trackedSessionIDs . add ( task . sessionId )
2026-03-13 10:56:44 +09:00
}
2026-05-02 03:01:03 +09:00
if ( task . status === "running" && task . sessionId ) {
2026-04-03 17:12:18 +09:00
abortRequests . push ( {
2026-05-02 03:01:03 +09:00
sessionID : task.sessionId ,
promise : abortWithTimeout ( this . client , task . sessionId ) ,
2026-04-03 17:12:18 +09:00
} )
}
}
if ( abortRequests . length > 0 ) {
const abortResults = await Promise . allSettled ( abortRequests . map ( ( request ) = > request . promise ) )
for ( const [ index , abortResult ] of abortResults . entries ( ) ) {
if ( abortResult . status === "fulfilled" ) continue
log ( "[background-agent] Error aborting session during shutdown:" , {
error : abortResult.reason ,
sessionID : abortRequests [ index ] ? . sessionID ,
} )
2026-01-29 18:29:47 +09:00
}
}
2026-02-01 16:51:11 +09:00
// Notify shutdown listeners (e.g., tmux cleanup)
2026-01-29 18:29:47 +09:00
if ( this . onShutdown ) {
try {
2026-03-11 21:30:04 +09:00
await this . onShutdown ( )
2026-01-29 18:29:47 +09:00
} catch ( error ) {
log ( "[background-agent] Error in onShutdown callback:" , error )
}
}
2026-02-01 16:51:11 +09:00
// Release concurrency for all running tasks
for ( const task of this . tasks . values ( ) ) {
2026-01-14 15:09:32 -08:00
if ( task . concurrencyKey ) {
this . concurrencyManager . release ( task . concurrencyKey )
task . concurrencyKey = undefined
}
}
2026-02-01 16:51:11 +09:00
for ( const timer of this . completionTimers . values ( ) ) {
clearTimeout ( timer )
}
this . completionTimers . clear ( )
2026-02-06 16:01:54 +09:00
for ( const timer of this . idleDeferralTimers . values ( ) ) {
clearTimeout ( timer )
}
this . idleDeferralTimers . clear ( )
2026-03-13 10:56:44 +09:00
for ( const sessionID of trackedSessionIDs ) {
subagentSessions . delete ( sessionID )
SessionCategoryRegistry . remove ( sessionID )
}
2026-01-14 15:09:32 -08:00
this . concurrencyManager . clear ( )
2026-02-01 16:51:11 +09:00
this . tasks . clear ( )
2026-04-28 18:00:04 +09:00
this . tasksByParentSession . clear ( )
2026-02-01 16:51:11 +09:00
this . notifications . clear ( )
2026-02-25 16:26:31 +09:00
this . pendingNotifications . clear ( )
2026-02-01 16:51:11 +09:00
this . pendingByParent . clear ( )
2026-02-08 13:05:06 +09:00
this . notificationQueueByParent . clear ( )
2026-03-11 17:46:04 +09:00
this . rootDescendantCounts . clear ( )
2026-02-01 16:51:11 +09:00
this . queuesByKey . clear ( )
this . processingKeys . clear ( )
2026-03-11 20:12:12 +09:00
this . taskHistory . clearAll ( )
2026-03-11 20:39:03 +09:00
this . completedTaskSummaries . clear ( )
2026-01-14 23:11:38 -08:00
this . unregisterProcessCleanup ( )
2026-01-14 15:09:32 -08:00
log ( "[background-agent] Shutdown complete" )
2026-02-01 16:51:11 +09:00
2026-01-14 15:09:32 -08:00
}
2026-02-08 13:05:06 +09:00
private enqueueNotificationForParent (
parentSessionID : string | undefined ,
operation : ( ) = > Promise < void >
) : Promise < void > {
if ( ! parentSessionID ) {
return operation ( )
}
const previous = this . notificationQueueByParent . get ( parentSessionID ) ? ? Promise . resolve ( )
2026-04-03 21:42:10 +09:00
const cleanupQueueEntry = ( ) : void = > {
if ( this . notificationQueueByParent . get ( parentSessionID ) === current ) {
this . notificationQueueByParent . delete ( parentSessionID )
}
}
2026-02-08 13:05:06 +09:00
const current = previous
2026-04-03 21:42:10 +09:00
. catch ( ( error ) = > {
log ( "[background-agent] Continuing notification queue after previous failure:" , {
parentSessionID ,
error ,
} )
} )
2026-02-08 13:05:06 +09:00
. then ( operation )
this . notificationQueueByParent . set ( parentSessionID , current )
2026-04-03 21:42:10 +09:00
void current . then ( cleanupQueueEntry , cleanupQueueEntry )
2026-02-08 13:05:06 +09:00
return current
}
2026-01-14 15:09:32 -08:00
}