fix(runner): bind requested file completion to publication receipts
Require current-run verified attachment receipts and exact filename, size, hash, and task binding for requested file output. Prior attachments remain usable in text-only follow-ups; a replacement can verify and register preserved bytes without user bookkeeping. Current-run registered external work products remain supported. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
104d1e2319
commit
8279608917
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -11,6 +11,28 @@ function evidenceRefs(value: unknown): string[] {
|
|||
});
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
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<void> {
|
||||
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:<attachmentId> 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:<attachmentId>, 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:<attachmentId>, 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.");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, { operationId: string }>;
|
||||
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));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue