fix(tasks): blocked completion outcomes cannot be filtered (#129196)

* fix(tasks): make blocked completion outcomes filterable

* refactor(tasks): remove unused raw status export

* test(tasks): update invalid status filter expectations
This commit is contained in:
Peter Steinberger
2026-08-25 03:02:11 -07:00
committed by GitHub
parent d632304dc4
commit b9d522738d
10 changed files with 121 additions and 22 deletions
+5
View File
@@ -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
```
</Tab>
@@ -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:
+11 -5
View File
@@ -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 <lookup>
openclaw tasks notify <lookup> state_changes
openclaw tasks cancel <lookup>
@@ -34,11 +35,11 @@ openclaw tasks flow cancel <lookup>
## Root Options
| Flag | Description |
| ------------------ | -------------------------------------------------------------------------------------------------- |
| `--json` | Output JSON. |
| `--runtime <name>` | Filter by kind: `subagent`, `acp`, `cron`, or `cli`. |
| `--status <name>` | Filter by status: `queued`, `running`, `succeeded`, `failed`, `timed_out`, `cancelled`, or `lost`. |
| Flag | Description |
| ------------------ | ------------------------------------------------------------------------------------------------------------- |
| `--json` | Output JSON. |
| `--runtime <name>` | Filter by kind: `subagent`, `acp`, `cron`, or `cli`. |
| `--status <name>` | Filter by status: `queued`, `running`, `succeeded`, `failed`, `timed_out`, `cancelled`, `lost`, or `blocked`. |
## Subcommands
@@ -50,6 +51,11 @@ openclaw tasks list [--runtime <name>] [--status <name>] [--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
+31
View File
@@ -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"],
+2 -2
View File
@@ -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 <name>", `Filter by kind (${TASK_RUNTIMES.join(", ")})`)
.option("--status <name>", `Filter by status (${TASK_STATUSES.join(", ")})`);
.option("--status <name>", `Filter by status (${TASK_STATUS_FILTERS.join(", ")})`);
}
function isTaskNotifyPolicy(value: unknown): value is TaskNotifyPolicy {
+42 -2
View File
@@ -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) =>
+8 -3
View File
@@ -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;
+2 -2
View File
@@ -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
+4 -3
View File
@@ -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;
+14 -1
View File
@@ -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<TaskStatus, number>;
export type TaskRuntimeCounts = Record<TaskRuntime, number>;
export function matchesTaskStatusFilter(
task: Pick<TaskRecord, "status" | "terminalOutcome">,
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<TaskRuntime>(TASK_RUNTIMES);
const TASK_STATUS_SET = new Set<TaskStatus>(TASK_STATUSES);
const TASK_DELIVERY_STATUSES = new Set<TaskDeliveryStatus>([
+2 -4
View File
@@ -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<TaskRecord, "status" | "terminalOutcome">) {
return task.status === "succeeded" && task.terminalOutcome === "blocked"
? "blocked"
: task.status;
return matchesTaskStatusFilter(task, "blocked") ? "blocked" : task.status;
}
export function isTaskStatusIssue(task: Pick<TaskRecord, "status" | "terminalOutcome">): boolean {