Merge branch 'dev' into fix/issue-2232

This commit is contained in:
YeonGyu-Kim
2026-03-11 20:20:04 +09:00
committed by GitHub
210 changed files with 7089 additions and 1274 deletions
@@ -0,0 +1,49 @@
import { describe, expect, test } from "bun:test"
import { tmpdir } from "node:os"
import type { PluginInput } from "@opencode-ai/plugin"
import { BackgroundManager } from "./manager"
describe("BackgroundManager session permission", () => {
test("passes explicit session permission rules to child session creation", async () => {
// given
const createCalls: Array<Record<string, unknown>> = []
const client = {
session: {
get: async () => ({ data: { directory: "/parent" } }),
create: async (input: Record<string, unknown>) => {
createCalls.push(input)
return { data: { id: "ses_child" } }
},
promptAsync: async () => ({}),
abort: async () => ({}),
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
// when
await manager.launch({
description: "Test task",
prompt: "Do something",
agent: "explore",
parentSessionID: "ses_parent",
parentMessageID: "msg_parent",
sessionPermission: [
{ permission: "question", action: "deny", pattern: "*" },
],
})
await new Promise(resolve => setTimeout(resolve, 50))
manager.shutdown()
// then
expect(createCalls).toHaveLength(1)
expect(createCalls[0]?.body).toEqual({
parentID: "ses_parent",
title: "Test task (@explore subagent)",
permission: [
{ permission: "question", action: "deny", pattern: "*" },
],
})
})
})
@@ -2,6 +2,7 @@ import { describe, test, expect } from "bun:test"
import { tmpdir } from "node:os"
import type { PluginInput } from "@opencode-ai/plugin"
import { BackgroundManager } from "./manager"
import type { BackgroundTask } from "./types"
function createManagerWithStatus(statusImpl: () => Promise<{ data: Record<string, { type: string }> }>): BackgroundManager {
const client = {
@@ -51,3 +52,105 @@ describe("BackgroundManager polling overlap", () => {
expect(statusCallCount).toBe(1)
})
})
function createRunningTask(sessionID: string): BackgroundTask {
return {
id: `bg_test_${sessionID}`,
sessionID,
parentSessionID: "parent-session",
parentMessageID: "parent-msg",
description: "test task",
prompt: "test",
agent: "explore",
status: "running",
startedAt: new Date(),
progress: { toolCalls: 0, lastUpdate: new Date() },
}
}
function injectTask(manager: BackgroundManager, task: BackgroundTask): void {
const tasks = (manager as unknown as { tasks: Map<string, BackgroundTask> }).tasks
tasks.set(task.id, task)
}
function createManagerWithClient(clientOverrides: Record<string, unknown> = {}): BackgroundManager {
const client = {
session: {
status: async () => ({ data: {} }),
prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async () => ({}),
todo: async () => ({ data: [] }),
messages: async () => ({
data: [{
info: { role: "assistant", finish: "end_turn", id: "msg-2" },
parts: [{ type: "text", text: "done" }],
}, {
info: { role: "user", id: "msg-1" },
parts: [{ type: "text", text: "go" }],
}],
}),
...clientOverrides,
},
}
return new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
}
describe("BackgroundManager pollRunningTasks", () => {
describe("#given a running task whose session is no longer in status response", () => {
test("#when pollRunningTasks runs #then completes the task instead of leaving it running", async () => {
//#given
const manager = createManagerWithClient()
const task = createRunningTask("ses-gone")
injectTask(manager, task)
//#when
const poll = (manager as unknown as { pollRunningTasks: () => Promise<void> }).pollRunningTasks
await poll.call(manager)
manager.shutdown()
//#then
expect(task.status).toBe("completed")
expect(task.completedAt).toBeDefined()
})
})
describe("#given a running task whose session status is idle", () => {
test("#when pollRunningTasks runs #then completes the task", async () => {
//#given
const manager = createManagerWithClient({
status: async () => ({ data: { "ses-idle": { type: "idle" } } }),
})
const task = createRunningTask("ses-idle")
injectTask(manager, task)
//#when
const poll = (manager as unknown as { pollRunningTasks: () => Promise<void> }).pollRunningTasks
await poll.call(manager)
manager.shutdown()
//#then
expect(task.status).toBe("completed")
})
})
describe("#given a running task whose session status is busy", () => {
test("#when pollRunningTasks runs #then keeps the task running", async () => {
//#given
const manager = createManagerWithClient({
status: async () => ({ data: { "ses-busy": { type: "busy" } } }),
})
const task = createRunningTask("ses-busy")
injectTask(manager, task)
//#when
const poll = (manager as unknown as { pollRunningTasks: () => Promise<void> }).pollRunningTasks
await poll.call(manager)
manager.shutdown()
//#then
expect(task.status).toBe("running")
})
})
})
+109 -13
View File
@@ -1334,6 +1334,100 @@ describe("BackgroundManager.tryCompleteTask", () => {
expect(getPendingByParent(manager).get(task.parentSessionID)).toBeUndefined()
})
test("should remove toast tracking before notifying completed task", async () => {
// given
const { removeTaskCalls, resetToastManager } = createToastRemoveTaskTracker()
const task: BackgroundTask = {
id: "task-toast-complete",
sessionID: "session-toast-complete",
parentSessionID: "parent-toast-complete",
parentMessageID: "msg-1",
description: "toast completion task",
prompt: "test",
agent: "explore",
status: "running",
startedAt: new Date(),
}
try {
// when
await tryCompleteTaskForTest(manager, task)
// then
expect(removeTaskCalls).toContain(task.id)
} finally {
resetToastManager()
}
})
test("should release task concurrencyKey when startTask throws after assigning it", async () => {
// given
const concurrencyKey = "anthropic/claude-opus-4-6"
const concurrencyManager = getConcurrencyManager(manager)
const task = createMockTask({
id: "task-process-key-concurrency",
sessionID: "session-process-key-concurrency",
parentSessionID: "parent-process-key-concurrency",
status: "pending",
agent: "explore",
})
const input = {
description: task.description,
prompt: task.prompt,
agent: task.agent,
parentSessionID: task.parentSessionID,
parentMessageID: task.parentMessageID,
model: { providerID: "anthropic", modelID: "claude-opus-4-6" },
}
getTaskMap(manager).set(task.id, task)
getQueuesByKey(manager).set(concurrencyKey, [{ task, input }])
;(manager as unknown as { startTask: (item: { task: BackgroundTask; input: typeof input }) => Promise<void> }).startTask = async (item) => {
item.task.concurrencyKey = concurrencyKey
throw new Error("startTask failed after assigning concurrencyKey")
}
// when
await processKeyForTest(manager, concurrencyKey)
// then
expect(concurrencyManager.getCount(concurrencyKey)).toBe(0)
expect(task.concurrencyKey).toBeUndefined()
})
test("should release queue slot when queued task is already interrupt", async () => {
// given
const concurrencyKey = "anthropic/claude-opus-4-6"
const concurrencyManager = getConcurrencyManager(manager)
const task = createMockTask({
id: "task-process-key-interrupt",
sessionID: "session-process-key-interrupt",
parentSessionID: "parent-process-key-interrupt",
status: "interrupt",
agent: "explore",
})
const input = {
description: task.description,
prompt: task.prompt,
agent: task.agent,
parentSessionID: task.parentSessionID,
parentMessageID: task.parentMessageID,
model: { providerID: "anthropic", modelID: "claude-opus-4-6" },
}
getTaskMap(manager).set(task.id, task)
getQueuesByKey(manager).set(concurrencyKey, [{ task, input }])
// when
await processKeyForTest(manager, concurrencyKey)
// then
expect(concurrencyManager.getCount(concurrencyKey)).toBe(0)
expect(getQueuesByKey(manager).get(concurrencyKey)).toEqual([])
})
test("should avoid overlapping promptAsync calls when tasks complete concurrently", async () => {
// given
type PromptAsyncBody = Record<string, unknown> & { noReply?: boolean }
@@ -3189,7 +3283,7 @@ describe("BackgroundManager.handleEvent - session.error", () => {
concurrencyKey,
fallbackChain: [
{ providers: ["anthropic"], model: "claude-opus-4-6", variant: "max" },
{ providers: ["anthropic"], model: "claude-opus-4-5" },
{ providers: ["anthropic"], model: "claude-opus-4-5", variant: "max" },
],
})
@@ -3271,21 +3365,23 @@ describe("BackgroundManager.handleEvent - session.error", () => {
})
//#when
const messageInfo = {
id: "msg_errored",
sessionID,
role: "assistant",
error: {
name: "UnknownError",
data: {
message:
"Bad Gateway: {\"error\":{\"message\":\"unknown provider for model claude-opus-4-6-thinking\"}}",
},
},
}
manager.handleEvent({
type: "message.updated",
properties: {
info: {
id: "msg_errored",
sessionID,
role: "assistant",
error: {
name: "UnknownError",
data: {
message:
"Bad Gateway: {\"error\":{\"message\":\"unknown provider for model claude-opus-4-6-thinking\"}}",
},
},
},
info: messageInfo,
},
})
+51 -40
View File
@@ -50,6 +50,7 @@ import { MESSAGE_STORAGE } from "../hook-message-injector"
import { join } from "node:path"
import { pruneStaleTasksAndNotifications } from "./task-poller"
import { checkAndInterruptStaleTasks } from "./task-poller"
import { removeTaskToastTracking } from "./remove-task-toast-tracking"
type OpencodeClient = PluginInput["client"]
@@ -225,7 +226,7 @@ export class BackgroundManager {
await this.concurrencyManager.acquire(key)
if (item.task.status === "cancelled" || item.task.status === "error") {
if (item.task.status === "cancelled" || item.task.status === "error" || item.task.status === "interrupt") {
this.concurrencyManager.release(key)
queue.shift()
continue
@@ -235,9 +236,10 @@ export class BackgroundManager {
await this.startTask(item)
} catch (error) {
log("[background-agent] Error starting task:", error)
// Release concurrency slot if startTask failed and didn't release it itself
// This prevents slot leaks when errors occur after acquire but before task.concurrencyKey is set
if (!item.task.concurrencyKey) {
if (item.task.concurrencyKey) {
this.concurrencyManager.release(item.task.concurrencyKey)
item.task.concurrencyKey = undefined
} else {
this.concurrencyManager.release(key)
}
}
@@ -273,6 +275,7 @@ export class BackgroundManager {
body: {
parentID: input.parentSessionID,
title: `${input.description} (@${input.agent} subagent)`,
...(input.sessionPermission ? { permission: input.sessionPermission } : {}),
} as Record<string, unknown>,
query: {
directory: parentDirectory,
@@ -387,6 +390,8 @@ export class BackgroundManager {
existingTask.concurrencyKey = undefined
}
removeTaskToastTracking(existingTask.id)
// Abort the session to prevent infinite polling hang
this.client.session.abort({
path: { id: sessionID },
@@ -656,6 +661,8 @@ export class BackgroundManager {
existingTask.concurrencyKey = undefined
}
removeTaskToastTracking(existingTask.id)
// Abort the session to prevent infinite polling hang
if (existingTask.sessionID) {
this.client.session.abort({
@@ -1107,11 +1114,9 @@ export class BackgroundManager {
SessionCategoryRegistry.remove(task.sessionID)
}
removeTaskToastTracking(task.id)
if (options?.skipNotification) {
const toastManager = getTaskToastManager()
if (toastManager) {
toastManager.removeTask(task.id)
}
log(`[background-agent] Task cancelled via ${source} (notification skipped):`, task.id)
return true
}
@@ -1197,6 +1202,8 @@ export class BackgroundManager {
task.completedAt = new Date()
this.taskHistory.record(task.parentSessionID, { id: task.id, sessionID: task.sessionID, agent: task.agent, description: task.description, status: "completed", category: task.category, startedAt: task.startedAt, completedAt: task.completedAt })
removeTaskToastTracking(task.id)
// Release concurrency BEFORE any async operations to prevent slot leaks
if (task.concurrencyKey) {
this.concurrencyManager.release(task.concurrencyKey)
@@ -1444,6 +1451,7 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
this.concurrencyManager.release(task.concurrencyKey)
task.concurrencyKey = undefined
}
removeTaskToastTracking(task.id)
this.cleanupPendingByParent(task)
if (wasPending) {
const key = task.model
@@ -1506,32 +1514,7 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
try {
const sessionStatus = allStatuses[sessionID]
if (sessionStatus?.type === "idle") {
// Edge guard: Validate session has actual output before completing
const hasValidOutput = await this.validateSessionHasOutput(sessionID)
if (!hasValidOutput) {
log("[background-agent] Polling idle but no valid output yet, waiting:", task.id)
continue
}
// Re-check status after async operation
if (task.status !== "running") continue
const hasIncompleteTodos = await this.checkSessionTodos(sessionID)
if (hasIncompleteTodos) {
log("[background-agent] Task has incomplete todos via polling, waiting:", task.id)
continue
}
await this.tryCompleteTask(task, "polling (idle status)")
continue
}
// Session is still actively running (not idle).
// Progress is already tracked via handleEvent(message.part.updated),
// so we skip the expensive session.messages() fetch here.
// Completion will be detected when session transitions to idle.
// Handle retry before checking running state
if (sessionStatus?.type === "retry") {
const retryMessage = typeof (sessionStatus as { message?: string }).message === "string"
? (sessionStatus as { message?: string }).message
@@ -1542,12 +1525,40 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
}
}
log("[background-agent] Session still running, relying on event-based progress:", {
taskId: task.id,
sessionID,
sessionStatus: sessionStatus?.type ?? "not_in_status",
toolCalls: task.progress?.toolCalls ?? 0,
})
// Match sync-session-poller pattern: only skip completion check when
// status EXISTS and is not idle (i.e., session is actively running).
// When sessionStatus is undefined, the session has completed and dropped
// from the status response — fall through to completion detection.
if (sessionStatus && sessionStatus.type !== "idle") {
log("[background-agent] Session still running, relying on event-based progress:", {
taskId: task.id,
sessionID,
sessionStatus: sessionStatus.type,
toolCalls: task.progress?.toolCalls ?? 0,
})
continue
}
// Session is idle or no longer in status response (completed/disappeared)
const completionSource = sessionStatus?.type === "idle"
? "polling (idle status)"
: "polling (session gone from status)"
const hasValidOutput = await this.validateSessionHasOutput(sessionID)
if (!hasValidOutput) {
log("[background-agent] Polling idle/gone but no valid output yet, waiting:", task.id)
continue
}
// Re-check status after async operation
if (task.status !== "running") continue
const hasIncompleteTodos = await this.checkSessionTodos(sessionID)
if (hasIncompleteTodos) {
log("[background-agent] Task has incomplete todos via polling, waiting:", task.id)
continue
}
await this.tryCompleteTask(task, completionSource)
} catch (error) {
log("[background-agent] Poll error for task:", { taskId: task.id, error })
}
@@ -0,0 +1,8 @@
import { getTaskToastManager } from "../task-toast-manager"
export function removeTaskToastTracking(taskId: string): void {
const toastManager = getTaskToastManager()
if (toastManager) {
toastManager.removeTask(taskId)
}
}
@@ -3,7 +3,7 @@ import { describe, test, expect } from "bun:test"
import { createTask, startTask } from "./spawner"
describe("background-agent spawner.startTask", () => {
test("does not override parent session permission rules when creating child session", async () => {
test("applies explicit child session permission rules when creating child session", async () => {
//#given
const createCalls: any[] = []
const parentPermission = [
@@ -41,6 +41,9 @@ describe("background-agent spawner.startTask", () => {
parentModel: task.parentModel,
parentAgent: task.parentAgent,
model: task.model,
sessionPermission: [
{ permission: "question", action: "deny", pattern: "*" },
],
},
}
@@ -57,6 +60,8 @@ describe("background-agent spawner.startTask", () => {
//#then
expect(createCalls).toHaveLength(1)
expect(createCalls[0]?.body?.permission).toBeUndefined()
expect(createCalls[0]?.body?.permission).toEqual([
{ permission: "question", action: "deny", pattern: "*" },
])
})
})
+1
View File
@@ -61,6 +61,7 @@ export async function startTask(
const createResult = await client.session.create({
body: {
parentID: input.parentSessionID,
...(input.sessionPermission ? { permission: input.sessionPermission } : {}),
} as Record<string, unknown>,
query: {
directory: parentDirectory,
@@ -391,6 +391,31 @@ describe("checkAndInterruptStaleTasks", () => {
expect(releaseMock).toHaveBeenCalledWith("anthropic/claude-opus-4-6")
expect(task.concurrencyKey).toBeUndefined()
})
it("should invoke interruption callback immediately when stale task is cancelled", async () => {
//#given
const task = createRunningTask({
progress: {
toolCalls: 1,
lastUpdate: new Date(Date.now() - 200_000),
},
})
const onTaskInterrupted = mock(() => {})
//#when
await checkAndInterruptStaleTasks({
tasks: [task],
client: mockClient as never,
config: { staleTimeoutMs: 180_000 },
concurrencyManager: mockConcurrencyManager as never,
notifyParentSession: mockNotify,
onTaskInterrupted,
})
//#then
expect(task.status).toBe("cancelled")
expect(onTaskInterrupted).toHaveBeenCalledWith(task)
})
})
describe("pruneStaleTasksAndNotifications", () => {
+15 -1
View File
@@ -11,6 +11,7 @@ import {
MIN_RUNTIME_BEFORE_STALE_MS,
TASK_TTL_MS,
} from "./constants"
import { removeTaskToastTracking } from "./remove-task-toast-tracking"
export function pruneStaleTasksAndNotifications(args: {
tasks: Map<string, BackgroundTask>
@@ -66,8 +67,17 @@ export async function checkAndInterruptStaleTasks(args: {
concurrencyManager: ConcurrencyManager
notifyParentSession: (task: BackgroundTask) => Promise<void>
sessionStatuses?: SessionStatusMap
onTaskInterrupted?: (task: BackgroundTask) => void
}): Promise<void> {
const { tasks, client, config, concurrencyManager, notifyParentSession, sessionStatuses } = args
const {
tasks,
client,
config,
concurrencyManager,
notifyParentSession,
sessionStatuses,
onTaskInterrupted = (task) => removeTaskToastTracking(task.id),
} = args
const staleTimeoutMs = config?.staleTimeoutMs ?? DEFAULT_STALE_TIMEOUT_MS
const now = Date.now()
@@ -98,6 +108,8 @@ export async function checkAndInterruptStaleTasks(args: {
task.concurrencyKey = undefined
}
onTaskInterrupted(task)
client.session.abort({ path: { id: sessionID } }).catch(() => {})
log(`[background-agent] Task ${task.id} interrupted: no progress since start`)
@@ -127,6 +139,8 @@ export async function checkAndInterruptStaleTasks(args: {
task.concurrencyKey = undefined
}
onTaskInterrupted(task)
client.session.abort({ path: { id: sessionID } }).catch(() => {})
log(`[background-agent] Task ${task.id} interrupted: stale timeout`)
+2
View File
@@ -1,4 +1,5 @@
import type { FallbackEntry } from "../../shared/model-requirements"
import type { SessionPermissionRule } from "../../shared/question-denied-session-permission"
export type BackgroundTaskStatus =
| "pending"
@@ -72,6 +73,7 @@ export interface LaunchInput {
skills?: string[]
skillContent?: string
category?: string
sessionPermission?: SessionPermissionRule[]
}
export interface ResumeInput {