Harden LLM wiki auto-apply failures

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-05-04 16:00:03 -05:00
1 parent aacd40a67a
commit fc42373f70
2 files changed
+144 -27

No files matched your search

+60 -27
View File
@@ -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<PaperclipSourceBundleIn
return true;
}
async function listPaperclipBundleIssues(ctx: PluginContext, input: PaperclipSourceBundleInput): Promise<Issue[]> {
async function listPaperclipBundleIssues(ctx: PluginContext, input: PaperclipSourceBundleInput): Promise<PaperclipBundleIssueList> {
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<PaperclipSourceBundle> {
@@ -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,
@@ -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<string, string>([
["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";