Merge pull request #4074 from code-yeongyu/fix/delegate-task-spawn
fix(delegate-task): start child prompts reliably
This commit is contained in:
@@ -1,16 +1,15 @@
|
|||||||
import { describe, test, expect } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { buildAgent } from "./agent-builder"
|
import { buildAgent } from "./agent-builder"
|
||||||
import type { AgentFactory } from "./types"
|
import type { AgentFactory } from "./types"
|
||||||
|
|
||||||
describe("#given an agent factory with mode", () => {
|
describe("#given an agent factory with mode", () => {
|
||||||
const mockFactory = ((model: string) => ({
|
const mockFactory: AgentFactory = Object.assign((model: string) => ({
|
||||||
name: "test-agent",
|
name: "test-agent",
|
||||||
description: "Test",
|
description: "Test",
|
||||||
instructions: "test",
|
instructions: "test",
|
||||||
model,
|
model,
|
||||||
temperature: 0.1,
|
temperature: 0.1,
|
||||||
})) as AgentFactory
|
}), { mode: "subagent" as const })
|
||||||
mockFactory.mode = "subagent"
|
|
||||||
|
|
||||||
test("#when building agent from factory", () => {
|
test("#when building agent from factory", () => {
|
||||||
const agent = buildAgent(mockFactory, "test-model")
|
const agent = buildAgent(mockFactory, "test-model")
|
||||||
@@ -19,14 +18,13 @@ describe("#given an agent factory with mode", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe("#given an agent factory with mode=primary", () => {
|
describe("#given an agent factory with mode=primary", () => {
|
||||||
const mockFactory = ((model: string) => ({
|
const mockFactory: AgentFactory = Object.assign((model: string) => ({
|
||||||
name: "primary-agent",
|
name: "primary-agent",
|
||||||
description: "Primary Test",
|
description: "Primary Test",
|
||||||
instructions: "test",
|
instructions: "test",
|
||||||
model,
|
model,
|
||||||
temperature: 0.1,
|
temperature: 0.1,
|
||||||
})) as AgentFactory
|
}), { mode: "primary" as const })
|
||||||
mockFactory.mode = "primary"
|
|
||||||
|
|
||||||
test("#when building agent from factory", () => {
|
test("#when building agent from factory", () => {
|
||||||
const agent = buildAgent(mockFactory, "test-model")
|
const agent = buildAgent(mockFactory, "test-model")
|
||||||
@@ -50,15 +48,14 @@ describe("#given an agent config object without mode", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe("#given an agent factory with mode but config already has mode", () => {
|
describe("#given an agent factory with mode but config already has mode", () => {
|
||||||
const mockFactory = ((model: string) => ({
|
const mockFactory: AgentFactory = Object.assign((model: string) => ({
|
||||||
name: "override-agent",
|
name: "override-agent",
|
||||||
description: "Override Test",
|
description: "Override Test",
|
||||||
instructions: "test",
|
instructions: "test",
|
||||||
model,
|
model,
|
||||||
temperature: 0.1,
|
temperature: 0.1,
|
||||||
mode: "all",
|
mode: "all" as const,
|
||||||
})) as AgentFactory
|
}), { mode: "subagent" as const })
|
||||||
mockFactory.mode = "subagent"
|
|
||||||
|
|
||||||
test("#when building agent from factory", () => {
|
test("#when building agent from factory", () => {
|
||||||
const agent = buildAgent(mockFactory, "test-model")
|
const agent = buildAgent(mockFactory, "test-model")
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ import { describe, expect, test } from "bun:test"
|
|||||||
|
|
||||||
import { buildAvailableSkills } from "./available-skills"
|
import { buildAvailableSkills } from "./available-skills"
|
||||||
|
|
||||||
|
type DiscoveredSkills = Parameters<typeof buildAvailableSkills>[0]
|
||||||
|
|
||||||
describe("buildAvailableSkills", () => {
|
describe("buildAvailableSkills", () => {
|
||||||
test("includes team-mode when team mode is enabled", () => {
|
test("includes team-mode when team mode is enabled", () => {
|
||||||
// given
|
// given
|
||||||
const discoveredSkills = []
|
const discoveredSkills: DiscoveredSkills = []
|
||||||
|
|
||||||
// when
|
// when
|
||||||
const availableSkills = buildAvailableSkills(discoveredSkills, undefined, undefined, true)
|
const availableSkills = buildAvailableSkills(discoveredSkills, undefined, undefined, true)
|
||||||
@@ -16,7 +18,7 @@ describe("buildAvailableSkills", () => {
|
|||||||
|
|
||||||
test("excludes team-mode when team mode is disabled", () => {
|
test("excludes team-mode when team mode is disabled", () => {
|
||||||
// given
|
// given
|
||||||
const discoveredSkills = []
|
const discoveredSkills: DiscoveredSkills = []
|
||||||
|
|
||||||
// when
|
// when
|
||||||
const availableSkills = buildAvailableSkills(discoveredSkills, undefined, undefined, false)
|
const availableSkills = buildAvailableSkills(discoveredSkills, undefined, undefined, false)
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test"
|
import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test"
|
||||||
import { tryFallbackRetry, type FallbackRetryHandlerDeps } from "./fallback-retry-handler"
|
import { tryFallbackRetry, type FallbackRetryHandlerDeps } from "./fallback-retry-handler"
|
||||||
import type { FallbackEntry } from "../../shared/model-requirements"
|
import type { FallbackEntry } from "../../shared/model-requirements"
|
||||||
|
import type { ProviderModelsCache } from "../../shared/connected-providers-cache"
|
||||||
|
import { QUESTION_DENIED_SESSION_PERMISSION } from "../../shared/question-denied-session-permission"
|
||||||
|
|
||||||
const sharedLogMock = mock(() => {})
|
const sharedLogMock = mock(() => {})
|
||||||
const readConnectedProvidersCacheMock = mock(() => null)
|
const readConnectedProvidersCacheMock = mock(() => null)
|
||||||
const readProviderModelsCacheMock = mock((): { connected: string[] } | null => null)
|
const readProviderModelsCacheMock = mock((): ProviderModelsCache | null => null)
|
||||||
const shouldRetryErrorMock = mock(() => true)
|
const shouldRetryErrorMock = mock(() => true)
|
||||||
const getNextFallbackMock = mock((chain: FallbackEntry[], attempt: number) => chain[attempt])
|
const getNextFallbackMock = mock((chain: FallbackEntry[], attempt: number) => chain[attempt])
|
||||||
const hasMoreFallbacksMock = mock((chain: FallbackEntry[], attempt: number) => attempt < chain.length)
|
const hasMoreFallbacksMock = mock((chain: FallbackEntry[], attempt: number) => attempt < chain.length)
|
||||||
@@ -258,6 +260,20 @@ describe("tryFallbackRetry", () => {
|
|||||||
expect(retryInput?.onSessionCreated).toBe(onSessionCreated)
|
expect(retryInput?.onSessionCreated).toBe(onSessionCreated)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("preserves delegated launch context in retry input", async () => {
|
||||||
|
const args = createDefaultArgs({
|
||||||
|
skillContent: "delegated skill system",
|
||||||
|
sessionPermission: QUESTION_DENIED_SESSION_PERMISSION,
|
||||||
|
})
|
||||||
|
|
||||||
|
await tryFallbackRetry(args)
|
||||||
|
|
||||||
|
const key = `${args.task.model!.providerID}/${args.task.model!.modelID}`
|
||||||
|
const retryInput = args.queuesByKey.get(key)?.[0]?.input
|
||||||
|
expect(retryInput?.skillContent).toBe("delegated skill system")
|
||||||
|
expect(retryInput?.sessionPermission).toEqual(QUESTION_DENIED_SESSION_PERMISSION)
|
||||||
|
})
|
||||||
|
|
||||||
test("finalizes the failed attempt, creates a new pending attempt, and enqueues its explicit attemptID", async () => {
|
test("finalizes the failed attempt, creates a new pending attempt, and enqueues its explicit attemptID", async () => {
|
||||||
const args = createDefaultArgs({
|
const args = createDefaultArgs({
|
||||||
status: "running",
|
status: "running",
|
||||||
@@ -416,7 +432,11 @@ describe("tryFallbackRetry", () => {
|
|||||||
|
|
||||||
describe("#given disconnected fallback providers with connected preferred provider", () => {
|
describe("#given disconnected fallback providers with connected preferred provider", () => {
|
||||||
test("keeps fallback entry and selects connected preferred provider", async () => {
|
test("keeps fallback entry and selects connected preferred provider", async () => {
|
||||||
readProviderModelsCacheMock.mockReturnValueOnce({ connected: ["provider-a"] })
|
readProviderModelsCacheMock.mockReturnValueOnce({
|
||||||
|
connected: ["provider-a"],
|
||||||
|
models: {},
|
||||||
|
updatedAt: new Date("2026-05-16T00:00:00.000Z").toISOString(),
|
||||||
|
})
|
||||||
selectFallbackProviderMock.mockImplementationOnce(
|
selectFallbackProviderMock.mockImplementationOnce(
|
||||||
(_providers: string[], preferredProviderID?: string) => preferredProviderID ?? "provider-b",
|
(_providers: string[], preferredProviderID?: string) => preferredProviderID ?? "provider-b",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -197,6 +197,8 @@ export async function tryFallbackRetry(args: {
|
|||||||
teamRunId: task.teamRunId,
|
teamRunId: task.teamRunId,
|
||||||
model: nextModel,
|
model: nextModel,
|
||||||
fallbackChain: task.fallbackChain,
|
fallbackChain: task.fallbackChain,
|
||||||
|
skillContent: task.skillContent,
|
||||||
|
sessionPermission: task.sessionPermission,
|
||||||
category: task.category,
|
category: task.category,
|
||||||
isUnstableAgent: task.isUnstableAgent,
|
isUnstableAgent: task.isUnstableAgent,
|
||||||
onSessionCreated: task.onSessionCreated,
|
onSessionCreated: task.onSessionCreated,
|
||||||
|
|||||||
@@ -1,21 +1,32 @@
|
|||||||
declare const require: (name: string) => any
|
import { tmpdir } from "node:os"
|
||||||
const { describe, test, expect, beforeEach, afterEach, afterAll, spyOn, mock } = require("bun:test")
|
import { describe, test, expect, beforeEach, afterEach, afterAll, spyOn, mock } from "bun:test"
|
||||||
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
|
import * as sharedModule from "../../shared"
|
||||||
|
import {
|
||||||
|
clearAllDelegatedChildSessionBootstrap,
|
||||||
|
getDelegatedChildSessionBootstrap,
|
||||||
|
} from "../../shared/delegated-child-session-bootstrap"
|
||||||
|
import { promptAsyncAfterSessionIdle } from "../../shared/prompt-async-gate"
|
||||||
|
import { clearSessionPromptParams, getSessionPromptParams } from "../../shared/session-prompt-params-state"
|
||||||
|
import {
|
||||||
|
getSessionAgent,
|
||||||
|
registerAgentName,
|
||||||
|
_resetForTesting as resetClaudeCodeSessionState,
|
||||||
|
subagentSessions,
|
||||||
|
} from "../claude-code-session-state"
|
||||||
|
import { _resetTaskToastManagerForTesting, initTaskToastManager } from "../task-toast-manager/manager"
|
||||||
|
import type { ConcurrencyManager } from "./concurrency"
|
||||||
|
import { MIN_IDLE_TIME_MS } from "./constants"
|
||||||
|
import { BackgroundManager } from "./manager"
|
||||||
|
import { _resetForTesting as resetProcessCleanupState } from "./process-cleanup"
|
||||||
|
import { clearBackgroundTaskRegistryForTesting } from "./task-registry"
|
||||||
|
import type { BackgroundTask, ResumeInput } from "./types"
|
||||||
|
|
||||||
afterAll(() => { mock.restore() })
|
afterAll(() => { mock.restore() })
|
||||||
|
|
||||||
import { getSessionPromptParams, clearSessionPromptParams } from "../../shared/session-prompt-params-state"
|
afterEach(() => {
|
||||||
import { tmpdir } from "node:os"
|
clearBackgroundTaskRegistryForTesting()
|
||||||
import type { PluginInput } from "@opencode-ai/plugin"
|
})
|
||||||
import * as sharedModule from "../../shared"
|
|
||||||
import { _resetForTesting as resetClaudeCodeSessionState, registerAgentName, subagentSessions } from "../claude-code-session-state"
|
|
||||||
import type { BackgroundTask, ResumeInput } from "./types"
|
|
||||||
import { MIN_IDLE_TIME_MS } from "./constants"
|
|
||||||
import { BackgroundManager } from "./manager"
|
|
||||||
import { ConcurrencyManager } from "./concurrency"
|
|
||||||
import { promptAsyncAfterSessionIdle } from "../../shared/prompt-async-gate"
|
|
||||||
import { initTaskToastManager, _resetTaskToastManagerForTesting } from "../task-toast-manager/manager"
|
|
||||||
import { _resetForTesting as resetProcessCleanupState } from "./process-cleanup"
|
|
||||||
|
|
||||||
|
|
||||||
const TASK_TTL_MS = 30 * 60 * 1000
|
const TASK_TTL_MS = 30 * 60 * 1000
|
||||||
type PendingParentWakeForTest = {
|
type PendingParentWakeForTest = {
|
||||||
@@ -186,6 +197,30 @@ function cast<T>(value: unknown): T {
|
|||||||
return value as T
|
return value as T
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function expectRejectsWithMessage(promise: Promise<unknown>, expectedMessage: string): Promise<void> {
|
||||||
|
await promise.then(
|
||||||
|
() => {
|
||||||
|
throw new Error(`Expected promise to reject with ${expectedMessage}`)
|
||||||
|
},
|
||||||
|
(error: unknown) => {
|
||||||
|
expect(String(error)).toContain(expectedMessage)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectResolvesDefined(promise: Promise<unknown>): Promise<void> {
|
||||||
|
const result = await promise
|
||||||
|
expect(result).toBeDefined()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectResolvesMatchObject<TActual extends object>(
|
||||||
|
promise: Promise<TActual>,
|
||||||
|
expected: Partial<TActual>,
|
||||||
|
): Promise<void> {
|
||||||
|
const result = await promise
|
||||||
|
expect(result).toMatchObject(expected)
|
||||||
|
}
|
||||||
|
|
||||||
function createPluginInput(client: unknown, directory = tmpdir()): PluginInput {
|
function createPluginInput(client: unknown, directory = tmpdir()): PluginInput {
|
||||||
return cast<PluginInput>({ client, directory })
|
return cast<PluginInput>({ client, directory })
|
||||||
}
|
}
|
||||||
@@ -458,6 +493,77 @@ describe("BackgroundManager session.error fallback hydration", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("BackgroundManager delegated child-session bootstrap", () => {
|
||||||
|
test("registers launch bootstrap before first prompt and clears it after completion", async () => {
|
||||||
|
//#given
|
||||||
|
clearAllDelegatedChildSessionBootstrap()
|
||||||
|
const observedBootstrapPrompts: string[] = []
|
||||||
|
const client = {
|
||||||
|
session: {
|
||||||
|
get: async () => ({ data: { directory: tmpdir() } }),
|
||||||
|
create: async () => ({ data: { id: "ses_background_bootstrap" } }),
|
||||||
|
promptAsync: async () => {
|
||||||
|
const bootstrap = getDelegatedChildSessionBootstrap("ses_background_bootstrap")
|
||||||
|
observedBootstrapPrompts.push(bootstrap?.retryParts[0]?.text ?? "")
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
abort: async () => ({}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
|
||||||
|
stubNotifyParentSession(manager)
|
||||||
|
const task = createMockTask({
|
||||||
|
id: "bg_bootstrap",
|
||||||
|
parentSessionId: "parent-session",
|
||||||
|
status: "pending",
|
||||||
|
queuedAt: new Date(),
|
||||||
|
prompt: "background bootstrap prompt",
|
||||||
|
agent: "sisyphus-junior",
|
||||||
|
skillContent: "background delegated skill system",
|
||||||
|
category: "quick",
|
||||||
|
model: { providerID: "anthropic", modelID: "claude-haiku-4-5" },
|
||||||
|
fallbackChain: [{ model: "gpt-5.4", providers: ["openai"], variant: "high" }],
|
||||||
|
})
|
||||||
|
getTaskMap(manager).set(task.id, task)
|
||||||
|
const input = {
|
||||||
|
description: task.description,
|
||||||
|
prompt: task.prompt,
|
||||||
|
agent: task.agent,
|
||||||
|
parentSessionId: task.parentSessionId,
|
||||||
|
parentMessageId: task.parentMessageId,
|
||||||
|
parentModel: task.parentModel,
|
||||||
|
parentAgent: task.parentAgent,
|
||||||
|
parentTools: task.parentTools,
|
||||||
|
model: task.model,
|
||||||
|
fallbackChain: task.fallbackChain,
|
||||||
|
skillContent: task.skillContent,
|
||||||
|
category: task.category,
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
//#when
|
||||||
|
await (cast<{ startTask: (item: { task: BackgroundTask; input: typeof input }) => Promise<void> }>(manager))
|
||||||
|
.startTask({ task, input })
|
||||||
|
await flushBackgroundNotifications()
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(observedBootstrapPrompts[0]).toContain("background bootstrap prompt")
|
||||||
|
const bootstrap = getDelegatedChildSessionBootstrap("ses_background_bootstrap")
|
||||||
|
expect(bootstrap?.system).toBe("background delegated skill system")
|
||||||
|
expect(bootstrap?.tools?.question).toBe(false)
|
||||||
|
expect(bootstrap?.tools?.task).toBe(false)
|
||||||
|
expect(getDelegatedChildSessionBootstrap("ses_background_bootstrap")).toBeDefined()
|
||||||
|
|
||||||
|
const completed = await tryCompleteTaskForTest(manager, task)
|
||||||
|
expect(completed).toBe(true)
|
||||||
|
expect(getDelegatedChildSessionBootstrap("ses_background_bootstrap")).toBeUndefined()
|
||||||
|
} finally {
|
||||||
|
manager.shutdown()
|
||||||
|
clearAllDelegatedChildSessionBootstrap()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe("BackgroundManager prompt rejection fallback routing", () => {
|
describe("BackgroundManager prompt rejection fallback routing", () => {
|
||||||
test("routes launch-time prompt rejections into tryFallbackRetry before marking interrupt", async () => {
|
test("routes launch-time prompt rejections into tryFallbackRetry before marking interrupt", async () => {
|
||||||
//#given
|
//#given
|
||||||
@@ -635,7 +741,13 @@ describe("BackgroundManager retry observability", () => {
|
|||||||
|
|
||||||
//#then
|
//#then
|
||||||
expect(queuePendingParentWake).toHaveBeenCalledTimes(1)
|
expect(queuePendingParentWake).toHaveBeenCalledTimes(1)
|
||||||
const [sessionID, notification, promptContext, shouldReply] = queuePendingParentWake.mock.calls[0]
|
const retryingCall = cast<Array<[string, string, Record<string, unknown>, boolean]>>(
|
||||||
|
queuePendingParentWake.mock.calls,
|
||||||
|
)[0]
|
||||||
|
if (!retryingCall) {
|
||||||
|
throw new Error("Expected retrying parent wake call")
|
||||||
|
}
|
||||||
|
const [sessionID, notification, promptContext, shouldReply] = retryingCall
|
||||||
expect(sessionID).toBe("parent-session")
|
expect(sessionID).toBe("parent-session")
|
||||||
expect(promptContext).toEqual({})
|
expect(promptContext).toEqual({})
|
||||||
expect(shouldReply).toBe(false)
|
expect(shouldReply).toBe(false)
|
||||||
@@ -2767,7 +2879,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
|||||||
const result = manager.launch(input)
|
const result = manager.launch(input)
|
||||||
|
|
||||||
// then
|
// then
|
||||||
await expect(result).rejects.toThrow("Agent parameter is required after sanitization")
|
await expectRejectsWithMessage(result, "Agent parameter is required after sanitization")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("should initialize attempt state for a newly launched task", async () => {
|
test("should initialize attempt state for a newly launched task", async () => {
|
||||||
@@ -3089,7 +3201,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
|||||||
const result = manager.launch(input)
|
const result = manager.launch(input)
|
||||||
|
|
||||||
// then
|
// then
|
||||||
await expect(result).rejects.toThrow("background_task.maxDepth=3")
|
await expectRejectsWithMessage(result, "background_task.maxDepth=3")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("allows multiple descendants without a root spawn cap", async () => {
|
test("allows multiple descendants without a root spawn cap", async () => {
|
||||||
@@ -3118,7 +3230,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
|||||||
const result = manager.launch(input)
|
const result = manager.launch(input)
|
||||||
|
|
||||||
// then
|
// then
|
||||||
await expect(result).resolves.toBeDefined()
|
await expectResolvesDefined(result)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("allows spawn assertions after reserveSubagentSpawn without a root spawn cap", async () => {
|
test("allows spawn assertions after reserveSubagentSpawn without a root spawn cap", async () => {
|
||||||
@@ -3139,7 +3251,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
|||||||
const result = manager.assertCanSpawn("session-root")
|
const result = manager.assertCanSpawn("session-root")
|
||||||
|
|
||||||
// then
|
// then
|
||||||
await expect(result).resolves.toMatchObject({
|
await expectResolvesMatchObject(result, {
|
||||||
rootSessionID: "session-root",
|
rootSessionID: "session-root",
|
||||||
childDepth: 1,
|
childDepth: 1,
|
||||||
})
|
})
|
||||||
@@ -3172,7 +3284,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
|||||||
const result = manager.launch(input)
|
const result = manager.launch(input)
|
||||||
|
|
||||||
// then
|
// then
|
||||||
await expect(result).rejects.toThrow("background_task.maxDepth cannot be enforced safely")
|
await expectRejectsWithMessage(result, "background_task.maxDepth cannot be enforced safely")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("allows replacement launch when a queued task is cancelled before session starts", async () => {
|
test("allows replacement launch when a queued task is cancelled before session starts", async () => {
|
||||||
@@ -3672,7 +3784,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
|||||||
// Complete via internal method (session.status events go through the poller, not handleEvent)
|
// Complete via internal method (session.status events go through the poller, not handleEvent)
|
||||||
await tryCompleteTaskForTest(manager, internalTask)
|
await tryCompleteTaskForTest(manager, internalTask)
|
||||||
|
|
||||||
await expect(manager.launch(input)).resolves.toBeDefined()
|
await expectResolvesDefined(manager.launch(input))
|
||||||
})
|
})
|
||||||
|
|
||||||
test("allows relaunch after running task is cancelled", async () => {
|
test("allows relaunch after running task is cancelled", async () => {
|
||||||
@@ -3701,7 +3813,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
|||||||
|
|
||||||
await manager.cancelTask(task.id)
|
await manager.cancelTask(task.id)
|
||||||
|
|
||||||
await expect(manager.launch(input)).resolves.toBeDefined()
|
await expectResolvesDefined(manager.launch(input))
|
||||||
})
|
})
|
||||||
|
|
||||||
test("allows relaunch after task errors", async () => {
|
test("allows relaunch after task errors", async () => {
|
||||||
@@ -3734,7 +3846,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
|||||||
})
|
})
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||||
|
|
||||||
await expect(manager.launch(input)).resolves.toBeDefined()
|
await expectResolvesDefined(manager.launch(input))
|
||||||
})
|
})
|
||||||
|
|
||||||
test("allows repeated relaunch after pending tasks are cancelled", async () => {
|
test("allows repeated relaunch after pending tasks are cancelled", async () => {
|
||||||
@@ -3762,8 +3874,8 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
|||||||
await manager.cancelTask(task1.id)
|
await manager.cancelTask(task1.id)
|
||||||
await manager.cancelTask(task2.id)
|
await manager.cancelTask(task2.id)
|
||||||
|
|
||||||
await expect(manager.launch(input)).resolves.toBeDefined()
|
await expectResolvesDefined(manager.launch(input))
|
||||||
await expect(manager.launch(input)).resolves.toBeDefined()
|
await expectResolvesDefined(manager.launch(input))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -4977,10 +5089,12 @@ describe("BackgroundManager.handleEvent - session.error", () => {
|
|||||||
|
|
||||||
const mockVerifySessionExists = (manager: BackgroundManager, sessionExists: boolean): void => {
|
const mockVerifySessionExists = (manager: BackgroundManager, sessionExists: boolean): void => {
|
||||||
verifySessionExistsSpy?.mockRestore()
|
verifySessionExistsSpy?.mockRestore()
|
||||||
verifySessionExistsSpy = spyOn(
|
const spy = spyOn(
|
||||||
cast<{ verifySessionExists: (sessionID: string) => Promise<boolean> }>(manager),
|
cast<{ verifySessionExists: (sessionID: string) => Promise<boolean> }>(manager),
|
||||||
"verifySessionExists",
|
"verifySessionExists",
|
||||||
).mockResolvedValue(sessionExists)
|
)
|
||||||
|
spy.mockImplementation(async () => sessionExists)
|
||||||
|
verifySessionExistsSpy = spy
|
||||||
}
|
}
|
||||||
|
|
||||||
const stubProcessKey = (manager: BackgroundManager) => {
|
const stubProcessKey = (manager: BackgroundManager) => {
|
||||||
@@ -6721,6 +6835,157 @@ describe("BackgroundManager regression fixes - resume and aborted notification",
|
|||||||
manager.shutdown()
|
manager.shutdown()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("should resolve a completed task registered by an earlier plugin manager instance", () => {
|
||||||
|
//#given
|
||||||
|
const firstManager = createBackgroundManager()
|
||||||
|
const secondManager = createBackgroundManager()
|
||||||
|
const task: BackgroundTask = {
|
||||||
|
id: "task-cross-manager-regression",
|
||||||
|
sessionId: "session-cross-manager-regression",
|
||||||
|
parentSessionId: "parent-session",
|
||||||
|
parentMessageId: "msg-1",
|
||||||
|
description: "cross manager regression",
|
||||||
|
prompt: "test",
|
||||||
|
agent: "explore",
|
||||||
|
status: "completed",
|
||||||
|
startedAt: new Date(),
|
||||||
|
completedAt: new Date(),
|
||||||
|
}
|
||||||
|
|
||||||
|
//#when
|
||||||
|
;(cast<{ addTask: (task: BackgroundTask) => void }>(firstManager)).addTask(task)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
const resolvedTask = secondManager.getTask(task.id)
|
||||||
|
expect(resolvedTask?.sessionId).toBe(task.sessionId)
|
||||||
|
|
||||||
|
firstManager.shutdown()
|
||||||
|
secondManager.shutdown()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should redact active task prompts resolved from an earlier plugin manager instance", () => {
|
||||||
|
//#given
|
||||||
|
const firstManager = createBackgroundManager()
|
||||||
|
const secondManager = createBackgroundManager()
|
||||||
|
const task: BackgroundTask = {
|
||||||
|
id: "task-cross-manager-active-redaction",
|
||||||
|
parentSessionId: "parent-session",
|
||||||
|
parentMessageId: "msg-1",
|
||||||
|
description: "cross manager active redaction",
|
||||||
|
prompt: "secret prompt",
|
||||||
|
agent: "explore",
|
||||||
|
status: "pending",
|
||||||
|
queuedAt: new Date(),
|
||||||
|
}
|
||||||
|
|
||||||
|
//#when
|
||||||
|
;(cast<{ addTask: (task: BackgroundTask) => void }>(firstManager)).addTask(task)
|
||||||
|
task.sessionId = "session-cross-manager-active-redaction"
|
||||||
|
task.status = "running"
|
||||||
|
task.startedAt = new Date()
|
||||||
|
task.progress = {
|
||||||
|
lastUpdate: new Date(),
|
||||||
|
toolCalls: 1,
|
||||||
|
countedToolPartIDs: new Set(["part-1"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
//#then
|
||||||
|
const localTask = firstManager.getTask(task.id)
|
||||||
|
const registeredTask = secondManager.getTask(task.id)
|
||||||
|
expect(localTask?.prompt).toBe("secret prompt")
|
||||||
|
expect(registeredTask?.sessionId).toBe(task.sessionId)
|
||||||
|
expect(registeredTask?.prompt).toBe("[redacted]")
|
||||||
|
expect(registeredTask?.progress?.countedToolPartIDs).toEqual(new Set(["part-1"]))
|
||||||
|
|
||||||
|
firstManager.shutdown()
|
||||||
|
secondManager.shutdown()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should resolve archived completed task from an earlier plugin manager instance", () => {
|
||||||
|
//#given
|
||||||
|
const firstManager = createBackgroundManager()
|
||||||
|
const secondManager = createBackgroundManager()
|
||||||
|
const task: BackgroundTask = {
|
||||||
|
id: "task-cross-manager-archive-regression",
|
||||||
|
sessionId: "session-cross-manager-archive-regression",
|
||||||
|
parentSessionId: "parent-session",
|
||||||
|
parentMessageId: "msg-1",
|
||||||
|
description: "cross manager archive regression",
|
||||||
|
prompt: "sensitive prompt",
|
||||||
|
agent: "explore",
|
||||||
|
status: "completed",
|
||||||
|
startedAt: new Date(),
|
||||||
|
completedAt: new Date(),
|
||||||
|
}
|
||||||
|
getTaskMap(firstManager).set(task.id, task)
|
||||||
|
|
||||||
|
//#when
|
||||||
|
;(cast<{ removeTask: (task: BackgroundTask) => void }>(firstManager)).removeTask(task)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
const resolvedTask = secondManager.getTask(task.id)
|
||||||
|
expect(resolvedTask?.sessionId).toBe(task.sessionId)
|
||||||
|
expect(resolvedTask?.prompt).toBe("[redacted]")
|
||||||
|
|
||||||
|
firstManager.shutdown()
|
||||||
|
secondManager.shutdown()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should archive terminal registry tasks during earlier manager shutdown", async () => {
|
||||||
|
//#given
|
||||||
|
const firstManager = createBackgroundManager()
|
||||||
|
const secondManager = createBackgroundManager()
|
||||||
|
const task: BackgroundTask = {
|
||||||
|
id: "task-shutdown-archive-regression",
|
||||||
|
sessionId: "session-shutdown-archive-regression",
|
||||||
|
parentSessionId: "parent-session",
|
||||||
|
parentMessageId: "msg-1",
|
||||||
|
description: "shutdown archive regression",
|
||||||
|
prompt: "sensitive shutdown prompt",
|
||||||
|
agent: "explore",
|
||||||
|
status: "completed",
|
||||||
|
startedAt: new Date(),
|
||||||
|
completedAt: new Date(),
|
||||||
|
}
|
||||||
|
;(cast<{ addTask: (task: BackgroundTask) => void }>(firstManager)).addTask(task)
|
||||||
|
|
||||||
|
//#when
|
||||||
|
await firstManager.shutdown()
|
||||||
|
|
||||||
|
//#then
|
||||||
|
const resolvedTask = secondManager.getTask(task.id)
|
||||||
|
expect(resolvedTask?.sessionId).toBe(task.sessionId)
|
||||||
|
expect(resolvedTask?.prompt).toBe("[redacted]")
|
||||||
|
|
||||||
|
await secondManager.shutdown()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should forget active registry tasks during earlier manager shutdown", async () => {
|
||||||
|
//#given
|
||||||
|
const firstManager = createBackgroundManager()
|
||||||
|
const secondManager = createBackgroundManager()
|
||||||
|
const task: BackgroundTask = {
|
||||||
|
id: "task-shutdown-active-regression",
|
||||||
|
sessionId: "session-shutdown-active-regression",
|
||||||
|
parentSessionId: "parent-session",
|
||||||
|
parentMessageId: "msg-1",
|
||||||
|
description: "shutdown active regression",
|
||||||
|
prompt: "test",
|
||||||
|
agent: "explore",
|
||||||
|
status: "running",
|
||||||
|
startedAt: new Date(),
|
||||||
|
}
|
||||||
|
;(cast<{ addTask: (task: BackgroundTask) => void }>(firstManager)).addTask(task)
|
||||||
|
|
||||||
|
//#when
|
||||||
|
await firstManager.shutdown()
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(secondManager.getTask(task.id)).toBeUndefined()
|
||||||
|
|
||||||
|
await secondManager.shutdown()
|
||||||
|
})
|
||||||
|
|
||||||
test("should cap completed task archive size at 100 entries", () => {
|
test("should cap completed task archive size at 100 entries", () => {
|
||||||
//#given
|
//#given
|
||||||
const manager = createBackgroundManager()
|
const manager = createBackgroundManager()
|
||||||
@@ -6848,6 +7113,62 @@ describe("BackgroundManager - tool permission spread order", () => {
|
|||||||
manager.shutdown()
|
manager.shutdown()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("startTask updates tracked session agent when launch falls back to general", async () => {
|
||||||
|
//#given
|
||||||
|
const promptCalls: Array<{ path: { id: string }; body: Record<string, unknown> }> = []
|
||||||
|
let promptCallCount = 0
|
||||||
|
const client = {
|
||||||
|
session: {
|
||||||
|
get: async () => ({ data: { directory: "/test/dir" } }),
|
||||||
|
create: async () => ({ data: { id: "session-manager-fallback" } }),
|
||||||
|
promptAsync: async (args: { path: { id: string }; body: Record<string, unknown> }) => {
|
||||||
|
promptCallCount++
|
||||||
|
promptCalls.push(args)
|
||||||
|
if (promptCallCount === 1) {
|
||||||
|
throw new Error("Agent not found: missing-agent")
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
|
||||||
|
const task: BackgroundTask = {
|
||||||
|
id: "task-manager-fallback",
|
||||||
|
status: "pending",
|
||||||
|
queuedAt: new Date(),
|
||||||
|
description: "test task",
|
||||||
|
prompt: "test prompt",
|
||||||
|
agent: "missing-agent",
|
||||||
|
parentSessionId: "parent-session",
|
||||||
|
parentMessageId: "parent-message",
|
||||||
|
}
|
||||||
|
const input: import("./types").LaunchInput = {
|
||||||
|
description: task.description,
|
||||||
|
prompt: task.prompt,
|
||||||
|
agent: task.agent,
|
||||||
|
parentSessionId: task.parentSessionId,
|
||||||
|
parentMessageId: task.parentMessageId,
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
//#when
|
||||||
|
await (cast<{ startTask: (item: { task: BackgroundTask; input: import("./types").LaunchInput }) => Promise<void> }>(manager))
|
||||||
|
.startTask({ task, input })
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(promptCalls).toHaveLength(2)
|
||||||
|
expect(promptCalls[0].body.agent).toBe("missing-agent")
|
||||||
|
expect(promptCalls[1].body.agent).toBe("general")
|
||||||
|
expect(task.agent).toBe("general")
|
||||||
|
expect(getSessionAgent("session-manager-fallback")).toBe("general")
|
||||||
|
expect(getDelegatedChildSessionBootstrap("session-manager-fallback")?.tools?.call_omo_agent).toBe(true)
|
||||||
|
} finally {
|
||||||
|
manager.shutdown()
|
||||||
|
clearAllDelegatedChildSessionBootstrap()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
test("resume respects explore agent restrictions", async () => {
|
test("resume respects explore agent restrictions", async () => {
|
||||||
//#given
|
//#given
|
||||||
let capturedTools: Record<string, unknown> | undefined
|
let capturedTools: Record<string, unknown> | undefined
|
||||||
@@ -6992,6 +7313,7 @@ describe("BackgroundManager.launch - attempt state initialization", () => {
|
|||||||
describe("BackgroundManager attempt lifecycle bindings", () => {
|
describe("BackgroundManager attempt lifecycle bindings", () => {
|
||||||
test("startTask binds the created session to the queued attempt ID and mirrors task projection", async () => {
|
test("startTask binds the created session to the queued attempt ID and mirrors task projection", async () => {
|
||||||
//#given
|
//#given
|
||||||
|
resetClaudeCodeSessionState()
|
||||||
const client = {
|
const client = {
|
||||||
session: {
|
session: {
|
||||||
get: async () => ({ data: { directory: "/test/dir" } }),
|
get: async () => ({ data: { directory: "/test/dir" } }),
|
||||||
@@ -7064,6 +7386,68 @@ describe("BackgroundManager attempt lifecycle bindings", () => {
|
|||||||
status: "error",
|
status: "error",
|
||||||
error: "first attempt failed",
|
error: "first attempt failed",
|
||||||
})
|
})
|
||||||
|
expect(getSessionAgent("session-attempt-2")).toBe("sisyphus-junior")
|
||||||
|
|
||||||
|
manager.shutdown()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("startTask clears child session agent state when task is cancelled before launch binding", async () => {
|
||||||
|
//#given
|
||||||
|
resetClaudeCodeSessionState()
|
||||||
|
const sessionID = "session-cancelled-prelaunch"
|
||||||
|
const client = {
|
||||||
|
session: {
|
||||||
|
get: async () => ({ data: { directory: "/test/dir" } }),
|
||||||
|
create: async () => ({ data: { id: sessionID } }),
|
||||||
|
promptAsync: async () => ({}),
|
||||||
|
abort: async () => ({}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
|
||||||
|
const task: BackgroundTask = {
|
||||||
|
id: "task-cancel-prelaunch",
|
||||||
|
status: "pending",
|
||||||
|
queuedAt: new Date(),
|
||||||
|
description: "cancel before bind",
|
||||||
|
prompt: "continue",
|
||||||
|
agent: "sisyphus-junior",
|
||||||
|
parentSessionId: "parent-session",
|
||||||
|
parentMessageId: "parent-message",
|
||||||
|
model: { providerID: "anthropic", modelID: "claude-haiku-4.5" },
|
||||||
|
attempts: [
|
||||||
|
{
|
||||||
|
attemptId: "attempt-1",
|
||||||
|
attemptNumber: 1,
|
||||||
|
providerId: "anthropic",
|
||||||
|
modelId: "claude-haiku-4.5",
|
||||||
|
status: "pending",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
currentAttemptID: "attempt-1",
|
||||||
|
attemptCount: 1,
|
||||||
|
}
|
||||||
|
const input: import("./types").LaunchInput = {
|
||||||
|
description: task.description,
|
||||||
|
prompt: task.prompt,
|
||||||
|
agent: task.agent,
|
||||||
|
parentSessionId: task.parentSessionId,
|
||||||
|
parentMessageId: task.parentMessageId,
|
||||||
|
model: task.model,
|
||||||
|
onSessionCreated: async () => {
|
||||||
|
// simulate parent flipping task to cancelled between create and bind
|
||||||
|
task.status = "cancelled"
|
||||||
|
const internal = cast<{ tasks: Map<string, BackgroundTask> }>(manager)
|
||||||
|
internal.tasks.set(task.id, task)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
//#when
|
||||||
|
await (cast<{
|
||||||
|
startTask: (item: { task: BackgroundTask; input: import("./types").LaunchInput; attemptID: string }) => Promise<void>
|
||||||
|
}>(manager)).startTask({ task, input, attemptID: "attempt-1" })
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(getSessionAgent(sessionID)).toBeUndefined()
|
||||||
|
|
||||||
manager.shutdown()
|
manager.shutdown()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,99 +1,107 @@
|
|||||||
|
import { join } from "node:path"
|
||||||
import type { PluginInput } from "@opencode-ai/plugin"
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
|
import type { BackgroundTaskConfig, TmuxConfig } from "../../config/schema"
|
||||||
|
import { setContinuationMarkerSource } from "../../features/run-continuation-state"
|
||||||
import type { ModelFallbackControllerAccessor } from "../../hooks/model-fallback"
|
import type { ModelFallbackControllerAccessor } from "../../hooks/model-fallback"
|
||||||
import { isAgentNotFoundError, FALLBACK_AGENT, buildFallbackBody } from "./spawner"
|
import { type PromptAsyncGateResult, promptAsyncAfterSessionIdle } from "../../hooks/shared/prompt-async-gate"
|
||||||
import type {
|
import { isSessionActive as isOpenCodeSessionActive } from "../../hooks/shared/session-idle-settle"
|
||||||
BackgroundTask,
|
|
||||||
BackgroundTaskAttempt,
|
|
||||||
LaunchInput,
|
|
||||||
ResumeInput,
|
|
||||||
} from "./types"
|
|
||||||
import { TaskHistory } from "./task-history"
|
|
||||||
import {
|
import {
|
||||||
log,
|
createInternalAgentTextPart,
|
||||||
getAgentToolRestrictions,
|
getAgentToolRestrictions,
|
||||||
|
log,
|
||||||
|
messagesInDirectory,
|
||||||
normalizePromptTools,
|
normalizePromptTools,
|
||||||
normalizeSDKResponse,
|
normalizeSDKResponse,
|
||||||
resolveInheritedPromptTools,
|
|
||||||
createInternalAgentTextPart,
|
|
||||||
messagesInDirectory,
|
|
||||||
promptWithRetryInDirectory,
|
promptWithRetryInDirectory,
|
||||||
|
resolveInheritedPromptTools,
|
||||||
} from "../../shared"
|
} from "../../shared"
|
||||||
|
import {
|
||||||
|
clearDelegatedChildSessionBootstrap,
|
||||||
|
registerDelegatedChildSessionBootstrap,
|
||||||
|
} from "../../shared/delegated-child-session-bootstrap"
|
||||||
import { resolveMessageEventSessionID, resolveSessionEventID } from "../../shared/event-session-id"
|
import { resolveMessageEventSessionID, resolveSessionEventID } from "../../shared/event-session-id"
|
||||||
|
import {
|
||||||
|
hasMoreFallbacks,
|
||||||
|
shouldRetryError,
|
||||||
|
} from "../../shared/model-error-classifier"
|
||||||
|
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
|
||||||
import { applySessionPromptParams } from "../../shared/session-prompt-params-helpers"
|
import { applySessionPromptParams } from "../../shared/session-prompt-params-helpers"
|
||||||
import { setSessionTools } from "../../shared/session-tools-store"
|
import { setSessionTools } from "../../shared/session-tools-store"
|
||||||
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
|
|
||||||
import { ConcurrencyManager } from "./concurrency"
|
|
||||||
import type { BackgroundTaskConfig, TmuxConfig } from "../../config/schema"
|
|
||||||
import { isInsideTmux } from "../../shared/tmux"
|
import { isInsideTmux } from "../../shared/tmux"
|
||||||
import {
|
import { clearSessionAgent, setSessionAgent, subagentSessions, updateSessionAgent } from "../claude-code-session-state"
|
||||||
shouldRetryError,
|
import { MESSAGE_STORAGE } from "../hook-message-injector"
|
||||||
hasMoreFallbacks,
|
|
||||||
} from "../../shared/model-error-classifier"
|
|
||||||
import {
|
|
||||||
POLLING_INTERVAL_MS,
|
|
||||||
TASK_CLEANUP_DELAY_MS,
|
|
||||||
TASK_TTL_MS,
|
|
||||||
type QueueItem,
|
|
||||||
} from "./constants"
|
|
||||||
|
|
||||||
import { subagentSessions } from "../claude-code-session-state"
|
|
||||||
import { getTaskToastManager } from "../task-toast-manager"
|
import { getTaskToastManager } from "../task-toast-manager"
|
||||||
import { formatDuration } from "./duration-formatter"
|
import { abortWithTimeout } from "./abort-with-timeout"
|
||||||
import {
|
|
||||||
buildBackgroundTaskNotificationText,
|
|
||||||
type BackgroundTaskNotificationTask,
|
|
||||||
} from "./background-task-notification-template"
|
|
||||||
import {
|
|
||||||
isAbortedSessionError,
|
|
||||||
extractErrorName,
|
|
||||||
extractErrorMessage,
|
|
||||||
extractErrorStatusCode,
|
|
||||||
getSessionErrorMessage,
|
|
||||||
isRecord,
|
|
||||||
} from "./error-classifier"
|
|
||||||
import { tryFallbackRetry } from "./fallback-retry-handler"
|
|
||||||
import {
|
import {
|
||||||
bindAttemptSession,
|
bindAttemptSession,
|
||||||
ensureCurrentAttempt,
|
ensureCurrentAttempt,
|
||||||
findAttemptBySession,
|
|
||||||
finalizeAttempt,
|
finalizeAttempt,
|
||||||
|
findAttemptBySession,
|
||||||
getCurrentAttempt,
|
getCurrentAttempt,
|
||||||
startAttempt,
|
startAttempt,
|
||||||
} from "./attempt-lifecycle"
|
} from "./attempt-lifecycle"
|
||||||
import { registerManagerForCleanup, unregisterManagerForCleanup } from "./process-cleanup"
|
import {
|
||||||
import { setContinuationMarkerSource } from "../../features/run-continuation-state"
|
type BackgroundTaskNotificationTask,
|
||||||
import { isSessionActive as isOpenCodeSessionActive } from "../../hooks/shared/session-idle-settle"
|
buildBackgroundTaskNotificationText,
|
||||||
import { promptAsyncAfterSessionIdle, type PromptAsyncGateResult } from "../../hooks/shared/prompt-async-gate"
|
} from "./background-task-notification-template"
|
||||||
import {
|
import {
|
||||||
findNearestMessageExcludingCompaction,
|
findNearestMessageExcludingCompaction,
|
||||||
resolvePromptContextFromSessionMessages,
|
resolvePromptContextFromSessionMessages,
|
||||||
} from "./compaction-aware-message-resolver"
|
} from "./compaction-aware-message-resolver"
|
||||||
import { handleSessionIdleBackgroundEvent } from "./session-idle-event-handler"
|
import { ConcurrencyManager } from "./concurrency"
|
||||||
import { MESSAGE_STORAGE } from "../hook-message-injector"
|
import {
|
||||||
import { join } from "node:path"
|
POLLING_INTERVAL_MS,
|
||||||
import { pruneStaleTasksAndNotifications, type SessionStatusMap } from "./task-poller"
|
type QueueItem,
|
||||||
import { checkAndInterruptStaleTasks } from "./task-poller"
|
TASK_CLEANUP_DELAY_MS,
|
||||||
|
TASK_TTL_MS,
|
||||||
|
} from "./constants"
|
||||||
|
import { formatDuration } from "./duration-formatter"
|
||||||
|
import {
|
||||||
|
extractErrorMessage,
|
||||||
|
extractErrorName,
|
||||||
|
extractErrorStatusCode,
|
||||||
|
getSessionErrorMessage,
|
||||||
|
isAbortedSessionError,
|
||||||
|
isRecord,
|
||||||
|
} from "./error-classifier"
|
||||||
|
import { tryFallbackRetry } from "./fallback-retry-handler"
|
||||||
|
import {
|
||||||
|
type CircuitBreakerSettings,
|
||||||
|
detectRepetitiveToolUse,
|
||||||
|
recordToolCall,
|
||||||
|
resolveCircuitBreakerSettings,
|
||||||
|
} from "./loop-detector"
|
||||||
|
import { ParentWakeNotifier, type ParentWakePromptContext } from "./parent-wake-notifier"
|
||||||
|
import { registerManagerForCleanup, unregisterManagerForCleanup } from "./process-cleanup"
|
||||||
import { removeTaskToastTracking } from "./remove-task-toast-tracking"
|
import { removeTaskToastTracking } from "./remove-task-toast-tracking"
|
||||||
import { abortWithTimeout } from "./abort-with-timeout"
|
|
||||||
import {
|
import {
|
||||||
MIN_SESSION_GONE_POLLS,
|
MIN_SESSION_GONE_POLLS,
|
||||||
verifySessionExists as verifySessionStillExists,
|
verifySessionExists as verifySessionStillExists,
|
||||||
} from "./session-existence"
|
} from "./session-existence"
|
||||||
|
import { handleSessionIdleBackgroundEvent } from "./session-idle-event-handler"
|
||||||
import { isActiveSessionStatus, isTerminalSessionStatus } from "./session-status-classifier"
|
import { isActiveSessionStatus, isTerminalSessionStatus } from "./session-status-classifier"
|
||||||
import {
|
import { buildFallbackBody, FALLBACK_AGENT, isAgentNotFoundError } from "./spawner"
|
||||||
detectRepetitiveToolUse,
|
|
||||||
recordToolCall,
|
|
||||||
resolveCircuitBreakerSettings,
|
|
||||||
type CircuitBreakerSettings,
|
|
||||||
} from "./loop-detector"
|
|
||||||
import {
|
import {
|
||||||
createSubagentDepthLimitError,
|
createSubagentDepthLimitError,
|
||||||
getMaxSubagentDepth,
|
getMaxSubagentDepth,
|
||||||
resolveSubagentSpawnContext,
|
resolveSubagentSpawnContext,
|
||||||
type SubagentSpawnContext,
|
type SubagentSpawnContext,
|
||||||
} from "./subagent-spawn-limits"
|
} from "./subagent-spawn-limits"
|
||||||
import { ParentWakeNotifier, type ParentWakePromptContext } from "./parent-wake-notifier"
|
import { TaskHistory } from "./task-history"
|
||||||
|
import { checkAndInterruptStaleTasks, pruneStaleTasksAndNotifications, type SessionStatusMap } from "./task-poller"
|
||||||
|
import {
|
||||||
|
archiveBackgroundTask,
|
||||||
|
forgetBackgroundTask,
|
||||||
|
getRegisteredBackgroundTask,
|
||||||
|
rememberBackgroundTask,
|
||||||
|
} from "./task-registry"
|
||||||
|
import type {
|
||||||
|
BackgroundTask,
|
||||||
|
BackgroundTaskAttempt,
|
||||||
|
LaunchInput,
|
||||||
|
ResumeInput,
|
||||||
|
} from "./types"
|
||||||
|
|
||||||
type OpencodeClient = PluginInput["client"]
|
type OpencodeClient = PluginInput["client"]
|
||||||
|
|
||||||
type ResumeTaskSnapshot = {
|
type ResumeTaskSnapshot = {
|
||||||
@@ -111,6 +119,13 @@ type ResumeTaskSnapshot = {
|
|||||||
concurrencyGroup?: string
|
concurrencyGroup?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TERMINAL_BACKGROUND_TASK_STATUSES = new Set<BackgroundTask["status"]>([
|
||||||
|
"completed",
|
||||||
|
"error",
|
||||||
|
"cancelled",
|
||||||
|
"interrupt",
|
||||||
|
])
|
||||||
|
|
||||||
const PENDING_PARENT_WAKE_RETRY_MS = 1_000
|
const PENDING_PARENT_WAKE_RETRY_MS = 1_000
|
||||||
const PENDING_PARENT_WAKE_DEBOUNCE_MS = 100
|
const PENDING_PARENT_WAKE_DEBOUNCE_MS = 100
|
||||||
const PARENT_WAKE_ACCEPTED_MESSAGE_SKEW_MS = 5_000
|
const PARENT_WAKE_ACCEPTED_MESSAGE_SKEW_MS = 5_000
|
||||||
@@ -376,6 +391,7 @@ export class BackgroundManager {
|
|||||||
private addTask(task: BackgroundTask): void {
|
private addTask(task: BackgroundTask): void {
|
||||||
this.completedTaskArchive.delete(task.id)
|
this.completedTaskArchive.delete(task.id)
|
||||||
this.tasks.set(task.id, task)
|
this.tasks.set(task.id, task)
|
||||||
|
rememberBackgroundTask(task)
|
||||||
if (!task.parentSessionId) {
|
if (!task.parentSessionId) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -387,6 +403,7 @@ export class BackgroundManager {
|
|||||||
|
|
||||||
private removeTask(task: BackgroundTask): void {
|
private removeTask(task: BackgroundTask): void {
|
||||||
this.archiveCompletedTask(task)
|
this.archiveCompletedTask(task)
|
||||||
|
archiveBackgroundTask(task)
|
||||||
this.tasks.delete(task.id)
|
this.tasks.delete(task.id)
|
||||||
this.removeTaskFromParentIndex(task.id, task.parentSessionId)
|
this.removeTaskFromParentIndex(task.id, task.parentSessionId)
|
||||||
}
|
}
|
||||||
@@ -557,6 +574,8 @@ export class BackgroundManager {
|
|||||||
parentTools: input.parentTools,
|
parentTools: input.parentTools,
|
||||||
model: input.model,
|
model: input.model,
|
||||||
fallbackChain: input.fallbackChain,
|
fallbackChain: input.fallbackChain,
|
||||||
|
skillContent: input.skillContent,
|
||||||
|
sessionPermission: input.sessionPermission,
|
||||||
attemptCount: 0,
|
attemptCount: 0,
|
||||||
category: input.category,
|
category: input.category,
|
||||||
onSessionCreated: input.onSessionCreated,
|
onSessionCreated: input.onSessionCreated,
|
||||||
@@ -659,6 +678,7 @@ export class BackgroundManager {
|
|||||||
|
|
||||||
// Abort the orphaned session if one was created before the error
|
// Abort the orphaned session if one was created before the error
|
||||||
if (item.task.sessionId) {
|
if (item.task.sessionId) {
|
||||||
|
clearDelegatedChildSessionBootstrap(item.task.sessionId)
|
||||||
await this.abortSessionWithLogging(item.task.sessionId, "startTask error cleanup")
|
await this.abortSessionWithLogging(item.task.sessionId, "startTask error cleanup")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -729,6 +749,7 @@ export class BackgroundManager {
|
|||||||
const sessionID = createResult.data.id
|
const sessionID = createResult.data.id
|
||||||
|
|
||||||
if (task.status === "cancelled") {
|
if (task.status === "cancelled") {
|
||||||
|
clearDelegatedChildSessionBootstrap(sessionID)
|
||||||
await this.abortSessionWithLogging(sessionID, "cancelled pre-start cleanup")
|
await this.abortSessionWithLogging(sessionID, "cancelled pre-start cleanup")
|
||||||
this.concurrencyManager.release(concurrencyKey)
|
this.concurrencyManager.release(concurrencyKey)
|
||||||
return
|
return
|
||||||
@@ -737,8 +758,11 @@ export class BackgroundManager {
|
|||||||
await input.onSessionCreated?.(sessionID)
|
await input.onSessionCreated?.(sessionID)
|
||||||
this.settlePreStartDescendantReservation(task)
|
this.settlePreStartDescendantReservation(task)
|
||||||
subagentSessions.add(sessionID)
|
subagentSessions.add(sessionID)
|
||||||
|
setSessionAgent(sessionID, input.agent)
|
||||||
|
|
||||||
if (this.tasks.get(task.id)?.status === "cancelled") {
|
if (this.tasks.get(task.id)?.status === "cancelled") {
|
||||||
|
clearDelegatedChildSessionBootstrap(sessionID)
|
||||||
|
clearSessionAgent(sessionID)
|
||||||
await this.abortSessionWithLogging(sessionID, "cancelled during launch setup")
|
await this.abortSessionWithLogging(sessionID, "cancelled during launch setup")
|
||||||
subagentSessions.delete(sessionID)
|
subagentSessions.delete(sessionID)
|
||||||
if (task.rootSessionId) {
|
if (task.rootSessionId) {
|
||||||
@@ -750,6 +774,8 @@ export class BackgroundManager {
|
|||||||
|
|
||||||
const boundAttempt = bindAttemptSession(task, attemptID, sessionID, input.model)
|
const boundAttempt = bindAttemptSession(task, attemptID, sessionID, input.model)
|
||||||
if (!boundAttempt) {
|
if (!boundAttempt) {
|
||||||
|
clearDelegatedChildSessionBootstrap(sessionID)
|
||||||
|
clearSessionAgent(sessionID)
|
||||||
await this.abortSessionWithLogging(sessionID, "stale attempt binding cleanup")
|
await this.abortSessionWithLogging(sessionID, "stale attempt binding cleanup")
|
||||||
subagentSessions.delete(sessionID)
|
subagentSessions.delete(sessionID)
|
||||||
if (task.rootSessionId) {
|
if (task.rootSessionId) {
|
||||||
@@ -805,21 +831,6 @@ The fallback retry session is now created and can be inspected directly.
|
|||||||
this.taskHistory.record(input.parentSessionId, { id: task.id, sessionID, agent: input.agent, description: input.description, status: "running", category: input.category, startedAt: task.startedAt })
|
this.taskHistory.record(input.parentSessionId, { id: task.id, sessionID, agent: input.agent, description: input.description, status: "running", category: input.category, startedAt: task.startedAt })
|
||||||
this.startPolling()
|
this.startPolling()
|
||||||
|
|
||||||
log("[background-agent] Launching task:", { taskId: task.id, sessionID, agent: input.agent })
|
|
||||||
|
|
||||||
const toastManager = getTaskToastManager()
|
|
||||||
if (toastManager) {
|
|
||||||
toastManager.updateTask(task.id, "running")
|
|
||||||
}
|
|
||||||
|
|
||||||
log("[background-agent] Calling prompt (fire-and-forget) for launch with:", {
|
|
||||||
sessionID,
|
|
||||||
agent: input.agent,
|
|
||||||
model: input.model,
|
|
||||||
hasSkillContent: !!input.skillContent,
|
|
||||||
promptLength: input.prompt.length,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Fire-and-forget prompt via promptAsync (no response body needed)
|
// Fire-and-forget prompt via promptAsync (no response body needed)
|
||||||
// OpenCode prompt payload accepts model provider/model IDs and top-level variant only.
|
// OpenCode prompt payload accepts model provider/model IDs and top-level variant only.
|
||||||
// Temperature/topP and provider-specific options are applied through chat.params.
|
// Temperature/topP and provider-specific options are applied through chat.params.
|
||||||
@@ -835,23 +846,46 @@ The fallback retry session is now created and can be inspected directly.
|
|||||||
applySessionPromptParams(sessionID, input.model)
|
applySessionPromptParams(sessionID, input.model)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const launchTools = {
|
||||||
|
task: false,
|
||||||
|
call_omo_agent: true,
|
||||||
|
question: false,
|
||||||
|
...getAgentToolRestrictions(input.agent, {
|
||||||
|
includeTeamToolDenylist: input.teamRunId === undefined,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
setSessionTools(sessionID, launchTools)
|
||||||
|
|
||||||
|
log("[background-agent] Launching task:", { taskId: task.id, sessionID, agent: input.agent })
|
||||||
|
registerDelegatedChildSessionBootstrap({
|
||||||
|
sessionID,
|
||||||
|
promptText: input.prompt,
|
||||||
|
fallbackChain: input.fallbackChain,
|
||||||
|
category: input.category,
|
||||||
|
system: input.skillContent,
|
||||||
|
tools: launchTools,
|
||||||
|
modelFallbackControllerAccessor: this.modelFallbackControllerAccessor,
|
||||||
|
})
|
||||||
|
|
||||||
|
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 promptBody = {
|
const promptBody = {
|
||||||
agent: input.agent,
|
agent: input.agent,
|
||||||
...(launchModel ? { model: launchModel } : {}),
|
...(launchModel ? { model: launchModel } : {}),
|
||||||
...(launchVariant ? { variant: launchVariant } : {}),
|
...(launchVariant ? { variant: launchVariant } : {}),
|
||||||
system: input.skillContent,
|
system: input.skillContent,
|
||||||
tools: (() => {
|
tools: launchTools,
|
||||||
const tools = {
|
|
||||||
task: false,
|
|
||||||
call_omo_agent: true,
|
|
||||||
question: false,
|
|
||||||
...getAgentToolRestrictions(input.agent, {
|
|
||||||
includeTeamToolDenylist: input.teamRunId === undefined,
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
setSessionTools(sessionID, tools)
|
|
||||||
return tools
|
|
||||||
})(),
|
|
||||||
parts: [createInternalAgentTextPart(input.prompt)],
|
parts: [createInternalAgentTextPart(input.prompt)],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -870,7 +904,18 @@ The fallback retry session is now created and can be inspected directly.
|
|||||||
const fallbackBody = buildFallbackBody(promptBody, FALLBACK_AGENT, {
|
const fallbackBody = buildFallbackBody(promptBody, FALLBACK_AGENT, {
|
||||||
includeTeamToolDenylist: input.teamRunId === undefined,
|
includeTeamToolDenylist: input.teamRunId === undefined,
|
||||||
})
|
})
|
||||||
setSessionTools(sessionID, fallbackBody.tools as Record<string, boolean>)
|
const fallbackTools = fallbackBody.tools as Record<string, boolean>
|
||||||
|
setSessionTools(sessionID, fallbackTools)
|
||||||
|
updateSessionAgent(sessionID, FALLBACK_AGENT)
|
||||||
|
registerDelegatedChildSessionBootstrap({
|
||||||
|
sessionID,
|
||||||
|
promptText: input.prompt,
|
||||||
|
fallbackChain: input.fallbackChain,
|
||||||
|
category: input.category,
|
||||||
|
system: input.skillContent,
|
||||||
|
tools: fallbackTools,
|
||||||
|
modelFallbackControllerAccessor: this.modelFallbackControllerAccessor,
|
||||||
|
})
|
||||||
await promptWithRetryInDirectory(this.client, {
|
await promptWithRetryInDirectory(this.client, {
|
||||||
path: { id: sessionID },
|
path: { id: sessionID },
|
||||||
body: fallbackBody,
|
body: fallbackBody,
|
||||||
@@ -926,6 +971,7 @@ The fallback retry session is now created and can be inspected directly.
|
|||||||
|
|
||||||
// Abort the session to prevent infinite polling hang
|
// Abort the session to prevent infinite polling hang
|
||||||
// Awaited to prevent dangling promise during subagent teardown (Bun/WebKit SIGABRT)
|
// Awaited to prevent dangling promise during subagent teardown (Bun/WebKit SIGABRT)
|
||||||
|
clearDelegatedChildSessionBootstrap(sessionID)
|
||||||
await this.abortSessionWithLogging(sessionID, "launch error cleanup")
|
await this.abortSessionWithLogging(sessionID, "launch error cleanup")
|
||||||
|
|
||||||
this.markForNotification(existingTask)
|
this.markForNotification(existingTask)
|
||||||
@@ -960,7 +1006,7 @@ The fallback retry session is now created and can be inspected directly.
|
|||||||
}
|
}
|
||||||
|
|
||||||
getTask(id: string): BackgroundTask | undefined {
|
getTask(id: string): BackgroundTask | undefined {
|
||||||
return this.tasks.get(id) ?? this.completedTaskArchive.get(id)
|
return this.tasks.get(id) ?? this.completedTaskArchive.get(id) ?? getRegisteredBackgroundTask(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
getTasksByParentSession(sessionID: string): BackgroundTask[] {
|
getTasksByParentSession(sessionID: string): BackgroundTask[] {
|
||||||
@@ -1313,6 +1359,7 @@ The fallback retry session is now created and can be inspected directly.
|
|||||||
// Abort the session to prevent infinite polling hang
|
// Abort the session to prevent infinite polling hang
|
||||||
// Awaited to prevent dangling promise during subagent teardown (Bun/WebKit SIGABRT)
|
// Awaited to prevent dangling promise during subagent teardown (Bun/WebKit SIGABRT)
|
||||||
if (existingTask.sessionId) {
|
if (existingTask.sessionId) {
|
||||||
|
clearDelegatedChildSessionBootstrap(existingTask.sessionId)
|
||||||
await this.abortSessionWithLogging(existingTask.sessionId, "resume error cleanup")
|
await this.abortSessionWithLogging(existingTask.sessionId, "resume error cleanup")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1650,6 +1697,7 @@ The fallback retry session is now created and can be inspected directly.
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.rootDescendantCounts.delete(sessionID)
|
this.rootDescendantCounts.delete(sessionID)
|
||||||
|
clearDelegatedChildSessionBootstrap(sessionID)
|
||||||
SessionCategoryRegistry.remove(sessionID)
|
SessionCategoryRegistry.remove(sessionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1732,6 +1780,7 @@ The fallback retry session is now created and can be inspected directly.
|
|||||||
this.scheduleTaskRemoval(task.id)
|
this.scheduleTaskRemoval(task.id)
|
||||||
|
|
||||||
if (task.sessionId) {
|
if (task.sessionId) {
|
||||||
|
clearDelegatedChildSessionBootstrap(task.sessionId)
|
||||||
SessionCategoryRegistry.remove(task.sessionId)
|
SessionCategoryRegistry.remove(task.sessionId)
|
||||||
await this.abortSessionWithLogging(task.sessionId, `${reason} cleanup`)
|
await this.abortSessionWithLogging(task.sessionId, `${reason} cleanup`)
|
||||||
}
|
}
|
||||||
@@ -1838,6 +1887,7 @@ The fallback retry session is now created and can be inspected directly.
|
|||||||
}
|
}
|
||||||
this.scheduleTaskRemoval(task.id)
|
this.scheduleTaskRemoval(task.id)
|
||||||
if (task.sessionId) {
|
if (task.sessionId) {
|
||||||
|
clearDelegatedChildSessionBootstrap(task.sessionId)
|
||||||
SessionCategoryRegistry.remove(task.sessionId)
|
SessionCategoryRegistry.remove(task.sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1895,6 +1945,7 @@ The task was re-queued on a fallback model after a retryable failure.
|
|||||||
if (retried && previousSessionID) {
|
if (retried && previousSessionID) {
|
||||||
this.clearSessionOutputObserved(previousSessionID)
|
this.clearSessionOutputObserved(previousSessionID)
|
||||||
this.clearSessionTodoObservation(previousSessionID)
|
this.clearSessionTodoObservation(previousSessionID)
|
||||||
|
clearDelegatedChildSessionBootstrap(previousSessionID)
|
||||||
subagentSessions.delete(previousSessionID)
|
subagentSessions.delete(previousSessionID)
|
||||||
}
|
}
|
||||||
return retried
|
return retried
|
||||||
@@ -2059,6 +2110,7 @@ The task was re-queued on a fallback model after a retryable failure.
|
|||||||
this.clearTaskHistoryWhenParentTasksGone(task.parentSessionId)
|
this.clearTaskHistoryWhenParentTasksGone(task.parentSessionId)
|
||||||
if (task.sessionId) {
|
if (task.sessionId) {
|
||||||
subagentSessions.delete(task.sessionId)
|
subagentSessions.delete(task.sessionId)
|
||||||
|
clearDelegatedChildSessionBootstrap(task.sessionId)
|
||||||
SessionCategoryRegistry.remove(task.sessionId)
|
SessionCategoryRegistry.remove(task.sessionId)
|
||||||
}
|
}
|
||||||
log("[background-agent] Removed completed task from memory:", taskId)
|
log("[background-agent] Removed completed task from memory:", taskId)
|
||||||
@@ -2134,6 +2186,7 @@ The task was re-queued on a fallback model after a retryable failure.
|
|||||||
// Awaited to prevent dangling promise during subagent teardown (Bun/WebKit SIGABRT)
|
// Awaited to prevent dangling promise during subagent teardown (Bun/WebKit SIGABRT)
|
||||||
await this.abortSessionWithLogging(task.sessionId, `task cancellation (${source})`)
|
await this.abortSessionWithLogging(task.sessionId, `task cancellation (${source})`)
|
||||||
|
|
||||||
|
clearDelegatedChildSessionBootstrap(task.sessionId)
|
||||||
SessionCategoryRegistry.remove(task.sessionId)
|
SessionCategoryRegistry.remove(task.sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2259,6 +2312,7 @@ The task was re-queued on a fallback model after a retryable failure.
|
|||||||
// Awaited to prevent dangling promise during subagent teardown (Bun/WebKit SIGABRT)
|
// Awaited to prevent dangling promise during subagent teardown (Bun/WebKit SIGABRT)
|
||||||
await this.abortSessionWithLogging(task.sessionId, `task completion (${source})`)
|
await this.abortSessionWithLogging(task.sessionId, `task completion (${source})`)
|
||||||
|
|
||||||
|
clearDelegatedChildSessionBootstrap(task.sessionId)
|
||||||
SessionCategoryRegistry.remove(task.sessionId)
|
SessionCategoryRegistry.remove(task.sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2588,6 +2642,7 @@ The task was re-queued on a fallback model after a retryable failure.
|
|||||||
removeTaskToastTracking(task.id)
|
removeTaskToastTracking(task.id)
|
||||||
this.scheduleTaskRemoval(task.id)
|
this.scheduleTaskRemoval(task.id)
|
||||||
if (task.sessionId) {
|
if (task.sessionId) {
|
||||||
|
clearDelegatedChildSessionBootstrap(task.sessionId)
|
||||||
SessionCategoryRegistry.remove(task.sessionId)
|
SessionCategoryRegistry.remove(task.sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2775,6 +2830,12 @@ The task was re-queued on a fallback model after a retryable failure.
|
|||||||
|
|
||||||
// Release concurrency for all running tasks
|
// Release concurrency for all running tasks
|
||||||
for (const task of this.tasks.values()) {
|
for (const task of this.tasks.values()) {
|
||||||
|
if (TERMINAL_BACKGROUND_TASK_STATUSES.has(task.status)) {
|
||||||
|
archiveBackgroundTask(task)
|
||||||
|
} else {
|
||||||
|
forgetBackgroundTask(task.id)
|
||||||
|
}
|
||||||
|
|
||||||
if (task.concurrencyKey) {
|
if (task.concurrencyKey) {
|
||||||
this.concurrencyManager.release(task.concurrencyKey)
|
this.concurrencyManager.release(task.concurrencyKey)
|
||||||
task.concurrencyKey = undefined
|
task.concurrencyKey = undefined
|
||||||
@@ -2795,6 +2856,7 @@ The task was re-queued on a fallback model after a retryable failure.
|
|||||||
|
|
||||||
for (const sessionID of trackedSessionIDs) {
|
for (const sessionID of trackedSessionIDs) {
|
||||||
subagentSessions.delete(sessionID)
|
subagentSessions.delete(sessionID)
|
||||||
|
clearDelegatedChildSessionBootstrap(sessionID)
|
||||||
SessionCategoryRegistry.remove(sessionID)
|
SessionCategoryRegistry.remove(sessionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { describe, test, expect, mock, afterEach } from "bun:test"
|
import { afterEach, describe, expect, mock, test } from "bun:test"
|
||||||
import { createTask, startTask } from "./spawner"
|
|
||||||
import type { BackgroundTask } from "./types"
|
|
||||||
import {
|
import {
|
||||||
clearSessionPromptParams,
|
clearSessionPromptParams,
|
||||||
getSessionPromptParams,
|
getSessionPromptParams,
|
||||||
} from "../../shared/session-prompt-params-state"
|
} from "../../shared/session-prompt-params-state"
|
||||||
|
import { createTask, startTask } from "./spawner"
|
||||||
|
import type { BackgroundTask } from "./types"
|
||||||
|
|
||||||
describe("background-agent spawner agent-not-found fallback", () => {
|
describe("background-agent spawner agent-not-found fallback", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -694,6 +694,67 @@ describe("background-agent spawner fallback model promotion", () => {
|
|||||||
expect(promptCalls).toHaveLength(1)
|
expect(promptCalls).toHaveLength(1)
|
||||||
expect(promptCalls[0]?.body?.agent).toBe("Hephaestus - Deep Agent")
|
expect(promptCalls[0]?.body?.agent).toBe("Hephaestus - Deep Agent")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("persists the same normalized agent used by promptAsync into session-agent state (GH-3259 follow-up)", async () => {
|
||||||
|
//#given - ZWSP+sort-prefix wrapped agent name
|
||||||
|
const promptCalls: Array<{ body?: { agent?: string } }> = []
|
||||||
|
const sessionID = "ses_child_normalized"
|
||||||
|
const wrappedAgent = "\u200B\u200B5|Hephaestus - Deep Agent"
|
||||||
|
|
||||||
|
const client = {
|
||||||
|
session: {
|
||||||
|
get: async () => ({ data: { directory: "/parent/dir" } }),
|
||||||
|
create: async () => ({ data: { id: sessionID } }),
|
||||||
|
promptAsync: async (args?: { body?: { agent?: string } }) => {
|
||||||
|
promptCalls.push(args ?? {})
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const { _resetForTesting: resetState, getSessionAgent } = await import("../claude-code-session-state")
|
||||||
|
resetState()
|
||||||
|
|
||||||
|
const task = createTask({
|
||||||
|
description: "Normalized agent storage",
|
||||||
|
prompt: "Do work",
|
||||||
|
agent: wrappedAgent,
|
||||||
|
parentSessionId: "ses_parent",
|
||||||
|
parentMessageId: "msg_parent",
|
||||||
|
})
|
||||||
|
|
||||||
|
const item = {
|
||||||
|
task,
|
||||||
|
input: {
|
||||||
|
description: task.description,
|
||||||
|
prompt: task.prompt,
|
||||||
|
agent: task.agent,
|
||||||
|
parentSessionId: task.parentSessionId,
|
||||||
|
parentMessageId: task.parentMessageId,
|
||||||
|
parentModel: task.parentModel,
|
||||||
|
parentAgent: task.parentAgent,
|
||||||
|
model: task.model,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
client,
|
||||||
|
directory: "/fallback",
|
||||||
|
concurrencyManager: { release: () => {} },
|
||||||
|
tmuxEnabled: false,
|
||||||
|
onTaskError: () => {},
|
||||||
|
}
|
||||||
|
|
||||||
|
//#when
|
||||||
|
await startTask(item as never, ctx as never)
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(promptCalls).toHaveLength(1)
|
||||||
|
const dispatchedAgent = promptCalls[0]?.body?.agent
|
||||||
|
expect(dispatchedAgent).toBe("Hephaestus - Deep Agent")
|
||||||
|
expect(getSessionAgent(sessionID)).toBe(dispatchedAgent)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("background-agent spawner tmux callback ordering", () => {
|
describe("background-agent spawner tmux callback ordering", () => {
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import type { BackgroundTask, LaunchInput, ResumeInput } from "./types"
|
import { createInternalAgentTextPart, getAgentToolRestrictions, log, promptWithRetryInDirectory } from "../../shared"
|
||||||
import type { OpencodeClient, OnSubagentSessionCreated, QueueItem } from "./constants"
|
import { stripAgentListSortPrefix } from "../../shared/agent-display-names"
|
||||||
import { log, getAgentToolRestrictions, createInternalAgentTextPart, promptWithRetryInDirectory } from "../../shared"
|
|
||||||
import { releasePromptAsyncReservation } from "../../shared/prompt-async-gate"
|
import { releasePromptAsyncReservation } from "../../shared/prompt-async-gate"
|
||||||
import { applySessionPromptParams } from "../../shared/session-prompt-params-helpers"
|
import { applySessionPromptParams } from "../../shared/session-prompt-params-helpers"
|
||||||
import { subagentSessions } from "../claude-code-session-state"
|
import { setSessionTools } from "../../shared/session-tools-store"
|
||||||
import { getTaskToastManager } from "../task-toast-manager"
|
|
||||||
import { isInsideTmux } from "../../shared/tmux"
|
import { isInsideTmux } from "../../shared/tmux"
|
||||||
import { stripAgentListSortPrefix } from "../../shared/agent-display-names"
|
import { setSessionAgent, subagentSessions, updateSessionAgent } from "../claude-code-session-state"
|
||||||
|
import { getTaskToastManager } from "../task-toast-manager"
|
||||||
import type { ConcurrencyManager } from "./concurrency"
|
import type { ConcurrencyManager } from "./concurrency"
|
||||||
|
import type { OnSubagentSessionCreated, OpencodeClient, QueueItem } from "./constants"
|
||||||
|
import type { BackgroundTask, LaunchInput, ResumeInput } from "./types"
|
||||||
|
|
||||||
export const FALLBACK_AGENT = "general"
|
export const FALLBACK_AGENT = "general"
|
||||||
|
|
||||||
@@ -65,7 +66,13 @@ export function createTask(input: LaunchInput): BackgroundTask {
|
|||||||
teamRunId: input.teamRunId,
|
teamRunId: input.teamRunId,
|
||||||
parentModel: input.parentModel,
|
parentModel: input.parentModel,
|
||||||
parentAgent: input.parentAgent,
|
parentAgent: input.parentAgent,
|
||||||
|
parentTools: input.parentTools,
|
||||||
model: input.model,
|
model: input.model,
|
||||||
|
fallbackChain: input.fallbackChain,
|
||||||
|
skillContent: input.skillContent,
|
||||||
|
sessionPermission: input.sessionPermission,
|
||||||
|
category: input.category,
|
||||||
|
isUnstableAgent: input.isUnstableAgent,
|
||||||
onSessionCreated: input.onSessionCreated,
|
onSessionCreated: input.onSessionCreated,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -116,8 +123,10 @@ export async function startTask(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sessionID = createResult.data.id
|
const sessionID = createResult.data.id
|
||||||
|
const normalizedAgent = stripAgentListSortPrefix(input.agent)
|
||||||
await input.onSessionCreated?.(sessionID)
|
await input.onSessionCreated?.(sessionID)
|
||||||
subagentSessions.add(sessionID)
|
subagentSessions.add(sessionID)
|
||||||
|
setSessionAgent(sessionID, normalizedAgent)
|
||||||
|
|
||||||
task.status = "running"
|
task.status = "running"
|
||||||
task.startedAt = new Date()
|
task.startedAt = new Date()
|
||||||
@@ -129,7 +138,7 @@ export async function startTask(
|
|||||||
task.concurrencyKey = concurrencyKey
|
task.concurrencyKey = concurrencyKey
|
||||||
task.concurrencyGroup = concurrencyKey
|
task.concurrencyGroup = concurrencyKey
|
||||||
|
|
||||||
log("[background-agent] Launching task:", { taskId: task.id, sessionID, agent: input.agent })
|
log("[background-agent] Launching task:", { taskId: task.id, sessionID, agent: normalizedAgent })
|
||||||
|
|
||||||
const toastManager = getTaskToastManager()
|
const toastManager = getTaskToastManager()
|
||||||
if (toastManager) {
|
if (toastManager) {
|
||||||
@@ -138,7 +147,7 @@ export async function startTask(
|
|||||||
|
|
||||||
log("[background-agent] Calling prompt (fire-and-forget) for launch with:", {
|
log("[background-agent] Calling prompt (fire-and-forget) for launch with:", {
|
||||||
sessionID,
|
sessionID,
|
||||||
agent: input.agent,
|
agent: normalizedAgent,
|
||||||
model: input.model,
|
model: input.model,
|
||||||
hasSkillContent: !!input.skillContent,
|
hasSkillContent: !!input.skillContent,
|
||||||
promptLength: input.prompt.length,
|
promptLength: input.prompt.length,
|
||||||
@@ -151,7 +160,6 @@ export async function startTask(
|
|||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
const launchVariant = input.model?.variant
|
const launchVariant = input.model?.variant
|
||||||
const normalizedAgent = stripAgentListSortPrefix(input.agent)
|
|
||||||
|
|
||||||
applySessionPromptParams(sessionID, input.model)
|
applySessionPromptParams(sessionID, input.model)
|
||||||
|
|
||||||
@@ -170,6 +178,7 @@ export async function startTask(
|
|||||||
},
|
},
|
||||||
parts: [createInternalAgentTextPart(input.prompt)],
|
parts: [createInternalAgentTextPart(input.prompt)],
|
||||||
}
|
}
|
||||||
|
setSessionTools(sessionID, promptBody.tools)
|
||||||
|
|
||||||
// Must fire BEFORE tmux callback: attach client needs session activity to render TUI.
|
// Must fire BEFORE tmux callback: attach client needs session activity to render TUI.
|
||||||
const promptChain = promptWithRetryInDirectory(client, {
|
const promptChain = promptWithRetryInDirectory(client, {
|
||||||
@@ -184,11 +193,15 @@ export async function startTask(
|
|||||||
})
|
})
|
||||||
try {
|
try {
|
||||||
releasePromptAsyncReservation(sessionID, "model-suggestion-retry")
|
releasePromptAsyncReservation(sessionID, "model-suggestion-retry")
|
||||||
|
const fallbackBody = buildFallbackBody(promptBody, FALLBACK_AGENT, {
|
||||||
|
includeTeamToolDenylist: input.teamRunId === undefined,
|
||||||
|
})
|
||||||
|
const fallbackTools = fallbackBody.tools as Record<string, boolean>
|
||||||
|
setSessionTools(sessionID, fallbackTools)
|
||||||
|
updateSessionAgent(sessionID, FALLBACK_AGENT)
|
||||||
await promptWithRetryInDirectory(client, {
|
await promptWithRetryInDirectory(client, {
|
||||||
path: { id: sessionID },
|
path: { id: sessionID },
|
||||||
body: buildFallbackBody(promptBody, FALLBACK_AGENT, {
|
body: fallbackBody,
|
||||||
includeTeamToolDenylist: input.teamRunId === undefined,
|
|
||||||
}),
|
|
||||||
}, parentDirectory)
|
}, parentDirectory)
|
||||||
task.agent = FALLBACK_AGENT
|
task.agent = FALLBACK_AGENT
|
||||||
return
|
return
|
||||||
@@ -310,6 +323,7 @@ export async function resumeTask(
|
|||||||
},
|
},
|
||||||
parts: [createInternalAgentTextPart(input.prompt)],
|
parts: [createInternalAgentTextPart(input.prompt)],
|
||||||
}
|
}
|
||||||
|
setSessionTools(sessionID, resumeBody.tools)
|
||||||
|
|
||||||
promptWithRetryInDirectory(client, {
|
promptWithRetryInDirectory(client, {
|
||||||
path: { id: sessionID },
|
path: { id: sessionID },
|
||||||
@@ -323,11 +337,15 @@ export async function resumeTask(
|
|||||||
})
|
})
|
||||||
try {
|
try {
|
||||||
releasePromptAsyncReservation(sessionID, "model-suggestion-retry")
|
releasePromptAsyncReservation(sessionID, "model-suggestion-retry")
|
||||||
|
const fallbackBody = buildFallbackBody(resumeBody, FALLBACK_AGENT, {
|
||||||
|
includeTeamToolDenylist: task.teamRunId === undefined,
|
||||||
|
})
|
||||||
|
const fallbackTools = fallbackBody.tools as Record<string, boolean>
|
||||||
|
setSessionTools(sessionID, fallbackTools)
|
||||||
|
updateSessionAgent(sessionID, FALLBACK_AGENT)
|
||||||
await promptWithRetryInDirectory(client, {
|
await promptWithRetryInDirectory(client, {
|
||||||
path: { id: sessionID },
|
path: { id: sessionID },
|
||||||
body: buildFallbackBody(resumeBody, FALLBACK_AGENT, {
|
body: fallbackBody,
|
||||||
includeTeamToolDenylist: task.teamRunId === undefined,
|
|
||||||
}),
|
|
||||||
}, directory)
|
}, directory)
|
||||||
task.agent = FALLBACK_AGENT
|
task.agent = FALLBACK_AGENT
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import type { BackgroundTask } from "./types"
|
||||||
|
|
||||||
|
const MAX_COMPLETED_TASK_REGISTRY_SIZE = 100
|
||||||
|
const REGISTRY_KEY = "__omoBackgroundTaskRegistry"
|
||||||
|
|
||||||
|
type BackgroundTaskRegistry = {
|
||||||
|
activeTasks: Map<string, () => BackgroundTask>
|
||||||
|
completedTasks: Map<string, BackgroundTask>
|
||||||
|
}
|
||||||
|
|
||||||
|
type GlobalWithBackgroundTaskRegistry = typeof globalThis & {
|
||||||
|
[REGISTRY_KEY]?: BackgroundTaskRegistry
|
||||||
|
}
|
||||||
|
|
||||||
|
const TERMINAL_TASK_STATUSES = new Set<BackgroundTask["status"]>([
|
||||||
|
"completed",
|
||||||
|
"error",
|
||||||
|
"cancelled",
|
||||||
|
"interrupt",
|
||||||
|
])
|
||||||
|
|
||||||
|
function getRegistry(): BackgroundTaskRegistry {
|
||||||
|
const registryGlobal = globalThis as GlobalWithBackgroundTaskRegistry
|
||||||
|
registryGlobal[REGISTRY_KEY] ??= {
|
||||||
|
activeTasks: new Map<string, () => BackgroundTask>(),
|
||||||
|
completedTasks: new Map<string, BackgroundTask>(),
|
||||||
|
}
|
||||||
|
const registry = registryGlobal[REGISTRY_KEY]
|
||||||
|
return registry
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneProgress(progress: BackgroundTask["progress"]): BackgroundTask["progress"] {
|
||||||
|
if (!progress) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...progress,
|
||||||
|
countedToolPartIDs: progress.countedToolPartIDs ? new Set(progress.countedToolPartIDs) : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneAttempts(attempts: BackgroundTask["attempts"]): BackgroundTask["attempts"] {
|
||||||
|
if (!attempts) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return attempts.map((attempt) => ({ ...attempt }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneRegisteredTask(task: BackgroundTask): BackgroundTask {
|
||||||
|
return {
|
||||||
|
id: task.id,
|
||||||
|
rootSessionId: task.rootSessionId,
|
||||||
|
parentSessionId: task.parentSessionId,
|
||||||
|
parentMessageId: task.parentMessageId,
|
||||||
|
teamRunId: task.teamRunId,
|
||||||
|
description: task.description,
|
||||||
|
prompt: "[redacted]",
|
||||||
|
agent: task.agent,
|
||||||
|
spawnDepth: task.spawnDepth,
|
||||||
|
sessionId: task.sessionId,
|
||||||
|
status: task.status,
|
||||||
|
queuedAt: task.queuedAt,
|
||||||
|
startedAt: task.startedAt,
|
||||||
|
completedAt: task.completedAt,
|
||||||
|
result: task.result,
|
||||||
|
progress: cloneProgress(task.progress),
|
||||||
|
parentModel: task.parentModel,
|
||||||
|
model: task.model,
|
||||||
|
fallbackChain: task.fallbackChain,
|
||||||
|
attemptCount: task.attemptCount,
|
||||||
|
concurrencyKey: task.concurrencyKey,
|
||||||
|
concurrencyGroup: task.concurrencyGroup,
|
||||||
|
parentAgent: task.parentAgent,
|
||||||
|
parentTools: task.parentTools,
|
||||||
|
isUnstableAgent: task.isUnstableAgent,
|
||||||
|
error: task.error,
|
||||||
|
category: task.category,
|
||||||
|
retryNotification: task.retryNotification ? { ...task.retryNotification } : undefined,
|
||||||
|
attempts: cloneAttempts(task.attempts),
|
||||||
|
currentAttemptID: task.currentAttemptID,
|
||||||
|
lastMsgCount: task.lastMsgCount,
|
||||||
|
stablePolls: task.stablePolls,
|
||||||
|
consecutiveMissedPolls: task.consecutiveMissedPolls,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimCompletedTasks(registry: BackgroundTaskRegistry): void {
|
||||||
|
while (registry.completedTasks.size > MAX_COMPLETED_TASK_REGISTRY_SIZE) {
|
||||||
|
const oldestTaskID = registry.completedTasks.keys().next().value
|
||||||
|
if (typeof oldestTaskID !== "string") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
registry.completedTasks.delete(oldestTaskID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rememberBackgroundTask(task: BackgroundTask): void {
|
||||||
|
const registry = getRegistry()
|
||||||
|
registry.completedTasks.delete(task.id)
|
||||||
|
registry.activeTasks.set(task.id, () => cloneRegisteredTask(task))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function archiveBackgroundTask(task: BackgroundTask): void {
|
||||||
|
const registry = getRegistry()
|
||||||
|
registry.activeTasks.delete(task.id)
|
||||||
|
registry.completedTasks.delete(task.id)
|
||||||
|
if (!task.sessionId || !TERMINAL_TASK_STATUSES.has(task.status)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
registry.completedTasks.set(task.id, cloneRegisteredTask(task))
|
||||||
|
trimCompletedTasks(registry)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRegisteredBackgroundTask(taskID: string): BackgroundTask | undefined {
|
||||||
|
const registry = getRegistry()
|
||||||
|
const activeTask = registry.activeTasks.get(taskID)
|
||||||
|
if (activeTask) {
|
||||||
|
return activeTask()
|
||||||
|
}
|
||||||
|
|
||||||
|
const completedTask = registry.completedTasks.get(taskID)
|
||||||
|
return completedTask ? cloneRegisteredTask(completedTask) : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function forgetBackgroundTask(taskID: string): void {
|
||||||
|
const registry = getRegistry()
|
||||||
|
registry.activeTasks.delete(taskID)
|
||||||
|
registry.completedTasks.delete(taskID)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearBackgroundTaskRegistryForTesting(): void {
|
||||||
|
const registry = getRegistry()
|
||||||
|
registry.activeTasks.clear()
|
||||||
|
registry.completedTasks.clear()
|
||||||
|
}
|
||||||
@@ -73,6 +73,8 @@ export interface BackgroundTask {
|
|||||||
parentAgent?: string
|
parentAgent?: string
|
||||||
/** Parent session's tool restrictions for notification prompts */
|
/** Parent session's tool restrictions for notification prompts */
|
||||||
parentTools?: Record<string, boolean>
|
parentTools?: Record<string, boolean>
|
||||||
|
skillContent?: string
|
||||||
|
sessionPermission?: SessionPermissionRule[]
|
||||||
/** Marks if the task was launched from an unstable agent/category */
|
/** Marks if the task was launched from an unstable agent/category */
|
||||||
isUnstableAgent?: boolean
|
isUnstableAgent?: boolean
|
||||||
/** Category used for this task (e.g., 'quick', 'visual-engineering') */
|
/** Category used for this task (e.g., 'quick', 'visual-engineering') */
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ import { getSessionAgent } from "../../features/claude-code-session-state"
|
|||||||
import { getFallbackModelsForSession } from "./fallback-models"
|
import { getFallbackModelsForSession } from "./fallback-models"
|
||||||
import { prepareFallback } from "./fallback-state"
|
import { prepareFallback } from "./fallback-state"
|
||||||
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
|
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
|
||||||
|
import { clearDelegatedChildSessionBootstrap } from "../../shared/delegated-child-session-bootstrap"
|
||||||
import { buildRetryModelPayload } from "./retry-model-payload"
|
import { buildRetryModelPayload } from "./retry-model-payload"
|
||||||
import { getLastUserRetryParts } from "./last-user-retry-parts"
|
import { getLastUserRetryPayload } from "./last-user-retry-parts"
|
||||||
import { extractSessionMessages } from "./session-messages"
|
import { extractSessionMessages } from "./session-messages"
|
||||||
import { resolveRegisteredAgentName } from "../../features/claude-code-session-state"
|
import { resolveRegisteredAgentName } from "../../features/claude-code-session-state"
|
||||||
import {
|
import {
|
||||||
@@ -143,7 +144,8 @@ export function createAutoRetryHelpers(deps: HookDeps) {
|
|||||||
path: { id: sessionID },
|
path: { id: sessionID },
|
||||||
query: { directory: ctx.directory },
|
query: { directory: ctx.directory },
|
||||||
})
|
})
|
||||||
const retryParts = getLastUserRetryParts(messagesResp)
|
const retryPayload = getLastUserRetryPayload(messagesResp, sessionID)
|
||||||
|
const retryParts = retryPayload.retryParts
|
||||||
if (retryParts.length > 0) {
|
if (retryParts.length > 0) {
|
||||||
log(`[${HOOK_NAME}] Auto-retrying with fallback model (${source})`, {
|
log(`[${HOOK_NAME}] Auto-retrying with fallback model (${source})`, {
|
||||||
sessionID,
|
sessionID,
|
||||||
@@ -165,6 +167,8 @@ export function createAutoRetryHelpers(deps: HookDeps) {
|
|||||||
body: {
|
body: {
|
||||||
...(launchAgent ? { agent: launchAgent } : {}),
|
...(launchAgent ? { agent: launchAgent } : {}),
|
||||||
...retryModelPayload,
|
...retryModelPayload,
|
||||||
|
...(retryPayload.system ? { system: retryPayload.system } : {}),
|
||||||
|
...(retryPayload.tools ? { tools: retryPayload.tools } : {}),
|
||||||
parts: retryParts,
|
parts: retryParts,
|
||||||
},
|
},
|
||||||
query: { directory: ctx.directory },
|
query: { directory: ctx.directory },
|
||||||
@@ -239,6 +243,7 @@ export function createAutoRetryHelpers(deps: HookDeps) {
|
|||||||
sessionRetryInFlight.delete(sessionID)
|
sessionRetryInFlight.delete(sessionID)
|
||||||
sessionAwaitingFallbackResult.delete(sessionID)
|
sessionAwaitingFallbackResult.delete(sessionID)
|
||||||
clearSessionFallbackTimeout(sessionID)
|
clearSessionFallbackTimeout(sessionID)
|
||||||
|
clearDelegatedChildSessionBootstrap(sessionID)
|
||||||
SessionCategoryRegistry.remove(sessionID)
|
SessionCategoryRegistry.remove(sessionID)
|
||||||
sessionStatusRetryKeys.delete(sessionID)
|
sessionStatusRetryKeys.delete(sessionID)
|
||||||
cleanedCount++
|
cleanedCount++
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
import { describe, expect, test, beforeEach, afterEach, mock } from "bun:test"
|
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"
|
||||||
import type { RuntimeFallbackConfig, OhMyOpenCodeConfig } from "../../config"
|
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
|
||||||
|
import type { OhMyOpenCodeConfig, RuntimeFallbackConfig } from "../../config"
|
||||||
|
import {
|
||||||
|
clearAllDelegatedChildSessionBootstrap,
|
||||||
|
getDelegatedChildSessionBootstrap,
|
||||||
|
registerDelegatedChildSessionBootstrap,
|
||||||
|
} from "../../shared/delegated-child-session-bootstrap"
|
||||||
import * as loggerModule from "../../shared/logger"
|
import * as loggerModule from "../../shared/logger"
|
||||||
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
|
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
|
||||||
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
|
import type { RuntimeFallbackPluginInput } from "./types"
|
||||||
|
|
||||||
type RuntimeFallbackModule = typeof import("./hook")
|
type RuntimeFallbackModule = typeof import("./hook")
|
||||||
|
|
||||||
@@ -16,6 +22,7 @@ describe("runtime-fallback", () => {
|
|||||||
logCalls = []
|
logCalls = []
|
||||||
toastCalls = []
|
toastCalls = []
|
||||||
SessionCategoryRegistry.clear()
|
SessionCategoryRegistry.clear()
|
||||||
|
clearAllDelegatedChildSessionBootstrap()
|
||||||
|
|
||||||
const cacheBuster = `${Date.now()}-${Math.random()}`
|
const cacheBuster = `${Date.now()}-${Math.random()}`
|
||||||
|
|
||||||
@@ -32,6 +39,7 @@ describe("runtime-fallback", () => {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
SessionCategoryRegistry.clear()
|
SessionCategoryRegistry.clear()
|
||||||
|
clearAllDelegatedChildSessionBootstrap()
|
||||||
mock.restore()
|
mock.restore()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -42,8 +50,8 @@ describe("runtime-fallback", () => {
|
|||||||
abort?: (args: unknown) => Promise<unknown>
|
abort?: (args: unknown) => Promise<unknown>
|
||||||
status?: () => Promise<unknown>
|
status?: () => Promise<unknown>
|
||||||
}
|
}
|
||||||
}) {
|
}): RuntimeFallbackPluginInput {
|
||||||
return unsafeTestValue({
|
return unsafeTestValue<RuntimeFallbackPluginInput>({
|
||||||
client: {
|
client: {
|
||||||
tui: {
|
tui: {
|
||||||
showToast: async (opts: { body: { title: string; message: string; variant: string; duration: number } }) => {
|
showToast: async (opts: { body: { title: string; message: string; variant: string; duration: number } }) => {
|
||||||
@@ -489,6 +497,122 @@ describe("runtime-fallback", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("should retry delegated child session from bootstrap when history has no user prompt", async () => {
|
||||||
|
const promptCalls: Array<Record<string, unknown>> = []
|
||||||
|
const hook = createRuntimeFallbackHook(
|
||||||
|
createMockPluginInput({
|
||||||
|
session: {
|
||||||
|
messages: async () => ({ data: [] }),
|
||||||
|
promptAsync: async (args) => {
|
||||||
|
promptCalls.push(args as Record<string, unknown>)
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
config: createMockConfig({ notify_on_fallback: false }),
|
||||||
|
pluginConfig: createMockPluginConfigWithCategoryModel(
|
||||||
|
"quick",
|
||||||
|
"anthropic/claude-haiku-4-5",
|
||||||
|
["openai/gpt-5.4(high)"],
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const sessionID = "test-delegated-empty-history-bootstrap"
|
||||||
|
registerDelegatedChildSessionBootstrap({
|
||||||
|
sessionID,
|
||||||
|
promptText: "inspect src/tools/delegate-task and report the issue",
|
||||||
|
category: "quick",
|
||||||
|
system: "delegated child system prompt",
|
||||||
|
tools: { call_omo_agent: true, question: false, task: false },
|
||||||
|
})
|
||||||
|
|
||||||
|
await hook.event({
|
||||||
|
event: {
|
||||||
|
type: "session.error",
|
||||||
|
properties: {
|
||||||
|
sessionID,
|
||||||
|
error: { statusCode: 429, message: "Rate limit exceeded before history persisted" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(promptCalls).toHaveLength(1)
|
||||||
|
const promptBody = promptCalls[0]?.body as {
|
||||||
|
model?: { providerID?: string; modelID?: string }
|
||||||
|
parts?: Array<{ type?: string; text?: string }>
|
||||||
|
system?: string
|
||||||
|
tools?: Record<string, boolean>
|
||||||
|
variant?: string
|
||||||
|
} | undefined
|
||||||
|
expect(promptBody?.model).toEqual({ providerID: "openai", modelID: "gpt-5.4" })
|
||||||
|
expect(promptBody?.variant).toBe("high")
|
||||||
|
expect(promptBody?.system).toBe("delegated child system prompt")
|
||||||
|
expect(promptBody?.tools?.question).toBe(false)
|
||||||
|
expect(promptBody?.tools?.call_omo_agent).toBe(true)
|
||||||
|
expect(promptBody?.parts?.[0]?.text).toContain("inspect src/tools/delegate-task")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should use persisted user prompt while preserving delegated bootstrap launch context", async () => {
|
||||||
|
const promptCalls: Array<Record<string, unknown>> = []
|
||||||
|
const sessionID = "test-delegated-history-prefers-persisted-user"
|
||||||
|
const hook = createRuntimeFallbackHook(
|
||||||
|
createMockPluginInput({
|
||||||
|
session: {
|
||||||
|
messages: async () => ({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
info: { role: "user" },
|
||||||
|
parts: [{ type: "text", text: "persisted child task prompt" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
promptAsync: async (args) => {
|
||||||
|
promptCalls.push(args as Record<string, unknown>)
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
config: createMockConfig({ notify_on_fallback: false }),
|
||||||
|
pluginConfig: createMockPluginConfigWithCategoryModel(
|
||||||
|
"test",
|
||||||
|
"anthropic/claude-haiku-4-5",
|
||||||
|
["openai/gpt-5.4"],
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
registerDelegatedChildSessionBootstrap({
|
||||||
|
sessionID,
|
||||||
|
promptText: "bootstrap copy should not be reused",
|
||||||
|
system: "persisted delegated child system prompt",
|
||||||
|
tools: { call_omo_agent: true, question: false, task: false },
|
||||||
|
})
|
||||||
|
SessionCategoryRegistry.register(sessionID, "test")
|
||||||
|
|
||||||
|
await hook.event({
|
||||||
|
event: {
|
||||||
|
type: "session.error",
|
||||||
|
properties: {
|
||||||
|
sessionID,
|
||||||
|
error: { statusCode: 429, message: "Rate limit after prompt persisted" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(promptCalls).toHaveLength(1)
|
||||||
|
const promptBody = promptCalls[0]?.body as {
|
||||||
|
parts?: Array<{ type?: string; text?: string }>
|
||||||
|
system?: string
|
||||||
|
tools?: Record<string, boolean>
|
||||||
|
} | undefined
|
||||||
|
expect(promptBody?.parts?.[0]?.text).toBe("persisted child task prompt")
|
||||||
|
expect(promptBody?.system).toBe("persisted delegated child system prompt")
|
||||||
|
expect(promptBody?.tools?.question).toBe(false)
|
||||||
|
expect(promptBody?.tools?.call_omo_agent).toBe(true)
|
||||||
|
expect(getDelegatedChildSessionBootstrap(sessionID)).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
test("should trigger fallback on Copilot auto-retry signal in message.updated", async () => {
|
test("should trigger fallback on Copilot auto-retry signal in message.updated", async () => {
|
||||||
const hook = createRuntimeFallbackHook(createMockPluginInput(), {
|
const hook = createRuntimeFallbackHook(createMockPluginInput(), {
|
||||||
config: createMockConfig({ notify_on_fallback: false }),
|
config: createMockConfig({ notify_on_fallback: false }),
|
||||||
|
|||||||
@@ -1,15 +1,36 @@
|
|||||||
import { extractSessionMessages } from "./session-messages"
|
import { extractSessionMessages } from "./session-messages"
|
||||||
|
import {
|
||||||
|
clearDelegatedChildSessionBootstrap,
|
||||||
|
getDelegatedChildSessionBootstrap,
|
||||||
|
} from "../../shared/delegated-child-session-bootstrap"
|
||||||
|
|
||||||
|
type RetryPart = { type: "text"; text: string }
|
||||||
|
|
||||||
|
export type LastUserRetryPayload = {
|
||||||
|
retryParts: RetryPart[]
|
||||||
|
system?: string
|
||||||
|
tools?: Record<string, boolean>
|
||||||
|
}
|
||||||
|
|
||||||
export function getLastUserRetryParts(
|
export function getLastUserRetryParts(
|
||||||
messagesResponse: unknown,
|
messagesResponse: unknown,
|
||||||
): Array<{ type: "text"; text: string }> {
|
sessionID?: string,
|
||||||
|
): RetryPart[] {
|
||||||
|
return getLastUserRetryPayload(messagesResponse, sessionID).retryParts
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLastUserRetryPayload(
|
||||||
|
messagesResponse: unknown,
|
||||||
|
sessionID?: string,
|
||||||
|
): LastUserRetryPayload {
|
||||||
|
const bootstrap = sessionID ? getDelegatedChildSessionBootstrap(sessionID) : undefined
|
||||||
const messages = extractSessionMessages(messagesResponse)
|
const messages = extractSessionMessages(messagesResponse)
|
||||||
const lastUserMessage = messages?.filter((message) => message.info?.role === "user").pop()
|
const lastUserMessage = messages?.filter((message) => message.info?.role === "user").pop()
|
||||||
const lastUserParts =
|
const lastUserParts =
|
||||||
lastUserMessage?.parts
|
lastUserMessage?.parts
|
||||||
?? (lastUserMessage?.info?.parts as Array<{ type?: string; text?: string }> | undefined)
|
?? (lastUserMessage?.info?.parts as Array<{ type?: string; text?: string }> | undefined)
|
||||||
|
|
||||||
return (lastUserParts ?? [])
|
const retryParts = (lastUserParts ?? [])
|
||||||
.filter(
|
.filter(
|
||||||
(part): part is { type: "text"; text: string } =>
|
(part): part is { type: "text"; text: string } =>
|
||||||
part.type === "text"
|
part.type === "text"
|
||||||
@@ -17,4 +38,25 @@ export function getLastUserRetryParts(
|
|||||||
&& part.text.length > 0,
|
&& part.text.length > 0,
|
||||||
)
|
)
|
||||||
.map((part) => ({ type: "text" as const, text: part.text }))
|
.map((part) => ({ type: "text" as const, text: part.text }))
|
||||||
|
|
||||||
|
if (retryParts.length > 0) {
|
||||||
|
if (sessionID) {
|
||||||
|
clearDelegatedChildSessionBootstrap(sessionID)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
retryParts,
|
||||||
|
...(bootstrap?.system ? { system: bootstrap.system } : {}),
|
||||||
|
...(bootstrap?.tools ? { tools: bootstrap.tools } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sessionID) {
|
||||||
|
return { retryParts }
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
retryParts: bootstrap?.retryParts ?? [],
|
||||||
|
...(bootstrap?.system ? { system: bootstrap.system } : {}),
|
||||||
|
...(bootstrap?.tools ? { tools: bootstrap.tools } : {}),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ export interface RuntimeFallbackPluginInput {
|
|||||||
body: {
|
body: {
|
||||||
agent?: string
|
agent?: string
|
||||||
model: { providerID: string; modelID: string }
|
model: { providerID: string; modelID: string }
|
||||||
|
system?: string
|
||||||
|
tools?: Record<string, boolean>
|
||||||
parts: Array<{ type: "text"; text: string }>
|
parts: Array<{ type: "text"; text: string }>
|
||||||
}
|
}
|
||||||
query: { directory: string }
|
query: { directory: string }
|
||||||
|
|||||||
@@ -91,6 +91,36 @@ describe("promptAsyncAfterSessionIdle", () => {
|
|||||||
expect(promptCalls).toBe(1)
|
expect(promptCalls).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("#given SDK promptAsync depends on its session receiver #when the gate dispatches #then method binding is preserved", async () => {
|
||||||
|
// given
|
||||||
|
const session = {
|
||||||
|
_client: { accepted: true },
|
||||||
|
async promptAsync(
|
||||||
|
this: { _client: { accepted: boolean } },
|
||||||
|
input: { path: { id: string }, body: { parts: unknown[] } },
|
||||||
|
) {
|
||||||
|
return { accepted: this._client.accepted, sessionID: input.path.id }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const client = { session }
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = await promptAsyncAfterSessionIdle({
|
||||||
|
client,
|
||||||
|
sessionID: "ses_bound_prompt_async",
|
||||||
|
input: { path: { id: "ses_bound_prompt_async" }, body: { parts: [] } },
|
||||||
|
source: "test:bound-prompt-async",
|
||||||
|
settleMs: 0,
|
||||||
|
postDispatchHoldMs: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(result).toEqual({
|
||||||
|
status: "dispatched",
|
||||||
|
response: { accepted: true, sessionID: "ses_bound_prompt_async" },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
test("#given session.status reports busy #when an internal promptAsync is requested #then no prompt is sent", async () => {
|
test("#given session.status reports busy #when an internal promptAsync is requested #then no prompt is sent", async () => {
|
||||||
// given
|
// given
|
||||||
let promptCalls = 0
|
let promptCalls = 0
|
||||||
@@ -445,4 +475,34 @@ describe("promptAsyncAfterSessionIdle", () => {
|
|||||||
expect(second.status).toBe("reserved")
|
expect(second.status).toBe("reserved")
|
||||||
expect(promptCalls).toBe(1)
|
expect(promptCalls).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("#given SDK prompt depends on its session receiver #when the gate dispatches #then method binding is preserved", async () => {
|
||||||
|
// given
|
||||||
|
const session = {
|
||||||
|
_client: { accepted: true },
|
||||||
|
async prompt(
|
||||||
|
this: { _client: { accepted: boolean } },
|
||||||
|
input: { path: { id: string }, body: { parts: unknown[] } },
|
||||||
|
) {
|
||||||
|
return { accepted: this._client.accepted, sessionID: input.path.id }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const client = { session }
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = await promptAfterSessionIdle({
|
||||||
|
client,
|
||||||
|
sessionID: "ses_bound_prompt",
|
||||||
|
input: { path: { id: "ses_bound_prompt" }, body: { parts: [] } },
|
||||||
|
source: "test:bound-prompt",
|
||||||
|
settleMs: 0,
|
||||||
|
postDispatchHoldMs: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(result).toEqual({
|
||||||
|
status: "dispatched",
|
||||||
|
response: { accepted: true, sessionID: "ses_bound_prompt" },
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import type { ModelFallbackControllerAccessor } from "../hooks/model-fallback"
|
||||||
|
import { createInternalAgentTextPart } from "./internal-initiator-marker"
|
||||||
|
import type { FallbackEntry } from "./model-requirements"
|
||||||
|
import { SessionCategoryRegistry } from "./session-category-registry"
|
||||||
|
|
||||||
|
export type DelegatedChildSessionRetryPart = {
|
||||||
|
type: "text"
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DelegatedChildSessionBootstrap = {
|
||||||
|
retryParts: DelegatedChildSessionRetryPart[]
|
||||||
|
fallbackChain?: FallbackEntry[]
|
||||||
|
category?: string
|
||||||
|
system?: string
|
||||||
|
tools?: Record<string, boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
const delegatedChildSessionBootstraps = new Map<string, DelegatedChildSessionBootstrap>()
|
||||||
|
|
||||||
|
function cloneRetryParts(parts: DelegatedChildSessionRetryPart[]): DelegatedChildSessionRetryPart[] {
|
||||||
|
return parts.map((part) => ({ type: part.type, text: part.text }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneFallbackChain(fallbackChain: FallbackEntry[] | undefined): FallbackEntry[] | undefined {
|
||||||
|
return fallbackChain?.map((entry) => ({
|
||||||
|
...entry,
|
||||||
|
providers: [...entry.providers],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneTools(tools: Record<string, boolean> | undefined): Record<string, boolean> | undefined {
|
||||||
|
return tools ? { ...tools } : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerDelegatedChildSessionBootstrap(_args: {
|
||||||
|
sessionID: string
|
||||||
|
promptText: string
|
||||||
|
fallbackChain?: FallbackEntry[]
|
||||||
|
category?: string
|
||||||
|
system?: string
|
||||||
|
tools?: Record<string, boolean>
|
||||||
|
modelFallbackControllerAccessor?: ModelFallbackControllerAccessor
|
||||||
|
}): void {
|
||||||
|
const retryParts = [createInternalAgentTextPart(_args.promptText)]
|
||||||
|
const fallbackChain = cloneFallbackChain(_args.fallbackChain)
|
||||||
|
const tools = cloneTools(_args.tools)
|
||||||
|
delegatedChildSessionBootstraps.set(_args.sessionID, {
|
||||||
|
retryParts,
|
||||||
|
...(fallbackChain ? { fallbackChain } : {}),
|
||||||
|
...(_args.category ? { category: _args.category } : {}),
|
||||||
|
...(_args.system ? { system: _args.system } : {}),
|
||||||
|
...(tools ? { tools } : {}),
|
||||||
|
})
|
||||||
|
|
||||||
|
_args.modelFallbackControllerAccessor?.setSessionFallbackChain(_args.sessionID, fallbackChain)
|
||||||
|
if (_args.category) {
|
||||||
|
SessionCategoryRegistry.register(_args.sessionID, _args.category)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDelegatedChildSessionBootstrap(_sessionID: string): DelegatedChildSessionBootstrap | undefined {
|
||||||
|
const bootstrap = delegatedChildSessionBootstraps.get(_sessionID)
|
||||||
|
if (!bootstrap) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackChain = cloneFallbackChain(bootstrap.fallbackChain)
|
||||||
|
const tools = cloneTools(bootstrap.tools)
|
||||||
|
return {
|
||||||
|
retryParts: cloneRetryParts(bootstrap.retryParts),
|
||||||
|
...(fallbackChain ? { fallbackChain } : {}),
|
||||||
|
...(bootstrap.category ? { category: bootstrap.category } : {}),
|
||||||
|
...(bootstrap.system ? { system: bootstrap.system } : {}),
|
||||||
|
...(tools ? { tools } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearDelegatedChildSessionBootstrap(_sessionID: string): void {
|
||||||
|
delegatedChildSessionBootstraps.delete(_sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearAllDelegatedChildSessionBootstrap(): void {
|
||||||
|
delegatedChildSessionBootstraps.clear()
|
||||||
|
}
|
||||||
@@ -217,12 +217,13 @@ export async function promptAsyncAfterSessionIdle<TInput = PromptAsyncInput>(arg
|
|||||||
} = args
|
} = args
|
||||||
const postDispatchHoldMs = args.postDispatchHoldMs ?? DEFAULT_PROMPT_ASYNC_POST_DISPATCH_HOLD_MS
|
const postDispatchHoldMs = args.postDispatchHoldMs ?? DEFAULT_PROMPT_ASYNC_POST_DISPATCH_HOLD_MS
|
||||||
const dispatchTimeoutMs = args.dispatchTimeoutMs ?? DEFAULT_PROMPT_DISPATCH_TIMEOUT_MS
|
const dispatchTimeoutMs = args.dispatchTimeoutMs ?? DEFAULT_PROMPT_DISPATCH_TIMEOUT_MS
|
||||||
const promptAsync = client.session?.promptAsync
|
const session = client.session
|
||||||
|
|
||||||
if (typeof promptAsync !== "function") {
|
if (typeof session?.promptAsync !== "function") {
|
||||||
log("[prompt-async-gate] promptAsync unavailable", { sessionID, source })
|
log("[prompt-async-gate] promptAsync unavailable", { sessionID, source })
|
||||||
return { status: "unavailable" }
|
return { status: "unavailable" }
|
||||||
}
|
}
|
||||||
|
const dispatchPromptAsync = session.promptAsync.bind(session)
|
||||||
|
|
||||||
return dispatchAfterSessionIdle({
|
return dispatchAfterSessionIdle({
|
||||||
sessionName: "promptAsync",
|
sessionName: "promptAsync",
|
||||||
@@ -234,7 +235,7 @@ export async function promptAsyncAfterSessionIdle<TInput = PromptAsyncInput>(arg
|
|||||||
postDispatchHoldMs,
|
postDispatchHoldMs,
|
||||||
dispatchTimeoutMs,
|
dispatchTimeoutMs,
|
||||||
checkStatus: args.checkStatus !== false,
|
checkStatus: args.checkStatus !== false,
|
||||||
dispatch: (dispatchInput) => promptAsync(dispatchInput),
|
dispatch: (dispatchInput) => dispatchPromptAsync(dispatchInput),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,12 +258,13 @@ export async function promptAfterSessionIdle<TInput = PromptAsyncInput>(args: {
|
|||||||
} = args
|
} = args
|
||||||
const postDispatchHoldMs = args.postDispatchHoldMs ?? DEFAULT_PROMPT_ASYNC_POST_DISPATCH_HOLD_MS
|
const postDispatchHoldMs = args.postDispatchHoldMs ?? DEFAULT_PROMPT_ASYNC_POST_DISPATCH_HOLD_MS
|
||||||
const dispatchTimeoutMs = args.dispatchTimeoutMs ?? DEFAULT_PROMPT_DISPATCH_TIMEOUT_MS
|
const dispatchTimeoutMs = args.dispatchTimeoutMs ?? DEFAULT_PROMPT_DISPATCH_TIMEOUT_MS
|
||||||
const prompt = client.session?.prompt
|
const session = client.session
|
||||||
|
|
||||||
if (typeof prompt !== "function") {
|
if (typeof session?.prompt !== "function") {
|
||||||
log("[prompt-async-gate] prompt unavailable", { sessionID, source })
|
log("[prompt-async-gate] prompt unavailable", { sessionID, source })
|
||||||
return { status: "unavailable" }
|
return { status: "unavailable" }
|
||||||
}
|
}
|
||||||
|
const dispatchPrompt = session.prompt.bind(session)
|
||||||
|
|
||||||
return dispatchAfterSessionIdle({
|
return dispatchAfterSessionIdle({
|
||||||
sessionName: "prompt",
|
sessionName: "prompt",
|
||||||
@@ -274,7 +276,7 @@ export async function promptAfterSessionIdle<TInput = PromptAsyncInput>(args: {
|
|||||||
postDispatchHoldMs,
|
postDispatchHoldMs,
|
||||||
dispatchTimeoutMs,
|
dispatchTimeoutMs,
|
||||||
checkStatus: args.checkStatus !== false,
|
checkStatus: args.checkStatus !== false,
|
||||||
dispatch: (dispatchInput) => prompt(dispatchInput),
|
dispatch: (dispatchInput) => dispatchPrompt(dispatchInput),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
|
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
|
||||||
const { describe, test, expect, mock } = require("bun:test")
|
import { describe, test, expect, mock } from "bun:test"
|
||||||
|
|
||||||
type ExecuteSync = typeof import("./sync-executor").executeSync
|
type ExecuteSync = typeof import("./sync-executor").executeSync
|
||||||
|
|
||||||
@@ -13,6 +13,7 @@ type PromptAsyncInput = {
|
|||||||
variant?: string
|
variant?: string
|
||||||
temperature?: number
|
temperature?: number
|
||||||
topP?: number
|
topP?: number
|
||||||
|
maxOutputTokens?: number
|
||||||
options?: Record<string, unknown>
|
options?: Record<string, unknown>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -342,6 +343,64 @@ describe("executeSync", () => {
|
|||||||
expect(deps.setSessionFallbackChain).toHaveBeenCalledWith("ses-fallback", fallbackChain)
|
expect(deps.setSessionFallbackChain).toHaveBeenCalledWith("ses-fallback", fallbackChain)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("registers child-session bootstrap and tracked prompt state before sync prompt dispatch", async () => {
|
||||||
|
//#given
|
||||||
|
const executeSync = await importExecuteSync()
|
||||||
|
const { _resetForTesting, getSessionAgent } = require("../../features/claude-code-session-state")
|
||||||
|
const { clearAllDelegatedChildSessionBootstrap, getDelegatedChildSessionBootstrap } = require("../../shared/delegated-child-session-bootstrap")
|
||||||
|
const { clearSessionTools, getSessionTools } = require("../../shared/session-tools-store")
|
||||||
|
const deps = createDependencies({
|
||||||
|
createOrGetSession: mock(async () => ({ sessionID: "ses-call-bootstrap", isNew: true })),
|
||||||
|
})
|
||||||
|
const toolContext = createToolContext()
|
||||||
|
const observed: Array<{
|
||||||
|
agent: string | undefined
|
||||||
|
tools: Record<string, boolean> | undefined
|
||||||
|
bootstrap: ReturnType<typeof getDelegatedChildSessionBootstrap>
|
||||||
|
}> = []
|
||||||
|
const recorder = createPromptAsyncRecorder(async () => {
|
||||||
|
observed.push({
|
||||||
|
agent: getSessionAgent("ses-call-bootstrap"),
|
||||||
|
tools: getSessionTools("ses-call-bootstrap"),
|
||||||
|
bootstrap: getDelegatedChildSessionBootstrap("ses-call-bootstrap"),
|
||||||
|
})
|
||||||
|
return { data: {} }
|
||||||
|
})
|
||||||
|
const args = {
|
||||||
|
subagent_type: "explore",
|
||||||
|
description: "bootstrap state",
|
||||||
|
prompt: "collect bootstrap evidence",
|
||||||
|
run_in_background: false,
|
||||||
|
}
|
||||||
|
const fallbackChain = [
|
||||||
|
{ providers: ["openai"], model: "gpt-5.4", variant: "high" },
|
||||||
|
]
|
||||||
|
|
||||||
|
try {
|
||||||
|
//#when
|
||||||
|
await executeSync(
|
||||||
|
args,
|
||||||
|
toolContext,
|
||||||
|
createContext(recorder.promptAsync) as never,
|
||||||
|
deps,
|
||||||
|
fallbackChain
|
||||||
|
)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(observed[0]?.agent).toBe("explore")
|
||||||
|
expect(observed[0]?.tools?.question).toBe(false)
|
||||||
|
expect(observed[0]?.tools?.task).toBe(false)
|
||||||
|
expect(observed[0]?.bootstrap?.retryParts[0]?.text).toContain("collect bootstrap evidence")
|
||||||
|
expect(observed[0]?.bootstrap?.tools?.question).toBe(false)
|
||||||
|
expect(observed[0]?.bootstrap?.fallbackChain?.[0]?.model).toBe("gpt-5.4")
|
||||||
|
expect(getDelegatedChildSessionBootstrap("ses-call-bootstrap")).toBeUndefined()
|
||||||
|
} finally {
|
||||||
|
clearAllDelegatedChildSessionBootstrap()
|
||||||
|
clearSessionTools()
|
||||||
|
_resetForTesting()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
test("returns dedicated agent-not-found error with task metadata", async () => {
|
test("returns dedicated agent-not-found error with task metadata", async () => {
|
||||||
//#given
|
//#given
|
||||||
const executeSync = await importExecuteSync()
|
const executeSync = await importExecuteSync()
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
import type { CallOmoAgentArgs } from "./types"
|
|
||||||
import type { PluginInput } from "@opencode-ai/plugin"
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
import { subagentSessions, syncSubagentSessions } from "../../features/claude-code-session-state"
|
import { setSessionAgent, subagentSessions, syncSubagentSessions } from "../../features/claude-code-session-state"
|
||||||
import { getAgentToolRestrictions, log } from "../../shared"
|
|
||||||
import { applySessionPromptParams } from "../../shared/session-prompt-params-helpers"
|
|
||||||
import type { DelegatedModelConfig } from "../../shared/model-resolution-types"
|
|
||||||
import type { FallbackEntry } from "../../shared/model-requirements"
|
|
||||||
import { getAgentDisplayName, stripAgentListSortPrefix } from "../../shared/agent-display-names"
|
|
||||||
import { promptAsyncAfterSessionIdle } from "../../hooks/shared/prompt-async-gate"
|
import { promptAsyncAfterSessionIdle } from "../../hooks/shared/prompt-async-gate"
|
||||||
|
import { getAgentToolRestrictions, log } from "../../shared"
|
||||||
|
import { getAgentDisplayName, stripAgentListSortPrefix } from "../../shared/agent-display-names"
|
||||||
|
import {
|
||||||
|
clearDelegatedChildSessionBootstrap,
|
||||||
|
registerDelegatedChildSessionBootstrap,
|
||||||
|
} from "../../shared/delegated-child-session-bootstrap"
|
||||||
|
import type { FallbackEntry } from "../../shared/model-requirements"
|
||||||
|
import type { DelegatedModelConfig } from "../../shared/model-resolution-types"
|
||||||
|
import { applySessionPromptParams } from "../../shared/session-prompt-params-helpers"
|
||||||
|
import { deleteSessionTools, setSessionTools } from "../../shared/session-tools-store"
|
||||||
import { waitForCompletion } from "./completion-poller"
|
import { waitForCompletion } from "./completion-poller"
|
||||||
import { processMessages } from "./message-processor"
|
import { processMessages } from "./message-processor"
|
||||||
import { createOrGetSession } from "./session-creator"
|
import { createOrGetSession } from "./session-creator"
|
||||||
|
import type { CallOmoAgentArgs } from "./types"
|
||||||
|
|
||||||
type SessionWithPromptAsync = {
|
type SessionWithPromptAsync = {
|
||||||
promptAsync: (opts: { path: { id: string }; body: Record<string, unknown> }) => Promise<unknown>
|
promptAsync: (opts: { path: { id: string }; body: Record<string, unknown> }) => Promise<unknown>
|
||||||
@@ -58,6 +63,14 @@ function buildPromptGenerationParams(model: DelegatedModelConfig | undefined): R
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildSyncPromptTools(agent: string): Record<string, boolean> {
|
||||||
|
return {
|
||||||
|
...getAgentToolRestrictions(agent),
|
||||||
|
task: false,
|
||||||
|
question: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function executeSync(
|
export async function executeSync(
|
||||||
args: CallOmoAgentArgs,
|
args: CallOmoAgentArgs,
|
||||||
toolContext: {
|
toolContext: {
|
||||||
@@ -105,6 +118,16 @@ export async function executeSync(
|
|||||||
log(`[call_omo_agent] Sending prompt to session ${sessionID}`)
|
log(`[call_omo_agent] Sending prompt to session ${sessionID}`)
|
||||||
log(`[call_omo_agent] Prompt text:`, args.prompt.substring(0, 100))
|
log(`[call_omo_agent] Prompt text:`, args.prompt.substring(0, 100))
|
||||||
const normalizedSubagentType = stripAgentListSortPrefix(args.subagent_type)
|
const normalizedSubagentType = stripAgentListSortPrefix(args.subagent_type)
|
||||||
|
const promptAgent = getAgentDisplayName(normalizedSubagentType)
|
||||||
|
const promptTools = buildSyncPromptTools(normalizedSubagentType)
|
||||||
|
setSessionAgent(sessionID, promptAgent)
|
||||||
|
setSessionTools(sessionID, promptTools)
|
||||||
|
registerDelegatedChildSessionBootstrap({
|
||||||
|
sessionID,
|
||||||
|
promptText: args.prompt,
|
||||||
|
fallbackChain,
|
||||||
|
tools: promptTools,
|
||||||
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!hasPromptAsync(ctx.client.session)) {
|
if (!hasPromptAsync(ctx.client.session)) {
|
||||||
@@ -119,12 +142,8 @@ export async function executeSync(
|
|||||||
input: {
|
input: {
|
||||||
path: { id: sessionID },
|
path: { id: sessionID },
|
||||||
body: {
|
body: {
|
||||||
agent: getAgentDisplayName(normalizedSubagentType),
|
agent: promptAgent,
|
||||||
tools: {
|
tools: promptTools,
|
||||||
...getAgentToolRestrictions(normalizedSubagentType),
|
|
||||||
task: false,
|
|
||||||
question: false,
|
|
||||||
},
|
|
||||||
parts: [{ type: "text", text: args.prompt }],
|
parts: [{ type: "text", text: args.prompt }],
|
||||||
...(model ? { model: { providerID: model.providerID, modelID: model.modelID } } : {}),
|
...(model ? { model: { providerID: model.providerID, modelID: model.modelID } } : {}),
|
||||||
...(model?.variant ? { variant: model.variant } : {}),
|
...(model?.variant ? { variant: model.variant } : {}),
|
||||||
@@ -160,9 +179,14 @@ export async function executeSync(
|
|||||||
deps.clearSessionFallbackChain(sessionID)
|
deps.clearSessionFallbackChain(sessionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (sessionID) {
|
||||||
|
clearDelegatedChildSessionBootstrap(sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
if (sessionID && createdSessionForExecution) {
|
if (sessionID && createdSessionForExecution) {
|
||||||
subagentSessions.delete(sessionID)
|
subagentSessions.delete(sessionID)
|
||||||
syncSubagentSessions.delete(sessionID)
|
syncSubagentSessions.delete(sessionID)
|
||||||
|
deleteSessionTools(sessionID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import type { DelegateTaskArgs, OpencodeClient, DelegatedModelConfig } from "./types"
|
|
||||||
import type { SisyphusAgentConfig } from "../../config/schema"
|
import type { SisyphusAgentConfig } from "../../config/schema"
|
||||||
import { isPlanFamily } from "./constants"
|
import { stripInvisibleAgentCharacters } from "../../shared/agent-display-names"
|
||||||
import { buildTaskPrompt } from "./prompt-builder"
|
import { getAgentToolRestrictions } from "../../shared/agent-tool-restrictions"
|
||||||
|
import { createInternalAgentTextPart } from "../../shared/internal-initiator-marker"
|
||||||
import {
|
import {
|
||||||
promptSyncWithModelSuggestionRetry,
|
promptSyncWithModelSuggestionRetry,
|
||||||
promptWithModelSuggestionRetry,
|
promptWithModelSuggestionRetry,
|
||||||
} from "../../shared/model-suggestion-retry"
|
} from "../../shared/model-suggestion-retry"
|
||||||
import { routePromptRetry, routePromptSyncRetry } from "../../shared/session-route"
|
|
||||||
import { formatDetailedError } from "./error-formatting"
|
|
||||||
import { getAgentToolRestrictions } from "../../shared/agent-tool-restrictions"
|
|
||||||
import { stripInvisibleAgentCharacters } from "../../shared/agent-display-names"
|
|
||||||
import { applySessionPromptParams } from "../../shared/session-prompt-params-helpers"
|
import { applySessionPromptParams } from "../../shared/session-prompt-params-helpers"
|
||||||
|
import { routePromptRetry, routePromptSyncRetry } from "../../shared/session-route"
|
||||||
import { setSessionTools } from "../../shared/session-tools-store"
|
import { setSessionTools } from "../../shared/session-tools-store"
|
||||||
import { createInternalAgentTextPart } from "../../shared/internal-initiator-marker"
|
import { isPlanFamily } from "./constants"
|
||||||
|
import { formatDetailedError } from "./error-formatting"
|
||||||
|
import { buildTaskPrompt } from "./prompt-builder"
|
||||||
|
import type { DelegatedModelConfig, DelegateTaskArgs, OpencodeClient } from "./types"
|
||||||
|
|
||||||
type SendSyncPromptDeps = {
|
type SendSyncPromptDeps = {
|
||||||
promptWithModelSuggestionRetry: typeof promptWithModelSuggestionRetry
|
promptWithModelSuggestionRetry: typeof promptWithModelSuggestionRetry
|
||||||
@@ -52,6 +52,15 @@ function isUnexpectedEofError(error: unknown): boolean {
|
|||||||
return lowered.includes("unexpected eof") || lowered.includes("json parse error")
|
return lowered.includes("unexpected eof") || lowered.includes("json parse error")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildSyncPromptTools(agentToUse: string): Record<string, boolean> {
|
||||||
|
return {
|
||||||
|
task: isPlanFamily(agentToUse),
|
||||||
|
call_omo_agent: true,
|
||||||
|
question: false,
|
||||||
|
...getAgentToolRestrictions(agentToUse),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function sendSyncPrompt(
|
export async function sendSyncPrompt(
|
||||||
client: OpencodeClient,
|
client: OpencodeClient,
|
||||||
input: {
|
input: {
|
||||||
@@ -67,15 +76,9 @@ export async function sendSyncPrompt(
|
|||||||
},
|
},
|
||||||
deps: SendSyncPromptDeps = sendSyncPromptDeps
|
deps: SendSyncPromptDeps = sendSyncPromptDeps
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const allowTask = isPlanFamily(input.agentToUse)
|
|
||||||
const tddEnabled = input.sisyphusAgentConfig?.tdd
|
const tddEnabled = input.sisyphusAgentConfig?.tdd
|
||||||
const effectivePrompt = buildTaskPrompt(input.args.prompt, input.agentToUse, tddEnabled)
|
const effectivePrompt = buildTaskPrompt(input.args.prompt, input.agentToUse, tddEnabled)
|
||||||
const tools = {
|
const tools = buildSyncPromptTools(input.agentToUse)
|
||||||
task: allowTask,
|
|
||||||
call_omo_agent: true,
|
|
||||||
question: false,
|
|
||||||
...getAgentToolRestrictions(input.agentToUse),
|
|
||||||
}
|
|
||||||
setSessionTools(input.sessionID, tools)
|
setSessionTools(input.sessionID, tools)
|
||||||
|
|
||||||
applySessionPromptParams(input.sessionID, input.categoryModel)
|
applySessionPromptParams(input.sessionID, input.categoryModel)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const { describe, test, expect, beforeEach, afterEach, mock, spyOn } = require("bun:test")
|
import { describe, test, expect, beforeEach, afterEach, mock, spyOn } from "bun:test"
|
||||||
|
|
||||||
function clearRequireCache(modulePath: string): void {
|
function clearRequireCache(modulePath: string): void {
|
||||||
const resolvedPath = require.resolve(modulePath)
|
const resolvedPath = require.resolve(modulePath)
|
||||||
@@ -27,6 +27,8 @@ describe("executeSyncTask - cleanup on error paths", () => {
|
|||||||
addTaskCalls = []
|
addTaskCalls = []
|
||||||
deleteCalls = []
|
deleteCalls = []
|
||||||
addCalls = []
|
addCalls = []
|
||||||
|
const { clearAllDelegatedChildSessionBootstrap } = require("../../shared/delegated-child-session-bootstrap")
|
||||||
|
clearAllDelegatedChildSessionBootstrap()
|
||||||
|
|
||||||
clearRequireCache("./sync-task")
|
clearRequireCache("./sync-task")
|
||||||
|
|
||||||
@@ -62,6 +64,8 @@ describe("executeSyncTask - cleanup on error paths", () => {
|
|||||||
mock.restore()
|
mock.restore()
|
||||||
resetToastManager?.()
|
resetToastManager?.()
|
||||||
resetToastManager = null
|
resetToastManager = null
|
||||||
|
const { clearAllDelegatedChildSessionBootstrap } = require("../../shared/delegated-child-session-bootstrap")
|
||||||
|
clearAllDelegatedChildSessionBootstrap()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("cleans up toast and subagentSessions when fetchSyncResult returns ok: false", async () => {
|
test("cleans up toast and subagentSessions when fetchSyncResult returns ok: false", async () => {
|
||||||
@@ -664,6 +668,69 @@ describe("executeSyncTask - cleanup on error paths", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("registers child-session bootstrap before sync prompt and clears it after completion", async () => {
|
||||||
|
const mockClient = {
|
||||||
|
session: {
|
||||||
|
create: async () => ({ data: { id: "ignored" } }),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const { executeSyncTask } = require("./sync-task")
|
||||||
|
const { getDelegatedChildSessionBootstrap } = require("../../shared/delegated-child-session-bootstrap")
|
||||||
|
const observedBootstrapPrompts: string[] = []
|
||||||
|
const observedBootstrapSystems: Array<string | undefined> = []
|
||||||
|
const observedBootstrapTools: Array<Record<string, boolean> | undefined> = []
|
||||||
|
|
||||||
|
const deps = {
|
||||||
|
createSyncSession: async () => ({ ok: true as const, sessionID: "ses_bootstrap_sync" }),
|
||||||
|
sendSyncPrompt: async (_client: unknown, input: { sessionID: string }) => {
|
||||||
|
const bootstrap = getDelegatedChildSessionBootstrap(input.sessionID)
|
||||||
|
observedBootstrapPrompts.push(bootstrap?.retryParts[0]?.text ?? "")
|
||||||
|
observedBootstrapSystems.push(bootstrap?.system)
|
||||||
|
observedBootstrapTools.push(bootstrap?.tools)
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
pollSyncSession: async () => null,
|
||||||
|
fetchSyncResult: async () => ({ ok: true as const, textContent: "sync result" }),
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockCtx = {
|
||||||
|
sessionID: "parent-session",
|
||||||
|
callID: "call-123",
|
||||||
|
metadata: () => {},
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockExecutorCtx = {
|
||||||
|
client: mockClient,
|
||||||
|
directory: "/tmp",
|
||||||
|
onSyncSessionCreated: null,
|
||||||
|
modelFallbackControllerAccessor: {
|
||||||
|
setSessionFallbackChain: () => {},
|
||||||
|
clearSessionFallbackChain: () => {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = {
|
||||||
|
prompt: "sync bootstrap prompt",
|
||||||
|
description: "sync bootstrap task",
|
||||||
|
category: "quick",
|
||||||
|
load_skills: [],
|
||||||
|
run_in_background: false,
|
||||||
|
command: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await executeSyncTask(args, mockCtx, mockExecutorCtx, {
|
||||||
|
sessionID: "parent-session",
|
||||||
|
}, "sisyphus-junior", undefined, "sync delegated skill system", undefined, undefined, deps)
|
||||||
|
|
||||||
|
expect(result).toContain("sync result")
|
||||||
|
expect(observedBootstrapPrompts[0]).toContain("sync bootstrap prompt")
|
||||||
|
expect(observedBootstrapSystems[0]).toBe("sync delegated skill system")
|
||||||
|
expect(observedBootstrapTools[0]?.question).toBe(false)
|
||||||
|
expect(observedBootstrapTools[0]?.call_omo_agent).toBe(true)
|
||||||
|
expect(getDelegatedChildSessionBootstrap("ses_bootstrap_sync")).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
test("replays sync session side effects for retry-created sessions", async () => {
|
test("replays sync session side effects for retry-created sessions", async () => {
|
||||||
const mockClient = {
|
const mockClient = {
|
||||||
session: {
|
session: {
|
||||||
|
|||||||
@@ -1,19 +1,25 @@
|
|||||||
import type { ModelFallbackInfo } from "../../features/task-toast-manager/types"
|
import { setSessionAgent, subagentSessions, syncSubagentSessions } from "../../features/claude-code-session-state"
|
||||||
import type { DelegateTaskArgs, ToolContextWithMetadata, DelegatedModelConfig } from "./types"
|
|
||||||
import type { ExecutorContext, ParentContext } from "./executor-types"
|
|
||||||
import { getTaskToastManager } from "../../features/task-toast-manager"
|
import { getTaskToastManager } from "../../features/task-toast-manager"
|
||||||
|
import type { ModelFallbackInfo } from "../../features/task-toast-manager/types"
|
||||||
import { publishToolMetadata } from "../../features/tool-metadata-store"
|
import { publishToolMetadata } from "../../features/tool-metadata-store"
|
||||||
import { subagentSessions, syncSubagentSessions, setSessionAgent } from "../../features/claude-code-session-state"
|
|
||||||
import { log } from "../../shared/logger"
|
|
||||||
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
|
|
||||||
import { formatDuration } from "./time-formatter"
|
|
||||||
import { formatDetailedError } from "./error-formatting"
|
|
||||||
import { syncTaskDeps, type SyncTaskDeps } from "./sync-task-deps"
|
|
||||||
import { getNextSyncFallbackModel, retrySyncPromptWithFallbacks } from "./sync-task-fallback"
|
|
||||||
import { buildTaskMetadataBlock } from "../../features/tool-metadata-store/task-metadata-contract"
|
import { buildTaskMetadataBlock } from "../../features/tool-metadata-store/task-metadata-contract"
|
||||||
import { resolveMetadataModel } from "./resolve-metadata-model"
|
|
||||||
import { shouldRetryError } from "../../shared/model-error-classifier"
|
|
||||||
import type { ModelFallbackState } from "../../hooks/model-fallback/hook"
|
import type { ModelFallbackState } from "../../hooks/model-fallback/hook"
|
||||||
|
import {
|
||||||
|
clearDelegatedChildSessionBootstrap,
|
||||||
|
registerDelegatedChildSessionBootstrap,
|
||||||
|
} from "../../shared/delegated-child-session-bootstrap"
|
||||||
|
import { log } from "../../shared/logger"
|
||||||
|
import { shouldRetryError } from "../../shared/model-error-classifier"
|
||||||
|
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
|
||||||
|
import { formatDetailedError } from "./error-formatting"
|
||||||
|
import type { ExecutorContext, ParentContext } from "./executor-types"
|
||||||
|
import { buildTaskPrompt } from "./prompt-builder"
|
||||||
|
import { resolveMetadataModel } from "./resolve-metadata-model"
|
||||||
|
import { buildSyncPromptTools } from "./sync-prompt-sender"
|
||||||
|
import { type SyncTaskDeps, syncTaskDeps } from "./sync-task-deps"
|
||||||
|
import { getNextSyncFallbackModel, retrySyncPromptWithFallbacks } from "./sync-task-fallback"
|
||||||
|
import { formatDuration } from "./time-formatter"
|
||||||
|
import type { DelegatedModelConfig, DelegateTaskArgs, ToolContextWithMetadata } from "./types"
|
||||||
|
|
||||||
function shouldAttemptPollErrorRecovery(pollError: string): boolean {
|
function shouldAttemptPollErrorRecovery(pollError: string): boolean {
|
||||||
const trimmed = pollError.trim()
|
const trimmed = pollError.trim()
|
||||||
@@ -107,11 +113,15 @@ export async function executeSyncTask(
|
|||||||
subagentSessions.add(newSessionID)
|
subagentSessions.add(newSessionID)
|
||||||
syncSubagentSessions.add(newSessionID)
|
syncSubagentSessions.add(newSessionID)
|
||||||
setSessionAgent(newSessionID, agentToUse)
|
setSessionAgent(newSessionID, agentToUse)
|
||||||
executorCtx.modelFallbackControllerAccessor?.setSessionFallbackChain(newSessionID, fallbackChain)
|
registerDelegatedChildSessionBootstrap({
|
||||||
|
sessionID: newSessionID,
|
||||||
if (args.category) {
|
promptText: buildTaskPrompt(args.prompt, agentToUse, executorCtx.sisyphusAgentConfig?.tdd),
|
||||||
SessionCategoryRegistry.register(newSessionID, args.category)
|
fallbackChain,
|
||||||
}
|
category: args.category,
|
||||||
|
system: systemContent,
|
||||||
|
tools: buildSyncPromptTools(agentToUse),
|
||||||
|
modelFallbackControllerAccessor: executorCtx.modelFallbackControllerAccessor,
|
||||||
|
})
|
||||||
|
|
||||||
if (onSyncSessionCreated) {
|
if (onSyncSessionCreated) {
|
||||||
log("[task] Invoking onSyncSessionCreated callback", { sessionID: newSessionID, parentID: parentContext.sessionID })
|
log("[task] Invoking onSyncSessionCreated callback", { sessionID: newSessionID, parentID: parentContext.sessionID })
|
||||||
@@ -131,7 +141,6 @@ export async function executeSyncTask(
|
|||||||
const publishSyncMetadata = async (
|
const publishSyncMetadata = async (
|
||||||
currentSessionID: string,
|
currentSessionID: string,
|
||||||
currentModel: DelegatedModelConfig | undefined,
|
currentModel: DelegatedModelConfig | undefined,
|
||||||
currentTaskId: string,
|
|
||||||
spawnDepth: number,
|
spawnDepth: number,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
await publishToolMetadata(ctx, {
|
await publishToolMetadata(ctx, {
|
||||||
@@ -171,7 +180,7 @@ export async function executeSyncTask(
|
|||||||
modelInfo,
|
modelInfo,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
await publishSyncMetadata(sessionID, categoryModel, taskId, spawnContext.childDepth)
|
await publishSyncMetadata(sessionID, categoryModel, spawnContext.childDepth)
|
||||||
|
|
||||||
const syncPromptInput = {
|
const syncPromptInput = {
|
||||||
sessionID,
|
sessionID,
|
||||||
@@ -199,6 +208,7 @@ export async function executeSyncTask(
|
|||||||
const cleanupRetrySession = (currentSessionID: string): void => {
|
const cleanupRetrySession = (currentSessionID: string): void => {
|
||||||
subagentSessions.delete(currentSessionID)
|
subagentSessions.delete(currentSessionID)
|
||||||
syncSubagentSessions.delete(currentSessionID)
|
syncSubagentSessions.delete(currentSessionID)
|
||||||
|
clearDelegatedChildSessionBootstrap(currentSessionID)
|
||||||
executorCtx.modelFallbackControllerAccessor?.clearSessionFallbackChain(currentSessionID)
|
executorCtx.modelFallbackControllerAccessor?.clearSessionFallbackChain(currentSessionID)
|
||||||
SessionCategoryRegistry.remove(currentSessionID)
|
SessionCategoryRegistry.remove(currentSessionID)
|
||||||
}
|
}
|
||||||
@@ -304,7 +314,7 @@ export async function executeSyncTask(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (taskId) {
|
if (taskId) {
|
||||||
await publishSyncMetadata(activeSessionID, effectiveCategoryModel, taskId, spawnContext.childDepth)
|
await publishSyncMetadata(activeSessionID, effectiveCategoryModel, spawnContext.childDepth)
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -329,7 +339,7 @@ export async function executeSyncTask(
|
|||||||
modelRoutingNote = `\nModel: ${actualModelStr}${args.category ? ` (category: ${args.category})` : ""}`
|
modelRoutingNote = `\nModel: ${actualModelStr}${args.category ? ` (category: ${args.category})` : ""}`
|
||||||
}
|
}
|
||||||
|
|
||||||
await publishSyncMetadata(activeSessionID, effectiveCategoryModel, taskId!, spawnContext.childDepth)
|
await publishSyncMetadata(activeSessionID, effectiveCategoryModel, spawnContext.childDepth)
|
||||||
|
|
||||||
return `Task completed in ${duration}.
|
return `Task completed in ${duration}.
|
||||||
|
|
||||||
@@ -364,6 +374,7 @@ ${buildTaskMetadataBlock({
|
|||||||
if (syncSessionID) {
|
if (syncSessionID) {
|
||||||
subagentSessions.delete(syncSessionID)
|
subagentSessions.delete(syncSessionID)
|
||||||
syncSubagentSessions.delete(syncSessionID)
|
syncSubagentSessions.delete(syncSessionID)
|
||||||
|
clearDelegatedChildSessionBootstrap(syncSessionID)
|
||||||
executorCtx.modelFallbackControllerAccessor?.clearSessionFallbackChain(syncSessionID)
|
executorCtx.modelFallbackControllerAccessor?.clearSessionFallbackChain(syncSessionID)
|
||||||
SessionCategoryRegistry.remove(syncSessionID)
|
SessionCategoryRegistry.remove(syncSessionID)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user