Skip to content

Commit 29a49cb

Browse files
committed
mason: apply D19 resilience safe parts
1 parent 11a5b70 commit 29a49cb

24 files changed

Lines changed: 680 additions & 226 deletions

packages/pi-plugin/src/index.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1551,7 +1551,12 @@ export default async function (pi: ExtensionAPI): Promise<void> {
15511551
if (typeof sessionId !== "string" || sessionId.length === 0) return;
15521552
const msgRaw = event.message as unknown;
15531553
if (!msgRaw || typeof msgRaw !== "object") return;
1554-
const msg = msgRaw as { role?: string; errorMessage?: string };
1554+
const msg = msgRaw as {
1555+
role?: string;
1556+
errorMessage?: string;
1557+
provider?: string;
1558+
model?: string;
1559+
};
15551560
if (msg.role !== "assistant") return;
15561561
if (
15571562
typeof msg.errorMessage !== "string" ||
@@ -1561,7 +1566,14 @@ export default async function (pi: ExtensionAPI): Promise<void> {
15611566
}
15621567
const detection = detectOverflow(msg.errorMessage);
15631568
if (!detection.isOverflow) return;
1564-
recordOverflowDetected(db, sessionId, detection.reportedLimit);
1569+
const modelKey =
1570+
typeof msg.provider === "string" &&
1571+
typeof msg.model === "string" &&
1572+
msg.provider.length > 0 &&
1573+
msg.model.length > 0
1574+
? `${msg.provider}/${msg.model}`
1575+
: undefined;
1576+
recordOverflowDetected(db, sessionId, detection.reportedLimit, modelKey);
15651577
log(
15661578
`[magic-context][${sessionId}] overflow detected: reportedLimit=${
15671579
detection.reportedLimit ?? "?"

packages/pi-plugin/src/subagent-runner.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -504,6 +504,31 @@ describe("PiSubagentRunner spawn lifecycle", () => {
504504
});
505505
});
506506

507+
it("returns no_assistant for empty assistant text", async () => {
508+
const child = createMockChild();
509+
const { runner } = runnerWith(child);
510+
511+
const resultPromise = runner.run(baseOptions);
512+
child.writeStdoutLine(
513+
agentEnd([
514+
{
515+
role: "assistant",
516+
content: [{ type: "text", text: " " }],
517+
stopReason: "stop",
518+
},
519+
]),
520+
);
521+
child.emitClose(0);
522+
523+
expect(await resultPromise).toEqual({
524+
ok: false,
525+
reason: "no_assistant",
526+
error: "pi assistant produced empty text",
527+
durationMs: expect.any(Number),
528+
meta: { stderr: undefined },
529+
});
530+
});
531+
507532
it("returns no_assistant for empty stdout and successful exit", async () => {
508533
const child = createMockChild();
509534
const { runner } = runnerWith(child);
@@ -681,6 +706,55 @@ describe("PiSubagentRunner spawn lifecycle", () => {
681706
);
682707
});
683708

709+
it("retries fallback models after empty assistant text", async () => {
710+
const first = createMockChild();
711+
const second = createMockChild();
712+
let spawnCount = 0;
713+
const spawnImpl = mock(() => {
714+
spawnCount += 1;
715+
return (spawnCount === 1 ? first : second) as never;
716+
});
717+
const runner = new PiSubagentRunner({
718+
piBinary: "pi-test",
719+
spawnImpl: spawnImpl as never,
720+
});
721+
722+
const resultPromise = runner.run({
723+
...baseOptions,
724+
model: "anthropic/primary",
725+
fallbackModels: ["openai/fallback"],
726+
});
727+
first.writeStdoutLine(
728+
agentEnd([
729+
{
730+
role: "assistant",
731+
content: [{ type: "text", text: " " }],
732+
stopReason: "stop",
733+
},
734+
]),
735+
);
736+
first.emitClose(0);
737+
await new Promise((resolve) => setTimeout(resolve, 0));
738+
second.writeStdoutLine(
739+
agentEnd([
740+
{
741+
role: "assistant",
742+
content: [{ type: "text", text: "fallback text" }],
743+
stopReason: "stop",
744+
},
745+
]),
746+
);
747+
second.emitClose(0);
748+
749+
expect(await resultPromise).toEqual({
750+
ok: true,
751+
assistantText: "fallback text",
752+
durationMs: expect.any(Number),
753+
meta: { stderr: undefined },
754+
});
755+
expect(spawnImpl).toHaveBeenCalledTimes(2);
756+
});
757+
684758
it("returns timeout and terminates a child that never closes", async () => {
685759
const child = createMockChild();
686760
const { runner } = runnerWith(child);

packages/pi-plugin/src/subagent-runner.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -725,11 +725,18 @@ export class PiSubagentRunner implements SubagentRunner {
725725
// of truth; a signaled close here must not turn a valid answer
726726
// into a fake subprocess failure.
727727
if (sawAgentEnd) {
728-
if (finalAssistantText === null) {
728+
const trimmedAssistantText = finalAssistantText?.trim() ?? null;
729+
if (
730+
trimmedAssistantText === null ||
731+
trimmedAssistantText.length === 0
732+
) {
729733
settle({
730734
ok: false,
731735
reason: "no_assistant",
732-
error: "pi agent_end did not include an assistant message",
736+
error:
737+
trimmedAssistantText === null
738+
? "pi agent_end did not include an assistant message"
739+
: "pi assistant produced empty text",
733740
durationMs: Date.now() - startTime,
734741
meta: { stderr: stderr.length > 0 ? stderr : undefined },
735742
});
@@ -754,7 +761,7 @@ export class PiSubagentRunner implements SubagentRunner {
754761
}
755762
settle({
756763
ok: true,
757-
assistantText: (finalAssistantText ?? "").trim(),
764+
assistantText: trimmedAssistantText,
758765
durationMs: Date.now() - startTime,
759766
meta: { stderr: stderr.length > 0 ? stderr : undefined },
760767
});

packages/plugin/src/config/index.test.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -267,7 +267,6 @@ describe("loadPluginConfig — experimental graduation migration", () => {
267267
expect(result.dreamer?.pin_key_files?.enabled).toBe(true);
268268
});
269269

270-
271270
it("merges experimental.pin_key_files sub-fields when dreamer.pin_key_files is a boolean shorthand", () => {
272271
const config = JSON.stringify({
273272
experimental: {

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

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -196,13 +196,23 @@ describe("dreamer", () => {
196196
let promptCalls = 0;
197197
const promptSyncSpy = spyOn(
198198
shared,
199-
"promptSyncWithModelSuggestionRetry",
199+
"promptSyncWithValidatedOutputRetry",
200200
).mockImplementation(async () => {
201201
promptCalls += 1;
202202
if (promptCalls === 1 && db) {
203203
setDreamState(db, "dreaming_lease_holder", "stolen-holder");
204204
setDreamState(db, "dreaming_lease_expiry", String(Date.now() + 120_000));
205205
}
206+
return {
207+
output: [],
208+
validated: "completed dream task",
209+
attempt: {
210+
label: "primary",
211+
attemptIndex: 0,
212+
isFallback: false,
213+
totalAttempts: 1,
214+
},
215+
};
206216
});
207217

208218
try {
@@ -298,7 +308,7 @@ describe("dreamer", () => {
298308
const client = createDreamClient();
299309
const promptSyncSpy = spyOn(
300310
shared,
301-
"promptSyncWithModelSuggestionRetry",
311+
"promptSyncWithValidatedOutputRetry",
302312
).mockImplementation(async () => {
303313
const tampered = [
304314
"# Architecture",
@@ -310,6 +320,16 @@ describe("dreamer", () => {
310320
"outside after edited",
311321
].join("\n");
312322
writeFileSync(join(docsDir, "ARCHITECTURE.md"), tampered, "utf8");
323+
return {
324+
output: [],
325+
validated: "maintain-docs updated architecture",
326+
attempt: {
327+
label: "primary",
328+
attemptIndex: 0,
329+
isFallback: false,
330+
totalAttempts: 1,
331+
},
332+
};
313333
});
314334

315335
try {
@@ -362,7 +382,7 @@ describe("dreamer", () => {
362382

363383
const promptSyncSpy = spyOn(
364384
shared,
365-
"promptSyncWithModelSuggestionRetry",
385+
"promptSyncWithValidatedOutputRetry",
366386
).mockRejectedValue(new ProviderModelNotFoundError());
367387

368388
try {

packages/plugin/src/features/magic-context/dreamer/runner.ts

Lines changed: 75 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -373,11 +373,12 @@ export async function runDream(args: {
373373
throw error;
374374
}
375375
log(`[dreamer] task ${taskName}: child session created ${agentSessionId}`);
376+
const childSessionId = agentSessionId;
376377

377-
await shared.promptSyncWithModelSuggestionRetry(
378+
const dreamTaskRun = await shared.promptSyncWithValidatedOutputRetry(
378379
args.client,
379380
{
380-
path: { id: agentSessionId },
381+
path: { id: childSessionId },
381382
query: { directory: args.sessionDirectory ?? args.projectIdentity },
382383
body: {
383384
agent: DREAMER_AGENT,
@@ -392,24 +393,33 @@ export async function runDream(args: {
392393
signal: taskAbortController.signal,
393394
fallbackModels: args.fallbackModels,
394395
callContext: `dreamer:${taskName}`,
396+
fetchOutput: async () => {
397+
const messagesResponse = await args.client.session.messages({
398+
path: { id: childSessionId },
399+
query: {
400+
directory: args.sessionDirectory ?? args.projectIdentity,
401+
limit: 50,
402+
},
403+
});
404+
return shared.normalizeSDKResponse(messagesResponse, [] as unknown[], {
405+
preferResponseOnMissingData: true,
406+
});
407+
},
408+
validateOutput: (messages) => {
409+
const taskResult = extractLatestAssistantText(messages);
410+
if (!taskResult) {
411+
throw new Error("Dreamer returned no assistant output.");
412+
}
413+
return taskResult;
414+
},
395415
},
396416
);
397417
if (lostLease) {
398418
throw new Error(lostLeaseReason ?? `Dream lease lost during ${taskName}`);
399419
}
400420

401-
const messagesResponse = await args.client.session.messages({
402-
path: { id: agentSessionId },
403-
query: { directory: args.sessionDirectory ?? args.projectIdentity, limit: 50 },
404-
});
405-
const messages = shared.normalizeSDKResponse(messagesResponse, [] as unknown[], {
406-
preferResponseOnMissingData: true,
407-
});
408-
recordInvocation({ status: "completed", messages });
409-
const taskResult = extractLatestAssistantText(messages);
410-
if (!taskResult) {
411-
throw new Error("Dreamer returned no assistant output.");
412-
}
421+
const taskResult = dreamTaskRun.validated;
422+
recordInvocation({ status: "completed", messages: dreamTaskRun.output });
413423

414424
if (
415425
taskName === "maintain-docs" &&
@@ -867,12 +877,13 @@ Only include notes whose conditions you could definitively evaluate against exte
867877
}
868878

869879
log(`[dreamer] smart notes: child session created ${agentSessionId}`);
880+
const childSessionId = agentSessionId;
870881

871882
const remainingMs = Math.max(0, args.deadline - Date.now());
872-
await shared.promptSyncWithModelSuggestionRetry(
883+
const smartNoteRun = await shared.promptSyncWithValidatedOutputRetry(
873884
args.client,
874885
{
875-
path: { id: agentSessionId },
886+
path: { id: childSessionId },
876887
query: { directory: args.sessionDirectory ?? args.projectIdentity },
877888
body: {
878889
agent: DREAMER_AGENT,
@@ -887,37 +898,44 @@ Only include notes whose conditions you could definitively evaluate against exte
887898
signal: abortController.signal,
888899
fallbackModels: args.fallbackModels,
889900
callContext: "dreamer:smart-notes",
901+
fetchOutput: async () => {
902+
const messagesResponse = await args.client.session.messages({
903+
path: { id: childSessionId },
904+
query: {
905+
directory: args.sessionDirectory ?? args.projectIdentity,
906+
limit: 50,
907+
},
908+
});
909+
return shared.normalizeSDKResponse(messagesResponse, [] as unknown[], {
910+
preferResponseOnMissingData: true,
911+
});
912+
},
913+
validateOutput: (messages) => {
914+
const output = extractLatestAssistantText(messages);
915+
if (!output) throw new Error("Smart note evaluation returned no output.");
916+
917+
// Parse the JSON results from the LLM response — use greedy match to handle
918+
// `]` chars inside JSON string values (e.g., reasons containing brackets).
919+
const jsonMatch = output.match(/\[[\s\S]*\]/);
920+
if (!jsonMatch) {
921+
throw new Error("Smart note evaluation returned no JSON array.");
922+
}
923+
924+
try {
925+
return JSON.parse(jsonMatch[0]) as Array<{
926+
id: number;
927+
met: boolean;
928+
reason?: string;
929+
}>;
930+
} catch {
931+
throw new Error("Smart note evaluation returned invalid JSON.");
932+
}
933+
},
890934
},
891935
);
892936

893-
const messagesResponse = await args.client.session.messages({
894-
path: { id: agentSessionId },
895-
query: { directory: args.sessionDirectory ?? args.projectIdentity, limit: 50 },
896-
});
897-
const messages = shared.normalizeSDKResponse(messagesResponse, [] as unknown[], {
898-
preferResponseOnMissingData: true,
899-
});
900-
recordInvocation({ status: "completed", messages });
901-
const output = extractLatestAssistantText(messages);
902-
if (!output) throw new Error("Smart note evaluation returned no output.");
903-
904-
// Parse the JSON results from the LLM response — use greedy match to handle
905-
// `]` chars inside JSON string values (e.g., reasons containing brackets).
906-
const jsonMatch = output.match(/\[[\s\S]*\]/);
907-
if (!jsonMatch) {
908-
log("[dreamer] smart notes: no JSON array found in output, skipping");
909-
for (const note of pendingNotes) markNoteChecked(args.db, note.id);
910-
throw new Error("Smart note evaluation returned no JSON array.");
911-
}
912-
913-
let evaluations: Array<{ id: number; met: boolean; reason?: string }>;
914-
try {
915-
evaluations = JSON.parse(jsonMatch[0]);
916-
} catch {
917-
log(`[dreamer] smart notes: failed to parse JSON from LLM output, marking all checked`);
918-
for (const note of pendingNotes) markNoteChecked(args.db, note.id);
919-
throw new Error("Smart note evaluation returned invalid JSON.");
920-
}
937+
recordInvocation({ status: "completed", messages: smartNoteRun.output });
938+
const evaluations = smartNoteRun.validated;
921939
let surfaced = 0;
922940
for (const evaluation of evaluations) {
923941
if (typeof evaluation.id !== "number") continue;
@@ -956,6 +974,19 @@ Only include notes whose conditions you could definitively evaluate against exte
956974
});
957975
} catch (error) {
958976
phaseFailed = true;
977+
if (
978+
error instanceof Error &&
979+
error.message === "Smart note evaluation returned no JSON array."
980+
) {
981+
log("[dreamer] smart notes: no JSON array found in output, skipping");
982+
for (const note of pendingNotes) markNoteChecked(args.db, note.id);
983+
} else if (
984+
error instanceof Error &&
985+
error.message === "Smart note evaluation returned invalid JSON."
986+
) {
987+
log(`[dreamer] smart notes: failed to parse JSON from LLM output, marking all checked`);
988+
for (const note of pendingNotes) markNoteChecked(args.db, note.id);
989+
}
959990
const durationMs = Date.now() - taskStartedAt;
960991
const errorDescription = describeError(error);
961992
args.result.smartNotesSurfaced = 0;

0 commit comments

Comments
 (0)