diff --git a/CHANGELOG.md b/CHANGELOG.md index bf4f8170495..a5c53812c2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/agents/openclaw-tools.session-status.test.ts b/src/agents/openclaw-tools.session-status.test.ts index 8b96463c0da..e5351a7f8e0 100644 --- a/src/agents/openclaw-tools.session-status.test.ts +++ b/src/agents/openclaw-tools.session-status.test.ts @@ -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>, ), ); -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 = createMockConfig(); +const TASK_STATUS_SNAPSHOT_NOW = 1_000_000_000_000; function createScopedSessionStores() { return new Map>([ @@ -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: { diff --git a/src/agents/tools/session-status-tool.ts b/src/agents/tools/session-status-tool.ts index 1a6778ca95b..9aa8f900b48 100644 --- a/src/agents/tools/session-status-tool.ts +++ b/src/agents/tools/session-status-tool.ts @@ -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; } diff --git a/src/auto-reply/reply/commands-status.test.ts b/src/auto-reply/reply/commands-status.test.ts index 2838bda6509..385d0e6fcb4 100644 --- a/src/auto-reply/reply/commands-status.test.ts +++ b/src/auto-reply/reply/commands-status.test.ts @@ -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", diff --git a/src/auto-reply/reply/commands-status.ts b/src/auto-reply/reply/commands-status.ts index da2b427a722..efbde555acf 100644 --- a/src/auto-reply/reply/commands-status.ts +++ b/src/auto-reply/reply/commands-status.ts @@ -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: { diff --git a/src/tasks/task-owner-access.ts b/src/tasks/task-owner-access.ts index eaf51126037..7cc956e586e 100644 --- a/src/tasks/task-owner-access.ts +++ b/src/tasks/task-owner-access.ts @@ -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; diff --git a/src/tasks/task-status.ts b/src/tasks/task-status.ts new file mode 100644 index 00000000000..ff94903f54a --- /dev/null +++ b/src/tasks/task-status.ts @@ -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, + }; +}