Skip to content

Commit 81a0122

Browse files
d-csclaude
andcommitted
fix(webapp): reflect terminal state in mollifier synthetic span/trace
The synthetic SpanRun/trace builders for buffered runs hardcoded non-terminal state, so a CANCELED or FAILED buffered run rendered as a healthy in-progress run: - syntheticSpanRun: FAILED now maps to SYSTEM_FAILURE (matching ApiRetrieveRunPresenter.bufferedStatusToTaskRunStatus); isFinished is true for CANCELED/FAILED; isError is true for FAILED; the error block is synthesised as STRING_ERROR and statusReason carries the message. - syntheticSpanRun: drop the empty-string spanId/taskIdentifier relationship stubs (blank task name + misleading `?span=` jump) since the snapshot only carries friendly IDs. - syntheticTrace: FAILED now renders as an errored, non-partial, "failed" root span instead of executing/partial. CANCELED stays "completed", matching RunPresenter's derivation. - tests: cover the CANCELED and FAILED terminal paths. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 8cc981f commit 81a0122

3 files changed

Lines changed: 93 additions & 31 deletions

File tree

apps/webapp/app/v3/mollifier/syntheticSpanRun.server.ts

Lines changed: 43 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,11 @@ function narrowMachinePreset(value: string | undefined): SpanRun["machinePreset"
2020

2121
// Synthesise a SpanRun-shaped object from a buffered run so the run-detail
2222
// page's right-side details panel renders identically to a PG-resident
23-
// run. The shape matches `SpanPresenter.getRun`'s return value exactly;
24-
// buffered-irrelevant fields (output, error, attempts, schedule, session,
25-
// region, batch) are filled with sensible defaults.
23+
// run. The shape matches `SpanPresenter.getRun`'s return value;
24+
// buffered-irrelevant fields (output, attempts, schedule, session,
25+
// region, batch) are filled with sensible defaults, while terminal state
26+
// (CANCELED / FAILED) is reflected into `status`, `isFinished`, `isError`
27+
// and `error` so a finished buffered run does not render as PENDING.
2628
//
2729
// Pretty-printing for payload and metadata mirrors SpanPresenter so the
2830
// UI receives data in the same shape. Buffered runs cannot use the
@@ -64,11 +66,36 @@ export async function buildSyntheticSpanRun(args: {
6466

6567
const queueName = run.queue ?? "task/";
6668
const isCancelled = run.status === "CANCELED";
69+
const isFailed = run.status === "FAILED";
70+
71+
// The run-detail panel derives terminal/error state from `status`,
72+
// `isFinished` and `isError` (SpanPresenter.getRun -> isFinalRunStatus /
73+
// isFailedRunStatus). Buffered FAILED runs surface as SYSTEM_FAILURE to
74+
// match ApiRetrieveRunPresenter.bufferedStatusToTaskRunStatus; both
75+
// CANCELED and SYSTEM_FAILURE are final run statuses, and SYSTEM_FAILURE
76+
// is also a failed status.
77+
const status: SpanRun["status"] = isCancelled
78+
? "CANCELED"
79+
: isFailed
80+
? "SYSTEM_FAILURE"
81+
: "PENDING";
82+
83+
// Mirror ApiRetrieveRunPresenter's STRING_ERROR synthesis so the panel
84+
// shows why a buffered run failed instead of an empty error block.
85+
const error: SpanRun["error"] =
86+
isFailed && run.error
87+
? { type: "STRING_ERROR", raw: `${run.error.code}: ${run.error.message}` }
88+
: undefined;
89+
6790
return {
6891
id: run.id,
6992
friendlyId: run.friendlyId,
70-
status: isCancelled ? "CANCELED" : "PENDING",
71-
statusReason: isCancelled ? run.cancelReason ?? undefined : undefined,
93+
status,
94+
statusReason: isCancelled
95+
? run.cancelReason ?? undefined
96+
: isFailed
97+
? run.error?.message ?? undefined
98+
: undefined,
7299
createdAt: run.createdAt,
73100
startedAt: null,
74101
executedAt: null,
@@ -102,32 +129,24 @@ export async function buildSyntheticSpanRun(args: {
102129
costInCents: 0,
103130
totalCostInCents: 0,
104131
usageDurationMs: 0,
105-
isFinished: false,
132+
isFinished: isCancelled || isFailed,
106133
isRunning: false,
107-
isError: false,
134+
isError: isFailed,
108135
isAgentRun,
109136
payload,
110137
payloadType: run.payloadType ?? "application/json",
111138
output: undefined,
112139
outputType: "application/json",
113-
error: undefined,
140+
error,
141+
// The snapshot only carries the root/parent friendly IDs, not the
142+
// spanId or taskIdentifier that SpanPresenter sources from the joined
143+
// PG rows. Emitting them with empty-string stubs renders a blank task
144+
// name and a misleading `?span=` jump target, so we omit the
145+
// relationships until the drainer materialises the row (a transient
146+
// window). Top-level buffered runs have no relationships regardless.
114147
relationships: {
115-
root: run.rootTaskRunFriendlyId
116-
? {
117-
friendlyId: run.rootTaskRunFriendlyId,
118-
spanId: "",
119-
taskIdentifier: "",
120-
createdAt: run.createdAt,
121-
isParent: run.parentTaskRunFriendlyId === run.rootTaskRunFriendlyId,
122-
}
123-
: undefined,
124-
parent: run.parentTaskRunFriendlyId
125-
? {
126-
friendlyId: run.parentTaskRunFriendlyId,
127-
spanId: "",
128-
taskIdentifier: "",
129-
}
130-
: undefined,
148+
root: undefined,
149+
parent: undefined,
131150
},
132151
context: JSON.stringify(
133152
{

apps/webapp/app/v3/mollifier/syntheticTrace.server.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import type { SyntheticRun } from "./readFallback.server";
1313
export function buildSyntheticTraceForBufferedRun(run: SyntheticRun) {
1414
const spanId = run.spanId ?? "";
1515
const isCancelled = run.status === "CANCELED";
16+
const isFailed = run.status === "FAILED";
1617
const span: SpanSummary = {
1718
id: spanId,
1819
parentId: run.parentSpanId,
@@ -23,8 +24,11 @@ export function buildSyntheticTraceForBufferedRun(run: SyntheticRun) {
2324
events: [],
2425
startTime: run.createdAt,
2526
duration: 0,
26-
isError: false,
27-
isPartial: !isCancelled,
27+
isError: isFailed,
28+
// CANCELED and FAILED are terminal; only a still-queued buffered run
29+
// is partial. A partial failed span would otherwise render as
30+
// "executing" forever in the timeline.
31+
isPartial: !isCancelled && !isFailed,
2832
isCancelled,
2933
isDebug: false,
3034
level: "TRACE",
@@ -54,7 +58,13 @@ export function buildSyntheticTraceForBufferedRun(run: SyntheticRun) {
5458
: [];
5559

5660
return {
57-
rootSpanStatus: (isCancelled ? "completed" : "executing") as "executing" | "completed" | "failed",
61+
// Matches RunPresenter's derivation: failed root span -> "failed",
62+
// otherwise a terminal (non-partial) span -> "completed", else
63+
// "executing". CANCELED is terminal-but-not-error, so "completed".
64+
rootSpanStatus: (isFailed ? "failed" : isCancelled ? "completed" : "executing") as
65+
| "executing"
66+
| "completed"
67+
| "failed",
5868
events,
5969
duration: totalDuration,
6070
rootStartedAt: tree?.data.startTime,

apps/webapp/test/mollifierSyntheticSpanRun.test.ts

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -129,17 +129,16 @@ describe("buildSyntheticSpanRun", () => {
129129
expect(noKey.idempotencyKeyStatus).toBeUndefined();
130130
});
131131

132-
it("fills relationship metadata from parent/root snapshot fields when present", async () => {
132+
it("omits relationships even when parent/root friendlyIds are present, since the snapshot lacks their spanId/taskIdentifier", async () => {
133133
const synth = await buildSyntheticSpanRun({
134134
run: makeSyntheticRun({
135135
parentTaskRunFriendlyId: "run_parent",
136136
rootTaskRunFriendlyId: "run_root",
137137
}),
138138
environment: ENV,
139139
});
140-
expect(synth.relationships.parent?.friendlyId).toBe("run_parent");
141-
expect(synth.relationships.root?.friendlyId).toBe("run_root");
142-
expect(synth.relationships.root?.isParent).toBe(false);
140+
expect(synth.relationships.parent).toBeUndefined();
141+
expect(synth.relationships.root).toBeUndefined();
143142
});
144143

145144
it("returns no relationship objects when the snapshot has no parent/root", async () => {
@@ -151,6 +150,40 @@ describe("buildSyntheticSpanRun", () => {
151150
expect(synth.relationships.root).toBeUndefined();
152151
});
153152

153+
it("reflects a buffered CANCELED run as a finished, cancelled terminal state", async () => {
154+
const synth = await buildSyntheticSpanRun({
155+
run: makeSyntheticRun({
156+
status: "CANCELED",
157+
cancelledAt: NOW,
158+
cancelReason: "cancelled by user",
159+
}),
160+
environment: ENV,
161+
});
162+
expect(synth.status).toBe("CANCELED");
163+
expect(synth.statusReason).toBe("cancelled by user");
164+
expect(synth.isFinished).toBe(true);
165+
expect(synth.isError).toBe(false);
166+
expect(synth.completedAt).toEqual(NOW);
167+
});
168+
169+
it("reflects a buffered FAILED run as a finished, errored SYSTEM_FAILURE", async () => {
170+
const synth = await buildSyntheticSpanRun({
171+
run: makeSyntheticRun({
172+
status: "FAILED",
173+
error: { code: "GATE_REJECTED", message: "buffer rejected the run" },
174+
}),
175+
environment: ENV,
176+
});
177+
expect(synth.status).toBe("SYSTEM_FAILURE");
178+
expect(synth.isFinished).toBe(true);
179+
expect(synth.isError).toBe(true);
180+
expect(synth.statusReason).toBe("buffer rejected the run");
181+
expect(synth.error).toEqual({
182+
type: "STRING_ERROR",
183+
raw: "GATE_REJECTED: buffer rejected the run",
184+
});
185+
});
186+
154187
it("flags the synthetic run as 'not cached' since cache lookup did not match it", async () => {
155188
const synth = await buildSyntheticSpanRun({ run: makeSyntheticRun(), environment: ENV });
156189
expect(synth.isCached).toBe(false);

0 commit comments

Comments
 (0)