fix(gateway): stop reporting user cancellations as gateway draining (#110220)

* fix(gateway): preserve agent cancel attribution

* chore: keep release note in PR body
This commit is contained in:
Peter Steinberger
2026-07-18 00:08:05 +01:00
committed by GitHub
parent 9dedacf577
commit abc7dffe4c
2 changed files with 34 additions and 10 deletions

View File

@@ -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({

View File

@@ -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");
});
});
});