From b891e69bd23d6dab8ce536569e15bc4a7a31c35c Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 4 Nov 2026 07:19:12 -0600 Subject: [PATCH] fix(native): correct unread wake comments before completion (#18937) ## Thinking Path > - Paperclip manages AI agents and their work. > - Native agents report completion through a server-owned tool boundary. > - A truncated external-chat request requires a complete run-bound comment read. > - The terminal guard checks that receipt after the provider ends its turn. > - Early completion feedback omitted the same check and could accept an unread report. > - The provider then lost its chance to correct the report in the same turn. > - This pull request returns the existing read requirement through the completion tool before accepting the report. > - The final guard still protects the exact source and snapshot after provider settlement. ## Linked Issues or Issue Description Related: #16995 keeps run-scoped readers available to retained provider threads. #18913 preserves queued comment input. This change fixes a separate completion-feedback boundary. **What happened?** A native external-chat run could submit an accepted completion without reading all server-bound comments. The terminal guard then failed the run with `native_current_wake_comments_unread`. Early feedback also accepted partial, changed and foreign read receipts. The agent did not receive the required correction while it could still act in the same turn. **Expected behavior** The completion tool must reject the report with a `read_current_wake_comments` instruction and the cursor requirements. A complete current read must permit the corrected report in the same turn. The final snapshot check must remain effective. **Steps to reproduce** 1. Create a running native task with a truncated external-chat wake and exact accepted comment IDs. 2. Call completion feedback before reading the comments, or after only a partial or stale read. 3. Observe that the old code returns an accepted report instead of the read correction. ## What Changed - Check the existing run, company, task and agent binding before every completion-feedback acceptance return. - Reuse the authoritative current-wake reader and terminal receipt validator. - Share the two unchanged read-error codes from their owner across the validator, feedback and executor classification. - Convert only the two owned unread or changed-snapshot errors into a bounded tool correction. Other errors still propagate. - Keep the final validator, receipt parser, result persistence and admission policy unchanged. - Add real PostgreSQL regressions and an actual OpenCode MCP tool sequence with a controlled local provider process. - Document the early correction and final snapshot fence. ## Verification The source checkout uses Node 24.22.2 on macOS arm64 with a fresh owned embedded PostgreSQL database. Final published tip: `37bfb269432f86f98cab1a58c09b5519ed9477fd`. Its production code is commit `8c7297318c3a841fc1c72c846acfaabf58adabe9`; the last commit adapts three existing module mocks. The expanded affected group passes **1,284 cases** with **35 existing opt-in skips**. The skips require captured private-runner transition fixtures and a configured fresh database, which were not supplied. All nine real PostgreSQL/MCP cases execute and pass. Recursive typecheck and the full build pass for production commit `8c7297318c`; the final changed test layer also passes. - Untouched master `1a39dea9aa2bb9bea416439b0a574c788e88b9a0`: four causal failures. Unread, partial, changed and foreign complete receipts incorrectly receive accepted completion feedback. The ordinary inline-complete control passes. - Current source: all nine new cases pass. The actual authorized reader returns the full accepted comment. The same running task and run remain intact, with no replacement or accepted-result replay. - The OpenCode native backend sends a real MCP finish rejection to the controlled provider process. The process follows the returned reader instruction, reads every page, and resubmits finish. One provider turn produces one semantic result proposal. The terminal snapshot fence still rejects a later comment edit. - All 98 affected OpenCode driver and MCP bridge cases pass with the fixture-mode addition. - The first tool-sequence attempt failed on a fixture-only relative path before starting the provider. The test now uses its module URL. This setup failure is separate from the four causal failures. - The first recursive typecheck found missing local workspace links in the clone's excluded sandbox-provider directories. Existing dependency links were copied into the isolated clone. No lockfile or source change was made for this setup repair. - At `c57e532a87`, all 1255 cases in the six-file affected server family, recursive Node 24 typecheck and the full build pass. At production commit `1ae9f54107e1762bc8222572b365a013d4572d1a`, all 1,257 affected cases, recursive Node 24 typecheck and the full build pass. - Greptile found that the initial correction could restart a partial read after page two. On `c57e532a87`, following that correction fails with the actual reader cursor-order error. The follow-up continues the caller's most recent cursor and retains the changed-snapshot restart path. All nine new cases pass, including this regression, a stale-partial control, the foreign receipt refusal and the actual same-turn MCP sequence. - [Linux server3/12 job](https://github.com/paperclipai/paperclip/actions/runs/41788671370/job/126437378334) found five failures in the older format-feedback SQL fixture. Its flat heartbeat row did not match the real joined projection. The same five failures reproduced locally. The test-only follow-up returns a coherent run/agent/task projection and keeps the real resolver and formatter assertions. All 14 format and real-PG/MCP cases pass, and the generated server test layer typechecks. Production code and the earlier 1,257-case/typecheck/build proof are unchanged. - Qualification of the shared-code change found 49 failures because whole-module mocks omitted the new exports. The three mocks now pass through actual module exports and keep their controlled reader methods. Literal error assertions remain independent. The final 1,284-case group passes with the exact 35 opt-in skips described above. No production fallback or substitute code was added. - [One Linux Runner package attempt](https://github.com/paperclipai/paperclip/actions/runs/41792315273/job/126448500037) passed 4,913 cases and skipped 10, but its synthetic post-rename credential durability case failed with ENOENT. The complete Runner package tree and lockfile are identical to the [earlier Linux control](https://github.com/paperclipai/paperclip/actions/runs/41790199252/job/126441739681), where all 42 credential cases passed on Node 24.22.2. The exact case also passes once on both base and head on macOS with Node 24.22.2. The original failure cause remains unverified; OS/load and shared-test timing uncertainty remain. Credential code and assertions are unchanged. The single affected-job rerun passes, including all 42 credential tests. The original failure remains unexplained. - An ordinary full local test attempt was stopped before the review repair. It has no completion summary and is INCOMPLETE. Current-head full CI completed; no local full-suite pass is claimed. Greptile reviewed the final published tip at 5/5 with zero open threads. All current-tip checks pass: 54 successful check runs, one successful legacy commit status, 3 skipped and 2 neutral, with no pending or failing checks. Paperclip also reviewed final tip `37bfb26943` at 5/5 with no remaining findings. Its prior 4/5 code-ownership recommendation is fixed and resolved. No review nudge or hosted task retry was issued. The controlled OpenCode process uses the supported v1 factory envelope to exercise the common native feedback and MCP boundary. It does not call a live model or qualify a provider pack. PostgreSQL, authorization, pagination and receipt checks are real. No hosted task, model, permission or saved result was changed. Recovery of an already accepted historical run remains unverified. ## Risks - Each finish call adds an exact binding query. A mandatory read also validates the current accepted-comment snapshot through the existing transaction. Cost grows with those exact comment IDs, bodies and attachment metadata; it does not scan unrelated task history or add idle polling. - A valid read can become stale before the provider settles. The unchanged terminal guard still rejects that race. - Missing ownership or unexpected validation errors remain refusals. This change does not grant replay authority or turn an accepted historical result into fresh work. ## Model Used OpenAI Codex, GPT-6 family, with repository inspection, code execution and GitHub tools. The runtime does not expose the exact serving model ID or context window. The coordinator performed independent read-only source review. ## 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 --- doc/execution-semantics.md | 9 + .../test/fixtures/fake-opencode-server.mjs | 14 +- .../native-runtime/current-wake-comments.ts | 8 +- .../native-completion-feedback.ts | 19 ++ .../native-completion-format-feedback.test.ts | 10 +- ...e-current-wake-completion-feedback.test.ts | 234 ++++++++++++++++++ .../native-recovered-owner-followup.test.ts | 3 +- .../native-session-executor.test.ts | 3 +- .../native-runtime/native-session-executor.ts | 18 +- ...arm-transition-restart.integration.test.ts | 3 +- 10 files changed, 303 insertions(+), 18 deletions(-) create mode 100644 server/src/services/native-runtime/native-current-wake-completion-feedback.test.ts diff --git a/doc/execution-semantics.md b/doc/execution-semantics.md index eb80643cf22..ad32c0c2e37 100644 --- a/doc/execution-semantics.md +++ b/doc/execution-semantics.md @@ -263,6 +263,15 @@ Messages from the same conversation still coalesce into one run. Messages from different conversations queue separately and run one after the other. Wakes without a conversation keep their normal coalescing. +For a truncated external-chat wake, native completion feedback requires the +run-bound complete comment-read receipt before accepting a finish report. An +unread, partial, stale or foreign receipt returns a corrective +`read_current_wake_comments` instruction through the completion tool. The agent +can read the accepted comments and correct its report in the same turn without +repeating completed actions. An unfinished read continues with its most recent +`nextCursor`; a changed snapshot restarts without a cursor. The terminal guard +still checks the exact current binding and snapshot after provider settlement. + ### `done` The work is complete and terminal. diff --git a/packages/paperclip-runner/test/fixtures/fake-opencode-server.mjs b/packages/paperclip-runner/test/fixtures/fake-opencode-server.mjs index d86ce0f955e..74f483c4790 100644 --- a/packages/paperclip-runner/test/fixtures/fake-opencode-server.mjs +++ b/packages/paperclip-runner/test/fixtures/fake-opencode-server.mjs @@ -282,6 +282,18 @@ async function callTerminalTool(promptBody, signal) { const first = await call(); if (String(prompt.message ?? prompt.task?.prompt ?? "").includes("completion-feedback")) { const outcomes = [first]; + if (first.result?.isError && String(prompt.message ?? prompt.task?.prompt ?? "").includes("current-wake-completion-feedback")) { + let cursor; + for (let pageIndex = 0; pageIndex < 8; pageIndex++) { + const response = await mcpRequest("tools/call", { + name: "paperclip_read_current_wake_comments", arguments: { cursor }, + }); + if (response.result?.isError) throw new Error("Current-wake fixture reader was rejected"); + const page = JSON.parse(response.result.content[0].text); + if (page.complete) break; + cursor = page.nextCursor; + } + } if (first.result?.isError) outcomes.push(await call()); await writeFile(join(process.env.XDG_DATA_HOME, "fake-completion-feedback.json"), JSON.stringify(outcomes)); return outcomes.at(-1); @@ -507,7 +519,7 @@ const server = createServer(async (request, response) => { emit({ type: "session.idle", properties: { sessionID: session.id } }); return; } - await callFirstPaperclipTool(); + if (!String(parsedPrompt.message ?? parsedPrompt.task?.prompt ?? "").includes("current-wake-completion-feedback")) await callFirstPaperclipTool(); if (String(parsedPrompt.message ?? "").includes("managed-github-operation")) { const result = await new Promise(resolve => { execFile("gh", ["api", "user"], { timeout: 5000 }, (error, stdout, stderr) => { diff --git a/server/src/services/native-runtime/current-wake-comments.ts b/server/src/services/native-runtime/current-wake-comments.ts index 8fcb1e9d155..2f82eafd516 100644 --- a/server/src/services/native-runtime/current-wake-comments.ts +++ b/server/src/services/native-runtime/current-wake-comments.ts @@ -21,6 +21,8 @@ export const CURRENT_WAKE_COMMENTS_BINDING_SCHEMA = "paperclip.current-wake-comments-binding.v1"; export const CURRENT_WAKE_COMMENTS_RECEIPT_SCHEMA = "paperclip.current-wake-comments-receipt.v1"; +export const NATIVE_CURRENT_WAKE_COMMENTS_UNREAD = "native_current_wake_comments_unread"; +export const NATIVE_CURRENT_WAKE_COMMENTS_CHANGED_AFTER_READ = "native_current_wake_comments_changed_after_read"; const CURRENT_WAKE_COMMENTS_RECEIPT_KEY = "currentWakeCommentsReceipt"; const MAX_PAGE_BODY_CHARS = 12_000; @@ -816,17 +818,17 @@ export async function assertCurrentWakeCommentsRead( record(locked.resultJson)[CURRENT_WAKE_COMMENTS_RECEIPT_KEY], ); if (!receipt || receipt.complete !== true || receipt.nextCursor !== null) { - throw new Error("native_current_wake_comments_unread"); + throw new Error(NATIVE_CURRENT_WAKE_COMMENTS_UNREAD); } if (receipt.bindingDigest !== binding.bindingDigest) { - throw new Error("native_current_wake_comments_changed_after_read"); + throw new Error(NATIVE_CURRENT_WAKE_COMMENTS_CHANGED_AFTER_READ); } const snapshot = await currentWakeCommentsSnapshot( tx as unknown as Db, binding, ); if (receipt.snapshotDigest !== snapshot.snapshotDigest) { - throw new Error("native_current_wake_comments_changed_after_read"); + throw new Error(NATIVE_CURRENT_WAKE_COMMENTS_CHANGED_AFTER_READ); } }); } diff --git a/server/src/services/native-runtime/native-completion-feedback.ts b/server/src/services/native-runtime/native-completion-feedback.ts index bd269a9df21..941edb16daf 100644 --- a/server/src/services/native-runtime/native-completion-feedback.ts +++ b/server/src/services/native-runtime/native-completion-feedback.ts @@ -13,6 +13,7 @@ import { isConversation } from "../agent-conversations.js"; import { issueTreeControlService } from "../issue-tree-control.js"; import { isPublishedGitHubReviewCompletionContinuation, readPublishedGitHubReviewCompletionContinuation, resolveExternalChatResponseWaitAuthorization } from "./chat-attachment-reuse.js"; import { getNativeReviewAssignment, readNativeReviewAssignmentContext } from "./native-review-participant.js"; +import { assertCurrentWakeCommentsRead, NATIVE_CURRENT_WAKE_COMMENTS_CHANGED_AFTER_READ, NATIVE_CURRENT_WAKE_COMMENTS_UNREAD, resolveCurrentWakeCommentsBinding } from "./current-wake-comments.js"; import { and, eq, inArray, notInArray } from "drizzle-orm"; import { approvals, @@ -66,6 +67,24 @@ export async function nativeCompletionFeedback( ) .then((rows) => rows[0]); if (!issue) throw new Error("Completion task no longer exists."); + const wakeScope = { companyId: run.companyId, issueId: issue.id, runId, agentId: run.agentId }; + const currentWake = await resolveCurrentWakeCommentsBinding(db, wakeScope); + if (currentWake) { + try { + await assertCurrentWakeCommentsRead(db, wakeScope, currentWake); + } catch (error) { + if (error instanceof Error && (error.message === NATIVE_CURRENT_WAKE_COMMENTS_UNREAD || error.message === NATIVE_CURRENT_WAKE_COMMENTS_CHANGED_AFTER_READ)) { + const reason = error.message === NATIVE_CURRENT_WAKE_COMMENTS_UNREAD + ? "The accepted comments have not been fully read." + : "The accepted comments changed after the complete read."; + const cursorInstruction = error.message === NATIVE_CURRENT_WAKE_COMMENTS_UNREAD + ? "If you have an unfinished read, continue with its most recent nextCursor; otherwise start without a cursor. If the reader reports that the snapshot changed, restart without a cursor." + : "Start without a cursor."; + throw new Error(`${reason} Call read_current_wake_comments. ${cursorInstruction} Follow each returned nextCursor until complete=true. Reassess the complete current request and resubmit the completion report in this turn. Preserve completed work and do not repeat completed actions.`); + } + throw error; + } + } if (result.reportedWorkDisposition === "yielded" && isPublishedGitHubReviewCompletionContinuation(result.continuation)) { const current = await readPublishedGitHubReviewCompletionContinuation({ db, binding: { companyId: run.companyId, issueId: issue.id, runId, agentId: run.agentId } }); diff --git a/server/src/services/native-runtime/native-completion-format-feedback.test.ts b/server/src/services/native-runtime/native-completion-format-feedback.test.ts index 4094bcf4b4b..4a43b230858 100644 --- a/server/src/services/native-runtime/native-completion-format-feedback.test.ts +++ b/server/src/services/native-runtime/native-completion-format-feedback.test.ts @@ -17,9 +17,13 @@ vi.mock("../../vendor/paperclip-runner/index.js", () => ({ normalizePrpResultSig const result = { reportedWorkDisposition: "done", summary: "EXACT_PRIVATE_MARKER", completionClaim: { objectiveSatisfied: true, criteria: [], remainingWork: [] } } as unknown as PrpStructuredRunResult; function database(options: { interaction?: boolean; approval?: boolean; review?: boolean } = {}): Db { // Pure query double: no client, PostgreSQL process, or network is created. - return { select: () => ({ from: (table: unknown) => { - const rows = table === heartbeatRuns ? [{ id: "run", companyId: "company", nativeIssueId: "issue", contextSnapshot: {} }] - : table === issues ? [{ id: "issue", companyId: "company", status: "in_progress", ...(options.review ? { executionState: { status: "pending" } } : {}) }] + const run = { id: "run", companyId: "company", agentId: "agent", nativeIssueId: "issue", runtimeMode: "native", status: "running", contextSnapshot: {} }; + const issue = { id: "issue", companyId: "company", status: "in_progress", assigneeAgentId: "agent", executionRunId: "run", ...(options.review ? { executionState: { status: "pending" } } : {}) }; + return { select: (fields?: { run?: unknown }) => ({ from: (table: unknown) => { + const rows = table === heartbeatRuns ? fields?.run + ? [{ run, issueAssigneeAgentId: issue.assigneeAgentId, issueExecutionRunId: issue.executionRunId }] + : [run] + : table === issues ? [issue] : table === issueThreadInteractions && options.interaction ? [{ id: "request", kind: "request_confirmation", title: "Decision" }] : table === issueApprovals && options.approval ? [{ id: "approval" }] : []; const query = { where: () => query, limit: () => query, orderBy: () => query, innerJoin: () => query, then: (resolve: (value: unknown[]) => unknown) => Promise.resolve(rows).then(resolve) }; diff --git a/server/src/services/native-runtime/native-current-wake-completion-feedback.test.ts b/server/src/services/native-runtime/native-current-wake-completion-feedback.test.ts new file mode 100644 index 00000000000..616372ac49a --- /dev/null +++ b/server/src/services/native-runtime/native-current-wake-completion-feedback.test.ts @@ -0,0 +1,234 @@ +import { randomUUID } from "node:crypto"; +import { chmod, copyFile, mkdtemp, readFile, readdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { and, eq } from "drizzle-orm"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { agents, companies, createDb, heartbeatRuns, issueComments, issues, nativeRunResults } from "@paperclipai/db"; +import { createNativeSessionBackend, type NativeExecutionInput, type PrpEvent, type PrpStructuredRunResult } from "../../vendor/paperclip-runner/index.js"; +import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js"; +import { assertCurrentWakeCommentsRead, READ_CURRENT_WAKE_COMMENTS_TOOL_DEFINITION, resolveCurrentWakeCommentsBinding, type CurrentWakeCommentsPage } from "./current-wake-comments.js"; +import { nativeCompletionFeedback } from "./native-completion-feedback.js"; +import { PaperclipRunnerToolAuthority } from "./paperclip-runner-tool-authority.js"; + +const support = await getEmbeddedPostgresTestSupport(); +const report: PrpStructuredRunResult = { + schema: "paperclip.run_result.v1", reportedWorkDisposition: "done", summary: "Answered the accepted message.", + completionClaim: { contractRevision: "test", objectiveSatisfied: true, criteria: [], remainingWork: [] }, + evidence: [], verification: [], attentionRequests: [], artifacts: [], +}; + +describe.skipIf(!support.supported)("current-wake completion feedback", () => { + let temporary: Awaited>; + let db: ReturnType; + beforeAll(async () => { + temporary = await startEmbeddedPostgresTestDatabase("paperclip-wake-feedback-"); + db = createDb(temporary.connectionString); + }, 120_000); + afterAll(async () => { await db?.$client.end({ timeout: 0 }); await temporary?.cleanup(); }); + + async function seed(bodies = ["Read this complete accepted message."], fallbackFetchNeeded = true) { + const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(), runId = randomUUID(); + await db.insert(companies).values({ id: companyId, name: "Wake feedback", issuePrefix: `WF${companyId.slice(0, 6)}` }); + await db.insert(agents).values({ id: agentId, companyId, name: "Reader", adapterType: "paperclip_runner", status: "running" }); + await db.insert(issues).values({ id: issueId, companyId, title: "Answer the accepted message", status: "in_progress", assigneeAgentId: agentId }); + const comments = await db.insert(issueComments).values(bodies.map(body => ({ companyId, issueId, body, authorType: "user" as const, authorUserId: "local-board" }))).returning(); + const commentIds = comments.map(comment => comment.id); + await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, nativeIssueId: issueId, + runtimeMode: "native", status: "running", resultJson: { existingEvidence: "keep" }, contextSnapshot: { + issueId, paperclipExternalChatExecutionBound: true, paperclipWake: { + externalChatProvider: "github", checkedOutByHarness: false, externalChatExecutionBound: true, + fallbackFetchNeeded, truncated: fallbackFetchNeeded, commentIds, latestCommentId: commentIds.at(-1), + commentWindow: { requestedCount: commentIds.length, includedCount: commentIds.length, missingCount: 0 }, + }, + } }); + await db.update(issues).set({ executionRunId: runId, executionLockedAt: new Date() }).where(eq(issues.id, issueId)); + const binding = { companyId, agentId, issueId, runId }; + const wake = await resolveCurrentWakeCommentsBinding(db, binding); + const authority = new PaperclipRunnerToolAuthority(db, { ...binding, currentWakeComments: wake ?? undefined }); + let reads = 0; + const read = async (cursor?: string | null) => authority.execute({ tool: "read_current_wake_comments", callId: `read-${++reads}`, arguments: { cursor } }) as Promise; + return { ...binding, comments, wake, read, authority }; + } + + async function finish(value: Awaited>) { + return nativeCompletionFeedback(db, value.runId, report); + } + async function receipt(value: Awaited>) { + return db.select({ result: heartbeatRuns.resultJson }).from(heartbeatRuns).where(eq(heartbeatRuns.id, value.runId)).then(rows => rows[0]!.result); + } + async function assertSameRunningTurn(value: Awaited>) { + const runs = await db.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.companyId, value.companyId), eq(heartbeatRuns.agentId, value.agentId))); + expect(runs).toHaveLength(1); + expect(runs[0]).toMatchObject({ id: value.runId, status: "running", resultJson: { existingEvidence: "keep" } }); + expect(await db.select().from(nativeRunResults).where(eq(nativeRunResults.companyId, value.companyId))).toHaveLength(0); + } + + it("corrects an unread finish before settlement, then accepts the actual reader in the same turn", async () => { + const value = await seed(); + await expect(finish(value)).rejects.toThrow(/read_current_wake_comments/); + await assertSameRunningTurn(value); + const page = await value.read(); + expect(page).toMatchObject({ complete: true, nextCursor: null }); + expect(page.comments[0]?.bodyChunk).toBe(value.comments[0]!.body); + await expect(finish(value)).resolves.toContain("Completion report accepted"); + await expect(assertCurrentWakeCommentsRead(db, value, value.wake)).resolves.toBeUndefined(); + await assertSameRunningTurn(value); + }); + + it("requires every page before accepting finish", async () => { + const value = await seed(["A".repeat(16_001)]); + let page = await value.read(); + expect(page.complete).toBe(false); + await expect(finish(value)).rejects.toThrow(/read_current_wake_comments/); + const chunks = page.comments.map(comment => comment.bodyChunk); + while (!page.complete) { + page = await value.read(page.nextCursor); + chunks.push(...page.comments.map(comment => comment.bodyChunk)); + } + expect(chunks.join("")).toBe(value.comments[0]!.body); + await expect(finish(value)).resolves.toContain("Completion report accepted"); + await assertSameRunningTurn(value); + }); + + it("follows partial-read feedback after two of three pages without restarting the reader", async () => { + const value = await seed(["B".repeat(32_001)]); + const first = await value.read(); + const second = await value.read(first.nextCursor); + expect(first.complete).toBe(false); + expect(second.complete).toBe(false); + const correction = await finish(value).then( + () => { throw new Error("An incomplete read received an accepted report"); }, + error => (error as Error).message, + ); + const instructedCursor = correction.includes("most recent nextCursor") ? second.nextCursor : undefined; + const last = await value.read(instructedCursor); + expect(last).toMatchObject({ complete: true, nextCursor: null }); + expect([...first.comments, ...second.comments, ...last.comments].map(comment => comment.bodyChunk).join("")).toBe(value.comments[0]!.body); + await expect(finish(value)).resolves.toContain("Completion report accepted"); + await assertSameRunningTurn(value); + }); + + it("corrects a changed snapshot and accepts only a new complete read", async () => { + const value = await seed(); + await value.read(); + await db.update(issueComments).set({ body: "Revised accepted message", updatedAt: new Date() }).where(eq(issueComments.id, value.comments[0]!.id)); + await expect(finish(value)).rejects.toThrow(/read_current_wake_comments/); + const page = await value.read(); + expect(page.comments[0]?.bodyChunk).toBe("Revised accepted message"); + await expect(finish(value)).resolves.toContain("Completion report accepted"); + await assertSameRunningTurn(value); + }); + + it("retains the snapshot-change restart path during a partial read", async () => { + const value = await seed(["C".repeat(32_001)]); + const first = await value.read(); + const second = await value.read(first.nextCursor); + await db.update(issueComments).set({ body: "A revised request", updatedAt: new Date() }).where(eq(issueComments.id, value.comments[0]!.id)); + const correction = await finish(value).then( + () => { throw new Error("A stale incomplete read received an accepted report"); }, + error => (error as Error).message, + ); + expect(correction).toContain("If the reader reports that the snapshot changed, restart without a cursor"); + await expect(value.read(second.nextCursor)).rejects.toThrow("paperclip_current_wake_comments_snapshot_changed"); + const restarted = await value.read(); + expect(restarted).toMatchObject({ complete: true, nextCursor: null }); + expect(restarted.comments[0]?.bodyChunk).toBe("A revised request"); + await expect(finish(value)).resolves.toContain("Completion report accepted"); + await assertSameRunningTurn(value); + }); + + it("does not accept another company's complete read receipt", async () => { + const foreign = await seed(), value = await seed(); + await foreign.read(); + const foreignReceipt = (await receipt(foreign))!.currentWakeCommentsReceipt; + await db.update(heartbeatRuns).set({ resultJson: { existingEvidence: "keep", currentWakeCommentsReceipt: foreignReceipt } }).where(eq(heartbeatRuns.id, value.runId)); + await expect(finish(value)).rejects.toThrow(/read_current_wake_comments/); + await expect(finish(foreign)).resolves.toContain("Completion report accepted"); + await value.read(); + await expect(finish(value)).resolves.toContain("Completion report accepted"); + await assertSameRunningTurn(value); + }); + + it("keeps inline-complete ordinary completion unchanged", async () => { + const value = await seed(["The full inline message"], false); + expect(value.wake).toBeNull(); + await expect(finish(value)).resolves.toContain("Completion report accepted"); + await assertSameRunningTurn(value); + }); + + it("keeps the terminal snapshot fence after early feedback accepts the report", async () => { + const value = await seed(); + await value.read(); + await expect(finish(value)).resolves.toContain("Completion report accepted"); + await db.update(issueComments).set({ body: "Changed after feedback", updatedAt: new Date() }).where(eq(issueComments.id, value.comments[0]!.id)); + await expect(assertCurrentWakeCommentsRead(db, value, value.wake)).rejects.toThrow("native_current_wake_comments_changed_after_read"); + await assertSameRunningTurn(value); + }); + + it("delivers corrective MCP feedback and completes one OpenCode turn after the real paginated reader", async () => { + const value = await seed(["A".repeat(16_001)]); + const root = await mkdtemp(join(tmpdir(), "paperclip-wake-feedback-provider-")); + const command = join(root, "fake-opencode-server.mjs"); + await copyFile(new URL("../../../../packages/paperclip-runner/test/fixtures/fake-opencode-server.mjs", import.meta.url), command); + await chmod(command, 0o755); + const order: string[] = []; + const sessionId = randomUUID(); + const execution: NativeExecutionInput = { + schema: "paperclip.native-execution-input.v1", + binding: { companyId: value.companyId, issueId: value.issueId, runId: value.runId, agentId: value.agentId, executionWorkspaceId: randomUUID() }, + task: { identifier: "WF-1", title: "Read and answer the accepted message", description: null, prompt: "current-wake-completion-feedback", workMode: "standard" }, + workspace: { cwd: root, repoUrl: null, repoRef: null, branchName: null }, + session: { normalizedSessionId: sessionId, driverKind: "opencode_server", protocolVersion: 1, lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null } }, + provider: { kind: "opencode", model: "openrouter/deepseek/deepseek-v4.1-flash-1017", permissionMode: "allow" }, + completionContract: { id: "feedback", sha256: "feedback", schemaVersion: "1", contract: { revision: "test", objective: "Answer the accepted message", criteria: [] } }, + interactionResponses: [], credentialBindings: [], + }; + const backend = createNativeSessionBackend(execution, { + opencodeRuntimeDirectory: join(root, "runtime"), opencodeCommand: command, + opencodeEnvironment: { PATH: process.env.PATH, OPENROUTER_API_KEY: "fixture-key" }, + dynamicTools: [READ_CURRENT_WAKE_COMMENTS_TOOL_DEFINITION], + dynamicToolHandler: async call => { + order.push("read"); + return value.authority.execute({ tool: call.tool, callId: call.callId, arguments: call.arguments }); + }, + completionFeedback: async result => { + try { + const response = await nativeCompletionFeedback(db, value.runId, result); + order.push("finish-accepted"); + return response; + } catch (error) { order.push("finish-refused"); throw error; } + }, + }); + const session = await backend.openSession({ identity: { companyId: value.companyId, runId: value.runId, sessionId, issueId: value.issueId, agentId: value.agentId }, workingDirectory: root }); + try { + const collected = (async () => { + const events: PrpEvent[] = []; + for await (const event of session.events()) { + events.push(event); + if (["turn.completed", "turn.failed", "turn.interrupted", "turn.cancelled"].includes(event.eventType)) break; + } + return events; + })(); + await session.startTurn({ message: { role: "user", text: "current-wake-completion-feedback" } }); + const events = await collected; + expect(order[0]).toBe("finish-refused"); + expect(order.slice(1, -1).length).toBeGreaterThan(1); + expect(order.slice(1, -1).every(step => step === "read")).toBe(true); + expect(order.at(-1)).toBe("finish-accepted"); + expect(order.filter(step => step.startsWith("finish"))).toHaveLength(2); + expect(events.filter(event => event.eventType === "turn.started")).toHaveLength(1); + expect(events.filter(event => event.eventType === "run.result.proposed")).toHaveLength(1); + expect(events.at(-1)?.eventType).toBe("turn.completed"); + expect((await session.snapshot()).semanticResult).not.toBeNull(); + const runtime = join(root, "runtime"); + const directories = (await readdir(runtime, { withFileTypes: true })).filter(entry => entry.isDirectory()); + const outcomes = JSON.parse(await readFile(join(runtime, directories[0]!.name, "data/fake-completion-feedback.json"), "utf8")); + expect(outcomes).toHaveLength(2); + expect(outcomes[0].result).toMatchObject({ isError: true, content: [{ text: expect.stringContaining("read_current_wake_comments") }] }); + expect(outcomes[1].result.isError).not.toBe(true); + await expect(assertCurrentWakeCommentsRead(db, value, value.wake)).resolves.toBeUndefined(); + await assertSameRunningTurn(value); + } finally { await session.close({ reason: "feedback qualification complete" }); await rm(root, { recursive: true, force: true }); } + }, 60_000); +}); diff --git a/server/src/services/native-runtime/native-recovered-owner-followup.test.ts b/server/src/services/native-runtime/native-recovered-owner-followup.test.ts index 2f91c43a862..071fdf49e8d 100644 --- a/server/src/services/native-runtime/native-recovered-owner-followup.test.ts +++ b/server/src/services/native-runtime/native-recovered-owner-followup.test.ts @@ -30,7 +30,8 @@ vi.mock("./paperclip-runner-tool-authority.js", () => ({ close() {} }, })); -vi.mock("./current-wake-comments.js", () => ({ +vi.mock("./current-wake-comments.js", async importOriginal => ({ + ...await importOriginal(), resolveCurrentWakeCommentsBinding: async () => null, assertCurrentWakeCommentsRead: async () => undefined, })); diff --git a/server/src/services/native-runtime/native-session-executor.test.ts b/server/src/services/native-runtime/native-session-executor.test.ts index 316dab9ac5a..f3a7ea42bbf 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -346,7 +346,8 @@ vi.mock("./native-runner-file-handoff.js", () => ({ state.renderNativeRunnerStagedAttachmentPrompt, })); -vi.mock("./current-wake-comments.js", () => ({ +vi.mock("./current-wake-comments.js", async importOriginal => ({ + ...await importOriginal(), resolveCurrentWakeCommentsBinding: state.resolveCurrentWakeCommentsBinding, assertCurrentWakeCommentsRead: state.assertCurrentWakeCommentsRead, })); diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index 3bd3b3718ea..b480ea84321 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -215,6 +215,8 @@ import { getNativeReviewAssignment, readNativeReviewAssignmentContext } from "./ import { NativeChatAttachmentReadScope } from "./chat-attachment-read.js"; import { assertCurrentWakeCommentsRead, + NATIVE_CURRENT_WAKE_COMMENTS_CHANGED_AFTER_READ, + NATIVE_CURRENT_WAKE_COMMENTS_UNREAD, resolveCurrentWakeCommentsBinding, } from "./current-wake-comments.js"; import { @@ -7022,8 +7024,8 @@ export function nativeSessionFailureDisposition( sourceFailureCode === "runner_remote_provider_verification_unavailable" || sourceFailureCode === "native_provider_terminal_failed" || sourceFailureCode === NATIVE_PROVIDER_STATE_INCOMPATIBLE || - sourceFailureCode === "native_current_wake_comments_unread" || - sourceFailureCode === "native_current_wake_comments_changed_after_read" || + sourceFailureCode === NATIVE_CURRENT_WAKE_COMMENTS_UNREAD || + sourceFailureCode === NATIVE_CURRENT_WAKE_COMMENTS_CHANGED_AFTER_READ || sourceFailureCode === "native_session_cleanup_quarantined" || sourceFailureCode === "native_adopted_runner_authentication_timeout" || sourceFailureCode === "native_provider_usage_limit"; @@ -7094,8 +7096,8 @@ export function nativeSessionFailureSourceCode( | "native_event_replay_conflict" | "native_provider_runtime_missing" | "native_provider_model_rejected" - | "native_current_wake_comments_unread" - | "native_current_wake_comments_changed_after_read" + | typeof NATIVE_CURRENT_WAKE_COMMENTS_UNREAD + | typeof NATIVE_CURRENT_WAKE_COMMENTS_CHANGED_AFTER_READ | "provider_session_open_rejected" | "native_session_interrupted" { if (error instanceof NativePermissionDeclinedError) return "native_permission_declined"; @@ -7175,11 +7177,11 @@ export function nativeSessionFailureSourceCode( if (/native_event_replay_conflict/i.test(message)) { return "native_event_replay_conflict"; } - if (/native_current_wake_comments_changed_after_read/i.test(message)) { - return "native_current_wake_comments_changed_after_read"; + if (message.toLowerCase().includes(NATIVE_CURRENT_WAKE_COMMENTS_CHANGED_AFTER_READ.toLowerCase())) { + return NATIVE_CURRENT_WAKE_COMMENTS_CHANGED_AFTER_READ; } - if (/native_current_wake_comments_unread/i.test(message)) { - return "native_current_wake_comments_unread"; + if (message.toLowerCase().includes(NATIVE_CURRENT_WAKE_COMMENTS_UNREAD.toLowerCase())) { + return NATIVE_CURRENT_WAKE_COMMENTS_UNREAD; } // The runner reported a definite session.open outcome. That command runs // before any prompt, so provider work cannot have started. An indeterminate diff --git a/server/src/services/native-runtime/native-warm-transition-restart.integration.test.ts b/server/src/services/native-runtime/native-warm-transition-restart.integration.test.ts index c60b376fed5..9753733cac7 100644 --- a/server/src/services/native-runtime/native-warm-transition-restart.integration.test.ts +++ b/server/src/services/native-runtime/native-warm-transition-restart.integration.test.ts @@ -122,7 +122,8 @@ vi.mock("./paperclip-runner-tool-authority.js", () => ({ } }, })); -vi.mock("./current-wake-comments.js", () => ({ +vi.mock("./current-wake-comments.js", async importOriginal => ({ + ...await importOriginal(), resolveCurrentWakeCommentsBinding: async () => null, assertCurrentWakeCommentsRead: async () => undefined, }));