From f47b7fb6088b5d8e375f9c80956252924e696891 Mon Sep 17 00:00:00 2001 From: tt-a1i Date: Mon, 24 Aug 2026 23:11:58 +0800 Subject: [PATCH] fix(workflows): fail closed on unknown persisted states --- extensions/workflows/dashboard.test.ts | 65 ++++++++++++++++++++++++++ extensions/workflows/dashboard.ts | 56 ++++++++++++++++------ 2 files changed, 106 insertions(+), 15 deletions(-) diff --git a/extensions/workflows/dashboard.test.ts b/extensions/workflows/dashboard.test.ts index ea1de41b..45f66430 100644 --- a/extensions/workflows/dashboard.test.ts +++ b/extensions/workflows/dashboard.test.ts @@ -18,6 +18,7 @@ import { import type { TUI } from "@earendil-works/pi-tui"; import type { Theme, WorkflowDetails } from "./model.ts"; import { SPINNER_INTERVAL_MS } from "../shared/spinner.ts"; +import { safeStringify } from "./serialization.ts"; // runsDir() resolves against getAgentDir(), which reads this env var. const agentDir = mkdtempSync(join(tmpdir(), "my-pi-setup-workflows-")); @@ -85,6 +86,70 @@ test("persisted nonterminal invocation facts are projected as uncertain", () => assert.equal(restored?.agents[0]?.invocation?.outcome, "uncertain"); }); +test("unknown or missing persisted states fail closed without breaking known aliases", () => { + for (const state of [undefined, "future-state"]) { + const restored = normalizePersistedWorkflowDetails("wf_unknown", { + ...(state === undefined ? {} : { status: state }), + agents: [ + { + index: 1, + label: "unknown", + ...(state === undefined ? {} : { state }), + }, + ], + phases: [], + }); + assert.equal(restored?.status, "uncertain"); + assert.equal(restored?.agents[0]?.state, "uncertain"); + } + + const legacy = normalizePersistedWorkflowDetails("wf_legacy_aliases", { + status: "completed", + agents: [ + { index: 1, label: "done", state: "completed" }, + { index: 2, label: "failed", state: "failed" }, + ], + phases: [], + }); + assert.equal(legacy?.status, "completed"); + assert.deepEqual( + legacy?.agents.map((agent) => agent.state), + ["done", "error"], + ); + + const unknownAgent = normalizePersistedWorkflowDetails("wf_unknown_agent", { + status: "completed", + agents: [{ index: 1, label: "unknown" }], + phases: [], + }); + assert.equal(unknownAgent?.status, "uncertain"); + assert.equal(unknownAgent?.agents[0]?.state, "uncertain"); +}); + +test("an oversized workflow truncation stub cannot become completed", () => { + const stub = JSON.parse( + safeStringify( + { status: "completed", payload: "x".repeat(2_000) }, + { maxBytes: 256 }, + ), + ); + assert.equal(stub.truncated, true); + assert.equal( + normalizePersistedWorkflowDetails("wf_truncated", stub)?.status, + "uncertain", + ); +}); + +test("a terminal persisted run cannot retain running agents", () => { + const restored = normalizePersistedWorkflowDetails("wf_contradictory", { + status: "completed", + agents: [{ index: 1, label: "still running", state: "running" }], + phases: [], + }); + assert.equal(restored?.status, "uncertain"); + assert.equal(restored?.agents[0]?.state, "uncertain"); +}); + test("persisted transcripts retain exact tool call identities", () => { const details = normalizePersistedWorkflowDetails("wf_tools", { status: "completed", diff --git a/extensions/workflows/dashboard.ts b/extensions/workflows/dashboard.ts index 705d6248..a37152eb 100644 --- a/extensions/workflows/dashboard.ts +++ b/extensions/workflows/dashboard.ts @@ -268,6 +268,35 @@ function normalizeTranscript(value: unknown): TranscriptEntry[] { return transcript; } +function normalizeAgentState(value: unknown): AgentRecord["state"] { + switch (value) { + case "done": + case "completed": + return "done"; + case "error": + case "failed": + return "error"; + case "running": + case "uncertain": + return value; + default: + return "uncertain"; + } +} + +function normalizeWorkflowStatus(value: unknown): WorkflowDetails["status"] { + switch (value) { + case "running": + case "completed": + case "failed": + case "aborted": + case "uncertain": + return value; + default: + return "uncertain"; + } +} + /** Leniently normalize a workflow.json (including runs from older tooling). */ export function normalizePersistedWorkflowDetails( runId: string, @@ -283,14 +312,7 @@ export function normalizePersistedWorkflowDetails( for (const item of rawAgents) { if (!item || typeof item !== "object") continue; const a = item as Record; - const state = - a.state === "error" || a.state === "failed" - ? "error" - : a.state === "uncertain" - ? "uncertain" - : a.state === "running" - ? "running" - : "done"; + const state = normalizeAgentState(a.state); const index = typeof a.index === "number" ? a.index : agents.length + 1; const decodedInvocation = decodeInvocationRecord(a.invocation); const invocation = @@ -409,13 +431,17 @@ export function normalizePersistedWorkflowDetails( }); } - const status = - record.status === "running" || - record.status === "failed" || - record.status === "aborted" || - record.status === "uncertain" - ? record.status - : "completed"; + let status = normalizeWorkflowStatus(record.status); + if (status !== "running") { + for (const agent of agents) { + if (agent.state !== "running" && agent.state !== "uncertain") continue; + status = "uncertain"; + agent.state = "uncertain"; + agent.error = + agent.error ?? + "Persisted terminal workflow contained an agent without terminal evidence"; + } + } return { runId,