diff --git a/docs/automation/tasks.md b/docs/automation/tasks.md index 5e8cc896079f..21e19fdac1fb 100644 --- a/docs/automation/tasks.md +++ b/docs/automation/tasks.md @@ -46,6 +46,7 @@ Not every agent run creates a task. Heartbeat turns and normal interactive chat # Filter by runtime or status openclaw tasks list --runtime acp openclaw tasks list --status running + openclaw tasks list --status blocked ``` @@ -155,6 +156,10 @@ terminal outcome is `succeeded` after delivery and `blocked` when the work finished but the result could not be handed back. This preserves the completed result instead of misreporting the child execution as failed. +Use `openclaw tasks list --status blocked` to find these tasks. They also remain +in `--status succeeded` results because the underlying execution succeeded, and +JSON output preserves the stored status plus the `blocked` terminal outcome. + Agent run completion is authoritative for active task records. A successful detached run finalizes as `succeeded`, ordinary run errors finalize as `failed`, timeouts finalize as `timed_out`, and cancel/abort outcomes finalize as `cancelled`. Once a task is terminal, later lifecycle signals do not downgrade it - an operator-cancelled or already-`failed`/`timed_out`/`lost` task stays that way even if a success signal arrives afterwards. `lost` is runtime-aware: diff --git a/docs/cli/tasks.md b/docs/cli/tasks.md index 4bf0c973d251..a3d44bd8d05c 100644 --- a/docs/cli/tasks.md +++ b/docs/cli/tasks.md @@ -19,6 +19,7 @@ openclaw tasks openclaw tasks list openclaw tasks list --runtime acp openclaw tasks list --status running +openclaw tasks list --status blocked openclaw tasks show openclaw tasks notify state_changes openclaw tasks cancel @@ -34,11 +35,11 @@ openclaw tasks flow cancel ## Root Options -| Flag | Description | -| ------------------ | -------------------------------------------------------------------------------------------------- | -| `--json` | Output JSON. | -| `--runtime ` | Filter by kind: `subagent`, `acp`, `cron`, or `cli`. | -| `--status ` | Filter by status: `queued`, `running`, `succeeded`, `failed`, `timed_out`, `cancelled`, or `lost`. | +| Flag | Description | +| ------------------ | ------------------------------------------------------------------------------------------------------------- | +| `--json` | Output JSON. | +| `--runtime ` | Filter by kind: `subagent`, `acp`, `cron`, or `cli`. | +| `--status ` | Filter by status: `queued`, `running`, `succeeded`, `failed`, `timed_out`, `cancelled`, `lost`, or `blocked`. | ## Subcommands @@ -50,6 +51,11 @@ openclaw tasks list [--runtime ] [--status ] [--json] Lists tracked background tasks newest first. +Use `--status blocked` to find completed tasks whose result delivery is blocked. +These tasks retain their stored `succeeded` status and also remain included in +`--status succeeded` results; JSON task records keep the same stored status and +`terminalOutcome` fields. + ### `show` ```bash diff --git a/src/cli/program/register.tasks.test.ts b/src/cli/program/register.tasks.test.ts index be1f675f8f8e..ecd9f5383a6f 100644 --- a/src/cli/program/register.tasks.test.ts +++ b/src/cli/program/register.tasks.test.ts @@ -104,7 +104,38 @@ describe("registerTasksCommand", () => { }); }); + it("advertises the displayed blocked status on the root and list commands", () => { + const program = new Command(); + registerTasksCommand(program); + const tasks = program.commands.find((command) => command.name() === "tasks"); + const list = tasks?.commands.find((command) => command.name() === "list"); + + for (const command of [tasks, list]) { + expect(command?.options.find((option) => option.long === "--status")?.description).toContain( + "blocked", + ); + } + }); + it.each([ + { + label: "blocked status on the bare task list", + args: ["tasks", "--status", "blocked"], + handler: mocks.tasksListCommand, + expected: { json: false, status: "blocked" }, + }, + { + label: "blocked status before the task list leaf", + args: ["tasks", "--status", "blocked", "list"], + handler: mocks.tasksListCommand, + expected: { json: false, status: "blocked" }, + }, + { + label: "blocked status after the task list leaf", + args: ["tasks", "list", "--status", "blocked"], + handler: mocks.tasksListCommand, + expected: { json: false, status: "blocked" }, + }, { label: "task list options before the leaf", args: ["tasks", "--json", "--runtime", "acp", "--status", "running", "list"], diff --git a/src/cli/program/register.tasks.ts b/src/cli/program/register.tasks.ts index a957bd2dc7c1..3a620a7ceca4 100644 --- a/src/cli/program/register.tasks.ts +++ b/src/cli/program/register.tasks.ts @@ -5,7 +5,7 @@ import { defaultRuntime } from "../../runtime.js"; import { TASK_FLOW_STATUSES } from "../../tasks/task-flow-registry.types.js"; import { TASK_RUNTIMES, - TASK_STATUSES, + TASK_STATUS_FILTERS, type TaskNotifyPolicy, } from "../../tasks/task-registry.types.js"; import { @@ -50,7 +50,7 @@ function addTasksListOptions(command: Command): Command { return command .option("--json", "Output as JSON", false) .option("--runtime ", `Filter by kind (${TASK_RUNTIMES.join(", ")})`) - .option("--status ", `Filter by status (${TASK_STATUSES.join(", ")})`); + .option("--status ", `Filter by status (${TASK_STATUS_FILTERS.join(", ")})`); } function isTaskNotifyPolicy(value: unknown): value is TaskNotifyPolicy { diff --git a/src/commands/tasks-json.test.ts b/src/commands/tasks-json.test.ts index f516083deb81..135f1b8d676f 100644 --- a/src/commands/tasks-json.test.ts +++ b/src/commands/tasks-json.test.ts @@ -133,7 +133,7 @@ describe("tasks JSON commands", () => { }); }); - it("shows blocked completion outcomes without changing task JSON or filters", async () => { + it("filters blocked completion outcomes without changing stored statuses or JSON", async () => { await withTaskJsonStateDir(async () => { const task = createTaskRecord({ runtime: "cli", @@ -150,6 +150,20 @@ describe("tasks JSON commands", () => { terminalSummary: "Required completion did not produce a final deliverable.", endedAt: Date.now(), }); + const completed = createTaskRecord({ + runtime: "cli", + ownerKey: "agent:main:main", + scopeKind: "session", + status: "running", + runId: "task-list-completed", + task: "Inspect a completed background task", + }); + markTaskTerminalById({ + taskId: completed.taskId, + status: "succeeded", + terminalOutcome: "succeeded", + endedAt: Date.now(), + }); const listRuntime = createRuntime(); await tasksListCommand({ status: "succeeded" }, listRuntime); @@ -157,6 +171,32 @@ describe("tasks JSON commands", () => { expect(listOutput).toContain("Task pressure: 0 queued · 0 running · 1 issues"); expect(listOutput).toMatch(/\bblocked\s+pending\b/); + const blockedRuntime = createRuntime(); + await tasksListCommand({ status: "blocked" }, blockedRuntime); + const blockedOutput = vi.mocked(blockedRuntime.log).mock.calls.flat().join("\n"); + expect(blockedOutput).toContain(task.taskId.slice(0, 9)); + expect(blockedOutput).not.toContain(completed.taskId.slice(0, 9)); + + const blockedJsonRuntime = createRuntime(); + await tasksListJsonCommand({ json: true, status: "blocked" }, blockedJsonRuntime); + expect(readJsonLog(blockedJsonRuntime)).toMatchObject({ + count: 1, + runtime: null, + status: "blocked", + tasks: [{ taskId: task.taskId, status: "succeeded", terminalOutcome: "blocked" }], + }); + + const succeededJsonRuntime = createRuntime(); + await tasksListJsonCommand({ json: true, status: "succeeded" }, succeededJsonRuntime); + expect(readJsonLog(succeededJsonRuntime)).toMatchObject({ + count: 2, + status: "succeeded", + tasks: expect.arrayContaining([ + expect.objectContaining({ taskId: task.taskId, terminalOutcome: "blocked" }), + expect.objectContaining({ taskId: completed.taskId, terminalOutcome: "succeeded" }), + ]), + }); + const showRuntime = createRuntime(); await tasksShowCommand({ lookup: task.taskId }, showRuntime); expect(vi.mocked(showRuntime.log).mock.calls.flat().join("\n")).toContain("status: blocked"); @@ -304,7 +344,7 @@ describe("tasks JSON commands", () => { run: (runtime: RuntimeEnv) => tasksListJsonCommand({ json: true, status: "RUNNING" }, runtime), message: - "--status must be queued, running, succeeded, failed, timed_out, cancelled, or lost.", + "--status must be queued, running, succeeded, failed, timed_out, cancelled, lost, or blocked.", }, { run: (runtime: RuntimeEnv) => diff --git a/src/commands/tasks-json.ts b/src/commands/tasks-json.ts index 91f368194dec..3b3f5d4fe991 100644 --- a/src/commands/tasks-json.ts +++ b/src/commands/tasks-json.ts @@ -7,7 +7,12 @@ import { writeRuntimeJson } from "../runtime.js"; import { listTaskRecords } from "../tasks/runtime-internal.js"; import { listTaskFlowAuditFindings } from "../tasks/task-flow-registry.audit.js"; import { listTaskAuditFindings } from "../tasks/task-registry.audit.js"; -import { TASK_RUNTIMES, TASK_STATUSES, type TaskRecord } from "../tasks/task-registry.types.js"; +import { + matchesTaskStatusFilter, + TASK_RUNTIMES, + TASK_STATUS_FILTERS, + type TaskRecord, +} from "../tasks/task-registry.types.js"; import { TASK_SYSTEM_AUDIT_CODES, TASK_SYSTEM_AUDIT_SEVERITIES, @@ -56,12 +61,12 @@ function toSystemAuditFindings(params: { function buildTasksListJsonPayload(opts: TasksListJsonArgs) { const runtimeFilter = parseCliEnumFilter(opts.runtime, "--runtime", TASK_RUNTIMES); - const statusFilter = parseCliEnumFilter(opts.status, "--status", TASK_STATUSES); + const statusFilter = parseCliEnumFilter(opts.status, "--status", TASK_STATUS_FILTERS); const tasks = listTaskJsonRecords().filter((task) => { if (runtimeFilter && task.runtime !== runtimeFilter) { return false; } - if (statusFilter && task.status !== statusFilter) { + if (statusFilter && !matchesTaskStatusFilter(task, statusFilter)) { return false; } return true; diff --git a/src/commands/tasks.filter-validation.test.ts b/src/commands/tasks.filter-validation.test.ts index 490996a82202..e50f4f211e5a 100644 --- a/src/commands/tasks.filter-validation.test.ts +++ b/src/commands/tasks.filter-validation.test.ts @@ -68,12 +68,12 @@ describe("tasks command filter validation", () => { { options: { status: "bogus" }, message: - "--status must be queued, running, succeeded, failed, timed_out, cancelled, or lost.", + "--status must be queued, running, succeeded, failed, timed_out, cancelled, lost, or blocked.", }, { options: { status: "RUNNING" }, message: - "--status must be queued, running, succeeded, failed, timed_out, cancelled, or lost.", + "--status must be queued, running, succeeded, failed, timed_out, cancelled, lost, or blocked.", }, ])("rejects invalid task list filters before querying", async ({ options, message }) => { const query = vi diff --git a/src/commands/tasks.ts b/src/commands/tasks.ts index d334e5ae9161..52f2ad39dc6a 100644 --- a/src/commands/tasks.ts +++ b/src/commands/tasks.ts @@ -39,8 +39,9 @@ import { } from "../tasks/task-registry.reconcile.js"; import { summarizeTaskRecords } from "../tasks/task-registry.summary.js"; import { + matchesTaskStatusFilter, TASK_RUNTIMES, - TASK_STATUSES, + TASK_STATUS_FILTERS, type TaskNotifyPolicy, type TaskRecord, } from "../tasks/task-registry.types.js"; @@ -283,12 +284,12 @@ export async function tasksListCommand( runtime: RuntimeEnv, ) { const runtimeFilter = parseCliEnumFilter(opts.runtime, "--runtime", TASK_RUNTIMES); - const statusFilter = parseCliEnumFilter(opts.status, "--status", TASK_STATUSES); + const statusFilter = parseCliEnumFilter(opts.status, "--status", TASK_STATUS_FILTERS); const tasks = reconcileInspectableTasks().filter((task) => { if (runtimeFilter && task.runtime !== runtimeFilter) { return false; } - if (statusFilter && task.status !== statusFilter) { + if (statusFilter && !matchesTaskStatusFilter(task, statusFilter)) { return false; } return true; diff --git a/src/tasks/task-registry.types.ts b/src/tasks/task-registry.types.ts index 0a37d0e585fe..3d8f91c75439 100644 --- a/src/tasks/task-registry.types.ts +++ b/src/tasks/task-registry.types.ts @@ -12,7 +12,7 @@ export type JsonValue = /** Runtime families that own task run lifecycles. */ export const TASK_RUNTIMES = ["subagent", "acp", "cron", "cli"] as const; -export const TASK_STATUSES = [ +const TASK_STATUSES = [ "queued", "running", "succeeded", @@ -21,9 +21,11 @@ export const TASK_STATUSES = [ "cancelled", "lost", ] as const; +export const TASK_STATUS_FILTERS = [...TASK_STATUSES, "blocked"] as const; export type TaskRuntime = (typeof TASK_RUNTIMES)[number]; export type TaskStatus = (typeof TASK_STATUSES)[number]; +export type TaskStatusFilter = (typeof TASK_STATUS_FILTERS)[number]; export type TaskDeliveryStatus = | "pending" @@ -43,6 +45,17 @@ export type TaskScopeKind = "session" | "system"; export type TaskStatusCounts = Record; export type TaskRuntimeCounts = Record; +export function matchesTaskStatusFilter( + task: Pick, + filter: TaskStatusFilter, +): boolean { + // Blocked delivery is projected over a persisted success, so succeeded filters must keep matching. + return ( + task.status === filter || + (filter === "blocked" && task.status === "succeeded" && task.terminalOutcome === "blocked") + ); +} + const TASK_RUNTIME_SET = new Set(TASK_RUNTIMES); const TASK_STATUS_SET = new Set(TASK_STATUSES); const TASK_DELIVERY_STATUSES = new Set([ diff --git a/src/tasks/task-status.ts b/src/tasks/task-status.ts index 74bb32bbe3f7..f7ce7c1f2c1d 100644 --- a/src/tasks/task-status.ts +++ b/src/tasks/task-status.ts @@ -6,7 +6,7 @@ import { INTERNAL_RUNTIME_CONTEXT_END, } from "../agents/internal-runtime-context.js"; import { truncateUtf16Safe } from "../utils.js"; -import type { TaskRecord } from "./task-registry.types.js"; +import { matchesTaskStatusFilter, type TaskRecord } from "./task-registry.types.js"; const ACTIVE_TASK_STATUSES = new Set(["queued", "running"]); const FAILURE_TASK_STATUSES = new Set(["failed", "timed_out", "lost", "blocked"]); @@ -20,9 +20,7 @@ function isActiveTask(task: TaskRecord): boolean { } export function formatTaskStatus(task: Pick) { - return task.status === "succeeded" && task.terminalOutcome === "blocked" - ? "blocked" - : task.status; + return matchesTaskStatusFilter(task, "blocked") ? "blocked" : task.status; } export function isTaskStatusIssue(task: Pick): boolean {