2026-02-01 16:51:11 +09:00
2025-12-11 15:45:37 +09:00
import type { PluginInput } from "@opencode-ai/plugin"
2026-02-01 16:51:11 +09:00
import type {
BackgroundTask ,
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-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"
import { registerManagerForCleanup , unregisterManagerForCleanup } from "./process-cleanup"
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-02-22 11:58:57 +09:00
import { pruneStaleTasksAndNotifications } from "./task-poller"
import { checkAndInterruptStaleTasks } from "./task-poller"
2026-03-09 11:39:04 +09:00
import { removeTaskToastTracking } from "./remove-task-toast-tracking"
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 ,
createSubagentDescendantLimitError ,
2026-03-11 20:57:09 +09:00
getMaxRootSessionSpawnBudget ,
2026-03-11 17:46:04 +09:00
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
}
interface QueueItem {
task : BackgroundTask
input : LaunchInput
}
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
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 >
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-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-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-01-25 15:34:10 +09:00
constructor (
ctx : PluginInput ,
config? : BackgroundTaskConfig ,
2026-01-26 12:02:37 +09:00
options ? : {
tmuxConfig? : TmuxConfig
2026-02-01 16:51:11 +09:00
onSubagentSessionCreated? : OnSubagentSessionCreated
2026-03-11 21:30:04 +09:00
onShutdown ? : ( ) = > void | Promise < void >
2026-02-16 00:58:33 +02:00
enableParentSessionNotifications? : boolean
2026-01-26 12:02:37 +09:00
}
2026-01-25 15:34:10 +09:00
) {
2026-02-01 16:51:11 +09:00
this . tasks = new Map ( )
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 ( )
2025-12-11 18:13:02 +09:00
this . client = ctx . client
this . directory = ctx . directory
2026-01-07 01:24:47 +09:00
this . concurrencyManager = new ConcurrencyManager ( config )
2026-01-17 17:40:58 +09:00
this . config = 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-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 {
await this . client . session . abort ( {
path : { id : sessionID } ,
} )
} 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 > {
const spawnContext = await resolveSubagentSpawnContext ( this . client , parentSessionID )
const maxDepth = getMaxSubagentDepth ( this . config )
if ( spawnContext . childDepth > maxDepth ) {
throw createSubagentDepthLimitError ( {
childDepth : spawnContext.childDepth ,
maxDepth ,
parentSessionID ,
rootSessionID : spawnContext.rootSessionID ,
} )
}
2026-03-11 20:57:09 +09:00
const maxRootSessionSpawnBudget = getMaxRootSessionSpawnBudget ( this . config )
2026-03-11 17:46:04 +09:00
const descendantCount = this . rootDescendantCounts . get ( spawnContext . rootSessionID ) ? ? 0
2026-03-11 20:57:09 +09:00
if ( descendantCount >= maxRootSessionSpawnBudget ) {
2026-03-11 17:46:04 +09:00
throw createSubagentDescendantLimitError ( {
rootSessionID : spawnContext.rootSessionID ,
descendantCount ,
2026-03-11 20:57:09 +09:00
maxDescendants : maxRootSessionSpawnBudget ,
2026-03-11 17:46:04 +09:00
} )
}
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
}
if ( ! task . rootSessionID ) {
return
}
this . unregisterRootDescendant ( task . rootSessionID )
}
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 ,
parentSessionID : input.parentSessionID ,
} )
2025-12-14 01:22:28 +09:00
if ( ! input . agent || input . agent . trim ( ) === "" ) {
throw new Error ( "Agent parameter is required" )
}
2026-03-11 18:44:20 +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" , {
parentSessionID : input.parentSessionID ,
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 ( ) ,
rootSessionID : spawnReservation.spawnContext.rootSessionID ,
// 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 ,
parentSessionID : input.parentSessionID ,
parentMessageID : input.parentMessageID ,
parentModel : input.parentModel ,
parentAgent : input.parentAgent ,
parentTools : input.parentTools ,
model : input.model ,
fallbackChain : input.fallbackChain ,
attemptCount : 0 ,
category : input.category ,
}
2026-01-18 14:39:11 +09:00
2026-03-11 18:44:20 +09:00
this . tasks . set ( task . id , task )
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)
if ( input . parentSessionID ) {
const pending = this . pendingByParent . get ( input . parentSessionID ) ? ? new Set ( )
pending . add ( task . id )
this . pendingByParent . set ( input . parentSessionID , pending )
}
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 ) ? ? [ ]
queue . push ( { task , input } )
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-03-11 18:44:20 +09:00
// Trigger processing (fire-and-forget)
this . processKey ( key )
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-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-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
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 ( {
path : { id : input.parentSessionID } ,
} ) . 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 : {
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-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-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 ,
parentID : input.parentSessionID ,
} )
if ( this . onSubagentSessionCreated && this . tmuxEnabled && isInsideTmux ( ) ) {
log ( "[background-agent] Invoking tmux callback NOW" , { sessionID } )
await this . onSubagentSessionCreated ( {
sessionID ,
parentID : input.parentSessionID ,
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 {
log ( "[background-agent] SKIP tmux callback - conditions not met" )
}
// Update task to running state
task . status = "running"
task . startedAt = new Date ( )
task . sessionID = sessionID
task . progress = {
toolCalls : 0 ,
lastUpdate : new Date ( ) ,
}
task . concurrencyKey = concurrencyKey
task . concurrencyGroup = concurrencyKey
2026-02-13 17:40:44 +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-02-01 16:51:11 +09:00
promptWithModelSuggestionRetry ( this . client , {
path : { id : sessionID } ,
body : {
2026-03-25 11:44:01 +09:00
agent : input.agent ,
2026-02-01 16:51:11 +09:00
. . . ( launchModel ? { model : launchModel } : { } ) ,
. . . ( launchVariant ? { variant : launchVariant } : { } ) ,
system : input.skillContent ,
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 ( input . agent ) ,
2026-02-14 14:30:30 +09:00
}
setSessionTools ( sessionID , tools )
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] promptAsync error:" , error )
const existingTask = this . findBySession ( sessionID )
if ( existingTask ) {
2026-02-09 18:25:54 +09:00
existingTask . status = "interrupt"
2026-02-01 16:51:11 +09:00
const errorMessage = error instanceof Error ? error.message : String ( error )
if ( errorMessage . includes ( "agent.name" ) || errorMessage . includes ( "undefined" ) ) {
existingTask . error = ` Agent " ${ input . agent } " not found. Make sure the agent is registered in your opencode.json or provided by a plugin. `
} else {
existingTask . error = errorMessage
}
existingTask . completedAt = new Date ( )
2026-03-20 12:51:21 -04:00
if ( existingTask . rootSessionID ) {
this . unregisterRootDescendant ( existingTask . rootSessionID )
}
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-02-08 13:05:06 +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-02-01 16:51:11 +09:00
const result : BackgroundTask [ ] = [ ]
for ( const task of this . tasks . values ( ) ) {
if ( task . parentSessionID === sessionID ) {
result . push ( task )
}
}
return result
2025-12-11 15:45:37 +09:00
}
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 )
if ( child . sessionID ) {
const descendants = this . getAllDescendantTasks ( child . sessionID )
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 ( ) ) {
if ( task . sessionID === sessionID ) {
return task
}
}
return undefined
2026-01-18 14:29:46 +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
sessionID : string
parentSessionID : string
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-01-15 00:16:35 -08:00
const parentChanged = input . parentSessionID !== existingTask . parentSessionID
if ( parentChanged ) {
2026-02-01 16:51:11 +09:00
this . cleanupPendingByParent ( existingTask ) // Clean from OLD parent
2026-01-14 22:40:16 -08:00
existingTask . parentSessionID = input . parentSessionID
}
if ( input . parentAgent !== undefined ) {
existingTask . parentAgent = input . parentAgent
}
if ( ! existingTask . concurrencyGroup ) {
existingTask . concurrencyGroup = input . concurrencyKey ? ? existingTask . agent
}
2026-01-19 10:35:47 +09:00
if ( existingTask . sessionID ) {
subagentSessions . add ( existingTask . sessionID )
}
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-02-01 16:51:11 +09:00
const pending = this . pendingByParent . get ( input . parentSessionID ) ? ? new Set ( )
pending . add ( existingTask . id )
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-01-14 23:51:19 -08: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 ,
sessionID : input.sessionID ,
parentSessionID : input.parentSessionID ,
parentMessageID : "" ,
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-02-01 16:51:11 +09:00
this . tasks . set ( task . id , task )
2026-01-09 02:24:43 +09:00
subagentSessions . add ( input . sessionID )
this . startPolling ( )
2026-02-13 17:40:44 +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-01-18 14:39:11 +09:00
if ( input . parentSessionID ) {
2026-02-01 16:51:11 +09:00
const pending = this . pendingByParent . get ( input . parentSessionID ) ? ? new Set ( )
pending . add ( task . id )
this . pendingByParent . set ( input . parentSessionID , pending )
2026-01-18 14:39:11 +09:00
}
2026-01-10 13:00:25 +08:00
2026-01-09 02:24:43 +09:00
log ( "[background-agent] Registered external task:" , { taskId : task.id , sessionID : input.sessionID } )
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-02-01 16:51:11 +09:00
if ( ! existingTask . sessionID ) {
throw new Error ( ` Task has no sessionID: ${ existingTask . id } ` )
}
if ( existingTask . status === "running" ) {
log ( "[background-agent] Resume skipped - task already running:" , {
taskId : existingTask.id ,
sessionID : existingTask.sessionID ,
} )
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
existingTask . parentSessionID = input . parentSessionID
existingTask . parentMessageID = input . parentMessageID
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-01-19 10:35:47 +09:00
if ( existingTask . sessionID ) {
subagentSessions . add ( existingTask . sessionID )
}
2026-01-09 02:24:43 +09:00
2026-01-18 14:39:11 +09:00
if ( input . parentSessionID ) {
2026-02-01 16:51:11 +09:00
const pending = this . pendingByParent . get ( input . parentSessionID ) ? ? new Set ( )
pending . add ( existingTask . id )
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 ,
} )
}
log ( "[background-agent] Resuming task:" , { taskId : existingTask.id , sessionID : existingTask.sessionID } )
log ( "[background-agent] Resuming task - calling prompt (fire-and-forget) with:" , {
sessionID : existingTask.sessionID ,
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 ) {
applySessionPromptParams ( existingTask . sessionID ! , existingTask . model )
}
2026-02-07 13:42:20 +01:00
this . client . session . promptAsync ( {
2026-02-01 16:51:11 +09:00
path : { id : existingTask.sessionID } ,
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
}
setSessionTools ( existingTask . sessionID ! , tools )
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-02-09 18:25:54 +09:00
existingTask . status = "interrupt"
2026-02-01 16:51:11 +09:00
const errorMessage = error instanceof Error ? error.message : String ( error )
existingTask . error = errorMessage
existingTask . completedAt = new Date ( )
2026-03-20 12:51:21 -04:00
if ( existingTask . rootSessionID ) {
this . unregisterRootDescendant ( existingTask . rootSessionID )
}
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-02-04 13:14:18 +09:00
if ( existingTask . sessionID ) {
2026-04-03 21:42:10 +09:00
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-02-08 13:05:06 +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 > {
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-02-01 16:51:11 +09:00
if ( ! todos || todos . length === 0 ) return false
const incomplete = todos . filter (
( t ) = > t . status !== "completed" && t . status !== "cancelled"
)
return incomplete . length > 0
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
}
}
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" ]
if ( typeof sessionID !== "string" || role !== "assistant" ) return
const task = this . findBySession ( sessionID )
if ( ! task || task . status !== "running" ) return
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-02-01 16:51:11 +09:00
const task = this . findBySession ( sessionID )
2025-12-11 15:45:37 +09:00
if ( ! task ) return
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-03-18 14:18:38 +09:00
const circuitBreaker = this . cachedCircuitBreakerSettings ? ? ( this . cachedCircuitBreakerSettings = resolveCircuitBreakerSettings ( this . config ) )
2026-03-17 16:31:18 +09:00
if ( partInfo . tool ) {
2026-03-17 13:40:46 -06:00
task . progress . toolCallWindow = recordToolCall (
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
}
}
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 > ,
findBySession : ( id ) = > this . findBySession ( id ) ,
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
const task = this . findBySession ( sessionID )
if ( ! task || task . status !== "running" ) return
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
2025-12-11 15:45:37 +09:00
2026-02-07 19:10:49 +09:00
const tasksToCancel = new Map < string , BackgroundTask > ( )
const directTask = this . findBySession ( sessionID )
if ( directTask ) {
tasksToCancel . set ( directTask . id , directTask )
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 ( ) ) {
if ( task . sessionID ) {
deletedSessionIDs . add ( task . sessionID )
}
}
2026-02-07 19:10:49 +09:00
for ( const task of tasksToCancel . values ( ) ) {
2026-03-11 20:12:12 +09:00
parentSessionsToClear . add ( task . parentSessionID )
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 ( ( ) = > {
if ( deletedSessionIDs . has ( task . parentSessionID ) ) {
this . pendingNotifications . delete ( task . parentSessionID )
}
2026-02-07 19:10:49 +09:00
} ) . catch ( err = > {
2026-03-11 18:20:20 +09:00
if ( deletedSessionIDs . has ( task . parentSessionID ) ) {
this . pendingNotifications . delete ( task . parentSessionID )
}
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
const task = this . findBySession ( sessionID )
if ( ! task || task . status !== "running" ) return
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
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 ,
} )
task . status = "error"
task . error = errorMsg
task . completedAt = new Date ( )
if ( task . rootSessionID ) {
this . unregisterRootDescendant ( task . rootSessionID )
}
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 } )
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 )
if ( task . sessionID ) {
SessionCategoryRegistry . remove ( task . sessionID )
}
this . markForNotification ( task )
this . enqueueNotificationForParent ( task . parentSessionID , ( ) = > this . notifyParentSession ( task ) ) . catch ( err = > {
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-02-22 12:14:26 +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-02-19 04:41:00 +02:00
} )
2026-04-03 17:12:18 +09:00
return result . then ( ( retried ) = > {
if ( retried && previousSessionID ) {
subagentSessions . delete ( previousSessionID )
}
return retried
} )
2025-12-11 15:45:37 +09:00
}
markForNotification ( task : BackgroundTask ) : void {
2026-02-01 16:51:11 +09:00
const queue = this . notifications . get ( task . parentSessionID ) ? ? [ ]
queue . push ( task )
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 > {
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
}
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 {
if ( ! task . parentSessionID ) return
const pending = this . pendingByParent . get ( task . parentSessionID )
if ( pending ) {
pending . delete ( task . id )
if ( pending . size === 0 ) {
this . pendingByParent . delete ( task . parentSessionID )
}
}
}
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
if ( task . parentSessionID ) {
const siblings = this . getTasksByParentSession ( task . parentSessionID )
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 )
this . tasks . delete ( taskId )
this . clearTaskHistoryWhenParentTasksGone ( task . parentSessionID )
if ( task . sessionID ) {
subagentSessions . delete ( task . sessionID )
SessionCategoryRegistry . remove ( task . sessionID )
}
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-02-01 16:51:11 +09:00
task . status = "cancelled"
task . completedAt = new Date ( )
2026-03-20 12:51:21 -04:00
if ( wasRunning && task . rootSessionID ) {
this . unregisterRootDescendant ( task . rootSessionID )
}
2026-02-03 12:11:45 +09:00
if ( reason ) {
task . error = reason
}
2026-02-13 17:40:44 +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-02-03 12:11:45 +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-04-03 21:42:10 +09:00
await this . abortSessionWithLogging ( task . sessionID , ` task cancellation ( ${ source } ) ` )
2026-02-04 15:25:41 +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-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-02-08 13:05:06 +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
task . status = "completed"
task . completedAt = new Date ( )
2026-02-13 17:40:44 +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-03-20 12:51:21 -04:00
if ( task . rootSessionID ) {
this . unregisterRootDescendant ( task . rootSessionID )
}
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-02-01 16:51:11 +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-04-03 21:42:10 +09:00
await this . abortSessionWithLogging ( task . sessionID , ` task completion ( ${ source } ) ` )
2026-02-04 15:25:41 +09:00
SessionCategoryRegistry . remove ( task . sessionID )
2026-02-01 16:51:11 +09:00
}
try {
2026-02-08 13:05:06 +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-03-11 20:39:03 +09:00
if ( ! this . completedTaskSummaries . has ( task . parentSessionID ) ) {
this . completedTaskSummaries . set ( task . parentSessionID , [ ] )
}
this . completedTaskSummaries . get ( task . parentSessionID ) ! . push ( {
id : task.id ,
description : task.description ,
2026-03-27 15:48:07 +09:00
status : task.status ,
error : task.error ,
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
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-02-01 16:51:11 +09:00
this . pendingByParent . delete ( task . parentSessionID )
}
2026-02-08 13:05:06 +09:00
} else {
2026-03-11 18:20:20 +09:00
remainingCount = Array . from ( this . tasks . values ( ) )
. filter ( t = > t . parentSessionID === task . parentSessionID && t . id !== task . id && ( t . status === "running" || t . status === "pending" ) )
. length
allComplete = remainingCount === 0
2026-02-01 16:51:11 +09:00
}
2026-02-16 00:58:33 +02:00
const completedTasks = allComplete
2026-03-27 15:48:07 +09:00
? ( this . completedTaskSummaries . get ( task . parentSessionID ) ? ? [ { id : task.id , description : task.description , status : task.status , error : task.error } ] )
2026-02-16 00:58:33 +02:00
: [ ]
2026-03-11 20:39:03 +09:00
if ( allComplete ) {
this . completedTaskSummaries . delete ( task . parentSessionID )
}
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-02-16 00:58:33 +02:00
2026-02-17 01:36:52 +09:00
if ( this . enableParentSessionNotifications ) {
try {
const messagesResp = await this . client . session . messages ( { path : { id : task.parentSessionID } } )
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-03-08 02:23:33 +09:00
const promptContext = resolvePromptContextFromSessionMessages (
messages ,
task . parentSessionID ,
)
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 ,
parentSessionID : task.parentSessionID ,
} )
}
2026-02-22 12:14:26 +09:00
const messageDir = join ( MESSAGE_STORAGE , task . parentSessionID )
2026-03-08 02:23:33 +09:00
const currentMessage = messageDir
? findNearestMessageExcludingCompaction ( messageDir , task . parentSessionID )
: 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-02-19 04:41:00 +02: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-02-17 01:36:52 +09:00
try {
await this . client . session . promptAsync ( {
path : { id : task.parentSessionID } ,
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-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 ,
parentSessionID : task.parentSessionID ,
} )
2026-02-25 16:26:31 +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 ,
parentSessionID : task.parentSessionID ,
} )
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-03-20 12:51:21 -04:00
if ( ! wasPending && task . rootSessionID ) {
this . unregisterRootDescendant ( task . rootSessionID )
}
2026-03-11 18:20:20 +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-03-11 18:20:20 +09:00
this . markForNotification ( task )
this . enqueueNotificationForParent ( task . parentSessionID , ( ) = > this . notifyParentSession ( task ) ) . catch ( err = > {
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 (
allStatuses : Record < string , { type : string } > = { } ,
) : Promise < void > {
2026-02-22 11:58:57 +09:00
await checkAndInterruptStaleTasks ( {
tasks : this.tasks.values ( ) ,
client : this.client ,
config : this.config ,
concurrencyManager : this.concurrencyManager ,
notifyParentSession : ( task ) = > this . enqueueNotificationForParent ( task . parentSessionID , ( ) = > this . notifyParentSession ( task ) ) ,
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-03-31 17:04:07 -07:00
return verifySessionStillExists ( this . client , sessionID )
2026-03-27 15:43:01 +09:00
}
private async failCrashedTask ( task : BackgroundTask , errorMessage : string ) : Promise < void > {
task . status = "error"
task . error = errorMessage
task . completedAt = new Date ( )
if ( task . rootSessionID ) {
this . unregisterRootDescendant ( task . rootSessionID )
}
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 } )
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 )
if ( task . sessionID ) {
SessionCategoryRegistry . remove ( task . sessionID )
}
this . markForNotification ( task )
this . enqueueNotificationForParent ( task . parentSessionID , ( ) = > this . notifyParentSession ( task ) ) . catch ( err = > {
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-01-02 22:25:49 +09:00
this . pruneStaleTasksAndNotifications ( )
2025-12-11 17:38:01 +09:00
const statusResult = await this . client . session . status ( )
2026-02-16 18:20:19 +09:00
const allStatuses = normalizeSDKResponse ( statusResult , { } as Record < string , { type : string } > )
2025-12-11 17:38:01 +09:00
2026-02-14 17:59:01 +09:00
await this . checkAndInterruptStaleTasks ( allStatuses )
2026-02-01 16:51:11 +09:00
for ( const task of this . tasks . values ( ) ) {
2025-12-11 17:12:45 +09:00
if ( task . status !== "running" ) continue
2026-01-19 10:35:47 +09:00
const sessionID = task . sessionID
if ( ! sessionID ) continue
2025-12-11 17:12:45 +09:00
2026-01-14 15:09:32 -08:00
try {
2026-01-19 10:35:47 +09:00
const sessionStatus = allStatuses [ sessionID ]
2026-03-08 23:42:11 +09:00
// Handle retry before checking running state
2026-02-19 04:41:00 +02:00
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 }
2026-04-03 17:12:18 +09:00
if ( await this . tryFallbackRetry ( task , errorInfo , "polling:session.status" ) ) {
2026-02-19 04:41:00 +02:00
continue
}
}
2026-03-18 13:56:11 +09:00
// Only skip completion when session status is actively running.
// Unknown or terminal statuses (like "interrupted") fall through to completion.
if ( sessionStatus && isActiveSessionStatus ( sessionStatus . type ) ) {
2026-03-08 23:42:11 +09:00
log ( "[background-agent] Session still running, relying on event-based progress:" , {
taskId : task.id ,
sessionID ,
sessionStatus : sessionStatus.type ,
toolCalls : task.progress?.toolCalls ? ? 0 ,
} )
continue
}
2026-03-18 13:56:11 +09:00
if ( sessionStatus && isTerminalSessionStatus ( sessionStatus . type ) ) {
await this . tryCompleteTask ( task , ` polling (terminal session status: ${ sessionStatus . type } ) ` )
continue
}
if ( sessionStatus && sessionStatus . type !== "idle" ) {
log ( "[background-agent] Unknown session status, treating as potentially idle:" , {
taskId : task.id ,
sessionID ,
sessionStatus : sessionStatus.type ,
} )
}
2026-03-08 23:42:11 +09:00
// Session is idle or no longer in status response (completed/disappeared)
2026-03-27 15:43:01 +09:00
const sessionGoneFromStatus = ! sessionStatus
2026-03-31 17:04:07 -07:00
const sessionGoneThresholdReached = sessionGoneFromStatus
&& ( task . consecutiveMissedPolls ? ? 0 ) >= MIN_SESSION_GONE_POLLS
2026-03-08 23:42:11 +09:00
const completionSource = sessionStatus ? . type === "idle"
? "polling (idle status)"
: "polling (session gone from status)"
const hasValidOutput = await this . validateSessionHasOutput ( sessionID )
if ( ! hasValidOutput ) {
2026-03-31 17:04:07 -07:00
if ( sessionGoneThresholdReached ) {
2026-03-27 15:43:01 +09:00
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-31 17:04:07 -07:00
task . consecutiveMissedPolls = 0
2026-03-27 15:43:01 +09:00
}
2026-03-08 23:42:11 +09:00
log ( "[background-agent] Polling idle/gone but no valid output yet, waiting:" , task . id )
continue
}
// Re-check status after async operation
if ( task . status !== "running" ) continue
const hasIncompleteTodos = await this . checkSessionTodos ( sessionID )
if ( hasIncompleteTodos ) {
log ( "[background-agent] Task has incomplete todos via polling, waiting:" , task . id )
continue
}
await this . tryCompleteTask ( task , completionSource )
2025-12-11 17:42:33 +09:00
} catch ( error ) {
log ( "[background-agent] Poll error for task:" , { taskId : task.id , error } )
2025-12-11 17:12:45 +09:00
}
}
2026-02-01 16:51:11 +09:00
if ( ! this . hasRunningTasks ( ) ) {
2025-12-11 17:12:45 +09:00
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-03-13 10:56:44 +09:00
if ( task . sessionID ) {
trackedSessionIDs . add ( task . sessionID )
}
2026-01-29 18:29:47 +09:00
if ( task . status === "running" && task . sessionID ) {
2026-04-03 17:12:18 +09:00
abortRequests . push ( {
sessionID : task.sessionID ,
promise : this.client.session.abort ( {
path : { id : task.sessionID } ,
} ) ,
} )
}
}
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 ( )
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
}