From 84dfcf27a3cec2cfd31bcd58f6b8e9378b3e7788 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 25 Aug 2026 01:06:15 -0700 Subject: [PATCH] fix(tasks): show blocked outcomes across human and agent status (#129101) --- .../openclaw-tools.session-status.test.ts | 20 ++++----- src/agents/tools/session-status-tool.ts | 11 +++-- src/auto-reply/reply/commands-status.test.ts | 20 ++++----- src/auto-reply/reply/commands-tasks.test.ts | 22 ++++++++++ src/auto-reply/reply/commands-tasks.ts | 16 +++---- src/commands/flows.test.ts | 34 +++++++++++++++ src/commands/flows.ts | 10 +++-- src/commands/tasks-json.test.ts | 43 ++++++++++++++++++- src/commands/tasks.ts | 15 +++++-- src/status/status-text.ts | 4 +- src/tasks/task-status.test.ts | 19 ++++++++ src/tasks/task-status.ts | 17 +++++--- 12 files changed, 184 insertions(+), 47 deletions(-) diff --git a/src/agents/openclaw-tools.session-status.test.ts b/src/agents/openclaw-tools.session-status.test.ts index 86ead4b634a1..f24afde6695c 100644 --- a/src/agents/openclaw-tools.session-status.test.ts +++ b/src/agents/openclaw-tools.session-status.test.ts @@ -1737,27 +1737,27 @@ describe("session_status tool", () => { expect(text).not.toContain("finished long ago"); }); - it("shows recent failure context in session_status output when no task is active", async () => { + it("shows blocked completion outcomes in session_status output", async () => { const text = await renderTaskStatus( [ { - taskId: "task-failed", + taskId: "task-blocked", runtime: "cron", requesterSessionKey: "agent:main:main", - task: "failing task", - status: "failed", - deliveryStatus: "pending", + task: "blocked task", + status: "succeeded", + terminalOutcome: "blocked", notifyPolicy: "done_only", createdAt: Date.now() - 5_000, - error: "permission denied", + terminalSummary: "Additional input required.", }, ], - "tc-failed", + "tc-blocked", ); - expect(text).toContain("📌 Tasks: 1 recent failure"); - expect(text).toContain("failing task"); - expect(text).toContain("permission denied"); + expect(text).toContain("📌 Tasks: 1 recent failure · blocked"); + expect(text).toContain("blocked task"); + expect(text).toContain("Additional input required."); }); it("truncates long task titles and details in session_status output", async () => { diff --git a/src/agents/tools/session-status-tool.ts b/src/agents/tools/session-status-tool.ts index ccb3942617cd..15b48facabff 100644 --- a/src/agents/tools/session-status-tool.ts +++ b/src/agents/tools/session-status-tool.ts @@ -39,7 +39,11 @@ import { import { createLazyImportLoader } from "../../shared/lazy-promise.js"; import type { BuildStatusTextParams } from "../../status/status-text.types.js"; import { buildTaskStatusSnapshotForRelatedSessionKeyForOwner } from "../../tasks/task-owner-access.js"; -import { formatTaskStatusDetail, formatTaskStatusTitle } from "../../tasks/task-status.js"; +import { + formatTaskStatus, + formatTaskStatusDetail, + formatTaskStatusTitle, +} from "../../tasks/task-status.js"; import { deliveryContextFromSession, normalizeDeliveryContext, @@ -455,10 +459,11 @@ function formatSessionTaskLine(params: { ? `${snapshot.activeCount} active` : snapshot.recentFailureCount > 0 ? `${snapshot.recentFailureCount} recent failure${snapshot.recentFailureCount === 1 ? "" : "s"}` - : `latest ${task.status.replaceAll("_", " ")}`; + : `latest ${formatTaskStatus(task).replaceAll("_", " ")}`; const title = formatTaskStatusTitle(task); const detail = formatTaskStatusDetail(task); - const parts = [headline, task.runtime, title, detail].filter(Boolean); + const blocked = formatTaskStatus(task) === "blocked" ? "blocked" : undefined; + const parts = [headline, blocked, task.runtime, title, detail].filter(Boolean); return parts.length ? `📌 Tasks: ${parts.join(" · ")}` : undefined; } diff --git a/src/auto-reply/reply/commands-status.test.ts b/src/auto-reply/reply/commands-status.test.ts index d42f1e433e7e..ec035b5bd81b 100644 --- a/src/auto-reply/reply/commands-status.test.ts +++ b/src/auto-reply/reply/commands-status.test.ts @@ -496,25 +496,25 @@ describe("buildStatusReply subagent summary", () => { expect(reply?.text).not.toContain("done a while ago"); }); - it("shows a recent failure when no active tasks remain", async () => { + it("shows blocked completion outcomes when no active tasks remain", async () => { createRunningTaskRunCore({ runtime: "acp", requesterSessionKey: "agent:main:main", - childSessionKey: "agent:main:acp:status-task-failed", - runId: "run-status-task-failed", - task: "failed background task", + runId: "run-status-task-blocked", + task: "blocked background task", }); - failTaskRunByRunIdCore({ - runId: "run-status-task-failed", + completeTaskRunByRunIdCore({ + runId: "run-status-task-blocked", endedAt: Date.now(), - error: "approval denied", + terminalOutcome: "blocked", + terminalSummary: "Additional input required.", }); const reply = await buildStatusReplyForTest({}); - expect(reply?.text).toContain("📌 Tasks: 1 recent failure"); - expect(reply?.text).toContain("failed background task"); - expect(reply?.text).toContain("approval denied"); + expect(reply?.text).toContain("📌 Tasks: 1 recent failure · blocked"); + expect(reply?.text).toContain("blocked background task"); + expect(reply?.text).toContain("Additional input required."); }); it("does not leak internal runtime context through the task status line", async () => { diff --git a/src/auto-reply/reply/commands-tasks.test.ts b/src/auto-reply/reply/commands-tasks.test.ts index 1ae095e83461..77cbbfeef610 100644 --- a/src/auto-reply/reply/commands-tasks.test.ts +++ b/src/auto-reply/reply/commands-tasks.test.ts @@ -93,6 +93,28 @@ describe("handleTasksCommand task board", () => { expect(reply.text).toContain("approval denied"); }); + it("shows blocked completions as warnings instead of successes", async () => { + createRunningTaskRunCore({ + runtime: "subagent", + requesterSessionKey: "agent:main:main", + childSessionKey: "agent:main:subagent:tasks-blocked", + runId: "run-tasks-blocked", + task: "Incomplete background task", + }); + completeTaskRunByRunIdCore({ + runId: "run-tasks-blocked", + endedAt: Date.now(), + terminalOutcome: "blocked", + terminalSummary: "Required completion did not produce a final deliverable.", + }); + + const reply = await buildTasksReplyForTest(); + + expect(reply.text).toContain("⚠️ Incomplete background task"); + expect(reply.text).toContain("Subagent · blocked"); + expect(reply.text).not.toContain("✅ Incomplete background task"); + }); + it("lists session-backed video generation tasks for the current session", async () => { createRunningTaskRunCore({ runtime: "cli", diff --git a/src/auto-reply/reply/commands-tasks.ts b/src/auto-reply/reply/commands-tasks.ts index 11511f3d5a4d..de9fb10ea8e4 100644 --- a/src/auto-reply/reply/commands-tasks.ts +++ b/src/auto-reply/reply/commands-tasks.ts @@ -9,6 +9,7 @@ import { } from "../../tasks/task-status-access.js"; import { buildTaskStatusSnapshot, + formatTaskStatus, formatTaskStatusDetail, formatTaskStatusTitle, } from "../../tasks/task-status.js"; @@ -18,10 +19,11 @@ import type { CommandHandler, HandleCommandsParams } from "./commands-types.js"; const MAX_VISIBLE_TASKS = 5; -const TASK_STATUS_ICONS: Record = { +const TASK_STATUS_ICONS: Record, string> = { queued: "🟡", running: "🟢", succeeded: "✅", + blocked: "⚠️", failed: "🔴", timed_out: "⏱️", cancelled: "⚪️", @@ -62,20 +64,16 @@ function formatTaskTiming(task: TaskRecord): string | undefined { return `finished ${formatTimeAgo(Date.now() - endedAt)}`; } -function formatTaskDetail(task: TaskRecord): string | undefined { - return formatTaskStatusDetail(task); -} - function formatVisibleTask(task: TaskRecord, index: number): string { const title = formatTaskStatusTitle(task); - const status = task.status.replaceAll("_", " "); + const status = formatTaskStatus(task); const timing = formatTaskTiming(task); - const detail = formatTaskDetail(task); - let meta = `${TASK_RUNTIME_LABELS[task.runtime]} · ${status}`; + const detail = formatTaskStatusDetail(task); + let meta = `${TASK_RUNTIME_LABELS[task.runtime]} · ${status.replaceAll("_", " ")}`; if (timing) { meta += ` · ${timing}`; } - const lines = [`${index + 1}. ${TASK_STATUS_ICONS[task.status]} ${title}`, ` ${meta}`]; + const lines = [`${index + 1}. ${TASK_STATUS_ICONS[status]} ${title}`, ` ${meta}`]; if (detail) { lines.push(` ${detail}`); } diff --git a/src/commands/flows.test.ts b/src/commands/flows.test.ts index 9c871a3d4b00..f51878ae62f9 100644 --- a/src/commands/flows.test.ts +++ b/src/commands/flows.test.ts @@ -484,17 +484,51 @@ describe("flows commands", () => { endedAt: Date.now(), terminalSummary: "Provider metadata refreshed", }); + const blocked = createRunningTaskRunCore({ + runtime: "subagent", + ownerKey: "agent:main:main", + scopeKind: "session", + parentFlowId: flow.flowId, + childSessionKey: "agent:main:flow-child-blocked", + runId: "run-flow-child-blocked", + label: "Inspect blocked child", + task: "Inspect blocked child", + notifyPolicy: "silent", + startedAt: Date.now(), + }); + markTaskTerminalById({ + taskId: blocked.taskId, + status: "succeeded", + terminalOutcome: "blocked", + endedAt: Date.now(), + terminalSummary: "Required completion did not produce a final deliverable.", + }); const runtime = createRuntime(); await flowsShowCommand({ lookup: flow.flowId }, runtime); const lines = vi.mocked(runtime.log).mock.calls.map(([line]) => String(line)); + expect(lines).toContain("tasks: 3 total · 1 active · 1 issues"); expect(lines.find((line) => line.startsWith(`- ${running.taskId} `))).toContain( "Downloading provider metadata", ); expect(lines.find((line) => line.startsWith(`- ${completed.taskId} `))).toContain( "Provider metadata refreshed", ); + expect(lines.find((line) => line.startsWith(`- ${blocked.taskId} `))).toContain(" blocked "); + + const jsonRuntime = createRuntime(); + await flowsShowCommand({ lookup: flow.flowId, json: true }, jsonRuntime); + expect(vi.mocked(jsonRuntime.writeJson).mock.calls[0]?.[0]).toMatchObject({ + tasks: expect.arrayContaining([ + expect.objectContaining({ + taskId: blocked.taskId, + status: "succeeded", + terminalOutcome: "blocked", + }), + ]), + taskSummary: expect.objectContaining({ failures: 0 }), + }); }); }); diff --git a/src/commands/flows.ts b/src/commands/flows.ts index a4afea2793c4..64a45dc46fe6 100644 --- a/src/commands/flows.ts +++ b/src/commands/flows.ts @@ -24,7 +24,11 @@ import { resolveTaskFlowForLookupToken, } from "../tasks/task-flow-runtime-internal.js"; import { isTerminalFlowStatus } from "../tasks/task-registry-common.js"; -import { formatTaskStatusDetail } from "../tasks/task-status.js"; +import { + formatTaskStatus, + formatTaskStatusDetail, + isTaskStatusIssue, +} from "../tasks/task-status.js"; const ID_PAD = 10; const STATUS_PAD = 10; @@ -248,7 +252,7 @@ export async function flowsShowCommand( `createdAt: ${formatFlowTimestamp(flow.createdAt)}`, `updatedAt: ${formatFlowTimestamp(flow.updatedAt)}`, `endedAt: ${formatFlowTimestamp(flow.endedAt)}`, - `tasks: ${taskSummary.total} total · ${taskSummary.active} active · ${taskSummary.failures} issues`, + `tasks: ${taskSummary.total} total · ${taskSummary.active} active · ${tasks.filter(isTaskStatusIssue).length} issues`, ]; for (const line of lines) { runtime.log(sanitizeTerminalText(line)); @@ -264,7 +268,7 @@ export async function flowsShowCommand( const safeDetail = detail ? ` · ${safeFlowDisplayText(detail)}` : ""; runtime.log( sanitizeTerminalText( - `- ${task.taskId} ${task.status} ${safeFlowDisplayText(task.runId)} ${safeLabel}${safeDetail}`, + `- ${task.taskId} ${formatTaskStatus(task)} ${safeFlowDisplayText(task.runId)} ${safeLabel}${safeDetail}`, ), ); } diff --git a/src/commands/tasks-json.test.ts b/src/commands/tasks-json.test.ts index 66efaa3a8f82..f516083deb81 100644 --- a/src/commands/tasks-json.test.ts +++ b/src/commands/tasks-json.test.ts @@ -5,7 +5,10 @@ import type { RuntimeEnv } from "../runtime.js"; import * as taskRuntime from "../tasks/runtime-internal.js"; import { createManagedTaskFlow as createManagedTaskFlowOrNull } from "../tasks/task-flow-registry.js"; import type { TaskFlowRecord } from "../tasks/task-flow-registry.types.js"; -import { createTaskRecord as createTaskRecordOrNull } from "../tasks/task-registry.js"; +import { + createTaskRecord as createTaskRecordOrNull, + markTaskTerminalById, +} from "../tasks/task-registry.js"; import type { TaskRecord } from "../tasks/task-registry.types.js"; import { configureTaskFlowRegistryRuntime, @@ -19,6 +22,7 @@ import type { } from "../tasks/task-system-audit.types.js"; import { withOpenClawTestState } from "../test-utils/openclaw-test-state.js"; import { tasksAuditJsonCommand, tasksListJsonCommand } from "./tasks-json.js"; +import { tasksListCommand, tasksShowCommand } from "./tasks.js"; function createRuntime(): RuntimeEnv { return { @@ -129,6 +133,43 @@ describe("tasks JSON commands", () => { }); }); + it("shows blocked completion outcomes without changing task JSON or filters", async () => { + await withTaskJsonStateDir(async () => { + const task = createTaskRecord({ + runtime: "cli", + ownerKey: "agent:main:main", + scopeKind: "session", + status: "running", + runId: "task-list-blocked", + task: "Inspect an incomplete background task", + }); + markTaskTerminalById({ + taskId: task.taskId, + status: "succeeded", + terminalOutcome: "blocked", + terminalSummary: "Required completion did not produce a final deliverable.", + endedAt: Date.now(), + }); + + const listRuntime = createRuntime(); + await tasksListCommand({ status: "succeeded" }, listRuntime); + const listOutput = vi.mocked(listRuntime.log).mock.calls.flat().join("\n"); + expect(listOutput).toContain("Task pressure: 0 queued · 0 running · 1 issues"); + expect(listOutput).toMatch(/\bblocked\s+pending\b/); + + const showRuntime = createRuntime(); + await tasksShowCommand({ lookup: task.taskId }, showRuntime); + expect(vi.mocked(showRuntime.log).mock.calls.flat().join("\n")).toContain("status: blocked"); + + const jsonRuntime = createRuntime(); + await tasksShowCommand({ lookup: task.taskId, json: true }, jsonRuntime); + expect(readJsonLog(jsonRuntime)).toMatchObject({ + status: "succeeded", + terminalOutcome: "blocked", + }); + }); + }); + it("reports blank list filters as absent in JSON output", async () => { await withTaskJsonStateDir(async () => { const task = createTaskRecord({ diff --git a/src/commands/tasks.ts b/src/commands/tasks.ts index 4398526018fc..d334e5ae9161 100644 --- a/src/commands/tasks.ts +++ b/src/commands/tasks.ts @@ -44,7 +44,11 @@ import { type TaskNotifyPolicy, type TaskRecord, } from "../tasks/task-registry.types.js"; -import { formatTaskStatusDetail } from "../tasks/task-status.js"; +import { + formatTaskStatus, + formatTaskStatusDetail, + isTaskStatusIssue, +} from "../tasks/task-status.js"; import { TASK_SYSTEM_AUDIT_CODES, TASK_SYSTEM_AUDIT_SEVERITIES, @@ -158,6 +162,9 @@ function formatTaskStatusCell(status: string, rich: boolean) { if (status === "running") { return theme.accentBright(padded); } + if (status === "blocked") { + return theme.warn(padded); + } return theme.muted(padded); } @@ -182,7 +189,7 @@ function formatTaskRows(tasks: TaskRecord[], rich: boolean) { const line = [ shortToken(task.taskId).padEnd(ID_PAD), task.runtime.padEnd(RUNTIME_PAD), - formatTaskStatusCell(task.status, rich), + formatTaskStatusCell(formatTaskStatus(task), rich), task.deliveryStatus.padEnd(DELIVERY_PAD), shortToken(task.runId, RUN_PAD).padEnd(RUN_PAD), shortToken(task.childSessionKey, 36).padEnd(36), @@ -195,7 +202,7 @@ function formatTaskRows(tasks: TaskRecord[], rich: boolean) { function formatTaskListSummary(tasks: TaskRecord[]) { const summary = summarizeTaskRecords(tasks); - return `${summary.byStatus.queued} queued · ${summary.byStatus.running} running · ${summary.failures} issues`; + return `${summary.byStatus.queued} queued · ${summary.byStatus.running} running · ${tasks.filter(isTaskStatusIssue).length} issues`; } function formatAgeMs(ageMs: number | undefined): string { @@ -344,7 +351,7 @@ export async function tasksShowCommand( `taskId: ${task.taskId}`, `kind: ${task.runtime}`, `sourceId: ${task.sourceId ?? "n/a"}`, - `status: ${task.status}`, + `status: ${formatTaskStatus(task)}`, `result: ${task.terminalOutcome ?? "n/a"}`, `delivery: ${task.deliveryStatus}`, `notify: ${task.notifyPolicy}`, diff --git a/src/status/status-text.ts b/src/status/status-text.ts index 550bbaa0539c..a24f520c5687 100644 --- a/src/status/status-text.ts +++ b/src/status/status-text.ts @@ -48,6 +48,7 @@ import { } from "../tasks/task-status-access.js"; import { buildTaskStatusSnapshot, + formatTaskStatus, formatTaskStatusDetail, formatTaskStatusTitle, } from "../tasks/task-status.js"; @@ -204,7 +205,8 @@ function formatSessionTaskLine(sessionKey: string): string | undefined { : "recently finished"; const title = formatTaskStatusTitle(task); const detail = formatTaskStatusDetail(task); - const parts = [headline, task.runtime, title, detail].filter(Boolean); + const blocked = formatTaskStatus(task) === "blocked" ? "blocked" : undefined; + const parts = [headline, blocked, task.runtime, title, detail].filter(Boolean); return parts.length ? `📌 Tasks: ${parts.join(" · ")}` : undefined; } diff --git a/src/tasks/task-status.test.ts b/src/tasks/task-status.test.ts index b94b6f096a42..7bf543fd0bc4 100644 --- a/src/tasks/task-status.test.ts +++ b/src/tasks/task-status.test.ts @@ -56,6 +56,25 @@ describe("task status snapshot", () => { expect(snapshot.totalCount).toBe(0); expect(snapshot.focus).toBeUndefined(); }); + + it("focuses blocked completions ahead of ordinary successes", () => { + const completed = makeTask({ + taskId: "completed", + status: "succeeded", + endedAt: NOW - 100, + }); + const blocked = makeTask({ + taskId: "blocked", + status: "succeeded", + terminalOutcome: "blocked", + endedAt: NOW - 200, + }); + + const snapshot = buildTaskStatusSnapshot([completed, blocked], { now: NOW }); + + expect(snapshot.focus?.taskId).toBe("blocked"); + expect(snapshot.recentFailureCount).toBe(1); + }); }); describe("task status formatting", () => { diff --git a/src/tasks/task-status.ts b/src/tasks/task-status.ts index 8e12c3ddcbe9..74bb32bbe3f7 100644 --- a/src/tasks/task-status.ts +++ b/src/tasks/task-status.ts @@ -9,7 +9,7 @@ import { truncateUtf16Safe } from "../utils.js"; import 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"]); +const FAILURE_TASK_STATUSES = new Set(["failed", "timed_out", "lost", "blocked"]); /** Window for showing recently completed tasks in compact status output. */ const TASK_STATUS_RECENT_WINDOW_MS = 5 * 60_000; const TASK_STATUS_TITLE_MAX_CHARS = 80; @@ -19,8 +19,14 @@ function isActiveTask(task: TaskRecord): boolean { return ACTIVE_TASK_STATUSES.has(task.status); } -function isFailureTask(task: TaskRecord): boolean { - return FAILURE_TASK_STATUSES.has(task.status); +export function formatTaskStatus(task: Pick) { + return task.status === "succeeded" && task.terminalOutcome === "blocked" + ? "blocked" + : task.status; +} + +export function isTaskStatusIssue(task: Pick): boolean { + return FAILURE_TASK_STATUSES.has(formatTaskStatus(task)); } function resolveTaskReferenceAt(task: TaskRecord): number { @@ -181,8 +187,7 @@ export function buildTaskStatusSnapshot( const active = visibleCandidates.filter(isActiveTask); const recentTerminal = visibleCandidates.filter((task) => isRecentTerminalTask(task, now)); const visible = active.length > 0 ? [...active, ...recentTerminal] : recentTerminal; - const focus = - active[0] ?? recentTerminal.find((task) => isFailureTask(task)) ?? recentTerminal[0]; + const focus = active[0] ?? recentTerminal.find(isTaskStatusIssue) ?? recentTerminal[0]; return { latest: active[0] ?? recentTerminal[0], focus, @@ -191,6 +196,6 @@ export function buildTaskStatusSnapshot( recentTerminal, activeCount: active.length, totalCount: visible.length, - recentFailureCount: recentTerminal.filter(isFailureTask).length, + recentFailureCount: recentTerminal.filter(isTaskStatusIssue).length, }; }