mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 04:15:48 -06:00
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:
committed by
GitHub
parent
d632304dc4
commit
b9d522738d
@@ -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
@@ -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
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>([
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user