From fc42373f70cf640d891eaa04aa53de49992fc849 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 4 May 2026 16:00:03 -0500 Subject: [PATCH] Harden LLM wiki auto-apply failures Co-Authored-By: Paperclip --- packages/plugins/plugin-llm-wiki/src/wiki.ts | 87 +++++++++++++------ .../plugin-llm-wiki/tests/plugin.spec.ts | 84 ++++++++++++++++++ 2 files changed, 144 insertions(+), 27 deletions(-) diff --git a/packages/plugins/plugin-llm-wiki/src/wiki.ts b/packages/plugins/plugin-llm-wiki/src/wiki.ts index ab02bb3d896..483eb1d1735 100644 --- a/packages/plugins/plugin-llm-wiki/src/wiki.ts +++ b/packages/plugins/plugin-llm-wiki/src/wiki.ts @@ -177,6 +177,11 @@ type PaperclipSourceBundle = { warnings: string[]; }; +type PaperclipBundleIssueList = { + issues: Issue[]; + warnings: string[]; +}; + type PaperclipDistillationRunInput = PaperclipSourceBundleInput; type PaperclipDistillationOutcomeInput = { @@ -1409,7 +1414,7 @@ function issueInBackfillWindow(issue: Issue, input: Pick { +async function listPaperclipBundleIssues(ctx: PluginContext, input: PaperclipSourceBundleInput): Promise { const filterAndSort = (issues: Issue[]) => issues .filter((issue) => !isLlmWikiOperationIssue(issue)) @@ -1423,7 +1428,7 @@ async function listPaperclipBundleIssues(ctx: PluginContext, input: PaperclipSou includeDocuments: true, includeAssignees: true, }); - return filterAndSort(subtree.issues); + return { issues: filterAndSort(subtree.issues), warnings: [] }; } const issues = await ctx.issues.list({ @@ -1432,7 +1437,10 @@ async function listPaperclipBundleIssues(ctx: PluginContext, input: PaperclipSou includePluginOperations: false, limit: 500, }); - return filterAndSort(issues); + const warnings = issues.length >= 500 + ? ["Source bundle may be truncated: fetched the 500 issue limit for this scope; narrow the scope or use a backfill window if older issues are missing."] + : []; + return { issues: filterAndSort(issues), warnings }; } export async function assemblePaperclipSourceBundle(ctx: PluginContext, input: PaperclipSourceBundleInput): Promise { @@ -1442,10 +1450,11 @@ export async function assemblePaperclipSourceBundle(ctx: PluginContext, input: P const perSourceLimit = limits.maxCharactersPerSource; const includeComments = input.includeComments !== false; const includeDocuments = input.includeDocuments !== false; - const issues = await listPaperclipBundleIssues(ctx, input); + const issueList = await listPaperclipBundleIssues(ctx, input); + const issues = issueList.issues; const scope = paperclipCursorScopeMetadata(input); const sourceRefs: PaperclipSourceRef[] = []; - const warnings: string[] = []; + const warnings: string[] = [...issueList.warnings]; const lines = [ `# Paperclip source bundle`, "", @@ -2122,7 +2131,7 @@ export async function distillPaperclipProjectPage(ctx: PluginContext, input: Pap } const wikiId = normalizeWikiId(input.wikiId); const scope = paperclipCursorScopeMetadata(input); - const issues = await listPaperclipBundleIssues(ctx, input); + const { issues } = await listPaperclipBundleIssues(ctx, input); const project = scope.projectId ? await ctx.projects.get(scope.projectId, input.companyId) : null; const rootIssue = scope.rootIssueId ? await ctx.issues.get(scope.rootIssueId, input.companyId) : null; const slug = projectPageSlug({ project, rootIssue }); @@ -2278,27 +2287,51 @@ export async function distillPaperclipProjectPage(ctx: PluginContext, input: Pap } const appliedPages: string[] = []; - for (const patch of patches) { - await writeWikiPage(ctx, { - companyId: input.companyId, - wikiId, - path: patch.pagePath, - contents: patch.proposedContents, - expectedHash: patch.currentHash, - summary: `Paperclip distillation ${patch.operationType} from ${bundle.sourceHash}`, - sourceRefs: patch.sourceRefs, - }); - await upsertPageBinding(ctx, { - companyId: input.companyId, - wikiId, - projectId: scope.projectId, - rootIssueId: scope.rootIssueId, - pagePath: patch.pagePath, - sourceHash: bundle.sourceHash, - runId: run.runId, - metadata: { operationType: patch.operationType }, - }); - appliedPages.push(patch.pagePath); + try { + for (const patch of patches) { + const latest = await readCurrentWithHash(ctx, input.companyId, patch.pagePath); + assertExpectedHash(patch.currentHash, latest.hash, patch.pagePath); + } + + for (const patch of patches) { + await writeWikiPage(ctx, { + companyId: input.companyId, + wikiId, + path: patch.pagePath, + contents: patch.proposedContents, + expectedHash: patch.currentHash, + summary: `Paperclip distillation ${patch.operationType} from ${bundle.sourceHash}`, + sourceRefs: patch.sourceRefs, + }); + await upsertPageBinding(ctx, { + companyId: input.companyId, + wikiId, + projectId: scope.projectId, + rootIssueId: scope.rootIssueId, + pagePath: patch.pagePath, + sourceHash: bundle.sourceHash, + runId: run.runId, + metadata: { operationType: patch.operationType }, + }); + appliedPages.push(patch.pagePath); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + try { + await recordPaperclipDistillationOutcome(ctx, { + companyId: input.companyId, + wikiId, + runId: run.runId, + cursorId: run.cursorId, + status: "failed", + sourceHash: bundle.sourceHash, + sourceWindowEnd: bundle.sourceWindowEnd, + warning: `Auto-apply failed after ${appliedPages.length} page(s): ${message}`, + }); + } catch { + // Preserve the original write failure for the caller. + } + throw error; } await recordPaperclipDistillationOutcome(ctx, { companyId: input.companyId, diff --git a/packages/plugins/plugin-llm-wiki/tests/plugin.spec.ts b/packages/plugins/plugin-llm-wiki/tests/plugin.spec.ts index 460a5a72c8b..fd839d30760 100644 --- a/packages/plugins/plugin-llm-wiki/tests/plugin.spec.ts +++ b/packages/plugins/plugin-llm-wiki/tests/plugin.spec.ts @@ -1287,6 +1287,37 @@ Route sidebar state stays attached to the selected wiki page. expect(first.sourceWindowEnd).toEqual(expect.any(String)); }); + it("warns when Paperclip source listing reaches the scoped issue limit", async () => { + const harness = createTestHarness({ manifest }); + const project = existingProject(); + const issues = Array.from({ length: 500 }, (_, index) => paperclipIssue({ + id: `88888888-8888-4888-8888-${String(index).padStart(12, "0")}`, + identifier: `PAP-${5000 + index}`, + issueNumber: 5000 + index, + title: `Bounded source issue ${index}`, + description: "Completed implementation note for project wiki distillation.", + status: "done", + projectId: project.id, + updatedAt: new Date(`2026-05-${String((index % 28) + 1).padStart(2, "0")}T10:00:00Z`), + })); + harness.seed({ projects: [project], issues }); + + await plugin.definition.setup(harness.ctx); + const bundle = await harness.performAction<{ + markdown: string; + warnings: string[]; + }>("assemble-paperclip-source-bundle", { + companyId: COMPANY_ID, + projectId: project.id, + maxCharacters: 400000, + }); + + expect(bundle.markdown).toContain("- Issue count: 500"); + expect(bundle.warnings).toEqual(expect.arrayContaining([ + expect.stringContaining("500 issue limit"), + ])); + }); + it("suppresses secret-like comment and document bodies before storing distillation snapshots", async () => { const harness = createTestHarness({ manifest }); const issue = paperclipIssue({ @@ -1813,6 +1844,59 @@ Route sidebar state stays attached to the selected wiki page. ])); }); + it("records failed distillation outcomes when auto-apply writes partially fail", async () => { + const harness = createTestHarness({ manifest, config: { autoApplyIngestPatches: true } }); + const project = existingProject(); + const issue = paperclipIssue({ + id: "77777777-7777-4777-8777-77777777779b", + identifier: "PAP-4105", + title: "Publish project page with shared index", + description: "Implementation completed enough to publish the generated project page.", + status: "done", + projectId: project.id, + updatedAt: new Date("2026-05-04T10:00:00Z"), + }); + const files = new Map([ + ["wiki/index.md", DEFAULT_INDEX], + ["wiki/log.md", DEFAULT_LOG], + ]); + const writes: string[] = []; + harness.seed({ projects: [project], issues: [issue] }); + harness.ctx.localFolders.readText = async (_companyId, _folderKey, relativePath) => { + const contents = files.get(relativePath); + if (contents == null) throw new Error(`missing ${relativePath}`); + return contents; + }; + harness.ctx.localFolders.writeTextAtomic = async (_companyId, _folderKey, relativePath, contents) => { + writes.push(relativePath); + if (relativePath === "wiki/index.md") { + throw new Error("simulated index write conflict"); + } + files.set(relativePath, contents); + return harness.ctx.localFolders.status(COMPANY_ID, "wiki-root"); + }; + + await plugin.definition.setup(harness.ctx); + await expect(harness.performAction("distill-paperclip-project-page", { + companyId: COMPANY_ID, + projectId: project.id, + autoApply: true, + maxCharacters: 20000, + includeSupportingPages: false, + })).rejects.toThrow("simulated index write conflict"); + + expect(writes).toEqual([ + "wiki/projects/existing-wiki-project.md", + "wiki/index.md", + ]); + const failedOutcome = harness.dbExecutes.find((execute) => + execute.sql.trim().startsWith("UPDATE") + && execute.sql.includes("paperclip_distillation_runs") + && execute.params?.[3] === "failed"); + expect(failedOutcome?.params?.[4]).toContain("Auto-apply failed after 1 page(s)"); + expect(failedOutcome?.params?.[4]).toContain("simulated index write conflict"); + }); + it("refuses auto-apply Paperclip project page patches in authenticated/public deployments", async () => { process.env.PAPERCLIP_DEPLOYMENT_MODE = "authenticated"; process.env.PAPERCLIP_DEPLOYMENT_EXPOSURE = "public";