fix(coding-agent/task): plugged wall-clock timer holes and propagated context stats

- Added defensive abort re-check after registering the abortSignal listener plus a checkAbort() immediately before await session.prompt(...), so a wall-clock timer that fires during pre-prompt setup is no longer lost between listener registration and the prompt call.
- Late yield events arriving after a wall-clock timeout no longer flip the result to success: a runtimeLimitExceeded flag derived from the internal abortReason forces wasAborted=true and exitCode=1 regardless of hasYield, while yield payloads remain captured in extractedToolData.
- Async task progress now copies contextTokens and contextWindow from the completed SingleResult onto AgentProgress, so backgrounded tasks still surface their context gauge to the UI.
This commit is contained in:
can1357
2026-05-15 17:49:00 +02:00
parent dad6149393
commit c4672f11af
4 changed files with 186 additions and 5 deletions
+3
View File
@@ -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
+23 -5
View File
@@ -1162,6 +1162,12 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
},
{ once: true, signal: sessionAbortController.signal },
);
// Defensive: if the wall-clock timer (or external signal) fired during
// the awaited setup above, the listener registration races the dispatch
// and may not observe the already-fired abort event. Mirror it manually.
if (abortSignal.aborted) {
void session.abort();
}
const extensionRunner = session.extensionRunner;
if (extensionRunner) {
@@ -1231,6 +1237,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
}
});
checkAbort();
await session.prompt(task, { attribution: "agent" });
await session.waitForIdle();
@@ -1378,12 +1385,23 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
}
}
// Update final progress
const wasAborted = abortedViaYield || (!hasYield && (done.aborted || signal?.aborted || false));
// Update final progress. A wall-clock timeout always wins: if the runtime
// limit fired we report aborted/failed regardless of whether a yield landed
// while we were tearing the session down. The yield data is still surfaced
// to the caller via `progress.extractedToolData`, but the exit status must
// reflect the timeout so on-call doesn't mistake a stuck run for success.
const runtimeLimitExceeded = abortReason === "timeout";
if (runtimeLimitExceeded && exitCode === 0) {
exitCode = 1;
}
const wasAborted =
runtimeLimitExceeded || abortedViaYield || (!hasYield && (done.aborted || signal?.aborted || false));
const finalAbortReason = wasAborted
? abortedViaYield
? yieldAbortReason
: (done.abortReason ?? (signal?.aborted ? resolveSignalAbortReason() : resolveAbortReasonText()))
? runtimeLimitExceeded
? resolveAbortReasonText()
: abortedViaYield
? yieldAbortReason
: (done.abortReason ?? (signal?.aborted ? resolveSignalAbortReason() : resolveAbortReasonText()))
: undefined;
progress.status = wasAborted ? "aborted" : exitCode === 0 ? "completed" : "failed";
scheduleProgress(true);
+2
View File
@@ -391,6 +391,8 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
: "failed";
progress.durationMs = singleResult?.durationMs ?? Math.max(0, Date.now() - startedAt);
progress.tokens = singleResult?.tokens ?? 0;
progress.contextTokens = singleResult?.contextTokens;
progress.contextWindow = singleResult?.contextWindow;
progress.cost = singleResult?.usage?.cost.total ?? 0;
progress.extractedToolData = singleResult?.extractedToolData;
}
@@ -157,4 +157,162 @@ describe("runSubprocess wall clock (task.maxRuntimeMs)", () => {
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<void>();
let listenerRef: ((event: AgentSessionEvent) => void) | undefined;
let abortCount = 0;
const session: Partial<AgentSession> = {
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<AgentSession> = {
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();
});
});