Skip to content

Commit 7f71460

Browse files
fix: allow CLI task cancel for stuck background tasks (#62506) (thanks @neeravmakwana)
* Tasks: allow openclaw tasks cancel for CLI runtime (#62419) Made-with: Cursor * Tasks: address review — changelog order, CLI cancel without session, lock terminal status Made-with: Cursor * fix: freeze terminal task listener updates * fix: clean changelog block for CLI task cancel (#62506) (thanks @neeravmakwana) --------- Co-authored-by: Ayaan Zaidi <hi@obviy.us>
1 parent 9267c3f commit 7f71460

4 files changed

Lines changed: 169 additions & 31 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ Docs: https://docs.openclaw.ai
189189
- Agents/model resolution: keep explicit-model runtime comparisons on the configured workspace plugin registry, so workspace-installed providers do not silently fall back to stale explicit metadata during runtime model lookup.
190190
- Providers/Z.AI: default onboarding and endpoint detection to GLM-5.1 instead of GLM-5. (#61998) Thanks @serg0x.
191191
- Cron/isolated: resolve auth profiles without treating every isolated run as a brand-new auth session, so profile-based providers (for example OpenRouter) keep a stable credential choice instead of rotating or ignoring stored keys. (#62783) Thanks @neeravmakwana.
192+
- CLI/tasks: `openclaw tasks cancel` now records operator cancellation for CLI runtime tasks instead of returning "Task runtime does not support cancellation yet", so stuck `running` CLI tasks can be cleared. (#62419) Thanks @neeravmakwana.
192193

193194
## 2026.4.5
194195

docs/automation/tasks.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,7 @@ The lookup token accepts a task ID, run ID, or session key. Shows the full recor
180180
openclaw tasks cancel <lookup>
181181
```
182182

183-
For ACP and subagent tasks, this kills the child session. Status transitions to `cancelled` and a delivery notification is sent.
183+
For ACP and subagent tasks, this kills the child session. For CLI-tracked tasks, cancellation is recorded in the task registry (there is no separate child runtime handle). Status transitions to `cancelled` and a delivery notification is sent when applicable.
184184

185185
### `tasks notify`
186186

src/tasks/task-registry.test.ts

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,56 @@ describe("task-registry", () => {
268268
});
269269
});
270270

271+
it("ignores late agent events for operator-cancelled tasks", async () => {
272+
await withTaskRegistryTempDir(async (root) => {
273+
process.env.OPENCLAW_STATE_DIR = root;
274+
resetTaskRegistryForTests();
275+
276+
const task = createTaskRecord({
277+
runtime: "cli",
278+
ownerKey: "agent:main:main",
279+
scopeKind: "session",
280+
childSessionKey: "agent:main:main",
281+
runId: "run-cancel-then-end",
282+
task: "Do the thing",
283+
status: "running",
284+
deliveryStatus: "not_applicable",
285+
startedAt: 100,
286+
});
287+
288+
markTaskTerminalById({
289+
taskId: task.taskId,
290+
status: "cancelled",
291+
endedAt: 200,
292+
lastEventAt: 200,
293+
error: "Cancelled by operator.",
294+
});
295+
296+
emitAgentEvent({
297+
runId: "run-cancel-then-end",
298+
stream: "lifecycle",
299+
data: {
300+
phase: "end",
301+
endedAt: 999,
302+
},
303+
});
304+
emitAgentEvent({
305+
runId: "run-cancel-then-end",
306+
stream: "error",
307+
data: {
308+
error: "late error",
309+
},
310+
});
311+
312+
expect(findTaskByRunId("run-cancel-then-end")).toMatchObject({
313+
status: "cancelled",
314+
endedAt: 200,
315+
lastEventAt: 200,
316+
error: "Cancelled by operator.",
317+
});
318+
});
319+
});
320+
271321
it("summarizes task pressure by status and runtime", async () => {
272322
await withTaskRegistryTempDir(async (root) => {
273323
process.env.OPENCLAW_STATE_DIR = root;
@@ -1872,4 +1922,86 @@ describe("task-registry", () => {
18721922
);
18731923
});
18741924
});
1925+
1926+
it("cancels CLI-tracked tasks in the registry without ACP or subagent teardown", async () => {
1927+
await withTaskRegistryTempDir(async (root) => {
1928+
process.env.OPENCLAW_STATE_DIR = root;
1929+
hoisted.cancelSessionMock.mockClear();
1930+
hoisted.killSubagentRunAdminMock.mockClear();
1931+
1932+
const task = createTaskRecord({
1933+
runtime: "cli",
1934+
ownerKey: "agent:main:main",
1935+
scopeKind: "session",
1936+
requesterOrigin: {
1937+
channel: "telegram",
1938+
to: "telegram:123",
1939+
},
1940+
childSessionKey: "agent:main:main",
1941+
runId: "run-cancel-cli",
1942+
task: "Investigate issue",
1943+
status: "running",
1944+
deliveryStatus: "pending",
1945+
});
1946+
1947+
const result = await cancelTaskById({
1948+
cfg: {} as never,
1949+
taskId: task.taskId,
1950+
});
1951+
1952+
expect(hoisted.cancelSessionMock).not.toHaveBeenCalled();
1953+
expect(hoisted.killSubagentRunAdminMock).not.toHaveBeenCalled();
1954+
expect(result).toMatchObject({
1955+
found: true,
1956+
cancelled: true,
1957+
task: expect.objectContaining({
1958+
taskId: task.taskId,
1959+
status: "cancelled",
1960+
error: "Cancelled by operator.",
1961+
}),
1962+
});
1963+
await waitForAssertion(() =>
1964+
expect(hoisted.sendMessageMock).toHaveBeenCalledWith(
1965+
expect.objectContaining({
1966+
channel: "telegram",
1967+
to: "telegram:123",
1968+
content: "Background task cancelled: Investigate issue (run run-canc).",
1969+
}),
1970+
),
1971+
);
1972+
});
1973+
});
1974+
1975+
it("cancels CLI-tracked tasks without childSessionKey", async () => {
1976+
await withTaskRegistryTempDir(async (root) => {
1977+
process.env.OPENCLAW_STATE_DIR = root;
1978+
const task = createTaskRecord({
1979+
runtime: "cli",
1980+
ownerKey: "agent:main:main",
1981+
scopeKind: "session",
1982+
requesterOrigin: {
1983+
channel: "telegram",
1984+
to: "telegram:123",
1985+
},
1986+
runId: "run-cli-no-child",
1987+
task: "Legacy row",
1988+
status: "running",
1989+
deliveryStatus: "pending",
1990+
});
1991+
1992+
const result = await cancelTaskById({
1993+
cfg: {} as never,
1994+
taskId: task.taskId,
1995+
});
1996+
1997+
expect(result).toMatchObject({
1998+
found: true,
1999+
cancelled: true,
2000+
task: expect.objectContaining({
2001+
taskId: task.taskId,
2002+
status: "cancelled",
2003+
}),
2004+
});
2005+
});
2006+
});
18752007
});

src/tasks/task-registry.ts

Lines changed: 35 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1305,6 +1305,9 @@ function ensureListener() {
13051305
}
13061306
const now = evt.ts || Date.now();
13071307
for (const current of scopedTasks) {
1308+
if (isTerminalTaskStatus(current.status)) {
1309+
continue;
1310+
}
13081311
const patch: Partial<TaskRecord> = {
13091312
lastEventAt: now,
13101313
};
@@ -1719,43 +1722,45 @@ export async function cancelTaskById(params: {
17191722
};
17201723
}
17211724
const childSessionKey = task.childSessionKey?.trim();
1722-
if (!childSessionKey) {
1723-
return {
1724-
found: true,
1725-
cancelled: false,
1726-
reason: "Task has no cancellable child session.",
1727-
task: cloneTaskRecord(task),
1728-
};
1729-
}
17301725
try {
1731-
if (task.runtime === "acp") {
1732-
const { getAcpSessionManager } = await loadTaskRegistryControlRuntime();
1733-
await getAcpSessionManager().cancelSession({
1734-
cfg: params.cfg,
1735-
sessionKey: childSessionKey,
1736-
reason: "task-cancel",
1737-
});
1738-
} else if (task.runtime === "subagent") {
1739-
const { killSubagentRunAdmin } = await loadTaskRegistryControlRuntime();
1740-
const result = await killSubagentRunAdmin({
1741-
cfg: params.cfg,
1742-
sessionKey: childSessionKey,
1743-
});
1744-
if (!result.found || !result.killed) {
1726+
if (task.runtime !== "cli") {
1727+
if (!childSessionKey) {
17451728
return {
17461729
found: true,
17471730
cancelled: false,
1748-
reason: result.found ? "Subagent was not running." : "Subagent task not found.",
1731+
reason: "Task has no cancellable child session.",
1732+
task: cloneTaskRecord(task),
1733+
};
1734+
}
1735+
if (task.runtime === "acp") {
1736+
const { getAcpSessionManager } = await loadTaskRegistryControlRuntime();
1737+
await getAcpSessionManager().cancelSession({
1738+
cfg: params.cfg,
1739+
sessionKey: childSessionKey,
1740+
reason: "task-cancel",
1741+
});
1742+
} else if (task.runtime === "subagent") {
1743+
const { killSubagentRunAdmin } = await loadTaskRegistryControlRuntime();
1744+
const result = await killSubagentRunAdmin({
1745+
cfg: params.cfg,
1746+
sessionKey: childSessionKey,
1747+
});
1748+
if (!result.found || !result.killed) {
1749+
return {
1750+
found: true,
1751+
cancelled: false,
1752+
reason: result.found ? "Subagent was not running." : "Subagent task not found.",
1753+
task: cloneTaskRecord(task),
1754+
};
1755+
}
1756+
} else {
1757+
return {
1758+
found: true,
1759+
cancelled: false,
1760+
reason: "Task runtime does not support cancellation yet.",
17491761
task: cloneTaskRecord(task),
17501762
};
17511763
}
1752-
} else {
1753-
return {
1754-
found: true,
1755-
cancelled: false,
1756-
reason: "Task runtime does not support cancellation yet.",
1757-
task: cloneTaskRecord(task),
1758-
};
17591764
}
17601765
const updated = updateTask(task.taskId, {
17611766
status: "cancelled",

0 commit comments

Comments
 (0)