From e3bd43ff643b813c786816409e575cf75e7044bb Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Feb 2026 16:20:52 +0900 Subject: [PATCH] refactor(background-agent): split manager.ts into focused modules Extract 30+ single-responsibility modules from manager.ts (1556 LOC): - task lifecycle: task-starter, task-completer, task-canceller, task-resumer - task queries: task-queries, task-poller, task-queue-processor - notifications: notification-builder, notification-tracker, parent-session-notifier - session handling: session-validator, session-output-validator, session-todo-checker - spawner: spawner/ directory with focused spawn modules - utilities: duration-formatter, error-classifier, message-storage-locator - result handling: result-handler-context, background-task-completer - shutdown: background-manager-shutdown, process-signal --- .../background-event-handler.ts | 199 ++ .../background-manager-shutdown.ts | 82 + .../background-task-completer.ts | 40 + .../background-task-notification-template.ts | 46 + .../background-agent/duration-formatter.ts | 14 + .../background-agent/error-classifier.ts | 21 + .../background-agent/format-duration.ts | 14 + src/features/background-agent/manager.ts | 1644 +---------------- src/features/background-agent/message-dir.ts | 18 + .../message-storage-locator.ts | 17 + .../background-agent/notification-builder.ts | 40 + .../background-agent/notification-tracker.ts | 52 + .../background-agent/notify-parent-session.ts | 192 ++ .../background-agent/opencode-client.ts | 3 + .../parent-session-context-resolver.ts | 75 + .../parent-session-notifier.ts | 102 + .../background-agent/poll-running-tasks.ts | 178 ++ .../background-agent/process-signal.ts | 19 + .../result-handler-context.ts | 9 + .../background-agent/result-handler.ts | 283 +-- .../session-output-validator.ts | 88 + .../background-agent/session-todo-checker.ts | 33 + .../background-agent/session-validator.ts | 111 ++ src/features/background-agent/spawner.ts | 246 +-- .../spawner/background-session-creator.ts | 45 + .../concurrency-key-from-launch-input.ts | 7 + .../spawner/parent-directory-resolver.ts | 21 + .../spawner/spawner-context.ts | 12 + .../background-agent/spawner/task-factory.ts | 18 + .../background-agent/spawner/task-resumer.ts | 91 + .../background-agent/spawner/task-starter.ts | 94 + .../spawner/tmux-callback-invoker.ts | 40 + .../background-agent/stale-task-pruner.ts | 57 + src/features/background-agent/state.ts | 5 - .../background-agent/task-canceller.ts | 117 ++ .../background-agent/task-completer.ts | 68 + src/features/background-agent/task-launch.ts | 77 + src/features/background-agent/task-poller.ts | 107 ++ src/features/background-agent/task-queries.ts | 56 + .../background-agent/task-queue-processor.ts | 52 + src/features/background-agent/task-resumer.ts | 144 ++ src/features/background-agent/task-starter.ts | 190 ++ src/features/background-agent/task-tracker.ts | 97 + 43 files changed, 2753 insertions(+), 2071 deletions(-) create mode 100644 src/features/background-agent/background-event-handler.ts create mode 100644 src/features/background-agent/background-manager-shutdown.ts create mode 100644 src/features/background-agent/background-task-completer.ts create mode 100644 src/features/background-agent/background-task-notification-template.ts create mode 100644 src/features/background-agent/duration-formatter.ts create mode 100644 src/features/background-agent/error-classifier.ts create mode 100644 src/features/background-agent/format-duration.ts create mode 100644 src/features/background-agent/message-dir.ts create mode 100644 src/features/background-agent/message-storage-locator.ts create mode 100644 src/features/background-agent/notification-builder.ts create mode 100644 src/features/background-agent/notification-tracker.ts create mode 100644 src/features/background-agent/notify-parent-session.ts create mode 100644 src/features/background-agent/opencode-client.ts create mode 100644 src/features/background-agent/parent-session-context-resolver.ts create mode 100644 src/features/background-agent/parent-session-notifier.ts create mode 100644 src/features/background-agent/poll-running-tasks.ts create mode 100644 src/features/background-agent/process-signal.ts create mode 100644 src/features/background-agent/result-handler-context.ts create mode 100644 src/features/background-agent/session-output-validator.ts create mode 100644 src/features/background-agent/session-todo-checker.ts create mode 100644 src/features/background-agent/session-validator.ts create mode 100644 src/features/background-agent/spawner/background-session-creator.ts create mode 100644 src/features/background-agent/spawner/concurrency-key-from-launch-input.ts create mode 100644 src/features/background-agent/spawner/parent-directory-resolver.ts create mode 100644 src/features/background-agent/spawner/spawner-context.ts create mode 100644 src/features/background-agent/spawner/task-factory.ts create mode 100644 src/features/background-agent/spawner/task-resumer.ts create mode 100644 src/features/background-agent/spawner/task-starter.ts create mode 100644 src/features/background-agent/spawner/tmux-callback-invoker.ts create mode 100644 src/features/background-agent/stale-task-pruner.ts create mode 100644 src/features/background-agent/task-canceller.ts create mode 100644 src/features/background-agent/task-completer.ts create mode 100644 src/features/background-agent/task-launch.ts create mode 100644 src/features/background-agent/task-poller.ts create mode 100644 src/features/background-agent/task-queries.ts create mode 100644 src/features/background-agent/task-queue-processor.ts create mode 100644 src/features/background-agent/task-resumer.ts create mode 100644 src/features/background-agent/task-starter.ts create mode 100644 src/features/background-agent/task-tracker.ts diff --git a/src/features/background-agent/background-event-handler.ts b/src/features/background-agent/background-event-handler.ts new file mode 100644 index 000000000..3d6e18d9b --- /dev/null +++ b/src/features/background-agent/background-event-handler.ts @@ -0,0 +1,199 @@ +import { log } from "../../shared" +import { MIN_IDLE_TIME_MS } from "./constants" +import { subagentSessions } from "../claude-code-session-state" +import type { BackgroundTask } from "./types" + +type Event = { type: string; properties?: Record } + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null +} + +function getString(obj: Record, key: string): string | undefined { + const value = obj[key] + return typeof value === "string" ? value : undefined +} + +export function handleBackgroundEvent(args: { + event: Event + findBySession: (sessionID: string) => BackgroundTask | undefined + getAllDescendantTasks: (sessionID: string) => BackgroundTask[] + cancelTask: ( + taskId: string, + options: { source: string; reason: string; skipNotification: true } + ) => Promise + tryCompleteTask: (task: BackgroundTask, source: string) => Promise + validateSessionHasOutput: (sessionID: string) => Promise + checkSessionTodos: (sessionID: string) => Promise + idleDeferralTimers: Map> + completionTimers: Map> + tasks: Map + cleanupPendingByParent: (task: BackgroundTask) => void + clearNotificationsForTask: (taskId: string) => void + emitIdleEvent: (sessionID: string) => void +}): void { + const { + event, + findBySession, + getAllDescendantTasks, + cancelTask, + tryCompleteTask, + validateSessionHasOutput, + checkSessionTodos, + idleDeferralTimers, + completionTimers, + tasks, + cleanupPendingByParent, + clearNotificationsForTask, + emitIdleEvent, + } = args + + const props = event.properties + + if (event.type === "message.part.updated") { + if (!props || !isRecord(props)) return + const sessionID = getString(props, "sessionID") + if (!sessionID) return + + const task = findBySession(sessionID) + if (!task) return + + const existingTimer = idleDeferralTimers.get(task.id) + if (existingTimer) { + clearTimeout(existingTimer) + idleDeferralTimers.delete(task.id) + } + + const type = getString(props, "type") + const tool = getString(props, "tool") + + if (type === "tool" || tool) { + if (!task.progress) { + task.progress = { toolCalls: 0, lastUpdate: new Date() } + } + task.progress.toolCalls += 1 + task.progress.lastTool = tool + task.progress.lastUpdate = new Date() + } + } + + if (event.type === "session.idle") { + if (!props || !isRecord(props)) return + const sessionID = getString(props, "sessionID") + if (!sessionID) return + + const task = findBySession(sessionID) + if (!task || task.status !== "running") return + + const startedAt = task.startedAt + if (!startedAt) return + + const elapsedMs = Date.now() - startedAt.getTime() + if (elapsedMs < MIN_IDLE_TIME_MS) { + const remainingMs = MIN_IDLE_TIME_MS - elapsedMs + if (!idleDeferralTimers.has(task.id)) { + log("[background-agent] Deferring early session.idle:", { + elapsedMs, + remainingMs, + taskId: task.id, + }) + const timer = setTimeout(() => { + idleDeferralTimers.delete(task.id) + emitIdleEvent(sessionID) + }, remainingMs) + idleDeferralTimers.set(task.id, timer) + } else { + log("[background-agent] session.idle already deferred:", { elapsedMs, taskId: task.id }) + } + return + } + + validateSessionHasOutput(sessionID) + .then(async (hasValidOutput) => { + if (task.status !== "running") { + log("[background-agent] Task status changed during validation, skipping:", { + taskId: task.id, + status: task.status, + }) + return + } + + if (!hasValidOutput) { + log("[background-agent] Session.idle but no valid output yet, waiting:", task.id) + return + } + + const hasIncompleteTodos = await checkSessionTodos(sessionID) + + if (task.status !== "running") { + log("[background-agent] Task status changed during todo check, skipping:", { + taskId: task.id, + status: task.status, + }) + return + } + + if (hasIncompleteTodos) { + log("[background-agent] Task has incomplete todos, waiting for todo-continuation:", task.id) + return + } + + await tryCompleteTask(task, "session.idle event") + }) + .catch((err) => { + log("[background-agent] Error in session.idle handler:", err) + }) + } + + if (event.type === "session.deleted") { + if (!props || !isRecord(props)) return + const infoRaw = props["info"] + if (!isRecord(infoRaw)) return + const sessionID = getString(infoRaw, "id") + if (!sessionID) return + + const tasksToCancel = new Map() + const directTask = findBySession(sessionID) + if (directTask) { + tasksToCancel.set(directTask.id, directTask) + } + for (const descendant of getAllDescendantTasks(sessionID)) { + tasksToCancel.set(descendant.id, descendant) + } + if (tasksToCancel.size === 0) return + + for (const task of tasksToCancel.values()) { + if (task.status === "running" || task.status === "pending") { + void cancelTask(task.id, { + source: "session.deleted", + reason: "Session deleted", + skipNotification: true, + }).catch((err) => { + log("[background-agent] Failed to cancel task on session.deleted:", { + taskId: task.id, + error: err, + }) + }) + } + + const completionTimer = completionTimers.get(task.id) + if (completionTimer) { + clearTimeout(completionTimer) + completionTimers.delete(task.id) + } + + const idleTimer = idleDeferralTimers.get(task.id) + if (idleTimer) { + clearTimeout(idleTimer) + idleDeferralTimers.delete(task.id) + } + + cleanupPendingByParent(task) + tasks.delete(task.id) + clearNotificationsForTask(task.id) + if (task.sessionID) { + subagentSessions.delete(task.sessionID) + } + } + } +} diff --git a/src/features/background-agent/background-manager-shutdown.ts b/src/features/background-agent/background-manager-shutdown.ts new file mode 100644 index 000000000..01abd298f --- /dev/null +++ b/src/features/background-agent/background-manager-shutdown.ts @@ -0,0 +1,82 @@ +import { log } from "../../shared" + +import type { BackgroundTask, LaunchInput } from "./types" +import type { ConcurrencyManager } from "./concurrency" +import type { PluginInput } from "@opencode-ai/plugin" + +type QueueItem = { task: BackgroundTask; input: LaunchInput } + +export function shutdownBackgroundManager(args: { + shutdownTriggered: { value: boolean } + stopPolling: () => void + tasks: Map + client: PluginInput["client"] + onShutdown?: () => void + concurrencyManager: ConcurrencyManager + completionTimers: Map> + idleDeferralTimers: Map> + notifications: Map + pendingByParent: Map> + queuesByKey: Map + processingKeys: Set + unregisterProcessCleanup: () => void +}): void { + const { + shutdownTriggered, + stopPolling, + tasks, + client, + onShutdown, + concurrencyManager, + completionTimers, + idleDeferralTimers, + notifications, + pendingByParent, + queuesByKey, + processingKeys, + unregisterProcessCleanup, + } = args + + if (shutdownTriggered.value) return + shutdownTriggered.value = true + + log("[background-agent] Shutting down BackgroundManager") + stopPolling() + + for (const task of tasks.values()) { + if (task.status === "running" && task.sessionID) { + client.session.abort({ path: { id: task.sessionID } }).catch(() => {}) + } + } + + if (onShutdown) { + try { + onShutdown() + } catch (error) { + log("[background-agent] Error in onShutdown callback:", error) + } + } + + for (const task of tasks.values()) { + if (task.concurrencyKey) { + concurrencyManager.release(task.concurrencyKey) + task.concurrencyKey = undefined + } + } + + for (const timer of completionTimers.values()) clearTimeout(timer) + completionTimers.clear() + + for (const timer of idleDeferralTimers.values()) clearTimeout(timer) + idleDeferralTimers.clear() + + concurrencyManager.clear() + tasks.clear() + notifications.clear() + pendingByParent.clear() + queuesByKey.clear() + processingKeys.clear() + unregisterProcessCleanup() + + log("[background-agent] Shutdown complete") +} diff --git a/src/features/background-agent/background-task-completer.ts b/src/features/background-agent/background-task-completer.ts new file mode 100644 index 000000000..4c105eb04 --- /dev/null +++ b/src/features/background-agent/background-task-completer.ts @@ -0,0 +1,40 @@ +import type { BackgroundTask } from "./types" +import type { ResultHandlerContext } from "./result-handler-context" +import { log } from "../../shared" +import { notifyParentSession } from "./parent-session-notifier" + +export async function tryCompleteTask( + task: BackgroundTask, + source: string, + ctx: ResultHandlerContext +): Promise { + const { concurrencyManager, state } = ctx + + if (task.status !== "running") { + log("[background-agent] Task already completed, skipping:", { + taskId: task.id, + status: task.status, + source, + }) + return false + } + + task.status = "completed" + task.completedAt = new Date() + + if (task.concurrencyKey) { + concurrencyManager.release(task.concurrencyKey) + task.concurrencyKey = undefined + } + + state.markForNotification(task) + + try { + await notifyParentSession(task, ctx) + log(`[background-agent] Task completed via ${source}:`, task.id) + } catch (error) { + log("[background-agent] Error in notifyParentSession:", { taskId: task.id, error }) + } + + return true +} diff --git a/src/features/background-agent/background-task-notification-template.ts b/src/features/background-agent/background-task-notification-template.ts new file mode 100644 index 000000000..c3234decc --- /dev/null +++ b/src/features/background-agent/background-task-notification-template.ts @@ -0,0 +1,46 @@ +import type { BackgroundTask } from "./types" + +export type BackgroundTaskNotificationStatus = "COMPLETED" | "CANCELLED" + +export function buildBackgroundTaskNotificationText(input: { + task: BackgroundTask + duration: string + statusText: BackgroundTaskNotificationStatus + allComplete: boolean + remainingCount: number + completedTasks: BackgroundTask[] +}): string { + const { task, duration, statusText, allComplete, remainingCount, completedTasks } = input + + const errorInfo = task.error ? `\n**Error:** ${task.error}` : "" + + if (allComplete) { + const completedTasksText = completedTasks + .map((t) => `- \`${t.id}\`: ${t.description}`) + .join("\n") + + return ` +[ALL BACKGROUND TASKS COMPLETE] + +**Completed:** +${completedTasksText || `- \`${task.id}\`: ${task.description}`} + +Use \`background_output(task_id="")\` to retrieve each result. +` + } + + const agentInfo = task.category ? `${task.agent} (${task.category})` : task.agent + + return ` +[BACKGROUND TASK ${statusText}] +**ID:** \`${task.id}\` +**Description:** ${task.description} +**Agent:** ${agentInfo} +**Duration:** ${duration}${errorInfo} + +**${remainingCount} task${remainingCount === 1 ? "" : "s"} still in progress.** You WILL be notified when ALL complete. +Do NOT poll - continue productive work. + +Use \`background_output(task_id="${task.id}")\` to retrieve this result when ready. +` +} diff --git a/src/features/background-agent/duration-formatter.ts b/src/features/background-agent/duration-formatter.ts new file mode 100644 index 000000000..65fd8adf2 --- /dev/null +++ b/src/features/background-agent/duration-formatter.ts @@ -0,0 +1,14 @@ +export function formatDuration(start: Date, end?: Date): string { + const duration = (end ?? new Date()).getTime() - start.getTime() + const seconds = Math.floor(duration / 1000) + const minutes = Math.floor(seconds / 60) + const hours = Math.floor(minutes / 60) + + if (hours > 0) { + return `${hours}h ${minutes % 60}m ${seconds % 60}s` + } + if (minutes > 0) { + return `${minutes}m ${seconds % 60}s` + } + return `${seconds}s` +} diff --git a/src/features/background-agent/error-classifier.ts b/src/features/background-agent/error-classifier.ts new file mode 100644 index 000000000..8be1dd3d2 --- /dev/null +++ b/src/features/background-agent/error-classifier.ts @@ -0,0 +1,21 @@ +export function isAbortedSessionError(error: unknown): boolean { + const message = getErrorText(error) + return message.toLowerCase().includes("aborted") +} + +export function getErrorText(error: unknown): string { + if (!error) return "" + if (typeof error === "string") return error + if (error instanceof Error) { + return `${error.name}: ${error.message}` + } + if (typeof error === "object" && error !== null) { + if ("message" in error && typeof error.message === "string") { + return error.message + } + if ("name" in error && typeof error.name === "string") { + return error.name + } + } + return "" +} diff --git a/src/features/background-agent/format-duration.ts b/src/features/background-agent/format-duration.ts new file mode 100644 index 000000000..65fd8adf2 --- /dev/null +++ b/src/features/background-agent/format-duration.ts @@ -0,0 +1,14 @@ +export function formatDuration(start: Date, end?: Date): string { + const duration = (end ?? new Date()).getTime() - start.getTime() + const seconds = Math.floor(duration / 1000) + const minutes = Math.floor(seconds / 60) + const hours = Math.floor(minutes / 60) + + if (hours > 0) { + return `${hours}h ${minutes % 60}m ${seconds % 60}s` + } + if (minutes > 0) { + return `${minutes}m ${seconds % 60}s` + } + return `${seconds}s` +} diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index e631f30a0..808221af2 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -1,70 +1,32 @@ - import type { PluginInput } from "@opencode-ai/plugin" -import type { - BackgroundTask, - LaunchInput, - ResumeInput, -} from "./types" -import { log, getAgentToolRestrictions, promptWithModelSuggestionRetry } from "../../shared" -import { ConcurrencyManager } from "./concurrency" +import type { BackgroundTask, LaunchInput, ResumeInput } from "./types" import type { BackgroundTaskConfig, TmuxConfig } from "../../config/schema" -import { isInsideTmux } from "../../shared/tmux" -import { - DEFAULT_STALE_TIMEOUT_MS, - MIN_IDLE_TIME_MS, - MIN_RUNTIME_BEFORE_STALE_MS, - MIN_STABILITY_TIME_MS, - POLLING_INTERVAL_MS, - TASK_CLEANUP_DELAY_MS, - TASK_TTL_MS, -} from "./constants" -import { subagentSessions } from "../claude-code-session-state" -import { getTaskToastManager } from "../task-toast-manager" -import { findNearestMessageWithFields, MESSAGE_STORAGE } from "../hook-message-injector" -import { existsSync, readdirSync } from "node:fs" -import { join } from "node:path" +import { log } from "../../shared" +import { ConcurrencyManager } from "./concurrency" +import { POLLING_INTERVAL_MS } from "./constants" -type ProcessCleanupEvent = NodeJS.Signals | "beforeExit" | "exit" +import { handleBackgroundEvent } from "./background-event-handler" +import { shutdownBackgroundManager } from "./background-manager-shutdown" +import { clearNotifications, clearNotificationsForTask, cleanupPendingByParent, getPendingNotifications, markForNotification } from "./notification-tracker" +import { notifyParentSession as notifyParentSessionInternal } from "./notify-parent-session" +import { pollRunningTasks } from "./poll-running-tasks" +import { registerProcessSignal, type ProcessCleanupEvent } from "./process-signal" +import { validateSessionHasOutput, checkSessionTodos } from "./session-validator" +import { pruneStaleState } from "./stale-task-pruner" +import { getAllDescendantTasks, getCompletedTasks, getRunningTasks, getTasksByParentSession, hasRunningTasks, findTaskBySession } from "./task-queries" +import { checkAndInterruptStaleTasks } from "./task-poller" +import { cancelBackgroundTask } from "./task-canceller" +import { tryCompleteBackgroundTask } from "./task-completer" +import { launchBackgroundTask } from "./task-launch" +import { processConcurrencyKeyQueue } from "./task-queue-processor" +import { resumeBackgroundTask } from "./task-resumer" +import { startQueuedTask } from "./task-starter" +import { trackExternalTask } from "./task-tracker" -type OpencodeClient = PluginInput["client"] - - -interface MessagePartInfo { - sessionID?: string - type?: string - tool?: string -} - -interface EventProperties { - sessionID?: string - info?: { id?: string } - [key: string]: unknown -} - -interface Event { - type: string - properties?: EventProperties -} - -interface Todo { - content: string - status: string - priority: string - id: string -} - -interface QueueItem { - task: BackgroundTask - input: LaunchInput -} - -export interface SubagentSessionCreatedEvent { - sessionID: string - parentID: string - title: string -} +type QueueItem = { task: BackgroundTask; input: LaunchInput } +export interface SubagentSessionCreatedEvent { sessionID: string; parentID: string; title: string } export type OnSubagentSessionCreated = (event: SubagentSessionCreatedEvent) => Promise export class BackgroundManager { @@ -72,36 +34,25 @@ export class BackgroundManager { private static cleanupRegistered = false private static cleanupHandlers = new Map void>() - private tasks: Map - private notifications: Map - private pendingByParent: Map> // Track pending tasks per parent for batching - private client: OpencodeClient + private tasks = new Map() + private notifications = new Map() + private pendingByParent = new Map>() + private queuesByKey = new Map() + private processingKeys = new Set() + private completionTimers = new Map>() + private idleDeferralTimers = new Map>() + + private client: PluginInput["client"] private directory: string private pollingInterval?: ReturnType private concurrencyManager: ConcurrencyManager - private shutdownTriggered = false + private shutdownTriggered = { value: false } private config?: BackgroundTaskConfig private tmuxEnabled: boolean private onSubagentSessionCreated?: OnSubagentSessionCreated private onShutdown?: () => void - private queuesByKey: Map = new Map() - private processingKeys: Set = new Set() - private completionTimers: Map> = new Map() - private idleDeferralTimers: Map> = new Map() - - constructor( - ctx: PluginInput, - config?: BackgroundTaskConfig, - options?: { - tmuxConfig?: TmuxConfig - onSubagentSessionCreated?: OnSubagentSessionCreated - onShutdown?: () => void - } - ) { - this.tasks = new Map() - this.notifications = new Map() - this.pendingByParent = new Map() + constructor(ctx: PluginInput, config?: BackgroundTaskConfig, options?: { tmuxConfig?: TmuxConfig; onSubagentSessionCreated?: OnSubagentSessionCreated; onShutdown?: () => void }) { this.client = ctx.client this.directory = ctx.directory this.concurrencyManager = new ConcurrencyManager(config) @@ -113,1501 +64,98 @@ export class BackgroundManager { } async launch(input: LaunchInput): Promise { - log("[background-agent] launch() called with:", { - agent: input.agent, - model: input.model, - description: input.description, - parentSessionID: input.parentSessionID, - }) - - if (!input.agent || input.agent.trim() === "") { - throw new Error("Agent parameter is required") - } - - // Create task immediately with status="pending" - const task: BackgroundTask = { - id: `bg_${crypto.randomUUID().slice(0, 8)}`, - status: "pending", - queuedAt: new Date(), - // 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, - parentSessionID: input.parentSessionID, - parentMessageID: input.parentMessageID, - parentModel: input.parentModel, - parentAgent: input.parentAgent, - model: input.model, - category: input.category, - } - - this.tasks.set(task.id, task) - - // 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) - } - - // Add to queue - const key = this.getConcurrencyKeyFromInput(input) - const queue = this.queuesByKey.get(key) ?? [] - queue.push({ task, input }) - this.queuesByKey.set(key, queue) - - log("[background-agent] Task queued:", { taskId: task.id, key, queueLength: queue.length }) - - const toastManager = getTaskToastManager() - if (toastManager) { - toastManager.addTask({ - id: task.id, - description: input.description, - agent: input.agent, - isBackground: true, - status: "queued", - skills: input.skills, - }) - } - - // Trigger processing (fire-and-forget) - this.processKey(key) - - return task + return launchBackgroundTask({ input, tasks: this.tasks, pendingByParent: this.pendingByParent, queuesByKey: this.queuesByKey, getConcurrencyKeyFromInput: (i) => this.getConcurrencyKeyFromInput(i), processKey: (key) => void this.processKey(key) }) } - private async processKey(key: string): Promise { - if (this.processingKeys.has(key)) { - return - } - - this.processingKeys.add(key) - - try { - const queue = this.queuesByKey.get(key) - while (queue && queue.length > 0) { - const item = queue[0] - - await this.concurrencyManager.acquire(key) - - if (item.task.status === "cancelled") { - this.concurrencyManager.release(key) - queue.shift() - continue - } - - try { - await this.startTask(item) - } catch (error) { - log("[background-agent] Error starting task:", error) - // Release concurrency slot if startTask failed and didn't release it itself - // This prevents slot leaks when errors occur after acquire but before task.concurrencyKey is set - if (!item.task.concurrencyKey) { - this.concurrencyManager.release(key) - } - } - - queue.shift() - } - } finally { - this.processingKeys.delete(key) - } - } - - private async startTask(item: QueueItem): Promise { - 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, - title: `${input.description} (@${input.agent} subagent)`, - permission: [ - { permission: "question", action: "deny" as const, pattern: "*" }, - ], - } as any, - query: { - directory: parentDirectory, - }, - }) - - if (createResult.error) { - throw new Error(`Failed to create background session: ${createResult.error}`) - } - - if (!createResult.data?.id) { - throw new Error("Failed to create background session: API returned no session ID") - } - - const sessionID = createResult.data.id - 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 - - this.startPolling() - - log("[background-agent] Launching task:", { taskId: task.id, sessionID, agent: input.agent }) - - const toastManager = getTaskToastManager() - if (toastManager) { - toastManager.updateTask(task.id, "running") - } - - 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, - }) - - // Fire-and-forget prompt via promptAsync (no response body needed) - // Include model if caller provided one (e.g., from Sisyphus category configs) - // IMPORTANT: variant must be a top-level field in the body, NOT nested inside model - // OpenCode's PromptInput schema expects: { model: { providerID, modelID }, variant: "max" } - const launchModel = input.model - ? { providerID: input.model.providerID, modelID: input.model.modelID } - : undefined - const launchVariant = input.model?.variant - - promptWithModelSuggestionRetry(this.client, { - path: { id: sessionID }, - body: { - agent: input.agent, - ...(launchModel ? { model: launchModel } : {}), - ...(launchVariant ? { variant: launchVariant } : {}), - system: input.skillContent, - tools: { - ...getAgentToolRestrictions(input.agent), - task: false, - call_omo_agent: true, - question: false, - }, - parts: [{ type: "text", text: input.prompt }], - }, - }).catch((error) => { - log("[background-agent] promptAsync error:", error) - const existingTask = this.findBySession(sessionID) - if (existingTask) { - existingTask.status = "error" - 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() - if (existingTask.concurrencyKey) { - this.concurrencyManager.release(existingTask.concurrencyKey) - existingTask.concurrencyKey = undefined - } - - // Abort the session to prevent infinite polling hang - this.client.session.abort({ - path: { id: sessionID }, - }).catch(() => {}) - - this.markForNotification(existingTask) - this.cleanupPendingByParent(existingTask) - this.notifyParentSession(existingTask).catch(err => { - log("[background-agent] Failed to notify on error:", err) - }) - } - }) - } - - getTask(id: string): BackgroundTask | undefined { - return this.tasks.get(id) - } - - getTasksByParentSession(sessionID: string): BackgroundTask[] { - const result: BackgroundTask[] = [] - for (const task of this.tasks.values()) { - if (task.parentSessionID === sessionID) { - result.push(task) - } - } - return result - } - - getAllDescendantTasks(sessionID: string): BackgroundTask[] { - 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 - } - - findBySession(sessionID: string): BackgroundTask | undefined { - for (const task of this.tasks.values()) { - if (task.sessionID === sessionID) { - return task - } - } - return undefined - } - - private getConcurrencyKeyFromInput(input: LaunchInput): string { - if (input.model) { - return `${input.model.providerID}/${input.model.modelID}` - } - return input.agent - } - - /** - * Track a task created elsewhere (e.g., from task) for notification tracking. - * This allows tasks created by other tools to receive the same toast/prompt notifications. - */ - async trackTask(input: { - taskId: string - sessionID: string - parentSessionID: string - description: string - agent?: string - parentAgent?: string - concurrencyKey?: string - }): Promise { - const existingTask = this.tasks.get(input.taskId) - if (existingTask) { - // P2 fix: Clean up old parent's pending set BEFORE changing parent - // Otherwise cleanupPendingByParent would use the new parent ID - const parentChanged = input.parentSessionID !== existingTask.parentSessionID - if (parentChanged) { - this.cleanupPendingByParent(existingTask) // Clean from OLD parent - existingTask.parentSessionID = input.parentSessionID - } - if (input.parentAgent !== undefined) { - existingTask.parentAgent = input.parentAgent - } - if (!existingTask.concurrencyGroup) { - existingTask.concurrencyGroup = input.concurrencyKey ?? existingTask.agent - } - - if (existingTask.sessionID) { - subagentSessions.add(existingTask.sessionID) - } - this.startPolling() - - // Track for batched notifications if task is pending or running - if (existingTask.status === "pending" || existingTask.status === "running") { - const pending = this.pendingByParent.get(input.parentSessionID) ?? new Set() - pending.add(existingTask.id) - this.pendingByParent.set(input.parentSessionID, pending) - } else if (!parentChanged) { - // Only clean up if parent didn't change (already cleaned above if it did) - this.cleanupPendingByParent(existingTask) - } - - log("[background-agent] External task already registered:", { taskId: existingTask.id, sessionID: existingTask.sessionID, status: existingTask.status }) - - return existingTask - } - - const concurrencyGroup = input.concurrencyKey ?? input.agent ?? "task" - - // Acquire concurrency slot if a key is provided - if (input.concurrencyKey) { - await this.concurrencyManager.acquire(input.concurrencyKey) - } - - const task: BackgroundTask = { - id: input.taskId, - sessionID: input.sessionID, - parentSessionID: input.parentSessionID, - parentMessageID: "", - description: input.description, - prompt: "", - agent: input.agent || "task", - status: "running", - startedAt: new Date(), - progress: { - toolCalls: 0, - lastUpdate: new Date(), - }, - parentAgent: input.parentAgent, - concurrencyKey: input.concurrencyKey, - concurrencyGroup, - } - - this.tasks.set(task.id, task) - subagentSessions.add(input.sessionID) - this.startPolling() - - if (input.parentSessionID) { - const pending = this.pendingByParent.get(input.parentSessionID) ?? new Set() - pending.add(task.id) - this.pendingByParent.set(input.parentSessionID, pending) - } - - log("[background-agent] Registered external task:", { taskId: task.id, sessionID: input.sessionID }) - - return task + async trackTask(input: { taskId: string; sessionID: string; parentSessionID: string; description: string; agent?: string; parentAgent?: string; concurrencyKey?: string }): Promise { + return trackExternalTask({ input, tasks: this.tasks, pendingByParent: this.pendingByParent, concurrencyManager: this.concurrencyManager, startPolling: () => this.startPolling(), cleanupPendingByParent: (task) => this.cleanupPendingByParent(task) }) } async resume(input: ResumeInput): Promise { - const existingTask = this.findBySession(input.sessionId) - if (!existingTask) { - throw new Error(`Task not found for session: ${input.sessionId}`) - } - - 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 - } - - // 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 - // 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, - lastUpdate: new Date(), - } - - this.startPolling() - if (existingTask.sessionID) { - subagentSessions.add(existingTask.sessionID) - } - - if (input.parentSessionID) { - const pending = this.pendingByParent.get(input.parentSessionID) ?? new Set() - pending.add(existingTask.id) - this.pendingByParent.set(input.parentSessionID, pending) - } - - 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, - }) - - // Fire-and-forget prompt via promptAsync (no response body needed) - // Include model if task has one (preserved from original launch with category config) - // variant must be top-level in body, not nested inside model (OpenCode PromptInput schema) - const resumeModel = existingTask.model - ? { providerID: existingTask.model.providerID, modelID: existingTask.model.modelID } - : undefined - const resumeVariant = existingTask.model?.variant - - this.client.session.promptAsync({ - path: { id: existingTask.sessionID }, - body: { - agent: existingTask.agent, - ...(resumeModel ? { model: resumeModel } : {}), - ...(resumeVariant ? { variant: resumeVariant } : {}), - tools: { - ...getAgentToolRestrictions(existingTask.agent), - task: false, - call_omo_agent: true, - question: false, - }, - parts: [{ type: "text", text: input.prompt }], - }, - }).catch((error) => { - log("[background-agent] resume prompt error:", error) - existingTask.status = "error" - const errorMessage = error instanceof Error ? error.message : String(error) - existingTask.error = errorMessage - existingTask.completedAt = new Date() - - // Release concurrency on error to prevent slot leaks - if (existingTask.concurrencyKey) { - this.concurrencyManager.release(existingTask.concurrencyKey) - existingTask.concurrencyKey = undefined - } - - // Abort the session to prevent infinite polling hang - if (existingTask.sessionID) { - this.client.session.abort({ - path: { id: existingTask.sessionID }, - }).catch(() => {}) - } - - this.markForNotification(existingTask) - this.cleanupPendingByParent(existingTask) - this.notifyParentSession(existingTask).catch(err => { - log("[background-agent] Failed to notify on resume error:", err) - }) - }) - - return existingTask + return resumeBackgroundTask({ input, findBySession: (id) => this.findBySession(id), client: this.client, concurrencyManager: this.concurrencyManager, pendingByParent: this.pendingByParent, startPolling: () => this.startPolling(), markForNotification: (task) => this.markForNotification(task), cleanupPendingByParent: (task) => this.cleanupPendingByParent(task), notifyParentSession: (task) => this.notifyParentSession(task) }) } - private async checkSessionTodos(sessionID: string): Promise { - try { - const response = await this.client.session.todo({ - path: { id: sessionID }, - }) - const todos = (response.data ?? response) as Todo[] - if (!todos || todos.length === 0) return false + getTask(id: string): BackgroundTask | undefined { return this.tasks.get(id) } + getTasksByParentSession(sessionID: string): BackgroundTask[] { return getTasksByParentSession(this.tasks.values(), sessionID) } + getAllDescendantTasks(sessionID: string): BackgroundTask[] { return getAllDescendantTasks((id) => this.getTasksByParentSession(id), sessionID) } + findBySession(sessionID: string): BackgroundTask | undefined { return findTaskBySession(this.tasks.values(), sessionID) } + getRunningTasks(): BackgroundTask[] { return getRunningTasks(this.tasks.values()) } + getCompletedTasks(): BackgroundTask[] { return getCompletedTasks(this.tasks.values()) } - const incomplete = todos.filter( - (t) => t.status !== "completed" && t.status !== "cancelled" - ) - return incomplete.length > 0 - } catch { - return false - } - } + markForNotification(task: BackgroundTask): void { markForNotification(this.notifications, task) } + getPendingNotifications(sessionID: string): BackgroundTask[] { return getPendingNotifications(this.notifications, sessionID) } + clearNotifications(sessionID: string): void { clearNotifications(this.notifications, sessionID) } - handleEvent(event: Event): void { - const props = event.properties - - if (event.type === "message.part.updated") { - if (!props || typeof props !== "object" || !("sessionID" in props)) return - const partInfo = props as unknown as MessagePartInfo - const sessionID = partInfo?.sessionID - if (!sessionID) return - - const task = this.findBySession(sessionID) - if (!task) return - - // 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) - } - - if (partInfo?.type === "tool" || partInfo?.tool) { - if (!task.progress) { - task.progress = { - toolCalls: 0, - lastUpdate: new Date(), - } - } - task.progress.toolCalls += 1 - task.progress.lastTool = partInfo.tool - task.progress.lastUpdate = new Date() - } - } - - if (event.type === "session.idle") { - const sessionID = props?.sessionID as string | undefined - if (!sessionID) return - - const task = this.findBySession(sessionID) - if (!task || task.status !== "running") return - - const startedAt = task.startedAt - if (!startedAt) return - - // Edge guard: Require minimum elapsed time (5 seconds) before accepting idle - const elapsedMs = Date.now() - startedAt.getTime() - if (elapsedMs < MIN_IDLE_TIME_MS) { - const remainingMs = MIN_IDLE_TIME_MS - elapsedMs - if (!this.idleDeferralTimers.has(task.id)) { - log("[background-agent] Deferring early session.idle:", { elapsedMs, remainingMs, taskId: task.id }) - const timer = setTimeout(() => { - this.idleDeferralTimers.delete(task.id) - this.handleEvent({ type: "session.idle", properties: { sessionID } }) - }, remainingMs) - this.idleDeferralTimers.set(task.id, timer) - } else { - log("[background-agent] session.idle already deferred:", { elapsedMs, taskId: task.id }) - } - return - } - - // Edge guard: Verify session has actual assistant output before completing - this.validateSessionHasOutput(sessionID).then(async (hasValidOutput) => { - // Re-check status after async operation (could have been completed by polling) - if (task.status !== "running") { - log("[background-agent] Task status changed during validation, skipping:", { taskId: task.id, status: task.status }) - return - } - - if (!hasValidOutput) { - log("[background-agent] Session.idle but no valid output yet, waiting:", task.id) - return - } - - const hasIncompleteTodos = await this.checkSessionTodos(sessionID) - - // Re-check status after async operation again - if (task.status !== "running") { - log("[background-agent] Task status changed during todo check, skipping:", { taskId: task.id, status: task.status }) - return - } - - if (hasIncompleteTodos) { - log("[background-agent] Task has incomplete todos, waiting for todo-continuation:", task.id) - return - } - - await this.tryCompleteTask(task, "session.idle event") - }).catch(err => { - log("[background-agent] Error in session.idle handler:", err) - }) - } - - if (event.type === "session.deleted") { - const info = props?.info - if (!info || typeof info.id !== "string") return - const sessionID = info.id - - const tasksToCancel = new Map() - const directTask = this.findBySession(sessionID) - if (directTask) { - tasksToCancel.set(directTask.id, directTask) - } - for (const descendant of this.getAllDescendantTasks(sessionID)) { - tasksToCancel.set(descendant.id, descendant) - } - - if (tasksToCancel.size === 0) return - - for (const task of tasksToCancel.values()) { - if (task.status === "running" || task.status === "pending") { - void this.cancelTask(task.id, { - source: "session.deleted", - reason: "Session deleted", - skipNotification: true, - }).catch(err => { - log("[background-agent] Failed to cancel task on session.deleted:", { taskId: task.id, error: err }) - }) - } - - const existingTimer = this.completionTimers.get(task.id) - if (existingTimer) { - clearTimeout(existingTimer) - 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.tasks.delete(task.id) - this.clearNotificationsForTask(task.id) - if (task.sessionID) { - subagentSessions.delete(task.sessionID) - } - } - } - } - - markForNotification(task: BackgroundTask): void { - const queue = this.notifications.get(task.parentSessionID) ?? [] - queue.push(task) - this.notifications.set(task.parentSessionID, queue) - } - - getPendingNotifications(sessionID: string): BackgroundTask[] { - return this.notifications.get(sessionID) ?? [] - } - - clearNotifications(sessionID: string): void { - this.notifications.delete(sessionID) - } - - /** - * 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 { - try { - const response = await this.client.session.messages({ - path: { id: sessionID }, - }) - - const messages = response.data ?? [] - - // 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 - } - - // Additionally check that at least one message has content (not just empty) - // 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 - } - } - - 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) - } - } - } - - /** - * 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) - } - } - } - - async cancelTask( - taskId: string, - options?: { source?: string; reason?: string; abortSession?: boolean; skipNotification?: boolean } - ): Promise { - const task = this.tasks.get(taskId) - if (!task || (task.status !== "running" && task.status !== "pending")) { - return false - } - - 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) - } - } - } - log("[background-agent] Cancelled pending task:", { taskId, key }) - } - - task.status = "cancelled" - task.completedAt = new Date() - if (reason) { - task.error = reason - } - - 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) - } - - const idleTimer = this.idleDeferralTimers.get(task.id) - if (idleTimer) { - clearTimeout(idleTimer) - this.idleDeferralTimers.delete(task.id) - } - - this.cleanupPendingByParent(task) - - if (abortSession && task.sessionID) { - this.client.session.abort({ - path: { id: task.sessionID }, - }).catch(() => {}) - } - - if (options?.skipNotification) { - log(`[background-agent] Task cancelled via ${source} (notification skipped):`, task.id) - return true - } - - this.markForNotification(task) - - try { - await this.notifyParentSession(task) - 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 - } - + if (!task || task.status !== "pending") return false void this.cancelTask(taskId, { source: "cancelPendingTask", abortSession: false }) return true } + async cancelTask(taskId: string, options?: { source?: string; reason?: string; abortSession?: boolean; skipNotification?: boolean }): Promise { + return cancelBackgroundTask({ taskId, options, tasks: this.tasks, queuesByKey: this.queuesByKey, completionTimers: this.completionTimers, idleDeferralTimers: this.idleDeferralTimers, concurrencyManager: this.concurrencyManager, client: this.client, cleanupPendingByParent: (task) => this.cleanupPendingByParent(task), markForNotification: (task) => this.markForNotification(task), notifyParentSession: (task) => this.notifyParentSession(task) }) + } + + handleEvent(event: { type: string; properties?: Record }): void { + handleBackgroundEvent({ event, findBySession: (id) => this.findBySession(id), getAllDescendantTasks: (id) => this.getAllDescendantTasks(id), cancelTask: (id, opts) => this.cancelTask(id, opts), tryCompleteTask: (task, source) => this.tryCompleteTask(task, source), validateSessionHasOutput: (id) => this.validateSessionHasOutput(id), checkSessionTodos: (id) => this.checkSessionTodos(id), idleDeferralTimers: this.idleDeferralTimers, completionTimers: this.completionTimers, tasks: this.tasks, cleanupPendingByParent: (task) => this.cleanupPendingByParent(task), clearNotificationsForTask: (id) => this.clearNotificationsForTask(id), emitIdleEvent: (sessionID) => this.handleEvent({ type: "session.idle", properties: { sessionID } }) }) + } + + shutdown(): void { + shutdownBackgroundManager({ shutdownTriggered: this.shutdownTriggered, stopPolling: () => this.stopPolling(), tasks: this.tasks, client: this.client, onShutdown: this.onShutdown, concurrencyManager: this.concurrencyManager, completionTimers: this.completionTimers, idleDeferralTimers: this.idleDeferralTimers, notifications: this.notifications, pendingByParent: this.pendingByParent, queuesByKey: this.queuesByKey, processingKeys: this.processingKeys, unregisterProcessCleanup: () => this.unregisterProcessCleanup() }) + } + + private getConcurrencyKeyFromInput(input: LaunchInput): string { return input.model ? `${input.model.providerID}/${input.model.modelID}` : input.agent } + private async processKey(key: string): Promise { await processConcurrencyKeyQueue({ key, queuesByKey: this.queuesByKey, processingKeys: this.processingKeys, concurrencyManager: this.concurrencyManager, startTask: (item) => this.startTask(item) }) } + private async startTask(item: QueueItem): Promise { + await startQueuedTask({ item, client: this.client, defaultDirectory: this.directory, tmuxEnabled: this.tmuxEnabled, onSubagentSessionCreated: this.onSubagentSessionCreated, startPolling: () => this.startPolling(), getConcurrencyKeyFromInput: (i) => this.getConcurrencyKeyFromInput(i), concurrencyManager: this.concurrencyManager, findBySession: (id) => this.findBySession(id), markForNotification: (task) => this.markForNotification(task), cleanupPendingByParent: (task) => this.cleanupPendingByParent(task), notifyParentSession: (task) => this.notifyParentSession(task) }) + } + private startPolling(): void { if (this.pollingInterval) return - - this.pollingInterval = setInterval(() => { - this.pollRunningTasks() - }, POLLING_INTERVAL_MS) + this.pollingInterval = setInterval(() => void this.pollRunningTasks(), POLLING_INTERVAL_MS) this.pollingInterval.unref() } + private stopPolling(): void { if (this.pollingInterval) { clearInterval(this.pollingInterval); this.pollingInterval = undefined } } - private stopPolling(): void { - if (this.pollingInterval) { - clearInterval(this.pollingInterval) - this.pollingInterval = undefined - } + private async pollRunningTasks(): Promise { + await pollRunningTasks({ tasks: this.tasks.values(), client: this.client, pruneStaleTasksAndNotifications: () => this.pruneStaleTasksAndNotifications(), checkAndInterruptStaleTasks: () => this.checkAndInterruptStaleTasks(), validateSessionHasOutput: (id) => this.validateSessionHasOutput(id), checkSessionTodos: (id) => this.checkSessionTodos(id), tryCompleteTask: (task, source) => this.tryCompleteTask(task, source), hasRunningTasks: () => this.hasRunningTasks(), stopPolling: () => this.stopPolling() }) } + private pruneStaleTasksAndNotifications(): void { + pruneStaleState({ tasks: this.tasks, notifications: this.notifications, concurrencyManager: this.concurrencyManager, cleanupPendingByParent: (task) => this.cleanupPendingByParent(task), clearNotificationsForTask: (id) => this.clearNotificationsForTask(id) }) + } + private async checkAndInterruptStaleTasks(): Promise { + await checkAndInterruptStaleTasks({ tasks: this.tasks.values(), client: this.client, config: this.config, concurrencyManager: this.concurrencyManager, notifyParentSession: (task) => this.notifyParentSession(task) }) + } + + private hasRunningTasks(): boolean { return hasRunningTasks(this.tasks.values()) } + private async tryCompleteTask(task: BackgroundTask, source: string): Promise { + return tryCompleteBackgroundTask({ task, source, concurrencyManager: this.concurrencyManager, idleDeferralTimers: this.idleDeferralTimers, client: this.client, markForNotification: (t) => this.markForNotification(t), cleanupPendingByParent: (t) => this.cleanupPendingByParent(t), notifyParentSession: (t) => this.notifyParentSession(t) }) + } + private async notifyParentSession(task: BackgroundTask): Promise { + await notifyParentSessionInternal({ task, tasks: this.tasks, pendingByParent: this.pendingByParent, completionTimers: this.completionTimers, clearNotificationsForTask: (id) => this.clearNotificationsForTask(id), client: this.client }) + } + + private async validateSessionHasOutput(sessionID: string): Promise { return validateSessionHasOutput(this.client, sessionID) } + private async checkSessionTodos(sessionID: string): Promise { return checkSessionTodos(this.client, sessionID) } + private clearNotificationsForTask(taskId: string): void { clearNotificationsForTask(this.notifications, taskId) } + private cleanupPendingByParent(task: BackgroundTask): void { cleanupPendingByParent(this.pendingByParent, task) } + private registerProcessCleanup(): void { BackgroundManager.cleanupManagers.add(this) - if (BackgroundManager.cleanupRegistered) return BackgroundManager.cleanupRegistered = true - - const cleanupAll = () => { - for (const manager of BackgroundManager.cleanupManagers) { - try { - manager.shutdown() - } catch (error) { - log("[background-agent] Error during shutdown cleanup:", error) - } - } - } - - const registerSignal = (signal: ProcessCleanupEvent, exitAfter: boolean): void => { - const listener = registerProcessSignal(signal, cleanupAll, exitAfter) - BackgroundManager.cleanupHandlers.set(signal, listener) - } - - registerSignal("SIGINT", true) - registerSignal("SIGTERM", true) - if (process.platform === "win32") { - registerSignal("SIGBREAK", true) - } - registerSignal("beforeExit", false) - registerSignal("exit", false) + const cleanupAll = () => { for (const manager of BackgroundManager.cleanupManagers) { try { manager.shutdown() } catch (error) { log("[background-agent] Error during shutdown cleanup:", error) } } } + const registerSignal = (signal: ProcessCleanupEvent, exitAfter: boolean) => { const listener = registerProcessSignal(signal, cleanupAll, exitAfter); BackgroundManager.cleanupHandlers.set(signal, listener) } + registerSignal("SIGINT", true); registerSignal("SIGTERM", true); if (process.platform === "win32") registerSignal("SIGBREAK", true) + registerSignal("beforeExit", false); registerSignal("exit", false) } private unregisterProcessCleanup(): void { BackgroundManager.cleanupManagers.delete(this) - if (BackgroundManager.cleanupManagers.size > 0) return - - for (const [signal, listener] of BackgroundManager.cleanupHandlers.entries()) { - process.off(signal, listener) - } - BackgroundManager.cleanupHandlers.clear() - BackgroundManager.cleanupRegistered = false - } - - - /** - * Get all running tasks (for compaction hook) - */ - getRunningTasks(): BackgroundTask[] { - return Array.from(this.tasks.values()).filter(t => t.status === "running") - } - - /** - * Get all completed tasks still in memory (for compaction hook) - */ - getCompletedTasks(): BackgroundTask[] { - 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 { - // 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() - - // Release concurrency BEFORE any async operations to prevent slot leaks - if (task.concurrencyKey) { - this.concurrencyManager.release(task.concurrencyKey) - task.concurrencyKey = undefined - } - - this.markForNotification(task) - - // Ensure pending tracking is cleaned up even if notification fails - this.cleanupPendingByParent(task) - - const idleTimer = this.idleDeferralTimers.get(task.id) - if (idleTimer) { - clearTimeout(idleTimer) - this.idleDeferralTimers.delete(task.id) - } - - if (task.sessionID) { - this.client.session.abort({ - path: { id: task.sessionID }, - }).catch(() => {}) - } - - try { - await this.notifyParentSession(task) - 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 { - // Note: Callers must release concurrency before calling this method - // to ensure slots are freed even if notification fails - - const duration = this.formatDuration(task.startedAt ?? new Date(), task.completedAt) - - 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, - }) - } - - // Update pending tracking and check if all tasks complete - const pendingSet = this.pendingByParent.get(task.parentSessionID) - if (pendingSet) { - pendingSet.delete(task.id) - if (pendingSet.size === 0) { - this.pendingByParent.delete(task.parentSessionID) - } - } - - const allComplete = !pendingSet || pendingSet.size === 0 - const remainingCount = pendingSet?.size ?? 0 - - const statusText = task.status === "completed" ? "COMPLETED" : "CANCELLED" - const errorInfo = task.error ? `\n**Error:** ${task.error}` : "" - - let notification: string - let completedTasks: BackgroundTask[] = [] - if (allComplete) { - completedTasks = Array.from(this.tasks.values()) - .filter(t => t.parentSessionID === task.parentSessionID && t.status !== "running" && t.status !== "pending") - const completedTasksText = completedTasks - .map(t => `- \`${t.id}\`: ${t.description}`) - .join("\n") - - notification = ` -[ALL BACKGROUND TASKS COMPLETE] - -**Completed:** -${completedTasksText || `- \`${task.id}\`: ${task.description}`} - -Use \`background_output(task_id="")\` to retrieve each result. -` - } else { - // Individual completion - silent notification - notification = ` -[BACKGROUND TASK ${statusText}] -**ID:** \`${task.id}\` -**Description:** ${task.description} -**Duration:** ${duration}${errorInfo} - -**${remainingCount} task${remainingCount === 1 ? "" : "s"} still in progress.** You WILL be notified when ALL complete. -Do NOT poll - continue productive work. - -Use \`background_output(task_id="${task.id}")\` to retrieve this result when ready. -` - } - - let agent: string | undefined = task.parentAgent - let model: { providerID: string; modelID: string } | undefined - - try { - const messagesResp = await this.client.session.messages({ path: { id: task.parentSessionID } }) - const messages = (messagesResp.data ?? []) as Array<{ - info?: { agent?: string; model?: { providerID: string; modelID: string }; modelID?: string; providerID?: string } - }> - for (let i = messages.length - 1; i >= 0; i--) { - const info = messages[i].info - if (info?.agent || info?.model || (info?.modelID && info?.providerID)) { - agent = info.agent ?? task.parentAgent - model = info.model ?? (info.providerID && info.modelID ? { providerID: info.providerID, modelID: info.modelID } : undefined) - break - } - } - } catch (error) { - if (this.isAbortedSessionError(error)) { - log("[background-agent] Parent session aborted, skipping notification:", { - taskId: task.id, - parentSessionID: task.parentSessionID, - }) - return - } - const messageDir = getMessageDir(task.parentSessionID) - const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null - agent = currentMessage?.agent ?? task.parentAgent - model = currentMessage?.model?.providerID && currentMessage?.model?.modelID - ? { providerID: currentMessage.model.providerID, modelID: currentMessage.model.modelID } - : undefined - } - - log("[background-agent] notifyParentSession context:", { - taskId: task.id, - resolvedAgent: agent, - resolvedModel: model, - }) - - try { - await this.client.session.promptAsync({ - path: { id: task.parentSessionID }, - body: { - noReply: !allComplete, - ...(agent !== undefined ? { agent } : {}), - ...(model !== undefined ? { model } : {}), - parts: [{ type: "text", text: notification }], - }, - }) - log("[background-agent] Sent notification to parent session:", { - taskId: task.id, - allComplete, - noReply: !allComplete, - }) - } catch (error) { - if (this.isAbortedSessionError(error)) { - log("[background-agent] Parent session aborted, skipping notification:", { - taskId: task.id, - parentSessionID: task.parentSessionID, - }) - return - } - log("[background-agent] Failed to send notification:", error) - } - - if (allComplete) { - for (const completedTask of completedTasks) { - const taskId = completedTask.id - const existingTimer = this.completionTimers.get(taskId) - if (existingTimer) { - clearTimeout(existingTimer) - this.completionTimers.delete(taskId) - } - const timer = setTimeout(() => { - this.completionTimers.delete(taskId) - if (this.tasks.has(taskId)) { - this.clearNotificationsForTask(taskId) - this.tasks.delete(taskId) - log("[background-agent] Removed completed task from memory:", taskId) - } - }, TASK_CLEANUP_DELAY_MS) - this.completionTimers.set(taskId, timer) - } - } - } - - private formatDuration(start: Date, end?: Date): string { - const duration = (end ?? new Date()).getTime() - start.getTime() - const seconds = Math.floor(duration / 1000) - const minutes = Math.floor(seconds / 60) - const hours = Math.floor(minutes / 60) - - if (hours > 0) { - return `${hours}h ${minutes % 60}m ${seconds % 60}s` - } else if (minutes > 0) { - return `${minutes}m ${seconds % 60}s` - } - return `${seconds}s` - } - - private isAbortedSessionError(error: unknown): boolean { - const message = this.getErrorText(error) - return message.toLowerCase().includes("aborted") - } - - private getErrorText(error: unknown): string { - if (!error) return "" - if (typeof error === "string") return error - if (error instanceof Error) { - return `${error.name}: ${error.message}` - } - if (typeof error === "object" && error !== null) { - if ("message" in error && typeof error.message === "string") { - return error.message - } - if ("name" in error && typeof error.name === "string") { - return error.name - } - } - return "" - } - - private hasRunningTasks(): boolean { - for (const task of this.tasks.values()) { - if (task.status === "running") return true - } - return false - } - - private pruneStaleTasksAndNotifications(): void { - const now = Date.now() - - for (const [taskId, task] of this.tasks.entries()) { - const timestamp = task.status === "pending" - ? task.queuedAt?.getTime() - : task.startedAt?.getTime() - - if (!timestamp) { - continue - } - - const age = now - timestamp - if (age > TASK_TTL_MS) { - const errorMessage = task.status === "pending" - ? "Task timed out while queued (30 minutes)" - : "Task timed out after 30 minutes" - - log("[background-agent] Pruning stale task:", { taskId, status: task.status, age: Math.round(age / 1000) + "s" }) - task.status = "error" - task.error = errorMessage - task.completedAt = new Date() - if (task.concurrencyKey) { - this.concurrencyManager.release(task.concurrencyKey) - task.concurrencyKey = undefined - } - // Clean up pendingByParent to prevent stale entries - this.cleanupPendingByParent(task) - this.clearNotificationsForTask(taskId) - this.tasks.delete(taskId) - if (task.sessionID) { - subagentSessions.delete(task.sessionID) - } - } - } - - for (const [sessionID, notifications] of this.notifications.entries()) { - if (notifications.length === 0) { - this.notifications.delete(sessionID) - continue - } - const validNotifications = notifications.filter((task) => { - if (!task.startedAt) return false - const age = now - task.startedAt.getTime() - return age <= TASK_TTL_MS - }) - if (validNotifications.length === 0) { - this.notifications.delete(sessionID) - } else if (validNotifications.length !== notifications.length) { - this.notifications.set(sessionID, validNotifications) - } - } - } - - private async checkAndInterruptStaleTasks(): Promise { - const staleTimeoutMs = this.config?.staleTimeoutMs ?? DEFAULT_STALE_TIMEOUT_MS - const now = Date.now() - - for (const task of this.tasks.values()) { - if (task.status !== "running") continue - if (!task.progress?.lastUpdate) continue - - const startedAt = task.startedAt - const sessionID = task.sessionID - if (!startedAt || !sessionID) continue - - const runtime = now - startedAt.getTime() - if (runtime < MIN_RUNTIME_BEFORE_STALE_MS) continue - - const timeSinceLastUpdate = now - task.progress.lastUpdate.getTime() - if (timeSinceLastUpdate <= staleTimeoutMs) continue - - if (task.status !== "running") continue - - const staleMinutes = Math.round(timeSinceLastUpdate / 60000) - task.status = "cancelled" - task.error = `Stale timeout (no activity for ${staleMinutes}min)` - task.completedAt = new Date() - - if (task.concurrencyKey) { - this.concurrencyManager.release(task.concurrencyKey) - task.concurrencyKey = undefined - } - - this.client.session.abort({ - path: { id: sessionID }, - }).catch(() => {}) - - log(`[background-agent] Task ${task.id} interrupted: stale timeout`) - - try { - await this.notifyParentSession(task) - } catch (err) { - log("[background-agent] Error in notifyParentSession for stale task:", { taskId: task.id, error: err }) - } - } - } - - private async pollRunningTasks(): Promise { - this.pruneStaleTasksAndNotifications() - await this.checkAndInterruptStaleTasks() - - const statusResult = await this.client.session.status() - const allStatuses = (statusResult.data ?? {}) as Record - - for (const task of this.tasks.values()) { - if (task.status !== "running") continue - - const sessionID = task.sessionID - if (!sessionID) continue - - try { - const sessionStatus = allStatuses[sessionID] - - // Don't skip if session not in status - fall through to message-based detection - if (sessionStatus?.type === "idle") { - // Edge guard: Validate session has actual output before completing - const hasValidOutput = await this.validateSessionHasOutput(sessionID) - if (!hasValidOutput) { - log("[background-agent] Polling idle 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, "polling (idle status)") - continue - } - - const messagesResult = await this.client.session.messages({ - path: { id: sessionID }, - }) - - if (!messagesResult.error && messagesResult.data) { - const messages = messagesResult.data as Array<{ - info?: { role?: string } - parts?: Array<{ type?: string; tool?: string; name?: string; text?: string }> - }> - const assistantMsgs = messages.filter( - (m) => m.info?.role === "assistant" - ) - - let toolCalls = 0 - let lastTool: string | undefined - let lastMessage: string | undefined - - for (const msg of assistantMsgs) { - const parts = msg.parts ?? [] - for (const part of parts) { - if (part.type === "tool_use" || part.tool) { - toolCalls++ - lastTool = part.tool || part.name || "unknown" - } - if (part.type === "text" && part.text) { - lastMessage = part.text - } - } - } - - if (!task.progress) { - task.progress = { toolCalls: 0, lastUpdate: new Date() } - } - task.progress.toolCalls = toolCalls - task.progress.lastTool = lastTool - task.progress.lastUpdate = new Date() - if (lastMessage) { - task.progress.lastMessage = lastMessage - task.progress.lastMessageAt = new Date() - } - - // Stability detection: complete when message count unchanged for 3 polls - const currentMsgCount = messages.length - const startedAt = task.startedAt - if (!startedAt) continue - - const elapsedMs = Date.now() - startedAt.getTime() - - if (elapsedMs >= MIN_STABILITY_TIME_MS) { - if (task.lastMsgCount === currentMsgCount) { - task.stablePolls = (task.stablePolls ?? 0) + 1 - if (task.stablePolls >= 3) { - // Re-fetch session status to confirm agent is truly idle - const recheckStatus = await this.client.session.status() - const recheckData = (recheckStatus.data ?? {}) as Record - const currentStatus = recheckData[sessionID] - - if (currentStatus?.type !== "idle") { - log("[background-agent] Stability reached but session not idle, resetting:", { - taskId: task.id, - sessionStatus: currentStatus?.type ?? "not_in_status" - }) - task.stablePolls = 0 - continue - } - - // Edge guard: Validate session has actual output before completing - const hasValidOutput = await this.validateSessionHasOutput(sessionID) - if (!hasValidOutput) { - log("[background-agent] Stability reached but no valid output, waiting:", task.id) - continue - } - - // Re-check status after async operation - if (task.status !== "running") continue - - const hasIncompleteTodos = await this.checkSessionTodos(sessionID) - if (!hasIncompleteTodos) { - await this.tryCompleteTask(task, "stability detection") - continue - } - } - } else { - task.stablePolls = 0 - } - } - task.lastMsgCount = currentMsgCount - } - } catch (error) { - log("[background-agent] Poll error for task:", { taskId: task.id, error }) - } - } - - if (!this.hasRunningTasks()) { - this.stopPolling() - } - } - - /** - * Shutdown the manager gracefully. - * Cancels all pending concurrency waiters and clears timers. - * Should be called when the plugin is unloaded. - */ - shutdown(): void { - if (this.shutdownTriggered) return - this.shutdownTriggered = true - log("[background-agent] Shutting down BackgroundManager") - this.stopPolling() - - // Abort all running sessions to prevent zombie processes (#1240) - for (const task of this.tasks.values()) { - if (task.status === "running" && task.sessionID) { - this.client.session.abort({ - path: { id: task.sessionID }, - }).catch(() => {}) - } - } - - // Notify shutdown listeners (e.g., tmux cleanup) - if (this.onShutdown) { - try { - this.onShutdown() - } catch (error) { - log("[background-agent] Error in onShutdown callback:", error) - } - } - - // Release concurrency for all running tasks - for (const task of this.tasks.values()) { - if (task.concurrencyKey) { - this.concurrencyManager.release(task.concurrencyKey) - task.concurrencyKey = undefined - } - } - - for (const timer of this.completionTimers.values()) { - clearTimeout(timer) - } - this.completionTimers.clear() - - for (const timer of this.idleDeferralTimers.values()) { - clearTimeout(timer) - } - this.idleDeferralTimers.clear() - - this.concurrencyManager.clear() - this.tasks.clear() - this.notifications.clear() - this.pendingByParent.clear() - this.queuesByKey.clear() - this.processingKeys.clear() - this.unregisterProcessCleanup() - log("[background-agent] Shutdown complete") - + for (const [signal, listener] of BackgroundManager.cleanupHandlers.entries()) process.off(signal, listener) + BackgroundManager.cleanupHandlers.clear(); BackgroundManager.cleanupRegistered = false } } - -function registerProcessSignal( - signal: ProcessCleanupEvent, - handler: () => void, - exitAfter: boolean -): () => void { - const listener = () => { - handler() - if (exitAfter) { - // Set exitCode and schedule exit after delay to allow other handlers to complete async cleanup - // Use 6s delay to accommodate LSP cleanup (5s timeout + 1s SIGKILL wait) - process.exitCode = 0 - setTimeout(() => process.exit(), 6000) - } - } - process.on(signal, listener) - return listener -} - - -function getMessageDir(sessionID: string): string | null { - if (!existsSync(MESSAGE_STORAGE)) return null - - const directPath = join(MESSAGE_STORAGE, sessionID) - if (existsSync(directPath)) return directPath - - for (const dir of readdirSync(MESSAGE_STORAGE)) { - const sessionPath = join(MESSAGE_STORAGE, dir, sessionID) - if (existsSync(sessionPath)) return sessionPath - } - return null -} diff --git a/src/features/background-agent/message-dir.ts b/src/features/background-agent/message-dir.ts new file mode 100644 index 000000000..3e8f56a47 --- /dev/null +++ b/src/features/background-agent/message-dir.ts @@ -0,0 +1,18 @@ +import { existsSync, readdirSync } from "node:fs" +import { join } from "node:path" + +import { MESSAGE_STORAGE } from "../hook-message-injector" + +export function getMessageDir(sessionID: string): string | null { + if (!existsSync(MESSAGE_STORAGE)) return null + + const directPath = join(MESSAGE_STORAGE, sessionID) + if (existsSync(directPath)) return directPath + + for (const dir of readdirSync(MESSAGE_STORAGE)) { + const sessionPath = join(MESSAGE_STORAGE, dir, sessionID) + if (existsSync(sessionPath)) return sessionPath + } + + return null +} diff --git a/src/features/background-agent/message-storage-locator.ts b/src/features/background-agent/message-storage-locator.ts new file mode 100644 index 000000000..ceecd329c --- /dev/null +++ b/src/features/background-agent/message-storage-locator.ts @@ -0,0 +1,17 @@ +import { existsSync, readdirSync } from "node:fs" +import { join } from "node:path" +import { MESSAGE_STORAGE } from "../hook-message-injector" + +export function getMessageDir(sessionID: string): string | null { + if (!existsSync(MESSAGE_STORAGE)) return null + + const directPath = join(MESSAGE_STORAGE, sessionID) + if (existsSync(directPath)) return directPath + + for (const dir of readdirSync(MESSAGE_STORAGE)) { + const sessionPath = join(MESSAGE_STORAGE, dir, sessionID) + if (existsSync(sessionPath)) return sessionPath + } + + return null +} diff --git a/src/features/background-agent/notification-builder.ts b/src/features/background-agent/notification-builder.ts new file mode 100644 index 000000000..e16d2b4e5 --- /dev/null +++ b/src/features/background-agent/notification-builder.ts @@ -0,0 +1,40 @@ +import type { BackgroundTask } from "./types" + +export function buildBackgroundTaskNotificationText(args: { + task: BackgroundTask + duration: string + allComplete: boolean + remainingCount: number + completedTasks: BackgroundTask[] +}): string { + const { task, duration, allComplete, remainingCount, completedTasks } = args + const statusText = task.status === "completed" ? "COMPLETED" : "CANCELLED" + const errorInfo = task.error ? `\n**Error:** ${task.error}` : "" + + if (allComplete) { + const completedTasksText = completedTasks + .map((t) => `- \`${t.id}\`: ${t.description}`) + .join("\n") + + return ` +[ALL BACKGROUND TASKS COMPLETE] + +**Completed:** +${completedTasksText || `- \`${task.id}\`: ${task.description}`} + +Use \`background_output(task_id="")\` to retrieve each result. +` + } + + return ` +[BACKGROUND TASK ${statusText}] +**ID:** \`${task.id}\` +**Description:** ${task.description} +**Duration:** ${duration}${errorInfo} + +**${remainingCount} task${remainingCount === 1 ? "" : "s"} still in progress.** You WILL be notified when ALL complete. +Do NOT poll - continue productive work. + +Use \`background_output(task_id="${task.id}")\` to retrieve this result when ready. +` +} diff --git a/src/features/background-agent/notification-tracker.ts b/src/features/background-agent/notification-tracker.ts new file mode 100644 index 000000000..722c27300 --- /dev/null +++ b/src/features/background-agent/notification-tracker.ts @@ -0,0 +1,52 @@ +import type { BackgroundTask } from "./types" + +export function markForNotification( + notifications: Map, + task: BackgroundTask +): void { + const queue = notifications.get(task.parentSessionID) ?? [] + queue.push(task) + notifications.set(task.parentSessionID, queue) +} + +export function getPendingNotifications( + notifications: Map, + sessionID: string +): BackgroundTask[] { + return notifications.get(sessionID) ?? [] +} + +export function clearNotifications( + notifications: Map, + sessionID: string +): void { + notifications.delete(sessionID) +} + +export function clearNotificationsForTask( + notifications: Map, + taskId: string +): void { + for (const [sessionID, tasks] of notifications.entries()) { + const filtered = tasks.filter((t) => t.id !== taskId) + if (filtered.length === 0) { + notifications.delete(sessionID) + } else { + notifications.set(sessionID, filtered) + } + } +} + +export function cleanupPendingByParent( + pendingByParent: Map>, + task: BackgroundTask +): void { + if (!task.parentSessionID) return + const pending = pendingByParent.get(task.parentSessionID) + if (!pending) return + + pending.delete(task.id) + if (pending.size === 0) { + pendingByParent.delete(task.parentSessionID) + } +} diff --git a/src/features/background-agent/notify-parent-session.ts b/src/features/background-agent/notify-parent-session.ts new file mode 100644 index 000000000..ea28f025f --- /dev/null +++ b/src/features/background-agent/notify-parent-session.ts @@ -0,0 +1,192 @@ +import { log } from "../../shared" + +import { findNearestMessageWithFields } from "../hook-message-injector" +import { getTaskToastManager } from "../task-toast-manager" + +import { TASK_CLEANUP_DELAY_MS } from "./constants" +import { formatDuration } from "./format-duration" +import { isAbortedSessionError } from "./error-classifier" +import { getMessageDir } from "./message-dir" +import { buildBackgroundTaskNotificationText } from "./notification-builder" + +import type { BackgroundTask } from "./types" +import type { OpencodeClient } from "./opencode-client" + +type AgentModel = { providerID: string; modelID: string } + +type MessageInfo = { + agent?: string + model?: AgentModel + providerID?: string + modelID?: string +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null +} + +function extractMessageInfo(message: unknown): MessageInfo { + if (!isRecord(message)) return {} + const info = message["info"] + if (!isRecord(info)) return {} + + const agent = typeof info["agent"] === "string" ? info["agent"] : undefined + const modelObj = info["model"] + if (isRecord(modelObj)) { + const providerID = modelObj["providerID"] + const modelID = modelObj["modelID"] + if (typeof providerID === "string" && typeof modelID === "string") { + return { agent, model: { providerID, modelID } } + } + } + + const providerID = info["providerID"] + const modelID = info["modelID"] + if (typeof providerID === "string" && typeof modelID === "string") { + return { agent, model: { providerID, modelID } } + } + + return { agent } +} + +export async function notifyParentSession(args: { + task: BackgroundTask + tasks: Map + pendingByParent: Map> + completionTimers: Map> + clearNotificationsForTask: (taskId: string) => void + client: OpencodeClient +}): Promise { + const { task, tasks, pendingByParent, completionTimers, clearNotificationsForTask, client } = args + + const duration = formatDuration(task.startedAt ?? new Date(), task.completedAt) + log("[background-agent] notifyParentSession called for task:", task.id) + + const toastManager = getTaskToastManager() + if (toastManager) { + toastManager.showCompletionToast({ + id: task.id, + description: task.description, + duration, + }) + } + + const pendingSet = pendingByParent.get(task.parentSessionID) + if (pendingSet) { + pendingSet.delete(task.id) + if (pendingSet.size === 0) { + pendingByParent.delete(task.parentSessionID) + } + } + + const allComplete = !pendingSet || pendingSet.size === 0 + const remainingCount = pendingSet?.size ?? 0 + + const completedTasks = allComplete + ? Array.from(tasks.values()).filter( + (t) => + t.parentSessionID === task.parentSessionID && + t.status !== "running" && + t.status !== "pending" + ) + : [] + + const notification = buildBackgroundTaskNotificationText({ + task, + duration, + allComplete, + remainingCount, + completedTasks, + }) + + let agent: string | undefined = task.parentAgent + let model: AgentModel | undefined + + try { + const messagesResp = await client.session.messages({ + path: { id: task.parentSessionID }, + }) + const raw = (messagesResp as { data?: unknown }).data ?? [] + const messages = Array.isArray(raw) ? raw : [] + + for (let i = messages.length - 1; i >= 0; i--) { + const extracted = extractMessageInfo(messages[i]) + if (extracted.agent || extracted.model) { + agent = extracted.agent ?? task.parentAgent + model = extracted.model + break + } + } + } catch (error) { + if (isAbortedSessionError(error)) { + log("[background-agent] Parent session aborted, skipping notification:", { + taskId: task.id, + parentSessionID: task.parentSessionID, + }) + return + } + + const messageDir = getMessageDir(task.parentSessionID) + const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null + agent = currentMessage?.agent ?? task.parentAgent + model = + currentMessage?.model?.providerID && currentMessage?.model?.modelID + ? { providerID: currentMessage.model.providerID, modelID: currentMessage.model.modelID } + : undefined + } + + log("[background-agent] notifyParentSession context:", { + taskId: task.id, + resolvedAgent: agent, + resolvedModel: model, + }) + + try { + await client.session.promptAsync({ + path: { id: task.parentSessionID }, + body: { + noReply: !allComplete, + ...(agent !== undefined ? { agent } : {}), + ...(model !== undefined ? { model } : {}), + parts: [{ type: "text", text: notification }], + }, + }) + + log("[background-agent] Sent notification to parent session:", { + taskId: task.id, + allComplete, + noReply: !allComplete, + }) + } catch (error) { + if (isAbortedSessionError(error)) { + log("[background-agent] Parent session aborted, skipping notification:", { + taskId: task.id, + parentSessionID: task.parentSessionID, + }) + return + } + log("[background-agent] Failed to send notification:", error) + } + + if (!allComplete) return + + for (const completedTask of completedTasks) { + const taskId = completedTask.id + const existingTimer = completionTimers.get(taskId) + if (existingTimer) { + clearTimeout(existingTimer) + completionTimers.delete(taskId) + } + + const timer = setTimeout(() => { + completionTimers.delete(taskId) + if (tasks.has(taskId)) { + clearNotificationsForTask(taskId) + tasks.delete(taskId) + log("[background-agent] Removed completed task from memory:", taskId) + } + }, TASK_CLEANUP_DELAY_MS) + + completionTimers.set(taskId, timer) + } +} diff --git a/src/features/background-agent/opencode-client.ts b/src/features/background-agent/opencode-client.ts new file mode 100644 index 000000000..6f314fcca --- /dev/null +++ b/src/features/background-agent/opencode-client.ts @@ -0,0 +1,3 @@ +import type { PluginInput } from "@opencode-ai/plugin" + +export type OpencodeClient = PluginInput["client"] diff --git a/src/features/background-agent/parent-session-context-resolver.ts b/src/features/background-agent/parent-session-context-resolver.ts new file mode 100644 index 000000000..d27dd375e --- /dev/null +++ b/src/features/background-agent/parent-session-context-resolver.ts @@ -0,0 +1,75 @@ +import type { OpencodeClient } from "./constants" +import type { BackgroundTask } from "./types" +import { findNearestMessageWithFields } from "../hook-message-injector" +import { getMessageDir } from "./message-storage-locator" + +type AgentModel = { providerID: string; modelID: string } + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null +} + +function extractAgentAndModelFromMessage(message: unknown): { + agent?: string + model?: AgentModel +} { + if (!isObject(message)) return {} + const info = message["info"] + if (!isObject(info)) return {} + + const agent = typeof info["agent"] === "string" ? info["agent"] : undefined + const modelObj = info["model"] + if (isObject(modelObj)) { + const providerID = modelObj["providerID"] + const modelID = modelObj["modelID"] + if (typeof providerID === "string" && typeof modelID === "string") { + return { agent, model: { providerID, modelID } } + } + } + + const providerID = info["providerID"] + const modelID = info["modelID"] + if (typeof providerID === "string" && typeof modelID === "string") { + return { agent, model: { providerID, modelID } } + } + + return { agent } +} + +export async function resolveParentSessionAgentAndModel(input: { + client: OpencodeClient + task: BackgroundTask +}): Promise<{ agent?: string; model?: AgentModel }> { + const { client, task } = input + + let agent: string | undefined = task.parentAgent + let model: AgentModel | undefined + + try { + const messagesResp = await client.session.messages({ + path: { id: task.parentSessionID }, + }) + + const messagesRaw = "data" in messagesResp ? messagesResp.data : [] + const messages = Array.isArray(messagesRaw) ? messagesRaw : [] + + for (let i = messages.length - 1; i >= 0; i--) { + const extracted = extractAgentAndModelFromMessage(messages[i]) + if (extracted.agent || extracted.model) { + agent = extracted.agent ?? task.parentAgent + model = extracted.model + break + } + } + } catch { + const messageDir = getMessageDir(task.parentSessionID) + const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null + agent = currentMessage?.agent ?? task.parentAgent + model = + currentMessage?.model?.providerID && currentMessage?.model?.modelID + ? { providerID: currentMessage.model.providerID, modelID: currentMessage.model.modelID } + : undefined + } + + return { agent, model } +} diff --git a/src/features/background-agent/parent-session-notifier.ts b/src/features/background-agent/parent-session-notifier.ts new file mode 100644 index 000000000..9d4c1ac08 --- /dev/null +++ b/src/features/background-agent/parent-session-notifier.ts @@ -0,0 +1,102 @@ +import type { BackgroundTask } from "./types" +import type { ResultHandlerContext } from "./result-handler-context" +import { TASK_CLEANUP_DELAY_MS } from "./constants" +import { log } from "../../shared" +import { getTaskToastManager } from "../task-toast-manager" +import { formatDuration } from "./duration-formatter" +import { buildBackgroundTaskNotificationText } from "./background-task-notification-template" +import { resolveParentSessionAgentAndModel } from "./parent-session-context-resolver" + +export async function notifyParentSession( + task: BackgroundTask, + ctx: ResultHandlerContext +): Promise { + const { client, state } = ctx + + const duration = formatDuration(task.startedAt ?? new Date(), task.completedAt) + log("[background-agent] notifyParentSession called for task:", task.id) + + const toastManager = getTaskToastManager() + if (toastManager) { + toastManager.showCompletionToast({ + id: task.id, + description: task.description, + duration, + }) + } + + const pendingSet = state.pendingByParent.get(task.parentSessionID) + if (pendingSet) { + pendingSet.delete(task.id) + if (pendingSet.size === 0) { + state.pendingByParent.delete(task.parentSessionID) + } + } + + const allComplete = !pendingSet || pendingSet.size === 0 + const remainingCount = pendingSet?.size ?? 0 + + const statusText = task.status === "completed" ? "COMPLETED" : "CANCELLED" + + const completedTasks = allComplete + ? Array.from(state.tasks.values()).filter( + (t) => + t.parentSessionID === task.parentSessionID && + t.status !== "running" && + t.status !== "pending" + ) + : [] + + const notification = buildBackgroundTaskNotificationText({ + task, + duration, + statusText, + allComplete, + remainingCount, + completedTasks, + }) + + const { agent, model } = await resolveParentSessionAgentAndModel({ client, task }) + + log("[background-agent] notifyParentSession context:", { + taskId: task.id, + resolvedAgent: agent, + resolvedModel: model, + }) + + try { + await client.session.promptAsync({ + path: { id: task.parentSessionID }, + body: { + noReply: !allComplete, + ...(agent !== undefined ? { agent } : {}), + ...(model !== undefined ? { model } : {}), + parts: [{ type: "text", text: notification }], + }, + }) + + log("[background-agent] Sent notification to parent session:", { + taskId: task.id, + allComplete, + noReply: !allComplete, + }) + } catch (error) { + log("[background-agent] Failed to send notification:", error) + } + + if (!allComplete) return + + for (const completedTask of completedTasks) { + const taskId = completedTask.id + state.clearCompletionTimer(taskId) + const timer = setTimeout(() => { + state.completionTimers.delete(taskId) + if (state.tasks.has(taskId)) { + state.clearNotificationsForTask(taskId) + state.tasks.delete(taskId) + log("[background-agent] Removed completed task from memory:", taskId) + } + }, TASK_CLEANUP_DELAY_MS) + state.setCompletionTimer(taskId, timer) + } +} diff --git a/src/features/background-agent/poll-running-tasks.ts b/src/features/background-agent/poll-running-tasks.ts new file mode 100644 index 000000000..688bba6f8 --- /dev/null +++ b/src/features/background-agent/poll-running-tasks.ts @@ -0,0 +1,178 @@ +import { log } from "../../shared" + +import { + MIN_STABILITY_TIME_MS, +} from "./constants" + +import type { BackgroundTask } from "./types" +import type { OpencodeClient } from "./opencode-client" + +type SessionStatusMap = Record + +type MessagePart = { + type?: string + tool?: string + name?: string + text?: string +} + +type SessionMessage = { + info?: { role?: string } + parts?: MessagePart[] +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null +} + +function asSessionMessages(value: unknown): SessionMessage[] { + if (!Array.isArray(value)) return [] + return value.filter(isRecord) as SessionMessage[] +} + +export async function pollRunningTasks(args: { + tasks: Iterable + client: OpencodeClient + pruneStaleTasksAndNotifications: () => void + checkAndInterruptStaleTasks: () => Promise + validateSessionHasOutput: (sessionID: string) => Promise + checkSessionTodos: (sessionID: string) => Promise + tryCompleteTask: (task: BackgroundTask, source: string) => Promise + hasRunningTasks: () => boolean + stopPolling: () => void +}): Promise { + const { + tasks, + client, + pruneStaleTasksAndNotifications, + checkAndInterruptStaleTasks, + validateSessionHasOutput, + checkSessionTodos, + tryCompleteTask, + hasRunningTasks, + stopPolling, + } = args + + pruneStaleTasksAndNotifications() + await checkAndInterruptStaleTasks() + + const statusResult = await client.session.status() + const allStatuses = ((statusResult as { data?: unknown }).data ?? {}) as SessionStatusMap + + for (const task of tasks) { + if (task.status !== "running") continue + + const sessionID = task.sessionID + if (!sessionID) continue + + try { + const sessionStatus = allStatuses[sessionID] + if (sessionStatus?.type === "idle") { + const hasValidOutput = await validateSessionHasOutput(sessionID) + if (!hasValidOutput) { + log("[background-agent] Polling idle but no valid output yet, waiting:", task.id) + continue + } + + if (task.status !== "running") continue + + const hasIncompleteTodos = await checkSessionTodos(sessionID) + if (hasIncompleteTodos) { + log("[background-agent] Task has incomplete todos via polling, waiting:", task.id) + continue + } + + await tryCompleteTask(task, "polling (idle status)") + continue + } + + const messagesResult = await client.session.messages({ + path: { id: sessionID }, + }) + + if ((messagesResult as { error?: unknown }).error) { + continue + } + + const messages = asSessionMessages((messagesResult as { data?: unknown }).data) + const assistantMsgs = messages.filter((m) => m.info?.role === "assistant") + + let toolCalls = 0 + let lastTool: string | undefined + let lastMessage: string | undefined + + for (const msg of assistantMsgs) { + const parts = msg.parts ?? [] + for (const part of parts) { + if (part.type === "tool_use" || part.tool) { + toolCalls += 1 + lastTool = part.tool || part.name || "unknown" + } + if (part.type === "text" && part.text) { + lastMessage = part.text + } + } + } + + if (!task.progress) { + task.progress = { toolCalls: 0, lastUpdate: new Date() } + } + task.progress.toolCalls = toolCalls + task.progress.lastTool = lastTool + task.progress.lastUpdate = new Date() + if (lastMessage) { + task.progress.lastMessage = lastMessage + task.progress.lastMessageAt = new Date() + } + + const currentMsgCount = messages.length + const startedAt = task.startedAt + if (!startedAt) continue + + const elapsedMs = Date.now() - startedAt.getTime() + if (elapsedMs >= MIN_STABILITY_TIME_MS) { + if (task.lastMsgCount === currentMsgCount) { + task.stablePolls = (task.stablePolls ?? 0) + 1 + if (task.stablePolls >= 3) { + const recheckStatus = await client.session.status() + const recheckData = ((recheckStatus as { data?: unknown }).data ?? {}) as SessionStatusMap + const currentStatus = recheckData[sessionID] + + if (currentStatus?.type !== "idle") { + log("[background-agent] Stability reached but session not idle, resetting:", { + taskId: task.id, + sessionStatus: currentStatus?.type ?? "not_in_status", + }) + task.stablePolls = 0 + continue + } + + const hasValidOutput = await validateSessionHasOutput(sessionID) + if (!hasValidOutput) { + log("[background-agent] Stability reached but no valid output, waiting:", task.id) + continue + } + + if (task.status !== "running") continue + + const hasIncompleteTodos = await checkSessionTodos(sessionID) + if (!hasIncompleteTodos) { + await tryCompleteTask(task, "stability detection") + continue + } + } + } else { + task.stablePolls = 0 + } + } + + task.lastMsgCount = currentMsgCount + } catch (error) { + log("[background-agent] Poll error for task:", { taskId: task.id, error }) + } + } + + if (!hasRunningTasks()) { + stopPolling() + } +} diff --git a/src/features/background-agent/process-signal.ts b/src/features/background-agent/process-signal.ts new file mode 100644 index 000000000..94f1b9db5 --- /dev/null +++ b/src/features/background-agent/process-signal.ts @@ -0,0 +1,19 @@ +export type ProcessCleanupEvent = NodeJS.Signals | "beforeExit" | "exit" + +export function registerProcessSignal( + signal: ProcessCleanupEvent, + handler: () => void, + exitAfter: boolean +): () => void { + const listener = () => { + handler() + if (exitAfter) { + // Set exitCode and schedule exit after delay to allow other handlers to complete async cleanup + // Use 6s delay to accommodate LSP cleanup (5s timeout + 1s SIGKILL wait) + process.exitCode = 0 + setTimeout(() => process.exit(), 6000) + } + } + process.on(signal, listener) + return listener +} diff --git a/src/features/background-agent/result-handler-context.ts b/src/features/background-agent/result-handler-context.ts new file mode 100644 index 000000000..7aa629542 --- /dev/null +++ b/src/features/background-agent/result-handler-context.ts @@ -0,0 +1,9 @@ +import type { OpencodeClient } from "./constants" +import type { ConcurrencyManager } from "./concurrency" +import type { TaskStateManager } from "./state" + +export interface ResultHandlerContext { + client: OpencodeClient + concurrencyManager: ConcurrencyManager + state: TaskStateManager +} diff --git a/src/features/background-agent/result-handler.ts b/src/features/background-agent/result-handler.ts index 84cb90843..ccc365c8d 100644 --- a/src/features/background-agent/result-handler.ts +++ b/src/features/background-agent/result-handler.ts @@ -1,276 +1,7 @@ -import type { BackgroundTask } from "./types" -import type { OpencodeClient, Todo } from "./constants" -import { TASK_CLEANUP_DELAY_MS } from "./constants" -import { log } from "../../shared" -import { getTaskToastManager } from "../task-toast-manager" -import { findNearestMessageWithFields, MESSAGE_STORAGE } from "../hook-message-injector" -import { existsSync, readdirSync } from "node:fs" -import { join } from "node:path" -import type { ConcurrencyManager } from "./concurrency" -import type { TaskStateManager } from "./state" - -export interface ResultHandlerContext { - client: OpencodeClient - concurrencyManager: ConcurrencyManager - state: TaskStateManager -} - -export async function checkSessionTodos( - client: OpencodeClient, - sessionID: string -): Promise { - try { - const response = await client.session.todo({ - path: { id: sessionID }, - }) - const todos = (response.data ?? response) as Todo[] - if (!todos || todos.length === 0) return false - - const incomplete = todos.filter( - (t) => t.status !== "completed" && t.status !== "cancelled" - ) - return incomplete.length > 0 - } catch { - return false - } -} - -export async function validateSessionHasOutput( - client: OpencodeClient, - sessionID: string -): Promise { - try { - const response = await client.session.messages({ - path: { id: sessionID }, - }) - - const messages = response.data ?? [] - - 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 - } - - // 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) => - (p.type === "text" && p.text && p.text.trim().length > 0) || - (p.type === "reasoning" && p.text && p.text.trim().length > 0) || - p.type === "tool" || - (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) - return true - } -} - -export function formatDuration(start: Date, end?: Date): string { - const duration = (end ?? new Date()).getTime() - start.getTime() - const seconds = Math.floor(duration / 1000) - const minutes = Math.floor(seconds / 60) - const hours = Math.floor(minutes / 60) - - if (hours > 0) { - return `${hours}h ${minutes % 60}m ${seconds % 60}s` - } else if (minutes > 0) { - return `${minutes}m ${seconds % 60}s` - } - return `${seconds}s` -} - -export function getMessageDir(sessionID: string): string | null { - if (!existsSync(MESSAGE_STORAGE)) return null - - const directPath = join(MESSAGE_STORAGE, sessionID) - if (existsSync(directPath)) return directPath - - for (const dir of readdirSync(MESSAGE_STORAGE)) { - const sessionPath = join(MESSAGE_STORAGE, dir, sessionID) - if (existsSync(sessionPath)) return sessionPath - } - return null -} - -export async function tryCompleteTask( - task: BackgroundTask, - source: string, - ctx: ResultHandlerContext -): Promise { - const { concurrencyManager, state } = ctx - - if (task.status !== "running") { - log("[background-agent] Task already completed, skipping:", { taskId: task.id, status: task.status, source }) - return false - } - - task.status = "completed" - task.completedAt = new Date() - - if (task.concurrencyKey) { - concurrencyManager.release(task.concurrencyKey) - task.concurrencyKey = undefined - } - - state.markForNotification(task) - - try { - await notifyParentSession(task, ctx) - log(`[background-agent] Task completed via ${source}:`, task.id) - } catch (err) { - log("[background-agent] Error in notifyParentSession:", { taskId: task.id, error: err }) - } - - return true -} - -export async function notifyParentSession( - task: BackgroundTask, - ctx: ResultHandlerContext -): Promise { - const { client, state } = ctx - const duration = formatDuration(task.startedAt ?? new Date(), task.completedAt) - - log("[background-agent] notifyParentSession called for task:", task.id) - - const toastManager = getTaskToastManager() - if (toastManager) { - toastManager.showCompletionToast({ - id: task.id, - description: task.description, - duration, - }) - } - - const pendingSet = state.pendingByParent.get(task.parentSessionID) - if (pendingSet) { - pendingSet.delete(task.id) - if (pendingSet.size === 0) { - state.pendingByParent.delete(task.parentSessionID) - } - } - - const allComplete = !pendingSet || pendingSet.size === 0 - const remainingCount = pendingSet?.size ?? 0 - - const statusText = task.status === "completed" ? "COMPLETED" : "CANCELLED" - const errorInfo = task.error ? `\n**Error:** ${task.error}` : "" - - let notification: string - let completedTasks: BackgroundTask[] = [] - if (allComplete) { - completedTasks = Array.from(state.tasks.values()) - .filter(t => t.parentSessionID === task.parentSessionID && t.status !== "running" && t.status !== "pending") - const completedTasksText = completedTasks - .map(t => `- \`${t.id}\`: ${t.description}`) - .join("\n") - - notification = ` -[ALL BACKGROUND TASKS COMPLETE] - -**Completed:** -${completedTasksText || `- \`${task.id}\`: ${task.description}`} - -Use \`background_output(task_id="")\` to retrieve each result. -` - } else { - const agentInfo = task.category - ? `${task.agent} (${task.category})` - : task.agent - notification = ` -[BACKGROUND TASK ${statusText}] -**ID:** \`${task.id}\` -**Description:** ${task.description} -**Agent:** ${agentInfo} -**Duration:** ${duration}${errorInfo} - -**${remainingCount} task${remainingCount === 1 ? "" : "s"} still in progress.** You WILL be notified when ALL complete. -Do NOT poll - continue productive work. - -Use \`background_output(task_id="${task.id}")\` to retrieve this result when ready. -` - } - - let agent: string | undefined = task.parentAgent - let model: { providerID: string; modelID: string } | undefined - - try { - const messagesResp = await client.session.messages({ path: { id: task.parentSessionID } }) - const messages = (messagesResp.data ?? []) as Array<{ - info?: { agent?: string; model?: { providerID: string; modelID: string }; modelID?: string; providerID?: string } - }> - for (let i = messages.length - 1; i >= 0; i--) { - const info = messages[i].info - if (info?.agent || info?.model || (info?.modelID && info?.providerID)) { - agent = info.agent ?? task.parentAgent - model = info.model ?? (info.providerID && info.modelID ? { providerID: info.providerID, modelID: info.modelID } : undefined) - break - } - } - } catch { - const messageDir = getMessageDir(task.parentSessionID) - const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null - agent = currentMessage?.agent ?? task.parentAgent - model = currentMessage?.model?.providerID && currentMessage?.model?.modelID - ? { providerID: currentMessage.model.providerID, modelID: currentMessage.model.modelID } - : undefined - } - - log("[background-agent] notifyParentSession context:", { - taskId: task.id, - resolvedAgent: agent, - resolvedModel: model, - }) - - try { - await client.session.promptAsync({ - path: { id: task.parentSessionID }, - body: { - noReply: !allComplete, - ...(agent !== undefined ? { agent } : {}), - ...(model !== undefined ? { model } : {}), - parts: [{ type: "text", text: notification }], - }, - }) - log("[background-agent] Sent notification to parent session:", { - taskId: task.id, - allComplete, - noReply: !allComplete, - }) - } catch (error) { - log("[background-agent] Failed to send notification:", error) - } - - if (allComplete) { - for (const completedTask of completedTasks) { - const taskId = completedTask.id - state.clearCompletionTimer(taskId) - const timer = setTimeout(() => { - state.completionTimers.delete(taskId) - if (state.tasks.has(taskId)) { - state.clearNotificationsForTask(taskId) - state.tasks.delete(taskId) - log("[background-agent] Removed completed task from memory:", taskId) - } - }, TASK_CLEANUP_DELAY_MS) - state.setCompletionTimer(taskId, timer) - } - } -} +export type { ResultHandlerContext } from "./result-handler-context" +export { formatDuration } from "./duration-formatter" +export { getMessageDir } from "./message-storage-locator" +export { checkSessionTodos } from "./session-todo-checker" +export { validateSessionHasOutput } from "./session-output-validator" +export { tryCompleteTask } from "./background-task-completer" +export { notifyParentSession } from "./parent-session-notifier" diff --git a/src/features/background-agent/session-output-validator.ts b/src/features/background-agent/session-output-validator.ts new file mode 100644 index 000000000..136bcc41c --- /dev/null +++ b/src/features/background-agent/session-output-validator.ts @@ -0,0 +1,88 @@ +import type { OpencodeClient } from "./constants" +import { log } from "../../shared" + +type SessionMessagePart = { + type?: string + text?: string + content?: unknown +} + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null +} + +function getMessageRole(message: unknown): string | undefined { + if (!isObject(message)) return undefined + const info = message["info"] + if (!isObject(info)) return undefined + const role = info["role"] + return typeof role === "string" ? role : undefined +} + +function getMessageParts(message: unknown): SessionMessagePart[] { + if (!isObject(message)) return [] + const parts = message["parts"] + if (!Array.isArray(parts)) return [] + + return parts + .filter((part): part is SessionMessagePart => isObject(part)) + .map((part) => ({ + type: typeof part["type"] === "string" ? part["type"] : undefined, + text: typeof part["text"] === "string" ? part["text"] : undefined, + content: part["content"], + })) +} + +function partHasContent(part: SessionMessagePart): boolean { + if (part.type === "text" || part.type === "reasoning") { + return Boolean(part.text && part.text.trim().length > 0) + } + if (part.type === "tool") return true + if (part.type === "tool_result") { + if (typeof part.content === "string") return part.content.trim().length > 0 + if (Array.isArray(part.content)) return part.content.length > 0 + return Boolean(part.content) + } + return false +} + +export async function validateSessionHasOutput( + client: OpencodeClient, + sessionID: string +): Promise { + try { + const response = await client.session.messages({ + path: { id: sessionID }, + }) + + const messagesRaw = "data" in response ? response.data : [] + const messages = Array.isArray(messagesRaw) ? messagesRaw : [] + + const hasAssistantOrToolMessage = messages.some((message) => { + const role = getMessageRole(message) + return role === "assistant" || role === "tool" + }) + + if (!hasAssistantOrToolMessage) { + log("[background-agent] No assistant/tool messages found in session:", sessionID) + return false + } + + const hasContent = messages.some((message) => { + const role = getMessageRole(message) + if (role !== "assistant" && role !== "tool") return false + const parts = getMessageParts(message) + return parts.some(partHasContent) + }) + + 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) + return true + } +} diff --git a/src/features/background-agent/session-todo-checker.ts b/src/features/background-agent/session-todo-checker.ts new file mode 100644 index 000000000..3feaedbf7 --- /dev/null +++ b/src/features/background-agent/session-todo-checker.ts @@ -0,0 +1,33 @@ +import type { OpencodeClient, Todo } from "./constants" + +function isTodo(value: unknown): value is Todo { + if (typeof value !== "object" || value === null) return false + const todo = value as Record + return ( + typeof todo["id"] === "string" && + typeof todo["content"] === "string" && + typeof todo["status"] === "string" && + typeof todo["priority"] === "string" + ) +} + +export async function checkSessionTodos( + client: OpencodeClient, + sessionID: string +): Promise { + try { + const response = await client.session.todo({ + path: { id: sessionID }, + }) + + const todosRaw = "data" in response ? response.data : response + if (!Array.isArray(todosRaw) || todosRaw.length === 0) return false + + const incomplete = todosRaw + .filter(isTodo) + .filter((todo) => todo.status !== "completed" && todo.status !== "cancelled") + return incomplete.length > 0 + } catch { + return false + } +} diff --git a/src/features/background-agent/session-validator.ts b/src/features/background-agent/session-validator.ts new file mode 100644 index 000000000..6181dec9b --- /dev/null +++ b/src/features/background-agent/session-validator.ts @@ -0,0 +1,111 @@ +import { log } from "../../shared" + +import type { OpencodeClient } from "./opencode-client" + +type Todo = { + content: string + status: string + priority: string + id: string +} + +type SessionMessage = { + info?: { role?: string } + parts?: unknown +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null +} + +function asSessionMessages(value: unknown): SessionMessage[] { + if (!Array.isArray(value)) return [] + return value as SessionMessage[] +} + +function asParts(value: unknown): Array> { + if (!Array.isArray(value)) return [] + return value.filter(isRecord) +} + +function hasNonEmptyText(value: unknown): boolean { + return typeof value === "string" && value.trim().length > 0 +} + +function isToolResultContentNonEmpty(content: unknown): boolean { + if (typeof content === "string") return content.trim().length > 0 + if (Array.isArray(content)) return content.length > 0 + return false +} + +/** + * Validates that a session has actual assistant/tool output before marking complete. + * Prevents premature completion when session.idle fires before agent responds. + */ +export async function validateSessionHasOutput( + client: OpencodeClient, + sessionID: string +): Promise { + try { + const response = await client.session.messages({ + path: { id: sessionID }, + }) + + const messages = asSessionMessages((response as { data?: unknown }).data ?? response) + + const hasAssistantOrToolMessage = messages.some( + (m) => m.info?.role === "assistant" || m.info?.role === "tool" + ) + if (!hasAssistantOrToolMessage) { + log("[background-agent] No assistant/tool messages found in session:", sessionID) + return false + } + + const hasContent = messages.some((m) => { + if (m.info?.role !== "assistant" && m.info?.role !== "tool") return false + + const parts = asParts(m.parts) + return parts.some((part) => { + const type = part.type + if (type === "tool") return true + if (type === "text" && hasNonEmptyText(part.text)) return true + if (type === "reasoning" && hasNonEmptyText(part.text)) return true + if (type === "tool_result" && isToolResultContentNonEmpty(part.content)) return true + return false + }) + }) + + 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 + } +} + +export async function checkSessionTodos( + client: OpencodeClient, + sessionID: string +): Promise { + try { + const response = await client.session.todo({ + path: { id: sessionID }, + }) + + const raw = (response as { data?: unknown }).data ?? response + const todos = Array.isArray(raw) ? (raw as Todo[]) : [] + if (todos.length === 0) return false + + const incomplete = todos.filter( + (t) => t.status !== "completed" && t.status !== "cancelled" + ) + return incomplete.length > 0 + } catch { + return false + } +} diff --git a/src/features/background-agent/spawner.ts b/src/features/background-agent/spawner.ts index 477aafc18..ddf0e1534 100644 --- a/src/features/background-agent/spawner.ts +++ b/src/features/background-agent/spawner.ts @@ -1,242 +1,4 @@ -import type { BackgroundTask, LaunchInput, ResumeInput } from "./types" -import type { OpencodeClient, OnSubagentSessionCreated, QueueItem } from "./constants" -import { TMUX_CALLBACK_DELAY_MS } from "./constants" -import { log, getAgentToolRestrictions, promptWithModelSuggestionRetry } from "../../shared" -import { subagentSessions } from "../claude-code-session-state" -import { getTaskToastManager } from "../task-toast-manager" -import { isInsideTmux } from "../../shared/tmux" -import type { ConcurrencyManager } from "./concurrency" - -export interface SpawnerContext { - client: OpencodeClient - directory: string - concurrencyManager: ConcurrencyManager - tmuxEnabled: boolean - onSubagentSessionCreated?: OnSubagentSessionCreated - onTaskError: (task: BackgroundTask, error: Error) => void -} - -export function createTask(input: LaunchInput): BackgroundTask { - return { - id: `bg_${crypto.randomUUID().slice(0, 8)}`, - status: "pending", - queuedAt: new Date(), - description: input.description, - prompt: input.prompt, - agent: input.agent, - parentSessionID: input.parentSessionID, - parentMessageID: input.parentMessageID, - parentModel: input.parentModel, - parentAgent: input.parentAgent, - model: input.model, - } -} - -export async function startTask( - item: QueueItem, - ctx: SpawnerContext -): Promise { - const { task, input } = item - const { client, directory, concurrencyManager, tmuxEnabled, onSubagentSessionCreated, onTaskError } = ctx - - log("[background-agent] Starting task:", { - taskId: task.id, - agent: input.agent, - model: input.model, - }) - - const concurrencyKey = input.model - ? `${input.model.providerID}/${input.model.modelID}` - : input.agent - - const parentSession = await 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 ?? directory - log(`[background-agent] Parent dir: ${parentSession?.data?.directory}, using: ${parentDirectory}`) - - const createResult = await client.session.create({ - body: { - parentID: input.parentSessionID, - title: `Background: ${input.description}`, - permission: [ - { permission: "question", action: "deny" as const, pattern: "*" }, - ], - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any, - query: { - directory: parentDirectory, - }, - }).catch((error) => { - concurrencyManager.release(concurrencyKey) - throw error - }) - - if (createResult.error) { - concurrencyManager.release(concurrencyKey) - throw new Error(`Failed to create background session: ${createResult.error}`) - } - - const sessionID = createResult.data.id - subagentSessions.add(sessionID) - - log("[background-agent] tmux callback check", { - hasCallback: !!onSubagentSessionCreated, - tmuxEnabled, - isInsideTmux: isInsideTmux(), - sessionID, - parentID: input.parentSessionID, - }) - - if (onSubagentSessionCreated && tmuxEnabled && isInsideTmux()) { - log("[background-agent] Invoking tmux callback NOW", { sessionID }) - await 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") - await new Promise(r => setTimeout(r, TMUX_CALLBACK_DELAY_MS)) - } else { - log("[background-agent] SKIP tmux callback - conditions not met") - } - - task.status = "running" - task.startedAt = new Date() - task.sessionID = sessionID - task.progress = { - toolCalls: 0, - lastUpdate: new Date(), - } - task.concurrencyKey = concurrencyKey - task.concurrencyGroup = concurrencyKey - - log("[background-agent] Launching task:", { taskId: task.id, sessionID, agent: input.agent }) - - const toastManager = getTaskToastManager() - if (toastManager) { - toastManager.updateTask(task.id, "running") - } - - 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, - }) - - const launchModel = input.model - ? { providerID: input.model.providerID, modelID: input.model.modelID } - : undefined - const launchVariant = input.model?.variant - - promptWithModelSuggestionRetry(client, { - path: { id: sessionID }, - body: { - agent: input.agent, - ...(launchModel ? { model: launchModel } : {}), - ...(launchVariant ? { variant: launchVariant } : {}), - system: input.skillContent, - tools: { - ...getAgentToolRestrictions(input.agent), - task: false, - call_omo_agent: true, - question: false, - }, - parts: [{ type: "text", text: input.prompt }], - }, - }).catch((error) => { - log("[background-agent] promptAsync error:", error) - onTaskError(task, error instanceof Error ? error : new Error(String(error))) - }) -} - -export async function resumeTask( - task: BackgroundTask, - input: ResumeInput, - ctx: Pick -): Promise { - const { client, concurrencyManager, onTaskError } = ctx - - if (!task.sessionID) { - throw new Error(`Task has no sessionID: ${task.id}`) - } - - if (task.status === "running") { - log("[background-agent] Resume skipped - task already running:", { - taskId: task.id, - sessionID: task.sessionID, - }) - return - } - - const concurrencyKey = task.concurrencyGroup ?? task.agent - await concurrencyManager.acquire(concurrencyKey) - task.concurrencyKey = concurrencyKey - task.concurrencyGroup = concurrencyKey - - task.status = "running" - task.completedAt = undefined - task.error = undefined - task.parentSessionID = input.parentSessionID - task.parentMessageID = input.parentMessageID - task.parentModel = input.parentModel - task.parentAgent = input.parentAgent - task.startedAt = new Date() - - task.progress = { - toolCalls: task.progress?.toolCalls ?? 0, - lastUpdate: new Date(), - } - - subagentSessions.add(task.sessionID) - - const toastManager = getTaskToastManager() - if (toastManager) { - toastManager.addTask({ - id: task.id, - description: task.description, - agent: task.agent, - isBackground: true, - }) - } - - log("[background-agent] Resuming task:", { taskId: task.id, sessionID: task.sessionID }) - - log("[background-agent] Resuming task - calling prompt (fire-and-forget) with:", { - sessionID: task.sessionID, - agent: task.agent, - model: task.model, - promptLength: input.prompt.length, - }) - - const resumeModel = task.model - ? { providerID: task.model.providerID, modelID: task.model.modelID } - : undefined - const resumeVariant = task.model?.variant - - client.session.promptAsync({ - path: { id: task.sessionID }, - body: { - agent: task.agent, - ...(resumeModel ? { model: resumeModel } : {}), - ...(resumeVariant ? { variant: resumeVariant } : {}), - tools: { - ...getAgentToolRestrictions(task.agent), - task: false, - call_omo_agent: true, - question: false, - }, - parts: [{ type: "text", text: input.prompt }], - }, - }).catch((error) => { - log("[background-agent] resume prompt error:", error) - onTaskError(task, error instanceof Error ? error : new Error(String(error))) - }) -} +export type { SpawnerContext } from "./spawner/spawner-context" +export { createTask } from "./spawner/task-factory" +export { startTask } from "./spawner/task-starter" +export { resumeTask } from "./spawner/task-resumer" diff --git a/src/features/background-agent/spawner/background-session-creator.ts b/src/features/background-agent/spawner/background-session-creator.ts new file mode 100644 index 000000000..9b27d869d --- /dev/null +++ b/src/features/background-agent/spawner/background-session-creator.ts @@ -0,0 +1,45 @@ +import type { OpencodeClient } from "../constants" +import type { ConcurrencyManager } from "../concurrency" +import type { LaunchInput } from "../types" +import { log } from "../../../shared" + +export async function createBackgroundSession(options: { + client: OpencodeClient + input: LaunchInput + parentDirectory: string + concurrencyManager: ConcurrencyManager + concurrencyKey: string +}): Promise { + const { client, input, parentDirectory, concurrencyManager, concurrencyKey } = options + + const body = { + parentID: input.parentSessionID, + title: `Background: ${input.description}`, + } + + const createResult = await client.session + .create({ + body, + query: { + directory: parentDirectory, + }, + }) + .catch((error: unknown) => { + concurrencyManager.release(concurrencyKey) + throw error + }) + + if (createResult.error) { + concurrencyManager.release(concurrencyKey) + throw new Error(`Failed to create background session: ${createResult.error}`) + } + + if (!createResult.data?.id) { + concurrencyManager.release(concurrencyKey) + throw new Error("Failed to create background session: API returned no session ID") + } + + const sessionID = createResult.data.id + log("[background-agent] Background session created", { sessionID }) + return sessionID +} diff --git a/src/features/background-agent/spawner/concurrency-key-from-launch-input.ts b/src/features/background-agent/spawner/concurrency-key-from-launch-input.ts new file mode 100644 index 000000000..7165877cc --- /dev/null +++ b/src/features/background-agent/spawner/concurrency-key-from-launch-input.ts @@ -0,0 +1,7 @@ +import type { LaunchInput } from "../types" + +export function getConcurrencyKeyFromLaunchInput(input: LaunchInput): string { + return input.model + ? `${input.model.providerID}/${input.model.modelID}` + : input.agent +} diff --git a/src/features/background-agent/spawner/parent-directory-resolver.ts b/src/features/background-agent/spawner/parent-directory-resolver.ts new file mode 100644 index 000000000..7e527551f --- /dev/null +++ b/src/features/background-agent/spawner/parent-directory-resolver.ts @@ -0,0 +1,21 @@ +import type { OpencodeClient } from "../constants" +import { log } from "../../../shared" + +export async function resolveParentDirectory(options: { + client: OpencodeClient + parentSessionID: string + defaultDirectory: string +}): Promise { + const { client, parentSessionID, defaultDirectory } = options + + const parentSession = await client.session + .get({ path: { id: parentSessionID } }) + .catch((error: unknown) => { + log(`[background-agent] Failed to get parent session: ${error}`) + return null + }) + + const parentDirectory = parentSession?.data?.directory ?? defaultDirectory + log(`[background-agent] Parent dir: ${parentSession?.data?.directory}, using: ${parentDirectory}`) + return parentDirectory +} diff --git a/src/features/background-agent/spawner/spawner-context.ts b/src/features/background-agent/spawner/spawner-context.ts new file mode 100644 index 000000000..3b6bb1484 --- /dev/null +++ b/src/features/background-agent/spawner/spawner-context.ts @@ -0,0 +1,12 @@ +import type { BackgroundTask } from "../types" +import type { ConcurrencyManager } from "../concurrency" +import type { OpencodeClient, OnSubagentSessionCreated } from "../constants" + +export interface SpawnerContext { + client: OpencodeClient + directory: string + concurrencyManager: ConcurrencyManager + tmuxEnabled: boolean + onSubagentSessionCreated?: OnSubagentSessionCreated + onTaskError: (task: BackgroundTask, error: Error) => void +} diff --git a/src/features/background-agent/spawner/task-factory.ts b/src/features/background-agent/spawner/task-factory.ts new file mode 100644 index 000000000..7af433d41 --- /dev/null +++ b/src/features/background-agent/spawner/task-factory.ts @@ -0,0 +1,18 @@ +import { randomUUID } from "crypto" +import type { BackgroundTask, LaunchInput } from "../types" + +export function createTask(input: LaunchInput): BackgroundTask { + return { + id: `bg_${randomUUID().slice(0, 8)}`, + status: "pending", + queuedAt: new Date(), + description: input.description, + prompt: input.prompt, + agent: input.agent, + parentSessionID: input.parentSessionID, + parentMessageID: input.parentMessageID, + parentModel: input.parentModel, + parentAgent: input.parentAgent, + model: input.model, + } +} diff --git a/src/features/background-agent/spawner/task-resumer.ts b/src/features/background-agent/spawner/task-resumer.ts new file mode 100644 index 000000000..4a517a651 --- /dev/null +++ b/src/features/background-agent/spawner/task-resumer.ts @@ -0,0 +1,91 @@ +import type { BackgroundTask, ResumeInput } from "../types" +import { log, getAgentToolRestrictions } from "../../../shared" +import type { SpawnerContext } from "./spawner-context" +import { subagentSessions } from "../../claude-code-session-state" +import { getTaskToastManager } from "../../task-toast-manager" + +export async function resumeTask( + task: BackgroundTask, + input: ResumeInput, + ctx: Pick +): Promise { + const { client, concurrencyManager, onTaskError } = ctx + + if (!task.sessionID) { + throw new Error(`Task has no sessionID: ${task.id}`) + } + + if (task.status === "running") { + log("[background-agent] Resume skipped - task already running:", { + taskId: task.id, + sessionID: task.sessionID, + }) + return + } + + const concurrencyKey = task.concurrencyGroup ?? task.agent + await concurrencyManager.acquire(concurrencyKey) + task.concurrencyKey = concurrencyKey + task.concurrencyGroup = concurrencyKey + + task.status = "running" + task.completedAt = undefined + task.error = undefined + task.parentSessionID = input.parentSessionID + task.parentMessageID = input.parentMessageID + task.parentModel = input.parentModel + task.parentAgent = input.parentAgent + task.startedAt = new Date() + + task.progress = { + toolCalls: task.progress?.toolCalls ?? 0, + lastUpdate: new Date(), + } + + subagentSessions.add(task.sessionID) + + const toastManager = getTaskToastManager() + if (toastManager) { + toastManager.addTask({ + id: task.id, + description: task.description, + agent: task.agent, + isBackground: true, + }) + } + + log("[background-agent] Resuming task:", { taskId: task.id, sessionID: task.sessionID }) + + log("[background-agent] Resuming task - calling prompt (fire-and-forget) with:", { + sessionID: task.sessionID, + agent: task.agent, + model: task.model, + promptLength: input.prompt.length, + }) + + const resumeModel = task.model + ? { providerID: task.model.providerID, modelID: task.model.modelID } + : undefined + const resumeVariant = task.model?.variant + + client.session + .promptAsync({ + path: { id: task.sessionID }, + body: { + agent: task.agent, + ...(resumeModel ? { model: resumeModel } : {}), + ...(resumeVariant ? { variant: resumeVariant } : {}), + tools: { + ...getAgentToolRestrictions(task.agent), + task: false, + call_omo_agent: true, + question: false, + }, + parts: [{ type: "text", text: input.prompt }], + }, + }) + .catch((error: unknown) => { + log("[background-agent] resume prompt error:", error) + onTaskError(task, error instanceof Error ? error : new Error(String(error))) + }) +} diff --git a/src/features/background-agent/spawner/task-starter.ts b/src/features/background-agent/spawner/task-starter.ts new file mode 100644 index 000000000..4dfb48d1f --- /dev/null +++ b/src/features/background-agent/spawner/task-starter.ts @@ -0,0 +1,94 @@ +import type { QueueItem } from "../constants" +import { log, getAgentToolRestrictions, promptWithModelSuggestionRetry } from "../../../shared" +import { subagentSessions } from "../../claude-code-session-state" +import { getTaskToastManager } from "../../task-toast-manager" +import { createBackgroundSession } from "./background-session-creator" +import { getConcurrencyKeyFromLaunchInput } from "./concurrency-key-from-launch-input" +import { resolveParentDirectory } from "./parent-directory-resolver" +import type { SpawnerContext } from "./spawner-context" +import { maybeInvokeTmuxCallback } from "./tmux-callback-invoker" + +export async function startTask(item: QueueItem, ctx: SpawnerContext): Promise { + const { task, input } = item + const { client, directory, concurrencyManager, tmuxEnabled, onSubagentSessionCreated, onTaskError } = ctx + + log("[background-agent] Starting task:", { + taskId: task.id, + agent: input.agent, + model: input.model, + }) + + const concurrencyKey = getConcurrencyKeyFromLaunchInput(input) + const parentDirectory = await resolveParentDirectory({ + client, + parentSessionID: input.parentSessionID, + defaultDirectory: directory, + }) + + const sessionID = await createBackgroundSession({ + client, + input, + parentDirectory, + concurrencyManager, + concurrencyKey, + }) + subagentSessions.add(sessionID) + + await maybeInvokeTmuxCallback({ + onSubagentSessionCreated, + tmuxEnabled, + sessionID, + parentID: input.parentSessionID, + title: input.description, + }) + + task.status = "running" + task.startedAt = new Date() + task.sessionID = sessionID + task.progress = { + toolCalls: 0, + lastUpdate: new Date(), + } + task.concurrencyKey = concurrencyKey + task.concurrencyGroup = concurrencyKey + + log("[background-agent] Launching task:", { taskId: task.id, sessionID, agent: input.agent }) + + const toastManager = getTaskToastManager() + if (toastManager) { + toastManager.updateTask(task.id, "running") + } + + 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, + }) + + const launchModel = input.model + ? { providerID: input.model.providerID, modelID: input.model.modelID } + : undefined + const launchVariant = input.model?.variant + + promptWithModelSuggestionRetry(client, { + path: { id: sessionID }, + body: { + agent: input.agent, + ...(launchModel ? { model: launchModel } : {}), + ...(launchVariant ? { variant: launchVariant } : {}), + system: input.skillContent, + tools: { + ...getAgentToolRestrictions(input.agent), + task: false, + call_omo_agent: true, + question: false, + }, + parts: [{ type: "text", text: input.prompt }], + }, + }).catch((error: unknown) => { + log("[background-agent] promptAsync error:", error) + onTaskError(task, error instanceof Error ? error : new Error(String(error))) + }) +} diff --git a/src/features/background-agent/spawner/tmux-callback-invoker.ts b/src/features/background-agent/spawner/tmux-callback-invoker.ts new file mode 100644 index 000000000..139dd8b71 --- /dev/null +++ b/src/features/background-agent/spawner/tmux-callback-invoker.ts @@ -0,0 +1,40 @@ +import { setTimeout } from "timers/promises" +import type { OnSubagentSessionCreated } from "../constants" +import { TMUX_CALLBACK_DELAY_MS } from "../constants" +import { log } from "../../../shared" +import { isInsideTmux } from "../../../shared/tmux" + +export async function maybeInvokeTmuxCallback(options: { + onSubagentSessionCreated?: OnSubagentSessionCreated + tmuxEnabled: boolean + sessionID: string + parentID: string + title: string +}): Promise { + const { onSubagentSessionCreated, tmuxEnabled, sessionID, parentID, title } = options + + log("[background-agent] tmux callback check", { + hasCallback: !!onSubagentSessionCreated, + tmuxEnabled, + isInsideTmux: isInsideTmux(), + sessionID, + parentID, + }) + + if (!onSubagentSessionCreated || !tmuxEnabled || !isInsideTmux()) { + log("[background-agent] SKIP tmux callback - conditions not met") + return + } + + log("[background-agent] Invoking tmux callback NOW", { sessionID }) + await onSubagentSessionCreated({ + sessionID, + parentID, + title, + }).catch((error: unknown) => { + log("[background-agent] Failed to spawn tmux pane:", error) + }) + + log("[background-agent] tmux callback completed, waiting") + await setTimeout(TMUX_CALLBACK_DELAY_MS) +} diff --git a/src/features/background-agent/stale-task-pruner.ts b/src/features/background-agent/stale-task-pruner.ts new file mode 100644 index 000000000..160f73138 --- /dev/null +++ b/src/features/background-agent/stale-task-pruner.ts @@ -0,0 +1,57 @@ +import { log } from "../../shared" + +import { TASK_TTL_MS } from "./constants" +import { subagentSessions } from "../claude-code-session-state" +import { pruneStaleTasksAndNotifications } from "./task-poller" + +import type { BackgroundTask } from "./types" +import type { ConcurrencyManager } from "./concurrency" + +export function pruneStaleState(args: { + tasks: Map + notifications: Map + concurrencyManager: ConcurrencyManager + cleanupPendingByParent: (task: BackgroundTask) => void + clearNotificationsForTask: (taskId: string) => void +}): void { + const { + tasks, + notifications, + concurrencyManager, + cleanupPendingByParent, + clearNotificationsForTask, + } = args + + pruneStaleTasksAndNotifications({ + tasks, + notifications, + onTaskPruned: (taskId, task, errorMessage) => { + const now = Date.now() + const timestamp = task.status === "pending" + ? task.queuedAt?.getTime() + : task.startedAt?.getTime() + const age = timestamp ? now - timestamp : TASK_TTL_MS + + log("[background-agent] Pruning stale task:", { + taskId, + status: task.status, + age: Math.round(age / 1000) + "s", + }) + + task.status = "error" + task.error = errorMessage + task.completedAt = new Date() + if (task.concurrencyKey) { + concurrencyManager.release(task.concurrencyKey) + task.concurrencyKey = undefined + } + + cleanupPendingByParent(task) + clearNotificationsForTask(taskId) + tasks.delete(taskId) + if (task.sessionID) { + subagentSessions.delete(task.sessionID) + } + }, + }) +} diff --git a/src/features/background-agent/state.ts b/src/features/background-agent/state.ts index 3997dcf6f..e3a1cd57b 100644 --- a/src/features/background-agent/state.ts +++ b/src/features/background-agent/state.ts @@ -2,7 +2,6 @@ import type { BackgroundTask, LaunchInput } from "./types" import type { QueueItem } from "./constants" import { log } from "../../shared" import { subagentSessions } from "../claude-code-session-state" - export class TaskStateManager { readonly tasks: Map = new Map() readonly notifications: Map = new Map() @@ -10,11 +9,9 @@ export class TaskStateManager { readonly queuesByKey: Map = new Map() readonly processingKeys: Set = new Set() readonly completionTimers: Map> = new Map() - getTask(id: string): BackgroundTask | undefined { return this.tasks.get(id) } - findBySession(sessionID: string): BackgroundTask | undefined { for (const task of this.tasks.values()) { if (task.sessionID === sessionID) { @@ -23,7 +20,6 @@ export class TaskStateManager { } return undefined } - getTasksByParentSession(sessionID: string): BackgroundTask[] { const result: BackgroundTask[] = [] for (const task of this.tasks.values()) { @@ -52,7 +48,6 @@ export class TaskStateManager { getRunningTasks(): BackgroundTask[] { return Array.from(this.tasks.values()).filter(t => t.status === "running") } - getCompletedTasks(): BackgroundTask[] { return Array.from(this.tasks.values()).filter(t => t.status !== "running") } diff --git a/src/features/background-agent/task-canceller.ts b/src/features/background-agent/task-canceller.ts new file mode 100644 index 000000000..f4aa940fb --- /dev/null +++ b/src/features/background-agent/task-canceller.ts @@ -0,0 +1,117 @@ +import { log } from "../../shared" + +import type { BackgroundTask } from "./types" +import type { LaunchInput } from "./types" +import type { ConcurrencyManager } from "./concurrency" +import type { OpencodeClient } from "./opencode-client" + +type QueueItem = { task: BackgroundTask; input: LaunchInput } + +export async function cancelBackgroundTask(args: { + taskId: string + options?: { + source?: string + reason?: string + abortSession?: boolean + skipNotification?: boolean + } + tasks: Map + queuesByKey: Map + completionTimers: Map> + idleDeferralTimers: Map> + concurrencyManager: ConcurrencyManager + client: OpencodeClient + cleanupPendingByParent: (task: BackgroundTask) => void + markForNotification: (task: BackgroundTask) => void + notifyParentSession: (task: BackgroundTask) => Promise +}): Promise { + const { + taskId, + options, + tasks, + queuesByKey, + completionTimers, + idleDeferralTimers, + concurrencyManager, + client, + cleanupPendingByParent, + markForNotification, + notifyParentSession, + } = args + + const task = tasks.get(taskId) + if (!task || (task.status !== "running" && task.status !== "pending")) { + return false + } + + 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 = 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) { + queuesByKey.delete(key) + } + } + } + log("[background-agent] Cancelled pending task:", { taskId, key }) + } + + task.status = "cancelled" + task.completedAt = new Date() + if (reason) { + task.error = reason + } + + if (task.concurrencyKey) { + concurrencyManager.release(task.concurrencyKey) + task.concurrencyKey = undefined + } + + const completionTimer = completionTimers.get(task.id) + if (completionTimer) { + clearTimeout(completionTimer) + completionTimers.delete(task.id) + } + + const idleTimer = idleDeferralTimers.get(task.id) + if (idleTimer) { + clearTimeout(idleTimer) + idleDeferralTimers.delete(task.id) + } + + cleanupPendingByParent(task) + + if (abortSession && task.sessionID) { + client.session.abort({ + path: { id: task.sessionID }, + }).catch(() => {}) + } + + if (options?.skipNotification) { + log(`[background-agent] Task cancelled via ${source} (notification skipped):`, task.id) + return true + } + + markForNotification(task) + + try { + await notifyParentSession(task) + 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 +} diff --git a/src/features/background-agent/task-completer.ts b/src/features/background-agent/task-completer.ts new file mode 100644 index 000000000..028c8534c --- /dev/null +++ b/src/features/background-agent/task-completer.ts @@ -0,0 +1,68 @@ +import { log } from "../../shared" + +import type { BackgroundTask } from "./types" +import type { ConcurrencyManager } from "./concurrency" +import type { OpencodeClient } from "./opencode-client" + +export async function tryCompleteBackgroundTask(args: { + task: BackgroundTask + source: string + concurrencyManager: ConcurrencyManager + idleDeferralTimers: Map> + client: OpencodeClient + markForNotification: (task: BackgroundTask) => void + cleanupPendingByParent: (task: BackgroundTask) => void + notifyParentSession: (task: BackgroundTask) => Promise +}): Promise { + const { + task, + source, + concurrencyManager, + idleDeferralTimers, + client, + markForNotification, + cleanupPendingByParent, + notifyParentSession, + } = args + + if (task.status !== "running") { + log("[background-agent] Task already completed, skipping:", { + taskId: task.id, + status: task.status, + source, + }) + return false + } + + task.status = "completed" + task.completedAt = new Date() + + if (task.concurrencyKey) { + concurrencyManager.release(task.concurrencyKey) + task.concurrencyKey = undefined + } + + markForNotification(task) + cleanupPendingByParent(task) + + const idleTimer = idleDeferralTimers.get(task.id) + if (idleTimer) { + clearTimeout(idleTimer) + idleDeferralTimers.delete(task.id) + } + + if (task.sessionID) { + client.session.abort({ + path: { id: task.sessionID }, + }).catch(() => {}) + } + + try { + await notifyParentSession(task) + log(`[background-agent] Task completed via ${source}:`, task.id) + } catch (err) { + log("[background-agent] Error in notifyParentSession:", { taskId: task.id, error: err }) + } + + return true +} diff --git a/src/features/background-agent/task-launch.ts b/src/features/background-agent/task-launch.ts new file mode 100644 index 000000000..37a7785b9 --- /dev/null +++ b/src/features/background-agent/task-launch.ts @@ -0,0 +1,77 @@ +import { getTaskToastManager } from "../task-toast-manager" +import { log } from "../../shared" + +import type { BackgroundTask } from "./types" +import type { LaunchInput } from "./types" + +type QueueItem = { + task: BackgroundTask + input: LaunchInput +} + +export function launchBackgroundTask(args: { + input: LaunchInput + tasks: Map + pendingByParent: Map> + queuesByKey: Map + getConcurrencyKeyFromInput: (input: LaunchInput) => string + processKey: (key: string) => void +}): BackgroundTask { + const { input, tasks, pendingByParent, queuesByKey, getConcurrencyKeyFromInput, processKey } = args + + log("[background-agent] launch() called with:", { + agent: input.agent, + model: input.model, + description: input.description, + parentSessionID: input.parentSessionID, + }) + + if (!input.agent || input.agent.trim() === "") { + throw new Error("Agent parameter is required") + } + + const task: BackgroundTask = { + id: `bg_${crypto.randomUUID().slice(0, 8)}`, + status: "pending", + queuedAt: new Date(), + description: input.description, + prompt: input.prompt, + agent: input.agent, + parentSessionID: input.parentSessionID, + parentMessageID: input.parentMessageID, + parentModel: input.parentModel, + parentAgent: input.parentAgent, + model: input.model, + category: input.category, + } + + tasks.set(task.id, task) + + if (input.parentSessionID) { + const pending = pendingByParent.get(input.parentSessionID) ?? new Set() + pending.add(task.id) + pendingByParent.set(input.parentSessionID, pending) + } + + const key = getConcurrencyKeyFromInput(input) + const queue = queuesByKey.get(key) ?? [] + queue.push({ task, input }) + queuesByKey.set(key, queue) + + log("[background-agent] Task queued:", { taskId: task.id, key, queueLength: queue.length }) + + const toastManager = getTaskToastManager() + if (toastManager) { + toastManager.addTask({ + id: task.id, + description: input.description, + agent: input.agent, + isBackground: true, + status: "queued", + skills: input.skills, + }) + } + + processKey(key) + return task +} diff --git a/src/features/background-agent/task-poller.ts b/src/features/background-agent/task-poller.ts new file mode 100644 index 000000000..a3b48b8c0 --- /dev/null +++ b/src/features/background-agent/task-poller.ts @@ -0,0 +1,107 @@ +import { log } from "../../shared" + +import type { BackgroundTaskConfig } from "../../config/schema" +import type { BackgroundTask } from "./types" +import type { ConcurrencyManager } from "./concurrency" +import type { OpencodeClient } from "./opencode-client" + +import { + DEFAULT_STALE_TIMEOUT_MS, + MIN_RUNTIME_BEFORE_STALE_MS, + TASK_TTL_MS, +} from "./constants" + +export function pruneStaleTasksAndNotifications(args: { + tasks: Map + notifications: Map + onTaskPruned: (taskId: string, task: BackgroundTask, errorMessage: string) => void +}): void { + const { tasks, notifications, onTaskPruned } = args + const now = Date.now() + + for (const [taskId, task] of tasks.entries()) { + const timestamp = task.status === "pending" + ? task.queuedAt?.getTime() + : task.startedAt?.getTime() + + if (!timestamp) continue + + const age = now - timestamp + if (age <= TASK_TTL_MS) continue + + const errorMessage = task.status === "pending" + ? "Task timed out while queued (30 minutes)" + : "Task timed out after 30 minutes" + + 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() + return age <= TASK_TTL_MS + }) + + if (validNotifications.length === 0) { + notifications.delete(sessionID) + } else if (validNotifications.length !== queued.length) { + notifications.set(sessionID, validNotifications) + } + } +} + +export async function checkAndInterruptStaleTasks(args: { + tasks: Iterable + client: OpencodeClient + config: BackgroundTaskConfig | undefined + concurrencyManager: ConcurrencyManager + notifyParentSession: (task: BackgroundTask) => Promise +}): Promise { + const { tasks, client, config, concurrencyManager, notifyParentSession } = args + const staleTimeoutMs = config?.staleTimeoutMs ?? DEFAULT_STALE_TIMEOUT_MS + const now = Date.now() + + for (const task of tasks) { + if (task.status !== "running") continue + if (!task.progress?.lastUpdate) continue + + const startedAt = task.startedAt + const sessionID = task.sessionID + if (!startedAt || !sessionID) continue + + const runtime = now - startedAt.getTime() + if (runtime < MIN_RUNTIME_BEFORE_STALE_MS) continue + + const timeSinceLastUpdate = now - task.progress.lastUpdate.getTime() + if (timeSinceLastUpdate <= staleTimeoutMs) continue + if (task.status !== "running") continue + + const staleMinutes = Math.round(timeSinceLastUpdate / 60000) + task.status = "cancelled" + task.error = `Stale timeout (no activity for ${staleMinutes}min)` + task.completedAt = new Date() + + if (task.concurrencyKey) { + concurrencyManager.release(task.concurrencyKey) + task.concurrencyKey = undefined + } + + client.session.abort({ + path: { id: sessionID }, + }).catch(() => {}) + + 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 }) + } + } +} diff --git a/src/features/background-agent/task-queries.ts b/src/features/background-agent/task-queries.ts new file mode 100644 index 000000000..d53c6f901 --- /dev/null +++ b/src/features/background-agent/task-queries.ts @@ -0,0 +1,56 @@ +import type { BackgroundTask } from "./types" + +export function getTasksByParentSession( + tasks: Iterable, + sessionID: string +): BackgroundTask[] { + const result: BackgroundTask[] = [] + for (const task of tasks) { + if (task.parentSessionID === sessionID) { + result.push(task) + } + } + return result +} + +export function getAllDescendantTasks( + tasksByParent: (sessionID: string) => BackgroundTask[], + sessionID: string +): BackgroundTask[] { + const result: BackgroundTask[] = [] + const directChildren = tasksByParent(sessionID) + + for (const child of directChildren) { + result.push(child) + if (child.sessionID) { + result.push(...getAllDescendantTasks(tasksByParent, child.sessionID)) + } + } + + return result +} + +export function findTaskBySession( + tasks: Iterable, + sessionID: string +): BackgroundTask | undefined { + for (const task of tasks) { + if (task.sessionID === sessionID) return task + } + return undefined +} + +export function getRunningTasks(tasks: Iterable): BackgroundTask[] { + return Array.from(tasks).filter((t) => t.status === "running") +} + +export function getCompletedTasks(tasks: Iterable): BackgroundTask[] { + return Array.from(tasks).filter((t) => t.status !== "running") +} + +export function hasRunningTasks(tasks: Iterable): boolean { + for (const task of tasks) { + if (task.status === "running") return true + } + return false +} diff --git a/src/features/background-agent/task-queue-processor.ts b/src/features/background-agent/task-queue-processor.ts new file mode 100644 index 000000000..64568eab4 --- /dev/null +++ b/src/features/background-agent/task-queue-processor.ts @@ -0,0 +1,52 @@ +import { log } from "../../shared" + +import type { BackgroundTask } from "./types" +import type { ConcurrencyManager } from "./concurrency" + +type QueueItem = { + task: BackgroundTask + input: import("./types").LaunchInput +} + +export async function processConcurrencyKeyQueue(args: { + key: string + queuesByKey: Map + processingKeys: Set + concurrencyManager: ConcurrencyManager + startTask: (item: QueueItem) => Promise +}): Promise { + const { key, queuesByKey, processingKeys, concurrencyManager, startTask } = args + + if (processingKeys.has(key)) return + processingKeys.add(key) + + try { + const queue = queuesByKey.get(key) + while (queue && queue.length > 0) { + const item = queue[0] + + await concurrencyManager.acquire(key) + + if (item.task.status === "cancelled") { + concurrencyManager.release(key) + queue.shift() + continue + } + + try { + await startTask(item) + } catch (error) { + log("[background-agent] Error starting task:", error) + // Release concurrency slot if startTask failed and didn't release it itself + // This prevents slot leaks when errors occur after acquire but before task.concurrencyKey is set + if (!item.task.concurrencyKey) { + concurrencyManager.release(key) + } + } + + queue.shift() + } + } finally { + processingKeys.delete(key) + } +} diff --git a/src/features/background-agent/task-resumer.ts b/src/features/background-agent/task-resumer.ts new file mode 100644 index 000000000..e09b12768 --- /dev/null +++ b/src/features/background-agent/task-resumer.ts @@ -0,0 +1,144 @@ +import { log, getAgentToolRestrictions } from "../../shared" +import { subagentSessions } from "../claude-code-session-state" +import { getTaskToastManager } from "../task-toast-manager" + +import type { BackgroundTask, ResumeInput } from "./types" +import type { ConcurrencyManager } from "./concurrency" +import type { OpencodeClient } from "./opencode-client" + +type ModelRef = { providerID: string; modelID: string } + +export async function resumeBackgroundTask(args: { + input: ResumeInput + findBySession: (sessionID: string) => BackgroundTask | undefined + client: OpencodeClient + concurrencyManager: ConcurrencyManager + pendingByParent: Map> + startPolling: () => void + markForNotification: (task: BackgroundTask) => void + cleanupPendingByParent: (task: BackgroundTask) => void + notifyParentSession: (task: BackgroundTask) => Promise +}): Promise { + const { + input, + findBySession, + client, + concurrencyManager, + pendingByParent, + startPolling, + markForNotification, + cleanupPendingByParent, + notifyParentSession, + } = args + + const existingTask = findBySession(input.sessionId) + if (!existingTask) { + throw new Error(`Task not found for session: ${input.sessionId}`) + } + + 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 + } + + const concurrencyKey = existingTask.concurrencyGroup ?? existingTask.agent + await 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 + existingTask.startedAt = new Date() + + existingTask.progress = { + toolCalls: existingTask.progress?.toolCalls ?? 0, + lastUpdate: new Date(), + } + + startPolling() + if (existingTask.sessionID) { + subagentSessions.add(existingTask.sessionID) + } + + if (input.parentSessionID) { + const pending = pendingByParent.get(input.parentSessionID) ?? new Set() + pending.add(existingTask.id) + pendingByParent.set(input.parentSessionID, pending) + } + + 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, + }) + + const resumeModel: ModelRef | undefined = existingTask.model + ? { providerID: existingTask.model.providerID, modelID: existingTask.model.modelID } + : undefined + const resumeVariant = existingTask.model?.variant + + client.session.promptAsync({ + path: { id: existingTask.sessionID }, + body: { + agent: existingTask.agent, + ...(resumeModel ? { model: resumeModel } : {}), + ...(resumeVariant ? { variant: resumeVariant } : {}), + tools: { + ...getAgentToolRestrictions(existingTask.agent), + task: false, + call_omo_agent: true, + question: false, + }, + parts: [{ type: "text", text: input.prompt }], + }, + }).catch((error) => { + log("[background-agent] resume prompt error:", error) + existingTask.status = "error" + const errorMessage = error instanceof Error ? error.message : String(error) + existingTask.error = errorMessage + existingTask.completedAt = new Date() + + if (existingTask.concurrencyKey) { + concurrencyManager.release(existingTask.concurrencyKey) + existingTask.concurrencyKey = undefined + } + + if (existingTask.sessionID) { + client.session.abort({ + path: { id: existingTask.sessionID }, + }).catch(() => {}) + } + + markForNotification(existingTask) + cleanupPendingByParent(existingTask) + notifyParentSession(existingTask).catch((err) => { + log("[background-agent] Failed to notify on resume error:", err) + }) + }) + + return existingTask +} diff --git a/src/features/background-agent/task-starter.ts b/src/features/background-agent/task-starter.ts new file mode 100644 index 000000000..6083d15a0 --- /dev/null +++ b/src/features/background-agent/task-starter.ts @@ -0,0 +1,190 @@ +import { log, getAgentToolRestrictions, promptWithModelSuggestionRetry } from "../../shared" +import { isInsideTmux } from "../../shared/tmux" + +import { subagentSessions } from "../claude-code-session-state" +import { getTaskToastManager } from "../task-toast-manager" + +import type { BackgroundTask } from "./types" +import type { LaunchInput } from "./types" +import type { ConcurrencyManager } from "./concurrency" +import type { OpencodeClient } from "./opencode-client" + +type QueueItem = { + task: BackgroundTask + input: LaunchInput +} + +type ModelRef = { providerID: string; modelID: string } + +export async function startQueuedTask(args: { + item: QueueItem + client: OpencodeClient + defaultDirectory: string + tmuxEnabled: boolean + onSubagentSessionCreated?: (event: { sessionID: string; parentID: string; title: string }) => Promise + startPolling: () => void + getConcurrencyKeyFromInput: (input: LaunchInput) => string + concurrencyManager: ConcurrencyManager + findBySession: (sessionID: string) => BackgroundTask | undefined + markForNotification: (task: BackgroundTask) => void + cleanupPendingByParent: (task: BackgroundTask) => void + notifyParentSession: (task: BackgroundTask) => Promise +}): Promise { + const { + item, + client, + defaultDirectory, + tmuxEnabled, + onSubagentSessionCreated, + startPolling, + getConcurrencyKeyFromInput, + concurrencyManager, + findBySession, + markForNotification, + cleanupPendingByParent, + notifyParentSession, + } = args + + const { task, input } = item + + log("[background-agent] Starting task:", { + taskId: task.id, + agent: input.agent, + model: input.model, + }) + + const concurrencyKey = getConcurrencyKeyFromInput(input) + + const parentSession = await 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 ?? defaultDirectory + log(`[background-agent] Parent dir: ${parentSession?.data?.directory}, using: ${parentDirectory}`) + + const createResult = await client.session.create({ + body: { + parentID: input.parentSessionID, + title: `${input.description} (@${input.agent} subagent)`, + }, + query: { + directory: parentDirectory, + }, + }) + + if (createResult.error) { + throw new Error(`Failed to create background session: ${createResult.error}`) + } + + if (!createResult.data?.id) { + throw new Error("Failed to create background session: API returned no session ID") + } + + const sessionID = createResult.data.id + subagentSessions.add(sessionID) + + log("[background-agent] tmux callback check", { + hasCallback: !!onSubagentSessionCreated, + tmuxEnabled, + isInsideTmux: isInsideTmux(), + sessionID, + parentID: input.parentSessionID, + }) + + if (onSubagentSessionCreated && tmuxEnabled && isInsideTmux()) { + log("[background-agent] Invoking tmux callback NOW", { sessionID }) + await 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((resolve) => { + setTimeout(() => resolve(), 200) + }) + } else { + log("[background-agent] SKIP tmux callback - conditions not met") + } + + task.status = "running" + task.startedAt = new Date() + task.sessionID = sessionID + task.progress = { + toolCalls: 0, + lastUpdate: new Date(), + } + task.concurrencyKey = concurrencyKey + task.concurrencyGroup = concurrencyKey + + startPolling() + + log("[background-agent] Launching task:", { taskId: task.id, sessionID, agent: input.agent }) + + const toastManager = getTaskToastManager() + if (toastManager) { + toastManager.updateTask(task.id, "running") + } + + 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, + }) + + const launchModel: ModelRef | undefined = input.model + ? { providerID: input.model.providerID, modelID: input.model.modelID } + : undefined + const launchVariant = input.model?.variant + + promptWithModelSuggestionRetry(client, { + path: { id: sessionID }, + body: { + agent: input.agent, + ...(launchModel ? { model: launchModel } : {}), + ...(launchVariant ? { variant: launchVariant } : {}), + system: input.skillContent, + tools: { + ...getAgentToolRestrictions(input.agent), + task: false, + call_omo_agent: true, + question: false, + }, + parts: [{ type: "text", text: input.prompt }], + }, + }).catch((error) => { + log("[background-agent] promptAsync error:", error) + const existingTask = findBySession(sessionID) + if (!existingTask) return + + existingTask.status = "error" + 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() + + if (existingTask.concurrencyKey) { + concurrencyManager.release(existingTask.concurrencyKey) + existingTask.concurrencyKey = undefined + } + + client.session.abort({ + path: { id: sessionID }, + }).catch(() => {}) + + markForNotification(existingTask) + cleanupPendingByParent(existingTask) + notifyParentSession(existingTask).catch((err) => { + log("[background-agent] Failed to notify on error:", err) + }) + }) +} diff --git a/src/features/background-agent/task-tracker.ts b/src/features/background-agent/task-tracker.ts new file mode 100644 index 000000000..6c78d50f7 --- /dev/null +++ b/src/features/background-agent/task-tracker.ts @@ -0,0 +1,97 @@ +import { log } from "../../shared" +import { subagentSessions } from "../claude-code-session-state" + +import type { BackgroundTask } from "./types" +import type { ConcurrencyManager } from "./concurrency" + +export async function trackExternalTask(args: { + input: { + taskId: string + sessionID: string + parentSessionID: string + description: string + agent?: string + parentAgent?: string + concurrencyKey?: string + } + tasks: Map + pendingByParent: Map> + concurrencyManager: ConcurrencyManager + startPolling: () => void + cleanupPendingByParent: (task: BackgroundTask) => void +}): Promise { + const { input, tasks, pendingByParent, concurrencyManager, startPolling, cleanupPendingByParent } = args + + const existingTask = tasks.get(input.taskId) + if (existingTask) { + const parentChanged = input.parentSessionID !== existingTask.parentSessionID + if (parentChanged) { + cleanupPendingByParent(existingTask) + existingTask.parentSessionID = input.parentSessionID + } + if (input.parentAgent !== undefined) { + existingTask.parentAgent = input.parentAgent + } + if (!existingTask.concurrencyGroup) { + existingTask.concurrencyGroup = input.concurrencyKey ?? existingTask.agent + } + + if (existingTask.sessionID) { + subagentSessions.add(existingTask.sessionID) + } + startPolling() + + if (existingTask.status === "pending" || existingTask.status === "running") { + const pending = pendingByParent.get(input.parentSessionID) ?? new Set() + pending.add(existingTask.id) + pendingByParent.set(input.parentSessionID, pending) + } else if (!parentChanged) { + cleanupPendingByParent(existingTask) + } + + log("[background-agent] External task already registered:", { + taskId: existingTask.id, + sessionID: existingTask.sessionID, + status: existingTask.status, + }) + + return existingTask + } + + const concurrencyGroup = input.concurrencyKey ?? input.agent ?? "task" + if (input.concurrencyKey) { + await concurrencyManager.acquire(input.concurrencyKey) + } + + const task: BackgroundTask = { + id: input.taskId, + sessionID: input.sessionID, + parentSessionID: input.parentSessionID, + parentMessageID: "", + description: input.description, + prompt: "", + agent: input.agent || "task", + status: "running", + startedAt: new Date(), + progress: { + toolCalls: 0, + lastUpdate: new Date(), + }, + parentAgent: input.parentAgent, + concurrencyKey: input.concurrencyKey, + concurrencyGroup, + } + + tasks.set(task.id, task) + subagentSessions.add(input.sessionID) + startPolling() + + if (input.parentSessionID) { + const pending = pendingByParent.get(input.parentSessionID) ?? new Set() + pending.add(task.id) + pendingByParent.set(input.parentSessionID, pending) + } + + log("[background-agent] Registered external task:", { taskId: task.id, sessionID: input.sessionID }) + return task +}