fix(tasks): show blocked outcomes across human and agent status (#129101)

This commit is contained in:
Peter Steinberger
2026-08-25 01:06:15 -07:00
committed by GitHub
parent f657a5e31a
commit 84dfcf27a3
12 changed files with 184 additions and 47 deletions
@@ -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 () => {
+8 -3
View File
@@ -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;
}
+10 -10
View File
@@ -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 () => {
@@ -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",
+7 -9
View File
@@ -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<TaskRecord["status"], string> = {
const TASK_STATUS_ICONS: Record<ReturnType<typeof formatTaskStatus>, 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}`);
}
+34
View File
@@ -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 }),
});
});
});
+7 -3
View File
@@ -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}`,
),
);
}
+42 -1
View File
@@ -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({
+11 -4
View File
@@ -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}`,
+3 -1
View File
@@ -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;
}
+19
View File
@@ -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", () => {
+11 -6
View File
@@ -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<TaskRecord, "status" | "terminalOutcome">) {
return task.status === "succeeded" && task.terminalOutcome === "blocked"
? "blocked"
: task.status;
}
export function isTaskStatusIssue(task: Pick<TaskRecord, "status" | "terminalOutcome">): 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,
};
}