refactor: major codebase cleanup - BDD comments, file splitting, bug fixes (#1350)

* style(tests): normalize BDD comments from '// #given' to '// given'

- Replace 4,668 Python-style BDD comments across 107 test files
- Patterns changed: // #given -> // given, // #when -> // when, // #then -> // then
- Also handles no-space variants: //#given -> // given

* fix(rules-injector): prefer output.metadata.filePath over output.title

- Extract file path resolution to dedicated output-path.ts module
- Prefer metadata.filePath which contains actual file path
- Fall back to output.title only when metadata unavailable
- Fixes issue where rules weren't injected when tool output title was a label

* feat(slashcommand): add optional user_message parameter

- Add user_message optional parameter for command arguments
- Model can now call: command='publish' user_message='patch'
- Improves error messages with clearer format guidance
- Helps LLMs understand correct parameter usage

* feat(hooks): restore compaction-context-injector hook

- Restore hook deleted in cbbc7bd0 for session compaction context
- Injects 7 mandatory sections: User Requests, Final Goal, Work Completed,
  Remaining Tasks, Active Working Context, MUST NOT Do, Agent Verification State
- Re-register in hooks/index.ts and main plugin entry

* refactor(background-agent): split manager.ts into focused modules

- Extract constants.ts for TTL values and internal types (52 lines)
- Extract state.ts for TaskStateManager class (204 lines)
- Extract spawner.ts for task creation logic (244 lines)
- Extract result-handler.ts for completion handling (265 lines)
- Reduce manager.ts from 1377 to 755 lines (45% reduction)
- Maintain backward compatible exports

* refactor(agents): split prometheus-prompt.ts into subdirectory

- Move 1196-line prometheus-prompt.ts to prometheus/ subdirectory
- Organize prompt sections into separate files for maintainability
- Update agents/index.ts exports

* refactor(delegate-task): split tools.ts into focused modules

- Extract categories.ts for category definitions and routing
- Extract executor.ts for task execution logic
- Extract helpers.ts for utility functions
- Extract prompt-builder.ts for prompt construction
- Reduce tools.ts complexity with cleaner separation of concerns

* refactor(builtin-skills): split skills.ts into individual skill files

- Move each skill to dedicated file in skills/ subdirectory
- Create barrel export for backward compatibility
- Improve maintainability with focused skill modules

* chore: update import paths and lockfile

- Update prometheus import path after refactor
- Update bun.lock

* fix(tests): complete BDD comment normalization

- Fix remaining #when/#then patterns missed by initial sed
- Affected: state.test.ts, events.test.ts

---------

Co-authored-by: justsisyphus <justsisyphus@users.noreply.github.com>
This commit is contained in:
YeonGyu-Kim
2026-02-01 16:47:50 +09:00
committed by GitHub
parent c83150d9ea
commit f146aeff0f
145 changed files with 10307 additions and 9562 deletions
@@ -4,87 +4,87 @@ import type { BackgroundTaskConfig } from "../../config/schema"
describe("ConcurrencyManager.getConcurrencyLimit", () => {
test("should return model-specific limit when modelConcurrency is set", () => {
// #given
// given
const config: BackgroundTaskConfig = {
modelConcurrency: { "anthropic/claude-sonnet-4-5": 5 }
}
const manager = new ConcurrencyManager(config)
// #when
// when
const limit = manager.getConcurrencyLimit("anthropic/claude-sonnet-4-5")
// #then
// then
expect(limit).toBe(5)
})
test("should return provider limit when providerConcurrency is set for model provider", () => {
// #given
// given
const config: BackgroundTaskConfig = {
providerConcurrency: { anthropic: 3 }
}
const manager = new ConcurrencyManager(config)
// #when
// when
const limit = manager.getConcurrencyLimit("anthropic/claude-sonnet-4-5")
// #then
// then
expect(limit).toBe(3)
})
test("should return provider limit even when modelConcurrency exists but doesn't match", () => {
// #given
// given
const config: BackgroundTaskConfig = {
modelConcurrency: { "google/gemini-3-pro": 5 },
providerConcurrency: { anthropic: 3 }
}
const manager = new ConcurrencyManager(config)
// #when
// when
const limit = manager.getConcurrencyLimit("anthropic/claude-sonnet-4-5")
// #then
// then
expect(limit).toBe(3)
})
test("should return default limit when defaultConcurrency is set", () => {
// #given
// given
const config: BackgroundTaskConfig = {
defaultConcurrency: 2
}
const manager = new ConcurrencyManager(config)
// #when
// when
const limit = manager.getConcurrencyLimit("anthropic/claude-sonnet-4-5")
// #then
// then
expect(limit).toBe(2)
})
test("should return default 5 when no config provided", () => {
// #given
// given
const manager = new ConcurrencyManager()
// #when
// when
const limit = manager.getConcurrencyLimit("anthropic/claude-sonnet-4-5")
// #then
// then
expect(limit).toBe(5)
})
test("should return default 5 when config exists but no concurrency settings", () => {
// #given
// given
const config: BackgroundTaskConfig = {}
const manager = new ConcurrencyManager(config)
// #when
// when
const limit = manager.getConcurrencyLimit("anthropic/claude-sonnet-4-5")
// #then
// then
expect(limit).toBe(5)
})
test("should prioritize model-specific over provider-specific over default", () => {
// #given
// given
const config: BackgroundTaskConfig = {
modelConcurrency: { "anthropic/claude-sonnet-4-5": 10 },
providerConcurrency: { anthropic: 5 },
@@ -92,68 +92,68 @@ describe("ConcurrencyManager.getConcurrencyLimit", () => {
}
const manager = new ConcurrencyManager(config)
// #when
// when
const modelLimit = manager.getConcurrencyLimit("anthropic/claude-sonnet-4-5")
const providerLimit = manager.getConcurrencyLimit("anthropic/claude-opus-4-5")
const defaultLimit = manager.getConcurrencyLimit("google/gemini-3-pro")
// #then
// then
expect(modelLimit).toBe(10)
expect(providerLimit).toBe(5)
expect(defaultLimit).toBe(2)
})
test("should handle models without provider part", () => {
// #given
// given
const config: BackgroundTaskConfig = {
providerConcurrency: { "custom-model": 4 }
}
const manager = new ConcurrencyManager(config)
// #when
// when
const limit = manager.getConcurrencyLimit("custom-model")
// #then
// then
expect(limit).toBe(4)
})
test("should return Infinity when defaultConcurrency is 0", () => {
// #given
// given
const config: BackgroundTaskConfig = { defaultConcurrency: 0 }
const manager = new ConcurrencyManager(config)
// #when
// when
const limit = manager.getConcurrencyLimit("any-model")
// #then
// then
expect(limit).toBe(Infinity)
})
test("should return Infinity when providerConcurrency is 0", () => {
// #given
// given
const config: BackgroundTaskConfig = {
providerConcurrency: { anthropic: 0 }
}
const manager = new ConcurrencyManager(config)
// #when
// when
const limit = manager.getConcurrencyLimit("anthropic/claude-sonnet-4-5")
// #then
// then
expect(limit).toBe(Infinity)
})
test("should return Infinity when modelConcurrency is 0", () => {
// #given
// given
const config: BackgroundTaskConfig = {
modelConcurrency: { "anthropic/claude-sonnet-4-5": 0 }
}
const manager = new ConcurrencyManager(config)
// #when
// when
const limit = manager.getConcurrencyLimit("anthropic/claude-sonnet-4-5")
// #then
// then
expect(limit).toBe(Infinity)
})
})
@@ -162,69 +162,69 @@ describe("ConcurrencyManager.acquire/release", () => {
let manager: ConcurrencyManager
beforeEach(() => {
// #given
// given
const config: BackgroundTaskConfig = {}
manager = new ConcurrencyManager(config)
})
test("should allow acquiring up to limit", async () => {
// #given
// given
const config: BackgroundTaskConfig = { defaultConcurrency: 2 }
manager = new ConcurrencyManager(config)
// #when
// when
await manager.acquire("model-a")
await manager.acquire("model-a")
// #then - both resolved without waiting, count should be 2
// then - both resolved without waiting, count should be 2
expect(manager.getCount("model-a")).toBe(2)
})
test("should allow acquires up to default limit of 5", async () => {
// #given - no config = default limit of 5
// given - no config = default limit of 5
// #when
// when
await manager.acquire("model-a")
await manager.acquire("model-a")
await manager.acquire("model-a")
await manager.acquire("model-a")
await manager.acquire("model-a")
// #then - all 5 resolved, count should be 5
// then - all 5 resolved, count should be 5
expect(manager.getCount("model-a")).toBe(5)
})
test("should queue when limit reached", async () => {
// #given
// given
const config: BackgroundTaskConfig = { defaultConcurrency: 1 }
manager = new ConcurrencyManager(config)
await manager.acquire("model-a")
// #when
// when
let resolved = false
const waitPromise = manager.acquire("model-a").then(() => { resolved = true })
// Give microtask queue a chance to run
await Promise.resolve()
// #then - should still be waiting
// then - should still be waiting
expect(resolved).toBe(false)
// #when - release
// when - release
manager.release("model-a")
await waitPromise
// #then - now resolved
// then - now resolved
expect(resolved).toBe(true)
})
test("should queue multiple tasks and process in order", async () => {
// #given
// given
const config: BackgroundTaskConfig = { defaultConcurrency: 1 }
manager = new ConcurrencyManager(config)
await manager.acquire("model-a")
// #when
// when
const order: string[] = []
const task1 = manager.acquire("model-a").then(() => { order.push("1") })
const task2 = manager.acquire("model-a").then(() => { order.push("2") })
@@ -233,10 +233,10 @@ describe("ConcurrencyManager.acquire/release", () => {
// Give microtask queue a chance to run
await Promise.resolve()
// #then - none resolved yet
// then - none resolved yet
expect(order).toEqual([])
// #when - release one at a time
// when - release one at a time
manager.release("model-a")
await task1
expect(order).toEqual(["1"])
@@ -251,63 +251,63 @@ describe("ConcurrencyManager.acquire/release", () => {
})
test("should handle independent models separately", async () => {
// #given
// given
const config: BackgroundTaskConfig = { defaultConcurrency: 1 }
manager = new ConcurrencyManager(config)
await manager.acquire("model-a")
// #when - acquire different model
// when - acquire different model
const resolved = await Promise.race([
manager.acquire("model-b").then(() => "resolved"),
Promise.resolve("timeout").then(() => "timeout")
])
// #then - different model should resolve immediately
// then - different model should resolve immediately
expect(resolved).toBe("resolved")
})
test("should allow re-acquiring after release", async () => {
// #given
// given
const config: BackgroundTaskConfig = { defaultConcurrency: 1 }
manager = new ConcurrencyManager(config)
// #when
// when
await manager.acquire("model-a")
manager.release("model-a")
await manager.acquire("model-a")
// #then - count should be 1 after re-acquiring
// then - count should be 1 after re-acquiring
expect(manager.getCount("model-a")).toBe(1)
})
test("should handle release when no acquire", () => {
// #given
// given
const config: BackgroundTaskConfig = { defaultConcurrency: 2 }
manager = new ConcurrencyManager(config)
// #when - release without acquire
// when - release without acquire
manager.release("model-a")
// #then - count should be 0 (no negative count)
// then - count should be 0 (no negative count)
expect(manager.getCount("model-a")).toBe(0)
})
test("should handle release when no prior acquire", () => {
// #given - default config
// given - default config
// #when - release without acquire
// when - release without acquire
manager.release("model-a")
// #then - count should be 0 (no negative count)
// then - count should be 0 (no negative count)
expect(manager.getCount("model-a")).toBe(0)
})
test("should handle multiple acquires and releases correctly", async () => {
// #given
// given
const config: BackgroundTaskConfig = { defaultConcurrency: 3 }
manager = new ConcurrencyManager(config)
// #when
// when
await manager.acquire("model-a")
await manager.acquire("model-a")
await manager.acquire("model-a")
@@ -320,12 +320,12 @@ describe("ConcurrencyManager.acquire/release", () => {
// Should be able to acquire again
await manager.acquire("model-a")
// #then - count should be 1 after re-acquiring
// then - count should be 1 after re-acquiring
expect(manager.getCount("model-a")).toBe(1)
})
test("should use model-specific limit for acquire", async () => {
// #given
// given
const config: BackgroundTaskConfig = {
modelConcurrency: { "anthropic/claude-sonnet-4-5": 2 },
defaultConcurrency: 5
@@ -334,14 +334,14 @@ describe("ConcurrencyManager.acquire/release", () => {
await manager.acquire("anthropic/claude-sonnet-4-5")
await manager.acquire("anthropic/claude-sonnet-4-5")
// #when
// when
let resolved = false
const waitPromise = manager.acquire("anthropic/claude-sonnet-4-5").then(() => { resolved = true })
// Give microtask queue a chance to run
await Promise.resolve()
// #then - should be waiting (model-specific limit is 2)
// then - should be waiting (model-specific limit is 2)
expect(resolved).toBe(false)
// Cleanup
@@ -352,7 +352,7 @@ describe("ConcurrencyManager.acquire/release", () => {
describe("ConcurrencyManager.cleanup", () => {
test("cancelWaiters should reject all pending acquires", async () => {
// #given
// given
const config: BackgroundTaskConfig = { defaultConcurrency: 1 }
const manager = new ConcurrencyManager(config)
await manager.acquire("model-a")
@@ -362,17 +362,17 @@ describe("ConcurrencyManager.cleanup", () => {
const p1 = manager.acquire("model-a").catch(e => errors.push(e))
const p2 = manager.acquire("model-a").catch(e => errors.push(e))
// #when
// when
manager.cancelWaiters("model-a")
await Promise.all([p1, p2])
// #then
// then
expect(errors.length).toBe(2)
expect(errors[0].message).toContain("cancelled")
})
test("clear should cancel all models and reset state", async () => {
// #given
// given
const config: BackgroundTaskConfig = { defaultConcurrency: 1 }
const manager = new ConcurrencyManager(config)
await manager.acquire("model-a")
@@ -382,22 +382,22 @@ describe("ConcurrencyManager.cleanup", () => {
const p1 = manager.acquire("model-a").catch(e => errors.push(e))
const p2 = manager.acquire("model-b").catch(e => errors.push(e))
// #when
// when
manager.clear()
await Promise.all([p1, p2])
// #then
// then
expect(errors.length).toBe(2)
expect(manager.getCount("model-a")).toBe(0)
expect(manager.getCount("model-b")).toBe(0)
})
test("getCount and getQueueLength should return correct values", async () => {
// #given
// given
const config: BackgroundTaskConfig = { defaultConcurrency: 2 }
const manager = new ConcurrencyManager(config)
// #when
// when
await manager.acquire("model-a")
expect(manager.getCount("model-a")).toBe(1)
expect(manager.getQueueLength("model-a")).toBe(0)
@@ -0,0 +1,52 @@
import type { PluginInput } from "@opencode-ai/plugin"
import type { BackgroundTask, LaunchInput } from "./types"
export const TASK_TTL_MS = 30 * 60 * 1000
export const MIN_STABILITY_TIME_MS = 10 * 1000
export const DEFAULT_STALE_TIMEOUT_MS = 180_000
export const MIN_RUNTIME_BEFORE_STALE_MS = 30_000
export const MIN_IDLE_TIME_MS = 5000
export const POLLING_INTERVAL_MS = 2000
export const TASK_CLEANUP_DELAY_MS = 5 * 60 * 1000
export const TMUX_CALLBACK_DELAY_MS = 200
export type ProcessCleanupEvent = NodeJS.Signals | "beforeExit" | "exit"
export type OpencodeClient = PluginInput["client"]
export interface MessagePartInfo {
sessionID?: string
type?: string
tool?: string
}
export interface EventProperties {
sessionID?: string
info?: { id?: string }
[key: string]: unknown
}
export interface BackgroundEvent {
type: string
properties?: EventProperties
}
export interface Todo {
content: string
status: string
priority: string
id: string
}
export interface QueueItem {
task: BackgroundTask
input: LaunchInput
}
export interface SubagentSessionCreatedEvent {
sessionID: string
parentID: string
title: string
}
export type OnSubagentSessionCreated = (event: SubagentSessionCreatedEvent) => Promise<void>
+2 -1
View File
@@ -1,3 +1,4 @@
export * from "./types"
export { BackgroundManager } from "./manager"
export { BackgroundManager, type SubagentSessionCreatedEvent, type OnSubagentSessionCreated } from "./manager"
export { ConcurrencyManager } from "./concurrency"
export { TaskStateManager } from "./state"
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,265 @@
import type { BackgroundTask } from "./types"
import type { OpencodeClient, Todo } from "./constants"
import { TASK_CLEANUP_DELAY_MS } from "./constants"
import { log } from "../../shared"
import { getTaskToastManager } from "../task-toast-manager"
import { findNearestMessageWithFields, MESSAGE_STORAGE } from "../hook-message-injector"
import { existsSync, readdirSync } from "node:fs"
import { join } from "node:path"
import type { ConcurrencyManager } from "./concurrency"
import type { TaskStateManager } from "./state"
export interface ResultHandlerContext {
client: OpencodeClient
concurrencyManager: ConcurrencyManager
state: TaskStateManager
}
export async function checkSessionTodos(
client: OpencodeClient,
sessionID: string
): Promise<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
if (allComplete) {
const completedTasks = Array.from(state.tasks.values())
.filter(t => t.parentSessionID === task.parentSessionID && t.status !== "running" && t.status !== "pending")
.map(t => `- \`${t.id}\`: ${t.description}`)
.join("\n")
notification = `<system-reminder>
[ALL BACKGROUND TASKS COMPLETE]
**Completed:**
${completedTasks || `- \`${task.id}\`: ${task.description}`}
Use \`background_output(task_id="<id>")\` to retrieve each result.
</system-reminder>`
} else {
notification = `<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>`
}
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.prompt({
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)
}
const taskId = task.id
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)
}
+244
View File
@@ -0,0 +1,244 @@
import type { BackgroundTask, LaunchInput, ResumeInput } from "./types"
import type { OpencodeClient, OnSubagentSessionCreated, QueueItem } from "./constants"
import { TMUX_CALLBACK_DELAY_MS } from "./constants"
import { log, getAgentToolRestrictions, promptWithModelSuggestionRetry } from "../../shared"
import { subagentSessions } from "../claude-code-session-state"
import { getTaskToastManager } from "../task-toast-manager"
import { isInsideTmux } from "../../shared/tmux"
import type { ConcurrencyManager } from "./concurrency"
export interface SpawnerContext {
client: OpencodeClient
directory: string
concurrencyManager: ConcurrencyManager
tmuxEnabled: boolean
onSubagentSessionCreated?: OnSubagentSessionCreated
onTaskError: (task: BackgroundTask, error: Error) => void
}
export function createTask(input: LaunchInput): BackgroundTask {
return {
id: `bg_${crypto.randomUUID().slice(0, 8)}`,
status: "pending",
queuedAt: new Date(),
description: input.description,
prompt: input.prompt,
agent: input.agent,
parentSessionID: input.parentSessionID,
parentMessageID: input.parentMessageID,
parentModel: input.parentModel,
parentAgent: input.parentAgent,
model: input.model,
}
}
export async function startTask(
item: QueueItem,
ctx: SpawnerContext
): Promise<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,
delegate_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.prompt({
path: { id: task.sessionID },
body: {
agent: task.agent,
...(resumeModel ? { model: resumeModel } : {}),
...(resumeVariant ? { variant: resumeVariant } : {}),
tools: {
...getAgentToolRestrictions(task.agent),
task: false,
delegate_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)))
})
}
+204
View File
@@ -0,0 +1,204 @@
import type { BackgroundTask, LaunchInput } from "./types"
import type { QueueItem } from "./constants"
import { log } from "../../shared"
import { subagentSessions } from "../claude-code-session-state"
export class TaskStateManager {
readonly tasks: Map<string, BackgroundTask> = new Map()
readonly notifications: Map<string, BackgroundTask[]> = new Map()
readonly pendingByParent: Map<string, Set<string>> = new Map()
readonly queuesByKey: Map<string, QueueItem[]> = new Map()
readonly processingKeys: Set<string> = new Set()
readonly completionTimers: Map<string, ReturnType<typeof setTimeout>> = new Map()
getTask(id: string): BackgroundTask | undefined {
return this.tasks.get(id)
}
findBySession(sessionID: string): BackgroundTask | undefined {
for (const task of this.tasks.values()) {
if (task.sessionID === sessionID) {
return task
}
}
return undefined
}
getTasksByParentSession(sessionID: string): BackgroundTask[] {
const result: BackgroundTask[] = []
for (const task of this.tasks.values()) {
if (task.parentSessionID === sessionID) {
result.push(task)
}
}
return result
}
getAllDescendantTasks(sessionID: string): BackgroundTask[] {
const result: BackgroundTask[] = []
const directChildren = this.getTasksByParentSession(sessionID)
for (const child of directChildren) {
result.push(child)
if (child.sessionID) {
const descendants = this.getAllDescendantTasks(child.sessionID)
result.push(...descendants)
}
}
return result
}
getRunningTasks(): BackgroundTask[] {
return Array.from(this.tasks.values()).filter(t => t.status === "running")
}
getCompletedTasks(): BackgroundTask[] {
return Array.from(this.tasks.values()).filter(t => t.status !== "running")
}
hasRunningTasks(): boolean {
for (const task of this.tasks.values()) {
if (task.status === "running") return true
}
return false
}
getConcurrencyKeyFromInput(input: LaunchInput): string {
if (input.model) {
return `${input.model.providerID}/${input.model.modelID}`
}
return input.agent
}
getConcurrencyKeyFromTask(task: BackgroundTask): string {
if (task.model) {
return `${task.model.providerID}/${task.model.modelID}`
}
return task.agent
}
addTask(task: BackgroundTask): void {
this.tasks.set(task.id, task)
}
removeTask(taskId: string): void {
const task = this.tasks.get(taskId)
if (task?.sessionID) {
subagentSessions.delete(task.sessionID)
}
this.tasks.delete(taskId)
}
trackPendingTask(parentSessionID: string, taskId: string): void {
const pending = this.pendingByParent.get(parentSessionID) ?? new Set()
pending.add(taskId)
this.pendingByParent.set(parentSessionID, pending)
}
cleanupPendingByParent(task: BackgroundTask): void {
if (!task.parentSessionID) return
const pending = this.pendingByParent.get(task.parentSessionID)
if (pending) {
pending.delete(task.id)
if (pending.size === 0) {
this.pendingByParent.delete(task.parentSessionID)
}
}
}
markForNotification(task: BackgroundTask): void {
const queue = this.notifications.get(task.parentSessionID) ?? []
queue.push(task)
this.notifications.set(task.parentSessionID, queue)
}
getPendingNotifications(sessionID: string): BackgroundTask[] {
return this.notifications.get(sessionID) ?? []
}
clearNotifications(sessionID: string): void {
this.notifications.delete(sessionID)
}
clearNotificationsForTask(taskId: string): void {
for (const [sessionID, tasks] of this.notifications.entries()) {
const filtered = tasks.filter((t) => t.id !== taskId)
if (filtered.length === 0) {
this.notifications.delete(sessionID)
} else {
this.notifications.set(sessionID, filtered)
}
}
}
addToQueue(key: string, item: QueueItem): void {
const queue = this.queuesByKey.get(key) ?? []
queue.push(item)
this.queuesByKey.set(key, queue)
}
getQueue(key: string): QueueItem[] | undefined {
return this.queuesByKey.get(key)
}
removeFromQueue(key: string, taskId: string): boolean {
const queue = this.queuesByKey.get(key)
if (!queue) return false
const index = queue.findIndex(item => item.task.id === taskId)
if (index === -1) return false
queue.splice(index, 1)
if (queue.length === 0) {
this.queuesByKey.delete(key)
}
return true
}
setCompletionTimer(taskId: string, timer: ReturnType<typeof setTimeout>): void {
this.completionTimers.set(taskId, timer)
}
clearCompletionTimer(taskId: string): void {
const timer = this.completionTimers.get(taskId)
if (timer) {
clearTimeout(timer)
this.completionTimers.delete(taskId)
}
}
clearAllCompletionTimers(): void {
for (const timer of this.completionTimers.values()) {
clearTimeout(timer)
}
this.completionTimers.clear()
}
clear(): void {
this.clearAllCompletionTimers()
this.tasks.clear()
this.notifications.clear()
this.pendingByParent.clear()
this.queuesByKey.clear()
this.processingKeys.clear()
}
cancelPendingTask(taskId: string): boolean {
const task = this.tasks.get(taskId)
if (!task || task.status !== "pending") {
return false
}
const key = this.getConcurrencyKeyFromTask(task)
this.removeFromQueue(key, taskId)
task.status = "cancelled"
task.completedAt = new Date()
this.cleanupPendingByParent(task)
log("[background-agent] Cancelled pending task:", { taskId, key })
return true
}
}