Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions AUDIT_OPEN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
20 changes: 20 additions & 0 deletions web/src/lib/timeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
});
19 changes: 15 additions & 4 deletions web/src/lib/timeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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<string, unknown>)[key];
if (typeof value === "string" && value.trim() !== "") return value;
}
}
return "No error detail was recorded.";
}
Loading