fix(status): filter stale task rows from status cards (#58810)

* fix(status): filter stale task rows

* test(status): use real task snapshot semantics

* fix(status): prefer failure task context in recent failures
This commit is contained in:
Vincent Koc 2026-04-01 16:19:02 +09:00 committed by GitHub
parent 9ab3352b1a
commit 340c99d657
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 340 additions and 47 deletions

View File

@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai
- Telegram/local Bot API: preserve media MIME types for absolute-path downloads so local audio files still trigger transcription and other MIME-based handling. (#54603) Thanks @jzakirov
- Tasks/gateway: re-check the current task record before maintenance marks runs lost or prunes them, so a task heartbeat or cleanup update that lands during a sweep no longer gets overwritten by stale snapshot state.
- Tasks/gateway: keep the task registry maintenance sweep from stalling the gateway event loop under synchronous SQLite pressure, so upgraded gateways stop hanging about a minute after startup. (#58670) Thanks @openperf
- Tasks/status: hide stale completed background tasks from `/status` and `session_status`, prefer live task context, and show recent failures only when no active work remains. (#58661) Thanks @vincentkoc
- Channels/WhatsApp: pass inbound message timestamp to model context so the AI can see when WhatsApp messages were sent. (#58590) Thanks @Maninae
- Web UI/OpenResponses: preserve rewritten stream snapshots in webchat and keep OpenResponses final streamed text aligned when models rewind earlier output. (#58641) Thanks @neeravmakwana
- MiniMax/plugins: auto-enable the bundled MiniMax plugin for API-key auth/config so MiniMax image generation and other plugin-owned capabilities load without manual plugin allowlisting. (#57127) Thanks @tars90percent.

View File

@ -1,6 +1,8 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { SessionEntry } from "../config/sessions.js";
import { resolvePreferredSessionKeyForSessionIdMatches } from "../sessions/session-id-resolution.js";
import type { TaskRecord } from "../tasks/task-registry.types.js";
import { buildTaskStatusSnapshot } from "../tasks/task-status.js";
const loadSessionStoreMock = vi.fn();
const updateSessionStoreMock = vi.fn();
@ -14,15 +16,11 @@ const listTasksForRelatedSessionKeyForOwnerMock = vi.hoisted(() =>
[] as Array<Record<string, unknown>>,
),
);
const resolveEnvApiKeyMock = vi.hoisted(
() => vi.fn((_provider?: string, _env?: NodeJS.ProcessEnv) => null),
const resolveEnvApiKeyMock = vi.hoisted(() =>
vi.fn((_provider?: string, _env?: NodeJS.ProcessEnv) => null),
);
const resolveUsableCustomProviderApiKeyMock = vi.hoisted(
() =>
vi.fn(
(_params?: { provider?: string }) =>
null as { apiKey: string; source: string } | null,
),
const resolveUsableCustomProviderApiKeyMock = vi.hoisted(() =>
vi.fn((_params?: { provider?: string }) => null as { apiKey: string; source: string } | null),
);
const createMockConfig = () => ({
@ -39,6 +37,7 @@ const createMockConfig = () => ({
});
let mockConfig: Record<string, unknown> = createMockConfig();
const TASK_STATUS_SNAPSHOT_NOW = 1_000_000_000_000;
function createScopedSessionStores() {
return new Map<string, Record<string, unknown>>([
@ -210,6 +209,13 @@ async function loadFreshOpenClawToolsForSessionStatusTest() {
relatedSessionKey: string;
callerOwnerKey: string;
}) => listTasksForRelatedSessionKeyForOwnerMock(params),
buildTaskStatusSnapshotForRelatedSessionKeyForOwner: (params: {
relatedSessionKey: string;
callerOwnerKey: string;
}) =>
buildTaskStatusSnapshot(listTasksForRelatedSessionKeyForOwnerMock(params) as TaskRecord[], {
now: TASK_STATUS_SNAPSHOT_NOW,
}),
}));
({ createSessionStatusTool } = await import("./tools/session-status-tool.js"));
}
@ -435,6 +441,126 @@ describe("session_status tool", () => {
expect(text).toContain("Indexing the latest threads");
});
it("hides stale completed task rows from session_status output", async () => {
resetSessionStore({
"agent:main:main": {
sessionId: "sess-main",
updatedAt: Date.now(),
},
});
listTasksForRelatedSessionKeyForOwnerMock.mockReturnValue([
{
taskId: "task-stale",
runtime: "cron",
requesterSessionKey: "agent:main:main",
task: "stale completed task",
status: "succeeded",
deliveryStatus: "delivered",
notifyPolicy: "done_only",
createdAt: Date.now() - 15 * 60_000,
terminalSummary: "finished long ago",
},
{
taskId: "task-live",
runtime: "subagent",
requesterSessionKey: "agent:main:main",
task: "live task",
status: "running",
deliveryStatus: "pending",
notifyPolicy: "done_only",
createdAt: Date.now() - 5_000,
progressSummary: "still working",
},
]);
const tool = createSessionStatusTool({ agentSessionKey: "agent:main:main" });
const result = await tool.execute("tc-stale", { sessionKey: "agent:main:main" });
const firstContent = result.content?.[0];
const text = (firstContent as { text: string } | undefined)?.text ?? "";
expect(text).toContain("📌 Tasks: 1 active");
expect(text).toContain("live task");
expect(text).not.toContain("stale completed task");
expect(text).not.toContain("finished long ago");
});
it("shows recent failure context in session_status output when no task is active", async () => {
resetSessionStore({
"agent:main:main": {
sessionId: "sess-main",
updatedAt: Date.now(),
},
});
listTasksForRelatedSessionKeyForOwnerMock.mockReturnValue([
{
taskId: "task-failed",
runtime: "cron",
requesterSessionKey: "agent:main:main",
task: "failing task",
status: "failed",
deliveryStatus: "pending",
notifyPolicy: "done_only",
createdAt: Date.now() - 5_000,
error: "permission denied",
},
]);
const tool = createSessionStatusTool({ agentSessionKey: "agent:main:main" });
const result = await tool.execute("tc-failed", { sessionKey: "agent:main:main" });
const firstContent = result.content?.[0];
const text = (firstContent as { text: string } | undefined)?.text ?? "";
expect(text).toContain("📌 Tasks: 1 recent failure");
expect(text).toContain("failing task");
expect(text).toContain("permission denied");
});
it("prefers failure context over newer success context in session_status output", async () => {
resetSessionStore({
"agent:main:main": {
sessionId: "sess-main",
updatedAt: Date.now(),
},
});
listTasksForRelatedSessionKeyForOwnerMock.mockReturnValue([
{
taskId: "task-failed",
runtime: "cron",
requesterSessionKey: "agent:main:main",
task: "failing task",
status: "failed",
deliveryStatus: "pending",
notifyPolicy: "done_only",
createdAt: Date.now() - 60_000,
endedAt: Date.now() - 30_000,
error: "permission denied",
},
{
taskId: "task-succeeded",
runtime: "subagent",
requesterSessionKey: "agent:main:main",
task: "successful task",
status: "succeeded",
deliveryStatus: "delivered",
notifyPolicy: "done_only",
createdAt: Date.now() - 10_000,
endedAt: Date.now(),
terminalSummary: "all done",
},
]);
const tool = createSessionStatusTool({ agentSessionKey: "agent:main:main" });
const result = await tool.execute("tc-failed-priority", { sessionKey: "agent:main:main" });
const firstContent = result.content?.[0];
const text = (firstContent as { text: string } | undefined)?.text ?? "";
expect(text).toContain("📌 Tasks: 1 recent failure");
expect(text).toContain("failing task");
expect(text).toContain("permission denied");
expect(text).not.toContain("successful task");
expect(text).not.toContain("all done");
});
it("resolves a literal current sessionId in session_status", async () => {
resetSessionStore({
main: {

View File

@ -22,7 +22,7 @@ import {
resolveAgentIdFromSessionKey,
} from "../../routing/session-key.js";
import { applyModelOverrideToSessionEntry } from "../../sessions/model-overrides.js";
import { listTasksForRelatedSessionKeyForOwner } from "../../tasks/task-owner-access.js";
import { buildTaskStatusSnapshotForRelatedSessionKeyForOwner } from "../../tasks/task-owner-access.js";
import { loadModelCatalog } from "../model-catalog.js";
import {
buildAllowedModelSet,
@ -119,32 +119,26 @@ function formatSessionTaskLine(params: {
relatedSessionKey: string;
callerOwnerKey: string;
}): string | undefined {
const tasks = listTasksForRelatedSessionKeyForOwner({
const snapshot = buildTaskStatusSnapshotForRelatedSessionKeyForOwner({
relatedSessionKey: params.relatedSessionKey,
callerOwnerKey: params.callerOwnerKey,
});
if (tasks.length === 0) {
const task = snapshot.focus;
if (!task) {
return undefined;
}
const latest = tasks[0];
const active = tasks.filter(
(task) => task.status === "queued" || task.status === "running",
).length;
const failed = tasks.filter(
(task) => task.status === "failed" || task.status === "timed_out" || task.status === "lost",
).length;
const headline =
active > 0
? `${active} active`
: failed > 0
? `${failed} recent failure${failed === 1 ? "" : "s"}`
: `latest ${latest.status.replaceAll("_", " ")}`;
const title = latest.label?.trim() || latest.task.trim();
snapshot.activeCount > 0
? `${snapshot.activeCount} active`
: snapshot.recentFailureCount > 0
? `${snapshot.recentFailureCount} recent failure${snapshot.recentFailureCount === 1 ? "" : "s"}`
: `latest ${task.status.replaceAll("_", " ")}`;
const title = task.label?.trim() || task.task.trim();
const detail =
latest.status === "running" || latest.status === "queued"
? latest.progressSummary?.trim()
: latest.error?.trim() || latest.terminalSummary?.trim();
const parts = [headline, latest.runtime, title, detail].filter(Boolean);
task.status === "running" || task.status === "queued"
? task.progressSummary?.trim()
: task.error?.trim() || task.terminalSummary?.trim();
const parts = [headline, task.runtime, title, detail].filter(Boolean);
return parts.length ? `📌 Tasks: ${parts.join(" · ")}` : undefined;
}

View File

@ -4,7 +4,12 @@ import {
resetSubagentRegistryForTests,
} from "../../agents/subagent-registry.js";
import type { OpenClawConfig } from "../../config/config.js";
import { createQueuedTaskRun, createRunningTaskRun } from "../../tasks/task-executor.js";
import {
completeTaskRunByRunId,
createQueuedTaskRun,
createRunningTaskRun,
failTaskRunByRunId,
} from "../../tasks/task-executor.js";
import { resetTaskRegistryForTests } from "../../tasks/task-registry.js";
import { buildStatusReply } from "./commands-status.js";
import { buildCommandTestParams } from "./commands.test-harness.js";
@ -206,6 +211,92 @@ describe("buildStatusReply subagent summary", () => {
expect(reply?.text).toMatch(/📌 Tasks: 2 active · 2 total · (subagent|cron) · /);
});
it("hides stale completed task rows from the session task line", async () => {
createRunningTaskRun({
runtime: "subagent",
requesterSessionKey: "agent:main:main",
childSessionKey: "agent:main:subagent:status-task-live",
runId: "run-status-task-live",
task: "live background task",
progressSummary: "still working",
});
createQueuedTaskRun({
runtime: "cron",
requesterSessionKey: "agent:main:main",
childSessionKey: "agent:main:subagent:status-task-stale-done",
runId: "run-status-task-stale-done",
task: "stale completed task",
});
completeTaskRunByRunId({
runId: "run-status-task-stale-done",
endedAt: Date.now() - 10 * 60_000,
terminalSummary: "done a while ago",
});
const reply = await buildStatusReplyForTest({});
expect(reply?.text).toContain("📌 Tasks: 1 active · 1 total");
expect(reply?.text).toContain("live background task");
expect(reply?.text).not.toContain("stale completed task");
expect(reply?.text).not.toContain("done a while ago");
});
it("shows a recent failure when no active tasks remain", async () => {
createRunningTaskRun({
runtime: "acp",
requesterSessionKey: "agent:main:main",
childSessionKey: "agent:main:acp:status-task-failed",
runId: "run-status-task-failed",
task: "failed background task",
});
failTaskRunByRunId({
runId: "run-status-task-failed",
endedAt: Date.now(),
error: "approval denied",
});
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");
});
it("prefers failure context over newer success context when showing recent failures", async () => {
createRunningTaskRun({
runtime: "acp",
requesterSessionKey: "agent:main:main",
childSessionKey: "agent:main:acp:status-task-failed-priority",
runId: "run-status-task-failed-priority",
task: "failed background task",
});
failTaskRunByRunId({
runId: "run-status-task-failed-priority",
endedAt: Date.now() - 30_000,
error: "approval denied",
});
createRunningTaskRun({
runtime: "subagent",
requesterSessionKey: "agent:main:main",
childSessionKey: "agent:main:subagent:status-task-succeeded-later",
runId: "run-status-task-succeeded-later",
task: "later successful task",
});
completeTaskRunByRunId({
runId: "run-status-task-succeeded-later",
endedAt: Date.now(),
terminalSummary: "all done",
});
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).not.toContain("later successful task");
expect(reply?.text).not.toContain("all done");
});
it("falls back to same-agent task counts without details when the current session has none", async () => {
createRunningTaskRun({
runtime: "subagent",

View File

@ -23,6 +23,7 @@ import {
} from "../../infra/provider-usage.js";
import type { MediaUnderstandingDecision } from "../../media-understanding/types.js";
import { listTasksForAgentId, listTasksForSessionKey } from "../../tasks/task-registry.js";
import { buildTaskStatusSnapshot } from "../../tasks/task-status.js";
import { normalizeGroupActivation } from "../group-activation.js";
import { resolveSelectedAndActiveModel } from "../model-runtime.js";
import { buildStatusMessage } from "../status.js";
@ -56,33 +57,32 @@ function shouldLoadUsageSummary(params: {
}
function formatSessionTaskLine(sessionKey: string): string | undefined {
const tasks = listTasksForSessionKey(sessionKey);
if (tasks.length === 0) {
const snapshot = buildTaskStatusSnapshot(listTasksForSessionKey(sessionKey));
const task = snapshot.focus;
if (!task) {
return undefined;
}
const latest = tasks[0];
const active = tasks.filter(
(task) => task.status === "queued" || task.status === "running",
).length;
const headline = `${active} active · ${tasks.length} total`;
const title = latest.label?.trim() || latest.task.trim();
const headline =
snapshot.activeCount > 0
? `${snapshot.activeCount} active · ${snapshot.totalCount} total`
: snapshot.recentFailureCount > 0
? `${snapshot.recentFailureCount} recent failure${snapshot.recentFailureCount === 1 ? "" : "s"}`
: "recently finished";
const title = task.label?.trim() || task.task.trim();
const detail =
latest.status === "running" || latest.status === "queued"
? latest.progressSummary?.trim()
: latest.error?.trim() || latest.terminalSummary?.trim();
const parts = [headline, latest.runtime, title, detail].filter(Boolean);
task.status === "running" || task.status === "queued"
? task.progressSummary?.trim()
: task.error?.trim() || task.terminalSummary?.trim();
const parts = [headline, task.runtime, title, detail].filter(Boolean);
return parts.length ? `📌 Tasks: ${parts.join(" · ")}` : undefined;
}
function formatAgentTaskCountsLine(agentId: string): string | undefined {
const tasks = listTasksForAgentId(agentId);
if (tasks.length === 0) {
const snapshot = buildTaskStatusSnapshot(listTasksForAgentId(agentId));
if (snapshot.totalCount === 0) {
return undefined;
}
const active = tasks.filter(
(task) => task.status === "queued" || task.status === "running",
).length;
return `📌 Tasks: ${active} active · ${tasks.length} total · agent-local`;
return `📌 Tasks: ${snapshot.activeCount} active · ${snapshot.totalCount} total · agent-local`;
}
export async function buildStatusReply(params: {

View File

@ -5,6 +5,7 @@ import {
resolveTaskForLookupToken,
} from "./task-registry.js";
import type { TaskRecord } from "./task-registry.types.js";
import { buildTaskStatusSnapshot } from "./task-status.js";
function normalizeOwnerKey(ownerKey?: string): string | undefined {
const trimmed = ownerKey?.trim();
@ -43,6 +44,18 @@ export function listTasksForRelatedSessionKeyForOwner(params: {
);
}
export function buildTaskStatusSnapshotForRelatedSessionKeyForOwner(params: {
relatedSessionKey: string;
callerOwnerKey: string;
}) {
return buildTaskStatusSnapshot(
listTasksForRelatedSessionKeyForOwner({
relatedSessionKey: params.relatedSessionKey,
callerOwnerKey: params.callerOwnerKey,
}),
);
}
export function findLatestTaskForRelatedSessionKeyForOwner(params: {
relatedSessionKey: string;
callerOwnerKey: string;

68
src/tasks/task-status.ts Normal file
View File

@ -0,0 +1,68 @@
import { reconcileTaskRecordForOperatorInspection } from "./task-registry.maintenance.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"]);
export const TASK_STATUS_RECENT_WINDOW_MS = 5 * 60_000;
function isActiveTask(task: TaskRecord): boolean {
return ACTIVE_TASK_STATUSES.has(task.status);
}
function isFailureTask(task: TaskRecord): boolean {
return FAILURE_TASK_STATUSES.has(task.status);
}
function resolveTaskReferenceAt(task: TaskRecord): number {
if (isActiveTask(task)) {
return task.lastEventAt ?? task.startedAt ?? task.createdAt;
}
return task.endedAt ?? task.lastEventAt ?? task.startedAt ?? task.createdAt;
}
function isExpiredTask(task: TaskRecord, now: number): boolean {
return typeof task.cleanupAfter === "number" && task.cleanupAfter <= now;
}
function isRecentTerminalTask(task: TaskRecord, now: number): boolean {
if (isActiveTask(task)) {
return false;
}
return now - resolveTaskReferenceAt(task) <= TASK_STATUS_RECENT_WINDOW_MS;
}
export type TaskStatusSnapshot = {
latest?: TaskRecord;
focus?: TaskRecord;
visible: TaskRecord[];
active: TaskRecord[];
recentTerminal: TaskRecord[];
activeCount: number;
totalCount: number;
recentFailureCount: number;
};
export function buildTaskStatusSnapshot(
tasks: TaskRecord[],
opts?: { now?: number },
): TaskStatusSnapshot {
const now = opts?.now ?? Date.now();
const reconciled = tasks
.map((task) => reconcileTaskRecordForOperatorInspection(task))
.filter((task) => !isExpiredTask(task, now));
const active = reconciled.filter(isActiveTask);
const recentTerminal = reconciled.filter((task) => isRecentTerminalTask(task, now));
const visible = active.length > 0 ? [...active, ...recentTerminal] : recentTerminal;
const focus =
active[0] ?? recentTerminal.find((task) => isFailureTask(task)) ?? recentTerminal[0];
return {
latest: active[0] ?? recentTerminal[0],
focus,
visible,
active,
recentTerminal,
activeCount: active.length,
totalCount: visible.length,
recentFailureCount: recentTerminal.filter(isFailureTask).length,
};
}