Skip to content

Commit a96f662

Browse files
V48 Gate 3 (implementation-only): stitch-repair validation errors stream as in-band 'repair', not terminal 'error' — stop marking actively-repairing runs failed
Run 61c3b0cf: the synthesis agent's plan step returned output missing the required 'options' array; StitchUntilComplete recorded the zod error (store('validation','error', …)) and began its bounded repair — exactly its job. But ExecutionStreamAdapter.inferEventType typed any key==='error' store as a terminal 'error' event, so: the SSE tail closed (usePipelineExecution sawTerminal), the activity builder surfaced the zod message as the run error, and /deposit flipped to failed — while the pipeline repaired and kept running (sidecar shows it deep in validation and a second fail-closed iteration, zero errors, 18+ minutes past the false verdict). - ExecutionStreamAdapter: new REPAIR='repair' stream type; 'validation'/'error' stores (the stitch loop's about-to-repair record, its only producer) classify as REPAIR. Genuine terminals are unchanged: namespace 'error' stores, the route catch's explicit error emitEvent, and stitching/error (stored immediately before the stitch-exceeded throw). - Legacy-row hardening (rows persisted before this fix are typed 'error' with namespace 'validation'): usePipelineExecution no longer treats them as terminal, buildTerminalRunActivityFromEvents no longer surfaces them as the run error, and the /api/executions/stream tail no longer closes on them — so reloading a mid-repair run resumes tailing to its real outcome. Verified: execution-generics adapter suite 15 green (incl. new repair classification test); uapi full suite 155 suites / 599 tests green (incl. new tail-past-repair and run-error regression tests); pipelines-generics streaming 46 green; tsc clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent b09513b commit a96f662

7 files changed

Lines changed: 141 additions & 3 deletions

File tree

packages/execution-generics/src/__tests__/execution-stream-adapter.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,15 @@ describe('ExecutionStreamAdapter — event type inference', () => {
108108
expect(await inferredType('misc', 'completion')).toBe('completion');
109109
expect(await inferredType('misc', 'anything')).toBe('status');
110110
});
111+
112+
it("classifies the stitch failsafe's validation error as in-band 'repair', not terminal 'error'", async () => {
113+
// The stitch loop stores the schema error it is ABOUT TO REPAIR
114+
// (store('validation','error', …)); typing it 'error' made stream
115+
// consumers mark an actively-repairing run as failed.
116+
expect(await inferredType('validation', 'error', 'options: Required')).toBe('repair');
117+
// Other validation stores stay status.
118+
expect(await inferredType('validation', 'result', { passed: true })).toBe('status');
119+
});
111120
});
112121

113122
describe('ExecutionStreamAdapter — event payload shape', () => {

packages/execution-generics/src/storage/ExecutionStreamAdapter.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ export enum ExecutionStreamEventType {
2424
COMPLETION = 'completion',
2525
STATUS = 'status',
2626
WORK_UPDATE = 'work-update',
27+
// In-band failsafe repair work (e.g. the stitch loop recording the
28+
// validation error it is about to repair) — NOT a terminal failure.
29+
REPAIR = 'repair',
2730
}
2831

2932
/**
@@ -189,6 +192,16 @@ export class ExecutionStreamAdapter {
189192
return ExecutionStreamEventType.THINKING;
190193
}
191194

195+
// Failsafe repair context: the stitch loop stores the schema-validation
196+
// error it is ABOUT TO REPAIR ('validation'/'error') before running its
197+
// bounded repair generations. That is in-band failsafe work, not a
198+
// terminal failure — typing it 'error' made stream consumers treat an
199+
// actively-repairing run as failed (tail closed, run marked failed) while
200+
// the pipeline kept working.
201+
if (namespace === 'validation' && key === 'error') {
202+
return ExecutionStreamEventType.REPAIR;
203+
}
204+
192205
// Errors
193206
if (namespace === 'error' || key === 'error') {
194207
return ExecutionStreamEventType.ERROR;

uapi/app/api/executions/stream/route.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,22 @@ const MAX_TAIL_MS = 5 * 60 * 1000;
99
const TERMINAL_EVENT_TYPES = new Set(['completion', 'error']);
1010
const TERMINAL_EXECUTION_STATUSES = new Set(['completed', 'failed', 'cancelled']);
1111

12+
/**
13+
* A 'validation'-namespace error row is the stitch failsafe recording the
14+
* schema error it is actively repairing — in-band failsafe work, not a
15+
* terminal failure. New events stream as type 'repair' (ExecutionStreamAdapter),
16+
* but rows persisted before that fix are still typed 'error' and must not end
17+
* the tail of a run that is still working.
18+
*/
19+
function isLegacyRepairErrorRow(eventType: string, eventData: unknown): boolean {
20+
return (
21+
eventType === 'error' &&
22+
Boolean(eventData) &&
23+
typeof eventData === 'object' &&
24+
(eventData as Record<string, unknown>).namespace === 'validation'
25+
);
26+
}
27+
1228
/**
1329
* Row-backed frames carry the execution_events row's insert-time `created_at`
1430
* as the standard SSE `id:` line. The tail filters rows with
@@ -119,7 +135,10 @@ export async function GET(request: Request) {
119135
for (const event of events || []) {
120136
cursor = event.created_at;
121137
send((event.event_data as Record<string, unknown>) || { type: event.event_type }, event.created_at);
122-
if (TERMINAL_EVENT_TYPES.has(String(event.event_type))) {
138+
if (
139+
TERMINAL_EVENT_TYPES.has(String(event.event_type)) &&
140+
!isLegacyRepairErrorRow(String(event.event_type), event.event_data)
141+
) {
123142
sawTerminalEvent = true;
124143
}
125144
}

uapi/app/terminal/terminal-run-activity.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,14 @@ export function buildTerminalRunActivityFromEvents(
186186
const normalizedIterationUpdates = new Map<number | string, any>();
187187
const statusEvents = events.filter((entry) => entry.event?.type === 'status');
188188
const completionEvent = events.find((entry) => entry.event?.type === 'completion');
189-
const errorEvent = events.find((entry) => entry.event?.type === 'error');
189+
// The run error is a GENUINE terminal error only. A 'validation'-namespace
190+
// error is the stitch failsafe recording the schema error it is actively
191+
// repairing (streamed as type 'repair' since the ExecutionStreamAdapter fix;
192+
// rows persisted before it are still typed 'error') — surfacing it as the
193+
// run error marked an actively-repairing run as failed.
194+
const errorEvent = events.find(
195+
(entry) => entry.event?.type === 'error' && entry.event?.namespace !== 'validation',
196+
);
190197

191198
for (const update of iterationUpdates || []) {
192199
if (update && typeof update.iteration !== 'undefined') {

uapi/hooks/usePipelineExecution.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,16 @@ export function usePipelineExecution(runId: string | null): UsePipelineExecution
159159
if (payload.type === 'work-update') {
160160
recordWorkUpdate(payload);
161161
}
162-
if (payload.type === 'completion' || payload.type === 'error') {
162+
// Terminal detection: completion, or a genuine error. A
163+
// 'validation'-namespace error is the stitch failsafe
164+
// recording the schema error it is actively repairing —
165+
// new events stream as type 'repair', but rows persisted
166+
// before that fix are still typed 'error' and must not
167+
// close the tail of a run that is still working.
168+
if (
169+
payload.type === 'completion' ||
170+
(payload.type === 'error' && payload.namespace !== 'validation')
171+
) {
163172
sawTerminal = true;
164173
}
165174
const createdAt =

uapi/tests/terminalTransactionActivity.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,44 @@ describe('terminal-run-activity helpers', () => {
299299
expect(snapshot.iterationUpdates.find((u: any) => u.iteration === 2)?.confidence).toBe(0.8);
300300
});
301301

302+
it("ignores a legacy 'error'-typed validation repair event as the run error, but keeps genuine errors", () => {
303+
// Rows persisted before the ExecutionStreamAdapter repair fix: the stitch
304+
// failsafe's validation error arrived typed 'error' with namespace
305+
// 'validation' — the run was still actively repairing, not failed.
306+
const repairOnly = buildTerminalRunActivityFromEvents(
307+
[
308+
{
309+
id: 'e1',
310+
created_at: '2026-07-03T19:12:29.447Z',
311+
event: {
312+
type: 'error',
313+
namespace: 'validation',
314+
key: 'error',
315+
message: '[{"path":["options"],"message":"Required"}]',
316+
},
317+
},
318+
] as any,
319+
null,
320+
[],
321+
null,
322+
);
323+
expect(repairOnly.error).toBeNull();
324+
325+
const genuine = buildTerminalRunActivityFromEvents(
326+
[
327+
{
328+
id: 'e2',
329+
created_at: '2026-07-03T19:13:00.000Z',
330+
event: { type: 'error', message: 'synthesis failed' },
331+
},
332+
] as any,
333+
null,
334+
[],
335+
null,
336+
);
337+
expect(genuine.error).toBe('synthesis failed');
338+
});
339+
302340
it('uses explicit stream error and mock snapshots', () => {
303341
const liveErrorSnapshot = buildTerminalRunActivityFromEvents([], null, [], 'Stream failed');
304342
expect(liveErrorSnapshot.error).toBe('Stream failed');

uapi/tests/usePipelineExecution.test.tsx

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,49 @@ describe('usePipelineExecution', () => {
247247
expect(streamUrls.length).toBe(1);
248248
});
249249

250+
it("keeps tailing past a legacy 'error'-typed validation repair event (stitch failsafe at work)", async () => {
251+
const historyResponse = {
252+
run: { id: 'r5b', user_id: 'user-1', created_at: '2026-07-01T00:00:00.000Z', items: [], context: {} },
253+
events: [],
254+
};
255+
const streamUrls: string[] = [];
256+
257+
global.fetch = jest.fn((request: RequestInfo) => {
258+
const url = typeof request === 'string' ? request : (request as Request)?.url ?? '';
259+
if (url.startsWith('/api/executions/history/')) {
260+
return Promise.resolve({ ok: true, json: async () => historyResponse } as any);
261+
}
262+
if (url.startsWith('/api/executions/stream')) {
263+
streamUrls.push(url);
264+
if (streamUrls.length === 1) {
265+
// Rows persisted before the ExecutionStreamAdapter repair fix: the
266+
// stitch loop's validation error arrived typed 'error' with
267+
// namespace 'validation' — the run is still actively repairing.
268+
return Promise.resolve(
269+
sseResponse([
270+
'id: 2026-07-01T00:00:01.000Z\ndata: {"type":"error","namespace":"validation","key":"error","message":"options: Required","timestamp":"2026-07-01T00:00:01.000Z"}\n\n',
271+
]),
272+
);
273+
}
274+
// The tail reconnects and receives the run's real completion.
275+
return Promise.resolve(
276+
sseResponse([
277+
'id: 2026-07-01T00:00:02.000Z\ndata: {"type":"completion","message":"done","timestamp":"2026-07-01T00:00:02.000Z"}\n\n',
278+
]),
279+
);
280+
}
281+
throw new Error(`Unexpected fetch: ${url}`);
282+
}) as any;
283+
284+
let latest: any;
285+
render(<Harness runId="r5b" onResult={(state) => (latest = state)} />);
286+
287+
await waitFor(() =>
288+
expect(latest?.events.some((e: any) => e.event?.type === 'completion')).toBe(true),
289+
);
290+
expect(streamUrls.length).toBeGreaterThanOrEqual(2);
291+
});
292+
250293
it('bounds the reconnect loop after repeated empty tail windows', async () => {
251294
const historyResponse = {
252295
run: { id: 'r6', user_id: 'user-1', created_at: '2026-07-01T00:00:00.000Z', items: [], context: {} },

0 commit comments

Comments
 (0)