diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 30448e514..da7b4bf4f 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -44,6 +44,9 @@ - Fixed legacy plugin extensions failing to load on Windows when they import a bare-specifier dependency from their own `node_modules` (e.g. `import YAML from "yaml"` in `supipowers`). The legacy-pi mirror resolved the dependency to its absolute path and then ran the path through `isUrlLikeSpecifier`, whose `^[A-Za-z][A-Za-z\d+.-]*:` regex matched the Windows drive letter (`C:`) and short-circuited the `pathToFileURL` conversion. The raw path was emitted into the mirrored TS source as `import x from "C:\\Users\\...\\dep\\dist\\index.js"`, where `\n`, `\U`, `\y` and other backslash sequences were eaten by the TS string-literal parser, producing nonsense package specifiers like `C:Usersjames.ompagentextensionssupipowers\node_modulesyamldistindex.js` that Bun's resolver rejected with `Cannot find package …`. `isUrlLikeSpecifier` now rejects `^[A-Za-z]:[\\/]` first, so Windows absolute paths flow through `pathToFileURL` like every other absolute path and reach the mirror as proper `file:///C:/...` URLs. - Fixed Python session queued executions silently resurrecting kernels after `disposeAllKernelSessions` or `disposeKernelSessionsByOwner` removed the session: queued work now checks the session is still registered before replacing or executing on a kernel and rejects with cancellation otherwise - Fixed Python session disposal treating an unconfirmed `PythonKernel.shutdown()` result as success: sessions whose kernel shutdown returns `{ confirmed: false }` (or rejects) are now retained in the registry and a `warn` is logged so a later dispose can retry instead of orphaning the subprocess +- Fixed `task.maxRuntimeMs` losing wall-clock aborts that fired during pre-prompt session setup by re-checking the abort signal immediately before issuing the model prompt, so a stalled subagent now exits with the runtime-limit reason instead of hanging through setup races +- Fixed late `yield` events landing after a wall-clock timeout from flipping a timed-out subagent to a successful exit, so the reported `aborted` flag and exit code now always reflect the runtime-limit breach while yield payloads remain in `extractedToolData` +- Fixed async-task progress consumer to copy `contextTokens` and `contextWindow` from the completed `SingleResult` onto `AgentProgress`, so UI gauges keep showing per-turn context after a backgrounded task finishes ## [15.0.2] - 2026-05-15 diff --git a/packages/coding-agent/src/task/executor.ts b/packages/coding-agent/src/task/executor.ts index b4ac92b5f..b625ce9cf 100644 --- a/packages/coding-agent/src/task/executor.ts +++ b/packages/coding-agent/src/task/executor.ts @@ -1162,6 +1162,12 @@ export async function runSubprocess(options: ExecutorOptions): Promise { expect(result.aborted).toBe(false); expect(result.abortReason).toBeUndefined(); }); + + it("aborts before prompting when the timer fires during session setup", async () => { + // Delay createAgentSession longer than maxRuntimeMs so the wall-clock + // timer fires while the executor is still doing async setup, well before + // it ever calls session.prompt(). The fix must observe abortSignal + // immediately before prompting and return the runtime-limit result. + const settings = Settings.isolated({ "task.maxRuntimeMs": 30 }); + const handle = createHangingSession(); + let promptCalls = 0; + const originalPrompt = handle.session.prompt; + handle.session.prompt = async (text, options) => { + promptCalls += 1; + return originalPrompt.call(handle.session, text, options); + }; + vi.spyOn(sdkModule, "createAgentSession").mockImplementation(async () => { + await new Promise(resolve => setTimeout(resolve, 200)); + return { + session: handle.session, + extensionsResult: {} as unknown as LoadExtensionsResult, + setToolUIContext: () => {}, + eventBus: new EventBus(), + } satisfies CreateAgentSessionResult; + }); + + const result = await runSubprocess({ + ...baseOptions, + id: "subagent-setup-timeout", + settings, + }); + + expect(result.aborted).toBe(true); + expect(result.exitCode).toBe(1); + expect(result.abortReason).toContain("runtime limit exceeded"); + expect(result.abortReason).toContain("task.maxRuntimeMs=30"); + // The whole point: we never reached session.prompt(), because the abort + // was observed before issuing the model call. + expect(promptCalls).toBe(0); + }); + + it("a late successful yield does not flip a timed-out run to success", async () => { + // A hung subagent emits a successful `yield` event during teardown (after + // the timer has already aborted). Without the fix, `hasYield=true` would + // make finalizeSubprocessOutput zero the exit code and `wasAborted` + // would resolve to false — silently masking the runtime-limit breach. + const settings = Settings.isolated({ "task.maxRuntimeMs": 30 }); + const { promise: hang, resolve: releaseHang } = Promise.withResolvers(); + let listenerRef: ((event: AgentSessionEvent) => void) | undefined; + let abortCount = 0; + 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 () => {}, + subscribe: (listener: (event: AgentSessionEvent) => void) => { + listenerRef = listener; + return () => {}; + }, + prompt: async (_text: string, _options?: PromptOptions) => { + await hang; + }, + waitForIdle: async () => { + await hang; + }, + getLastAssistantMessage: () => undefined, + abort: async () => { + abortCount += 1; + // Simulate a late yield arriving while the executor is tearing + // the session down in response to the wall-clock abort. + listenerRef?.({ + type: "tool_execution_end", + toolCallId: "tool-late-yield", + toolName: "yield", + result: { + content: [{ type: "text", text: "Result submitted." }], + details: { status: "success", data: { lateButLanded: true } }, + }, + isError: false, + } as AgentSessionEvent); + releaseHang(); + }, + dispose: async () => {}, + }; + mockCreateAgentSession(session as AgentSession); + + const result = await runSubprocess({ + ...baseOptions, + id: "subagent-late-yield", + settings, + }); + + expect(abortCount).toBeGreaterThanOrEqual(1); + expect(result.aborted).toBe(true); + expect(result.exitCode).toBe(1); + expect(result.abortReason).toContain("runtime limit exceeded"); + // Yield data is preserved for inspection — the regression was only in + // the exit status / abort flag, not in the captured payload. + expect(result.extractedToolData?.yield).toBeDefined(); + }); + + it("propagates per-turn context tokens onto the SingleResult", async () => { + // Async task consumers (index.ts) copy `singleResult.contextTokens` and + // `singleResult.contextWindow` onto AgentProgress. This test pins the + // upstream contract: when an assistant message_end carries totalTokens, + // executor must surface it on SingleResult.contextTokens. + 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) => { + queueMicrotask(() => { + listener({ + type: "message_end", + message: { + role: "assistant", + content: [{ type: "text", text: "ok" }], + usage: { input: 100, output: 50, cacheRead: 0, cacheWrite: 0, totalTokens: 12345 }, + }, + } as unknown as AgentSessionEvent); + listener({ + type: "tool_execution_end", + toolCallId: "tool-ok", + 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-context-tokens", + settings, + }); + + expect(result.aborted).toBe(false); + expect(result.contextTokens).toBe(12345); + // contextWindow is only populated when the model registry resolves one; + // here we mock createAgentSession so it stays undefined. The async-task + // consumer's assignment is a straight copy, so undefined is acceptable. + expect(result.contextWindow).toBeUndefined(); + }); });