import { afterEach, describe, expect, it, vi } from "bun:test"; import type { ModelRegistry } from "../../src/config/model-registry"; import { Settings } from "../../src/config/settings"; import type { LoadExtensionsResult } from "../../src/extensibility/extensions/types"; import type { CreateAgentSessionResult } from "../../src/sdk"; import * as sdkModule from "../../src/sdk"; import type { AgentSession, AgentSessionEvent, PromptOptions } from "../../src/session/agent-session"; import { runSubprocess } from "../../src/task/executor"; import type { AgentDefinition } from "../../src/task/types"; import { EventBus } from "../../src/utils/event-bus"; /** * Contract: when `task.maxRuntimeMs` is set, a subagent whose inference call * never resolves (provider stream hang the watchdog couldn't catch) MUST be * aborted within ~maxRuntimeMs and surface a clear "runtime limit exceeded" * reason — not a generic "Cancelled by caller" — so on-call engineers don't * mistake it for a user cancellation. * * Without this defense, the executor's `await session.waitForIdle()` waits * indefinitely (see session 019e2b4d-fa25-7000-a725-955278e9b293, subagent 7, * which stayed silent for ~2 hours). */ interface HangingSessionHandle { session: AgentSession; abortCalls: () => number; } function createHangingSession(): HangingSessionHandle { let abortCount = 0; const { promise: hang, resolve: releaseHang } = Promise.withResolvers(); const session: Partial = { state: { messages: [] } as never, agent: { state: { systemPrompt: ["test"] } } as never, extensionRunner: undefined as never, sessionManager: { appendSessionInit: () => {}, } as never, getActiveToolNames: () => ["read", "yield"], setActiveToolsByName: async (_names: string[]) => {}, subscribe: (_listener: (event: AgentSessionEvent) => void) => () => {}, prompt: async (_text: string, _options?: PromptOptions) => { await hang; }, waitForIdle: async () => { await hang; }, getLastAssistantMessage: () => undefined, abort: async () => { abortCount += 1; releaseHang(); }, dispose: async () => {}, }; return { session: session as AgentSession, abortCalls: () => abortCount, }; } function mockCreateAgentSession(session: AgentSession) { return vi.spyOn(sdkModule, "createAgentSession").mockResolvedValue({ session, extensionsResult: {} as unknown as LoadExtensionsResult, setToolUIContext: () => {}, eventBus: new EventBus(), } satisfies CreateAgentSessionResult); } describe("runSubprocess wall clock (task.maxRuntimeMs)", () => { afterEach(() => { vi.restoreAllMocks(); }); const baseAgent: AgentDefinition = { name: "task", description: "test", systemPrompt: "test", source: "bundled", }; const baseOptions = { cwd: "/tmp", agent: baseAgent, task: "do work", index: 0, id: "subagent-walltime", modelRegistry: { refresh: async () => {} } as unknown as ModelRegistry, enableLsp: false, }; it("aborts a stalled subagent and surfaces a runtime-limit reason", async () => { const settings = Settings.isolated({ "task.maxRuntimeMs": 50 }); const handle = createHangingSession(); mockCreateAgentSession(handle.session); const startedAt = Date.now(); const result = await runSubprocess({ ...baseOptions, id: "subagent-timeout", settings, }); const elapsedMs = Date.now() - startedAt; expect(result.aborted).toBe(true); expect(result.exitCode).toBe(1); expect(result.abortReason).toContain("runtime limit exceeded"); expect(result.abortReason).toContain("task.maxRuntimeMs=50"); expect(handle.abortCalls()).toBeGreaterThanOrEqual(1); // Sanity: must finish in roughly the configured window (allow generous slack // for CI; the contract is "doesn't hang for hours", not "exactly 50 ms"). expect(elapsedMs).toBeLessThan(10_000); }); it("does not abort early when the runtime budget is unlimited", async () => { // Stub session resolves immediately to a no-op yield so we don't actually // hang; we only need to assert that NO timeout fires when maxRuntimeMs=0. const settings = Settings.isolated({ "task.maxRuntimeMs": 0 }); const fastSession: Partial = { state: { messages: [] } as never, agent: { state: { systemPrompt: ["test"] } } as never, extensionRunner: undefined as never, sessionManager: { appendSessionInit: () => {} } as never, getActiveToolNames: () => ["read", "yield"], setActiveToolsByName: async () => {}, subscribe: (listener: (event: AgentSessionEvent) => void) => { // Fire a synthetic yield on the next tick to drive runSubprocess to // completion without depending on the real agent loop. queueMicrotask(() => { listener({ type: "tool_execution_end", toolCallId: "tool-fast", toolName: "yield", result: { content: [{ type: "text", text: "Result submitted." }], details: { status: "success", data: { ok: true } }, }, isError: false, } as AgentSessionEvent); }); return () => {}; }, prompt: async () => {}, waitForIdle: async () => {}, getLastAssistantMessage: () => undefined, abort: async () => {}, dispose: async () => {}, }; mockCreateAgentSession(fastSession as AgentSession); const result = await runSubprocess({ ...baseOptions, id: "subagent-no-limit", settings, }); expect(result.aborted).toBe(false); expect(result.abortReason).toBeUndefined(); }); });