2026-02-08 16:20:52 +09:00
import { log } from "../../shared"
2026-04-10 11:16:16 +09:00
import { CONFIG_BASENAME } from "../../shared/plugin-identity"
2026-02-08 16:20:52 +09:00
import type { BackgroundTaskConfig } from "../../config/schema"
import type { BackgroundTask } from "./types"
import type { ConcurrencyManager } from "./concurrency"
import type { OpencodeClient } from "./opencode-client"
import {
2026-02-14 14:56:51 +09:00
DEFAULT_MESSAGE_STALENESS_TIMEOUT_MS ,
2026-03-27 15:43:01 +09:00
DEFAULT_SESSION_GONE_TIMEOUT_MS ,
2026-02-08 16:20:52 +09:00
DEFAULT_STALE_TIMEOUT_MS ,
MIN_RUNTIME_BEFORE_STALE_MS ,
2026-03-17 15:17:34 +09:00
TERMINAL_TASK_TTL_MS ,
2026-02-08 16:20:52 +09:00
TASK_TTL_MS ,
} from "./constants"
2026-04-04 01:19:52 +09:00
import { abortWithTimeout } from "./abort-with-timeout"
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 } from "./session-existence"
2026-02-08 16:20:52 +09:00
2026-03-18 13:56:11 +09:00
import { isActiveSessionStatus } from "./session-status-classifier"
2026-03-11 18:20:13 +09:00
const TERMINAL_TASK_STATUSES = new Set < BackgroundTask [ "status" ] > ( [
"completed" ,
"error" ,
"cancelled" ,
"interrupt" ,
] )
2026-02-08 16:20:52 +09:00
export function pruneStaleTasksAndNotifications ( args : {
tasks : Map < string , BackgroundTask >
notifications : Map < string , BackgroundTask [ ] >
onTaskPruned : ( taskId : string , task : BackgroundTask , errorMessage : string ) = > void
2026-03-27 16:06:38 +09:00
taskTtlMs? : number
2026-02-08 16:20:52 +09:00
} ) : void {
const { tasks , notifications , onTaskPruned } = args
2026-03-27 16:06:38 +09:00
const effectiveTtl = args . taskTtlMs ? ? TASK_TTL_MS
2026-02-08 16:20:52 +09:00
const now = Date . now ( )
2026-03-11 21:52:48 +09:00
const tasksWithPendingNotifications = new Set < string > ( )
for ( const queued of notifications . values ( ) ) {
for ( const task of queued ) {
tasksWithPendingNotifications . add ( task . id )
}
}
2026-02-08 16:20:52 +09:00
for ( const [ taskId , task ] of tasks . entries ( ) ) {
2026-03-11 21:52:48 +09:00
if ( TERMINAL_TASK_STATUSES . has ( task . status ) ) {
if ( tasksWithPendingNotifications . has ( taskId ) ) continue
const completedAt = task . completedAt ? . getTime ( )
if ( ! completedAt ) continue
const age = now - completedAt
if ( age <= TERMINAL_TASK_TTL_MS ) continue
removeTaskToastTracking ( taskId )
tasks . delete ( taskId )
continue
}
2026-03-11 18:20:13 +09:00
2026-04-28 10:48:08 +09:00
if ( task . teamRunId ) {
continue
}
2026-03-27 16:06:38 +09:00
const lastActivity = task . status === "running" && task . progress ? . lastUpdate
? task . progress . lastUpdate . getTime ( )
: undefined
2026-02-08 16:20:52 +09:00
const timestamp = task . status === "pending"
? task . queuedAt ? . getTime ( )
2026-03-27 16:06:38 +09:00
: ( lastActivity ? ? task . startedAt ? . getTime ( ) )
2026-02-08 16:20:52 +09:00
if ( ! timestamp ) continue
const age = now - timestamp
2026-03-27 16:06:38 +09:00
if ( age <= effectiveTtl ) continue
2026-02-08 16:20:52 +09:00
2026-03-27 16:06:38 +09:00
const ttlMinutes = Math . round ( effectiveTtl / 60000 )
2026-02-08 16:20:52 +09:00
const errorMessage = task . status === "pending"
2026-03-27 16:06:38 +09:00
? ` Task timed out while queued ( ${ ttlMinutes } minutes) `
: ` Task timed out after ${ ttlMinutes } minutes of inactivity `
2026-02-08 16:20:52 +09:00
onTaskPruned ( taskId , task , errorMessage )
}
for ( const [ sessionID , queued ] of notifications . entries ( ) ) {
if ( queued . length === 0 ) {
notifications . delete ( sessionID )
continue
}
const validNotifications = queued . filter ( ( task ) = > {
if ( ! task . startedAt ) return false
const age = now - task . startedAt . getTime ( )
2026-03-27 16:06:38 +09:00
return age <= effectiveTtl
2026-02-08 16:20:52 +09:00
} )
if ( validNotifications . length === 0 ) {
notifications . delete ( sessionID )
} else if ( validNotifications . length !== queued . length ) {
notifications . set ( sessionID , validNotifications )
}
}
}
2026-02-14 17:59:01 +09:00
export type SessionStatusMap = Record < string , { type : string } >
2026-02-08 16:20:52 +09:00
export async function checkAndInterruptStaleTasks ( args : {
tasks : Iterable < BackgroundTask >
client : OpencodeClient
2026-04-12 02:30:01 +09:00
directory? : string
2026-02-08 16:20:52 +09:00
config : BackgroundTaskConfig | undefined
concurrencyManager : ConcurrencyManager
notifyParentSession : ( task : BackgroundTask ) = > Promise < void >
2026-02-14 17:59:01 +09:00
sessionStatuses? : SessionStatusMap
2026-03-09 11:39:04 +09:00
onTaskInterrupted ? : ( task : BackgroundTask ) = > void
2026-02-08 16:20:52 +09:00
} ) : Promise < void > {
2026-03-09 11:39:04 +09:00
const {
tasks ,
client ,
2026-04-12 02:30:01 +09:00
directory ,
2026-03-09 11:39:04 +09:00
config ,
concurrencyManager ,
notifyParentSession ,
sessionStatuses ,
onTaskInterrupted = ( task ) = > removeTaskToastTracking ( task . id ) ,
} = args
2026-02-08 16:20:52 +09:00
const staleTimeoutMs = config ? . staleTimeoutMs ? ? DEFAULT_STALE_TIMEOUT_MS
2026-03-27 15:43:01 +09:00
const sessionGoneTimeoutMs = config ? . sessionGoneTimeoutMs ? ? DEFAULT_SESSION_GONE_TIMEOUT_MS
2026-02-08 16:20:52 +09:00
const now = Date . now ( )
2026-04-03 17:12:18 +09:00
const abortPromises : Array < Promise < unknown > > = [ ]
2026-02-08 16:20:52 +09:00
2026-02-14 14:56:51 +09:00
const messageStalenessMs = config ? . messageStalenessTimeoutMs ? ? DEFAULT_MESSAGE_STALENESS_TIMEOUT_MS
2026-02-08 16:20:52 +09:00
for ( const task of tasks ) {
if ( task . status !== "running" ) continue
const startedAt = task . startedAt
2026-05-02 03:01:03 +09:00
const sessionID = task . sessionId
2026-02-08 16:20:52 +09:00
if ( ! startedAt || ! sessionID ) continue
2026-02-15 14:24:45 +09:00
const sessionStatus = sessionStatuses ? . [ sessionID ] ? . type
2026-03-18 13:56:11 +09:00
const sessionIsRunning = sessionStatus !== undefined && isActiveSessionStatus ( sessionStatus )
2026-03-28 15:24:18 +09:00
const sessionMissing = sessionStatuses !== undefined && sessionStatus === undefined
2026-02-08 16:20:52 +09:00
const runtime = now - startedAt . getTime ( )
2026-02-14 14:56:51 +09:00
2026-03-28 15:24:18 +09:00
if ( sessionMissing ) {
task . consecutiveMissedPolls = ( task . consecutiveMissedPolls ? ? 0 ) + 1
} else if ( sessionStatuses !== undefined ) {
task . consecutiveMissedPolls = 0
}
const sessionGone = sessionMissing && ( task . consecutiveMissedPolls ? ? 0 ) >= MIN_SESSION_GONE_POLLS
2026-04-28 10:48:08 +09:00
const shouldSkipInactivityTimeout = task . teamRunId !== undefined && ! sessionGone
2026-03-28 15:24:18 +09:00
2026-02-14 14:56:51 +09:00
if ( ! task . progress ? . lastUpdate ) {
2026-04-28 10:48:08 +09:00
if ( shouldSkipInactivityTimeout ) continue
2026-02-14 17:59:01 +09:00
if ( sessionIsRunning ) continue
2026-03-28 15:24:18 +09:00
if ( sessionMissing && ! sessionGone ) continue
2026-03-27 15:43:01 +09:00
const effectiveTimeout = sessionGone ? sessionGoneTimeoutMs : messageStalenessMs
if ( runtime <= effectiveTimeout ) continue
2026-02-14 14:56:51 +09:00
2026-04-12 02:30:01 +09:00
if ( sessionGone && await verifySessionExists ( client , sessionID , directory ) ) {
2026-03-28 15:24:18 +09:00
task . consecutiveMissedPolls = 0
continue
}
2026-02-14 14:56:51 +09:00
const staleMinutes = Math . round ( runtime / 60000 )
2026-03-27 15:43:01 +09:00
const reason = sessionGone ? "session gone from status registry" : "no activity"
2026-02-14 14:56:51 +09:00
task . status = "cancelled"
2026-04-10 11:16:16 +09:00
task . error = ` Stale timeout ( ${ reason } for ${ staleMinutes } min since start). This is a FINAL cancellation - do NOT create a replacement task. If the timeout is too short, increase 'background_task. ${ sessionGone ? "sessionGoneTimeoutMs" : "staleTimeoutMs" } ' in .opencode/ ${ CONFIG_BASENAME } .json. `
2026-02-14 14:56:51 +09:00
task . completedAt = new Date ( )
if ( task . concurrencyKey ) {
concurrencyManager . release ( task . concurrencyKey )
task . concurrencyKey = undefined
}
2026-03-09 11:39:04 +09:00
onTaskInterrupted ( task )
2026-04-04 01:19:52 +09:00
abortPromises . push ( abortWithTimeout ( client , sessionID ) )
2026-02-14 14:56:51 +09:00
log ( ` [background-agent] Task ${ task . id } interrupted: no progress since start ` )
try {
await notifyParentSession ( task )
} catch ( err ) {
log ( "[background-agent] Error in notifyParentSession for stale task:" , { taskId : task.id , error : err } )
}
continue
}
2026-02-14 17:59:01 +09:00
if ( sessionIsRunning ) continue
2026-04-28 10:48:08 +09:00
if ( shouldSkipInactivityTimeout ) continue
2026-02-14 17:59:01 +09:00
2026-02-08 16:20:52 +09:00
if ( runtime < MIN_RUNTIME_BEFORE_STALE_MS ) continue
const timeSinceLastUpdate = now - task . progress . lastUpdate . getTime ( )
2026-03-27 15:43:01 +09:00
const effectiveStaleTimeout = sessionGone ? sessionGoneTimeoutMs : staleTimeoutMs
if ( timeSinceLastUpdate <= effectiveStaleTimeout ) continue
2026-02-08 16:20:52 +09:00
if ( task . status !== "running" ) continue
2026-04-12 02:30:01 +09:00
if ( sessionGone && await verifySessionExists ( client , sessionID , directory ) ) {
2026-03-28 15:24:18 +09:00
task . consecutiveMissedPolls = 0
continue
}
const staleMinutes = Math . round ( timeSinceLastUpdate / 60000 )
const reason = sessionGone ? "session gone from status registry" : "no activity"
task . status = "cancelled"
2026-04-10 11:16:16 +09:00
task . error = ` Stale timeout ( ${ reason } for ${ staleMinutes } min). This is a FINAL cancellation - do NOT create a replacement task. If the timeout is too short, increase 'background_task. ${ sessionGone ? "sessionGoneTimeoutMs" : "staleTimeoutMs" } ' in .opencode/ ${ CONFIG_BASENAME } .json. `
2026-03-28 15:24:18 +09:00
task . completedAt = new Date ( )
2026-02-08 16:20:52 +09:00
if ( task . concurrencyKey ) {
concurrencyManager . release ( task . concurrencyKey )
task . concurrencyKey = undefined
}
2026-03-09 11:39:04 +09:00
onTaskInterrupted ( task )
2026-04-04 01:19:52 +09:00
abortPromises . push ( abortWithTimeout ( client , sessionID ) )
2026-02-08 16:20:52 +09:00
log ( ` [background-agent] Task ${ task . id } interrupted: stale timeout ` )
try {
await notifyParentSession ( task )
} catch ( err ) {
log ( "[background-agent] Error in notifyParentSession for stale task:" , { taskId : task.id , error : err } )
}
}
2026-04-03 17:12:18 +09:00
if ( abortPromises . length > 0 ) {
await Promise . allSettled ( abortPromises )
}
2026-02-08 16:20:52 +09:00
}