Skip to content

Commit 57c66c0

Browse files
committed
mason: classify Dreamer provider outage completions
Recognize invalid near-zero stop completions with zero reasoning as transient provider failures before the manifest parser error is surfaced. Abort a verify run after two identical provider-failure batches while preserving already-banked broad-cycle progress for scheduler retries.
1 parent 3ac431e commit 57c66c0

5 files changed

Lines changed: 477 additions & 4 deletions

File tree

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { describe, expect, it } from "bun:test";
2+
3+
import {
4+
DreamerProviderOutputFailureError,
5+
providerOutputFailureFromInvalidManifest,
6+
} from "./provider-output-failure";
7+
8+
function assistantCompletion(args: {
9+
created: number;
10+
output: number;
11+
reasoning: number;
12+
finish?: string;
13+
error?: unknown;
14+
}) {
15+
return {
16+
info: {
17+
role: "assistant",
18+
time: { created: args.created },
19+
finish: args.finish ?? "stop",
20+
error: args.error ?? null,
21+
tokens: { output: args.output, reasoning: args.reasoning },
22+
},
23+
parts: [{ type: "text", text: "provider text" }],
24+
};
25+
}
26+
27+
describe("providerOutputFailureFromInvalidManifest", () => {
28+
it("classifies the latest near-zero no-reasoning completion as a transient provider failure", () => {
29+
const messages = [
30+
assistantCompletion({ created: 1, output: 8, reasoning: 0 }),
31+
assistantCompletion({ created: 2, output: 8, reasoning: 0 }),
32+
assistantCompletion({ created: 3, output: 8, reasoning: 0 }),
33+
];
34+
35+
const failure = providerOutputFailureFromInvalidManifest(
36+
messages,
37+
"All Antigravity endpoints failed",
38+
);
39+
40+
expect(failure).toBeInstanceOf(DreamerProviderOutputFailureError);
41+
expect(failure?.transient).toBe(true);
42+
expect(failure?.outputTokens).toBe(8);
43+
expect(failure?.reasoningTokens).toBe(0);
44+
expect(failure?.message).toContain("provider-outage completion");
45+
expect(failure?.message).not.toContain("manifest missing");
46+
});
47+
48+
it("requires the complete outage token shape instead of matching response wording", () => {
49+
const responseText = "All Antigravity endpoints failed";
50+
51+
expect(
52+
providerOutputFailureFromInvalidManifest(
53+
[assistantCompletion({ created: 1, output: 33, reasoning: 0 })],
54+
responseText,
55+
),
56+
).toBeNull();
57+
expect(
58+
providerOutputFailureFromInvalidManifest(
59+
[assistantCompletion({ created: 1, output: 8, reasoning: 1 })],
60+
responseText,
61+
),
62+
).toBeNull();
63+
expect(
64+
providerOutputFailureFromInvalidManifest(
65+
[assistantCompletion({ created: 1, output: 8, reasoning: 0, finish: "length" })],
66+
responseText,
67+
),
68+
).toBeNull();
69+
expect(
70+
providerOutputFailureFromInvalidManifest(
71+
[
72+
assistantCompletion({
73+
created: 1,
74+
output: 8,
75+
reasoning: 0,
76+
error: { name: "ProviderError" },
77+
}),
78+
],
79+
responseText,
80+
),
81+
).toBeNull();
82+
});
83+
});
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { createHash } from "node:crypto";
2+
3+
import { isRecord } from "../../../shared/record-type-guard";
4+
5+
const MAX_NEAR_ZERO_OUTPUT_TOKENS = 32;
6+
7+
interface AssistantCompletionShape {
8+
createdAt: number;
9+
finish: string | null;
10+
error: unknown;
11+
outputTokens: number | null;
12+
reasoningTokens: number | null;
13+
}
14+
15+
/** A provider transport failure that arrived as an ordinary assistant completion. */
16+
export class DreamerProviderOutputFailureError extends Error {
17+
readonly transient = true;
18+
19+
constructor(
20+
readonly fingerprint: string,
21+
readonly outputTokens: number,
22+
readonly reasoningTokens: number,
23+
responseText: string,
24+
) {
25+
const preview = responseText.trim().replace(/\s+/g, " ").slice(0, 160);
26+
super(
27+
`verify provider-outage completion (output_tokens=${outputTokens}, reasoning_tokens=${reasoningTokens}): ${JSON.stringify(preview)}`,
28+
);
29+
this.name = "DreamerProviderOutputFailureError";
30+
}
31+
}
32+
33+
function finiteTokenCount(value: unknown): number | null {
34+
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
35+
}
36+
37+
function completionShape(value: unknown): AssistantCompletionShape | null {
38+
if (!isRecord(value)) return null;
39+
const info = isRecord(value.info) ? value.info : value;
40+
if (info.role !== "assistant") return null;
41+
42+
const time = isRecord(info.time) ? info.time : null;
43+
const tokens = isRecord(info.tokens) ? info.tokens : null;
44+
return {
45+
createdAt: typeof time?.created === "number" ? time.created : 0,
46+
finish:
47+
typeof info.finish === "string"
48+
? info.finish
49+
: typeof info.finish_reason === "string"
50+
? info.finish_reason
51+
: typeof info.finishReason === "string"
52+
? info.finishReason
53+
: null,
54+
error: info.error,
55+
outputTokens: finiteTokenCount(tokens?.output),
56+
reasoningTokens: finiteTokenCount(tokens?.reasoning),
57+
};
58+
}
59+
60+
function latestAssistantCompletion(messages: unknown): AssistantCompletionShape | null {
61+
if (!Array.isArray(messages)) return null;
62+
let latest: AssistantCompletionShape | null = null;
63+
for (const message of messages) {
64+
const completion = completionShape(message);
65+
if (completion && (!latest || completion.createdAt >= latest.createdAt))
66+
latest = completion;
67+
}
68+
return latest;
69+
}
70+
71+
/**
72+
* OpenCode can serialize a provider outage as a successful `finish=stop` assistant
73+
* message. Only classify that shape after manifest validation has already failed:
74+
* a real manifest remains authoritative regardless of its token counts.
75+
*/
76+
export function providerOutputFailureFromInvalidManifest(
77+
messages: unknown,
78+
responseText: string,
79+
): DreamerProviderOutputFailureError | null {
80+
const completion = latestAssistantCompletion(messages);
81+
if (completion?.finish?.toLowerCase() !== "stop") return null;
82+
if (
83+
completion.error != null ||
84+
completion.outputTokens === null ||
85+
completion.outputTokens > MAX_NEAR_ZERO_OUTPUT_TOKENS ||
86+
completion.reasoningTokens !== 0
87+
) {
88+
return null;
89+
}
90+
91+
const normalized = responseText.trim().replace(/\s+/g, " ").toLowerCase();
92+
if (!normalized) return null;
93+
const fingerprint = createHash("sha256").update(normalized).digest("hex").slice(0, 16);
94+
return new DreamerProviderOutputFailureError(
95+
fingerprint,
96+
completion.outputTokens,
97+
completion.reasoningTokens,
98+
responseText,
99+
);
100+
}

packages/plugin/src/features/magic-context/dreamer/task-executor.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,21 @@ function assistantMessages(text: string) {
4646
];
4747
}
4848

49+
function providerFailureMessages(text: string) {
50+
return [
51+
{
52+
info: {
53+
role: "assistant",
54+
time: { created: Date.now() },
55+
finish: "stop",
56+
error: null,
57+
tokens: { output: 8, reasoning: 0 },
58+
},
59+
parts: [{ type: "text", text }],
60+
},
61+
];
62+
}
63+
4964
describe("createDreamTaskExecutor — curate", () => {
5065
test("runs whole-pool curation without verification gate or watermark patch", async () => {
5166
db = freshDb();
@@ -234,6 +249,58 @@ describe("createDreamTaskExecutor — verify-broad disposition", () => {
234249
expect(task.backlog).toMatchObject({ pendingAtStart: 51, pendingAtEnd: 1, processed: 50 });
235250
});
236251

252+
test("surfaces provider-outage completions as transient task failures", async () => {
253+
db = freshDb();
254+
const project = "/repo/verify-broad-provider-outage";
255+
seedTaskScheduleState(db, project, "verify-broad", null, null, "0 3 * * 0");
256+
const memory = insertMemory(db, {
257+
projectPath: project,
258+
category: "ARCHITECTURE",
259+
content: "Mapped fact blocked by a provider outage.",
260+
});
261+
recordMemoryVerifications(db, memory.id, ["src/fact.ts"], 1_000);
262+
const client = {
263+
session: {
264+
list: mock(async () => ({ data: [] })),
265+
create: mock(async () => ({ data: { id: "verify-provider-outage" } })),
266+
prompt: mock(async () => ({})),
267+
messages: mock(async () => ({
268+
data: providerFailureMessages("All Antigravity endpoints failed"),
269+
})),
270+
delete: mock(async () => ({})),
271+
},
272+
};
273+
const executor = createDreamTaskExecutor({
274+
client: client as never,
275+
sessionDirectory: project,
276+
openOpenCodeDb: () => null,
277+
});
278+
const leaseKey = leaseKeyFor("verify-broad", project);
279+
expect(acquireLease(db, "holder-broad-provider-outage", leaseKey)).toBe(true);
280+
281+
const result = await executor(
282+
{ task: "verify-broad", schedule: "0 3 * * 0", timeoutMinutes: 20 },
283+
{
284+
db,
285+
projectIdentity: project,
286+
holderId: "holder-broad-provider-outage",
287+
leaseKey,
288+
},
289+
);
290+
291+
expect(result.status).toBe("failed");
292+
expect(result.transient).toBe(true);
293+
expect(result.error).toContain("provider-outage completion");
294+
expect(result.error).not.toContain("manifest missing");
295+
expect(getTaskScheduleState(db, project, "verify-broad")?.lastBroadRunAt).toBeGreaterThan(
296+
0,
297+
);
298+
const run = getDreamRuns(db, project)[0];
299+
expect(run?.tasks_failed).toBe(1);
300+
const task = JSON.parse(run?.tasks_json ?? "[]")[0] as { error?: string };
301+
expect(task.error).toContain("provider-outage completion");
302+
});
303+
237304
test("keeps a zero-progress broad run failed", async () => {
238305
db = freshDb();
239306
const project = "/repo/verify-broad-zero";

0 commit comments

Comments
 (0)