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
This commit is contained in:
parent
c7122b4127
commit
e3bd43ff64
199
src/features/background-agent/background-event-handler.ts
Normal file
199
src/features/background-agent/background-event-handler.ts
Normal file
@ -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<string, unknown> }
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getString(obj: Record<string, unknown>, 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<boolean>
|
||||||
|
tryCompleteTask: (task: BackgroundTask, source: string) => Promise<boolean>
|
||||||
|
validateSessionHasOutput: (sessionID: string) => Promise<boolean>
|
||||||
|
checkSessionTodos: (sessionID: string) => Promise<boolean>
|
||||||
|
idleDeferralTimers: Map<string, ReturnType<typeof setTimeout>>
|
||||||
|
completionTimers: Map<string, ReturnType<typeof setTimeout>>
|
||||||
|
tasks: Map<string, BackgroundTask>
|
||||||
|
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<string, BackgroundTask>()
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
82
src/features/background-agent/background-manager-shutdown.ts
Normal file
82
src/features/background-agent/background-manager-shutdown.ts
Normal file
@ -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<string, BackgroundTask>
|
||||||
|
client: PluginInput["client"]
|
||||||
|
onShutdown?: () => void
|
||||||
|
concurrencyManager: ConcurrencyManager
|
||||||
|
completionTimers: Map<string, ReturnType<typeof setTimeout>>
|
||||||
|
idleDeferralTimers: Map<string, ReturnType<typeof setTimeout>>
|
||||||
|
notifications: Map<string, BackgroundTask[]>
|
||||||
|
pendingByParent: Map<string, Set<string>>
|
||||||
|
queuesByKey: Map<string, QueueItem[]>
|
||||||
|
processingKeys: Set<string>
|
||||||
|
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")
|
||||||
|
}
|
||||||
40
src/features/background-agent/background-task-completer.ts
Normal file
40
src/features/background-agent/background-task-completer.ts
Normal file
@ -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<boolean> {
|
||||||
|
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
|
||||||
|
}
|
||||||
@ -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 `<system-reminder>
|
||||||
|
[ALL BACKGROUND TASKS COMPLETE]
|
||||||
|
|
||||||
|
**Completed:**
|
||||||
|
${completedTasksText || `- \`${task.id}\`: ${task.description}`}
|
||||||
|
|
||||||
|
Use \`background_output(task_id="<id>")\` to retrieve each result.
|
||||||
|
</system-reminder>`
|
||||||
|
}
|
||||||
|
|
||||||
|
const agentInfo = task.category ? `${task.agent} (${task.category})` : task.agent
|
||||||
|
|
||||||
|
return `<system-reminder>
|
||||||
|
[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.
|
||||||
|
</system-reminder>`
|
||||||
|
}
|
||||||
14
src/features/background-agent/duration-formatter.ts
Normal file
14
src/features/background-agent/duration-formatter.ts
Normal file
@ -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`
|
||||||
|
}
|
||||||
21
src/features/background-agent/error-classifier.ts
Normal file
21
src/features/background-agent/error-classifier.ts
Normal file
@ -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 ""
|
||||||
|
}
|
||||||
14
src/features/background-agent/format-duration.ts
Normal file
14
src/features/background-agent/format-duration.ts
Normal file
@ -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`
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
18
src/features/background-agent/message-dir.ts
Normal file
18
src/features/background-agent/message-dir.ts
Normal file
@ -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
|
||||||
|
}
|
||||||
17
src/features/background-agent/message-storage-locator.ts
Normal file
17
src/features/background-agent/message-storage-locator.ts
Normal file
@ -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
|
||||||
|
}
|
||||||
40
src/features/background-agent/notification-builder.ts
Normal file
40
src/features/background-agent/notification-builder.ts
Normal file
@ -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 `<system-reminder>
|
||||||
|
[ALL BACKGROUND TASKS COMPLETE]
|
||||||
|
|
||||||
|
**Completed:**
|
||||||
|
${completedTasksText || `- \`${task.id}\`: ${task.description}`}
|
||||||
|
|
||||||
|
Use \`background_output(task_id="<id>")\` to retrieve each result.
|
||||||
|
</system-reminder>`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<system-reminder>
|
||||||
|
[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.
|
||||||
|
</system-reminder>`
|
||||||
|
}
|
||||||
52
src/features/background-agent/notification-tracker.ts
Normal file
52
src/features/background-agent/notification-tracker.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import type { BackgroundTask } from "./types"
|
||||||
|
|
||||||
|
export function markForNotification(
|
||||||
|
notifications: Map<string, BackgroundTask[]>,
|
||||||
|
task: BackgroundTask
|
||||||
|
): void {
|
||||||
|
const queue = notifications.get(task.parentSessionID) ?? []
|
||||||
|
queue.push(task)
|
||||||
|
notifications.set(task.parentSessionID, queue)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPendingNotifications(
|
||||||
|
notifications: Map<string, BackgroundTask[]>,
|
||||||
|
sessionID: string
|
||||||
|
): BackgroundTask[] {
|
||||||
|
return notifications.get(sessionID) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearNotifications(
|
||||||
|
notifications: Map<string, BackgroundTask[]>,
|
||||||
|
sessionID: string
|
||||||
|
): void {
|
||||||
|
notifications.delete(sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearNotificationsForTask(
|
||||||
|
notifications: Map<string, BackgroundTask[]>,
|
||||||
|
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<string, Set<string>>,
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
192
src/features/background-agent/notify-parent-session.ts
Normal file
192
src/features/background-agent/notify-parent-session.ts
Normal file
@ -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<string, unknown> {
|
||||||
|
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<string, BackgroundTask>
|
||||||
|
pendingByParent: Map<string, Set<string>>
|
||||||
|
completionTimers: Map<string, ReturnType<typeof setTimeout>>
|
||||||
|
clearNotificationsForTask: (taskId: string) => void
|
||||||
|
client: OpencodeClient
|
||||||
|
}): Promise<void> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
3
src/features/background-agent/opencode-client.ts
Normal file
3
src/features/background-agent/opencode-client.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
|
|
||||||
|
export type OpencodeClient = PluginInput["client"]
|
||||||
@ -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<string, unknown> {
|
||||||
|
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 }
|
||||||
|
}
|
||||||
102
src/features/background-agent/parent-session-notifier.ts
Normal file
102
src/features/background-agent/parent-session-notifier.ts
Normal file
@ -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<void> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
178
src/features/background-agent/poll-running-tasks.ts
Normal file
178
src/features/background-agent/poll-running-tasks.ts
Normal file
@ -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<string, { type: string }>
|
||||||
|
|
||||||
|
type MessagePart = {
|
||||||
|
type?: string
|
||||||
|
tool?: string
|
||||||
|
name?: string
|
||||||
|
text?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type SessionMessage = {
|
||||||
|
info?: { role?: string }
|
||||||
|
parts?: MessagePart[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
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<BackgroundTask>
|
||||||
|
client: OpencodeClient
|
||||||
|
pruneStaleTasksAndNotifications: () => void
|
||||||
|
checkAndInterruptStaleTasks: () => Promise<void>
|
||||||
|
validateSessionHasOutput: (sessionID: string) => Promise<boolean>
|
||||||
|
checkSessionTodos: (sessionID: string) => Promise<boolean>
|
||||||
|
tryCompleteTask: (task: BackgroundTask, source: string) => Promise<boolean>
|
||||||
|
hasRunningTasks: () => boolean
|
||||||
|
stopPolling: () => void
|
||||||
|
}): Promise<void> {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
19
src/features/background-agent/process-signal.ts
Normal file
19
src/features/background-agent/process-signal.ts
Normal file
@ -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
|
||||||
|
}
|
||||||
9
src/features/background-agent/result-handler-context.ts
Normal file
9
src/features/background-agent/result-handler-context.ts
Normal file
@ -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
|
||||||
|
}
|
||||||
@ -1,276 +1,7 @@
|
|||||||
import type { BackgroundTask } from "./types"
|
export type { ResultHandlerContext } from "./result-handler-context"
|
||||||
import type { OpencodeClient, Todo } from "./constants"
|
export { formatDuration } from "./duration-formatter"
|
||||||
import { TASK_CLEANUP_DELAY_MS } from "./constants"
|
export { getMessageDir } from "./message-storage-locator"
|
||||||
import { log } from "../../shared"
|
export { checkSessionTodos } from "./session-todo-checker"
|
||||||
import { getTaskToastManager } from "../task-toast-manager"
|
export { validateSessionHasOutput } from "./session-output-validator"
|
||||||
import { findNearestMessageWithFields, MESSAGE_STORAGE } from "../hook-message-injector"
|
export { tryCompleteTask } from "./background-task-completer"
|
||||||
import { existsSync, readdirSync } from "node:fs"
|
export { notifyParentSession } from "./parent-session-notifier"
|
||||||
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<boolean> {
|
|
||||||
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<boolean> {
|
|
||||||
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<boolean> {
|
|
||||||
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<void> {
|
|
||||||
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 = `<system-reminder>
|
|
||||||
[ALL BACKGROUND TASKS COMPLETE]
|
|
||||||
|
|
||||||
**Completed:**
|
|
||||||
${completedTasksText || `- \`${task.id}\`: ${task.description}`}
|
|
||||||
|
|
||||||
Use \`background_output(task_id="<id>")\` to retrieve each result.
|
|
||||||
</system-reminder>`
|
|
||||||
} else {
|
|
||||||
const agentInfo = task.category
|
|
||||||
? `${task.agent} (${task.category})`
|
|
||||||
: task.agent
|
|
||||||
notification = `<system-reminder>
|
|
||||||
[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.
|
|
||||||
</system-reminder>`
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
88
src/features/background-agent/session-output-validator.ts
Normal file
88
src/features/background-agent/session-output-validator.ts
Normal file
@ -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<string, unknown> {
|
||||||
|
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<boolean> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
33
src/features/background-agent/session-todo-checker.ts
Normal file
33
src/features/background-agent/session-todo-checker.ts
Normal file
@ -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<string, unknown>
|
||||||
|
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<boolean> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
111
src/features/background-agent/session-validator.ts
Normal file
111
src/features/background-agent/session-validator.ts
Normal file
@ -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<string, unknown> {
|
||||||
|
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<Record<string, unknown>> {
|
||||||
|
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<boolean> {
|
||||||
|
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<boolean> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,242 +1,4 @@
|
|||||||
import type { BackgroundTask, LaunchInput, ResumeInput } from "./types"
|
export type { SpawnerContext } from "./spawner/spawner-context"
|
||||||
import type { OpencodeClient, OnSubagentSessionCreated, QueueItem } from "./constants"
|
export { createTask } from "./spawner/task-factory"
|
||||||
import { TMUX_CALLBACK_DELAY_MS } from "./constants"
|
export { startTask } from "./spawner/task-starter"
|
||||||
import { log, getAgentToolRestrictions, promptWithModelSuggestionRetry } from "../../shared"
|
export { resumeTask } from "./spawner/task-resumer"
|
||||||
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<void> {
|
|
||||||
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<SpawnerContext, "client" | "concurrencyManager" | "onTaskError">
|
|
||||||
): Promise<void> {
|
|
||||||
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)))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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<string> {
|
||||||
|
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
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
@ -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<string> {
|
||||||
|
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
|
||||||
|
}
|
||||||
12
src/features/background-agent/spawner/spawner-context.ts
Normal file
12
src/features/background-agent/spawner/spawner-context.ts
Normal file
@ -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
|
||||||
|
}
|
||||||
18
src/features/background-agent/spawner/task-factory.ts
Normal file
18
src/features/background-agent/spawner/task-factory.ts
Normal file
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
91
src/features/background-agent/spawner/task-resumer.ts
Normal file
91
src/features/background-agent/spawner/task-resumer.ts
Normal file
@ -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<SpawnerContext, "client" | "concurrencyManager" | "onTaskError">
|
||||||
|
): Promise<void> {
|
||||||
|
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)))
|
||||||
|
})
|
||||||
|
}
|
||||||
94
src/features/background-agent/spawner/task-starter.ts
Normal file
94
src/features/background-agent/spawner/task-starter.ts
Normal file
@ -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<void> {
|
||||||
|
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)))
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -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<void> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
57
src/features/background-agent/stale-task-pruner.ts
Normal file
57
src/features/background-agent/stale-task-pruner.ts
Normal file
@ -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<string, BackgroundTask>
|
||||||
|
notifications: Map<string, BackgroundTask[]>
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -2,7 +2,6 @@ import type { BackgroundTask, LaunchInput } from "./types"
|
|||||||
import type { QueueItem } from "./constants"
|
import type { QueueItem } from "./constants"
|
||||||
import { log } from "../../shared"
|
import { log } from "../../shared"
|
||||||
import { subagentSessions } from "../claude-code-session-state"
|
import { subagentSessions } from "../claude-code-session-state"
|
||||||
|
|
||||||
export class TaskStateManager {
|
export class TaskStateManager {
|
||||||
readonly tasks: Map<string, BackgroundTask> = new Map()
|
readonly tasks: Map<string, BackgroundTask> = new Map()
|
||||||
readonly notifications: Map<string, BackgroundTask[]> = new Map()
|
readonly notifications: Map<string, BackgroundTask[]> = new Map()
|
||||||
@ -10,11 +9,9 @@ export class TaskStateManager {
|
|||||||
readonly queuesByKey: Map<string, QueueItem[]> = new Map()
|
readonly queuesByKey: Map<string, QueueItem[]> = new Map()
|
||||||
readonly processingKeys: Set<string> = new Set()
|
readonly processingKeys: Set<string> = new Set()
|
||||||
readonly completionTimers: Map<string, ReturnType<typeof setTimeout>> = new Map()
|
readonly completionTimers: Map<string, ReturnType<typeof setTimeout>> = new Map()
|
||||||
|
|
||||||
getTask(id: string): BackgroundTask | undefined {
|
getTask(id: string): BackgroundTask | undefined {
|
||||||
return this.tasks.get(id)
|
return this.tasks.get(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
findBySession(sessionID: string): BackgroundTask | undefined {
|
findBySession(sessionID: string): BackgroundTask | undefined {
|
||||||
for (const task of this.tasks.values()) {
|
for (const task of this.tasks.values()) {
|
||||||
if (task.sessionID === sessionID) {
|
if (task.sessionID === sessionID) {
|
||||||
@ -23,7 +20,6 @@ export class TaskStateManager {
|
|||||||
}
|
}
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
getTasksByParentSession(sessionID: string): BackgroundTask[] {
|
getTasksByParentSession(sessionID: string): BackgroundTask[] {
|
||||||
const result: BackgroundTask[] = []
|
const result: BackgroundTask[] = []
|
||||||
for (const task of this.tasks.values()) {
|
for (const task of this.tasks.values()) {
|
||||||
@ -52,7 +48,6 @@ export class TaskStateManager {
|
|||||||
getRunningTasks(): BackgroundTask[] {
|
getRunningTasks(): BackgroundTask[] {
|
||||||
return Array.from(this.tasks.values()).filter(t => t.status === "running")
|
return Array.from(this.tasks.values()).filter(t => t.status === "running")
|
||||||
}
|
}
|
||||||
|
|
||||||
getCompletedTasks(): BackgroundTask[] {
|
getCompletedTasks(): BackgroundTask[] {
|
||||||
return Array.from(this.tasks.values()).filter(t => t.status !== "running")
|
return Array.from(this.tasks.values()).filter(t => t.status !== "running")
|
||||||
}
|
}
|
||||||
|
|||||||
117
src/features/background-agent/task-canceller.ts
Normal file
117
src/features/background-agent/task-canceller.ts
Normal file
@ -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<string, BackgroundTask>
|
||||||
|
queuesByKey: Map<string, QueueItem[]>
|
||||||
|
completionTimers: Map<string, ReturnType<typeof setTimeout>>
|
||||||
|
idleDeferralTimers: Map<string, ReturnType<typeof setTimeout>>
|
||||||
|
concurrencyManager: ConcurrencyManager
|
||||||
|
client: OpencodeClient
|
||||||
|
cleanupPendingByParent: (task: BackgroundTask) => void
|
||||||
|
markForNotification: (task: BackgroundTask) => void
|
||||||
|
notifyParentSession: (task: BackgroundTask) => Promise<void>
|
||||||
|
}): Promise<boolean> {
|
||||||
|
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
|
||||||
|
}
|
||||||
68
src/features/background-agent/task-completer.ts
Normal file
68
src/features/background-agent/task-completer.ts
Normal file
@ -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<string, ReturnType<typeof setTimeout>>
|
||||||
|
client: OpencodeClient
|
||||||
|
markForNotification: (task: BackgroundTask) => void
|
||||||
|
cleanupPendingByParent: (task: BackgroundTask) => void
|
||||||
|
notifyParentSession: (task: BackgroundTask) => Promise<void>
|
||||||
|
}): Promise<boolean> {
|
||||||
|
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
|
||||||
|
}
|
||||||
77
src/features/background-agent/task-launch.ts
Normal file
77
src/features/background-agent/task-launch.ts
Normal file
@ -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<string, BackgroundTask>
|
||||||
|
pendingByParent: Map<string, Set<string>>
|
||||||
|
queuesByKey: Map<string, QueueItem[]>
|
||||||
|
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<string>()
|
||||||
|
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
|
||||||
|
}
|
||||||
107
src/features/background-agent/task-poller.ts
Normal file
107
src/features/background-agent/task-poller.ts
Normal file
@ -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<string, BackgroundTask>
|
||||||
|
notifications: Map<string, BackgroundTask[]>
|
||||||
|
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<BackgroundTask>
|
||||||
|
client: OpencodeClient
|
||||||
|
config: BackgroundTaskConfig | undefined
|
||||||
|
concurrencyManager: ConcurrencyManager
|
||||||
|
notifyParentSession: (task: BackgroundTask) => Promise<void>
|
||||||
|
}): Promise<void> {
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
56
src/features/background-agent/task-queries.ts
Normal file
56
src/features/background-agent/task-queries.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import type { BackgroundTask } from "./types"
|
||||||
|
|
||||||
|
export function getTasksByParentSession(
|
||||||
|
tasks: Iterable<BackgroundTask>,
|
||||||
|
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<BackgroundTask>,
|
||||||
|
sessionID: string
|
||||||
|
): BackgroundTask | undefined {
|
||||||
|
for (const task of tasks) {
|
||||||
|
if (task.sessionID === sessionID) return task
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRunningTasks(tasks: Iterable<BackgroundTask>): BackgroundTask[] {
|
||||||
|
return Array.from(tasks).filter((t) => t.status === "running")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCompletedTasks(tasks: Iterable<BackgroundTask>): BackgroundTask[] {
|
||||||
|
return Array.from(tasks).filter((t) => t.status !== "running")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasRunningTasks(tasks: Iterable<BackgroundTask>): boolean {
|
||||||
|
for (const task of tasks) {
|
||||||
|
if (task.status === "running") return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
52
src/features/background-agent/task-queue-processor.ts
Normal file
52
src/features/background-agent/task-queue-processor.ts
Normal file
@ -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<string, QueueItem[]>
|
||||||
|
processingKeys: Set<string>
|
||||||
|
concurrencyManager: ConcurrencyManager
|
||||||
|
startTask: (item: QueueItem) => Promise<void>
|
||||||
|
}): Promise<void> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
144
src/features/background-agent/task-resumer.ts
Normal file
144
src/features/background-agent/task-resumer.ts
Normal file
@ -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<string, Set<string>>
|
||||||
|
startPolling: () => void
|
||||||
|
markForNotification: (task: BackgroundTask) => void
|
||||||
|
cleanupPendingByParent: (task: BackgroundTask) => void
|
||||||
|
notifyParentSession: (task: BackgroundTask) => Promise<void>
|
||||||
|
}): Promise<BackgroundTask> {
|
||||||
|
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<string>()
|
||||||
|
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
|
||||||
|
}
|
||||||
190
src/features/background-agent/task-starter.ts
Normal file
190
src/features/background-agent/task-starter.ts
Normal file
@ -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<void>
|
||||||
|
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<void>
|
||||||
|
}): Promise<void> {
|
||||||
|
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<void>((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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
97
src/features/background-agent/task-tracker.ts
Normal file
97
src/features/background-agent/task-tracker.ts
Normal file
@ -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<string, BackgroundTask>
|
||||||
|
pendingByParent: Map<string, Set<string>>
|
||||||
|
concurrencyManager: ConcurrencyManager
|
||||||
|
startPolling: () => void
|
||||||
|
cleanupPendingByParent: (task: BackgroundTask) => void
|
||||||
|
}): Promise<BackgroundTask> {
|
||||||
|
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<string>()
|
||||||
|
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<string>()
|
||||||
|
pending.add(task.id)
|
||||||
|
pendingByParent.set(input.parentSessionID, pending)
|
||||||
|
}
|
||||||
|
|
||||||
|
log("[background-agent] Registered external task:", { taskId: task.id, sessionID: input.sessionID })
|
||||||
|
return task
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user