fix(report): do not fail a written report when progress projection throws
Staging wrote four ready chapters then marked the job schema-invalid 34ms later with no summary telemetry. Keep chapter persistence and assemble going if onProgress or a non-lease heartbeat error fails. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -3155,6 +3155,13 @@ async function generateSectionedPersonalReport(
|
||||
return failSchema("sectioned_dependencies_missing");
|
||||
}
|
||||
const writePlans = plan.sections.filter((entry) => entry.kind === "thematic" && entry.disposition === "write");
|
||||
const emitProgress = async (phase: string, completed: number): Promise<void> => {
|
||||
try {
|
||||
await deps.onProgress?.({ phase, completed, total: writePlans.length });
|
||||
} catch (error) {
|
||||
rethrowIfAborted(error, deps.signal);
|
||||
}
|
||||
};
|
||||
for (const entry of writePlans) {
|
||||
await sectionService.ensure({
|
||||
userId: deps.userId, requestId: deps.requestId, sectionId: entry.id, maxAttempts: 2,
|
||||
@@ -3177,7 +3184,7 @@ async function generateSectionedPersonalReport(
|
||||
});
|
||||
if (!blockedRow) throw new Error("section_block_failed");
|
||||
blocked.push(blockedRow);
|
||||
await deps.onProgress?.({ phase: `section:${entry.id}`, completed: ready.length + blocked.length, total: writePlans.length });
|
||||
await emitProgress(`section:${entry.id}`, ready.length + blocked.length);
|
||||
continue;
|
||||
}
|
||||
const outputBudget = sectionOutputTokenBudget(entry.targetCharacters.max);
|
||||
@@ -3204,7 +3211,7 @@ async function generateSectionedPersonalReport(
|
||||
const completed = await sectionService.complete({ userId: deps.userId, requestId: deps.requestId, sectionId: entry.id, payload: output });
|
||||
if (!completed) throw new Error("section_complete_failed");
|
||||
ready.push(completed);
|
||||
await deps.onProgress?.({ phase: `section:${entry.id}`, completed: ready.length + blocked.length, total: writePlans.length });
|
||||
await emitProgress(`section:${entry.id}`, ready.length + blocked.length);
|
||||
break;
|
||||
} catch (error) {
|
||||
rethrowIfAborted(error, deps.signal);
|
||||
@@ -3214,7 +3221,7 @@ async function generateSectionedPersonalReport(
|
||||
});
|
||||
if (!blockedRow) throw new Error("section_block_failed");
|
||||
blocked.push(blockedRow);
|
||||
await deps.onProgress?.({ phase: `section:${entry.id}`, completed: ready.length + blocked.length, total: writePlans.length });
|
||||
await emitProgress(`section:${entry.id}`, ready.length + blocked.length);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -3232,13 +3239,13 @@ async function generateSectionedPersonalReport(
|
||||
});
|
||||
if (!blockedRow) throw new Error("section_block_failed");
|
||||
blocked.push(blockedRow);
|
||||
await deps.onProgress?.({ phase: `section:${entry.id}`, completed: ready.length + blocked.length, total: writePlans.length });
|
||||
await emitProgress(`section:${entry.id}`, ready.length + blocked.length);
|
||||
}
|
||||
}
|
||||
if (writePlans.length > 0 && ready.length === 0) {
|
||||
return failSchema("all_sections_blocked");
|
||||
}
|
||||
await deps.onProgress?.({ phase: "summary", completed: writePlans.length, total: writePlans.length });
|
||||
await emitProgress("summary", writePlans.length);
|
||||
let summary;
|
||||
try {
|
||||
summary = await deps.agent.generateSummary(ready.map((item) => ({
|
||||
@@ -3260,7 +3267,7 @@ async function generateSectionedPersonalReport(
|
||||
missingEvidence: [sectionFailureReason(item.lastErrorCode)], conflictNotes: [], evidenceRefs: refs,
|
||||
};
|
||||
});
|
||||
await deps.onProgress?.({ phase: "assemble", completed: writePlans.length, total: writePlans.length });
|
||||
await emitProgress("assemble", writePlans.length);
|
||||
try {
|
||||
const candidate = assembleReportDocumentV2({
|
||||
reportId: deps.reportId, generatedAt: (deps.now ?? (() => new Date()))().toISOString(),
|
||||
|
||||
@@ -338,14 +338,22 @@ export function createPersonalReportWorker(deps: PersonalReportWorkerDeps) {
|
||||
signal: controller.signal,
|
||||
sectionService: deps.sectionService,
|
||||
onProgress: async (progress) => {
|
||||
await refreshLease();
|
||||
if (heartbeatError !== null) throw heartbeatError;
|
||||
const percent = progress.total > 0
|
||||
? Math.min(89, 30 + Math.floor((progress.completed / progress.total) * 55))
|
||||
: 30;
|
||||
await deps.jobs.updateProgress({
|
||||
jobId: job.id, leaseToken: job.leaseToken!, phase: progress.phase, percent,
|
||||
});
|
||||
try {
|
||||
await refreshLease();
|
||||
if (heartbeatError !== null) throw heartbeatError;
|
||||
const percent = progress.total > 0
|
||||
? Math.min(89, 30 + Math.floor((progress.completed / progress.total) * 55))
|
||||
: 30;
|
||||
await deps.jobs.updateProgress({
|
||||
jobId: job.id, leaseToken: job.leaseToken!, phase: progress.phase, percent,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isLeaseLoss(error) || heartbeatError !== null) {
|
||||
heartbeatError = heartbeatError ?? error;
|
||||
controller.abort(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
await heartbeatChain;
|
||||
|
||||
@@ -426,8 +426,14 @@ test("production writer and worker keep the same signal on initial and repair mo
|
||||
new URL("../src/lib/personal-report-worker-core.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(workerCore, /onProgress: async \(progress\) => \{\s*await refreshLease\(\)/);
|
||||
assert.match(workerCore, /onProgress: async \(progress\) => \{\s*try \{\s*await refreshLease\(\)/);
|
||||
assert.doesNotMatch(workerCore, /timerUnref\(heartbeatTimer\)/);
|
||||
const generationSource = readFileSync(
|
||||
new URL("../src/lib/personal-report-generation.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(generationSource, /const emitProgress = async \(phase: string, completed: number\)/);
|
||||
assert.match(generationSource, /rethrowIfAborted\(error, deps\.signal\);/);
|
||||
});
|
||||
|
||||
test("an all-blocked plan still produces a valid v2 document with honest disclosures and no invented thematic section", async () => {
|
||||
@@ -828,6 +834,23 @@ test("sectioned generation makes one filtered call per write theme, then summary
|
||||
assert.match(document.executiveSummary.summary, /事业与方向/);
|
||||
});
|
||||
|
||||
test("sectioned generation still assembles when onProgress throws after a persisted section", async () => {
|
||||
const bundle = makeBundle({
|
||||
themes: fullThemes.slice(0, 3),
|
||||
charts: [chart("D1"), chart("D2", 1), chart("D9", 2), chart("D10", 3), chart("D11", 4)],
|
||||
});
|
||||
const result = await runSectioned(
|
||||
bundle,
|
||||
sectionedAgent({}),
|
||||
inMemorySectionService(),
|
||||
async () => {
|
||||
throw new Error("progress_projection_failed");
|
||||
},
|
||||
);
|
||||
const document = readyV2(result);
|
||||
assert.deepEqual(document.thematicNarrative.map((section) => section.theme), ["career", "marriage", "wealth"]);
|
||||
});
|
||||
|
||||
test("sectioned resume skips ready sections after an interruption", async () => {
|
||||
const bundle = makeBundle({ themes: fullThemes, charts: [chart("D1"), chart("D2", 1), chart("D9", 2), chart("D10", 3), chart("D11", 4), chart("D24", 5)] });
|
||||
const service = inMemorySectionService();
|
||||
|
||||
Reference in New Issue
Block a user