Skip to content

Commit f623d81

Browse files
authored
feat(ui): show background tasks live in the web Control UI (#100789)
* feat(gateway): broadcast task ledger changes as task events * feat(ui): add background Tasks page to Control UI * fix(gateway): address tasks page review findings * fix(ui): surface tasks.cancel refusals on the Tasks page * fix(gateway): guard stale task observer disposal against replacement gateways * chore: satisfy lint and docs-map gates for tasks page * test(gateway): await async agent unsub in stale-dispose test
1 parent 88f1ec3 commit f623d81

70 files changed

Lines changed: 2135 additions & 150 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/automation/tasks.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,10 @@ When the current session has no visible linked tasks, `/tasks` falls back to age
287287

288288
For the full operator ledger, use the CLI: `openclaw tasks list`.
289289

290+
### Control UI
291+
292+
The web Control UI has a **Tasks** page in the sidebar with live active and recent background tasks. Use it to inspect progress, open linked sessions, refresh the ledger, or cancel queued and running tasks.
293+
290294
## Status integration (task pressure)
291295

292296
`openclaw status` includes an at-a-glance task line:

docs/docs_map.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
199199
- H3: Notification policies
200200
- H2: CLI reference
201201
- H2: Chat task board (/tasks)
202+
- H3: Control UI
202203
- H2: Status integration (task pressure)
203204
- H2: Storage and maintenance
204205
- H3: Where tasks live

docs/web/control-ui.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,8 +142,9 @@ The compact footer keeps connection status, **Settings**, **Docs**, and mobile p
142142
- Dreams: dreaming status, enable/disable toggle, and Dream Diary reader (`doctor.memory.status`, `doctor.memory.dreamDiary`, `config.patch`).
143143

144144
</Accordion>
145-
<Accordion title="Cron, skills, nodes, exec approvals">
145+
<Accordion title="Cron, tasks, skills, nodes, exec approvals">
146146
- Cron jobs: list/add/edit/run/enable/disable plus run history (`cron.*`).
147+
- Tasks: live active and recent background task ledger with linked sessions and cancellation (`tasks.*`).
147148
- Skills: status, enable/disable, install, API key updates (`skills.*`).
148149
- Nodes: list plus caps (`node.list`), create mobile setup codes, and approve device pairing (`device.pair.*`).
149150
- Exec approvals: edit gateway or node allowlists and ask policy for `exec host=gateway/node` (`exec.approvals.*`).

scripts/protocol-event-coverage.allowlist.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"shutdown": "iOS relies on socket close plus reconnect/backoff instead of the shutdown notice.",
99
"heartbeat": "iOS liveness uses tick and WebSocket-level ping; heartbeat is unused.",
1010
"cron": "Cron run activity is not surfaced in the iOS app.",
11+
"task": "Background task activity is not surfaced in the iOS app.",
1112
"node.pair.requested": "Node pairing state is fetched on demand; no push consumer on iOS yet.",
1213
"node.pair.resolved": "Node pairing state is fetched on demand; no push consumer on iOS yet.",
1314
"device.pair.requested": "Device pairing flows poll via device.pair.* methods on iOS.",
@@ -27,6 +28,7 @@
2728
"shutdown": "Android relies on socket close plus reconnect/backoff instead of the shutdown notice.",
2829
"heartbeat": "Android liveness uses tick and WebSocket-level ping; heartbeat is unused.",
2930
"cron": "Cron run activity is not surfaced in the Android app.",
31+
"task": "Background task activity is not surfaced in the Android app.",
3032
"node.pair.requested": "Android's bounded operator session lacks operator.pairing; onboarding refreshes node approval with explicit node.list requests.",
3133
"node.pair.resolved": "Android's bounded operator session lacks operator.pairing; onboarding refreshes node approval with explicit node.list requests.",
3234
"device.pair.requested": "Device pairing flows poll via device.pair.* methods on Android.",

src/gateway/gateway-misc.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,19 @@ describe("gateway broadcaster", () => {
439439
expectSentEvents(adminSocket, expectedEvents);
440440
});
441441

442+
it("requires operator.read for task ledger broadcast events", () => {
443+
const { pairingSocket, nodeSocket, readSocket, writeSocket, adminSocket, broadcast } =
444+
makeScopedBroadcastContext();
445+
446+
broadcast("task", { action: "deleted", taskId: "task-1" });
447+
448+
expect(pairingSocket.send).not.toHaveBeenCalled();
449+
expect(nodeSocket.send).not.toHaveBeenCalled();
450+
expectSentEvents(readSocket, ["task"]);
451+
expectSentEvents(writeSocket, ["task"]);
452+
expectSentEvents(adminSocket, ["task"]);
453+
});
454+
442455
it("allows plugin.* broadcast events for operator.write and operator.admin", () => {
443456
const { pairingSocket, nodeSocket, readSocket, writeSocket, adminSocket, broadcast } =
444457
makeScopedBroadcastContext();

src/gateway/server-broadcast.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ const EVENT_SCOPE_GUARDS: Record<string, string[]> = {
3737
tick: [],
3838
"talk.event": [READ_SCOPE],
3939
"talk.mode": [WRITE_SCOPE],
40+
task: [READ_SCOPE],
4041
"update.available": [],
4142
"voicewake.changed": [READ_SCOPE],
4243
"voicewake.routing.changed": [READ_SCOPE],

src/gateway/server-close.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ function createGatewayCloseTestDeps(
118118
mediaCleanup: null,
119119
worktreeCleanup: null,
120120
agentUnsub: null,
121+
taskUnsub: null,
121122
heartbeatUnsub: null,
122123
transcriptUnsub: null,
123124
lifecycleUnsub: null,
@@ -1297,19 +1298,22 @@ describe("createGatewayCloseHandler", () => {
12971298

12981299
it("unsubscribes lifecycle listeners and disposes bundle runtimes during shutdown", async () => {
12991300
const lifecycleUnsub = vi.fn();
1301+
const taskUnsub = vi.fn();
13001302
const transcriptUnsub = vi.fn();
13011303
const stopTaskRegistryMaintenance = vi.fn();
13021304
const close = createGatewayCloseHandler(
13031305
createGatewayCloseTestDeps({
13041306
stopTaskRegistryMaintenance,
13051307
lifecycleUnsub,
1308+
taskUnsub,
13061309
transcriptUnsub,
13071310
}),
13081311
);
13091312

13101313
await close({ reason: "test shutdown" });
13111314

13121315
expect(lifecycleUnsub).toHaveBeenCalledTimes(1);
1316+
expect(taskUnsub).toHaveBeenCalledTimes(1);
13131317
expect(transcriptUnsub).toHaveBeenCalledTimes(1);
13141318
expect(stopTaskRegistryMaintenance).toHaveBeenCalledTimes(1);
13151319
expect(mocks.disposeAgentHarnesses).toHaveBeenCalledTimes(1);

src/gateway/server-close.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -692,6 +692,7 @@ export function createGatewayCloseHandler(
692692
heartbeatUnsub: (() => void) | null;
693693
transcriptUnsub: (() => void) | null;
694694
lifecycleUnsub: (() => void) | null;
695+
taskUnsub: (() => void) | null;
695696
getPendingReplyCount?: () => number;
696697
clients: Set<{ socket: { close: (code: number, reason: string) => void } }>;
697698
configReloader: { stop: () => Promise<void> };
@@ -909,6 +910,9 @@ export function createGatewayCloseHandler(
909910
if (params.lifecycleUnsub) {
910911
await shutdownStep("lifecycle-unsub", () => params.lifecycleUnsub!(), warnings);
911912
}
913+
if (params.taskUnsub) {
914+
await shutdownStep("task-unsub", () => params.taskUnsub!(), warnings);
915+
}
912916
params.chatRunState.clear();
913917
let clientCloseFailures = 0;
914918
for (const c of params.clients) {

src/gateway/server-methods-list.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ export const GATEWAY_EVENTS = [
5252
"health",
5353
"heartbeat",
5454
"cron",
55+
"task",
5556
"node.pair.requested",
5657
"node.pair.resolved",
5758
"node.invoke.request",
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
// Public task summaries keep task-registry internals and unbounded status text
2+
// out of gateway responses and events.
3+
import type { TaskSummary } from "../../../packages/gateway-protocol/src/index.js";
4+
import type { TaskRecord, TaskStatus } from "../../tasks/task-registry.types.js";
5+
import {
6+
TASK_STATUS_DETAIL_MAX_CHARS,
7+
formatTaskStatusTitle,
8+
sanitizeTaskStatusText,
9+
} from "../../tasks/task-status.js";
10+
11+
type TaskLedgerStatus = TaskSummary["status"];
12+
13+
export const TASK_STATUS_TO_LEDGER_STATUS: Record<TaskStatus, TaskLedgerStatus> = {
14+
queued: "queued",
15+
running: "running",
16+
succeeded: "completed",
17+
failed: "failed",
18+
timed_out: "timed_out",
19+
cancelled: "cancelled",
20+
lost: "failed",
21+
};
22+
23+
export type TaskEventPayload =
24+
| { action: "upserted"; task: TaskSummary }
25+
| { action: "deleted"; taskId: string }
26+
| { action: "restored" };
27+
28+
export function taskUpdatedAt(task: TaskRecord): number {
29+
return task.lastEventAt ?? task.endedAt ?? task.startedAt ?? task.createdAt;
30+
}
31+
32+
function sanitizeOptionalTaskText(
33+
value: unknown,
34+
opts?: { errorContext?: boolean },
35+
): string | undefined {
36+
const sanitized = sanitizeTaskStatusText(value, {
37+
errorContext: opts?.errorContext,
38+
maxChars: TASK_STATUS_DETAIL_MAX_CHARS,
39+
});
40+
return sanitized || undefined;
41+
}
42+
43+
export function mapTaskSummary(task: TaskRecord): TaskSummary {
44+
const progressSummary = sanitizeOptionalTaskText(task.progressSummary);
45+
const terminalSummary = sanitizeOptionalTaskText(task.terminalSummary, { errorContext: true });
46+
const error = sanitizeOptionalTaskText(task.error, { errorContext: true });
47+
return {
48+
id: task.taskId,
49+
taskId: task.taskId,
50+
kind: task.taskKind ?? task.runtime,
51+
runtime: task.runtime,
52+
status: TASK_STATUS_TO_LEDGER_STATUS[task.status],
53+
title: formatTaskStatusTitle(task),
54+
...(task.agentId ? { agentId: task.agentId } : {}),
55+
sessionKey: task.requesterSessionKey,
56+
...(task.childSessionKey ? { childSessionKey: task.childSessionKey } : {}),
57+
ownerKey: task.ownerKey,
58+
...(task.runId ? { runId: task.runId } : {}),
59+
...(task.parentFlowId ? { flowId: task.parentFlowId } : {}),
60+
...(task.parentTaskId ? { parentTaskId: task.parentTaskId } : {}),
61+
...(task.sourceId ? { sourceId: task.sourceId } : {}),
62+
createdAt: task.createdAt,
63+
updatedAt: taskUpdatedAt(task),
64+
...(task.startedAt !== undefined ? { startedAt: task.startedAt } : {}),
65+
...(task.endedAt !== undefined ? { endedAt: task.endedAt } : {}),
66+
...(progressSummary ? { progressSummary } : {}),
67+
...(terminalSummary ? { terminalSummary } : {}),
68+
...(error ? { error } : {}),
69+
};
70+
}

0 commit comments

Comments
 (0)