mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 04:15:48 -06:00
fix(tasks): show blocked outcomes across human and agent status (#129101)
This commit is contained in:
committed by
GitHub
parent
f657a5e31a
commit
84dfcf27a3
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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}`,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user