test(gateway): verify live task cancellation in release E2E (#139135)

Require offline CLI cancellation to fail visibly without mutating persisted
state. Exercise authenticated RPC and CLI cancellation against a real held
provider request, preserve cancellation provenance, and verify later heartbeat
work succeeds. Follow up the execution ownership repair in #118358.
This commit is contained in:
Peter Steinberger 2026-09-05 07:12:13 -07:00 • committed by GitHub
parent 6c1ebc03ad
commit 48406f2a41
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 61 additions and 25 deletions

View file

@ -97,9 +97,14 @@ describe("task operations product boundary", () => {
it("runs persisted task operations through CLI, chat, notification, audit, and maintenance", async () => {
await withOpenClawTestState(
{
layout: "state-only",
layout: "home",
scenario: "minimal",
prefix: "openclaw-task-operations-e2e-",
env: {
OPENCLAW_GATEWAY_TOKEN: undefined,
OPENCLAW_GATEWAY_PASSWORD: undefined,
OPENCLAW_GATEWAY_URL: undefined,
},
},
async () => {
resetTaskOperationsRuntime();
@ -265,19 +270,26 @@ describe("task operations product boundary", () => {
const cancel = createRuntime();
await tasksCancelCommand({ lookup: operatorTask.taskId }, cancel.runtime);
expect(cancel.errors).toEqual([]);
expect(cancel.exits).toEqual([]);
expect(cancel.logs).toEqual([
`Cancelled ${operatorTask.taskId} (cli) run run-a07-operator.`,
// A persisted row cannot stand in for the Gateway's live execution owner.
expect(cancel.errors).toEqual([
expect.stringContaining(
"CLI task cancellation requires the live Gateway tasks.cancel path:",
),
]);
expect(cancel.errors[0]).toContain("requires credentials before opening a websocket");
expect(cancel.exits).toEqual([1]);
expect(cancel.logs).toEqual([]);
resetTaskRegistryForTests({ persist: false });
reloadTaskRegistryFromStore();
expect(requireTask(operatorTask.taskId)).toMatchObject({
status: "cancelled",
error: "Cancelled by operator.",
status: "running",
});
const cancelledShow = createRuntime();
await tasksShowCommand({ lookup: operatorTask.taskId }, cancelledShow.runtime);
expect(cancelledShow.logs.join("\n")).toContain("status: cancelled");
expect(requireTask(operatorTask.taskId).error).toBeUndefined();
const retainedShow = createRuntime();
await tasksShowCommand({ lookup: operatorTask.taskId }, retainedShow.runtime);
expect(retainedShow.logs.join("\n")).toContain("status: running");
} finally {
clearHeartbeat();
resetTaskOperationsRuntime();

View file

@ -8,6 +8,7 @@ import type {
TasksListResult,
} from "../../../../packages/gateway-protocol/src/index.js";
import { withFastReplyConfig } from "../../../../src/auto-reply/reply/get-reply-fast-path.test-support.js";
import { tasksCancelCommand } from "../../../../src/commands/tasks.js";
import {
clearConfigCache,
clearRuntimeConfigSnapshot,
@ -25,6 +26,7 @@ import { resetAgentEventsForTest } from "../../../../src/infra/agent-events.js";
import { resetSystemEventsForTest } from "../../../../src/infra/system-events.js";
import { resetTaskRegistryForTests } from "../../../../src/tasks/task-runtime.test-helpers.js";
import { captureEnv, deleteTestEnvValue, setTestEnvValue } from "../../../../src/test-utils/env.js";
import { createRuntimeEnv } from "../../../../src/test-utils/plugin-runtime-env.js";
import { writeOpenAiResponsesSse } from "../../../helpers/openai-responses-sse.js";
import { useAutoCleanupTempDirTracker } from "../../../helpers/temp-dir.js";
@ -33,6 +35,7 @@ const ISOLATED_GATEWAY_ENV_KEYS = [
"OPENCLAW_STATE_DIR",
"OPENCLAW_CONFIG_PATH",
"OPENCLAW_GATEWAY_TOKEN",
"OPENCLAW_GATEWAY_URL",
"OPENCLAW_TEST_GATEWAY_OVERRIDE_TOKEN",
"OPENCLAW_TEST_RUNTIME_OVERRIDE_TOKEN",
"OPENCLAW_TEST_MINIMAL_GATEWAY",
@ -94,10 +97,10 @@ describe("Gateway task and automation RPCs", () => {
beforeEach(resetGatewayState);
afterEach(resetGatewayState);
it(
"persists cron CRUD, wakes the heartbeat, and controls an agent-created task",
it.each(["RPC", "CLI"] as const)(
"persists cron CRUD, wakes the heartbeat, and cancels an agent-created task through %s",
{ timeout: 90_000 },
async () => {
async (cancelSurface) => {
const envSnapshot = captureEnv([...ISOLATED_GATEWAY_ENV_KEYS]);
const tempHome = tempDirs.make("openclaw-gateway-automation-");
const stateDir = path.join(tempHome, ".openclaw");
@ -131,6 +134,7 @@ describe("Gateway task and automation RPCs", () => {
setTestEnvValue(key, value);
}
deleteTestEnvValue("OPENCLAW_CONFIG_PATH");
deleteTestEnvValue("OPENCLAW_GATEWAY_URL");
deleteTestEnvValue("OPENCLAW_TEST_MINIMAL_GATEWAY");
const taskPrompt = nextId("create-tracked-task");
@ -335,22 +339,42 @@ describe("Gateway task and automation RPCs", () => {
prompt: taskPrompt,
},
});
const cancelled = await client.request<TasksCancelResult>("tasks.cancel", {
taskId,
reason: "Gateway RPC automation evidence complete",
});
expect(cancelled).toMatchObject({
found: true,
cancelled: true,
task: { id: taskId, status: "cancelled" },
});
await expect
.poll(() => providerRequests.some((body) => JSON.stringify(body).includes(taskPrompt)), {
timeout: 10_000,
interval: 50,
})
.toBe(true);
const cancellationReason =
cancelSurface === "RPC"
? "Gateway RPC automation evidence complete"
: "Cancelled by operator.";
if (cancelSurface === "RPC") {
const cancelled = await client.request<TasksCancelResult>("tasks.cancel", {
taskId,
reason: cancellationReason,
});
expect(cancelled).toMatchObject({
found: true,
cancelled: true,
task: { id: taskId, status: "cancelled" },
});
} else {
const runtime = createRuntimeEnv();
await tasksCancelCommand({ lookup: taskId }, runtime);
expect(runtime.error).not.toHaveBeenCalled();
expect(runtime.exit).not.toHaveBeenCalled();
expect(runtime.log).toHaveBeenCalledExactlyOnceWith(
`Cancelled ${taskId} (cli) run ${runId}.`,
);
}
await expect(
client.request<TasksGetResult>("tasks.get", { taskId }),
).resolves.toMatchObject({
task: {
id: taskId,
status: "cancelled",
error: "Gateway RPC automation evidence complete",
error: cancellationReason,
},
});
const releaseResponse = releaseTaskResponse;
@ -364,14 +388,14 @@ describe("Gateway task and automation RPCs", () => {
{ runId: started.runId, timeoutMs: 30_000 },
{ timeoutMs: 35_000 },
);
expect(agentWait).toMatchObject({ status: "ok" });
expect(agentWait).toMatchObject({ status: "error", stopReason: "rpc" });
await expect(
client.request<TasksGetResult>("tasks.get", { taskId }),
).resolves.toMatchObject({
task: {
id: taskId,
status: "cancelled",
error: "Gateway RPC automation evidence complete",
error: cancellationReason,
},
});