From bf142db65aa90eb4c23bf9084e95b33515234637 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Sun, 25 Oct 2026 00:02:32 -0700 Subject: [PATCH] perf(server): narrow external review wait context reads (#17562) Project current chat authorization fields for external review-wait discovery instead of saved execution input. Preserve typed source bindings, nested question receipts, candidate eligibility, live revocation checks, and stored context. Validation: 181 native chat integration tests, full build and typecheck, exact-head Greptile 5/5 (medium), full green CI, and no unresolved review threads. The full-context counterfactual fails both direct-chat size regressions. Co-Authored-By: Paperclip --- doc/native-workspace-finalization-recovery.md | 3 ++ .../external-chat-wait.integration.test.ts | 46 +++++++++++++++++++ .../native-runtime/external-review-wait.ts | 21 ++++++++- 3 files changed, 68 insertions(+), 2 deletions(-) diff --git a/doc/native-workspace-finalization-recovery.md b/doc/native-workspace-finalization-recovery.md index c8353239234..5b1ffb5ce98 100644 --- a/doc/native-workspace-finalization-recovery.md +++ b/doc/native-workspace-finalization-recovery.md @@ -492,6 +492,9 @@ binding, and chat source fields under the existing nonblocking share lock. It excludes saved execution input, runner profiles, and log excerpts. Actual retry admission still reads the complete run and its recovery evidence. These reads preserve stored run data, source revocation checks, and JSON value types. +External review-wait discovery selects only chat authorization context fields. +It keeps the current endpoint, membership, resource, and question-receipt checks; +saved prompts remain stored and do not cross this discovery query. Legacy continuation recovery rejects native runs before it loads their saved execution input. The persisted disposition check reads only the task binding diff --git a/server/src/services/native-runtime/external-chat-wait.integration.test.ts b/server/src/services/native-runtime/external-chat-wait.integration.test.ts index b0ee8e0c5f6..70a6933e80a 100644 --- a/server/src/services/native-runtime/external-chat-wait.integration.test.ts +++ b/server/src/services/native-runtime/external-chat-wait.integration.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { and, eq, ne, sql } from "drizzle-orm"; +import { createDatabaseResponseCapture, installDatabaseResponseCapture } from "@paperclipai/db/response-capture"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { agentWakeupRequests, @@ -71,6 +72,7 @@ import { voiceCredentialFingerprint } from "../voice/voice-session-store.js"; import { nativeSha256 } from "./canonical.js"; import { questionResponseDeliveryValues } from "../question-response-delivery.js"; import { resolveExternalChatQuestionResponse } from "./external-chat-question-response.js"; +import { listExternalReviewWaits } from "./external-review-wait.js"; import { materializeExternalChatQuestionResponseInput } from "./external-chat-question-response-input.js"; import * as nativeInteractionBridge from "./native-interaction-bridge.js"; import { defaultGitHubReviewPolicy, type AskUserQuestionsInteraction } from "@paperclipai/shared"; @@ -866,6 +868,50 @@ describe("native external-chat response wait", () => { }; } + it.each(["telegram", "github", "github_board_answer", "slack_board_answer"] as const)( + "reads small current external review waits without saved prompts (%s)", async source => { + const fixture = await (async () => { + if (source === "github_board_answer" || source === "slack_board_answer") { + const answered = await seedBoardAnsweredTurn(true, source === "github_board_answer" ? "github" : "slack"); + await attestAnswer(answered); + return answered; + } + return seedWaitTurn(source); + })(); + const finalized = await finalizeNativeRun({ db, runId: fixture.runId, workspaceFinalizeStatus: "succeeded", projectRunStatus: true }); + expect(finalized.phase).toBe("committed"); + await db.update(statusDecisions).set({ toStatus: "in_review" }).where(eq(statusDecisions.id, finalized.decisionId!)); + await db.update(issues).set({ status: "in_review" }).where(eq(issues.id, fixture.issueId)); + const [decision] = await db.select({ reason: statusDecisions.reasonCode, state: statusDecisions.applicationState }) + .from(statusDecisions).where(eq(statusDecisions.id, finalized.decisionId!)); + const governed = source === "github_board_answer" || source === "slack_board_answer"; + expect(decision).toEqual({ reason: governed ? "governed_response_waiting" : "external_chat_response_waiting", state: "applied" }); + const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, fixture.runId)); + expect(run!.status).toBe("succeeded"); + const contextSnapshot = { ...run!.contextSnapshot, savedPrompt: "external-wait-saved-input🙂".repeat(30_000) }; + await db.update(heartbeatRuns).set({ contextSnapshot }).where(eq(heartbeatRuns.id, fixture.runId)); + await authorizeChatConversationForBoundRun(db, fixture, contextSnapshot, "read"); + const observed = createDb(temporary?.connectionString ?? externalTestDatabaseUrl!); + const events: Array> = [], now = Date.now(); + const capture = createDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event)); + installDatabaseResponseCapture(observed, { issues, heartbeatRuns }, capture); + try { + expect(await listExternalReviewWaits(observed, fixture.companyId, [fixture.issueId])).toEqual(governed ? [] : [ + expect.objectContaining({ companyId: fixture.companyId, issueId: fixture.issueId }), + ]); + capture.flush(); + const reads = events.filter(event => event.table === "issues" && event.operation === "select"); + expect(reads.length).toBeGreaterThan(0); + expect(reads.reduce((bytes, event) => bytes + Number(event.responseValueBytes), 0)).toBeLessThan(4096); + expect(await listExternalReviewWaits(observed, randomUUID(), [fixture.issueId])).toEqual([]); + expect(await listExternalReviewWaits(observed, fixture.companyId, [])).toEqual([]); + await db.update(companyMemberships).set({ status: "suspended" }).where(eq(companyMemberships.companyId, fixture.companyId)); + expect(await listExternalReviewWaits(observed, fixture.companyId, [fixture.issueId])).toEqual([]); + expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, fixture.runId)))[0]?.contextSnapshot).toEqual(contextSnapshot); + } finally { capture.flush(); await observed.$client.end(); } + }, + ); + it("resumes an oversized Slack multi-select answered through its Paperclip handoff", async () => { const fixture = await seedBoardAnsweredTurn(true, "slack", true); const [publication] = await db.select().from(chatPublications).where(eq(chatPublications.id, fixture.publicationId)); diff --git a/server/src/services/native-runtime/external-review-wait.ts b/server/src/services/native-runtime/external-review-wait.ts index fe5347a9cea..4a63ad6510d 100644 --- a/server/src/services/native-runtime/external-review-wait.ts +++ b/server/src/services/native-runtime/external-review-wait.ts @@ -1,6 +1,23 @@ -import { and, eq, inArray } from "drizzle-orm"; +import { and, eq, inArray, sql } from "drizzle-orm"; import { chatConversations, chatEndpoints, heartbeatRuns, issues, statusDecisions, type Db } from "@paperclipai/db"; import { authorizeChatConversationForBoundRun } from "./chat-attachment-reuse.js"; +import { EXTERNAL_CHAT_QUESTION_RESPONSE_KEY } from "./external-chat-question-response.js"; + +const authorizationContextKeys = [ + "source", "issueId", "wakeReason", "interactionKind", "interactionStatus", "externalChatContinuation", + "interactionId", "sourceRunId", "sourceCommentId", "wakeCommentId", "wakeCommentIds", + "paperclipHarnessCheckedOut", "paperclipExternalChatExecutionBound", + EXTERNAL_CHAT_QUESTION_RESPONSE_KEY, +] as const; + +const authorizationContext = sql | null>` + case when ${heartbeatRuns.contextSnapshot} is null then null else ( + select coalesce(jsonb_object_agg(field.key, field.value), '{}'::jsonb) + from jsonb_each(case when jsonb_typeof(${heartbeatRuns.contextSnapshot}) = 'object' + then ${heartbeatRuns.contextSnapshot} else '{}'::jsonb end) as field(key, value) + where field.key in (${sql.join(authorizationContextKeys.map(key => sql`${key}`), sql`, `)}) + ) end +`; export async function listExternalReviewWaits(db: Db, companyId: string, issueIds: string[]) { if (!issueIds.length) return []; @@ -12,7 +29,7 @@ export async function listExternalReviewWaits(db: Db, companyId: string, issueId createdAt: statusDecisions.createdAt, runId: heartbeatRuns.id, agentId: heartbeatRuns.agentId, - contextSnapshot: heartbeatRuns.contextSnapshot, + contextSnapshot: authorizationContext, }).from(issues) .innerJoin(statusDecisions, and( eq(statusDecisions.id, issues.lastStatusDecisionId),