From abc7dffe4cee8775033b6706e5a011776fb33f07 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 18 Jul 2026 00:08:05 +0100 Subject: [PATCH] fix(gateway): stop reporting user cancellations as gateway draining (#110220) * fix(gateway): preserve agent cancel attribution * chore: keep release note in PR body --- .../server-methods/agent-run-dispatch.ts | 26 ++++++++++++++----- .../agent.sessions-and-models.test-utils.ts | 18 ++++++++++--- 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/src/gateway/server-methods/agent-run-dispatch.ts b/src/gateway/server-methods/agent-run-dispatch.ts index 00396544a521..a10ec96270fb 100644 --- a/src/gateway/server-methods/agent-run-dispatch.ts +++ b/src/gateway/server-methods/agent-run-dispatch.ts @@ -64,6 +64,16 @@ function resolveGatewayAgentAbortStopReason(signal: AbortSignal): "restart" | "r return readErrorName(signal.reason) === "TimeoutError" ? "timeout" : "rpc"; } +function resolveAbortedAgentTaskStatus(stopReason: string | undefined): "cancelled" | "timed_out" { + return stopReason === "timeout" ? "timed_out" : "cancelled"; +} + +function resolveGatewayAgentAbortTimeoutPhase( + stopReason: "restart" | "rpc" | "timeout", +): "gateway_draining" | undefined { + return stopReason === "restart" ? "gateway_draining" : undefined; +} + export function resolveAbortedAgentStopReason(entry?: ChatAbortControllerEntry): string { return entry?.abortStopReason?.trim() || "rpc"; } @@ -146,11 +156,12 @@ export function dispatchAgentRunFromGateway(params: { }) .then(async (result) => { const aborted = result?.meta?.aborted === true; + const stopReason = aborted ? (result?.meta?.stopReason ?? "rpc") : undefined; const timeoutAttribution = readAgentRunTimeoutAttribution(result?.meta); if (taskTracked) { tryFinalizeTrackedAgentTask({ runId: params.runId, - status: aborted ? "timed_out" : "succeeded", + status: aborted ? resolveAbortedAgentTaskStatus(stopReason) : "succeeded", terminalSummary: aborted ? "aborted" : "completed", log: params.context.logGateway, }); @@ -159,7 +170,7 @@ export function dispatchAgentRunFromGateway(params: { runId: params.runId, status: aborted ? ("timeout" as const) : ("ok" as const), summary: aborted ? "aborted" : "completed", - ...(aborted ? { stopReason: result?.meta?.stopReason ?? "rpc" } : {}), + ...(aborted ? { stopReason } : {}), ...(aborted && timeoutAttribution.timeoutPhase ? { timeoutPhase: timeoutAttribution.timeoutPhase } : {}), @@ -213,28 +224,31 @@ export function dispatchAgentRunFromGateway(params: { .catch(async (err: unknown) => { const aborted = isGatewayAgentAbortRejection(err, params.abortController.signal); const renderedErr = formatForLog(err); + const stopReason = resolveGatewayAgentAbortStopReason(params.abortController.signal); + const timeoutPhase = aborted ? resolveGatewayAgentAbortTimeoutPhase(stopReason) : undefined; if (taskTracked) { tryFinalizeTrackedAgentTask({ runId: params.runId, - status: aborted ? "timed_out" : resolveFailedTrackedAgentTaskStatus(err), + status: aborted + ? resolveAbortedAgentTaskStatus(stopReason) + : resolveFailedTrackedAgentTaskStatus(err), error: renderedErr, terminalSummary: renderedErr, log: params.context.logGateway, }); } const error = errorShape(ErrorCodes.UNAVAILABLE, renderedErr); - const stopReason = resolveGatewayAgentAbortStopReason(params.abortController.signal); const terminalOutcome = buildAgentRunTerminalOutcome({ status: aborted ? "timeout" : "error", error: renderedErr, stopReason, - timeoutPhase: aborted ? "gateway_draining" : undefined, + timeoutPhase, }); const payload = { runId: params.runId, status: aborted ? ("timeout" as const) : ("error" as const), summary: aborted ? "aborted" : renderedErr, - ...(aborted ? { stopReason, timeoutPhase: "gateway_draining" as const } : {}), + ...(aborted ? { stopReason, ...(timeoutPhase ? { timeoutPhase } : {}) } : {}), }; const persistTerminalDedupe = (settlementPersisted: boolean) => { setGatewayDedupeEntries({ diff --git a/src/gateway/server-methods/agent.sessions-and-models.test-utils.ts b/src/gateway/server-methods/agent.sessions-and-models.test-utils.ts index 3f03b28378e5..eb2c1810b0ba 100644 --- a/src/gateway/server-methods/agent.sessions-and-models.test-utils.ts +++ b/src/gateway/server-methods/agent.sessions-and-models.test-utils.ts @@ -430,7 +430,7 @@ describe("gateway agent handler", () => { }); }); - it("preserves aborted async gateway agent runs as timed out", async () => { + it("preserves aborted async gateway agent runs as cancelled", async () => { await withTempDir({ prefix: "openclaw-gateway-agent-task-aborted-" }, async (root) => { useTestStateDir(root); resetTaskRegistryForTests(); @@ -454,7 +454,7 @@ describe("gateway agent handler", () => { expectRecordFields(findTaskByRunId("task-registry-agent-run-aborted"), { runtime: "cli", childSessionKey: "agent:main:main", - status: "timed_out", + status: "cancelled", terminalSummary: "aborted", }); expectRecordFields(context.dedupe.get("agent:task-registry-agent-run-aborted")?.payload, { @@ -466,7 +466,7 @@ describe("gateway agent handler", () => { }); }); - it("classifies aborted async gateway agent rejections as timed out", async () => { + it("classifies RPC-aborted async gateway agent rejections as cancelled", async () => { await withTempDir({ prefix: "openclaw-gateway-agent-task-abort-error-" }, async (root) => { useTestStateDir(root); resetTaskRegistryForTests(); @@ -493,7 +493,7 @@ describe("gateway agent handler", () => { expectRecordFields(findTaskByRunId("task-registry-agent-run-abort-error"), { runtime: "cli", childSessionKey: "agent:main:main", - status: "timed_out", + status: "cancelled", error: "AbortError: This operation was aborted", }); expectRecordFields( @@ -505,6 +505,9 @@ describe("gateway agent handler", () => { stopReason: "rpc", }, ); + expect( + context.dedupe.get("agent:task-registry-agent-run-abort-error")?.payload, + ).not.toHaveProperty("timeoutPhase"); }); }); }); @@ -536,11 +539,15 @@ describe("gateway agent handler", () => { ); await waitForAssertion(() => { + expectRecordFields(findTaskByRunId(runId), { + status: "cancelled", + }); expectRecordFields(context.dedupe.get(`agent:${runId}`)?.payload, { runId, status: "timeout", summary: "aborted", stopReason: "restart", + timeoutPhase: "gateway_draining", }); }); }); @@ -585,6 +592,9 @@ describe("gateway agent handler", () => { stopReason: "timeout", }, ); + expect( + context.dedupe.get("agent:task-registry-agent-run-timeout-error")?.payload, + ).not.toHaveProperty("timeoutPhase"); }); }); });