fix(github): recover long publications from durable receipts (#16883)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agents publish GitHub reviews through governed tools.
> - GitHub can accept a publication before the caller receives its
result.
> - Exact retries must return the existing publication receipt.
> - The gateway compared a shortened audit hash with a complete request
hash.
> - Long reviews therefore failed the retry check even when their
content was unchanged.
> - This pull request checks the complete saved publication and returns
its current receipt.
> - Agents can confirm their work without another provider write.

## Linked Issues or Issue Description

Refs #16284, which added durable publication recovery. Refs #16275,
which extended the GitHub tool deadline.

**What happened?**

An exact review retry returned an idempotency conflict after GitHub had
published the review. The invocation hash uses an audit preview that
shortens strings to 500 characters and arrays to 50 entries. The gateway
compared that hash with the complete request hash. The earlier retry
tests used short assessments.

**Expected behavior**

An unchanged request returns its durable receipt. Changed content,
changed authority, or a missing publication must not cause another
write.

**Steps to reproduce**

1. Submit a review with a summary and rationale longer than 500
characters.
2. Let publication finish after a short caller deadline.
3. Repeat the exact request. Before this fix, the gateway returns an
idempotency conflict.

**Paperclip version or commit**

Reproduced on master at `c5226daf7a37559ddc007446681717791d80a223`.

## What Changed

- Use the durable publication's complete request check for GitHub
retries. Keep run, agent, task, connection, tool, and current authority
checks.
- Return the saved receipt through normal gateway validation and
bookkeeping. Leave pending provider work to the durable worker.
- Refuse retries without a saved publication. Keep existing invocation
keys and audit previews unchanged.
- Cover long reviews, comments, progress updates, text suffix changes,
nested coverage changes, pending receipts, missing publications, and
revoked access.
- Document the recovery contract and verification recipes.

## Verification

- Red: both long-review receipt cases failed on unchanged production
code with the expected idempotency conflict.
- Green: all 144 GitHub review workflow tests passed on the final
source. This includes long reviews, comments, progress updates, pending
and missing receipts, revoked policy, and the native assigned-tool entry
point.
- Server and workspace typechecks passed. The production build passed.
- Map checks passed. These are structural checks.
- The first broad run found an incorrect new fixture assumption: its
discovery query returned two review tools. The fixture now selects the
exact submission tool. An existing description-edit polling test also
timed out; it passed separately without a production change, then passed
in the complete 144-test rerun. Both earlier failures remain recorded.
- Final commit `7cdcdc3a1ac79cb4ed77510a32d795228e3315e8` passed all 55
CI checks. Two optional Storybook jobs were skipped. [Complete CI
run](https://github.com/paperclipai/paperclip/actions/runs/39304187397).
Greptile is 5/5 on this exact commit, with zero unresolved review
threads.
- The earlier local full-suite attempt was stopped after the fixture
correction. The final local full-suite attempt was stopped after
complete final-head CI passed. No complete local full-suite pass is
claimed; full-suite acceptance uses the current-head CI matrix.
- Provider responses are controlled fixtures. Native assigned-tool
coverage uses the production gateway and disposable PostgreSQL. No live
GitHub fault injection, deployment, or historical task recovery is
claimed.

## Risks

A pending receipt remains pending. It does not complete the task. Caller
retries no longer dispatch provider work; the durable worker retains
that responsibility. A missing durable publication returns a conflict
because the gateway cannot establish the original write's outcome.
Existing approval and revocation checks remain effective. No database
migration or audit hash change is required.

## Model Used

OpenAI GPT-6 through Codex, with reasoning, tool use, and code
execution. The session does not expose a more specific serving model ID
or context-window limit.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change and contains no internal
Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-10-19 13:54:42 -05:00
1 parent 95672307f4
commit 9e8a9f28d5
5 files changed
+122 -6

No files matched your search

+6
View File
@@ -240,6 +240,12 @@ still pending. Claim publication only after `status: processed`. Receipt recover
does not itself mark a task Done; the agent must finish its task after confirming
the receipt.
Receipt recovery compares the complete saved publication request, including long
text and nested coverage entries. Shortened audit previews do not establish
request identity. Changed content or a missing durable publication returns a
conflict without dispatching another write. Exact retries read the current
receipt; the durable worker owns pending publication retries.
For a call that required human approval, the durable publication keeps that
verified approval for its exact operation. An exact caller retry only reads the
current receipt; the worker owns provider retries. A new policy denial or revoked
+4 -1
View File
@@ -98,7 +98,10 @@ live GitHub credential recovery.
GitHub publication recovery (2026-10-14): assigned-agent `submit_review`, `comment`,
and `update_comment` calls reuse their run-, task-, endpoint-, and argument-bound
publication on an exact retry. Fresh gateway policy and provider authority remain
publication on an exact retry. Identity uses the complete durable request rather
than the shortened audit preview. A changed suffix, changed nested entry, or
missing publication cannot authorize another write. Exact retries only read the
receipt; the worker owns pending provider retries. Fresh gateway policy and provider authority remain
required. The durable worker resumes transient failures after restart and stops
at its retry cap, revoked authority, or a superseded head. A pending receipt is
not task completion. Source:
+5 -1
View File
@@ -118,7 +118,11 @@ their App-scoped checks and comments remain independent.
For GitHub publication recovery, interrupt a response after GitHub accepts a
write and verify one summary, finding, and App-scoped check. Retry the same tool
call and verify it returns the saved receipt. Leave a transient failure queued,
call and verify it returns the saved receipt. Include text longer than 500
characters and coverage with more than 50 entries. Change only the text suffix
or final coverage entry and verify rejection without another write. Remove the
durable publication and verify that a retry cannot dispatch a new write.
Leave a transient failure queued,
restart the instance, and verify publication resumes at its saved deadline
without another inbound message. Revoked access and permanent rejections must
stop retries. These recipes are separate from live provider proof.
@@ -2925,7 +2925,9 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
const tools = await githubBotToolsForSession(db, f.session);
const tool = tools.find(tool => tool.upstreamToolName === "submit_review")!;
await githubChatReviewService(db, f.providerFetch).execute(f.session, "begin_review", { reviewedCommit: head });
const assessment = { reviewedCommit: head, score: 5, complete: true, summary: "Looks good", rationale: "Reviewed the changed file",
const assessment = { reviewedCommit: head, score: 5, complete: true,
summary: "Reviewed the changed behavior and its callers. ".repeat(30),
rationale: "Checked the mutation boundary and the client retry contract. ".repeat(30),
coverage: { reviewedPaths: ["src/math.ts"], omittedPaths: [], limitations: [] }, findings: [] };
const result = await gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: assessment, timeoutMs })
.then(value => ({ value }), error => ({ error }));
@@ -2948,6 +2950,89 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
expect(invocation).toMatchObject({ status: "succeeded", errorCode: null, errorMessage: null });
expect(f.responseWrites).toHaveLength(writes);
}, 40_000);
it.each(["submit_review", "comment", "update_comment"] as const)("recovers long %s requests without accepting changes hidden by the audit preview", async name => {
const f = await publicationRetryFixture();
vi.stubGlobal("fetch", f.providerFetch);
onTestFinished(() => vi.unstubAllGlobals());
const gateway = createToolGatewayService(db);
const session = await gateway.createSession({ companyId: f.companyId, agentId: f.assignedAgentId, runId: f.run.id });
const tool = (await githubBotToolsForSession(db, f.session)).find(tool => tool.upstreamToolName === name)!;
const text = "Checked the changed behavior, connected callers, and coverage. ".repeat(30);
const parameters = name === "submit_review"
? { ...f.assessment, summary: text, rationale: text, coverage: { ...f.assessment.coverage,
limitations: Array.from({ length: 51 }, (_, index) => `Coverage limitation ${index}`) } }
: { body: text, idempotencyKey: "long-publication" };
const first = await gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters });
const writes = f.responseWrites.length;
const replay = await gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters });
expect(replay).toMatchObject({ invocationId: first.invocationId, result: { data: { status: "processed" } } });
if (name === "submit_review") {
vi.stubEnv("PAPERCLIP_API_URL", "https://paperclip.example.test");
onTestFinished(() => vi.unstubAllEnvs());
await initializeRunIdentity(db, { companyId: f.companyId, runId: f.run.id, issueId: f.conversation.issueId!,
responsibleUserId: "owner-user", messageIds: [String(f.run.contextSnapshot?.wakeCommentId)], cause: "instruction" });
const servers = await buildPaperclipRuntimeMcpServers({ db,
agent: (await db.select().from(agents).where(eq(agents.id, f.assignedAgentId)))[0], runId: f.run.id });
const assigned = await createAssignedMcpTools({ gateway,
gatewayPublicId: new URL(servers[0].url).pathname.split("/").at(-1)!, bearerToken: servers[0].token });
const found = await assigned.execute({ tool: "paperclip_search_assigned_tools", arguments: { query: "Submit a review assessment" } }) as { tools: { name: string }[] };
const submit = found.tools.filter(candidate => candidate.name.includes("__submit_review_"));
expect(submit).toHaveLength(1);
expect(await assigned.execute({ tool: "paperclip_call_assigned_tool", arguments: { name: submit[0].name, arguments: parameters } }))
.toMatchObject({ data: { status: "processed" } });
}
expect(f.responseWrites).toHaveLength(writes);
expect(f.responseComments.size).toBe(1);
const changed = name === "submit_review" ? { ...parameters, summary: `${text} Changed conclusion.` }
: { ...parameters, body: `${text} Changed conclusion.` };
await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: changed }))
.rejects.toMatchObject({ status: 409, reasonCode: "github_idempotency_conflict" });
if (name === "submit_review") {
const changedCoverage = { ...parameters, coverage: { ...parameters.coverage!,
limitations: [...parameters.coverage!.limitations.slice(0, 50), "Changed final limitation"] } };
await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: changedCoverage }))
.rejects.toMatchObject({ status: 409, reasonCode: "github_idempotency_conflict" });
}
await db.insert(toolPolicies).values({ companyId: f.companyId, name: "Revoke publication", policyType: "block", priority: 0,
selectors: { catalogEntryId: tool.catalogEntryId! } });
await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters })).rejects.toThrow();
expect(f.responseWrites).toHaveLength(writes);
});
it("reads pending GitHub receipts without dispatch and refuses a missing publication", async () => {
const f = await publicationRetryFixture();
let attempts = 0;
let unavailable = true;
f.setSupplementalProviderFetch(async (input, init) => {
if (String(input).includes("/issues/comments/") && init?.method === "PATCH") {
attempts++;
if (unavailable) return new Response("Unavailable", { status: 503, headers: { "retry-after": "120" } });
}
return f.provider(input, init);
});
vi.stubGlobal("fetch", f.providerFetch);
onTestFinished(() => vi.unstubAllGlobals());
const gateway = createToolGatewayService(db);
const session = await gateway.createSession({ companyId: f.companyId, agentId: f.assignedAgentId, runId: f.run.id });
const tool = (await githubBotToolsForSession(db, f.session)).find(tool => tool.upstreamToolName === "submit_review")!;
const input = { sessionToken: session.token, tool: tool.name, parameters: { ...f.assessment, summary: "Review evidence. ".repeat(50) } };
const first = await gateway.executeTool(input);
expect(first).toMatchObject({ result: { data: { status: "failed", receipt: { retryable: true } } } });
unavailable = false;
expect(await gateway.executeTool(input)).toMatchObject({ invocationId: first.invocationId,
result: { data: { status: "failed", receipt: { retryable: true } } } });
expect(attempts).toBe(1);
const action = await f.action();
await db.update(chatActions).set({ result: { ...action.result, retryAt: new Date(0).toISOString() } }).where(eq(chatActions.id, action.id));
await githubChatReviewService(db, f.providerFetch).processPending();
expect(await gateway.executeTool(input)).toMatchObject({ invocationId: first.invocationId, result: { data: { status: "processed" } } });
expect(attempts).toBe(2);
expect(f.responseComments.size).toBe(1);
const writes = f.responseWrites.length;
await db.delete(chatActions).where(eq(chatActions.id, action.id));
await expect(gateway.executeTool(input)).rejects.toMatchObject({ status: 409, reasonCode: "github_publication_unavailable" });
expect(attempts).toBe(2);
expect(f.responseWrites).toHaveLength(writes);
});
it("does not post a GitHub working comment when the scheduler declines the request", async () => {
const f = await reviewBotFixture();
f.wakeup.mockImplementation(async (agentId, opts) => {
+21 -3
View File
@@ -10220,6 +10220,7 @@ export function createToolGatewayService(
sensitiveMode: "redact",
promptInjectionMode: "ignore",
});
let recoveredGitHubPublication: { content: string; data: unknown } | undefined;
let effectiveParameters: unknown = requestedParameters;
let effectiveArgumentsSummary = argumentValidation.summary;
@@ -10639,12 +10640,29 @@ export function createToolGatewayService(
const gitHubReplayBindingMatches =
recorded.invocation.agentId === session.agentId && recorded.invocation.runId === session.runId &&
recorded.invocation.issueId === session.issueId && recorded.invocation.connectionId === tool.connectionId &&
recorded.invocation.toolName === tool.name && recorded.invocation.argumentsHash === effectiveArgumentsSummary.sha256 &&
recorded.invocation.toolName === tool.name &&
["authorized", "executing", "succeeded", "failed", "timed_out"].includes(recorded.invocation.status);
if (gitHubPublicationReplay && gitHubReplayPolicyAllowed && !gitHubReplayBindingMatches)
throw new ToolGatewayHttpError(409, "Idempotency key belongs to a different GitHub publication", "github_idempotency_conflict");
const retryingGitHubPublication = gitHubPublicationReplay && accessDecision.allowed &&
recorded.invocation.approvalState === "not_required" && gitHubReplayBindingMatches;
if (retryingGitHubPublication) {
// Invocation hashes describe shortened audit previews. The durable
// publication checks the complete saved request and current authority.
let receipt;
try {
receipt = await githubChatReviewService(db).readPublicationReceipt(
session, tool.upstreamToolName!, effectiveParameters, invocationId,
);
} catch (error) {
if (error instanceof HttpError && error.status === 409)
throw new ToolGatewayHttpError(409, error.message, "github_idempotency_conflict");
throw error;
}
if (!receipt)
throw new ToolGatewayHttpError(409, "The saved GitHub publication is not available yet", "github_publication_unavailable");
recoveredGitHubPublication = { content: JSON.stringify(receipt), data: receipt };
}
if (recorded.replayed && !retryingSlackRateLimit && !retryingGitHubPublication) {
if (gitHubPublicationReplay && !gitHubReplayPolicyAllowed)
throw new ToolGatewayHttpError(policyErrorStatus(accessDecision), accessDecision.explanation, accessDecision.reasonCode);
@@ -10783,7 +10801,7 @@ export function createToolGatewayService(
input.timeoutMs === undefined,
)
: null;
const result = connectedMcpExecution
const result = recoveredGitHubPublication ?? (connectedMcpExecution
? connectedMcpExecution.result
: tool.providerType === "paperclip_plugin"
? await runWithTimeout(
@@ -10802,7 +10820,7 @@ export function createToolGatewayService(
: await runWithTimeout(
executeBuiltinTool(session, tool, effectiveParameters, invocationId),
executionTimeoutMs,
);
));
const resultValidation = validateToolContent({
value: result,