From f8c2de2fb17d3d61f5ad2bb7b08f68d35557a21f Mon Sep 17 00:00:00 2001 From: Tyler <53561637+im-tyler@users.noreply.github.com> Date: Fri, 25 Sep 2026 22:28:13 -0700 Subject: [PATCH] fix(web): show recorded terminal failure reasons --- AUDIT_OPEN.md | 14 ++++++++++++++ web/src/lib/timeline.test.ts | 20 ++++++++++++++++++++ web/src/lib/timeline.ts | 19 +++++++++++++++---- 3 files changed, 49 insertions(+), 4 deletions(-) diff --git a/AUDIT_OPEN.md b/AUDIT_OPEN.md index fd826d0..d6d410b 100644 --- a/AUDIT_OPEN.md +++ b/AUDIT_OPEN.md @@ -547,3 +547,17 @@ proof on the target):** Until then the trust statement is: a preview has the preview TARGET's network reach, and the target must be chosen as if it ran untrusted code with that reach — which is why it is compute-1 and not the Ship host. + +### 2026-09-26 — terminal failure evidence + +Fixed on this branch: workflow terminal errors with `{error:{message:...}}` +rendered an empty explanation in the workspace and its API. The timeline now +reads the recorded detail/message/title or legacy string, and explicitly names +missing details. Step failures use the same handling. This repairs presentation; +it does not claim to fix the timeout that produced the event. + +Validation: lint; 1,411 runtime tests; 100 script tests with one existing skip; +136 web tests; production web build. Initial web run through a shared dependency +symlink loaded duplicate project-error classes and failed two unrelated identity +checks; installing this worktree's web dependencies resolved both. Original +failure log retained in the private execution receipts. diff --git a/web/src/lib/timeline.test.ts b/web/src/lib/timeline.test.ts index 16b95aa..728ea80 100644 --- a/web/src/lib/timeline.test.ts +++ b/web/src/lib/timeline.test.ts @@ -151,3 +151,23 @@ test("an ask park reads as a question, and its decision as the answer", () => { assert.equal(items[1]?.body, "Archive them"); assert.equal(items[2]?.title, "waiting for approval"); }); + +// The production workflow's terminal timeout uses error.message, whereas +// problem responses use detail/title. Both must reach the workspace evidence. +test("failure evidence preserves workflow and problem-response causes", () => { + for (const [error, expected] of [ + [{ message: "timeout expired" }, "timeout expired"], + [{ title: "Unavailable", detail: "store read timed out" }, "store read timed out"], + [{ detail: "", message: "connection reset", title: "Failed" }, "connection reset"], + ["model request failed", "model request failed"], + [{ message: 42 }, "No error detail was recorded."], + [undefined, "No error detail was recorded."], + ] as const) { + for (const type of ["run-failed", "step-failed"] as const) { + const events = [{ v: 1, seq: 1, type, name: "turn-5-think", at: T0, data: { error, attempt: 1 } }] as WorkflowEvent[]; + const errors = toTimeline(events).filter((item) => item.kind === "error"); + assert.equal(errors.length, 1); + assert.equal(errors[0]?.body, expected); + } + } +}); diff --git a/web/src/lib/timeline.ts b/web/src/lib/timeline.ts index 4f5970c..2de2242 100644 --- a/web/src/lib/timeline.ts +++ b/web/src/lib/timeline.ts @@ -198,11 +198,11 @@ export function toTimeline(events: WorkflowEvent[]): TimelineItem[] { break; } case "step-failed": { - const data = event.data as { error?: { message?: string }; attempt?: number } | undefined; + const data = event.data as { error?: unknown; attempt?: number } | undefined; items.push({ kind: "error", title: `${event.name ?? "step"} failed (attempt ${data?.attempt ?? "?"})`, - body: data?.error?.message ?? "", + body: failureMessage(data?.error), at, }); break; @@ -247,8 +247,8 @@ export function toTimeline(events: WorkflowEvent[]): TimelineItem[] { break; } case "run-failed": { - const error = (event.data as { error?: { detail?: string; title?: string } } | undefined)?.error; - items.push({ kind: "error", title: "run failed", body: error?.detail ?? error?.title ?? "", at }); + const error = (event.data as { error?: unknown } | undefined)?.error; + items.push({ kind: "error", title: "run failed", body: failureMessage(error), at }); break; } default: @@ -451,3 +451,14 @@ export function took(ms: number): string { const s = ms / 1000; return s < 60 ? `${s.toFixed(1)}s` : `${Math.floor(s / 60)}m ${String(Math.round(s % 60)).padStart(2, "0")}s`; } + +function failureMessage(error: unknown): string { + if (typeof error === "string" && error.trim() !== "") return error; + if (typeof error === "object" && error !== null) { + for (const key of ["detail", "message", "title"] as const) { + const value = (error as Record)[key]; + if (typeof value === "string" && value.trim() !== "") return value; + } + } + return "No error detail was recorded."; +}