diff --git a/doc/execution-semantics.md b/doc/execution-semantics.md index fdc71cad49..3846253c80 100644 --- a/doc/execution-semantics.md +++ b/doc/execution-semantics.md @@ -1206,8 +1206,13 @@ that the task is complete. For an explicit file output in the current request, an empty report, a verification-only reference, or an unregistered URL cannot satisfy delivery. -The report must cite a task-scoped attachment or a registered accessible work -product with a published URL. A `workspace_file` locator alone is not delivery +The report must cite an attachment verified by the current run's durable +publication receipt, matching its task, filename, size, and SHA-256, or an +accessible work product registered by that run with a published URL. A prior +run's output cannot stand in for a newly requested file. A same-run controller +restart keeps the receipt; a replacement can inspect and re-register preserved +workspace bytes without user bookkeeping. Follow-ups requesting no new file can +still reference existing downloads. A `workspace_file` locator alone is not delivery evidence: it neither verifies the file nor preserves its bytes after cleanup. Reading or reviewing an existing file for an inline answer does not require uploading that input. Ambiguous prose remains subject to the runner's diff --git a/server/src/services/native-runtime/native-completion-feedback.ts b/server/src/services/native-runtime/native-completion-feedback.ts index d57a9128c7..046fc2299b 100644 --- a/server/src/services/native-runtime/native-completion-feedback.ts +++ b/server/src/services/native-runtime/native-completion-feedback.ts @@ -65,7 +65,8 @@ export async function nativeCompletionFeedback( const continuation = run.contextSnapshot?.executionContinuation as { objective?: unknown } | undefined; const objective = typeof continuation?.objective === "string" ? continuation.objective : [issue.title, issue.description].filter(Boolean).join("\n"); - await validateNativeDeliverableEvidence(db, { companyId: run.companyId, issueId: issue.id, objective }, result); + await validateNativeDeliverableEvidence(db, { companyId: run.companyId, issueId: issue.id, runId, + objective, semanticToolReceipts: run.resultJson?.semanticToolReceipts }, result); const retiredCandidates = await findAutomaticCompletionReviews(db, issue.id); const retiredIds = retiredCandidates.map(({ interaction }) => interaction.id); const [interaction, approval] = await Promise.all([ diff --git a/server/src/services/native-runtime/native-deliverable-feedback.ts b/server/src/services/native-runtime/native-deliverable-feedback.ts index 64f8a516c1..6341f12ed8 100644 --- a/server/src/services/native-runtime/native-deliverable-feedback.ts +++ b/server/src/services/native-runtime/native-deliverable-feedback.ts @@ -11,6 +11,28 @@ function evidenceRefs(value: unknown): string[] { }); } +function record(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record : {}; +} + +function hasCurrentPublicationReceipt(receipts: unknown, attachment: { + id: string; filename: string | null; byteSize: number; sha256: string; +}): boolean { + return Object.values(record(receipts)).some(value => { + const receipt = record(value); + const input = record(receipt.input); + const result = record(receipt.result); + return receipt.operationId === "register_deliverable" && + (result.disposition === "applied" || result.disposition === "duplicate") && + result.commandId === `deliverable-prepared:${attachment.id}` && + Array.isArray(result.entityRefs) && result.entityRefs[0] === attachment.id && + typeof input.filename === "string" && input.filename.trim() === attachment.filename && + input.byteSize === attachment.byteSize && + typeof input.sha256 === "string" && input.sha256.trim().toLowerCase() === attachment.sha256.toLowerCase(); + }); +} + /** Recognize explicit output requests, not incidental mentions of source files. * The current server-bound objective is authoritative; summaries cannot invent * an output requirement or erase a user's request for a file. @@ -39,7 +61,7 @@ export function explicitlyRequestsFileOutput(objective: string): boolean { /** Files cited as completed output must be reachable outside the agent workspace. */ export async function validateNativeDeliverableEvidence( db: Db, - binding: { companyId: string; issueId: string; objective: string }, + binding: { companyId: string; issueId: string; runId: string; objective: string; semanticToolReceipts: unknown }, result: PrpStructuredRunResult, ): Promise { if (result.reportedWorkDisposition !== "done") return; @@ -59,7 +81,8 @@ export async function validateNativeDeliverableEvidence( const id = attachmentPath?.[1] ?? ref.slice("deliverable:".length); const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu; const [attachment] = uuid.test(id) - ? await db.select({ id: issueAttachments.id }).from(issueAttachments) + ? await db.select({ id: issueAttachments.id, originatingRunId: issueAttachments.originatingRunId, + filename: assets.originalFilename, byteSize: assets.byteSize, sha256: assets.sha256 }).from(issueAttachments) .innerJoin(assets, and(eq(assets.id, issueAttachments.assetId), eq(assets.companyId, binding.companyId))) .where(and(eq(issueAttachments.id, id), eq(issueAttachments.companyId, binding.companyId), eq(issueAttachments.issueId, binding.issueId))) .limit(1) @@ -67,6 +90,14 @@ export async function validateNativeDeliverableEvidence( if (!attachment) { throw new Error("Completion cites no registered attachment on this task. Use register_deliverable for the requested file and cite deliverable: from its receipt. No human completion approval was created."); } + // A prior output (or user input) can be useful context, but does not prove + // this run published the newly requested output. The receipt survives a + // controller restart of this run; a replacement can re-register preserved + // workspace bytes internally rather than asking the user to confirm them. + if (fileRequested && (attachment.originatingRunId !== binding.runId || + !hasCurrentPublicationReceipt(binding.semanticToolReceipts, attachment))) { + throw new Error("This attachment has no matching verified publication receipt for this run's requested output. Inspect any preserved file and use register_deliverable to verify its current filename, size, and SHA-256, then cite the new receipt. No human completion approval was created."); + } registeredAttachment = true; continue; } @@ -83,6 +114,7 @@ export async function validateNativeDeliverableEvidence( eq(issueWorkProducts.companyId, binding.companyId), eq(issueWorkProducts.issueId, binding.issueId), )) : []; const accessibleProduct = products.some(product => { + if (product.createdByRunId !== binding.runId) return false; if (["failed", "cancelled", "archived"].includes(product.status)) return false; // A workspace_file resource is only a locator: registration neither checks // its current bytes nor keeps them alive after workspace cleanup. Requested @@ -91,6 +123,6 @@ export async function validateNativeDeliverableEvidence( return accessible && [product.url, `work_product:${product.id}`, `work-product:${product.id}`, `artifact:${product.id}`] .some(ref => typeof ref === "string" && refs.has(ref)); }); - if (!accessibleProduct) throw new Error("The requested file has no accessible delivery evidence. Use register_deliverable and cite deliverable:, or cite a registered accessible work product for this task. Empty evidence and a verification result cannot substitute for the requested file. Continue publishing or report a concrete blocker; no human completion approval was created."); + if (!accessibleProduct) throw new Error("The requested file has no accessible delivery evidence. Use register_deliverable and cite deliverable:, or cite an accessible work product registered by this run for this task. Empty evidence, prior-run output, and a verification result cannot substitute for the requested file. Continue publishing or report a concrete blocker; no human completion approval was created."); } } diff --git a/server/src/services/native-runtime/native-runner-file-handoff.test.ts b/server/src/services/native-runtime/native-runner-file-handoff.test.ts index bc750bb31c..31377d162a 100644 --- a/server/src/services/native-runtime/native-runner-file-handoff.test.ts +++ b/server/src/services/native-runtime/native-runner-file-handoff.test.ts @@ -1,6 +1,6 @@ import type { PrpStructuredRunResult } from "../../vendor/paperclip-runner/index.js"; import { nativeCompletionFeedback } from "./native-completion-feedback.js"; -import { createHash } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import { spawn } from "node:child_process"; import { once } from "node:events"; import { @@ -1153,4 +1153,69 @@ describe("native runner file handoff", () => { .where(eq(heartbeatRuns.id, runId)); } }); + it.each(["attachment", "work product"])("rejects a previous run's %s for new output and revalidates preserved bytes internally", async (kind) => { + const key = `prior-output-${kind}`; + const body = Buffer.from("Preserved work from the previous run.\n"); + await writeFile(path.join(workspaceRoot, `${key}.txt`), body); + const prior = await authority().execute(callFor(`${key}.txt`, body, key)) as { entityRefs: string[] }; + const [product] = await db.insert(issueWorkProducts).values({ companyId, issueId, type: "artifact", provider: "external", + title: "Previous report", status: "ready_for_review", url: "https://example.com/previous.pdf", createdByRunId: runId }).returning(); + const nextRunId = randomUUID(); + await db.insert(heartbeatRuns).values({ id: nextRunId, companyId, agentId, status: "running", runtimeMode: "native", + nativeIssueId: issueId, invocationSource: "assignment", triggerDetail: "system", contextSnapshot: { issueId } }); + await db.update(issues).set({ executionRunId: nextRunId }).where(eq(issues.id, issueId)); + const ref = kind === "attachment" ? `deliverable:${prior.entityRefs[0]}` : `work_product:${product.id}`; + try { + await expect(nativeCompletionFeedback(db, nextRunId, doneReport([ref]))) + .rejects.toThrow(/current run|this run|requested file.*accessible/); + + // A reference in a text-only follow-up is still useful evidence, not a new output claim. + await db.update(heartbeatRuns).set({ contextSnapshot: { issueId, executionContinuation: { objective: "Do not create a file. Explain the result inline." } } }).where(eq(heartbeatRuns.id, nextRunId)); + await expect(nativeCompletionFeedback(db, nextRunId, doneReport([ref]))) + .resolves.toContain("Completion report accepted"); + await db.update(heartbeatRuns).set({ contextSnapshot: { issueId } }).where(eq(heartbeatRuns.id, nextRunId)); + + // The replacement can verify and publish the existing bytes itself. No user action is needed. + const replacement = new PaperclipRunnerToolAuthority(db, { companyId, agentId, issueId, runId: nextRunId, + workspaceRoot, executionTargetKind: "local", storage: createStorageService(createLocalDiskStorageProvider(storageRoot)) }); + const current = await replacement.execute(callFor(`${key}.txt`, body, `${key}-verified`)) as { entityRefs: string[] }; + expect(current.entityRefs[0]).not.toBe(prior.entityRefs[0]); + expect(await readFile(path.join(workspaceRoot, `${key}.txt`))).toEqual(body); + // Feedback reloads the durable receipt, so a controller restart of the same run keeps this proof. + await expect(nativeCompletionFeedback(db, nextRunId, doneReport([`deliverable:${current.entityRefs[0]}`]))) + .resolves.toContain("Completion report accepted"); + } finally { + await db.update(issues).set({ executionRunId: runId }).where(eq(issues.id, issueId)); + await db.delete(issueWorkProducts).where(eq(issueWorkProducts.id, product.id)); + } + }); + + it.each(["filename", "size", "hash", "origin", "missing receipt", "wrong operation"])("requires matching current publication proof after %s changes", async (mutation) => { + const key = `receipt-match-${mutation}`; + const body = Buffer.from(`verified publication ${mutation}\n`); + await writeFile(path.join(workspaceRoot, `${key}.txt`), body); + const result = await authority().execute(callFor(`${key}.txt`, body, key)) as { entityRefs: string[] }; + const [attachment] = await db.select().from(issueAttachments).where(eq(issueAttachments.id, result.entityRefs[0])); + const [asset] = await db.select().from(assets).where(eq(assets.id, attachment.assetId)); + const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId)); + if (mutation === "filename") await db.update(assets).set({ originalFilename: "different.txt" }).where(eq(assets.id, asset.id)); + if (mutation === "size") await db.update(assets).set({ byteSize: asset.byteSize + 1 }).where(eq(assets.id, asset.id)); + if (mutation === "hash") await db.update(assets).set({ sha256: "0".repeat(64) }).where(eq(assets.id, asset.id)); + if (mutation === "origin") await db.update(issueAttachments).set({ originatingRunId: null }).where(eq(issueAttachments.id, attachment.id)); + if (mutation === "missing receipt" || mutation === "wrong operation") { + const changed = structuredClone(run.resultJson!); + const receipts = changed.semanticToolReceipts as Record; + if (mutation === "missing receipt") delete receipts[key]; + else receipts[key]!.operationId = "report_progress"; + await db.update(heartbeatRuns).set({ resultJson: changed }).where(eq(heartbeatRuns.id, runId)); + } + try { + await expect(nativeCompletionFeedback(db, runId, doneReport([`deliverable:${attachment.id}`]))) + .rejects.toThrow(/current run|this run/); + } finally { + await db.update(assets).set({ originalFilename: asset.originalFilename, byteSize: asset.byteSize, sha256: asset.sha256 }).where(eq(assets.id, asset.id)); + await db.update(issueAttachments).set({ originatingRunId: attachment.originatingRunId }).where(eq(issueAttachments.id, attachment.id)); + await db.update(heartbeatRuns).set({ resultJson: run.resultJson }).where(eq(heartbeatRuns.id, runId)); + } + }); });