mirror of https://github.com/openclaw/openclaw.git
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:
parent
9ab3352b1a
commit
340c99d657
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
Loading…
Reference in New Issue