{accessReady ? "Project access is ready" : "Reviewer can’t access this project"}
+
{accessReady
+ ? "Agent Extensions is now in Reviewer’s allowed projects. Retry to continue with your saved message."
+ : "This task uses Agent Extensions, which isn’t in Reviewer’s allowed projects. The agent hasn’t started. Your message is saved."}
+ {!accessReady && !canManage &&
An organization admin can add this project in Reviewer’s Permissions / Trust settings.
}
+
+
+
+ {accessReady ?
+ : canManage ?
+ : }
+
+ {retryError &&
Couldn’t start the retry. Project access is saved. Try again.
}
+ {!accessReady && Run details
+
Stopped during
Workspace setup
Reason
Project outside allowed scope
Provider started
No
+ }
+ }
+ {run === "replied" && }
+
;
+
+ const permissions =
+
+
Reviewer
Permissions / Trust
+
+
+
Low-trust review
Runs in an isolated sandbox. Untrusted output stays quarantined.
+
+
+
+
Allowed projects
+
Projects whose tasks and repositories this agent may access.
DEMO-42 is waiting for access to Agent Extensions.
}
+ {!canManage &&
Only people with agent configuration permission can change this access.
}
+ {added.length > 0 &&
+
Add access to {added.map(project => project.name).join(" and ")}
+
Reviewer will be able to access work and repositories in {added.length === 1 ? "this project" : "these projects"}. Existing project access and low-trust restrictions stay in place.
+
}
+ {saveError &&
Couldn’t save project access. No permissions changed. Your selection is kept.
}
+ {savedNotice &&
+ Project access saved.
+
+
}
+ {canManage &&
+
+
+
}
+
+
;
+
+ return
+ setShellNotice("This preview focuses on project access. Use Manage project access to try the repair.")} />}
+ headerActions={Design preview}>
+ {shellNotice &&
{shellNotice}
}
+ {view === "task" ? task : permissions}
+
+
;
+}
+
+export function ProjectAccessProposal(props: ProjectAccessProposalProps) {
+ return ;
+}
From 5c3dce09b5cb80911a65a03ec09d20bf8fbf78dd Mon Sep 17 00:00:00 2001
From: Dotta <34892728+cryppadotta@users.noreply.github.com>
Date: Sat, 24 Oct 2026 16:44:50 -0700
Subject: [PATCH 03/70] fix(runner): preserve verified remote artifacts across
controller releases (#17512)
## Thinking Path
> - Paperclip manages AI agents and owns their task authority.
> - Remote native agents can keep working while their controller
restarts.
> - The controller must authenticate the same runner and verify its
provider files before recovery.
> - The current path compares a retained provider pack with the new
controller release. A release change can reject intact files.
> - The new controller binary digest can also reject the original daemon
during PRP authentication.
> - This PR records the admitted release authority in private controller
state and uses it for exact process reattachment.
## Linked Issues or Issue Description
**What happened?**
A remote Claude session started and used tools. After controller
shutdown, recovery failed with
`runner_remote_provider_artifact_incompatible`. The transport combined
stderr into stdout, so the error omitted the verifier reason. The
historical rejected manifest is unavailable. This PR reproduces
controller release drift as a cause; it does not claim that the
historical mismatch is proven. Reaching pack verification implies the
earlier exact-process/controller-identity checks passed. That supports a
preserved-state recovery path but is not a direct inspection of the
deployed volume.
**Expected behavior**
A compatible, unchanged remote runner can reattach after a controller
release changes. Full package hashes, exact process scope, protocol
capabilities, and authenticated runner identity must still pass. Fresh
launches use the current release.
**Steps to reproduce**
1. Verify and admit a remote native session with release A.
2. Preserve its runner process and replace the controller with release
B.
3. Reattach the original process. The old path compares its pack with B
and fails at `manifest mismatch`.
4. With this fix, the controller reads its saved A authority and
verifies all A hashes. Changed files still fail.
**Paperclip version or commit**
Reproduced from master `470cb34c0e42cb9d0b232d0bcf5d45cc7b0a81ca`;
rebased onto `a3675ddcdf`.
**Deployment mode**
Native remote runner on a Daytona-backed Paperclip Computer. Local
acceptance uses isolated Linux containers and actual packaged artifacts.
Related work: #17361 and #17448 handle runner upgrade and qualification
permissions. #17097 and #17439 add durable controller checkpoints and
server adoption. That stack can skip remote artifact preparation with a
saved checkpoint. This PR fixes the current file-backed path and retains
full provider-pack verification; it has no checkpoint schema or
empty-disk controller recovery.
## What Changed
- Save the complete verified pack manifest and authenticated runner
version/digest after successful session admission.
- Bind the receipt to company, agent, workspace, session, runner,
environment lease, remote directory, and provider descriptor. Keep it
outside guest backup directories.
- Reuse the saved authority for exact reattachment. Keep current release
selection for fresh launches and explicit session replacement.
- Fail closed for mismatched or corrupt receipts. Historical sessions
without a receipt retain the current release integrity gate.
- Report bounded, redacted verifier output from both transport streams.
Warn on receipt write failure while returning the already admitted
session to its owner.
- Add scope, persistence, replacement, storage failure, and backend
wiring regressions. Update recovery documentation and the feature map.
## Verification
- Test-first red: the original selection chose release B instead of A
after controller recreation. The new receipt regression passes. A
separate red test showed the missing stdout diagnostic; it passes after
the diagnostic fix.
- Authority and diagnostic suites: 49 tests pass. Backend pack, process
adoption, and harness persistence selection: 47 tests pass.
- Actual Linux provider image: complete A manifest/artifact verification
passed; controller source-revision drift failed on the old path; saved A
authority passed; a modified ACPX sidecar failed its SHA-256 check.
- Actual native Linux runner: authenticated a session, detached the
controller, changed the controller manifest and binary, then
authenticated the original PID and provider session with saved
authority. There were zero duplicate spawns, zero new provider turns, no
stop/suspend, and the workspace file remained. This probe used the real
runner with a deterministic Codex fixture; it is separate from the real
ACPX package integrity proof.
- Full workspace typecheck and build passed. The full native executor
suite first hit host PostgreSQL semaphore exhaustion. The complete
changed files then passed 864/864 tests with separate real PostgreSQL
databases in a disposable Linux container. The temporary local fixture
changed only database provisioning and used normal repository
migrations. Current-head CI also passed the full required test, Runner,
browser, typecheck, and build lanes.
- `node scripts/check-maps.mjs` and `git diff --check` passed. These
structural checks do not prove live recovery.
- Current head `48fca2ae33d99f7f6d2fe12f431cf76d25185c70`: 55 successful
checks, two conditional skips, no pending or failed checks; fresh
Greptile 5/5 with zero findings and zero unresolved review threads.
Commitperclip quality review passed.
- No original task was retried or changed. Existing Daytona access could
not resolve the original sandbox or its image; a fresh staging Claude
recovery is not claimed.
## Risks
This prevents recurrence for sessions admitted with the new receipt. It
does not retroactively recover the historical failed run. Saved
authority exists only after this change has admitted a session. It
cannot repair an ambiguous historical launch or restore a receipt on an
empty replacement controller. A stale protocol or modified pack remains
a hard failure. Receipt write errors warn without losing the live
session handle, but later recovery still needs matching authority. The
broader checkpoint stack changes the same server preparation and
runner-artifact option and will require a deliberate integration.
## Model Used
OpenAI GPT-6 through Codex, with reasoning, repository tools, and code
execution. The runtime does not expose a more specific model ID or
context-window size.
## 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 (e.g. `docs/...`, `fix/...`)
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 | 6 +-
doc/native-workspace-finalization-recovery.md | 24 ++-
maps/feature-map/recovery.md | 8 +
.../native-session-executor.test.ts | 41 +++++-
.../native-runtime/native-session-executor.ts | 137 ++++++++----------
.../remote-provider-pack-authority.test.ts | 121 ++++++++++++++++
.../remote-provider-pack-authority.ts | 107 ++++++++++++++
.../remote-provider-pack-verification.ts | 85 +++++++++++
.../remote-provider-pack.test.ts | 8 +
.../native-runtime/remote-provider-pack.ts | 5 +-
10 files changed, 458 insertions(+), 84 deletions(-)
create mode 100644 server/src/services/native-runtime/remote-provider-pack-authority.test.ts
create mode 100644 server/src/services/native-runtime/remote-provider-pack-authority.ts
create mode 100644 server/src/services/native-runtime/remote-provider-pack-verification.ts
diff --git a/doc/execution-semantics.md b/doc/execution-semantics.md
index 4b2df094f3a..8bc8886b075 100644
--- a/doc/execution-semantics.md
+++ b/doc/execution-semantics.md
@@ -1807,7 +1807,11 @@ experimental isolated-workspaces UI. Ordinary workspace updates remain gated;
the runtime can bind only a validated shared workspace in the issue's company
and project. Follow-ups can therefore reuse the same sandbox and provider
session. A staged provider package is reused only after the complete expected
-manifest and artifact hashes verify. A missing, changed, or incompatible package
+manifest and artifact hashes verify. Exact live-process reattachment uses the
+controller-owned pack and runner identities recorded after the original session
+passed authentication. It preserves protocol and capability checks. Fresh or
+replacement launches use current release authority. A missing historical receipt
+retains the current release gate; guest files cannot establish old authority. A missing, changed, or incompatible package
must be replaced and verified before launch.
Warm attachment requires two consecutive authenticated readiness snapshots.
diff --git a/doc/native-workspace-finalization-recovery.md b/doc/native-workspace-finalization-recovery.md
index f6ba0ee73ea..8b9c004282c 100644
--- a/doc/native-workspace-finalization-recovery.md
+++ b/doc/native-workspace-finalization-recovery.md
@@ -371,6 +371,26 @@ and operator guidance remain under Recovery details. Source-only repair remains
available through the board-authorized recovery resolution API and its original
stop-receipt and evidence checks.
+After a remote provider pack passes full integrity verification and the session
+passes authenticated admission, the controller saves its manifest and runner
+binary identity in `provider-pack-authority.json`. The receipt binds the company,
+agent, workspace, session, runner instance, environment lease, remote working
+directory, and provider descriptor. It stays in private controller state, outside
+guest workspace and harness backup directories. Projectless runs use their
+original environment lease as the workspace identity because each run otherwise
+has a new workspace placeholder. Real workspace identities still require an exact
+match. A receipt write failure warns and returns the admitted session to its
+normal owner; it does not discard the live session handle. Future recovery still
+fails closed without matching authority.
+
+Exact process reattachment uses that saved release authority. It still verifies
+the full remote pack, runner protocol and capabilities, exact process identity,
+and authenticated PRP handshake. A new controller release alone does not require
+a live provider to change its executable files. Fresh launches and explicit
+session replacement select current release artifacts. Invalid or mismatched
+receipts fail closed. Older sessions without a receipt retain the strict current
+release gate; a sandbox's own manifest cannot supply missing launch authority.
+
Remote provider-pack verification retries an interrupted read at most once.
Each command retains its 30-second transport timeout. A timeout, signal, absent
exit status, or transport exception does not prove artifact incompatibility.
@@ -380,7 +400,9 @@ a new provider attempt, so it cannot renew this exhausted read budget. A
completed nonzero verification, digest mismatch, or
wrong OpenCode version remains incompatible and cannot authorize a new provider
turn. Recovery retains the exact source workspace and existing ownership gates;
-these read-only retries do not replay tools or alter a retained pack.
+these read-only retries do not replay tools or alter a retained pack. Verification
+errors include bounded, redacted output from both transport streams, including
+providers that combine stderr into stdout.
### Continue after confirmed source destruction
diff --git a/maps/feature-map/recovery.md b/maps/feature-map/recovery.md
index 6624fbc67f7..b7de12dddc4 100644
--- a/maps/feature-map/recovery.md
+++ b/maps/feature-map/recovery.md
@@ -176,6 +176,14 @@ and rejected identity evidence. The Runner restart tests require positive exit
evidence before replacement. This coverage does not prove recovery of a
historical retained sandbox or behavior during a live provider outage.
+For a release change during a remote provider session, verify that reattachment
+uses the controller's admitted pack manifest and runner digest. Check useful
+output from the same process without replaying a turn. Repeat with a modified
+sidecar, mismatched receipt scope, replacement lease, and incompatible runner
+protocol. The [authority tests](../../server/src/services/native-runtime/remote-provider-pack-authority.test.ts)
+and executor wiring regressions cover selection and fail-closed scope checks.
+They do not prove a historical retained sandbox was recovered.
+
For completion recovery, restart after a native completion proposal is appended
but before its session checkpoint. Verify that the original result is finalized
after provider termination, with no new provider turn or repeated external action.
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 51e0c3af818..ad754e8e84e 100644
--- a/server/src/services/native-runtime/native-session-executor.test.ts
+++ b/server/src/services/native-runtime/native-session-executor.test.ts
@@ -54,9 +54,11 @@ import {
type NativeExecutionInput,
type PrpEvent,
} from "@paperclipai/paperclip-runner";
+import { existsSync } from "node:fs";
import { createHash, generateKeyPairSync, randomUUID } from "node:crypto";
import { DatabaseSync } from "node:sqlite";
import { nativeSha256 } from "./canonical.js";
+import { resolveRemoteProviderPackAuthority } from "./remote-provider-pack-authority.js";
import { RemoteRunnerRecoveryError, readRemoteRunnerRecoveryError } from "./remote-runner-recovery-diagnostics.js";
import { agentDirectoryWorkingCopyService } from "../agent-directory-working-copies.js";
import { probeAgentDirectory } from "../agent-directory-probe.js";
@@ -1432,7 +1434,7 @@ describe("remote provider pack manifest", () => {
productionLock: "pnpm-lock.yaml", opencodeCommand: "node_modules/.bin/opencode",
opencodeExecutable: "node_modules/opencode-ai/bin/opencode.exe",
opencodeProxy: "dist/cli/opencode-app-server-proxy.cjs", acpxSidecar: "dist/cli/acpx-runtime-sidecar.cjs",
- }).map(([key, path]) => [key, { path, sha256: digest }]));
+ }).map(([key, path]) => [key, { path, sha256: digest }])) as Record<"nodeCommand" | "opencodeCommand", { path: string; sha256: string }>;
const payload = {
target: { platform: "linux", architecture }, runnerSourceRevision: "a".repeat(40),
pins: { nodeMinimum: "24.11.0", codex: QUALIFIED_ACPX_PROFILES.codex.agentRuntimeVersion,
@@ -1484,6 +1486,43 @@ describe("remote provider pack manifest", () => {
sourcePath: state.bundledRunnerBinary, targetPath: "/workspace/.paperclip-runtime/paperclip-runner/bin/paperclip-runnerd",
})] })]);
expect(execute.mock.calls.some(([call]) => call.args?.[1] === "uname -s; uname -m")).toBe(false);
+ expect(existsSync(join(transport.stateDirectory!, "provider-pack-authority.json"))).toBe(false);
+ if (provider === "claude-x64") {
+ const identity = transport.prpIdentity!;
+ const artifact = { version: "0.0.0", digest: "sha256:" + createHash("sha256").update(daemon).digest("hex") };
+ resolveRemoteProviderPackAuthority({ stateDirectory: transport.stateDirectory!,
+ binding: { companyId: remoteExecution.binding.companyId, agentId: remoteExecution.binding.agentId,
+ workspaceId: remoteExecution.binding.executionWorkspaceId, sessionId: remoteExecution.session.normalizedSessionId!,
+ runnerInstanceId: identity.runnerInstanceId, environmentLeaseId: identity.environmentLeaseId,
+ remoteCwd: "/workspace", providerDigest: nativeSha256(remoteExecution.provider) },
+ reattach: false, current: () => manifest, runnerArtifact: () => artifact }).verified();
+ await mkdir(join(transport.stateDirectory!, "control-plane"), { recursive: true });
+ await writeFile(join(transport.stateDirectory!, "control-plane", "control-plane-state.json"), JSON.stringify(durableControlPlaneState(identity)));
+ const next = structuredClone(manifest);
+ next.payload.runnerSourceRevision = "b".repeat(40);
+ next.digest = "sha256:" + createHash("sha256").update(canonical(next.payload)).digest("hex");
+ await writeFile(state.bundledPackManifestPath!, JSON.stringify(next));
+ await writeFile(state.bundledRunnerBinary!, Buffer.concat([daemon, Buffer.from("new release")]));
+ const recoveryExecute = vi.fn(async (request: { args: string[] }) => {
+ const script = request.args[1];
+ const stdout = script.includes("base64") ? Buffer.from(JSON.stringify({ ...identity, lifecycle: "running" })).toString("base64")
+ : script.includes("cat --") ? `nonce\n123\n2026-09-19T10:00:00.000Z\n${identity.runnerInstanceId}\nlinux:abcd-1234:100\n` : "";
+ return { stdout, stderr: "", exitCode: 0, timedOut: false };
+ });
+ await createRunnerdBackend({ db: leaseDb(remoteExecution), execution: remoteExecution,
+ runnerInstanceId: identity.runnerInstanceId,
+ runnerExecutionTarget: { kind: "remote", transport: "sandbox", remoteCwd: "/workspace",
+ environmentId: "environment", leaseId: "lease", providerKey: "daytona", runner: { execute: recoveryExecute },
+ sandboxLeaseAcquisition: { outcome: "resumed", providerLeaseId: "sandbox" } } as never,
+ restartRecovery: { kind: "reattach_remote_runner", runId: remoteExecution.binding.runId,
+ leaseOwner: "controller", controllerGeneration: 2, providerAttempt: 1, restartKind: "graceful", recoveryRequestId: null,
+ remote: { providerLeaseId: "sandbox", remoteCwd: "/workspace" } },
+ });
+ state.createBackend.mock.calls.at(-1)![1].codexTransportFactory!();
+ expect(state.createTransport.mock.calls.at(-1)![0]).toMatchObject({
+ providerPackAuthorityDigest: manifest.digest, runnerArtifact: artifact, adoptExistingRunner: { pid: 123 },
+ });
+ }
} finally {
state.bundledPackManifestPath = null; state.bundledRunnerBinary = null;
await rm(root, { recursive: true, force: true });
diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts
index fe6d4732e76..cc03fcbfa56 100644
--- a/server/src/services/native-runtime/native-session-executor.ts
+++ b/server/src/services/native-runtime/native-session-executor.ts
@@ -1,3 +1,5 @@
+import { providerPackWorkspaceId, recordAdmittedRemoteProviderPack, resolveRemoteProviderPackAuthority } from "./remote-provider-pack-authority.js";
+import { verifyRemoteProviderPack as verifyRemoteProviderPackIdentity } from "./remote-provider-pack-verification.js";
import { writeEnvironmentCleanup } from "../environment-cleanup-notifications.js";
import { DELIVERY_QUEUES } from "../delivery-work-notifications.js";
import { writeExecutionControl } from "../execution-control-notifications.js";
@@ -47,7 +49,7 @@ import { copyBackCodexAuth } from "@paperclipai/adapter-codex-local/server";
import { nativeCompletionFeedback } from "./native-completion-feedback.js";
import { hasAcknowledgedNativeReassignmentStopIntent, hasAcknowledgedNativeStopIntent } from "../acknowledged-native-stop.js";
import { stoppedCodexTurnIsTextOnly } from "./stopped-codex-turn.js";
-import { prepareVerifiedRemoteProviderPack, useVerifiedPreinstalledRemoteProviderPack, runRemoteProviderVerification, shouldUseBundledAcpxImageAssets } from "./remote-provider-pack.js";
+import { prepareVerifiedRemoteProviderPack, useVerifiedPreinstalledRemoteProviderPack, shouldUseBundledAcpxImageAssets } from "./remote-provider-pack.js";
import { readProviderPackArchive, stageProviderPackArchive } from "./provider-pack-archive.js";
import { selectRemotePiCompanion } from "./remote-pi-companion.js";
import { readNativeLocalProcessStop, PROCESS_START_REQUESTED, withReleasedNativeRunProcess } from "../native-local-process-stop.js";
@@ -12023,7 +12025,6 @@ async function createRunnerdBackendWithinSessionClaim(
}));
const configuredProviderPackRoot =
input.runnerRemoteProviderPackPath?.trim() || remotePiCompanion?.providerPack || bundledClaudeRoot || null;
- let expectedProviderPackManifest: RemoteProviderPackManifest | null = null;
const useBundledRemoteImageAssets =
(requiresRemoteProviderPack && !configuredProviderPackRoot &&
(input.execution.provider.kind === "opencode" || remoteClaude)) ||
@@ -12031,24 +12032,6 @@ async function createRunnerdBackendWithinSessionClaim(
requiresRemoteProviderPack, configuredProviderPackRoot,
provider: input.execution.provider,
});
- if (useBundledRemoteImageAssets) {
- expectedProviderPackManifest = bundledClaudeManifest && (remoteClaudeTarget === "linux-x64" || remoteClaudeTarget === "linux-arm64")
- ? readBundledRemoteProviderPackManifest(bundledClaudeManifest, remoteClaudeTarget)
- : readBundledRemoteProviderPackManifest();
- } else if (requiresRemoteProviderPack) {
- if (
- !configuredProviderPackRoot ||
- !existsSync(configuredProviderPackRoot) ||
- !lstatSync(configuredProviderPackRoot).isDirectory()
- ) {
- throw new Error(
- "runner_remote_provider_artifact_incompatible: configure PAPERCLIP_RUNNER_REMOTE_PROVIDER_PACK_PATH with the build-owned provider pack",
- );
- }
- expectedProviderPackManifest = readRemoteProviderPackManifest(
- configuredProviderPackRoot,
- );
- }
const stagedRemoteProviderPackRoot = remoteRuntimeRoot
? posix.join(remoteRuntimeRoot, "provider-pack")
: null;
@@ -12056,6 +12039,25 @@ async function createRunnerdBackendWithinSessionClaim(
const remoteBinary = remoteRuntimeRoot
? posix.join(remoteRuntimeRoot, "bin", "paperclip-runnerd")
: null;
+ let currentProviderPackManifest: RemoteProviderPackManifest | undefined;
+ const resolveCurrentProviderPackManifest = () => {
+ if (currentProviderPackManifest) return currentProviderPackManifest;
+ currentProviderPackManifest = (() => {
+ if (useBundledRemoteImageAssets) {
+ return bundledClaudeManifest && (remoteClaudeTarget === "linux-x64" || remoteClaudeTarget === "linux-arm64")
+ ? readBundledRemoteProviderPackManifest(bundledClaudeManifest, remoteClaudeTarget)
+ : readBundledRemoteProviderPackManifest();
+ }
+ if (!configuredProviderPackRoot || !existsSync(configuredProviderPackRoot)
+ || !lstatSync(configuredProviderPackRoot).isDirectory()) {
+ throw new Error("runner_remote_provider_artifact_incompatible: configure PAPERCLIP_RUNNER_REMOTE_PROVIDER_PACK_PATH with the build-owned provider pack");
+ }
+ return readRemoteProviderPackManifest(configuredProviderPackRoot);
+ })();
+ return currentProviderPackManifest;
+ };
+ const reattachProviderPack = input.restartRecovery?.kind === "reattach_remote_runner" || input.remoteWarmRecovery !== undefined;
+ if (requiresRemoteProviderPack && !reattachProviderPack) resolveCurrentProviderPackManifest();
// The transport hashes runnerBinary on the controller before an external
// launcher starts runnerd. Keep that artifact identity in the controller's
// filesystem; the remote launcher separately owns the sandbox command path.
@@ -12067,6 +12069,19 @@ async function createRunnerdBackendWithinSessionClaim(
: remoteTarget
? remoteClaudeRunnerBinary || input.runnerRemoteBinaryPath?.trim() || remotePiCompanion?.runnerBinary || (useBundledRemoteImageAssets ? bundledRemoteRunnerBinary() : resolvePaperclipRunnerBinary())
: resolvePaperclipRunnerBinary();
+ const selectProviderPackAuthority = (reattach: boolean) => requiresRemoteProviderPack ? resolveRemoteProviderPackAuthority({
+ stateDirectory: root,
+ binding: { companyId: input.execution.binding.companyId, agentId: input.execution.binding.agentId,
+ workspaceId: providerPackWorkspaceId(input.execution.binding.executionWorkspaceId, input.execution.binding.runId, effectiveEnvironmentLeaseId), sessionId: nativeSessionKey(input.execution),
+ runnerInstanceId: effectiveRunnerInstanceId, environmentLeaseId: effectiveEnvironmentLeaseId,
+ remoteCwd: remoteTarget!.remoteCwd, providerDigest: nativeSha256(input.execution.provider) },
+ reattach,
+ runnerArtifact: () => readRunnerdArtifactBinding(controllerRunnerBinary),
+ current: resolveCurrentProviderPackManifest,
+ }) : null;
+ let providerPackAuthority = selectProviderPackAuthority(reattachProviderPack);
+ let expectedProviderPackManifest = providerPackAuthority?.manifest ?? null;
+ let providerPackVerified = false;
const explicitRemoteCodex = input.runnerRemoteCodexPath?.trim() || null;
const remoteCodexNpmSpec = input.runnerRemoteCodexNpmSpec?.trim() || null;
if (explicitRemoteCodex && remoteCodexNpmSpec) {
@@ -12159,62 +12174,9 @@ async function createRunnerdBackendWithinSessionClaim(
const verifyRemoteProviderPack = async (packRoot: string) => {
if (!remoteTarget || !remoteCommandRunner || !expectedProviderPackManifest)
return;
- const expected = Buffer.from(
- canonicalJson(expectedProviderPackManifest),
- "utf8",
- ).toString("base64");
- const providerNodeCommand = posix.join(
- packRoot,
- expectedProviderPackManifest.payload.artifacts.nodeCommand.path,
- );
- const verifyScript = [
- "const fs=require('node:fs')",
- "const crypto=require('node:crypto')",
- "const path=require('node:path')",
- "const root=process.argv[1]",
- "const expected=Buffer.from(process.argv[2],'base64').toString('utf8')",
- "const actual=fs.readFileSync(path.join(root,'provider-pack.json'),'utf8').trim()",
- "const canonical=(v)=>Array.isArray(v)?'['+v.map(canonical).join(',')+']':v&&typeof v==='object'?'{'+Object.keys(v).sort().map(k=>JSON.stringify(k)+':'+canonical(v[k])).join(',')+'}':JSON.stringify(v)",
- "const manifest=JSON.parse(actual)",
- "if(canonical(manifest)!==expected)throw new Error('manifest mismatch')",
- "const hash=(p)=>'sha256:'+crypto.createHash('sha256').update(fs.readFileSync(path.join(root,p))).digest('hex')",
- "const tree=(treeRoot)=>{const digest=crypto.createHash('sha256');const visit=(directory,prefix='')=>{for(const entry of fs.readdirSync(directory,{withFileTypes:true}).sort((a,b)=>a.name.localeCompare(b.name))){const relative=prefix?prefix+'/'+entry.name:entry.name;const absolute=path.join(directory,entry.name);if(entry.isDirectory()){digest.update('directory\\0'+relative+'\\n');visit(absolute,relative)}else if(entry.isFile()){digest.update('file\\0'+relative+'\\0'+'sha256:'+crypto.createHash('sha256').update(fs.readFileSync(absolute)).digest('hex')+'\\n')}else if(entry.isSymbolicLink()){digest.update('symlink\\0'+relative+'\\0'+fs.readlinkSync(absolute)+'\\n')}else throw new Error('unsupported dist entry '+relative)}};visit(treeRoot);return 'sha256:'+digest.digest('hex')}",
- "for(const name of ['nodeCommand','productionLock','opencodeCommand','opencodeExecutable','opencodeProxy','acpxSidecar','grokLauncher']){const artifact=manifest.payload.artifacts[name];if(hash(artifact.path)!==artifact.sha256)throw new Error(name+' digest mismatch')}",
- "if(tree(path.join(root,'dist'))!==manifest.payload.distDigest)throw new Error('dist tree digest mismatch')",
- "for(const candidate of Object.values({...manifest.payload.providers,...manifest.payload.candidateProviders})){if(tree(path.join(root,candidate.path))!==candidate.sha256)throw new Error('candidate asset tree digest mismatch')}",
- "const version=process.versions.node.split('.').map(Number)",
- "const minimum=manifest.payload.pins.nodeMinimum.split('.').map(Number)",
- "if(version[0]JSON.parse(fs.readFileSync(path.join(root,'node_modules',...pkg.split('/'),'package.json'),'utf8')).version",
- "const expectedPackages={acpx:manifest.payload.pins.acpx,'@agentclientprotocol/claude-agent-acp':manifest.payload.pins.claudeAcp,'@agentclientprotocol/codex-acp':manifest.payload.pins.codexAcp,'opencode-ai':manifest.payload.pins.opencode}",
- "for(const [pkg,version] of Object.entries(expectedPackages))if(packageVersion(pkg)!==version)throw new Error(pkg+' version mismatch')",
- ].join(";");
- await runRemoteProviderVerification(() => remoteCommandRunner.execute({
- command: providerNodeCommand,
- args: ["-e", verifyScript, packRoot, expected],
- cwd: remoteTarget.remoteCwd,
- bypassSession: true,
- timeoutMs: 30_000,
- }), "provider pack verification");
- const opencodeCommand = posix.join(
- packRoot,
- expectedProviderPackManifest.payload.artifacts.opencodeCommand.path,
- );
- const opencodeVersion = await runRemoteProviderVerification(() => remoteCommandRunner.execute({
- command: opencodeCommand,
- args: ["--version"],
- cwd: remoteTarget.remoteCwd,
- bypassSession: true,
- timeoutMs: 30_000,
- }), "OpenCode version verification");
- if (
- opencodeVersion.stdout.trim() !== REMOTE_PROVIDER_PACK_PINS.opencode
- ) {
- throw new Error(
- "runner_remote_provider_artifact_incompatible: OpenCode version mismatch",
- );
- }
+ await verifyRemoteProviderPackIdentity({ runner: remoteCommandRunner,
+ cwd: remoteTarget.remoteCwd, packRoot, manifest: expectedProviderPackManifest });
+ providerPackVerified = true;
};
const discoverPreinstalledProviderPack = async () => {
@@ -12333,7 +12295,7 @@ async function createRunnerdBackendWithinSessionClaim(
);
}
const archive = await measureNativeRunnerSpan(input.trace, "provider_pack.archive.verify", () =>
- readProviderPackArchive(configuredProviderPackRoot, expectedProviderPackManifest.digest),
+ readProviderPackArchive(configuredProviderPackRoot, expectedProviderPackManifest!.digest),
);
const escapedPackRoot = stagedRemoteProviderPackRoot.replaceAll(
"'",
@@ -13243,6 +13205,7 @@ async function createRunnerdBackendWithinSessionClaim(
// makes the fresh replacement look like ambiguous lost harness state.
// Keep their evidence inside the same continuity-break archive.
"failover-backups",
+ "provider-pack-authority.json",
]) {
const source = resolve(root, name);
if (existsSync(source)) renameSync(source, resolve(archiveRoot, name));
@@ -13265,6 +13228,9 @@ async function createRunnerdBackendWithinSessionClaim(
}
}
remotePrepared = false;
+ providerPackAuthority = selectProviderPackAuthority(false);
+ expectedProviderPackManifest = providerPackAuthority?.manifest ?? null;
+ providerPackVerified = false;
};
const localRecoveryProcess =
input.restartRecovery?.kind === "reattach_existing_runner"
@@ -13455,6 +13421,7 @@ async function createRunnerdBackendWithinSessionClaim(
: {}),
stateDirectory: root,
runnerBinary: controllerRunnerBinary,
+ ...(providerPackAuthority ? { runnerArtifact: providerPackAuthority.runnerArtifact } : {}),
codexCommand: remoteCodexBinary ?? undefined,
sourceCodexHome: remoteTarget
? resolveSourceCodexHome(resolveNativeProviderEnvironment(input.execution.provider, runnerEnvironment))
@@ -13660,12 +13627,22 @@ async function createRunnerdBackendWithinSessionClaim(
priorAuthorityEpoch.revoke();
}
sessionToolAuthorityEpochs.set(sessionScopeId, authorityEpoch);
+ const recordAdmittedProviderPack = async () => {
+ if (providerPackVerified && providerPackAuthority) {
+ await recordAdmittedRemoteProviderPack(providerPackAuthority, message => input.onLog?.("stderr", message));
+ }
+ };
return {
bindManagedSession: wrapManagedSession,
descriptor: () => backend.descriptor(),
- openSession: async (sessionInput) => wrapManagedSession(await backend.openSession(sessionInput)),
+ openSession: async (sessionInput) => {
+ const session = await backend.openSession(sessionInput);
+ await recordAdmittedProviderPack();
+ return wrapManagedSession(session);
+ },
recoverSession: async (snapshot, options) => {
const result = backend.recoverSession ? await backend.recoverSession(snapshot, options) : { recovered: false, reason: "driver does not support recovery" };
+ if (result.session) await recordAdmittedProviderPack();
return result.session ? { ...result, session: wrapManagedSession(result.session) } : result;
},
openReplacementSession: async (sessionInput) => {
@@ -13675,7 +13652,9 @@ async function createRunnerdBackendWithinSessionClaim(
archiveContinuityState,
{ parentName: "native.session.execute" },
);
- return wrapManagedSession(await backend.openSession(sessionInput));
+ const session = await backend.openSession(sessionInput);
+ await recordAdmittedProviderPack();
+ return wrapManagedSession(session);
},
} satisfies NativeSessionBackend & { bindManagedSession(session: NativeSession): NativeSession };
}
diff --git a/server/src/services/native-runtime/remote-provider-pack-authority.test.ts b/server/src/services/native-runtime/remote-provider-pack-authority.test.ts
new file mode 100644
index 00000000000..f5f2039c4cf
--- /dev/null
+++ b/server/src/services/native-runtime/remote-provider-pack-authority.test.ts
@@ -0,0 +1,121 @@
+import { createHash } from "node:crypto";
+import { mkdtempSync, mkdirSync, rmSync, readFileSync, writeFileSync, symlinkSync, statSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { afterEach, expect, it, vi } from "vitest";
+import { providerPackWorkspaceId, recordAdmittedRemoteProviderPack, resolveRemoteProviderPackAuthority } from "./remote-provider-pack-authority.js";
+import { canonicalProviderPackJson } from "./remote-provider-pack-verification.js";
+
+const directories: string[] = [];
+afterEach(() => { for (const dir of directories.splice(0)) rmSync(dir, { recursive: true, force: true }); });
+const binding = { companyId: "company", agentId: "agent", workspaceId: "workspace", sessionId: "session",
+ runnerInstanceId: "runner", environmentLeaseId: "lease", remoteCwd: "/workspace", providerDigest: "provider" };
+const runnerArtifact = () => ({ version: "0.0.0", digest: "sha256:" + "c".repeat(64) });
+const manifest = (revision: string) => {
+ const payload = { runnerSourceRevision: revision.repeat(40), target: { platform: "linux", architecture: "x64" },
+ pins: { opencode: "1.18.34" }, artifacts: { nodeCommand: { path: "node", sha256: "sha256:" + "1".repeat(64) },
+ opencodeCommand: { path: "opencode", sha256: "sha256:" + "2".repeat(64) } } };
+ return { schema: "paperclip-runner/remote-provider-pack/v1", payload,
+ digest: "sha256:" + createHash("sha256").update(canonicalProviderPackJson(payload)).digest("hex") };
+};
+it("reattaches with the verified launch authority after the controller release changes", () => {
+ const stateDirectory = mkdtempSync(join(tmpdir(), "provider-pack-authority-")); directories.push(stateDirectory);
+ const original = manifest("a"), next = manifest("b");
+ const launched = resolveRemoteProviderPackAuthority({ stateDirectory, binding, reattach: false, runnerArtifact, current: () => original });
+ launched.verified();
+ const current = vi.fn(() => next);
+ const recovered = resolveRemoteProviderPackAuthority({ stateDirectory, binding, reattach: true, current, runnerArtifact });
+ expect(recovered.manifest).toEqual(original);
+ expect(recovered.runnerArtifact).toEqual(runnerArtifact());
+ expect(current).not.toHaveBeenCalled();
+});
+
+function fixture() {
+ const stateDirectory = mkdtempSync(join(tmpdir(), "provider-pack-authority-")); directories.push(stateDirectory);
+ const current = vi.fn(() => manifest("a"));
+ const input = { stateDirectory, binding, reattach: false, current, runnerArtifact };
+ return { input, path: join(stateDirectory, "provider-pack-authority.json") };
+}
+it("uses the new release for a fresh launch without changing the old receipt until verification", () => {
+ const { input, path } = fixture();
+ resolveRemoteProviderPackAuthority(input).verified();
+ input.current.mockReturnValue(manifest("b"));
+ const fresh = resolveRemoteProviderPackAuthority(input);
+ expect(fresh.manifest).toEqual(manifest("b"));
+ expect(JSON.parse(readFileSync(path, "utf8")).manifest).toEqual(manifest("a"));
+ fresh.verified();
+ expect(JSON.parse(readFileSync(path, "utf8")).manifest).toEqual(manifest("b"));
+ expect(statSync(path).mode & 0o777).toBe(0o600);
+});
+it("keeps historical sessions without a receipt at the current release integrity gate", () => {
+ const { input } = fixture();
+ const recovered = resolveRemoteProviderPackAuthority({ ...input, reattach: true });
+ expect(recovered.manifest).toEqual(manifest("a"));
+ expect(input.current).toHaveBeenCalledOnce();
+});
+it.each(Object.keys(binding) as (keyof typeof binding)[])("rejects a saved authority for a different %s", field => {
+ const { input } = fixture();
+ resolveRemoteProviderPackAuthority(input).verified();
+ expect(() => resolveRemoteProviderPackAuthority({ ...input, reattach: true,
+ binding: { ...binding, [field]: "other" } })).toThrow("does not match this execution");
+ expect(input.current).toHaveBeenCalledOnce();
+});
+it.each(["digest", "schema", "symlink", "oversized", "invalid_json"])("fails closed on %s instead of trusting remote or current bytes", kind => {
+ const { input, path } = fixture();
+ resolveRemoteProviderPackAuthority(input).verified();
+ const saved = JSON.parse(readFileSync(path, "utf8"));
+ if (kind === "digest") saved.manifest.payload.runnerSourceRevision = "c".repeat(40);
+ if (kind === "schema") saved.schema = "unknown";
+ writeFileSync(path, JSON.stringify(saved));
+ if (kind === "invalid_json") writeFileSync(path, "{");
+ if (kind === "oversized") writeFileSync(path, " ".repeat(512 * 1024 + 1));
+ if (kind === "symlink") { const copy = path + ".copy"; writeFileSync(copy, JSON.stringify(saved)); rmSync(path); symlinkSync(copy, path); }
+ expect(() => resolveRemoteProviderPackAuthority({ ...input, reattach: true })).toThrow("saved provider pack authority");
+ expect(input.current).toHaveBeenCalledOnce();
+});
+
+it("uses current authority for a replacement lease instead of restoring the prior pack", () => {
+ const { input, path } = fixture();
+ resolveRemoteProviderPackAuthority(input).verified();
+ const currentRunner = { version: "0.0.1", digest: "sha256:" + "d".repeat(64) };
+ const replacement = resolveRemoteProviderPackAuthority({ ...input, binding: { ...binding, environmentLeaseId: "replacement" },
+ current: () => manifest("b"), runnerArtifact: () => currentRunner });
+ expect(replacement.manifest).toEqual(manifest("b"));
+ expect(replacement.runnerArtifact).toEqual(currentRunner);
+ replacement.verified();
+ expect(JSON.parse(readFileSync(path, "utf8")).binding.environmentLeaseId).toBe("replacement");
+});
+it("retains the authenticated daemon identity across controller daemon replacement", () => {
+ const { input } = fixture();
+ resolveRemoteProviderPackAuthority(input).verified();
+ const changedRunner = vi.fn(() => ({ version: "new", digest: "sha256:" + "d".repeat(64) }));
+ const recovered = resolveRemoteProviderPackAuthority({ ...input, reattach: true, runnerArtifact: changedRunner });
+ expect(recovered.runnerArtifact).toEqual(runnerArtifact());
+ expect(changedRunner).not.toHaveBeenCalled();
+});
+it.each([{}, { version: "0.0.0", digest: "not-a-sha256" }])("rejects an invalid saved daemon identity: %j", runnerArtifact => {
+ const { input, path } = fixture();
+ resolveRemoteProviderPackAuthority(input).verified();
+ const saved = JSON.parse(readFileSync(path, "utf8"));
+ writeFileSync(path, JSON.stringify({ ...saved, runnerArtifact }));
+ expect(() => resolveRemoteProviderPackAuthority({ ...input, reattach: true })).toThrow("saved provider pack authority");
+});
+
+it("keeps admitted session ownership with the caller if receipt storage or its warning fails", async () => {
+ const { input, path } = fixture();
+ mkdirSync(path);
+ const authority = resolveRemoteProviderPackAuthority(input);
+ const warning = vi.fn(() => { throw new Error("log unavailable"); });
+ await expect(recordAdmittedRemoteProviderPack(authority, warning)).resolves.toBe(false);
+ expect(warning).toHaveBeenCalledWith(expect.stringContaining("could not save remote release authority"));
+ expect(() => resolveRemoteProviderPackAuthority({ ...input, reattach: true })).toThrow("saved provider pack authority");
+});
+it("retains the original projectless lease across warm run placeholders but rejects a different real workspace", () => {
+ const { input } = fixture();
+ const first = { ...binding, workspaceId: providerPackWorkspaceId("run-1", "run-1", "original-lease") };
+ resolveRemoteProviderPackAuthority({ ...input, binding: first }).verified();
+ const next = { ...binding, workspaceId: providerPackWorkspaceId("run-2", "run-2", "original-lease") };
+ expect(resolveRemoteProviderPackAuthority({ ...input, binding: next, reattach: true }).manifest).toEqual(manifest("a"));
+ const changed = { ...binding, workspaceId: providerPackWorkspaceId("project-workspace", "run-2", "original-lease") };
+ expect(() => resolveRemoteProviderPackAuthority({ ...input, binding: changed, reattach: true })).toThrow("does not match this execution");
+});
diff --git a/server/src/services/native-runtime/remote-provider-pack-authority.ts b/server/src/services/native-runtime/remote-provider-pack-authority.ts
new file mode 100644
index 00000000000..5516ad6d959
--- /dev/null
+++ b/server/src/services/native-runtime/remote-provider-pack-authority.ts
@@ -0,0 +1,107 @@
+import { createHash, randomUUID } from "node:crypto";
+import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
+import { join } from "node:path";
+import { redactSensitiveText } from "../../redaction.js";
+import { canonicalProviderPackJson, type ProviderPackIdentity } from "./remote-provider-pack-verification.js";
+
+export type ProviderPackBinding = {
+ companyId: string;
+ agentId: string;
+ workspaceId: string;
+ sessionId: string;
+ runnerInstanceId: string;
+ environmentLeaseId: string;
+ remoteCwd: string;
+ providerDigest: string;
+};
+export type RunnerArtifactIdentity = { version: string; digest: string };
+
+const schema = "paperclip.remote-provider-pack-authority.v1";
+const filename = "provider-pack-authority.json";
+const maximumBytes = 512 * 1024;
+
+function assertManifest(manifest: ProviderPackIdentity): void {
+ if (manifest?.schema !== "paperclip-runner/remote-provider-pack/v1" || !manifest.payload
+ || manifest.digest !== `sha256:${createHash("sha256").update(canonicalProviderPackJson(manifest.payload)).digest("hex")}`) {
+ throw new Error("runner_remote_provider_artifact_incompatible: saved provider pack authority is invalid");
+ }
+}
+
+export function resolveRemoteProviderPackAuthority(input: {
+ stateDirectory: string;
+ binding: ProviderPackBinding;
+ reattach: boolean;
+ current: () => T;
+ runnerArtifact: () => RunnerArtifactIdentity;
+}): { manifest: T; runnerArtifact: RunnerArtifactIdentity; verified: () => void } {
+ const root = lstatSync(input.stateDirectory);
+ if (!root.isDirectory() || root.isSymbolicLink()) {
+ throw new Error("runner_remote_provider_artifact_incompatible: provider pack authority directory is unsafe");
+ }
+ const path = join(input.stateDirectory, filename);
+ let manifest: T | undefined;
+ let runnerArtifact: RunnerArtifactIdentity | undefined;
+ if (input.reattach) {
+ let fd: number | undefined;
+ try {
+ fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
+ const stat = fstatSync(fd);
+ if (!stat.isFile() || stat.size > maximumBytes) throw new Error("invalid authority file");
+ const saved = JSON.parse(readFileSync(fd, "utf8"));
+ if (saved.schema !== schema || canonicalProviderPackJson(saved.binding) !== canonicalProviderPackJson(input.binding)) {
+ throw new Error("authority binding mismatch");
+ }
+ assertManifest(saved.manifest);
+ if (typeof saved.runnerArtifact?.version !== "string" || !saved.runnerArtifact.version
+ || !/^sha256:[a-f0-9]{64}$/.test(saved.runnerArtifact.digest)) throw new Error("invalid runner artifact identity");
+ manifest = saved.manifest as T;
+ runnerArtifact = saved.runnerArtifact;
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
+ throw new Error("runner_remote_provider_artifact_incompatible: saved provider pack authority is invalid or does not match this execution", { cause: error });
+ }
+ } finally {
+ if (fd !== undefined) closeSync(fd);
+ }
+ }
+ manifest ??= input.current();
+ assertManifest(manifest);
+ runnerArtifact ??= input.runnerArtifact();
+ const selectedRunner = structuredClone(runnerArtifact);
+ const selected = structuredClone(manifest);
+ const receipt = JSON.stringify({ schema, binding: input.binding, manifest: selected, runnerArtifact: selectedRunner });
+ if (Buffer.byteLength(receipt) > maximumBytes) throw new Error("runner_remote_provider_artifact_incompatible: provider pack authority exceeds its size limit");
+ return {
+ manifest: selected,
+ runnerArtifact: selectedRunner,
+ verified: () => {
+ const temporary = `${path}.${randomUUID()}.tmp`;
+ try {
+ writeFileSync(temporary, receipt, { flag: "wx", mode: 0o600 });
+ renameSync(temporary, path);
+ } finally {
+ rmSync(temporary, { force: true });
+ }
+ },
+ };
+}
+
+export function providerPackWorkspaceId(workspaceId: string, runId: string, environmentLeaseId: string): string {
+ return workspaceId === runId ? environmentLeaseId : workspaceId;
+}
+
+export async function recordAdmittedRemoteProviderPack(
+ authority: { verified: () => void },
+ onWarning: (message: string) => void | Promise,
+): Promise {
+ try {
+ authority.verified();
+ return true;
+ } catch (error) {
+ const reason = redactSensitiveText(error instanceof Error ? error.message : String(error)).slice(-1_024);
+ try {
+ await onWarning(`[paperclip-runner] could not save remote release authority; recovery still requires a verified receipt or current release artifacts: ${reason}\n`);
+ } catch {}
+ return false;
+ }
+}
diff --git a/server/src/services/native-runtime/remote-provider-pack-verification.ts b/server/src/services/native-runtime/remote-provider-pack-verification.ts
new file mode 100644
index 00000000000..3f504a636e9
--- /dev/null
+++ b/server/src/services/native-runtime/remote-provider-pack-verification.ts
@@ -0,0 +1,85 @@
+import { posix } from "node:path";
+import type { CommandManagedRuntimeRunner } from "@paperclipai/adapter-utils/command-managed-runtime";
+import { runRemoteProviderVerification } from "./remote-provider-pack.js";
+
+export function canonicalProviderPackJson(value: unknown): string {
+ if (Array.isArray(value)) return `[${value.map(canonicalProviderPackJson).join(",")}]`;
+ if (value && typeof value === "object") return `{${Object.entries(value).sort(([a], [b]) => a.localeCompare(b))
+ .map(([key, entry]) => `${JSON.stringify(key)}:${canonicalProviderPackJson(entry)}`).join(",")}}`;
+ return JSON.stringify(value) ?? "null";
+}
+
+export type ProviderPackIdentity = {
+ schema: string;
+ digest: string;
+ payload: {
+ pins: { opencode: string; [key: string]: string | null };
+ target: { platform: string; architecture: string };
+ runnerSourceRevision: string;
+ artifacts: { nodeCommand: { path: string; sha256: string }; opencodeCommand: { path: string; sha256: string }; [key: string]: { path: string; sha256: string } };
+ };
+};
+
+export async function verifyRemoteProviderPack({ runner, cwd, packRoot, manifest }: {
+ runner: CommandManagedRuntimeRunner;
+ cwd: string;
+ packRoot: string;
+ manifest: ProviderPackIdentity;
+}): Promise {
+ const expected = Buffer.from(
+ canonicalProviderPackJson(manifest),
+ "utf8",
+ ).toString("base64");
+ const providerNodeCommand = posix.join(
+ packRoot,
+ manifest.payload.artifacts.nodeCommand.path,
+ );
+ const verifyScript = [
+ "const fs=require('node:fs')",
+ "const crypto=require('node:crypto')",
+ "const path=require('node:path')",
+ "const root=process.argv[1]",
+ "const expected=Buffer.from(process.argv[2],'base64').toString('utf8')",
+ "const actual=fs.readFileSync(path.join(root,'provider-pack.json'),'utf8').trim()",
+ "const canonical=(v)=>Array.isArray(v)?'['+v.map(canonical).join(',')+']':v&&typeof v==='object'?'{'+Object.keys(v).sort().map(k=>JSON.stringify(k)+':'+canonical(v[k])).join(',')+'}':JSON.stringify(v)",
+ "const manifest=JSON.parse(actual)",
+ "if(canonical(manifest)!==expected)throw new Error('manifest mismatch')",
+ "const hash=(p)=>'sha256:'+crypto.createHash('sha256').update(fs.readFileSync(path.join(root,p))).digest('hex')",
+ "const tree=(treeRoot)=>{const digest=crypto.createHash('sha256');const visit=(directory,prefix='')=>{for(const entry of fs.readdirSync(directory,{withFileTypes:true}).sort((a,b)=>a.name.localeCompare(b.name))){const relative=prefix?prefix+'/'+entry.name:entry.name;const absolute=path.join(directory,entry.name);if(entry.isDirectory()){digest.update('directory\\0'+relative+'\\n');visit(absolute,relative)}else if(entry.isFile()){digest.update('file\\0'+relative+'\\0'+'sha256:'+crypto.createHash('sha256').update(fs.readFileSync(absolute)).digest('hex')+'\\n')}else if(entry.isSymbolicLink()){digest.update('symlink\\0'+relative+'\\0'+fs.readlinkSync(absolute)+'\\n')}else throw new Error('unsupported dist entry '+relative)}};visit(treeRoot);return 'sha256:'+digest.digest('hex')}",
+ "for(const name of ['nodeCommand','productionLock','opencodeCommand','opencodeExecutable','opencodeProxy','acpxSidecar','grokLauncher']){const artifact=manifest.payload.artifacts[name];if(hash(artifact.path)!==artifact.sha256)throw new Error(name+' digest mismatch')}",
+ "if(tree(path.join(root,'dist'))!==manifest.payload.distDigest)throw new Error('dist tree digest mismatch')",
+ "for(const candidate of Object.values({...manifest.payload.providers,...manifest.payload.candidateProviders})){if(tree(path.join(root,candidate.path))!==candidate.sha256)throw new Error('candidate asset tree digest mismatch')}",
+ "const version=process.versions.node.split('.').map(Number)",
+ "const minimum=manifest.payload.pins.nodeMinimum.split('.').map(Number)",
+ "if(version[0]JSON.parse(fs.readFileSync(path.join(root,'node_modules',...pkg.split('/'),'package.json'),'utf8')).version",
+ "const expectedPackages={acpx:manifest.payload.pins.acpx,'@agentclientprotocol/claude-agent-acp':manifest.payload.pins.claudeAcp,'@agentclientprotocol/codex-acp':manifest.payload.pins.codexAcp,'opencode-ai':manifest.payload.pins.opencode}",
+ "for(const [pkg,version] of Object.entries(expectedPackages))if(packageVersion(pkg)!==version)throw new Error(pkg+' version mismatch')",
+ ].join(";");
+ await runRemoteProviderVerification(() => runner.execute({
+ command: providerNodeCommand,
+ args: ["-e", verifyScript, packRoot, expected],
+ cwd: cwd,
+ bypassSession: true,
+ timeoutMs: 30_000,
+ }), "provider pack verification");
+ const opencodeCommand = posix.join(
+ packRoot,
+ manifest.payload.artifacts.opencodeCommand.path,
+ );
+ const opencodeVersion = await runRemoteProviderVerification(() => runner.execute({
+ command: opencodeCommand,
+ args: ["--version"],
+ cwd: cwd,
+ bypassSession: true,
+ timeoutMs: 30_000,
+ }), "OpenCode version verification");
+ if (
+ opencodeVersion.stdout.trim() !== manifest.payload.pins.opencode
+ ) {
+ throw new Error(
+ "runner_remote_provider_artifact_incompatible: OpenCode version mismatch",
+ );
+ }
+}
diff --git a/server/src/services/native-runtime/remote-provider-pack.test.ts b/server/src/services/native-runtime/remote-provider-pack.test.ts
index a4ec1f60d97..5d5991abca4 100644
--- a/server/src/services/native-runtime/remote-provider-pack.test.ts
+++ b/server/src/services/native-runtime/remote-provider-pack.test.ts
@@ -25,6 +25,14 @@ describe("remote provider verification", () => {
await expect(runRemoteProviderVerification(execute, "pack")).rejects.toThrow("runner_remote_provider_artifact_incompatible: pack failed (exitCode=1");
expect(execute).toHaveBeenCalledOnce();
});
+ it("retains a bounded redacted rejection from a combined-output transport", async () => {
+ const execute = vi.fn().mockResolvedValue({ ...ok, exitCode: 1,
+ stdout: "Error: manifest mismatch\nANTHROPIC_API_KEY=secret-fixture-value", stderr: "" });
+ const failure = await runRemoteProviderVerification(execute, "pack").catch(error => error);
+ expect(failure.message).toContain("manifest mismatch");
+ expect(failure.message).not.toContain("secret-fixture-value");
+ expect(execute).toHaveBeenCalledOnce();
+ });
it("recovers a transport exception and propagates cancellation", async () => {
const execute = vi.fn().mockRejectedValueOnce(new Error("connection unavailable")).mockResolvedValueOnce(ok);
await expect(runRemoteProviderVerification(execute, "pack")).resolves.toEqual(ok);
diff --git a/server/src/services/native-runtime/remote-provider-pack.ts b/server/src/services/native-runtime/remote-provider-pack.ts
index b7045ad42d7..f6621e1e1ea 100644
--- a/server/src/services/native-runtime/remote-provider-pack.ts
+++ b/server/src/services/native-runtime/remote-provider-pack.ts
@@ -1,3 +1,4 @@
+import { redactSensitiveText } from "../../redaction.js";
import type { NativeRunTrace } from "./native-run-trace.js";
import type { NativeExecutionInput } from "../../vendor/paperclip-runner/index.js";
@@ -37,10 +38,10 @@ export async function runRemoteProviderVerification(
result = await execute();
} catch (error) {
if (error instanceof Error && error.name === "AbortError") throw error;
- diagnostic = error instanceof Error ? error.message : String(error);
+ diagnostic = redactSensitiveText(error instanceof Error ? error.message : String(error)).slice(-1_024);
continue;
}
- diagnostic = `exitCode=${result.exitCode}; timedOut=${result.timedOut}; signal=${result.signal ?? "none"}; ${result.stderr.trim().slice(-1_024)}`;
+ diagnostic = `exitCode=${result.exitCode}; timedOut=${result.timedOut}; signal=${result.signal ?? "none"}; ${redactSensitiveText([result.stderr, result.stdout].filter(Boolean).join("\n")).trim().slice(-1_024)}`;
if (result.timedOut || result.exitCode === null || result.signal) continue;
if (result.exitCode !== 0) {
throw new Error(`runner_remote_provider_artifact_incompatible: ${label} failed (${diagnostic})`);
From cb47dbb2dac98bd3527c4eaaabca0b0119e2d9f2 Mon Sep 17 00:00:00 2001
From: Devin Foley
Date: Sat, 24 Oct 2026 16:49:21 -0700
Subject: [PATCH 04/70] fix: resolve git credentials with the issue's identity
in background jobs (#17515)
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - Git cache expiration, isolated source re-home, nested clone
migration and workspace reopen fetch from the workspace's remotes to
prove history is on the remote or to rebuild workspaces
> - They built git credentials from the issue id alone, but the managed
GitHub identity is selected from the run's responsible user and agent
> - So the credential never resolved, they fell back to anonymous
access, and private repositories were unreadable
> - This pull request resolves credentials with the issue's responsible
user and assigned agent, the same identity agent runs use
> - The benefit is that workspaces with private repositories can expire,
re-home, migrate and reopen like public ones
## Linked Issues or Issue Description
Refs #17464, #17407, #17369. Bug report described in-PR (bug report
template).
**What happened?**
On one instance, 18 long-closed workspaces in one project were kept on
every expiration sweep with `managed_checkout_upstream_unavailable`.
Their nested checkout is a private GitHub repository. Expiration called
`createGitRemoteAuthProvider(db, companyId, { issueId })`. With that
context, `resolveManagedGitHubCredential` returned "No managed GitHub
identity is available for this run". `withAnonymousGitFallback` then
tried anonymous access, which cannot read the private repository. Called
with the issue's `responsibleUserId` and `agentId`, the same lookup
returned a credential.
**Expected behavior**
Background workspace jobs authenticate as the issue's identity, as agent
runs do, and can verify private repositories.
**Steps to reproduce**
1. Configure a project with a private GitHub repository and a managed
GitHub identity tied to the issue's responsible user.
2. Close an issue whose workspace includes that repository and let git
cache expiration inspect it.
3. It is kept with `managed_checkout_upstream_unavailable`.
**Paperclip version or commit:** `master` at fe85fd7ac4.
**Deployment mode:** any with managed GitHub identities.
**Agent adapter(s) involved:** not adapter-specific (core).
## What Changed
- `createIssueGitRemoteAuthProvider(db, companyId, issueId)` in
`server/src/services/git-credentials.ts`:
- looks up the issue's `responsibleUserId` and `assigneeAgentId` once,
lazily, scoped to the company;
- builds `createGitRemoteAuthProvider` with `{ issueId,
responsibleUserId, agentId }`;
- falls back to the issue-only context when the issue is missing.
- Callers switched from the issue-only context:
- git cache expiration (`expirationGitAuth`);
- isolated source re-home;
- nested clone migration;
- both credential sites in
`reopenClosedIsolatedExecutionWorkspaceForIssue`.
- Anonymous fallback for public repositories is unchanged.
No code comments were added.
## Verification
- `pnpm exec vitest run src/__tests__/git-credentials.test.ts
src/__tests__/git-expiration-auth-fallback.test.ts` (in `server/`): 32
passed. New cases:
- the helper passes the issue's responsible user and agent to the
credential lookup;
- it looks the issue up once across calls;
- it falls back to the issue-only context when the issue is missing.
- **Mutation check:** dropping the identity fields from the context
fails two cases.
- **Live instance:** for an affected workspace, the managed credential
resolved only with the responsible user and agent; with the issue id
alone, or with standing delegation, it did not.
- `pnpm exec vitest run git-credentials git-expiration-auth-fallback
git-workspace-expiration git-restore-point isolated-repository-rehome
nested-clone-migration` (in `server/`): 296 passed.
- `pnpm run typecheck` (in `server/`): pass.
- Not run: server build and boot smoke.
## Risks
- **Whose identity is used:** background jobs now use the issue's
responsible user's managed GitHub identity, which agent runs on that
issue already use. They only read: fetch, ls-remote and clone for
verification and restore.
- **Unchanged failure behavior:** if the identity is unavailable (for
example, the user is no longer a member), the existing anonymous
fallback applies, as before.
## Model Used
- Claude Opus 5.5 (`claude-opus-5-5`), 1M context, through Claude Code
with tool use (shell, file edits, tests, read-only diagnosis on a live
instance).
## 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 (e.g. `docs/...`, `fix/...`)
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
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [ ] I will address all Greptile and reviewer comments before
requesting merge
Co-authored-by: Paperclip
---
server/src/__tests__/git-credentials.test.ts | 37 +++++++++++++++++++
server/src/services/execution-workspaces.ts | 8 ++--
server/src/services/git-credentials.ts | 14 +++++++
.../src/services/git-workspace-expiration.ts | 4 +-
.../services/isolated-repository-rehome.ts | 4 +-
server/src/services/nested-clone-migration.ts | 4 +-
6 files changed, 60 insertions(+), 11 deletions(-)
diff --git a/server/src/__tests__/git-credentials.test.ts b/server/src/__tests__/git-credentials.test.ts
index c286f832b42..e5aee1eb96f 100644
--- a/server/src/__tests__/git-credentials.test.ts
+++ b/server/src/__tests__/git-credentials.test.ts
@@ -9,6 +9,7 @@ import {
GIT_CREDENTIAL_TOKEN_ENV_KEY,
buildGitAuthInvocation,
createGitRemoteAuthProvider,
+ createIssueGitRemoteAuthProvider,
describeGitAuthFailure,
isGitHubHttpsRemoteUrl,
scrubGitCredentialText,
@@ -379,3 +380,39 @@ describe("DEFAULT_GITHUB_TOKEN_SECRET_NAMES", () => {
]);
});
});
+
+describe("createIssueGitRemoteAuthProvider", () => {
+ const githubUrl = "https://github.com/paperclipai/private-repo.git";
+ function issueDb(rows: unknown[]) {
+ const where = vi.fn(async () => rows);
+ return { db: { select: vi.fn(() => ({ from: () => ({ where }) })) } as unknown as Db, where };
+ }
+
+ it("resolves credentials with the issue's responsible user and assigned agent", async () => {
+ const { db } = issueDb([{ responsibleUserId: "user-1", assigneeAgentId: "agent-1" }]);
+ const inner = vi.fn(async () => ({ env: {}, configArgs: [], source: "managed_connection" }));
+ const create = vi.fn(() => inner) as unknown as typeof createGitRemoteAuthProvider;
+ const provider = createIssueGitRemoteAuthProvider(db, "company-1", "issue-1", create);
+ await expect(provider(githubUrl)).resolves.toMatchObject({ source: "managed_connection" });
+ expect(create).toHaveBeenCalledWith(db, "company-1", { issueId: "issue-1", responsibleUserId: "user-1", agentId: "agent-1" });
+ expect(inner).toHaveBeenCalledWith(githubUrl);
+ });
+
+ it("looks the issue up once across calls", async () => {
+ const { db } = issueDb([{ responsibleUserId: "user-1", assigneeAgentId: null }]);
+ const inner = vi.fn(async () => null);
+ const create = vi.fn(() => inner) as unknown as typeof createGitRemoteAuthProvider;
+ const provider = createIssueGitRemoteAuthProvider(db, "company-1", "issue-1", create);
+ await provider(githubUrl); await provider(githubUrl);
+ expect(db.select).toHaveBeenCalledTimes(1);
+ expect(create).toHaveBeenCalledTimes(1);
+ expect(inner).toHaveBeenCalledTimes(2);
+ });
+
+ it("falls back to the issue-only context when the issue is missing", async () => {
+ const { db } = issueDb([]);
+ const create = vi.fn(() => vi.fn(async () => null)) as unknown as typeof createGitRemoteAuthProvider;
+ await createIssueGitRemoteAuthProvider(db, "company-1", "issue-1", create)(githubUrl);
+ expect(create).toHaveBeenCalledWith(db, "company-1", { issueId: "issue-1", responsibleUserId: null, agentId: null });
+ });
+});
diff --git a/server/src/services/execution-workspaces.ts b/server/src/services/execution-workspaces.ts
index 63800f66b9e..4d83e2c8a9c 100644
--- a/server/src/services/execution-workspaces.ts
+++ b/server/src/services/execution-workspaces.ts
@@ -98,7 +98,7 @@ import {
type PullRequestMergeDetailsResolver,
} from "./github-pull-request-merge.js";
import { visibleIssueCondition } from "./issue-visibility.js";
-import { createGitRemoteAuthProvider } from "./git-credentials.js";
+import { createIssueGitRemoteAuthProvider } from "./git-credentials.js";
import { readProjectWorkspaceRuntimeConfig } from "./project-workspace-runtime-config.js";
import { workspaceGitOperationScheduler } from "./workspace-git-operation-scheduler.js";
import { isRuntimeOwnedGitBranch } from "./execution-workspace-branch-ownership.js";
@@ -4113,7 +4113,7 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic
try { recipe = readGitCacheRestoration(snapshot); }
catch { return { ok: false, code: "rebuild_failed", message: new GitCacheRestorationFailure().message }; }
const freshStart = recipe !== null && isClosedExecutionWorkspaceStatus(snapshot.status) && gitRestorePointExpired(recipe);
- const gitAuth = createGitRemoteAuthProvider(db, snapshot.companyId, { issueId: issue.id });
+ const gitAuth = createIssueGitRemoteAuthProvider(db, snapshot.companyId, issue.id);
const [
{ ensurePersistedExecutionWorkspaceAvailable },
{ workspaceOperationService },
@@ -4168,9 +4168,7 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic
});
};
},
- resolveGitAuth: createGitRemoteAuthProvider(db, snapshot.companyId, {
- issueId: issue.id,
- }),
+ resolveGitAuth: createIssueGitRemoteAuthProvider(db, snapshot.companyId, issue.id),
});
resolvedBaseCwd = managedWorkspace.cwd;
} catch {
diff --git a/server/src/services/git-credentials.ts b/server/src/services/git-credentials.ts
index 21899e5cef1..3fce7fc2b50 100644
--- a/server/src/services/git-credentials.ts
+++ b/server/src/services/git-credentials.ts
@@ -1,6 +1,7 @@
import {
companySecrets,
heartbeatRuns,
+ issues,
companyMemberships,
connectionGrantDelegations,
connectionGrants,
@@ -207,6 +208,19 @@ type GitCredentialSecretsDeps = {
* provider instance so one run performs at most one secret resolution (and writes at most
* one audit event) no matter how many git operations it authenticates.
*/
+export function createIssueGitRemoteAuthProvider(db: Db, companyId: string, issueId: string,
+ create: typeof createGitRemoteAuthProvider = createGitRemoteAuthProvider): GitRemoteAuthProvider {
+ let provider: Promise | null = null;
+ return async (remoteUrl: string) => {
+ provider ??= db.select({ responsibleUserId: issues.responsibleUserId, assigneeAgentId: issues.assigneeAgentId }).from(issues)
+ .where(and(eq(issues.id, issueId), eq(issues.companyId, companyId)))
+ .then(([issue]) => create(db, companyId, {
+ issueId, responsibleUserId: issue?.responsibleUserId ?? null, agentId: issue?.assigneeAgentId ?? null,
+ }));
+ return (await provider)(remoteUrl);
+ };
+}
+
export function createGitRemoteAuthProvider(
db: Db,
companyId: string,
diff --git a/server/src/services/git-workspace-expiration.ts b/server/src/services/git-workspace-expiration.ts
index 12cb8e52dda..5fdd85c7f96 100644
--- a/server/src/services/git-workspace-expiration.ts
+++ b/server/src/services/git-workspace-expiration.ts
@@ -6,7 +6,7 @@ import { acquireGitExpirationLocks } from "./git-workspace-expiration-locks.js";
import { and, eq, inArray, ne, sql } from "drizzle-orm";
import { executionWorkspaces, issues, heartbeatRuns, nativeRunFinalizations, environmentLeases,
workspaceRuntimeServices, issueRecoveryActions, projectWorkspaces, withDedicatedDbConnection, type Db } from "@paperclipai/db";
-import { createGitRemoteAuthProvider } from "./git-credentials.js";
+import { createIssueGitRemoteAuthProvider } from "./git-credentials.js";
import type { StorageService } from "../storage/types.js";
import { TASK_DIRECTORY_RETENTION_KEY } from "./isolated-task-directory-snapshot.js";
import { currentRestorePointSnapshot, RestorePointSkip, type RestorePointLimits } from "./git-restore-point.js";
@@ -37,7 +37,7 @@ export function withAnonymousGitFallback(provider: GitRemoteAuthProvider): GitRe
}
function expirationGitAuth(db: Db, workspace: Workspace) {
- return withAnonymousGitFallback(createGitRemoteAuthProvider(db, workspace.companyId, { issueId: workspace.sourceIssueId! }));
+ return withAnonymousGitFallback(createIssueGitRemoteAuthProvider(db, workspace.companyId, workspace.sourceIssueId!));
}
const EXPIRATION_KEY = "gitCacheExpiration";
diff --git a/server/src/services/isolated-repository-rehome.ts b/server/src/services/isolated-repository-rehome.ts
index c8c02f85578..6314f5edcc7 100644
--- a/server/src/services/isolated-repository-rehome.ts
+++ b/server/src/services/isolated-repository-rehome.ts
@@ -23,7 +23,7 @@ import { withExecutionWorkspaceLifecycleLock } from "./execution-workspace-lifec
import { loadGitRestorePoint } from "./git-restore-point-storage.js";
import { nestedWorktreeBranchPrefix, readLinkedNestedCheckout, repairMovedNestedRepositoryWorktree, withNestedRepositoryBaseLock } from "./nested-repository-worktrees.js";
import { getStorageService } from "../storage/index.js";
-import { createGitRemoteAuthProvider } from "./git-credentials.js";
+import { createIssueGitRemoteAuthProvider } from "./git-credentials.js";
import { resolvePaperclipInstanceRoot } from "../home-paths.js";
const execFile = promisify(execFileCallback);
@@ -598,7 +598,7 @@ async function worktreeOfClone(worktree: string, cloneReal: string, signal?: Abo
}
function upstreamCheck(db: Db, companyId: string, issueId: string, clone: string, repoUrl: string, signal?: AbortSignal): MissingUpstream {
- const auth = withAnonymousGitFallback(createGitRemoteAuthProvider(db, companyId, { issueId }));
+ const auth = withAnonymousGitFallback(createIssueGitRemoteAuthProvider(db, companyId, issueId));
return (commits, covered) => hasCommitsMissingUpstream(clone, commits, repoUrl, auth, signal, covered);
}
diff --git a/server/src/services/nested-clone-migration.ts b/server/src/services/nested-clone-migration.ts
index eb0e3a87eda..09b4f6663a9 100644
--- a/server/src/services/nested-clone-migration.ts
+++ b/server/src/services/nested-clone-migration.ts
@@ -13,7 +13,7 @@ import { GIT_CACHE_RESTORATION_KEY, readGitCacheRestoration, type GitCacheRestor
import { readManagedCheckoutContents, selectRestoredProjectRepositories } from "./git-workspace-contents.js";
import { applyRestorePoint, captureRestorePoint, currentRestorePointSnapshot, importRestorePoint } from "./git-restore-point.js";
import { managedProjectCheckoutName } from "./managed-project-checkout-name.js";
-import { createGitRemoteAuthProvider, type GitRemoteAuthProvider } from "./git-credentials.js";
+import { createIssueGitRemoteAuthProvider, type GitRemoteAuthProvider } from "./git-credentials.js";
import { isNestedWorktreeBranchName, isVerifiedNestedRepositoryBase, moveNestedRepositoryWorktree, nestedRepositoryBaseCandidates, nestedWorktreeBranchPrefix,
readLinkedNestedCheckout, removeStaleNestedRegistrations, trackNestedWorktreeUpstream, withNestedRepositoryBaseLock } from "./nested-repository-worktrees.js";
import { assertSafeBaseConfig, assertTransferredItems, historyKey, linkedCheckoutBases, moveTransferItems, presentCommits, returnTransferItems,
@@ -442,7 +442,7 @@ export async function migrateNestedRepositoryClone(db: Db, snapshot: Workspace,
const base = source.base;
await assertSafeBaseConfig(base, signal);
await fetchBase(base, source.repoUrl, options.resolveGitAuth
- ?? withAnonymousGitFallback(createGitRemoteAuthProvider(db, row!.companyId, { issueId: row!.sourceIssueId! })), signal);
+ ?? withAnonymousGitFallback(createIssueGitRemoteAuthProvider(db, row!.companyId, row!.sourceIssueId!)), signal);
await assertSafeBaseConfig(base, signal);
await assertNoBranchOnlyReflogHistory(inspected, signal);
const planned = await planRefs(inspected, base, prefix, signal);
From 71f06d84dc41c6e35d368147540588fd8fd8073d Mon Sep 17 00:00:00 2001
From: Devin Foley
Date: Sat, 24 Oct 2026 17:34:41 -0700
Subject: [PATCH 05/70] fix(db): keep bounded response captures attributable
(#17522)
Keep more caller groups in one bounded database response capture and verify public caller paths in the production bundle. All local checks and CI passed; Greptile scored the exact head 5/5 with no unresolved comments.
Co-Authored-By: Paperclip
---
doc/DATABASE.md | 13 +++++++++--
packages/db/src/response-capture.test.ts | 7 +++---
packages/db/src/response-capture.ts | 2 +-
server/scripts/bundle-runtime.test.mjs | 29 +++++++++++++++++++++++-
4 files changed, 44 insertions(+), 7 deletions(-)
diff --git a/doc/DATABASE.md b/doc/DATABASE.md
index bc1292bbe94..e6211773d87 100644
--- a/doc/DATABASE.md
+++ b/doc/DATABASE.md
@@ -332,12 +332,21 @@ The diagnostic measures awaited Postgres.js `unsafe` responses before Drizzle
column decoding. It retains the Drizzle builder caller for normal, prepared,
transaction, and dedicated-pool execution. It issues no queries.
It logs the public server caller locations, static schema table, operation,
-query/row counts, and response value sizes. It emits at most 128 aggregate
+query/row counts, and response value sizes. It emits at most 512 aggregate
groups and a summary at the deadline. It never logs SQL, parameters, result
values, connection strings, or query errors. In-flight queries continue normally.
+For a bundled server, keep the linked source maps and enable Node's
+`--enable-source-maps` option so the diagnostic can resolve public caller
+locations. Restore the prior Node options after capture. Sizes still count when
+caller locations are unavailable.
+
Look for `paperclip.db_response_capture.entry.v1` and
-`paperclip.db_response_capture.summary.v1` in the process logs. Tagged raw-driver calls, streams, unawaited raw-driver
+`paperclip.db_response_capture.summary.v1` in the process logs. A log provider
+can store `event` as a structured field instead of message text. Filter by that
+field or read the bounded window from the exact deployment. Check the summary
+and group totals, including the unattributed overflow group.
+Tagged raw-driver calls, streams, unawaited raw-driver
dispatch, and protocol overhead are not measured. Relational queries use a driver-level caller fallback.
Queries still in flight at the deadline are reported separately. Sizes describe
driver values before column decoding, not billed network bytes. Complex parsed
diff --git a/packages/db/src/response-capture.test.ts b/packages/db/src/response-capture.test.ts
index 65f58d10883..4b87c7924a4 100644
--- a/packages/db/src/response-capture.test.ts
+++ b/packages/db/src/response-capture.test.ts
@@ -107,11 +107,12 @@ describe("bounded database response capture", () => {
});
it("bounds cardinality without dropping response-byte totals", () => {
const f = fixture();
- for (let i = 0; i < 200; i += 1) f.capture.execute(() => [i], { ...metadata, callers: [`services/f${i}.ts:1:1`] });
+ for (let i = 0; i < 600; i += 1) f.capture.execute(() => [i], { ...metadata, callers: [`services/f${i}.ts:1:1`] });
f.capture.flush();
const summary = f.events.at(-1)!;
- expect(summary).toMatchObject({ startedQueries: 200, completedQueries: 200, groups: 128, overflowCalls: 73 });
- expect(summary.responseValueBytes).toBe(Array.from({ length: 200 }, (_, i) => Buffer.byteLength(JSON.stringify([i]))).reduce((a, b) => a + b, 0));
+ expect(summary).toMatchObject({ startedQueries: 600, completedQueries: 600, groups: 512, overflowCalls: 89 });
+ expect(f.events).toHaveLength(513);
+ expect(summary.responseValueBytes).toBe(Array.from({ length: 600 }, (_, i) => Buffer.byteLength(JSON.stringify([i]))).reduce((a, b) => a + b, 0));
});
it("does not execute builders or prepared statements until requested", async () => {
const f = fixture(), rows = [{ id: 1 }], execute = vi.fn(async () => rows);
diff --git a/packages/db/src/response-capture.ts b/packages/db/src/response-capture.ts
index 8f738043f4d..f8e6da8e87a 100644
--- a/packages/db/src/response-capture.ts
+++ b/packages/db/src/response-capture.ts
@@ -99,7 +99,7 @@ export function createDatabaseResponseCapture(
completed += 1;
try {
let key = JSON.stringify(metadata);
- if (!entries.has(key) && entries.size >= 127) {
+ if (!entries.has(key) && entries.size >= 511) {
overflowCalls += 1;
metadata = { operation: "other", table: "other", callers: [] };
key = "overflow";
diff --git a/server/scripts/bundle-runtime.test.mjs b/server/scripts/bundle-runtime.test.mjs
index 14b0d46c681..6e9375f61c4 100644
--- a/server/scripts/bundle-runtime.test.mjs
+++ b/server/scripts/bundle-runtime.test.mjs
@@ -2,7 +2,7 @@ import { execFile } from "node:child_process";
import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
-import { pathToFileURL } from "node:url";
+import { fileURLToPath, pathToFileURL } from "node:url";
import { promisify } from "node:util";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
@@ -85,6 +85,33 @@ describe("bundleRuntime", () => {
expect(output.entryDir).toBe(path.join(root, "server", "dist"));
});
+ it("keeps database caller locations public in a source-mapped runtime bundle", async () => {
+ const captureModule = fileURLToPath(new URL("../../packages/db/src/response-capture.ts", import.meta.url));
+ write(path.join(root, "server", "dist", "services", "response-caller.js"), [
+ `import { publicDatabaseCallers } from ${JSON.stringify(captureModule)};`,
+ 'export function callers() { return publicDatabaseCallers(new Error("private-capture-message").stack); }',
+ ].join("\n"));
+ write(path.join(root, "server", "dist", "index.js"), [
+ 'import { callers } from "./services/response-caller.js";',
+ 'console.log(JSON.stringify({ callers: callers() }));',
+ ].join("\n"));
+ const summary = await bundleRuntime({ entry: path.join(root, "server", "dist", "index.js"), outdir: path.join(root, "server", "dist-runtime") });
+ const env = { ...process.env };
+ delete env.NODE_OPTIONS;
+ delete env.PAPERCLIP_DB_RESPONSE_CAPTURE_WINDOW;
+ const options = { cwd: path.join(root, "server"), env, timeout: 30_000 };
+ const plain = JSON.parse((await execFileAsync(process.execPath, [summary.outfile], options)).stdout);
+ const mapped = JSON.parse((await execFileAsync(process.execPath, ["--enable-source-maps", summary.outfile], options)).stdout);
+ expect(plain.callers).toEqual([]);
+ expect(mapped.callers).toEqual([expect.stringMatching(/^services\/response-caller\.js:\d+:\d+$/)]);
+ for (const result of [plain, mapped]) {
+ const output = JSON.stringify(result);
+ expect(output).not.toContain(root);
+ expect(output).not.toContain(captureModule);
+ expect(output).not.toContain("private-capture-message");
+ }
+ });
+
it("emits a server module that loads without starting the server", async () => {
const outdir = path.join(root, "server", "dist-runtime");
const summary = await bundleRuntime({ entry: path.join(root, "server", "dist", "index.js"), outdir });
From 64a8b927c1f199c612bcd36dccb6190e6e250eb3 Mon Sep 17 00:00:00 2001
From: Devin Foley
Date: Sat, 24 Oct 2026 17:48:47 -0700
Subject: [PATCH 06/70] fix(native): narrow committed finalization and cleanup
reads (#17525)
Select fresh metadata for committed finalization and cleanup. Preserve full saved evidence for new finalization, cancelled workspace retention, and legacy Stop. PostgreSQL byte-budget and unchanged-row regressions pass, full CI is green, and Greptile scored the exact head 5/5.
Co-Authored-By: Paperclip
---
doc/DATABASE.md | 9 +++
server/src/services/heartbeat.ts | 4 ++
.../__tests__/run-control-fixtures.ts | 2 +
.../src/services/heartbeat/recovery.test.ts | 2 +
server/src/services/heartbeat/recovery.ts | 4 +-
.../services/heartbeat/run-control.test.ts | 6 +-
server/src/services/heartbeat/run-control.ts | 22 ++++---
.../src/services/heartbeat/run-state.test.ts | 57 +++++++++++++++++++
server/src/services/heartbeat/run-state.ts | 24 ++++++++
.../__tests__/helpers/committed-replay.ts | 34 +++++++++++
.../native-run-finalizer-telemetry.test.ts | 29 ++++++----
.../native-runtime/native-run-finalizer.ts | 55 ++++++++++++------
12 files changed, 208 insertions(+), 40 deletions(-)
create mode 100644 server/src/services/native-runtime/__tests__/helpers/committed-replay.ts
diff --git a/doc/DATABASE.md b/doc/DATABASE.md
index e6211773d87..717ec1ca0ed 100644
--- a/doc/DATABASE.md
+++ b/doc/DATABASE.md
@@ -68,6 +68,15 @@ Runner profiles retain the adapter type and complete native execution input and
workspace-sync receipts. Full result evidence stays available to the existing
retry and workspace checks. Stored context, profiles, and output are unchanged.
+Committed native finalization reads run identity and ownership state before it
+claims the coordinator. It loads complete saved input only when finalization
+still needs to process an accepted result. Review presentation retains the full
+result. Muse settlement reads its exact provider binding. Cleanup reads fresh
+status and ownership metadata; cancelled native workspace retention and legacy
+Stop settlement still load complete run evidence. Workspace settlement reads the
+saved sync descriptor, export retry, and first-heartbeat flag. These projections
+leave stored prompts, logs, results, and recovery state unchanged.
+
Conversation ownership checks select process identity and full recovery result
evidence without execution prompts or profiles. Committed native reply repair
selects its persisted result and identity bindings plus typed presentation flags.
diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts
index 543d96a97f8..9a18721aaa7 100644
--- a/server/src/services/heartbeat.ts
+++ b/server/src/services/heartbeat.ts
@@ -811,6 +811,8 @@ export function heartbeatService(
getAgent,
resolveSessionBeforeForWakeup,
getRun,
+ getRunCleanupState,
+ getRunWorkspaceSettlement,
getRunStatus,
ensureRuntimeState,
getTaskSession,
@@ -1014,6 +1016,7 @@ export function heartbeatService(
environmentRuntime,
scheduleNativeSessionResumeDispatch,
getRun,
+ getRunWorkspaceSettlement,
executeRun,
activeRunExecutionPromises,
cancelHeartbeatNativeRun,
@@ -1386,6 +1389,7 @@ export function heartbeatService(
const runControl = createHeartbeatRunControl(db, {
getRun,
+ getRunCleanupState,
options,
envOrchestrator,
activeRunExecutions,
diff --git a/server/src/services/heartbeat/__tests__/run-control-fixtures.ts b/server/src/services/heartbeat/__tests__/run-control-fixtures.ts
index 42f6bea5150..2adce9077c1 100644
--- a/server/src/services/heartbeat/__tests__/run-control-fixtures.ts
+++ b/server/src/services/heartbeat/__tests__/run-control-fixtures.ts
@@ -13,6 +13,7 @@ import type { HeartbeatRunControlDependencies } from "../run-control.js";
type TestCallbacks = HeartbeatRunControlDependencies & ReturnType & {
getRun: Mock;
getAgent: Mock;
+ getRunCleanupState: Mock;
setRunStatusFromLive: Mock;
getSchedulingSuppression: Mock;
options: { closeWarmNativeSessionsForRun: Mock> };
@@ -43,6 +44,7 @@ export function callbacks(db: Db): TestCallbacks {
...state,
...lifecycle,
getRun: vi.fn(state.getRun),
+ getRunCleanupState: vi.fn(state.getRunCleanupState),
getAgent: vi.fn(state.getAgent),
setRunStatusFromLive: vi.fn(lifecycle.setRunStatusFromLive),
setRunStatus: vi.fn(lifecycle.setRunStatus),
diff --git a/server/src/services/heartbeat/recovery.test.ts b/server/src/services/heartbeat/recovery.test.ts
index 8faa8c7e865..34139757468 100644
--- a/server/src/services/heartbeat/recovery.test.ts
+++ b/server/src/services/heartbeat/recovery.test.ts
@@ -7,6 +7,7 @@ import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } fro
import { classifyEnvironmentCapabilities, environmentRuntimeService } from "../environment-runtime.js";
import { environmentService } from "../environments.js";
import { createHeartbeatRecovery, type HeartbeatRecoveryDependencies } from "./recovery.js";
+import { createHeartbeatRunState } from "./run-state.js";
import type { HotRestartIntent } from "../hot-restart.js";
import type { NativeRestartRecoveryClaim, NativeRestartRecoveryDisposition } from "../native-runtime/index.js";
import { NATIVE_OWNERSHIP_UNVERIFIED_ERROR_CODE } from "../native-runtime/native-runner-ownership.js";
@@ -48,6 +49,7 @@ function callbacks(db: Db) {
return {
enterShutdown: vi.fn(),
getRun: vi.fn(async () => null),
+ getRunWorkspaceSettlement: vi.fn(createHeartbeatRunState(db).getRunWorkspaceSettlement),
getAgent: vi.fn(async () => null),
appendRunEvent: vi.fn(async () => undefined),
setRunStatusIfRunning: vi.fn(async () => ({ updated: false, run: null })),
diff --git a/server/src/services/heartbeat/recovery.ts b/server/src/services/heartbeat/recovery.ts
index 73c586bdff3..a5197485881 100644
--- a/server/src/services/heartbeat/recovery.ts
+++ b/server/src/services/heartbeat/recovery.ts
@@ -101,6 +101,7 @@ export interface HeartbeatRecoveryDependencies extends Pick["getRun"]>[1]) => Promise<
Awaited["getRun"]>> | null
>;
+ getRunWorkspaceSettlement: ReturnType["getRunWorkspaceSettlement"];
environmentRuntime: ReturnType;
pluginWorkerManager?: WorkspaceWorkers;
environmentsSvc: Pick, "getById" | "getLeaseById" | "releaseLease">;
@@ -360,6 +361,7 @@ export function createHeartbeatRecovery(db: Db, dependencies: HeartbeatRecoveryD
environmentRuntime,
scheduleNativeSessionResumeDispatch,
getRun,
+ getRunWorkspaceSettlement,
executeRun,
activeRunExecutionPromises,
cancelHeartbeatNativeRun,
@@ -1858,7 +1860,7 @@ export function createHeartbeatRecovery(db: Db, dependencies: HeartbeatRecoveryD
agentId: string;
succeeded: boolean;
}) {
- const settledRun = await getRun(input.runId);
+ const settledRun = await getRunWorkspaceSettlement(input.runId);
const workspaceSyncReference = readNativeWorkspaceSyncReference(
parseObject(settledRun?.runnerProfileJson).nativeWorkspaceSync,
);
diff --git a/server/src/services/heartbeat/run-control.test.ts b/server/src/services/heartbeat/run-control.test.ts
index 7f3991eaa0c..d0d08896538 100644
--- a/server/src/services/heartbeat/run-control.test.ts
+++ b/server/src/services/heartbeat/run-control.test.ts
@@ -98,7 +98,7 @@ describe("heartbeat run-control module boundary", () => {
it("retains leases while native execution ownership is unverified", async () => {
const database = guardedDatabase();
const deps = callbacks(database.db);
- deps.getRun.mockResolvedValue({ runtimeMode: "native", status: "running", nativePhase: "terminal_failure", errorCode: "native_execution_ownership_unverified" } as Run);
+ deps.getRunCleanupState.mockResolvedValue({ runtimeMode: "native", status: "running", nativePhase: "terminal_failure", errorCode: "native_execution_ownership_unverified" } as Run);
await createHeartbeatRunControl(database.db, deps).releaseEnvironmentLeasesForRun({ runId: "held", companyId: "company", agentId: "agent", status: "failed", providerResourceDisposition: "destroy" });
expect(database.access).not.toHaveBeenCalled();
expect(deps.options.closeWarmNativeSessionsForRun).not.toHaveBeenCalled();
@@ -108,7 +108,7 @@ describe("heartbeat run-control module boundary", () => {
it("uses the persisted terminal outcome when choosing warm resource retention", async () => {
const database = guardedDatabase();
const deps = callbacks(database.db);
- deps.getRun.mockResolvedValue({ status: "failed", runtimeMode: "legacy" } as Run);
+ deps.getRunCleanupState.mockResolvedValue({ status: "failed", runtimeMode: "legacy" } as Run);
await createHeartbeatRunControl(database.db, deps).releaseEnvironmentLeasesForRun({ runId: "failed", companyId: "company", agentId: "agent", status: "succeeded", providerResourceDisposition: "keep_running" });
expect(deps.envOrchestrator.releaseForRun).toHaveBeenCalledWith(expect.objectContaining({ heartbeatRunId: "failed", status: "failed", providerResourceDisposition: "stop_and_retain" }));
expect(database.access).not.toHaveBeenCalled();
@@ -117,7 +117,7 @@ describe("heartbeat run-control module boundary", () => {
it("defers destruction when a warm session remains busy", async () => {
const database = guardedDatabase();
const deps = callbacks(database.db);
- deps.getRun.mockResolvedValue({ status: "failed", runtimeMode: "legacy" } as Run);
+ deps.getRunCleanupState.mockResolvedValue({ status: "failed", runtimeMode: "legacy" } as Run);
deps.options.closeWarmNativeSessionsForRun.mockResolvedValue({ closed: 0, busy: 1, failed: 0 });
await createHeartbeatRunControl(database.db, deps).releaseEnvironmentLeasesForRun({ runId: "busy", companyId: "company", agentId: "agent", status: "failed", providerResourceDisposition: "destroy" });
expect(deps.options.closeWarmNativeSessionsForRun).toHaveBeenCalledWith({ runId: "busy", reason: "terminal heartbeat run destroyed its environment lease" });
diff --git a/server/src/services/heartbeat/run-control.ts b/server/src/services/heartbeat/run-control.ts
index 7f80f99321a..d7086c52508 100644
--- a/server/src/services/heartbeat/run-control.ts
+++ b/server/src/services/heartbeat/run-control.ts
@@ -164,7 +164,7 @@ async function withBudgetStopDelivery(db: Db, work: () => Promise): Promis
/** Cancellation shares executor ownership and delegates lifecycle and admission effects. */
export interface HeartbeatRunControlDependencies extends Pick,
- "getRun" | "getAgent">,
+ "getRun" | "getAgent" | "getRunCleanupState">,
Pick,
"mergeRunStopMetadataForAgent" | "setRunStatusFromLive" | "setRunStatus" | "setWakeupStatus" | "appendRunEvent">,
Pick {
@@ -324,6 +324,7 @@ function runCancellationFenceSql(input: {
export function createHeartbeatRunControl(db: Db, dependencies: HeartbeatRunControlDependencies) {
const {
getRun,
+ getRunCleanupState,
options,
envOrchestrator,
activeRunExecutions,
@@ -359,7 +360,11 @@ export function createHeartbeatRunControl(db: Db, dependencies: HeartbeatRunCont
sandboxResource: "keep_running" | "stop_and_reuse" | "destroy_after_turn";
};
}) {
- const leaseOwnerRun = await getRun(input.runId);
+ let leaseOwnerRun = await getRunCleanupState(input.runId);
+ if (leaseOwnerRun && isNativeRunnerOwnershipHeld(leaseOwnerRun)) return;
+ const cancelledRun = leaseOwnerRun?.runtimeMode === "native" && leaseOwnerRun.status === "cancelled"
+ ? await getRun(input.runId) : null;
+ if (cancelledRun) leaseOwnerRun = cancelledRun;
if (leaseOwnerRun && isNativeRunnerOwnershipHeld(leaseOwnerRun)) return;
// Recovery can finish workspace copy-back outside the executor's finally.
// Successful copy-back does not earn warm retention for a failed turn.
@@ -368,11 +373,11 @@ export function createHeartbeatRunControl(db: Db, dependencies: HeartbeatRunCont
input.providerResourceDisposition,
status,
);
- if (leaseOwnerRun?.runtimeMode === "native" && status === "cancelled"
- && readNativeWorkspaceSyncReference(leaseOwnerRun.runnerProfileJson?.nativeWorkspaceSync)?.state === "prepared"
- && !nativeRunHasNoWorkspaceAccess(leaseOwnerRun)) {
+ if (cancelledRun?.runtimeMode === "native" && status === "cancelled"
+ && readNativeWorkspaceSyncReference(cancelledRun.runnerProfileJson?.nativeWorkspaceSync)?.state === "prepared"
+ && !nativeRunHasNoWorkspaceAccess(cancelledRun)) {
try {
- const preserved = await preserveNativeFailedWorkspace(db, leaseOwnerRun, { allowPreterminal: true });
+ const preserved = await preserveNativeFailedWorkspace(db, cancelledRun, { allowPreterminal: true });
if (!preserved || !hasRequiredWorkspaceRecovery(preserved.resultJson)) return;
} catch {
// Keep the active source eligible for the bounded retention sweep.
@@ -478,13 +483,14 @@ export function createHeartbeatRunControl(db: Db, dependencies: HeartbeatRunCont
async function acknowledgeRemoteStop(runId: string, companyId: string) {
// The provider receipt arrives after adapter settlement. A remote ACP child
// has no host PID, so only this target-aware boundary can acknowledge Stop.
- const stopped = await getRun(runId);
- if (stopped?.runtimeMode === "native") {
+ const state = await getRunCleanupState(runId);
+ if (state?.runtimeMode === "native") {
const scopes = await stoppedRemoteCleanupScopes(db, companyId, runId);
for (const remoteCleanupScope of scopes ?? []) {
completeTerminatedRemoteNativeSessionCleanup({ companyId, runId, remoteCleanupScope });
}
}
+ const stopped = state?.runtimeMode === "legacy" && state.status === "cancelled" ? await getRun(runId) : null;
if (stopped?.runtimeMode === "legacy" && stopped.status === "cancelled" &&
parseObject(stopped.resultJson?.executionCancellation).state === "requested" &&
await runUsedConversationAdapter(db, stopped) &&
diff --git a/server/src/services/heartbeat/run-state.test.ts b/server/src/services/heartbeat/run-state.test.ts
index 4a729328027..ccc91735df4 100644
--- a/server/src/services/heartbeat/run-state.test.ts
+++ b/server/src/services/heartbeat/run-state.test.ts
@@ -1,4 +1,5 @@
import { randomBytes, randomUUID } from "node:crypto";
+import { createDatabaseResponseCapture, installDatabaseResponseCapture } from "@paperclipai/db/response-capture";
import type { Db } from "@paperclipai/db";
import {
agents,
@@ -86,6 +87,9 @@ describe("heartbeat run state module", () => {
expect(projectedSql(utf8.columns[0].resultJson)).toContain("pg_column_size");
await asciiState.getRun("evidence", { includeExecutionEvidence: true });
expect(projectedSql(ascii.columns[2].resultJson)).toContain("startupPreparationSettledAt");
+ await asciiState.getRunWorkspaceSettlement("settlement");
+ expect(projectedSql(ascii.columns[3].error)).toBe("NULL");
+ expect(ascii.execute).toHaveBeenCalledTimes(1);
const raw = readingDatabase("SQL_ASCII");
await extracted.createHeartbeatRunState(raw.db).getRun("raw", { unsafeFullResultJson: true });
expect(raw.execute).not.toHaveBeenCalled();
@@ -154,6 +158,59 @@ describePostgres("heartbeat run state database wiring", () => {
return { companyId, agent, issue, state: extracted.createHeartbeatRunState(db) };
}
+ it("reads cleanup and workspace settlement without transferring saved input or changing the run", async () => {
+ const { companyId, agent, issue, state } = await fixture();
+ const bulk = "cleanup-saved-input-sentinel🙂".repeat(40_000);
+ const sync = { schema: "paperclip.native-workspace-sync.v1", state: "prepared", resourceDisposition: "destroy" };
+ const [run] = await db.insert(heartbeatRuns).values({
+ companyId, agentId: agent.id, issueId: issue.id, runtimeMode: "native", status: "failed",
+ errorCode: "provider_transport_failed", error: "Retain saved files", nativePhase: "terminal_failure",
+ runnerProfileJson: { nativeWorkspaceSync: sync, savedInput: bulk },
+ resultJson: { workspaceExportRetry: { requestId: "retry-source" }, stdout: bulk },
+ contextSnapshot: { timerClaimWasFirstHeartbeat: true, savedInput: bulk },
+ stdoutExcerpt: bulk, stderrExcerpt: bulk,
+ }).returning();
+ await state.getRunWorkspaceSettlement(run.id);
+ const events: Array> = [];
+ const now = Date.now();
+ const capture = createDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event));
+ installDatabaseResponseCapture(db, { heartbeatRuns }, capture);
+ expect(await state.getRunCleanupState(run.id)).toEqual({
+ id: run.id, companyId, agentId: agent.id, runtimeMode: run.runtimeMode,
+ status: run.status, errorCode: run.errorCode, nativePhase: run.nativePhase,
+ });
+ expect(await state.getRunWorkspaceSettlement(run.id)).toEqual({
+ id: run.id, status: run.status, error: run.error,
+ runnerProfileJson: { nativeWorkspaceSync: sync },
+ resultJson: { workspaceExportRetry: { requestId: "retry-source" } },
+ contextSnapshot: { timerClaimWasFirstHeartbeat: true },
+ });
+ capture.flush();
+ const summary = events.find(event => event.event === "paperclip.db_response_capture.summary.v1")!;
+ expect(summary.completedQueries).toBe(2);
+ expect(summary.responseValueBytes).toBeLessThan(2000);
+ expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, run.id)))[0]).toEqual(run);
+ expect(JSON.stringify(events)).not.toContain("cleanup-saved-input-sentinel");
+ expect(await state.getRunCleanupState(randomUUID())).toBeNull();
+ expect(await state.getRunWorkspaceSettlement(randomUUID())).toBeNull();
+ });
+
+ it("preserves JSON types in workspace settlement decisions", async () => {
+ const { companyId, agent, state } = await fixture();
+ for (const value of [null, false, true, "true", 0, [], { future: [1, "retained"] }]) {
+ const [run] = await db.insert(heartbeatRuns).values({
+ companyId, agentId: agent.id,
+ runnerProfileJson: { nativeWorkspaceSync: value },
+ resultJson: { workspaceExportRetry: value },
+ contextSnapshot: { timerClaimWasFirstHeartbeat: value },
+ }).returning({ id: heartbeatRuns.id });
+ const actual = await state.getRunWorkspaceSettlement(run.id);
+ expect(actual?.runnerProfileJson?.nativeWorkspaceSync).toEqual(value);
+ expect(actual?.resultJson?.workspaceExportRetry).toEqual(value);
+ expect(actual?.contextSnapshot?.timerClaimWasFirstHeartbeat).toEqual(value);
+ }
+ });
+
it("retains the consumed queue identity in issue polling summaries", async () => {
const { companyId, agent, issue, state } = await fixture();
const [wake] = await db.insert(agentWakeupRequests).values({
diff --git a/server/src/services/heartbeat/run-state.ts b/server/src/services/heartbeat/run-state.ts
index 30f9ef0b001..1911ad1a25c 100644
--- a/server/src/services/heartbeat/run-state.ts
+++ b/server/src/services/heartbeat/run-state.ts
@@ -1119,6 +1119,28 @@ export function createHeartbeatRunState(db: Db) {
.then((rows) => rows[0] ?? null);
}
+ async function getRunCleanupState(runId: string) {
+ return db.select({
+ id: heartbeatRuns.id, companyId: heartbeatRuns.companyId, agentId: heartbeatRuns.agentId,
+ runtimeMode: heartbeatRuns.runtimeMode, status: heartbeatRuns.status,
+ errorCode: heartbeatRuns.errorCode, nativePhase: heartbeatRuns.nativePhase,
+ }).from(heartbeatRuns).where(eq(heartbeatRuns.id, runId)).then(rows => rows[0] ?? null);
+ }
+
+ async function getRunWorkspaceSettlement(runId: string) {
+ const safeForLegacyEncoding = await hasUnsafeTextProjectionDatabase();
+ return db.select({
+ id: heartbeatRuns.id, status: heartbeatRuns.status,
+ error: safeForLegacyEncoding ? heartbeatRunSqlAsciiSafeColumns.error : heartbeatRuns.error,
+ runnerProfileJson: sql | null>`case when ${heartbeatRuns.runnerProfileJson} is null then null
+ else jsonb_build_object('nativeWorkspaceSync', ${heartbeatRuns.runnerProfileJson}->'nativeWorkspaceSync') end`,
+ resultJson: sql | null>`case when ${heartbeatRuns.resultJson} is null then null
+ else jsonb_build_object('workspaceExportRetry', ${heartbeatRuns.resultJson}->'workspaceExportRetry') end`,
+ contextSnapshot: sql | null>`case when ${heartbeatRuns.contextSnapshot} is null then null
+ else jsonb_build_object('timerClaimWasFirstHeartbeat', ${heartbeatRuns.contextSnapshot}->'timerClaimWasFirstHeartbeat') end`,
+ }).from(heartbeatRuns).where(eq(heartbeatRuns.id, runId)).then(rows => rows[0] ?? null);
+ }
+
async function getRunStatus(runId: string) {
return db
.select({
@@ -1688,6 +1710,8 @@ export function createHeartbeatRunState(db: Db) {
getAgent,
resolveSessionBeforeForWakeup,
getRun,
+ getRunCleanupState,
+ getRunWorkspaceSettlement,
ensureRuntimeState,
getTaskSession,
getLatestAgentConfigRevision,
diff --git a/server/src/services/native-runtime/__tests__/helpers/committed-replay.ts b/server/src/services/native-runtime/__tests__/helpers/committed-replay.ts
new file mode 100644
index 00000000000..7caef7cf216
--- /dev/null
+++ b/server/src/services/native-runtime/__tests__/helpers/committed-replay.ts
@@ -0,0 +1,34 @@
+import { heartbeatRuns, type Db } from "@paperclipai/db";
+import { createDatabaseResponseCapture, installDatabaseResponseCapture } from "@paperclipai/db/response-capture";
+import { eq } from "drizzle-orm";
+import { expect } from "vitest";
+
+export async function verifyCommittedReplayPayloadReads(
+ db: Db,
+ binding: { runId: string; companyId: string; agentId: string },
+ replay: () => Promise,
+) {
+ const { runId, companyId, agentId } = binding;
+ const bulk = "saved-native-input-sentinel🙂".repeat(40_000);
+ const [original] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
+ await db.update(heartbeatRuns).set({
+ contextSnapshot: { ...original.contextSnapshot, savedInput: bulk },
+ runnerProfileJson: { savedInput: bulk, nativeExecutionInput: {
+ schema: "paperclip.native-execution-input.v8", savedInput: bulk,
+ provider: { kind: "muse", binding: { companyId, agentId, bindingId: runId } },
+ } }, stdoutExcerpt: bulk, stderrExcerpt: bulk,
+ }).where(eq(heartbeatRuns.id, runId));
+ const [before] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
+ const events: Array> = [];
+ const now = Date.now();
+ const capture = createDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event));
+ installDatabaseResponseCapture(db, { heartbeatRuns }, capture);
+ try { await replay(); }
+ finally { capture.flush(); }
+ const summary = events.find(event => event.event === "paperclip.db_response_capture.summary.v1")!;
+ expect(summary.completedQueries).toBeGreaterThan(0);
+ expect(summary.responseValueBytes).toBeLessThan(100_000);
+ const [after] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
+ expect(after).toEqual(before);
+ expect(JSON.stringify(events)).not.toContain("saved-native-input-sentinel");
+}
diff --git a/server/src/services/native-runtime/native-run-finalizer-telemetry.test.ts b/server/src/services/native-runtime/native-run-finalizer-telemetry.test.ts
index 0a8b6ce4ed1..2c2e534081b 100644
--- a/server/src/services/native-runtime/native-run-finalizer-telemetry.test.ts
+++ b/server/src/services/native-runtime/native-run-finalizer-telemetry.test.ts
@@ -1,5 +1,6 @@
import { enableRunFailureReporting } from "../../__tests__/helpers/run-failure-reporting.js";
import { randomUUID } from "node:crypto";
+import { verifyCommittedReplayPayloadReads } from "./__tests__/helpers/committed-replay.js";
import { eq } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import {
@@ -34,6 +35,12 @@ const mockTelemetryClient = vi.hoisted(() => ({
}));
vi.mock("../../telemetry.ts", () => ({ getTelemetryClient: () => mockTelemetryClient }));
+const reconcileMuseAssignments = vi.hoisted(() => vi.fn(async () => undefined));
+vi.mock("../muse-runner-broker.js", async importOriginal => ({
+ ...await importOriginal(),
+ museRunnerBroker: () => ({ reconcileTerminalAssignments: reconcileMuseAssignments }),
+}));
+
const mockCaptureRunFailure = vi.hoisted(() => vi.fn());
vi.mock("../../sentry.ts", async () => {
const actual = await vi.importActual("../../sentry.ts");
@@ -261,18 +268,18 @@ describeEmbeddedPostgres("native run finalizer / status decision committer — a
projectRunStatus: true,
});
- const callsBefore = mockTelemetryClient.track.mock.calls.length;
- // The coordinator is already "committed", so this second call takes the
- // projectCommittedRun short-circuit. A succeeded row is eligible so stale
- // cleanup errors can be projected separately; an unchanged terminal state
- // must still never emit a second agent.task_run event.
- await finalizeNativeRun({
- db,
- runId: fixture.runId,
- workspaceFinalizeStatus: "succeeded",
- projectRunStatus: true,
+ await verifyCommittedReplayPayloadReads(db, { runId: fixture.runId, companyId, agentId }, async () => {
+ const callsBefore = mockTelemetryClient.track.mock.calls.length;
+ reconcileMuseAssignments.mockClear();
+ await finalizeNativeRun({
+ db,
+ runId: fixture.runId,
+ workspaceFinalizeStatus: "succeeded",
+ projectRunStatus: true,
+ });
+ expect(agentTaskRunCalls(callsBefore)).toHaveLength(0);
+ expect(reconcileMuseAssignments).toHaveBeenCalledExactlyOnceWith(companyId, agentId, fixture.runId);
});
- expect(agentTaskRunCalls(callsBefore)).toHaveLength(0);
});
it("repairs a stale succeeded-run error once without duplicate telemetry or destroying retained owner evidence", async () => {
diff --git a/server/src/services/native-runtime/native-run-finalizer.ts b/server/src/services/native-runtime/native-run-finalizer.ts
index 8b2ae73c349..7d60d73e0d7 100644
--- a/server/src/services/native-runtime/native-run-finalizer.ts
+++ b/server/src/services/native-runtime/native-run-finalizer.ts
@@ -568,7 +568,7 @@ export async function recordNativeFinalizationFailure(input: {
});
}
-async function resolveCommittedFinalizationRecovery(db: Db, run: typeof heartbeatRuns.$inferSelect, issueId: string) {
+async function resolveCommittedFinalizationRecovery(db: Db, run: Pick, issueId: string) {
const actions = await db.select().from(issueRecoveryActions).where(and(
eq(issueRecoveryActions.companyId, run.companyId), eq(issueRecoveryActions.sourceIssueId, issueId),
eq(issueRecoveryActions.kind, "active_run_watchdog"), inArray(issueRecoveryActions.status, ["active", "escalated"]),
@@ -704,7 +704,8 @@ async function projectCommittedRun(input: {
async function materializeCommittedReviewResponse(db: Db, runId: string) {
try {
const [run] = await db
- .select()
+ .select({ id: heartbeatRuns.id, companyId: heartbeatRuns.companyId, agentId: heartbeatRuns.agentId,
+ nativeIssueId: heartbeatRuns.nativeIssueId, resultJson: heartbeatRuns.resultJson })
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, runId))
.limit(1);
@@ -1146,13 +1147,15 @@ export async function finalizeNativeRun(input: {
preserveProviderAttempt?: boolean;
failpoint?: NativeStatusCommitFailpoint;
}) {
- const run = await input.db
- .select()
+ const admittedRun = await input.db
+ .select({ id: heartbeatRuns.id, companyId: heartbeatRuns.companyId, agentId: heartbeatRuns.agentId,
+ runtimeMode: heartbeatRuns.runtimeMode, status: heartbeatRuns.status, errorCode: heartbeatRuns.errorCode,
+ nativePhase: heartbeatRuns.nativePhase, finishedAt: heartbeatRuns.finishedAt })
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, input.runId))
.limit(1)
.then((rows) => rows[0] ?? null);
- if (!run || run.runtimeMode !== "native")
+ if (!admittedRun || admittedRun.runtimeMode !== "native")
throw new Error("native_finalization_run_missing");
const claim = await claimCoordinator({
db: input.db,
@@ -1161,12 +1164,12 @@ export async function finalizeNativeRun(input: {
});
const coordinator = claim.coordinator;
if (!claim.leaseOwner && coordinator.phase === "committed") {
- if (isNativeRunnerOwnershipHeld(run)) return coordinator;
+ if (isNativeRunnerOwnershipHeld(admittedRun)) return coordinator;
const presentationAlreadyMaterialized = coordinator.decisionId
? await hasMaterializedNativeReviewResponse(input.db, {
- companyId: run.companyId,
+ companyId: admittedRun.companyId,
issueId: coordinator.issueId,
- runId: run.id,
+ runId: admittedRun.id,
decisionId: coordinator.decisionId,
})
: false;
@@ -1178,9 +1181,9 @@ export async function finalizeNativeRun(input: {
) {
await input.db.transaction((tx) =>
restoreNativeChatReviewPresentationInTransaction(tx as unknown as Db, {
- companyId: run.companyId,
+ companyId: admittedRun.companyId,
issueId: coordinator.issueId,
- runId: run.id,
+ runId: admittedRun.id,
decisionId: coordinator.decisionId!,
resultId: coordinator.resultId!,
assessmentId: coordinator.assessmentId!,
@@ -1188,27 +1191,45 @@ export async function finalizeNativeRun(input: {
);
}
if (input.projectRunStatus)
- await projectCommittedRun({ db: input.db, run, coordinator });
+ await projectCommittedRun({ db: input.db, run: admittedRun, coordinator });
if (input.projectRunStatus && !presentationAlreadyMaterialized)
await materializeCommittedReviewResponse(input.db, input.runId);
if (input.projectRunStatus)
await repairCommittedNativeChatResponse(input.db, {
- companyId: run.companyId,
+ companyId: admittedRun.companyId,
issueId: coordinator.issueId,
- runId: run.id,
+ runId: admittedRun.id,
});
if (input.projectRunStatus) {
- await settleExternalConversation(input.db, run.companyId, coordinator.issueId).catch((err) => {
- logger.warn({ err, runId: run.id }, "Slack conversation settlement deferred to reconciliation");
+ await settleExternalConversation(input.db, admittedRun.companyId, coordinator.issueId).catch((err) => {
+ logger.warn({ err, runId: admittedRun.id }, "Slack conversation settlement deferred to reconciliation");
});
}
- await resolveCommittedFinalizationRecovery(input.db, run, coordinator.issueId);
+ await resolveCommittedFinalizationRecovery(input.db, admittedRun, coordinator.issueId);
if (input.projectRunStatus) {
- const [projectedRun] = await input.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, run.id));
+ const [projectedRun] = await input.db.select({
+ id: heartbeatRuns.id, companyId: heartbeatRuns.companyId, agentId: heartbeatRuns.agentId,
+ runtimeMode: heartbeatRuns.runtimeMode, status: heartbeatRuns.status,
+ runnerProfileJson: sql | null>`case when ${heartbeatRuns.runnerProfileJson} is null then null
+ else jsonb_build_object('nativeExecutionInput', jsonb_build_object(
+ 'schema', ${heartbeatRuns.runnerProfileJson} #> '{nativeExecutionInput,schema}',
+ 'provider', jsonb_build_object(
+ 'kind', ${heartbeatRuns.runnerProfileJson} #> '{nativeExecutionInput,provider,kind}',
+ 'binding', jsonb_build_object(
+ 'companyId', ${heartbeatRuns.runnerProfileJson} #> '{nativeExecutionInput,provider,binding,companyId}',
+ 'agentId', ${heartbeatRuns.runnerProfileJson} #> '{nativeExecutionInput,provider,binding,agentId}',
+ 'bindingId', ${heartbeatRuns.runnerProfileJson} #> '{nativeExecutionInput,provider,binding,bindingId}'
+ )
+ )
+ )) end`,
+ }).from(heartbeatRuns).where(eq(heartbeatRuns.id, admittedRun.id));
if (projectedRun) await reconcileMuseNativeTerminalRun(input.db, projectedRun);
}
return coordinator;
}
+ const run = await input.db.select().from(heartbeatRuns)
+ .where(eq(heartbeatRuns.id, input.runId)).limit(1).then(rows => rows[0] ?? null);
+ if (!run || run.runtimeMode !== "native") throw new Error("native_finalization_run_missing");
const [resultRow, contractRow] = await Promise.all([
input.db
.select()
From e82d86bfebe34db8757036316b8997162aaa5b10 Mon Sep 17 00:00:00 2001
From: Dotta <34892728+cryppadotta@users.noreply.github.com>
Date: Sat, 24 Oct 2026 19:01:15 -0700
Subject: [PATCH 07/70] fix(server): gate periodic heartbeat recovery to one
chain in flight (#11699)
Bind periodic recovery admission to lifecycle tracking. Keep one physical pass active through cleanup, report skipped ticks and long-held ownership, and preserve direct scheduled delivery. Full build, typecheck, 32 focused tests, complete CI, and exact-head Greptile5/5 pass.
Co-Authored-By: Senior Engineer Fable
Co-Authored-By: Dotta
Co-Authored-By: Devin Foley
Co-Authored-By: Claude Fable 5
Co-Authored-By: Paperclip
---
doc/native-workspace-finalization-recovery.md | 10 ++
server/src/index.ts | 13 +-
server/src/lib/single-flight.test.ts | 163 ++++++++++++++++++
server/src/lib/single-flight.ts | 51 ++++++
4 files changed, 236 insertions(+), 1 deletion(-)
create mode 100644 server/src/lib/single-flight.test.ts
create mode 100644 server/src/lib/single-flight.ts
diff --git a/doc/native-workspace-finalization-recovery.md b/doc/native-workspace-finalization-recovery.md
index 8b9c004282c..428c32b3fe4 100644
--- a/doc/native-workspace-finalization-recovery.md
+++ b/doc/native-workspace-finalization-recovery.md
@@ -1,5 +1,15 @@
# Native workspace finalization ownership and recovery
+Periodic heartbeat recovery runs one pass at a time in each server process.
+A tick that arrives during a slow pass reports its age and starts no duplicate.
+A later tick can start a fresh pass after the current work settles. Shutdown
+and idle sleep still track the actual recovery promise through cleanup. Direct
+run operations and scheduled work delivery keep their existing paths. A pass
+that remains active for three scheduler intervals produces an error log with its
+age. A waiting timeout does not admit another pass: the actual work must settle
+before ownership is released. A stuck operation requires investigation and
+recovery at the operation or provider boundary.
+
A native provider can fail before it submits a result while its workspace still
contains unpublished edits. A prepared workspace reference is not an export
receipt. Terminal provider failure pins the exact bound sandbox and records a
diff --git a/server/src/index.ts b/server/src/index.ts
index 584e9e67697..e83d603e2aa 100644
--- a/server/src/index.ts
+++ b/server/src/index.ts
@@ -38,6 +38,7 @@ import { chatCompletionDeliveryService } from "./services/chat-completion-delive
// instrumentationReady before opening DB connections or constructing the
// HTTP server, so trace coverage does not depend on incidental timing.
import { instrumentationReady, shutdownInstrumentation } from "./instrumentation.js";
+import { createTrackedSingleFlight } from "./lib/single-flight.js";
import { sentryReady, shutdownSentry, captureException } from "./sentry.js";
import { setSentryRunLinkResolver } from "./sentry-links.js";
import { createSentryRunLinkResolver } from "./services/sentry-run-links.js";
@@ -1209,6 +1210,16 @@ async function startServerWithDatabaseTeardown(
});
heartbeatSchedulerInFlight.add(tracked);
};
+ const runPeriodicRecovery = createTrackedSingleFlight({
+ trackWork: trackHeartbeatSchedulerWork,
+ onSkipped: (inFlightForMs) => {
+ if (inFlightForMs >= 3 * config.heartbeatSchedulerIntervalMs) {
+ logger.error({ inFlightForMs }, "periodic heartbeat recovery remains in flight; duplicate pass suppressed");
+ } else {
+ logger.warn({ inFlightForMs }, "skipped periodic heartbeat recovery tick; previous chain still in flight");
+ }
+ },
+ });
const waitForHeartbeatSchedulerIdle = async () => {
while (heartbeatSchedulerInFlight.size > 0) {
await Promise.allSettled([...heartbeatSchedulerInFlight]);
@@ -1887,7 +1898,7 @@ async function startServerWithDatabaseTeardown(
if (!(await heartbeat.resolveSchedulingSuppression()).suppressed) {
// Periodically reap orphaned runs (5-min staleness threshold) and make sure
// persisted queued work is still being driven forward.
- trackHeartbeatSchedulerWork(heartbeat
+ runPeriodicRecovery(() => heartbeat
.reapOrphanedRuns({ staleThresholdMs: 5 * 60 * 1000 })
.then(() => heartbeat.promoteDueScheduledRetries())
.then(async (promotion) => {
diff --git a/server/src/lib/single-flight.test.ts b/server/src/lib/single-flight.test.ts
new file mode 100644
index 00000000000..06dabc92485
--- /dev/null
+++ b/server/src/lib/single-flight.test.ts
@@ -0,0 +1,163 @@
+import { describe, expect, it, vi } from "vitest";
+import { createSingleFlight, createTrackedSingleFlight } from "./single-flight.js";
+import { beginIdleTrackedWork, idleWorkSnapshot } from "../services/task-admission.js";
+
+function deferred() {
+ let resolve!: () => void;
+ let reject!: (err: unknown) => void;
+ const promise = new Promise((res, rej) => {
+ resolve = res;
+ reject = rej;
+ });
+ return { promise, resolve, reject };
+}
+
+describe("createSingleFlight", () => {
+ it("runs work when idle and reports busy until it settles", async () => {
+ const gate = createSingleFlight();
+ const gap = deferred();
+
+ const first = gate.run(() => gap.promise);
+ expect(first.started).toBe(true);
+ expect(gate.busy).toBe(true);
+
+ gap.resolve();
+ await (first as { settled: Promise }).settled;
+ expect(gate.busy).toBe(false);
+ });
+
+ it("skips a tick while the previous invocation is still in flight", async () => {
+ const clock = { nowMs: 1_000 };
+ const gate = createSingleFlight(() => clock.nowMs);
+ const gap = deferred();
+ const work = vi.fn(() => gap.promise);
+
+ const first = gate.run(work);
+ clock.nowMs = 31_000;
+ const second = gate.run(work);
+
+ expect(second).toEqual({ started: false, inFlightForMs: 30_000 });
+ expect(work).toHaveBeenCalledTimes(1);
+
+ gap.resolve();
+ await (first as { settled: Promise }).settled;
+ });
+
+ it("admits new work after the previous invocation resolves", async () => {
+ const gate = createSingleFlight();
+ const gap = deferred();
+
+ const first = gate.run(() => gap.promise);
+ gap.resolve();
+ await (first as { settled: Promise }).settled;
+
+ const second = gate.run(async () => {});
+ expect(second.started).toBe(true);
+ await (second as { settled: Promise }).settled;
+ });
+
+ it("releases the gate when work rejects and never rejects the settled promise", async () => {
+ const gate = createSingleFlight();
+ const gap = deferred();
+
+ const first = gate.run(() => gap.promise);
+ gap.reject(new Error("chain failed"));
+ await expect((first as { settled: Promise }).settled).resolves.toBeUndefined();
+ expect(gate.busy).toBe(false);
+
+ const second = gate.run(async () => {});
+ expect(second.started).toBe(true);
+ await (second as { settled: Promise }).settled;
+ });
+
+ it("propagates a synchronous throw and leaves the gate free", async () => {
+ const gate = createSingleFlight();
+
+ expect(() =>
+ gate.run(() => {
+ throw new Error("sync failure");
+ }),
+ ).toThrow("sync failure");
+ expect(gate.busy).toBe(false);
+
+ const second = gate.run(async () => {});
+ expect(second.started).toBe(true);
+ await (second as { settled: Promise }).settled;
+ });
+
+ it("keeps the owned pass active after a caller stops waiting", async () => {
+ const gate = createSingleFlight(), work = deferred(), observer = deferred();
+ const first = gate.run(() => work.promise);
+ if (!first.started) throw new Error("initial pass was not admitted");
+ const stopped = Promise.race([first.settled, observer.promise]);
+ observer.reject(new Error("observer stopped"));
+ await expect(stopped).rejects.toThrow("observer stopped");
+ const duplicate = vi.fn(async () => undefined);
+ expect(gate.run(duplicate).started).toBe(false);
+ expect(duplicate).not.toHaveBeenCalled();
+ expect(gate.busy).toBe(true);
+ work.resolve();
+ await first.settled;
+ expect(gate.busy).toBe(false);
+ });
+
+ it("keeps cleanup joined after the main operation completes", async () => {
+ const gate = createSingleFlight(), operation = deferred(), cleanup = deferred();
+ const first = gate.run(() => operation.promise.finally(() => cleanup.promise));
+ if (!first.started) throw new Error("initial pass was not admitted");
+ operation.resolve();
+ await Promise.resolve();
+ expect(gate.busy).toBe(true);
+ const duplicate = vi.fn(async () => undefined);
+ expect(gate.run(duplicate).started).toBe(false);
+ expect(duplicate).not.toHaveBeenCalled();
+ cleanup.resolve();
+ await first.settled;
+ expect(gate.busy).toBe(false);
+ const next = gate.run(duplicate);
+ expect(next.started).toBe(true);
+ if (next.started) await next.settled;
+ expect(duplicate).toHaveBeenCalledTimes(1);
+ });
+
+ it("tracks one periodic pass through scheduler shutdown and idle sleep", async () => {
+ vi.useFakeTimers();
+ const before = idleWorkSnapshot().active;
+ const operation = deferred(), cleanup = deferred();
+ const tracked: Promise[] = [];
+ const trackWork = vi.fn((work: Promise) => {
+ const finish = beginIdleTrackedWork();
+ tracked.push(work.finally(finish));
+ });
+ const onSkipped = vi.fn();
+ const tick = createTrackedSingleFlight({ trackWork, onSkipped });
+ const heartbeat = vi.fn(() => operation.promise.finally(() => cleanup.promise));
+ const timer = setInterval(() => tick(heartbeat), 30_000);
+ try {
+ await vi.advanceTimersByTimeAsync(60_000);
+ expect(heartbeat).toHaveBeenCalledOnce();
+ expect(trackWork).toHaveBeenCalledOnce();
+ expect(onSkipped).toHaveBeenCalledExactlyOnceWith(30_000);
+ clearInterval(timer);
+ let shutdownComplete = false;
+ const shutdown = Promise.all(tracked).then(() => { shutdownComplete = true; });
+ await Promise.resolve();
+ expect(shutdownComplete).toBe(false);
+ expect(idleWorkSnapshot().active).toBe(before + 1);
+ operation.resolve();
+ await Promise.resolve();
+ expect(shutdownComplete).toBe(false);
+ expect(idleWorkSnapshot().active).toBe(before + 1);
+ cleanup.resolve();
+ await shutdown;
+ expect(shutdownComplete).toBe(true);
+ expect(idleWorkSnapshot().active).toBe(before);
+ } finally {
+ clearInterval(timer);
+ operation.resolve();
+ cleanup.resolve();
+ await Promise.all(tracked);
+ vi.useRealTimers();
+ }
+ });
+});
diff --git a/server/src/lib/single-flight.ts b/server/src/lib/single-flight.ts
new file mode 100644
index 00000000000..68dc1b035f4
--- /dev/null
+++ b/server/src/lib/single-flight.ts
@@ -0,0 +1,51 @@
+export interface SingleFlightSkip {
+ started: false;
+ inFlightForMs: number;
+}
+
+export interface SingleFlightStart {
+ started: true;
+ settled: Promise;
+}
+
+export type SingleFlightResult = SingleFlightSkip | SingleFlightStart;
+
+export function createSingleFlight(now: () => number = Date.now) {
+ let inFlight: Promise | null = null;
+ let startedAtMs = 0;
+
+ return {
+ get busy(): boolean {
+ return inFlight !== null;
+ },
+ run(work: () => Promise): SingleFlightResult {
+ if (inFlight) {
+ return { started: false, inFlightForMs: now() - startedAtMs };
+ }
+ startedAtMs = now();
+ const settled = Promise.resolve(work())
+ .then(
+ () => undefined,
+ () => undefined,
+ )
+ .finally(() => {
+ inFlight = null;
+ });
+ inFlight = settled;
+ return { started: true, settled };
+ },
+ };
+}
+
+export function createTrackedSingleFlight(options: {
+ trackWork: (work: Promise) => void;
+ onSkipped: (inFlightForMs: number) => void;
+ now?: () => number;
+}) {
+ const gate = createSingleFlight(options.now);
+ return (work: () => Promise) => {
+ const tick = gate.run(work);
+ if (tick.started) options.trackWork(tick.settled);
+ else options.onSkipped(tick.inFlightForMs);
+ };
+}
From 39e5c8c1c32ee38d7b40d068560e63b2e93320be Mon Sep 17 00:00:00 2001
From: Devin Foley
Date: Sat, 24 Oct 2026 19:01:19 -0700
Subject: [PATCH 08/70] perf(server): narrow committed chat authorization reads
(#17530)
Select current chat authorization fields for committed responses under the existing scoped share lock. Preserve complete evidence for actual retries, source revocation, JSON types, and saved run data. Full build, typecheck, 113 focused tests, complete CI, and exact-head Greptile5/5 pass.
Co-Authored-By: Paperclip
---
doc/native-workspace-finalization-recovery.md | 6 +
.../authorization-run-read.test.ts | 116 ++++++++++++++++++
.../chat-channels/authorization-run-read.ts | 46 +++++++
.../chat-channels/failed-run-retry.ts | 20 +--
4 files changed, 174 insertions(+), 14 deletions(-)
create mode 100644 server/src/services/chat-channels/authorization-run-read.test.ts
create mode 100644 server/src/services/chat-channels/authorization-run-read.ts
diff --git a/doc/native-workspace-finalization-recovery.md b/doc/native-workspace-finalization-recovery.md
index 428c32b3fe4..c888ce1a34f 100644
--- a/doc/native-workspace-finalization-recovery.md
+++ b/doc/native-workspace-finalization-recovery.md
@@ -475,3 +475,9 @@ For a cancelled native run, the file-recovery form also offers an optional
confirmation that no external actions were performed. Leave it unchecked unless
the run history verifies that fact. The default records files without certifying
action outcomes. Changing the selected source clears the evidence and confirmations.
+
+Committed chat response authorization reads current run identity, completion
+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.
diff --git a/server/src/services/chat-channels/authorization-run-read.test.ts b/server/src/services/chat-channels/authorization-run-read.test.ts
new file mode 100644
index 00000000000..3d56b695222
--- /dev/null
+++ b/server/src/services/chat-channels/authorization-run-read.test.ts
@@ -0,0 +1,116 @@
+import { randomBytes, randomUUID } from "node:crypto";
+import { agents, companies, createDb, heartbeatRuns } from "@paperclipai/db";
+import { createDatabaseResponseCapture, installDatabaseResponseCapture } from "@paperclipai/db/response-capture";
+import { eq } from "drizzle-orm";
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js";
+import { readChatAuthorizationRun } from "./authorization-run-read.js";
+
+const support = await getEmbeddedPostgresTestSupport();
+
+describe.skipIf(!support.supported)("chat authorization run reads", () => {
+ let database: Awaited>;
+ let db: ReturnType;
+ let companyId: string;
+ let agentId: string;
+
+ beforeAll(async () => {
+ database = await startEmbeddedPostgresTestDatabase("chat-authorization-read-");
+ db = createDb(database.connectionString);
+ const [company] = await db.insert(companies).values({ name: "Chat authorization fixture", issuePrefix: "CAR" }).returning();
+ const [agent] = await db.insert(agents).values({ companyId: company.id, name: "Chat agent", adapterType: "process" }).returning();
+ companyId = company.id;
+ agentId = agent.id;
+ }, 60_000);
+
+ afterAll(async () => {
+ await db?.$client.end();
+ await database?.cleanup();
+ });
+
+ async function seed(contextSnapshot: unknown = {}) {
+ const [run] = await db.insert(heartbeatRuns).values({
+ companyId, agentId, contextSnapshot: contextSnapshot as Record | null,
+ }).returning();
+ return { companyId, agentId, failedRunId: run.id };
+ }
+
+ it("reads committed authority below the byte budget and keeps complete retry evidence", async () => {
+ const bulk = randomBytes(128 * 1024).toString("base64");
+ const context = {
+ source: "chat:slack", issueId: randomUUID(), interactionId: null, taskKey: "CAR-1",
+ wakeCommentIds: [randomUUID()], wakeCommentId: null,
+ chatFailedRunRetry: { version: 1, actionId: randomUUID(), failedRunId: randomUUID() },
+ retryOfRunId: randomUUID(),
+ };
+ const scope = await seed({ ...context, savedInput: bulk });
+ await db.update(heartbeatRuns).set({ runnerProfileJson: { savedInput: bulk }, stdoutExcerpt: bulk, stderrExcerpt: bulk })
+ .where(eq(heartbeatRuns.id, scope.failedRunId));
+ const [before] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, scope.failedRunId));
+ const events: Array> = [];
+ const now = Date.now();
+ const capture = createDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event));
+ installDatabaseResponseCapture(db, { heartbeatRuns }, capture);
+ try {
+ const selected = await db.transaction(tx => readChatAuthorizationRun(tx, scope, true));
+ expect(selected.kind).toBe("committed");
+ expect(selected.run?.contextSnapshot).toEqual(context);
+ expect(selected.run).not.toHaveProperty("runnerProfileJson");
+ expect(selected.run).not.toHaveProperty("stdoutExcerpt");
+ } finally { capture.flush(); }
+ const summary = events.find(event => event.event === "paperclip.db_response_capture.summary.v1")!;
+ expect(summary.completedQueries).toBeGreaterThan(0);
+ expect(summary.responseValueBytes).toBeLessThan(4096);
+ const retry = await db.transaction(tx => readChatAuthorizationRun(tx, scope, false));
+ expect(retry.kind).toBe("retry");
+ expect(retry.run).toEqual(before);
+ const [after] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, scope.failedRunId));
+ expect(after).toEqual(before);
+ expect(JSON.stringify(events)).not.toContain(bulk.slice(0, 64));
+ });
+
+ it.each([{}, { wakeCommentId: null }, { wakeCommentId: false, wakeCommentIds: "invalid", interactionId: [] }])(
+ "preserves key presence and JSON types in %j", async context => {
+ const scope = await seed(context);
+ expect((await readChatAuthorizationRun(db, scope, true)).run?.contextSnapshot).toEqual(context);
+ },
+ );
+
+ it.each([null, [], "invalid", 42])("handles non-object context %j", async context => {
+ const scope = await seed(context);
+ expect((await readChatAuthorizationRun(db, scope, true)).run?.contextSnapshot).toEqual(context === null ? null : {});
+ expect((await readChatAuthorizationRun(db, scope, false)).run?.contextSnapshot).toEqual(context);
+ });
+
+ it("reads current authority and fails closed for another company or agent", async () => {
+ const scope = await seed({ source: "chat:slack", taskKey: "CAR-1" });
+ expect((await readChatAuthorizationRun(db, scope, true)).run?.contextSnapshot).toEqual({ source: "chat:slack", taskKey: "CAR-1" });
+ await db.update(heartbeatRuns).set({ contextSnapshot: { source: "chat:discord", taskKey: "CAR-2" } })
+ .where(eq(heartbeatRuns.id, scope.failedRunId));
+ expect((await readChatAuthorizationRun(db, scope, true)).run?.contextSnapshot).toEqual({ source: "chat:discord", taskKey: "CAR-2" });
+ for (const committed of [true, false]) {
+ expect((await readChatAuthorizationRun(db, { ...scope, companyId: randomUUID() }, committed)).run).toBeUndefined();
+ expect((await readChatAuthorizationRun(db, { ...scope, agentId: randomUUID() }, committed)).run).toBeUndefined();
+ expect((await readChatAuthorizationRun(db, { ...scope, failedRunId: randomUUID() }, committed)).run).toBeUndefined();
+ }
+ });
+
+ it("preserves nonblocking authorization row locks", async () => {
+ const scope = await seed();
+ let acquired!: () => void, release!: () => void;
+ const ready = new Promise(resolve => { acquired = resolve; });
+ const released = new Promise(resolve => { release = resolve; });
+ const holder = db.transaction(async tx => {
+ await tx.update(heartbeatRuns).set({ status: "running" }).where(eq(heartbeatRuns.id, scope.failedRunId));
+ acquired();
+ await released;
+ });
+ try {
+ await ready;
+ for (const committed of [true, false]) {
+ await expect(db.transaction(tx => readChatAuthorizationRun(tx, scope, committed))).rejects.toMatchObject({ cause: { code: "55P03" } });
+ }
+ } finally { release(); await holder; }
+ expect((await readChatAuthorizationRun(db, scope, true)).run?.status).toBe("running");
+ });
+});
diff --git a/server/src/services/chat-channels/authorization-run-read.ts b/server/src/services/chat-channels/authorization-run-read.ts
new file mode 100644
index 00000000000..3390ba103b7
--- /dev/null
+++ b/server/src/services/chat-channels/authorization-run-read.ts
@@ -0,0 +1,46 @@
+import { heartbeatRuns, type Db } from "@paperclipai/db";
+import { and, eq, sql } from "drizzle-orm";
+
+type Database = Db | Parameters[0]>[0];
+type Scope = { companyId: string; agentId: string; failedRunId: string };
+
+const contextKeys = [
+ "source", "issueId", "interactionId", "taskKey", "wakeCommentIds",
+ "wakeCommentId", "chatFailedRunRetry", "retryOfRunId",
+] as const;
+
+const committedContext = 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(contextKeys.map(key => sql`${key}`), sql`, `)})
+ ) end
+`;
+
+export async function readChatAuthorizationRun(db: Database, scope: Scope, committed: boolean) {
+ const where = and(
+ eq(heartbeatRuns.id, scope.failedRunId),
+ eq(heartbeatRuns.companyId, scope.companyId),
+ eq(heartbeatRuns.agentId, scope.agentId),
+ );
+ if (!committed) {
+ const [run] = await db.select().from(heartbeatRuns).where(where).for("share", { noWait: true });
+ return { kind: "retry" as const, run };
+ }
+ const [run] = await db.select({
+ id: heartbeatRuns.id,
+ companyId: heartbeatRuns.companyId,
+ agentId: heartbeatRuns.agentId,
+ wakeupRequestId: heartbeatRuns.wakeupRequestId,
+ status: heartbeatRuns.status,
+ finishedAt: heartbeatRuns.finishedAt,
+ runtimeMode: heartbeatRuns.runtimeMode,
+ errorCode: heartbeatRuns.errorCode,
+ nativeIssueId: heartbeatRuns.nativeIssueId,
+ completionContractId: heartbeatRuns.completionContractId,
+ retryOfRunId: heartbeatRuns.retryOfRunId,
+ contextSnapshot: committedContext,
+ }).from(heartbeatRuns).where(where).for("share", { noWait: true });
+ return { kind: "committed" as const, run };
+}
diff --git a/server/src/services/chat-channels/failed-run-retry.ts b/server/src/services/chat-channels/failed-run-retry.ts
index 2b5d2c9b026..6310d448a66 100644
--- a/server/src/services/chat-channels/failed-run-retry.ts
+++ b/server/src/services/chat-channels/failed-run-retry.ts
@@ -2,6 +2,7 @@ import { continuingTeamsFilePublication, type ContinuingTeamsFileTransfer } from
import { readTaskChatRetryIntent, taskChatRetryCanAwaitRecovery, taskChatRetryExecutionStopped } from "./task-retry-authority.js";
import { notifyChatActionWork, notifyChatPublicationWork } from "../chat-work-notifications.js";
import { FAILED_RUN_RETRY_STALE_MS, dueChatAction } from "../chat-action-work.js";
+import { readChatAuthorizationRun } from "./authorization-run-read.js";
import {
createHash,
@@ -688,17 +689,8 @@ export function createChatFailedRunRetry(db: Db, options: ChatFailedRunRetryOpti
),
)
.for("share", { noWait: true });
- const [failedRun] = await tx
- .select()
- .from(heartbeatRuns)
- .where(
- and(
- eq(heartbeatRuns.id, input.failedRunId),
- eq(heartbeatRuns.companyId, input.companyId),
- eq(heartbeatRuns.agentId, input.agentId),
- ),
- )
- .for("share", { noWait: true });
+ const evidence = await readChatAuthorizationRun(tx, input, Boolean(input.committedResponse));
+ const failedRun = evidence.run;
if (
!issue ||
issue.assigneeAgentId !== input.agentId ||
@@ -719,7 +711,7 @@ export function createChatFailedRunRetry(db: Db, options: ChatFailedRunRetryOpti
"process_exit",
"timeout",
].includes(failedRun.errorCode ?? "") ||
- (await isPreProviderReviewedChatFailure(tx, failedRun))
+ (evidence.kind === "retry" && await isPreProviderReviewedChatFailure(tx, evidence.run))
)) ||
(!input.publication &&
["backlog", "done", "cancelled"].includes(issue.status))
@@ -729,8 +721,8 @@ export function createChatFailedRunRetry(db: Db, options: ChatFailedRunRetryOpti
companyId: input.companyId, issueId: issue.id, agentId: input.agentId,
sourceRunId: failedRun.id, intentId: input.taskRetryIntentId,
})) throw failedChatRetryDenied();
- if (failedRun.runtimeMode === "native" && !input.committedResponse && !input.taskRetryIntentId)
- await failedNativeRetryCoordinator(tx, failedRun, issue.id);
+ if (failedRun.runtimeMode === "native" && evidence.kind === "retry" && !input.taskRetryIntentId)
+ await failedNativeRetryCoordinator(tx, evidence.run, issue.id);
const context = failedRun.contextSnapshot ?? {};
const provider = CHAT_PROVIDERS.find(
(value) => context.source === `chat:${value}`,
From ef0a8db50252c23ca4a1326a27bbb9441c3bc52c Mon Sep 17 00:00:00 2001
From: Devin Foley
Date: Sat, 24 Oct 2026 19:01:23 -0700
Subject: [PATCH 09/70] perf(native): narrow reconciliation candidate payloads
(#17531)
Select the complete workspace-sync record and native status wake identifier for reconciliation candidates. Preserve candidate policy, JSON types, full later evidence, and stored data. The committed replay regression includes the real reconciliation scan. Full build, typecheck, 114 focused tests, complete CI, and exact-head Greptile5/5 pass.
Co-Authored-By: Paperclip
---
doc/native-workspace-finalization-recovery.md | 5 +++++
.../native-runtime/native-finalization-reconciler.ts | 11 +++++------
.../native-run-finalizer-telemetry.test.ts | 5 +++++
3 files changed, 15 insertions(+), 6 deletions(-)
diff --git a/doc/native-workspace-finalization-recovery.md b/doc/native-workspace-finalization-recovery.md
index c888ce1a34f..3d16ff3aada 100644
--- a/doc/native-workspace-finalization-recovery.md
+++ b/doc/native-workspace-finalization-recovery.md
@@ -19,6 +19,11 @@ cleanup run. This includes descriptor reads that fail inside runtime selection.
preparation failure; another owner or a stale claim prevents cleanup. Settling
that claim and recording file retention share a transaction.
+The reconciliation candidate scan selects the complete workspace-sync record and
+the native status wake identifier from each saved run. It keeps their JSON types
+and reads full evidence later where finalization or recovery needs it. Saved
+execution input, runner profiles, and logs stay in the stored run.
+
The recovery card names the original run and requires evidence that its exact
allocation stopped and its files were recovered. Recording that evidence does
not retry the old turn, certify unknown external actions, change the current task
diff --git a/server/src/services/native-runtime/native-finalization-reconciler.ts b/server/src/services/native-runtime/native-finalization-reconciler.ts
index ec391457b07..283aac76b2d 100644
--- a/server/src/services/native-runtime/native-finalization-reconciler.ts
+++ b/server/src/services/native-runtime/native-finalization-reconciler.ts
@@ -742,8 +742,8 @@ async function reconcileNativeFinalizationsPass(
resultId: nativeRunFinalizations.resultId,
assessmentId: nativeRunFinalizations.assessmentId,
decisionId: nativeRunFinalizations.decisionId,
- runnerProfileJson: heartbeatRuns.runnerProfileJson,
- contextSnapshot: heartbeatRuns.contextSnapshot,
+ nativeWorkspaceSync: sql`${heartbeatRuns.runnerProfileJson}->'nativeWorkspaceSync'`,
+ nativeStatusWakeIntentId: sql`${heartbeatRuns.contextSnapshot}->'nativeStatusWakeIntentId'`,
})
.from(heartbeatRuns)
.innerJoin(nativeRunFinalizations, eq(nativeRunFinalizations.runId, heartbeatRuns.id))
@@ -955,8 +955,7 @@ async function reconcileNativeFinalizationsPass(
if (reviewBarrier && reviewBarrier.status !== "succeeded") continue;
const childCompletionRecipient = {
companyId: row.companyId, issueId: row.issueId, agentId: row.agentId, runId: row.runId,
- sourceIntentId: typeof record(row.contextSnapshot).nativeStatusWakeIntentId === "string"
- ? record(row.contextSnapshot).nativeStatusWakeIntentId as string : null,
+ sourceIntentId: typeof row.nativeStatusWakeIntentId === "string" ? row.nativeStatusWakeIntentId : null,
};
const hasPendingChildCompletion = await hasPendingNativeChildCompletion(db, childCompletionRecipient);
const reassessmentRow = await recordNativeWorkAssessment({
@@ -1095,7 +1094,7 @@ async function reconcileNativeFinalizationsPass(
projectRunStatus: true,
preserveProviderAttempt: Boolean(
readNativeWorkspaceSyncReference(
- record(row.runnerProfileJson).nativeWorkspaceSync,
+ row.nativeWorkspaceSync,
),
),
});
@@ -1153,7 +1152,7 @@ async function reconcileNativeFinalizationsPass(
projectRunStatus: true,
preserveProviderAttempt: Boolean(
readNativeWorkspaceSyncReference(
- record(row.runnerProfileJson).nativeWorkspaceSync,
+ row.nativeWorkspaceSync,
),
),
});
diff --git a/server/src/services/native-runtime/native-run-finalizer-telemetry.test.ts b/server/src/services/native-runtime/native-run-finalizer-telemetry.test.ts
index 2c2e534081b..5d1ed177987 100644
--- a/server/src/services/native-runtime/native-run-finalizer-telemetry.test.ts
+++ b/server/src/services/native-runtime/native-run-finalizer-telemetry.test.ts
@@ -279,6 +279,11 @@ describeEmbeddedPostgres("native run finalizer / status decision committer — a
});
expect(agentTaskRunCalls(callsBefore)).toHaveLength(0);
expect(reconcileMuseAssignments).toHaveBeenCalledExactlyOnceWith(companyId, agentId, fixture.runId);
+ await db.insert(workspaceOperations).values({ companyId, issueId: fixture.issueId,
+ heartbeatRunId: fixture.runId, phase: "workspace_finalize", status: "succeeded" });
+ const reconciled = await reconcileNativeFinalizations(db, [fixture.runId]);
+ expect(reconciled).toContainEqual(expect.objectContaining({ runId: fixture.runId, phase: "committed" }));
+ expect(agentTaskRunCalls(callsBefore)).toHaveLength(0);
});
});
From ac15d883f7baeda25753aa7631c83a8d30d3d1e5 Mon Sep 17 00:00:00 2001
From: Devin Foley
Date: Sat, 24 Oct 2026 19:26:27 -0700
Subject: [PATCH 10/70] fix: preserve remote-tracking history when expiring
nested worktrees (#17535)
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - Nested project repositories are now worktrees of one shared clone
per project repository, and existing standalone clones are converted in
place
> - Conversion keeps the clone's remote-tracking refs as task-owned
branches (`paperclip//remotes/...`) so commits only they hold
survive
> - Expiration then required every task-owned branch, including those,
to be on the remote
> - Remote-tracking refs for branches deleted or force-pushed upstream
never are, so converted checkouts were kept forever
> - This pull request treats those refs the way the standalone-clone
path already does: commits missing upstream go into the restore point
instead of blocking
> - The benefit is that converted workspaces can expire and free disk
without losing any history
## Linked Issues or Issue Description
Refs #17407, #17382, #17289. Bug report described in-PR (bug report
template).
**What happened?**
On one instance, 17 closed workspaces in a two-repository project were
kept on every expiration sweep with
`unpushed_managed_checkout_branches`. Each nested checkout is a
converted worktree whose task-owned refs include
`paperclip//remotes/origin/` for upstream branches since
deleted or force-pushed (dependabot, squashed feature branches). Their
commits are on no remote, so `provenNestedWorktreeHistory` refused. The
task's own branch was fully pushed.
**Expected behavior**
As in the standalone-clone path (#17289), remote-tracking commits
missing upstream are preserved in the restore point, and only genuinely
local branches block expiration.
**Steps to reproduce**
1. Fetch an upstream branch into a standalone nested clone, then delete
that branch upstream.
2. Convert the clone into a worktree (nested clone migration).
3. Close the issue and let expiration inspect it: kept with
`unpushed_managed_checkout_branches`.
**Paperclip version or commit:** `master` at cb47dbb2da.
**Deployment mode:** any.
**Agent adapter(s) involved:** not adapter-specific (core).
## What Changed
- `provenNestedWorktreeHistory` in
`server/src/services/git-workspace-restoration.ts`:
- splits task-owned refs into those under `remotes/` and the
rest;
- still requires the rest (and their reflogs) to be on the remote;
- returns commits under `remotes/` that are missing upstream, and their
reflogs, as `preserve`, which the restore point bundles. This matches
the standalone-clone path.
- `restorePointHistoryDigest` in
`server/src/services/git-restore-point.ts`: for a linked worktree, it
fingerprints the task's own refs and their reflogs instead of the shared
clone's `refs/remotes`. Other tasks change those whenever they fetch,
and they never covered the task-owned remote refs. It loads
`nested-repository-worktrees` lazily to avoid an import cycle.
No code comments were added.
## Verification
- `pnpm exec vitest run nested-clone-migration` (in `server/`). New
cases:
- A converted checkout whose only extra history is a remote-tracking
commit deleted upstream expires with a restore point. After its refs are
removed and the shared clone is garbage-collected, reopening restores
the commit.
- The digest ignores the shared clone's remote refs and changes when the
task's own refs move.
- **Mutation checks**, each failing its test:
- reverting the prover split;
- dropping the preserve list;
- reverting the digest change.
- The existing case where a converted checkout with a genuinely unpushed
branch is kept still passes.
- `pnpm exec vitest run git-restore-point nested-repository-worktrees
nested-clone-migration git-workspace-expiration
git-reproducible-managed-checkouts isolated-task-directory
isolated-repository-rehome git-credentials execution-workspace` (in
`server/`): 591 passed.
- `pnpm run typecheck` (in `server/`): pass.
- Not run: server build and boot smoke.
## Risks
- **Misclassified branches:** a local branch literally named
`remotes/...` in a standalone clone becomes `remotes/...` after
conversion and is now preserved in the restore point instead of blocking
expiration. Its commits are still saved, not lost.
- **Leftover refs:** after expiry, the task's other owned refs stay in
the shared clone, as they did before this change, so their commits stay
in two places.
## Model Used
- Claude Opus 5.5 (`claude-opus-5-5`), 1M context, through Claude Code
with tool use (shell, file edits, tests, read-only diagnosis on a live
instance).
## 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 (e.g. `docs/...`, `fix/...`)
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
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [ ] I will address all Greptile and reviewer comments before
requesting merge
---------
Co-authored-by: Paperclip
---
.../__tests__/nested-clone-migration.test.ts | 37 ++++++++++++++++++-
server/src/services/git-restore-point.ts | 18 +++++++--
.../src/services/git-workspace-restoration.ts | 15 +++++---
.../services/nested-repository-worktrees.ts | 1 +
4 files changed, 62 insertions(+), 9 deletions(-)
diff --git a/server/src/__tests__/nested-clone-migration.test.ts b/server/src/__tests__/nested-clone-migration.test.ts
index e77222c4b9e..b417a4ce897 100644
--- a/server/src/__tests__/nested-clone-migration.test.ts
+++ b/server/src/__tests__/nested-clone-migration.test.ts
@@ -14,7 +14,7 @@ import { realizeExecutionWorkspace } from "../services/workspace-runtime.js";
import { executionWorkspaceService } from "../services/execution-workspaces.js";
import { prepareProjectRepositoryWorkspaces } from "../services/heartbeat/workspaces.js";
import { expireReconstructableGitWorkspace } from "../services/git-workspace-expiration.js";
-import { currentRestorePointSnapshot } from "../services/git-restore-point.js";
+import { currentRestorePointSnapshot, restorePointHistoryDigest } from "../services/git-restore-point.js";
import { nestedRepositoryBaseCandidates, nestedWorktreeBranchPrefix, readLinkedNestedCheckout, withNestedRepositoryBaseLock } from "../services/nested-repository-worktrees.js";
import { listNestedCloneMigrationNames, migrateNestedRepositoryClone, nextNestedCloneMigrationBatch, type NestedCloneMigrationOptions } from "../services/nested-clone-migration.js";
import { getStorageService } from "../storage/index.js";
@@ -225,6 +225,41 @@ describeEmbeddedPostgres("nested repository clone migration", () => {
expect(git(f.checkout, "cat-file", "-t", feature)).toBe("commit");
});
+ it("expires a converted checkout whose only extra history is remote-tracking and restores that history", async () => {
+ const f = await fixture({ issueStatus: "done" });
+ git(f.nested.author, "checkout", "-q", "-b", "squashed-away");
+ await fs.writeFile(path.join(f.nested.author, "feature.txt"), "feature\n");
+ git(f.nested.author, "add", "."); git(f.nested.author, "commit", "-qm", "Feature");
+ const feature = git(f.nested.author, "rev-parse", "HEAD");
+ git(f.nested.author, "push", "-q", f.nested.remote, "squashed-away");
+ git(f.checkout, "fetch", "-q", "origin");
+ git(f.nested.remote, "branch", "-D", "squashed-away");
+ git(f.nested.remote, "reflog", "expire", "--expire=now", "--all");
+ git(f.nested.remote, "gc", "-q", "--prune=now");
+ expect(await f.migrate()).toMatchObject({ outcome: "migrated" });
+
+ expect(await f.expire()).toMatchObject({ expired: true, restorePoint: true, skipReason: null });
+ for (const line of ownedRefs(f.base, f.prefix).split("\n").filter(Boolean)) git(f.base, "update-ref", "-d", line.split(" ")[0]!);
+ git(f.base, "reflog", "expire", "--expire=now", "--all");
+ git(f.base, "gc", "-q", "--prune=now");
+ expect(() => git(f.base, "cat-file", "-e", `${feature}^{commit}`)).toThrow();
+ expect(await f.reopen()).toMatchObject({ ok: true, reopened: true });
+ expect(git(f.checkout, "cat-file", "-t", feature)).toBe("commit");
+ });
+
+ it("fingerprints a converted checkout's own history rather than the shared clone's remote refs", async () => {
+ const f = await fixture();
+ expect(await f.migrate()).toMatchObject({ outcome: "migrated" });
+ const head = git(f.checkout, "rev-parse", "HEAD");
+ const before = await restorePointHistoryDigest(f.checkout);
+ git(f.base, "update-ref", "refs/remotes/origin/another-task", head);
+ expect(await restorePointHistoryDigest(f.checkout)).toBe(before);
+ git(f.base, "update-ref", `refs/heads/${f.prefix}remotes/origin/extra`, head);
+ expect(await restorePointHistoryDigest(f.checkout)).not.toBe(before);
+ for (let index = 0; index < 64; index++) git(f.base, "update-ref", `refs/heads/${f.prefix}remotes/origin/bulk-${index}`, head);
+ await expect(restorePointHistoryDigest(f.checkout)).rejects.toThrow("managed_checkout_local_tip_budget");
+ });
+
it("finishes an interrupted conversion after the workspace is archived and forgets one whose workspace is gone", async () => {
const f = await fixture();
const { unpushed } = await nestedWork(f.checkout);
diff --git a/server/src/services/git-restore-point.ts b/server/src/services/git-restore-point.ts
index 698b1395ade..3af9997b693 100644
--- a/server/src/services/git-restore-point.ts
+++ b/server/src/services/git-restore-point.ts
@@ -595,9 +595,21 @@ export async function restorePointHistoryDigest(input: string, signal?: AbortSig
const cwd = await fs.realpath(input);
const reflog = await gitText(cwd, ["reflog", "show", "--format=%H", "HEAD"], { signal }).catch(() => "");
const original = await gitText(cwd, ["rev-parse", "--verify", "-q", "ORIG_HEAD^{commit}"], { signal }).catch(() => "");
- const remotes = await gitText(cwd, ["for-each-ref", "--format=%(objectname) %(refname)", "refs/remotes"], { signal }).catch(() => "");
- const remoteReflogs = (await gitText(cwd, ["reflog", "show", "--all", "--format=%H %gD"], { signal }).catch(() => ""))
- .split("\n").filter((line) => / refs\/remotes\//.test(line));
+ const linked = (await fs.lstat(path.join(cwd, ".git")).catch(() => null))?.isFile() ?? false;
+ const nested = linked ? await import("./nested-repository-worktrees.js") : null;
+ const scope = nested ? `refs/heads/${await nested.nestedWorktreeBranchPrefix(cwd)}` : "refs/remotes";
+ const remotes = await gitText(cwd, ["for-each-ref", ...nested ? [`--count=${nested.OWNED_NESTED_REF_LIMIT + 1}`] : [],
+ "--format=%(objectname) %(refname)", scope], { signal }).catch(() => "");
+ const remoteReflogs: string[] = [];
+ if (nested) {
+ if (remotes.split("\n").filter(Boolean).length > nested.OWNED_NESTED_REF_LIMIT) throw new Error("managed_checkout_local_tip_budget");
+ for (const ref of remotes.split("\n").map((line) => line.split(" ")[1]).filter(Boolean)) {
+ remoteReflogs.push(...(await gitText(cwd, ["reflog", "show", "--format=%H", ref!], { signal }).catch(() => "")).split("\n").filter((line) => SHA.test(line)).map((sha) => `${sha} ${ref}`));
+ }
+ } else {
+ remoteReflogs.push(...(await gitText(cwd, ["reflog", "show", "--all", "--format=%H %gD"], { signal }).catch(() => ""))
+ .split("\n").filter((line) => / refs\/remotes\//.test(line)));
+ }
return createHash("sha256").update(JSON.stringify([reflog.split("\n").filter((line) => SHA.test(line)), SHA.test(original) ? original : null,
remotes.split("\n").filter(Boolean), remoteReflogs])).digest("hex");
}
diff --git a/server/src/services/git-workspace-restoration.ts b/server/src/services/git-workspace-restoration.ts
index f71c1851e7c..5aacb475a80 100644
--- a/server/src/services/git-workspace-restoration.ts
+++ b/server/src/services/git-workspace-restoration.ts
@@ -18,7 +18,7 @@ import { ensureGitRestorePointCommit, validateGitRestorePointRef, type GitRestor
import { getStorageService } from "../storage/index.js";
import type { StorageService } from "../storage/types.js";
import { PROJECT_REPOSITORIES_DIR } from "@paperclipai/adapter-utils/git-workspace-sync";
-import { forgetExpiredNestedRepositoryWorktree, nestedRepositoryBaseCandidates, readLinkedNestedCheckout, recordedNestedRepositoryBase, type LinkedNestedCheckout } from "./nested-repository-worktrees.js";
+import { OWNED_NESTED_REF_LIMIT, forgetExpiredNestedRepositoryWorktree, nestedRepositoryBaseCandidates, readLinkedNestedCheckout, recordedNestedRepositoryBase, type LinkedNestedCheckout } from "./nested-repository-worktrees.js";
const execFile = promisify(execFileCallback);
const shaPattern = /^[a-f0-9]{40}$/;
@@ -249,7 +249,6 @@ async function assertCommitMessageSaved(cwd: string, gitDir: string, signal?: Ab
if (!saved.includes(message.trim())) throw new Error("uncommitted_git_message");
}
-const OWNED_NESTED_REF_LIMIT = 64;
async function ownedNestedRefs(linked: LinkedNestedCheckout, signal?: AbortSignal) {
const refs = (await gitCacheCommand(linked.checkout, ["for-each-ref", `--count=${OWNED_NESTED_REF_LIMIT + 1}`,
@@ -380,9 +379,13 @@ async function provenNestedWorktreeHistory(linked: LinkedNestedCheckout, repoUrl
const covered = new Set([head,
...shas(await gitCacheCommand(checkout, ["reflog", "show", "--format=%H", "HEAD"], signal).catch(() => "")),
...shas(await gitCacheCommand(checkout, ["rev-parse", "--verify", "-q", "ORIG_HEAD^{commit}"], signal).catch(() => ""))]);
- const owned = await ownedNestedRefs(linked, signal);
+ const allOwned = await ownedNestedRefs(linked, signal);
+ const remotePrefix = `refs/heads/${linked.branchPrefix}remotes/`;
+ const owned = allOwned.filter(([, ref]) => !ref.startsWith(remotePrefix));
+ const remoteTracked = [...new Set([...allOwned.filter(([, ref]) => ref.startsWith(remotePrefix)).map(([sha]) => sha),
+ ...await ownedNestedReflogs(linked, allOwned.filter(([, ref]) => ref.startsWith(remotePrefix)), signal)])];
const recorded = new Set([...owned.map(([sha]) => sha), ...await ownedNestedReflogs(linked, owned, signal)]);
- if (recorded.size + covered.size > MANAGED_CHECKOUT_LOCAL_TIP_LIMIT) throw new Error("managed_checkout_local_tip_budget");
+ if (recorded.size + covered.size + remoteTracked.length > MANAGED_CHECKOUT_LOCAL_TIP_LIMIT) throw new Error("managed_checkout_local_tip_budget");
const current = (await gitCacheCommand(checkout, ["symbolic-ref", "--quiet", "HEAD"], signal).catch(() => "")).trim();
const otherTips = owned.filter(([, ref]) => ref !== current).map(([sha]) => sha);
if (otherTips.length && await hasCommitsMissingUpstream(checkout, otherTips, repoUrl, resolveGitAuth, signal, [head])) {
@@ -393,7 +396,9 @@ async function provenNestedWorktreeHistory(linked: LinkedNestedCheckout, repoUrl
throw new Error("unpushed_managed_checkout_branches");
}
const headOnRemote = !await hasCommitsMissingUpstream(checkout, [head], repoUrl, resolveGitAuth, signal);
- return { headOnRemote, preserve: [] };
+ const preserve = remoteTracked.length
+ ? await tipsMissingUpstream(checkout, remoteTracked, repoUrl, resolveGitAuth, signal, [...covered, ...recorded]) : [];
+ return { headOnRemote, preserve };
}
async function tipsMissingUpstream(checkout: string, tips: string[], repoUrl: string,
diff --git a/server/src/services/nested-repository-worktrees.ts b/server/src/services/nested-repository-worktrees.ts
index 8a2969ec2f3..ad907159cc3 100644
--- a/server/src/services/nested-repository-worktrees.ts
+++ b/server/src/services/nested-repository-worktrees.ts
@@ -15,6 +15,7 @@ import { sanitizeRuntimeServiceBaseEnv } from "./runtime-service-env.js";
const execFile = promisify(execFileCallback);
const NESTED_WORKTREE_GIT_TIMEOUT_MS = 10 * 60 * 1000;
const NESTED_WORKTREE_BRANCH = /^paperclip\/[a-f0-9]{12}\/./;
+export const OWNED_NESTED_REF_LIMIT = 64;
export const DETACHED_REPOSITORIES_DIR = path.join(".paperclip-runtime", "detached-repositories");
export type LinkedNestedCheckout = { checkout: string; base: string; gitDir: string; admin: string; branchPrefix: string };
From e0855fe92ad02250d3a289543e6900a050ccf411 Mon Sep 17 00:00:00 2001
From: Devin Foley
Date: Sat, 24 Oct 2026 19:32:51 -0700
Subject: [PATCH 11/70] perf(wake-queue): narrow native release reads (#17539)
Native wake-queue release reads omit saved profiles and excerpts. Legacy release reads keep their complete inputs. Ownership, cleanup, and file retention checks remain unchanged.
Verified with 275 wake-queue tests, the full build, and typecheck. All CI checks pass. Greptile scored the exact head 5/5 with Medium impact and no unresolved comments.
Co-Authored-By: Paperclip
---
doc/native-workspace-finalization-recovery.md | 6 ++
.../wake-queue/adapters/postgres.test.ts | 9 ++-
.../modules/wake-queue/adapters/postgres.ts | 27 +++----
.../adapters/release-run-read.test.ts | 74 +++++++++++++++++++
.../wake-queue/adapters/release-run-read.ts | 22 ++++++
5 files changed, 117 insertions(+), 21 deletions(-)
create mode 100644 server/src/modules/wake-queue/adapters/release-run-read.test.ts
create mode 100644 server/src/modules/wake-queue/adapters/release-run-read.ts
diff --git a/doc/native-workspace-finalization-recovery.md b/doc/native-workspace-finalization-recovery.md
index 3d16ff3aada..51d174bb3e7 100644
--- a/doc/native-workspace-finalization-recovery.md
+++ b/doc/native-workspace-finalization-recovery.md
@@ -24,6 +24,12 @@ the native status wake identifier from each saved run. It keeps their JSON types
and reads full evidence later where finalization or recovery needs it. Saved
execution input, runner profiles, and logs stay in the stored run.
+Wake-queue release reads keep complete context and result receipts. Native runs
+omit runner profiles and log excerpts from this read; cleanup and execution
+read their own current evidence. Legacy runs retain the full release row so
+workspace-restore and pre-dispatch proofs keep their existing inputs. The
+selection is one scoped database query for either runtime.
+
The recovery card names the original run and requires evidence that its exact
allocation stopped and its files were recovered. Recording that evidence does
not retry the old turn, certify unknown external actions, change the current task
diff --git a/server/src/modules/wake-queue/adapters/postgres.test.ts b/server/src/modules/wake-queue/adapters/postgres.test.ts
index 16479253a2f..6a8c360e22d 100644
--- a/server/src/modules/wake-queue/adapters/postgres.test.ts
+++ b/server/src/modules/wake-queue/adapters/postgres.test.ts
@@ -206,7 +206,7 @@ describeEmbeddedPostgres("wake-queue postgres adapter", () => {
return { recorded, rows };
}
- it("keeps in-flight work and every recovery receipt while omitting output from release reads", async () => {
+ it.each(["legacy", "native"])("keeps %s in-flight work and every recovery receipt while omitting output from release reads", async runtimeMode => {
const companyId = await seedCompany();
const agentId = await seedAgent({ companyId });
const issueId = await seedIssue({ companyId, assigneeAgentId: agentId });
@@ -218,7 +218,9 @@ describeEmbeddedPostgres("wake-queue postgres adapter", () => {
nativeCancellation: { schema: "paperclip.native-cancellation.v1", runId, companyId, issueId },
executionCancellation: { state: "acknowledged" }, executionRecovery: { kind: "interrupted", providerStopped: true },
configurationIncomplete: { reason: "workspace_unavailable" }, unknownFutureReceipt: { retained: true } };
- await db.update(heartbeatRuns).set({ resultJson: { ...evidence, stdout: payload.repeat(3), stderr: payload } }).where(eq(heartbeatRuns.id, runId));
+ await db.update(heartbeatRuns).set({ runtimeMode,
+ ...(runtimeMode === "native" ? { runnerProfileJson: { savedInput: payload }, stdoutExcerpt: payload, stderrExcerpt: payload } : {}),
+ resultJson: { ...evidence, stdout: payload.repeat(3), stderr: payload } }).where(eq(heartbeatRuns.id, runId));
await db.update(issues).set({ executionRunId: runId, checkoutRunId: runId }).where(eq(issues.id, issueId));
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
const [issue] = await db.select().from(issues).where(eq(issues.id, issueId));
@@ -230,7 +232,8 @@ describeEmbeddedPostgres("wake-queue postgres adapter", () => {
);
expect(result.outcome.kind).toBe("released");
expect(rows).toHaveLength(1);
- expect(rows[0]).toEqual([{ ...run, resultJson: evidence }]);
+ expect(rows[0]).toEqual([{ ...run, resultJson: evidence,
+ ...(runtimeMode === "native" ? { runnerProfileJson: null, stdoutExcerpt: null, stderrExcerpt: null } : {}) }]);
expect(Buffer.byteLength(JSON.stringify(rows))).toBeLessThan(8 * 1024);
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId)))[0]).toEqual(run);
expect((await db.select().from(issues).where(eq(issues.id, issueId)))[0]).toEqual(issue);
diff --git a/server/src/modules/wake-queue/adapters/postgres.ts b/server/src/modules/wake-queue/adapters/postgres.ts
index 6db54a82725..cd2f1aa7a48 100644
--- a/server/src/modules/wake-queue/adapters/postgres.ts
+++ b/server/src/modules/wake-queue/adapters/postgres.ts
@@ -5,7 +5,8 @@ import { isCompletedOnboardingHandoffWake } from "../../../services/chat-complet
import { instanceSettingsService } from "../../../services/instance-settings.js";
import { currentConversationCommentCondition } from "../../../services/agent-conversations.js";
import { getExecutionBlocker, hasPendingRunCleanup } from "../../../services/execution-blocker.js";
-import { and, asc, eq, getTableColumns, inArray, isNull, notInArray, or, sql } from "drizzle-orm";
+import { and, asc, eq, inArray, isNull, notInArray, or, sql } from "drizzle-orm";
+import { readReleaseRun, type ReleaseRun } from "./release-run-read.js";
import type { Db } from "@paperclipai/db";
import { extractIssueReferenceIdentifiers, isUuidLike } from "@paperclipai/shared";
import {
@@ -74,14 +75,6 @@ const EXECUTION_PATH_HEARTBEAT_RUN_STATUSES = ["queued", "running", "scheduled_r
type HeartbeatRunRow = typeof heartbeatRuns.$inferSelect;
type IssueRow = typeof issues.$inferSelect;
-const releaseRunFields = {
- ...getTableColumns(heartbeatRuns),
- resultJson: sql | null>`case
- when jsonb_typeof(${heartbeatRuns.resultJson}) = 'object'
- then ${heartbeatRuns.resultJson} - 'stdout' - 'stderr'
- else ${heartbeatRuns.resultJson} end`,
-};
-
function normalizeAgentNameKey(value: string | null | undefined): string | null {
if (typeof value !== "string") return null;
const normalized = value.trim().toLowerCase();
@@ -94,7 +87,7 @@ function toRequestedByActorType(value: string | null): "user" | "agent" | "syste
return value === "user" || value === "agent" || value === "system" ? value : null;
}
-function toRunSnapshot(row: HeartbeatRunRow): RunSnapshot {
+function toRunSnapshot(row: ReleaseRun): RunSnapshot {
const configurationIncompletePayload = parseObject(parseObject(row.resultJson).configurationIncomplete);
return {
id: row.id,
@@ -206,7 +199,7 @@ function buildHost(_tx: Db, deps: WakeQueuePostgresAdapterDeps): WakeQueueHost {
};
}
-function buildTransaction(tx: Db, deps: WakeQueuePostgresAdapterDeps, db: Db, run: HeartbeatRunRow): WakeQueueTransaction {
+function buildTransaction(tx: Db, deps: WakeQueuePostgresAdapterDeps, db: Db, run: ReleaseRun): WakeQueueTransaction {
const treeControlSvc = issueTreeControlService(tx);
const issuesSvc = issueService(tx);
const interruptQueueId = run.runtimeMode !== "native" && run.status === "cancelled"
@@ -750,7 +743,7 @@ function buildTransaction(tx: Db, deps: WakeQueuePostgresAdapterDeps, db: Db, ru
};
}
-async function recordNativeTerminalRecoveryIfNeeded(tx: Db, run: HeartbeatRunRow, issue: IssueRow, now: Date): Promise {
+async function recordNativeTerminalRecoveryIfNeeded(tx: Db, run: ReleaseRun, issue: IssueRow, now: Date): Promise {
const applies =
run.runtimeMode === "native" &&
["failed", "timed_out", "interrupted", "cancelled"].includes(run.status) &&
@@ -1058,11 +1051,8 @@ export function createPostgresWakeQueueAdapter(db: Db, deps: WakeQueuePostgresAd
async withIssueExecutionLock(input, fn): Promise {
return db.transaction(async (rawTx) => {
const tx = rawTx as unknown as Db;
- const run = await tx
- .select(releaseRunFields)
- .from(heartbeatRuns)
- .where(and(eq(heartbeatRuns.id, input.runId), eq(heartbeatRuns.companyId, input.companyId)))
- .then((rows) => rows[0] ?? null);
+ const evidence = await readReleaseRun(tx, input);
+ const run = evidence.run;
if (!run) {
throw new Error(`wake-queue: run ${input.runId} was not found while releasing issue execution`);
}
@@ -1164,7 +1154,8 @@ export function createPostgresWakeQueueAdapter(db: Db, deps: WakeQueuePostgresAd
issueStatus: issueRow?.status ?? "",
hasAssigneeUser: Boolean(issueRow?.assigneeUserId),
assigneeAgentMatchesRunAgent: issueRow?.assigneeAgentId === run.agentId,
- legacyExecutionNeedsReconciliation: await legacyExecutionNeedsReconciliationWithEvidence(tx as unknown as Db, run),
+ legacyExecutionNeedsReconciliation: evidence.kind === "complete" &&
+ await legacyExecutionNeedsReconciliationWithEvidence(tx as unknown as Db, evidence.run),
// An operator stop never promotes old queued work by itself. The
// next explicit wake adopts those messages atomically when it
// queues a run.
diff --git a/server/src/modules/wake-queue/adapters/release-run-read.test.ts b/server/src/modules/wake-queue/adapters/release-run-read.test.ts
new file mode 100644
index 00000000000..e4129e6c692
--- /dev/null
+++ b/server/src/modules/wake-queue/adapters/release-run-read.test.ts
@@ -0,0 +1,74 @@
+import { randomBytes, randomUUID } from "node:crypto";
+import { agents, companies, createDb, heartbeatRuns } from "@paperclipai/db";
+import { createDatabaseResponseCapture, installDatabaseResponseCapture } from "@paperclipai/db/response-capture";
+import { eq } from "drizzle-orm";
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../../../__tests__/helpers/embedded-postgres.js";
+import { readReleaseRun } from "./release-run-read.js";
+
+const support = await getEmbeddedPostgresTestSupport();
+
+describe.skipIf(!support.supported)("wake release run reads", () => {
+ let database: Awaited>;
+ let db: ReturnType;
+ let companyId: string;
+ let agentId: string;
+
+ beforeAll(async () => {
+ database = await startEmbeddedPostgresTestDatabase("wake-release-read-");
+ db = createDb(database.connectionString);
+ const [company] = await db.insert(companies).values({ name: "Wake release fixture", issuePrefix: "WRR" }).returning();
+ const [agent] = await db.insert(agents).values({ companyId: company.id, name: "Release agent", adapterType: "process" }).returning();
+ companyId = company.id;
+ agentId = agent.id;
+ }, 60_000);
+
+ afterAll(async () => {
+ await db?.$client.end();
+ await database?.cleanup();
+ });
+
+ it("omits native bulk at the driver boundary and preserves the complete legacy fallback", async () => {
+ const bulk = randomBytes(128 * 1024).toString("base64");
+ const context = { issueId: randomUUID(), opaqueFutureContext: { savedInput: "keep this input" }, explicitUserContinuation: true };
+ const evidence = { nativeCancellation: { schema: "paperclip.native-cancellation.v1", acknowledged: true }, unknownFutureReceipt: [true, null, 42] };
+ const [before] = await db.insert(heartbeatRuns).values({
+ companyId, agentId, runtimeMode: "native", status: "running", contextSnapshot: context,
+ runnerProfileJson: { nativeExecutionInput: { savedInput: bulk } }, stdoutExcerpt: bulk, stderrExcerpt: bulk,
+ resultJson: { ...evidence, stdout: bulk, stderr: bulk },
+ }).returning();
+ const scope = { companyId, runId: before.id };
+ const events: Array> = [];
+ const now = Date.now();
+ const capture = createDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event));
+ installDatabaseResponseCapture(db, { heartbeatRuns }, capture);
+ try {
+ const native = await db.transaction(tx => readReleaseRun(tx as unknown as typeof db, scope));
+ expect(native.kind).toBe("native");
+ expect(native.run?.contextSnapshot).toEqual(context);
+ expect(native.run?.resultJson).toEqual(evidence);
+ expect(native.run).not.toHaveProperty("runnerProfileJson");
+ expect(native.run).not.toHaveProperty("stdoutExcerpt");
+ expect(native.run).not.toHaveProperty("stderrExcerpt");
+ } finally { capture.flush(); }
+ const summary = events.find(event => event.event === "paperclip.db_response_capture.summary.v1")!;
+ expect(summary.completedQueries).toBe(1);
+ expect(summary.responseValueBytes).toBeLessThan(8192);
+ expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, before.id)))[0]).toEqual(before);
+ expect(JSON.stringify(events)).not.toContain(bulk.slice(0, 64));
+ await db.update(heartbeatRuns).set({ runtimeMode: "legacy" }).where(eq(heartbeatRuns.id, before.id));
+ const legacy = await readReleaseRun(db, scope);
+ expect(legacy.kind).toBe("complete");
+ expect(legacy.run).toEqual({ ...before, runtimeMode: "legacy", resultJson: evidence });
+ expect((await readReleaseRun(db, { ...scope, companyId: randomUUID() })).run).toBeUndefined();
+ expect((await readReleaseRun(db, { companyId, runId: randomUUID() })).run).toBeUndefined();
+ });
+
+ it.each([null, ["stdout", { stderr: "evidence" }], "scalar", 42].map(result => ({ result })))(
+ "preserves non-object native result evidence $result", async ({ result }) => {
+ const [run] = await db.insert(heartbeatRuns).values({ companyId, agentId, runtimeMode: "native",
+ resultJson: result as Record | null }).returning();
+ expect((await readReleaseRun(db, { companyId, runId: run.id })).run?.resultJson).toEqual(result);
+ },
+ );
+});
diff --git a/server/src/modules/wake-queue/adapters/release-run-read.ts b/server/src/modules/wake-queue/adapters/release-run-read.ts
new file mode 100644
index 00000000000..cc8bcf80388
--- /dev/null
+++ b/server/src/modules/wake-queue/adapters/release-run-read.ts
@@ -0,0 +1,22 @@
+import { heartbeatRuns, type Db } from "@paperclipai/db";
+import { and, eq, getTableColumns, sql } from "drizzle-orm";
+
+type Run = typeof heartbeatRuns.$inferSelect;
+export type ReleaseRun = Omit;
+
+export async function readReleaseRun(db: Db, scope: { companyId: string; runId: string }) {
+ const [selected] = await db.select({
+ ...getTableColumns(heartbeatRuns),
+ runnerProfileJson: sql`case when ${heartbeatRuns.runtimeMode} = 'native'
+ then null else ${heartbeatRuns.runnerProfileJson} end`,
+ stdoutExcerpt: sql`case when ${heartbeatRuns.runtimeMode} = 'native'
+ then null else ${heartbeatRuns.stdoutExcerpt} end`,
+ stderrExcerpt: sql`case when ${heartbeatRuns.runtimeMode} = 'native'
+ then null else ${heartbeatRuns.stderrExcerpt} end`,
+ resultJson: sql`case when jsonb_typeof(${heartbeatRuns.resultJson}) = 'object'
+ then ${heartbeatRuns.resultJson} - 'stdout' - 'stderr' else ${heartbeatRuns.resultJson} end`,
+ }).from(heartbeatRuns).where(and(eq(heartbeatRuns.id, scope.runId), eq(heartbeatRuns.companyId, scope.companyId)));
+ if (!selected || selected.runtimeMode !== "native") return { kind: "complete" as const, run: selected };
+ const { runnerProfileJson: _profile, stdoutExcerpt: _stdout, stderrExcerpt: _stderr, ...run } = selected;
+ return { kind: "native" as const, run };
+}
From a12329b8a96208918a03a9f11be93a7319b21cb9 Mon Sep 17 00:00:00 2001
From: Devin Foley
Date: Sat, 24 Oct 2026 19:34:31 -0700
Subject: [PATCH 12/70] fix(db): complete bounded response attribution (#17537)
Raise bounded database-response attribution to 2048 groups and add numbered 128-entry log pages. Preserve the overflow bucket and exact completed-call and byte totals. Document complete page retrieval and validation.
Verified with 30 focused tests, the full build, and typecheck. All CI checks pass. Exact-head Greptile is 5/5 at Medium impact with no unresolved comments.
Co-Authored-By: Paperclip
---
doc/DATABASE.md | 21 +++++++++++++++++-
packages/db/src/response-capture.test.ts | 27 ++++++++++++++++++++----
packages/db/src/response-capture.ts | 7 ++++--
3 files changed, 48 insertions(+), 7 deletions(-)
diff --git a/doc/DATABASE.md b/doc/DATABASE.md
index 717ec1ca0ed..17fbb47f400 100644
--- a/doc/DATABASE.md
+++ b/doc/DATABASE.md
@@ -341,7 +341,7 @@ The diagnostic measures awaited Postgres.js `unsafe` responses before Drizzle
column decoding. It retains the Drizzle builder caller for normal, prepared,
transaction, and dedicated-pool execution. It issues no queries.
It logs the public server caller locations, static schema table, operation,
-query/row counts, and response value sizes. It emits at most 512 aggregate
+query/row counts, and response value sizes. It emits at most 2,048 aggregate
groups and a summary at the deadline. It never logs SQL, parameters, result
values, connection strings, or query errors. In-flight queries continue normally.
@@ -355,6 +355,25 @@ Look for `paperclip.db_response_capture.entry.v1` and
can store `event` as a structured field instead of message text. Filter by that
field or read the bounded window from the exact deployment. Check the summary
and group totals, including the unattributed overflow group.
+
+Fetch a complete capture as follows:
+
+1. Read the summary events from the exact deployment and window. Keep each
+ distinct `captureId` separately.
+2. Each entry has a unique `groupIndex` within that capture and a `groupPage`.
+ The summary gives `groupPageSize` (128) and `groupPages`. Fetch every page
+ from zero through `groupPages - 1`, filtering on the entry event, `captureId`,
+ and `groupPage`. Request at least `groupPageSize` matching records per page.
+ A structured query can use
+ `@event:paperclip.db_response_capture.entry.v1 AND @captureId: AND @groupPage:0`.
+3. If the provider's record limit is smaller than a page, fetch each
+ `groupIndex` separately with the same capture ID. Each index matches one
+ entry. Do not split by timestamp: entries can share a timestamp.
+4. Deduplicate by `(captureId, groupIndex)`. Require every index from zero
+ through `groups - 1`, with no gaps. Entry count must equal `groups`, summed
+ `calls` must equal `completedQueries`, and summed `responseValueBytes` must
+ equal the summary total. A missing page is an incomplete measurement.
+
Tagged raw-driver calls, streams, unawaited raw-driver
dispatch, and protocol overhead are not measured. Relational queries use a driver-level caller fallback.
Queries still in flight at the deadline are reported separately. Sizes describe
diff --git a/packages/db/src/response-capture.test.ts b/packages/db/src/response-capture.test.ts
index 4b87c7924a4..9fc1c80f5b1 100644
--- a/packages/db/src/response-capture.test.ts
+++ b/packages/db/src/response-capture.test.ts
@@ -107,12 +107,31 @@ describe("bounded database response capture", () => {
});
it("bounds cardinality without dropping response-byte totals", () => {
const f = fixture();
- for (let i = 0; i < 600; i += 1) f.capture.execute(() => [i], { ...metadata, callers: [`services/f${i}.ts:1:1`] });
+ for (let i = 0; i < 2200; i += 1) f.capture.execute(() => [i], { ...metadata, callers: [`services/f${i}.ts:1:1`] });
f.capture.flush();
const summary = f.events.at(-1)!;
- expect(summary).toMatchObject({ startedQueries: 600, completedQueries: 600, groups: 512, overflowCalls: 89 });
- expect(f.events).toHaveLength(513);
- expect(summary.responseValueBytes).toBe(Array.from({ length: 600 }, (_, i) => Buffer.byteLength(JSON.stringify([i]))).reduce((a, b) => a + b, 0));
+ expect(summary).toMatchObject({ startedQueries: 2200, completedQueries: 2200, groups: 2048, overflowCalls: 153,
+ groupPageSize: 128, groupPages: 16 });
+ expect(f.events).toHaveLength(2049);
+ const entries = f.events.slice(0, -1);
+ expect(entries.map(entry => entry.groupIndex)).toEqual(Array.from({ length: 2048 }, (_, index) => index));
+ for (let page = 0; page < 16; page += 1) {
+ expect(entries.filter(entry => entry.groupPage === page)).toHaveLength(128);
+ }
+ expect(entries.reduce((sum, entry) => sum + Number(entry.calls), 0)).toBe(2200);
+ expect(summary.responseValueBytes).toBe(Array.from({ length: 2200 }, (_, i) => Buffer.byteLength(JSON.stringify([i]))).reduce((a, b) => a + b, 0));
+ });
+ it.each([0, 129])("numbers every page including an empty or partial final page (%s)", count => {
+ const f = fixture();
+ for (let index = 0; index < count; index += 1) {
+ f.capture.execute(() => [index], { ...metadata, callers: [`services/page${index}.ts:1:1`] });
+ }
+ f.capture.flush();
+ const entries = f.events.slice(0, -1), summary = f.events.at(-1)!;
+ expect(summary).toMatchObject({ groups: count, groupPageSize: 128, groupPages: Math.ceil(count / 128) });
+ expect(entries.map(entry => entry.groupIndex)).toEqual(Array.from({ length: count }, (_, index) => index));
+ expect(entries.filter(entry => entry.groupPage === 0)).toHaveLength(Math.min(count, 128));
+ expect(entries.filter(entry => entry.groupPage === 1)).toHaveLength(Math.max(0, count - 128));
});
it("does not execute builders or prepared statements until requested", async () => {
const f = fixture(), rows = [{ id: 1 }], execute = vi.fn(async () => rows);
diff --git a/packages/db/src/response-capture.ts b/packages/db/src/response-capture.ts
index f8e6da8e87a..675d57eeb82 100644
--- a/packages/db/src/response-capture.ts
+++ b/packages/db/src/response-capture.ts
@@ -7,6 +7,7 @@ type Entry = Metadata & { calls: number; rows: number; bytes: number; maxBytes:
type Event = Record;
type Method = (this: unknown, ...args: unknown[]) => unknown;
export type ResponseCaptureWindow = { start: number; end: number };
+const GROUP_PAGE_SIZE = 128;
export function responseCaptureWindow(value: string | undefined, now = Date.now()): ResponseCaptureWindow | undefined {
if (!value) return;
@@ -76,7 +77,8 @@ export function createDatabaseResponseCapture(
clearTimeout(timer);
scope.disable();
const ordered = [...entries.values()].sort((a, b) => b.bytes - a.bytes);
- for (const entry of ordered) send({ event: "paperclip.db_response_capture.entry.v1", captureId: id,
+ for (const [groupIndex, entry] of ordered.entries()) send({ event: "paperclip.db_response_capture.entry.v1", captureId: id,
+ groupIndex, groupPage: Math.floor(groupIndex / GROUP_PAGE_SIZE),
operation: entry.operation, table: entry.table, callers: entry.callers, calls: entry.calls, rows: entry.rows,
responseValueBytes: entry.bytes, maxResponseValueBytes: entry.maxBytes, estimatedValues: entry.estimatedValues,
queryErrors: entry.queryErrors, serializationErrors: entry.serializationErrors });
@@ -84,6 +86,7 @@ export function createDatabaseResponseCapture(
windowStart: new Date(window.start).toISOString(), windowEnd: new Date(window.end).toISOString(),
closedAt: new Date(now()).toISOString(),
startedQueries: started, completedQueries: completed, inFlightAtEnd: inFlight, groups: entries.size,
+ groupPageSize: GROUP_PAGE_SIZE, groupPages: Math.ceil(ordered.length / GROUP_PAGE_SIZE),
overflowCalls, responseValueBytes: ordered.reduce((sum, entry) => sum + entry.bytes, 0),
rowValuesLogged: false, sqlOrParametersLogged: false, wireBytesMeasured: false,
measurementBoundary: "postgres-js results before Drizzle column decoding",
@@ -99,7 +102,7 @@ export function createDatabaseResponseCapture(
completed += 1;
try {
let key = JSON.stringify(metadata);
- if (!entries.has(key) && entries.size >= 511) {
+ if (!entries.has(key) && entries.size >= 2047) {
overflowCalls += 1;
metadata = { operation: "other", table: "other", callers: [] };
key = "overflow";
From 80c1261417a578393b5420ca8d6ec95d7efaf8e0 Mon Sep 17 00:00:00 2001
From: Devin Foley
Date: Sat, 24 Oct 2026 19:50:19 -0700
Subject: [PATCH 13/70] perf(recovery): decline native legacy checks before
bulk reads (#17540)
Decline native legacy-continuation checks before loading saved execution input. Preserve exact nonblank task bindings, skip reasons, complete legacy recovery inputs, company scope, and retry accounting.
Verified with 105 recovery tests, the full build, and typecheck; the binding review fix passed 73 affected tests and server typecheck. CI is green and exact-head Greptile is 5/5 at Medium impact with no unresolved comments.
Co-Authored-By: Paperclip
---
doc/native-workspace-finalization-recovery.md | 7 ++
.../recovery/legacy-disposition-read.test.ts | 88 +++++++++++++++++++
.../recovery/legacy-disposition-read.ts | 24 +++++
server/src/services/recovery/service.ts | 11 ++-
4 files changed, 126 insertions(+), 4 deletions(-)
create mode 100644 server/src/services/recovery/legacy-disposition-read.test.ts
create mode 100644 server/src/services/recovery/legacy-disposition-read.ts
diff --git a/doc/native-workspace-finalization-recovery.md b/doc/native-workspace-finalization-recovery.md
index 51d174bb3e7..0875b177449 100644
--- a/doc/native-workspace-finalization-recovery.md
+++ b/doc/native-workspace-finalization-recovery.md
@@ -492,3 +492,10 @@ 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.
+
+Legacy continuation recovery rejects native runs before it loads their saved
+execution input. The persisted disposition check reads only the task binding
+for a native run. It keeps the same task-binding and native-finalization skip
+reasons, including the original nonblank binding values. Native input and history
+stay stored. Real legacy recovery still reads
+its complete context, profile, excerpts, and required failure evidence.
diff --git a/server/src/services/recovery/legacy-disposition-read.test.ts b/server/src/services/recovery/legacy-disposition-read.test.ts
new file mode 100644
index 00000000000..749a7991879
--- /dev/null
+++ b/server/src/services/recovery/legacy-disposition-read.test.ts
@@ -0,0 +1,88 @@
+import { randomBytes, randomUUID } from "node:crypto";
+import { agents, companies, createDb, heartbeatRuns } from "@paperclipai/db";
+import { createDatabaseResponseCapture, installDatabaseResponseCapture } from "@paperclipai/db/response-capture";
+import { eq } from "drizzle-orm";
+import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
+import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js";
+import { readLegacyDispositionSource } from "./legacy-disposition-read.js";
+import { recoveryService } from "./service.js";
+
+const support = await getEmbeddedPostgresTestSupport();
+
+describe.skipIf(!support.supported)("legacy disposition source reads", () => {
+ let database: Awaited>;
+ let db: ReturnType;
+ let companyId: string;
+ let agentId: string;
+
+ beforeAll(async () => {
+ database = await startEmbeddedPostgresTestDatabase("legacy-disposition-read-");
+ db = createDb(database.connectionString);
+ const [company] = await db.insert(companies).values({ name: "Legacy disposition fixture", issuePrefix: "LDF" }).returning();
+ const [agent] = await db.insert(agents).values({ companyId: company.id, name: "Disposition agent", adapterType: "process" }).returning();
+ companyId = company.id;
+ agentId = agent.id;
+ }, 60_000);
+
+ afterAll(async () => {
+ await db?.$client.end();
+ await database?.cleanup();
+ });
+
+ it("declines native input below the byte budget and keeps complete legacy evidence", async () => {
+ const issueId = randomUUID(), bulk = randomBytes(128 * 1024).toString("base64");
+ const context = { issueId, savedInput: bulk, executionRetryAccounting: { version: 1, failureRetries: 2, maxTurnContinuations: 1 } };
+ const [before] = await db.insert(heartbeatRuns).values({ companyId, agentId, runtimeMode: "native", status: "succeeded",
+ contextSnapshot: context, runnerProfileJson: { savedInput: bulk }, stdoutExcerpt: bulk, stderrExcerpt: bulk,
+ errorCode: "adapter_failed", resultJson: { executionRecovery: { providerWorkStarted: true }, stdout: bulk } }).returning();
+ const events: Array> = [];
+ const now = Date.now();
+ const capture = createDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event));
+ installDatabaseResponseCapture(db, { heartbeatRuns }, capture);
+ try {
+ expect(await readLegacyDispositionSource(db, { companyId, issueId, runId: before.id }))
+ .toEqual({ kind: "skip", decision: { kind: "skip", reason: "native_finalization" } });
+ } finally { capture.flush(); }
+ expect(events.at(-1)).toMatchObject({ completedQueries: 1, groups: 1 });
+ expect(Number(events.at(-1)?.responseValueBytes)).toBeLessThan(1024);
+ expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, before.id)))[0]).toEqual(before);
+ expect(JSON.stringify(events)).not.toContain(bulk.slice(0, 64));
+ await db.update(heartbeatRuns).set({ runtimeMode: "legacy" }).where(eq(heartbeatRuns.id, before.id));
+ const legacy = await readLegacyDispositionSource(db, { companyId, issueId, runId: before.id });
+ expect(legacy.kind).toBe("full");
+ if (legacy.kind !== "full") throw new Error("expected complete legacy input");
+ expect(legacy.run).toEqual({ ...before, runtimeMode: "legacy" });
+ expect((await readLegacyDispositionSource(db, { companyId: randomUUID(), issueId, runId: before.id })))
+ .toMatchObject({ kind: "full", run: undefined });
+ });
+
+ it.each([
+ { context: { issueId: " target " }, issueId: "target", reason: "invalid_issue_binding" },
+ { context: { issueId: " ", taskId: "target" }, issueId: "target", reason: "native_finalization" },
+ { context: { taskId: " target " }, issueId: "target", reason: "invalid_issue_binding" },
+ { context: { issueId: 42, taskId: "target" }, issueId: "target", reason: "native_finalization" },
+ { context: { issueId: "other", taskId: "target" }, issueId: "target", reason: "invalid_issue_binding" },
+ { context: { issueId: null, taskId: false }, issueId: "target", reason: "invalid_issue_binding" },
+ { context: null, issueId: "target", reason: "invalid_issue_binding" },
+ ])("keeps native binding decisions for $context", async ({ context, issueId, reason }) => {
+ const [run] = await db.insert(heartbeatRuns).values({ companyId, agentId, runtimeMode: "native", contextSnapshot: context }).returning();
+ expect(await readLegacyDispositionSource(db, { companyId, issueId, runId: run.id }))
+ .toEqual({ kind: "skip", decision: { kind: "skip", reason } });
+ });
+
+ it("skips native continuation in SQL without loading or rewriting its saved input", async () => {
+ const bulk = randomBytes(128 * 1024).toString("base64");
+ const [before] = await db.insert(heartbeatRuns).values({ companyId, agentId, runtimeMode: "native", status: "succeeded",
+ contextSnapshot: { issueId: randomUUID(), savedInput: bulk }, runnerProfileJson: { savedInput: bulk } }).returning();
+ const enqueueWakeup = vi.fn(async () => { throw new Error("native work must not enter legacy recovery"); });
+ const service = recoveryService(db, { enqueueWakeup });
+ const events: Array> = [], now = Date.now();
+ const capture = createDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event));
+ installDatabaseResponseCapture(db, { heartbeatRuns }, capture);
+ try { expect(await service.reconcileLegacyContinuation(before.id)).toBe("skipped"); }
+ finally { capture.flush(); }
+ expect(events.at(-1)).toMatchObject({ completedQueries: 1, responseValueBytes: 0 });
+ expect(enqueueWakeup).not.toHaveBeenCalled();
+ expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, before.id)))[0]).toEqual(before);
+ });
+});
diff --git a/server/src/services/recovery/legacy-disposition-read.ts b/server/src/services/recovery/legacy-disposition-read.ts
new file mode 100644
index 00000000000..08f9204f491
--- /dev/null
+++ b/server/src/services/recovery/legacy-disposition-read.ts
@@ -0,0 +1,24 @@
+import { heartbeatRuns, type Db } from "@paperclipai/db";
+import { and, eq, getTableColumns, sql } from "drizzle-orm";
+import { recoveryRunResultColumn } from "./run-result-read.js";
+
+export async function readLegacyDispositionSource(
+ db: Db,
+ scope: { companyId: string; issueId: string; runId: string },
+) {
+ const where = and(eq(heartbeatRuns.id, scope.runId), eq(heartbeatRuns.companyId, scope.companyId));
+ const [native] = await db.select({
+ issueId: sql`${heartbeatRuns.contextSnapshot}->'issueId'`,
+ taskId: sql`${heartbeatRuns.contextSnapshot}->'taskId'`,
+ }).from(heartbeatRuns).where(and(where, eq(heartbeatRuns.runtimeMode, "native"))).limit(1);
+ if (native) {
+ const bound = [native.issueId, native.taskId]
+ .map(value => typeof value === "string" && value.trim().length > 0 ? value : null).find(Boolean) ?? null;
+ return { kind: "skip" as const, decision: {
+ kind: "skip" as const, reason: bound === scope.issueId ? "native_finalization" : "invalid_issue_binding",
+ } };
+ }
+ const [run] = await db.select({ ...getTableColumns(heartbeatRuns), resultJson: recoveryRunResultColumn })
+ .from(heartbeatRuns).where(where).limit(1);
+ return { kind: "full" as const, run };
+}
diff --git a/server/src/services/recovery/service.ts b/server/src/services/recovery/service.ts
index 331122e10d6..5701571e864 100644
--- a/server/src/services/recovery/service.ts
+++ b/server/src/services/recovery/service.ts
@@ -1,5 +1,6 @@
import { recoveryRunContextColumn, recoveryRunVersionColumn } from "./run-context-read.js";
import { recoveryRunResultColumn } from "./run-result-read.js";
+import { readLegacyDispositionSource } from "./legacy-disposition-read.js";
import { isAgentAwaitingSetup } from "../../modules/agent-lifecycle/index.js";
import { onboardingFirstTaskAwaitingUserSql } from "./onboarding-first-task-wait.js";
import { isAiAuthenticationBlocked } from "../ai-auth-failure.js";
@@ -3793,9 +3794,9 @@ export function recoveryService(
episode: LegacyDispositionEpisode,
dispatchRunId?: string,
) {
- const [run] = await db.select(recoveryRunFields).from(heartbeatRuns).where(and(
- eq(heartbeatRuns.id, runId), eq(heartbeatRuns.companyId, issue.companyId),
- )).limit(1);
+ const source = await readLegacyDispositionSource(db, { companyId: issue.companyId, issueId: issue.id, runId });
+ if (source.kind === "skip") return source.decision;
+ const run = source.run;
if (!run) return { kind: "skip" as const, reason: "missing_run" };
const context = parseObject(run.contextSnapshot);
if ((readNonEmptyString(context.issueId) ?? readNonEmptyString(context.taskId)) !== issue.id) {
@@ -3854,7 +3855,9 @@ export function recoveryService(
}
async function reconcileLegacyContinuation(runId: string) {
- const [run] = await db.select(recoveryRunFields).from(heartbeatRuns).where(eq(heartbeatRuns.id, runId)).limit(1);
+ const [run] = await db.select(recoveryRunFields).from(heartbeatRuns).where(and(
+ eq(heartbeatRuns.id, runId), not(eq(heartbeatRuns.runtimeMode, "native")), eq(heartbeatRuns.status, "succeeded"),
+ )).limit(1);
if (!run || run.runtimeMode === "native" || run.status !== "succeeded") return "skipped" as const;
const context = parseObject(run.contextSnapshot);
const issueId = readNonEmptyString(context.issueId) ?? readNonEmptyString(context.taskId);
From 2f637fe8c49b5ad0276c3d23e9003859d311ffb5 Mon Sep 17 00:00:00 2001
From: Devin Foley
Date: Sat, 24 Oct 2026 20:05:45 -0700
Subject: [PATCH 14/70] perf(tools): scope connection readiness assignment
reads (#17541)
Connection readiness reads current permitted connection identities without unrelated catalog rows or complete profile-rule payloads. Reuse profile precedence and selector logic, preserve full public/runtime summaries, and keep installed connections, identity checks, and gateway authorization current.
Verified with 106 focused tests, the full build, and typecheck. The direct catalog inventory regression fails at 102 rows when the scope filter is removed and passes at two rows with the filter. One unrelated CI timing assertion differed by 1 ms; the exact local test and the single failed shard rerun passed on unchanged source. CI is green and exact-head Greptile is 5/5 at Medium impact with no unresolved comments.
Co-Authored-By: Paperclip
---
doc/DATABASE.md | 7 +
.../tool-effective-assignments.test.ts | 49 +++++-
server/src/services/connection-intents.ts | 4 +-
.../tool-access/profiles/projection.ts | 10 +-
.../tool-access/profiles/resolution.ts | 161 +++++++++++-------
5 files changed, 167 insertions(+), 64 deletions(-)
diff --git a/doc/DATABASE.md b/doc/DATABASE.md
index 17fbb47f400..aacf4f9f23d 100644
--- a/doc/DATABASE.md
+++ b/doc/DATABASE.md
@@ -471,6 +471,13 @@ bindings, selectors, exclusions, and installed connections on every call. The
public effective-profile response continues to include complete tool schemas
and annotations.
+Connection readiness checks use a scoped assignment reader. It applies the same
+current profile precedence, selectors, and exclusions to the candidate connection
+IDs. The reader selects rule match fields and catalog identities. It does not
+load rule conditions, catalog schemas, profile summaries, or the company-agent
+list. Installed connections and later identity and gateway checks remain fresh.
+Full effective-profile and effective-tool responses keep their complete shape.
+
## Execution identity row locks
Identity initialization, credential acquisition, and steering reconciliation lock
diff --git a/server/src/__tests__/tool-effective-assignments.test.ts b/server/src/__tests__/tool-effective-assignments.test.ts
index 2f69d9e62d0..13de5533fbf 100644
--- a/server/src/__tests__/tool-effective-assignments.test.ts
+++ b/server/src/__tests__/tool-effective-assignments.test.ts
@@ -1,10 +1,11 @@
-import { randomUUID } from "node:crypto";
+import { randomBytes, randomUUID } from "node:crypto";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { eq, sql } from "drizzle-orm";
import {
agents, companies, createDb, toolApplications, toolCatalogEntries,
toolConnections, toolProfileBindings, toolProfileEntries, toolProfiles, type Db,
} from "@paperclipai/db";
+import { createDatabaseResponseCapture, installDatabaseResponseCapture } from "@paperclipai/db/response-capture";
import { toolAccessService } from "../services/tool-access.js";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
@@ -49,6 +50,11 @@ describeDatabase("effective tool assignments", () => {
expect(assignments).toEqual({ ...full, allowedTools: full.allowedTools.map(
({ id, applicationId, connectionId, toolName }) => ({ id, applicationId, connectionId, toolName }),
) });
+ const connections = await service.getConnectionAssignmentsForAgent(f.company.id, f.agent.id, [f.connection.id]);
+ expect(connections).toEqual({
+ installedConnections: full.installedConnections,
+ allowedConnectionIds: [...new Set(full.allowedTools.filter(tool => tool.connectionId === f.connection.id).map(tool => tool.connectionId))],
+ });
return { service, full, assignments };
}
@@ -130,4 +136,45 @@ describeDatabase("effective tool assignments", () => {
expect(observed[1].bytes).toBeLessThan(observed[0].bytes / 100);
expect(full.allowedTools[0].inputSchema).toEqual(f.read.inputSchema);
});
+ it("scopes catalog reads, omits rule payloads, and keeps current full definitions", async () => {
+ const f = await fixture();
+ const bulk = randomBytes(128 * 1024).toString("base64");
+ const [rule] = await db.insert(toolProfileEntries).values({ companyId: f.company.id, profileId: f.profile.id,
+ selectorType: "application", effect: "include", applicationId: f.application.id, conditions: { savedRuleMetadata: bulk } }).returning();
+ const [unrelated] = await db.insert(toolConnections).values({ companyId: f.company.id, applicationId: f.application.id,
+ name: "Other connection", uid: randomUUID(), transport: "mcp_remote", status: "active", enabled: true }).returning();
+ await db.insert(toolCatalogEntries).values(Array.from({ length: 100 }, (_, index) => ({ companyId: f.company.id,
+ applicationId: f.application.id, connectionId: unrelated.id, name: `other_${index}`, toolName: `other_${index}`, versionHash: `v${index}` })));
+ const service = toolAccessService(db);
+ const before = await service.getEffectiveToolAssignmentsForAgent(f.company.id, f.agent.id);
+ expect(before.allowedTools).toHaveLength(102);
+ const events: Array> = [], now = Date.now();
+ const capture = createDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event));
+ installDatabaseResponseCapture(db, { toolCatalogEntries, toolProfileEntries }, capture);
+ let scoped: Awaited>;
+ try { scoped = await service.getConnectionAssignmentsForAgent(f.company.id, f.agent.id, [f.connection.id]); }
+ finally { capture.flush(); }
+ expect(Number(events.at(-1)?.completedQueries)).toBeGreaterThan(0);
+ expect(Number(events.at(-1)?.responseValueBytes)).toBeLessThan(16 * 1024);
+ const catalogQueries = events.filter(event => event.event === "paperclip.db_response_capture.entry.v1"
+ && event.table === "tool_catalog_entries" && event.operation === "select");
+ expect(catalogQueries).toHaveLength(1);
+ expect(catalogQueries[0]).toMatchObject({ calls: 1, rows: 2 });
+ expect(scoped!).toEqual({ allowedConnectionIds: [f.connection.id], installedConnections: before.installedConnections });
+ expect(JSON.stringify(events)).not.toContain(bulk.slice(0, 64));
+ expect((await db.select().from(toolProfileEntries).where(eq(toolProfileEntries.id, rule.id)))[0]).toEqual(rule);
+ expect(await service.getEffectiveToolAssignmentsForAgent(f.company.id, f.agent.id)).toEqual(before);
+ expect((await service.getConnectionAssignmentsForAgent(f.company.id, f.agent.id, [])).allowedConnectionIds).toEqual([]);
+ await db.delete(toolProfileBindings).where(eq(toolProfileBindings.profileId, f.profile.id));
+ expect((await service.getConnectionAssignmentsForAgent(f.company.id, f.agent.id, [f.connection.id])).allowedConnectionIds).toEqual([]);
+ });
+
+ it("keeps other companies' catalog rows out of scoped assignments", async () => {
+ const f = await fixture();
+ const other = await fixture();
+ await db.update(toolProfiles).set({ defaultAction: "allow" }).where(eq(toolProfiles.id, f.profile.id));
+ expect((await toolAccessService(db).getConnectionAssignmentsForAgent(f.company.id, f.agent.id, [other.connection.id])).allowedConnectionIds).toEqual([]);
+ await expect(toolAccessService(db).getConnectionAssignmentsForAgent(f.company.id, other.agent.id, [f.connection.id])).rejects.toThrow("same company");
+ });
+
});
diff --git a/server/src/services/connection-intents.ts b/server/src/services/connection-intents.ts
index a6d07331c0c..77dabac142b 100644
--- a/server/src/services/connection-intents.ts
+++ b/server/src/services/connection-intents.ts
@@ -291,9 +291,9 @@ export function connectionIntentService(db: Db) {
&& connection.status !== "archived" && connection.connectionPurpose !== "ai"
);
if (matching.length === 0) return null;
- const effective = await access.getEffectiveToolAssignmentsForAgent(input.companyId, input.agentId);
+ const effective = await access.getConnectionAssignmentsForAgent(input.companyId, input.agentId, matching.map(connection => connection.id));
const installedIds = new Set(effective.installedConnections.map((connection) => connection.id));
- const permittedIds = new Set(effective.allowedTools.map((tool) => tool.connectionId));
+ const permittedIds = new Set(effective.allowedConnectionIds);
const usable = (connection: ToolConnection | undefined) => connection
&& installedIds.has(connection.id) && permittedIds.has(connection.id)
&& connection.status === "active" && connection.enabled
diff --git a/server/src/services/tool-access/profiles/projection.ts b/server/src/services/tool-access/profiles/projection.ts
index 50b77ae3a79..53b92ef182f 100644
--- a/server/src/services/tool-access/profiles/projection.ts
+++ b/server/src/services/tool-access/profiles/projection.ts
@@ -80,9 +80,15 @@ export type ProfileApplication = Pick;
+export type ProfileEntryMatch = Pick;
+
+export type ProfileCatalogMatch = Pick;
+
export function profileEntryMatchesCatalog(
- entry: typeof toolProfileEntries.$inferSelect,
- catalogEntry: ProfileCatalogEntry,
+ entry: ProfileEntryMatch,
+ catalogEntry: ProfileCatalogMatch,
): boolean {
if (entry.selectorType === "application")
return entry.applicationId === catalogEntry.applicationId;
diff --git a/server/src/services/tool-access/profiles/resolution.ts b/server/src/services/tool-access/profiles/resolution.ts
index 80aa6870681..3cc4d36df4b 100644
--- a/server/src/services/tool-access/profiles/resolution.ts
+++ b/server/src/services/tool-access/profiles/resolution.ts
@@ -3,20 +3,67 @@ import type { Db } from "@paperclipai/db";
import { agents, toolCatalogEntries, toolProfileBindings, toolProfileEntries, toolProfiles } from "@paperclipai/db";
import type { ToolCatalogEntry, ToolConnection, ToolProfileEffectiveSummary, ToolProfileWithDetails } from "@paperclipai/shared";
import { effectiveToolProfileBindings, profileIdsInBindingOrder } from "../../tool-profile-binding-precedence.js";
-import { buildProfileDetails, profileCatalogColumns, profileEntryMatchesCatalog, toProfileBinding, toProfileEntry, type ProfileCatalogEntry } from "./projection.js";
+import { buildProfileDetails, profileCatalogColumns, profileEntryMatchesCatalog, toProfileBinding, toProfileEntry, type ProfileCatalogEntry, type ProfileEntryMatch, type ProfileCatalogMatch } from "./projection.js";
type ProfileAssignmentDependencies = {
assertOptionalAgent(companyId: string, agentId: string | null | undefined, label: string): Promise;
resolveInstalledConnectionsForAgent(companyId: string, agentId: string): Promise;
toCatalogEntry(row: typeof toolCatalogEntries.$inferSelect): ToolCatalogEntry;
};
+function resolveCatalogAccess(
+ profiles: Array>,
+ entries: Array,
+ catalog: ProfileCatalogMatch[],
+) {
+ const allowedCatalogIds = new Set();
+ const allowedToolNames = new Set();
+ const entriesByProfile = new Map();
+ for (const entry of entries) {
+ const group = entriesByProfile.get(entry.profileId) ?? [];
+ group.push(entry);
+ entriesByProfile.set(entry.profileId, group);
+ }
+ for (const profile of profiles) {
+ const profileEntries = entriesByProfile.get(profile.id) ?? [];
+ const includes = profileEntries.filter(
+ (entry) => entry.effect === "include",
+ );
+ const excludes = profileEntries.filter(
+ (entry) => entry.effect === "exclude",
+ );
+ for (const catalogEntry of catalog) {
+ if (
+ excludes.some((entry) =>
+ profileEntryMatchesCatalog(entry, catalogEntry),
+ )
+ )
+ continue;
+ if (
+ profile.defaultAction === "allow" ||
+ includes.some((entry) =>
+ profileEntryMatchesCatalog(entry, catalogEntry),
+ )
+ ) {
+ allowedCatalogIds.add(catalogEntry.id);
+ allowedToolNames.add(catalogEntry.toolName);
+ }
+ }
+ for (const entry of includes.filter(
+ (item) => item.selectorType === "tool_name" && item.toolName,
+ )) {
+ const matchingExclude = excludes.some(
+ (item) =>
+ item.selectorType === "tool_name" &&
+ item.toolName === entry.toolName,
+ );
+ if (!matchingExclude) allowedToolNames.add(entry.toolName!);
+ }
+ }
+ return { allowedCatalogIds, allowedToolNames };
+}
+
export function createToolProfileResolver(db: Db, { assertOptionalAgent, resolveInstalledConnectionsForAgent, toCatalogEntry }: ProfileAssignmentDependencies) {
- async function resolveEffectiveProfiles(
- companyId: string,
- agentId: string,
- loadCatalog: () => PromiseLike,
- toAllowedTool: (entry: TEntry) => TAllowedTool,
- ): Promise & { allowedTools: TAllowedTool[] }> {
+ async function readEffectiveProfileState(companyId: string, agentId: string) {
await assertOptionalAgent(
companyId,
agentId,
@@ -36,21 +83,8 @@ export function createToolProfileResolver(db: Db, { assertOptionalAgent, resolve
binding.targetId === companyId) ||
(binding.targetType === "agent" && binding.targetId === agentId),
);
- if (matchingBindings.length === 0) {
- return {
- agentId,
- profiles: [],
- entries: [],
- bindings: [],
- allowedTools: [],
- allowedToolNames: [],
- installedConnections: await resolveInstalledConnectionsForAgent(
- companyId,
- agentId,
- ),
- };
- }
const candidateProfileIds = profileIdsInBindingOrder(matchingBindings);
+ if (candidateProfileIds.length === 0) return { bindings: [], activeProfiles: [] };
const candidateProfiles = await db
.select()
.from(toolProfiles)
@@ -73,6 +107,16 @@ export function createToolProfileResolver(db: Db, { assertOptionalAgent, resolve
.filter((profile): profile is typeof toolProfiles.$inferSelect =>
Boolean(profile && profile.status === "active"),
);
+ return { bindings, activeProfiles };
+ }
+
+ async function resolveEffectiveProfiles(
+ companyId: string,
+ agentId: string,
+ loadCatalog: () => PromiseLike,
+ toAllowedTool: (entry: TEntry) => TAllowedTool,
+ ): Promise & { allowedTools: TAllowedTool[] }> {
+ const { bindings, activeProfiles } = await readEffectiveProfileState(companyId, agentId);
if (activeProfiles.length === 0) {
return {
agentId,
@@ -114,44 +158,7 @@ export function createToolProfileResolver(db: Db, { assertOptionalAgent, resolve
list.push(entry);
entriesByProfile.set(entry.profileId, list);
}
- const allowedCatalogIds = new Set();
- const allowedToolNames = new Set();
- for (const profile of activeProfiles) {
- const profileEntries = entriesByProfile.get(profile.id) ?? [];
- const includes = profileEntries.filter(
- (entry) => entry.effect === "include",
- );
- const excludes = profileEntries.filter(
- (entry) => entry.effect === "exclude",
- );
- for (const catalogEntry of catalog) {
- if (
- excludes.some((entry) =>
- profileEntryMatchesCatalog(entry, catalogEntry),
- )
- )
- continue;
- if (
- profile.defaultAction === "allow" ||
- includes.some((entry) =>
- profileEntryMatchesCatalog(entry, catalogEntry),
- )
- ) {
- allowedCatalogIds.add(catalogEntry.id);
- allowedToolNames.add(catalogEntry.toolName);
- }
- }
- for (const entry of includes.filter(
- (item) => item.selectorType === "tool_name" && item.toolName,
- )) {
- const matchingExclude = excludes.some(
- (item) =>
- item.selectorType === "tool_name" &&
- item.toolName === entry.toolName,
- );
- if (!matchingExclude) allowedToolNames.add(entry.toolName!);
- }
- }
+ const { allowedCatalogIds, allowedToolNames } = resolveCatalogAccess(activeProfiles, entries, catalog);
const agentIds = companyAgents.map((agent) => agent.id);
const details: ToolProfileWithDetails[] = activeProfiles.map((profile) =>
buildProfileDetails({
@@ -182,7 +189,43 @@ export function createToolProfileResolver(db: Db, { assertOptionalAgent, resolve
),
};
}
+ async function getConnectionAssignmentsForAgent(companyId: string, agentId: string, connectionIds: string[]) {
+ const { activeProfiles } = await readEffectiveProfileState(companyId, agentId);
+ if (activeProfiles.length === 0 || connectionIds.length === 0) {
+ return { allowedConnectionIds: [], installedConnections: await resolveInstalledConnectionsForAgent(companyId, agentId) };
+ }
+ const [entries, catalog] = await Promise.all([
+ db.select({
+ profileId: toolProfileEntries.profileId,
+ effect: toolProfileEntries.effect,
+ selectorType: toolProfileEntries.selectorType,
+ applicationId: toolProfileEntries.applicationId,
+ connectionId: toolProfileEntries.connectionId,
+ catalogEntryId: toolProfileEntries.catalogEntryId,
+ toolName: toolProfileEntries.toolName,
+ riskLevel: toolProfileEntries.riskLevel,
+ }).from(toolProfileEntries).where(and(
+ eq(toolProfileEntries.companyId, companyId), inArray(toolProfileEntries.profileId, activeProfiles.map(profile => profile.id)),
+ )),
+ db.select({
+ id: toolCatalogEntries.id,
+ applicationId: toolCatalogEntries.applicationId,
+ connectionId: toolCatalogEntries.connectionId,
+ toolName: toolCatalogEntries.toolName,
+ riskLevel: toolCatalogEntries.riskLevel,
+ }).from(toolCatalogEntries).where(and(
+ eq(toolCatalogEntries.companyId, companyId), eq(toolCatalogEntries.status, "active"), inArray(toolCatalogEntries.connectionId, connectionIds),
+ )),
+ ]);
+ const { allowedCatalogIds } = resolveCatalogAccess(activeProfiles, entries, catalog);
+ return {
+ allowedConnectionIds: [...new Set(catalog.filter(entry => allowedCatalogIds.has(entry.id)).map(entry => entry.connectionId))],
+ installedConnections: await resolveInstalledConnectionsForAgent(companyId, agentId),
+ };
+ }
+
return {
+ getConnectionAssignmentsForAgent,
getEffectiveProfilesForAgent: (companyId: string, agentId: string) =>
resolveEffectiveProfiles(
companyId,
From c8a6b4561bb679df1f507b819f9dc0f67e368473 Mon Sep 17 00:00:00 2001
From: Devin Foley
Date: Sat, 24 Oct 2026 20:18:23 -0700
Subject: [PATCH 15/70] perf(heartbeat): narrow native Stop evidence rechecks
(#17544)
Read current native Stop identity, acknowledgement, and process evidence without saved execution inputs, profiles, excerpts, or unrelated result data. Preserve the complete nested acknowledgement and every existing lease, remote-termination, process, ownership, and admission check. The physical-stop helper body is unchanged.
Verified with 101 tests across the affected suites, the full build, and typecheck. The review fix passed 62 focused tests. Large valid saved inputs stay below 2 KB at the raw driver boundary; large malformed string and array results stay below 1 KB and remain stored. CI is green and exact-head Greptile is 5/5 at Medium impact with no unresolved comments.
Co-Authored-By: Paperclip
---
doc/native-workspace-finalization-recovery.md | 6 ++
.../src/services/acknowledged-native-stop.ts | 3 +-
.../native-stop-evidence-read.test.ts | 93 +++++++++++++++++++
.../heartbeat/native-stop-evidence-read.ts | 20 ++++
server/src/services/heartbeat/run-control.ts | 3 +-
5 files changed, 123 insertions(+), 2 deletions(-)
create mode 100644 server/src/services/heartbeat/native-stop-evidence-read.test.ts
create mode 100644 server/src/services/heartbeat/native-stop-evidence-read.ts
diff --git a/doc/native-workspace-finalization-recovery.md b/doc/native-workspace-finalization-recovery.md
index 0875b177449..01e7a7031c4 100644
--- a/doc/native-workspace-finalization-recovery.md
+++ b/doc/native-workspace-finalization-recovery.md
@@ -499,3 +499,9 @@ for a native run. It keeps the same task-binding and native-finalization skip
reasons, including the original nonblank binding values. Native input and history
stay stored. Real legacy recovery still reads
its complete context, profile, excerpts, and required failure evidence.
+
+The native Stop continuation recheck reads current run identity, the full Stop
+acknowledgement receipt, and process identity. It does not load saved execution
+input, runner profiles, excerpts, or unrelated result fields. Lease cleanup,
+remote termination receipts, local process checks, task ownership, and admission
+checks keep their existing behavior. Stored runs retain all of their data.
diff --git a/server/src/services/acknowledged-native-stop.ts b/server/src/services/acknowledged-native-stop.ts
index bb6b7fa74c3..9d07f7b61a7 100644
--- a/server/src/services/acknowledged-native-stop.ts
+++ b/server/src/services/acknowledged-native-stop.ts
@@ -37,7 +37,8 @@ export function isAcknowledgedNativeStop(run: Parameters) {
if (!isAcknowledgedNativeStop(run)) return false;
const leases = await db.select().from(environmentLeases).where(and(
eq(environmentLeases.companyId, run.companyId), eq(environmentLeases.heartbeatRunId, run.id),
diff --git a/server/src/services/heartbeat/native-stop-evidence-read.test.ts b/server/src/services/heartbeat/native-stop-evidence-read.test.ts
new file mode 100644
index 00000000000..d53a5d4c417
--- /dev/null
+++ b/server/src/services/heartbeat/native-stop-evidence-read.test.ts
@@ -0,0 +1,93 @@
+import { randomBytes, randomUUID } from "node:crypto";
+import { heartbeatRuns, issues, type Db } from "@paperclipai/db";
+import { createDatabaseResponseCapture, installDatabaseResponseCapture } from "@paperclipai/db/response-capture";
+import { eq, sql } from "drizzle-orm";
+import { describe, expect, it, vi } from "vitest";
+import { isAcknowledgedNativeStop } from "../acknowledged-native-stop.js";
+import { callbacks, runControlPostgresSupport, setupRunControlDatabase } from "./__tests__/run-control-fixtures.js";
+import { readNativeStopExecutionEvidence } from "./native-stop-evidence-read.js";
+import { createHeartbeatRunControl } from "./run-control.js";
+
+vi.mock("../local-service-supervisor.js", async importOriginal => ({
+ ...await importOriginal(),
+ terminateLocalService: vi.fn(async () => undefined),
+}));
+
+describe.skipIf(!runControlPostgresSupport.supported)("native Stop evidence reads", () => {
+ let db: Db;
+ const { fixture } = setupRunControlDatabase("native-stop-evidence-", value => { db = value; });
+
+ it("reads current receipts below the byte budget without changing saved data", async () => {
+ const { company, agent } = await fixture();
+ const [issue] = await db.insert(issues).values({ companyId: company.id, title: "Stop evidence fixture" }).returning();
+ const id = randomUUID(), bulk = randomBytes(128 * 1024).toString("base64");
+ const receipt = { schema: "paperclip.native-cancellation.v1", runId: id, companyId: company.id, issueId: issue.id,
+ scope: "run", reasonCode: "cancellation_run_only", dispatchState: "acknowledged", dispatched: true,
+ intentAuditId: randomUUID(), acknowledgementAuditId: randomUUID(), futureProof: { version: 2 } };
+ const result = { nativeCancellation: receipt, cancelledByActorType: "user", cancelledByUserId: randomUUID(),
+ stdout: bulk, stderr: bulk, futureExecutionEvidence: { savedInput: bulk } };
+ const [before] = await db.insert(heartbeatRuns).values({ id, companyId: company.id, agentId: agent.id,
+ nativeIssueId: issue.id, runtimeMode: "native", status: "cancelled", processPid: 123, processGroupId: 456,
+ contextSnapshot: { savedInput: bulk }, runnerProfileJson: { savedInput: bulk }, stdoutExcerpt: bulk,
+ stderrExcerpt: bulk, resultJson: result }).returning();
+ const events: Array> = [], now = Date.now();
+ const capture = createDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event));
+ installDatabaseResponseCapture(db, { heartbeatRuns }, capture);
+ let evidence: Awaited>;
+ try { evidence = await readNativeStopExecutionEvidence(db, company.id, id); }
+ finally { capture.flush(); }
+ expect(evidence!).toEqual({ id, companyId: company.id, nativeIssueId: issue.id, status: "cancelled", processPid: 123,
+ processGroupId: 456, resultJson: { nativeCancellation: receipt, cancelledByActorType: "user", cancelledByUserId: result.cancelledByUserId } });
+ expect(isAcknowledgedNativeStop(evidence!)).toBe(true);
+ expect(events.at(-1)).toMatchObject({ completedQueries: 1, groups: 1 });
+ expect(Number(events.at(-1)?.responseValueBytes)).toBeLessThan(2048);
+ expect(JSON.stringify(events)).not.toContain(bulk.slice(0, 64));
+ expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, id)))[0]).toEqual(before);
+ await db.update(heartbeatRuns).set({ resultJson: { ...result, nativeCancellation: { ...receipt, dispatchState: "pending" } } })
+ .where(eq(heartbeatRuns.id, id));
+ expect(isAcknowledgedNativeStop((await readNativeStopExecutionEvidence(db, company.id, id))!)).toBe(false);
+ expect(await readNativeStopExecutionEvidence(db, randomUUID(), id)).toBeNull();
+ expect(await readNativeStopExecutionEvidence(db, company.id, randomUUID())).toBeNull();
+ });
+
+ it.each([null, [], false, 42, "not a receipt", { nativeCancellation: null }, { nativeCancellation: { dispatched: "true" } }])(
+ "keeps malformed receipts fail-closed: %j", async result => {
+ const { company, agent } = await fixture();
+ const [run] = await db.insert(heartbeatRuns).values({ companyId: company.id, agentId: agent.id,
+ runtimeMode: "native", status: "cancelled" }).returning();
+ await db.execute(sql`update ${heartbeatRuns} set result_json = ${JSON.stringify(result)}::jsonb where id = ${run.id}`);
+ expect(isAcknowledgedNativeStop((await readNativeStopExecutionEvidence(db, company.id, run.id))!)).toBe(false);
+ },
+ );
+
+ it.each(["string", "array"])("does not transfer a large malformed %s result", async kind => {
+ const { company, agent } = await fixture();
+ const bulk = randomBytes(128 * 1024).toString("base64");
+ const saved = kind === "array" ? [bulk] : bulk;
+ const [run] = await db.insert(heartbeatRuns).values({ companyId: company.id, agentId: agent.id,
+ runtimeMode: "native", status: "cancelled" }).returning();
+ await db.execute(sql`update ${heartbeatRuns} set result_json = ${JSON.stringify(saved)}::jsonb where id = ${run.id}`);
+ const events: Array> = [], now = Date.now();
+ const capture = createDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event));
+ installDatabaseResponseCapture(db, { heartbeatRuns }, capture);
+ try {
+ const evidence = await readNativeStopExecutionEvidence(db, company.id, run.id);
+ expect(evidence?.resultJson).toBeNull();
+ expect(isAcknowledgedNativeStop(evidence!)).toBe(false);
+ } finally { capture.flush(); }
+ expect(events.at(-1)).toMatchObject({ completedQueries: 1, groups: 1 });
+ expect(Number(events.at(-1)?.responseValueBytes)).toBeLessThan(1024);
+ expect((await db.select({ result: heartbeatRuns.resultJson }).from(heartbeatRuns).where(eq(heartbeatRuns.id, run.id)))[0].result).toEqual(saved);
+ });
+
+ it("uses the scoped receipt reader for the native continuation recheck", async () => {
+ const { company, agent } = await fixture();
+ const [issue] = await db.insert(issues).values({ companyId: company.id, title: "Native continuation fixture" }).returning();
+ const [run] = await db.insert(heartbeatRuns).values({ companyId: company.id, agentId: agent.id,
+ nativeIssueId: issue.id, runtimeMode: "native", status: "succeeded" }).returning();
+ const deps = callbacks(db);
+ const control = createHeartbeatRunControl(db, deps);
+ await control.resumeRemoteStopComments(run);
+ expect(deps.getRun).not.toHaveBeenCalled();
+ });
+});
diff --git a/server/src/services/heartbeat/native-stop-evidence-read.ts b/server/src/services/heartbeat/native-stop-evidence-read.ts
new file mode 100644
index 00000000000..6ee776bf03f
--- /dev/null
+++ b/server/src/services/heartbeat/native-stop-evidence-read.ts
@@ -0,0 +1,20 @@
+import { heartbeatRuns, type Db } from "@paperclipai/db";
+import { and, eq, sql } from "drizzle-orm";
+
+export async function readNativeStopExecutionEvidence(db: Db, companyId: string, runId: string) {
+ const [run] = await db.select({
+ id: heartbeatRuns.id,
+ companyId: heartbeatRuns.companyId,
+ nativeIssueId: heartbeatRuns.nativeIssueId,
+ status: heartbeatRuns.status,
+ processPid: heartbeatRuns.processPid,
+ processGroupId: heartbeatRuns.processGroupId,
+ resultJson: sql`case
+ when jsonb_typeof(${heartbeatRuns.resultJson}) = 'object' then (
+ select coalesce(jsonb_object_agg(key, value), '{}'::jsonb)
+ from jsonb_each(${heartbeatRuns.resultJson})
+ where key = any(array['nativeCancellation', 'cancelledByActorType', 'cancelledByUserId'])
+ ) else null end`,
+ }).from(heartbeatRuns).where(and(eq(heartbeatRuns.id, runId), eq(heartbeatRuns.companyId, companyId))).limit(1);
+ return run ?? null;
+}
diff --git a/server/src/services/heartbeat/run-control.ts b/server/src/services/heartbeat/run-control.ts
index d7086c52508..b4ba5f0ddea 100644
--- a/server/src/services/heartbeat/run-control.ts
+++ b/server/src/services/heartbeat/run-control.ts
@@ -1,4 +1,5 @@
import { notifyEnvironmentCleanup } from "../environment-cleanup-notifications.js";
+import { readNativeStopExecutionEvidence } from "./native-stop-evidence-read.js";
import { readTaskRetryIntent, reconcileTaskChatRetryReceipts } from "../task-retry.js";
import { PROCESS_IDENTITY_RECORDED, PROCESS_START_REQUESTED, readNativeLocalProcessStop, recordedLocalExecutionStopped } from "../native-local-process-stop.js";
import { isHeartbeatRunTerminalStatus } from "./run-lifecycle.js";
@@ -522,7 +523,7 @@ export function createHeartbeatRunControl(db: Db, dependencies: HeartbeatRunCont
!(await recordedLocalExecutionStopped(db, run))) return;
const issueId = run.nativeIssueId ?? (typeof run.contextSnapshot?.issueId === "string" ? run.contextSnapshot.issueId : null);
if (!issueId) return;
- const currentRun = run.runtimeMode === "native" ? await getRun(run.id, { includeExecutionEvidence: true }) : null;
+ const currentRun = run.runtimeMode === "native" ? await readNativeStopExecutionEvidence(db, run.companyId, run.id) : null;
const [coordinator] = currentRun ? await db.select({ phase: nativeRunFinalizations.phase,
leaseOwner: nativeRunFinalizations.leaseOwner }).from(nativeRunFinalizations).where(and(
eq(nativeRunFinalizations.companyId, run.companyId), eq(nativeRunFinalizations.runId, run.id),
From 8a85d9d647b9081cf6b71c0635383edf542eed2e Mon Sep 17 00:00:00 2001
From: Devin Foley
Date: Sat, 24 Oct 2026 21:04:36 -0700
Subject: [PATCH 16/70] perf(heartbeat): coalesce repeated native stranded
releases (#17547)
Skip repeated successful native release checks for the same unchanged source within one recovery pass. Preserve every saved request, failure retry, cursor update, source change, legacy path, and current admission gate.
Verified with 201 focused tests, 32 full-control queue cases, build and typecheck. Exact-head CI is green, Greptile is 5/5 with Low impact, and no review threads remain unresolved.
Co-Authored-By: Paperclip
---
doc/native-workspace-finalization-recovery.md | 8 ++
server/src/services/heartbeat/queue.test.ts | 130 ++++++++++++++++--
server/src/services/heartbeat/queue.ts | 8 +-
3 files changed, 137 insertions(+), 9 deletions(-)
diff --git a/doc/native-workspace-finalization-recovery.md b/doc/native-workspace-finalization-recovery.md
index 01e7a7031c4..c8353239234 100644
--- a/doc/native-workspace-finalization-recovery.md
+++ b/doc/native-workspace-finalization-recovery.md
@@ -505,3 +505,11 @@ acknowledgement receipt, and process identity. It does not load saved execution
input, runner profiles, excerpts, or unrelated result fields. Lease cleanup,
remote termination receipts, local process checks, task ownership, and admission
checks keep their existing behavior. Stored runs retain all of their data.
+
+One periodic stranded-message pass coalesces successful native release checks
+for the same company, task, source run, and current source row version. The release
+path already drains that
+task's queue through current admission checks. Each saved request still advances
+its recovery cursor. Rejected attempts, changed source runs, and updated source evidence remain eligible,
+and the next pass checks current state again. Legacy per-request handling stays
+unchanged. Pending work is awaited before a source is marked checked.
diff --git a/server/src/services/heartbeat/queue.test.ts b/server/src/services/heartbeat/queue.test.ts
index 5a31b5ee131..ef889747669 100644
--- a/server/src/services/heartbeat/queue.test.ts
+++ b/server/src/services/heartbeat/queue.test.ts
@@ -1,12 +1,14 @@
import { claimNativeSessionResumptions } from "../native-runtime/native-finalization-reconciler.js";
import type { Db } from "@paperclipai/db";
import { agents, agentWakeupRequests, caseHandoffs, cases, companies, createDb, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS, heartbeatRuns, issueComments, issues, nativeRunFinalizations } from "@paperclipai/db";
-import { eq, sql } from "drizzle-orm";
+import { eq, inArray, sql } from "drizzle-orm";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js";
import { createPostgresWakeQueueAdapter } from "../../modules/wake-queue/adapters/postgres.js";
import { createReleaseIssueExecution } from "../../modules/wake-queue/application/use-cases.js";
import { createHeartbeatQueue, type HeartbeatQueueDependencies } from "./queue.js";
+import { createHeartbeatRunControl } from "./run-control.js";
+import { callbacks as runControlCallbacks } from "./__tests__/run-control-fixtures.js";
import { createHeartbeatQueueTestDependencies as callbacks } from "../../__tests__/helpers/heartbeat-queue.js";
@@ -113,6 +115,116 @@ describe.skipIf(!support.supported)("heartbeat queue database wiring", () => {
return { ...seeded, recipient, pending };
}
+ async function addStrandedMessages(seeded: Awaited>) {
+ const messages = [seeded.pending];
+ for (let index = 0; index < 2; index++) {
+ const [comment] = await db.insert(issueComments).values({ companyId: seeded.company.id, issueId: seeded.issue.id,
+ body: `Additional saved feedback ${index}`, authorAgentId: seeded.agent.id }).returning();
+ const [wake] = await db.insert(agentWakeupRequests).values({ companyId: seeded.company.id, agentId: seeded.recipient.id,
+ source: "automation", reason: "issue_execution_deferred", status: "deferred_issue_execution",
+ payload: { issueId: seeded.issue.id, _paperclipWakeContext: { issueId: seeded.issue.id,
+ wakeReason: "issue_comment_mentioned", wakeCommentIds: [comment.id] } } }).returning();
+ messages.push(wake);
+ }
+ await db.update(agentWakeupRequests).set({ updatedAt: new Date(0) }).where(inArray(agentWakeupRequests.id, messages.map(row => row.id)));
+ return messages;
+ }
+
+ async function expectSavedMessages(messages: Awaited>) {
+ const rows = await db.select().from(agentWakeupRequests).where(inArray(agentWakeupRequests.id, messages.map(row => row.id)));
+ expect(rows).toHaveLength(messages.length);
+ for (const before of messages) {
+ const row = rows.find(row => row.id === before.id)!;
+ expect(row).toMatchObject({ payload: before.payload, status: "deferred_issue_execution", runId: null,
+ finishedAt: null, error: null });
+ expect(row.updatedAt.getTime()).toBeGreaterThan(0);
+ }
+ }
+
+ it.each(["legacy", "native"])("keeps saved %s messages while avoiding repeated native source release", async runtimeMode => {
+ const seeded = await strandedMention(runtimeMode);
+ const messages = await addStrandedMessages(seeded);
+ const deps = callbacks(db), queue = createHeartbeatQueue(db, deps);
+ await queue.resumeQueuedRuns();
+ expect(deps.releaseIssueExecutionAndPromote).toHaveBeenCalledTimes(runtimeMode === "native" ? 1 : 3);
+ await expectSavedMessages(messages);
+ await queue.resumeQueuedRuns();
+ expect(deps.releaseIssueExecutionAndPromote).toHaveBeenCalledTimes(runtimeMode === "native" ? 2 : 6);
+ expect(deps.executeRun).not.toHaveBeenCalled();
+ });
+
+ it("retries a rejected native release on the next saved message", async () => {
+ const seeded = await strandedMention("native"), messages = await addStrandedMessages(seeded);
+ const deps = callbacks(db);
+ deps.releaseIssueExecutionAndPromote.mockRejectedValueOnce(new Error("Fixture release remains pending"));
+ await createHeartbeatQueue(db, deps).resumeQueuedRuns();
+ expect(deps.releaseIssueExecutionAndPromote).toHaveBeenCalledTimes(2);
+ await expectSavedMessages(messages);
+ expect(deps.executeRun).not.toHaveBeenCalled();
+ });
+
+ it("joins the native release before it suppresses another message's duplicate check", async () => {
+ const seeded = await strandedMention("native"), messages = await addStrandedMessages(seeded);
+ const deps = callbacks(db);
+ let finish!: () => void;
+ deps.releaseIssueExecutionAndPromote.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; }));
+ let settled = false;
+ const pending = createHeartbeatQueue(db, deps).resumeQueuedRuns().then(() => { settled = true; });
+ try {
+ await vi.waitFor(() => expect(deps.releaseIssueExecutionAndPromote).toHaveBeenCalledTimes(1));
+ expect(settled).toBe(false);
+ } finally { finish?.(); }
+ await pending;
+ expect(deps.releaseIssueExecutionAndPromote).toHaveBeenCalledTimes(1);
+ await expectSavedMessages(messages);
+ });
+
+ it("rechecks a different native source that completes during the pass", async () => {
+ const seeded = await strandedMention("native"), messages = await addStrandedMessages(seeded);
+ await db.update(heartbeatRuns).set({ createdAt: new Date(0) }).where(eq(heartbeatRuns.id, seeded.run.id));
+ const deps = callbacks(db);
+ let nextId: string | undefined;
+ deps.releaseIssueExecutionAndPromote.mockImplementationOnce(async () => {
+ const [next] = await db.insert(heartbeatRuns).values({ companyId: seeded.company.id, agentId: seeded.agent.id,
+ runtimeMode: "native", nativeIssueId: seeded.issue.id, status: "succeeded", finishedAt: new Date(),
+ contextSnapshot: { issueId: seeded.issue.id } }).returning();
+ nextId = next.id;
+ });
+ await createHeartbeatQueue(db, deps).resumeQueuedRuns();
+ expect(deps.releaseIssueExecutionAndPromote.mock.calls.map(([run]) => run.id)).toEqual([seeded.run.id, nextId]);
+ await expectSavedMessages(messages);
+ });
+
+ it("rechecks changed native evidence on the same source run", async () => {
+ const seeded = await strandedMention("native"), messages = await addStrandedMessages(seeded);
+ const deps = callbacks(db);
+ deps.releaseIssueExecutionAndPromote.mockImplementationOnce(async () => {
+ await db.update(heartbeatRuns).set({ resultJson: { observedReceipt: "new evidence" } }).where(eq(heartbeatRuns.id, seeded.run.id));
+ });
+ await createHeartbeatQueue(db, deps).resumeQueuedRuns();
+ expect(deps.releaseIssueExecutionAndPromote).toHaveBeenCalledTimes(2);
+ expect(deps.releaseIssueExecutionAndPromote.mock.calls.map(([run]) => run.id)).toEqual([seeded.run.id, seeded.run.id]);
+ await expectSavedMessages(messages);
+ });
+
+ it("checks each unrelated native task in the same pass", async () => {
+ const seeded = await strandedMention("native"), firstMessages = await addStrandedMessages(seeded);
+ const [issue] = await db.insert(issues).values({ companyId: seeded.company.id, title: "Another saved task", status: "todo", assigneeAgentId: seeded.agent.id }).returning();
+ const [run] = await db.insert(heartbeatRuns).values({ companyId: seeded.company.id, agentId: seeded.agent.id,
+ runtimeMode: "native", nativeIssueId: issue.id, status: "succeeded", finishedAt: new Date(), contextSnapshot: { issueId: issue.id } }).returning();
+ const [comment] = await db.insert(issueComments).values({ companyId: seeded.company.id, issueId: issue.id,
+ body: "Other task feedback", authorAgentId: seeded.agent.id }).returning();
+ const [pending] = await db.insert(agentWakeupRequests).values({ companyId: seeded.company.id, agentId: seeded.recipient.id,
+ source: "automation", reason: "issue_execution_deferred", status: "deferred_issue_execution",
+ payload: { issueId: issue.id, _paperclipWakeContext: { issueId: issue.id, wakeReason: "issue_comment_mentioned", wakeCommentIds: [comment.id] } } }).returning();
+ const secondMessages = await addStrandedMessages({ ...seeded, issue, run, pending });
+ const deps = callbacks(db);
+ await createHeartbeatQueue(db, deps).resumeQueuedRuns();
+ expect(deps.releaseIssueExecutionAndPromote).toHaveBeenCalledTimes(2);
+ expect(new Set(deps.releaseIssueExecutionAndPromote.mock.calls.map(([row]) => row.id))).toEqual(new Set([seeded.run.id, run.id]));
+ await expectSavedMessages([...firstMessages, ...secondMessages]);
+ });
+
it("rechecks a handoff's assignment version under the issue claim lock", async () => {
const { company, agent, issue, wake, run } = await fixture("queued");
const [record] = await db.insert(cases).values({ companyId: company.id, caseNumber: 1, identifier: "QM-CASE-1",
@@ -137,7 +249,9 @@ describe.skipIf(!support.supported)("heartbeat queue database wiring", () => {
});
it.each(["legacy", "native"])("periodic recovery preserves a paused recipient through the real %s release adapter", async runtimeMode => {
- const { company, issue, recipient, pending } = await strandedMention(runtimeMode);
+ const seeded = await strandedMention(runtimeMode);
+ const { company, issue, recipient } = seeded;
+ const messages = await addStrandedMessages(seeded);
await db.update(agents).set({ status: "paused" }).where(eq(agents.id, recipient.id));
await db.update(issues).set({ status: "blocked" }).where(eq(issues.id, issue.id));
const deps = callbacks(db);
@@ -146,16 +260,16 @@ describe.skipIf(!support.supported)("heartbeat queue database wiring", () => {
getRoutineEnv: async () => ({ routineId: null, env: null, responsibleUserId: null }),
resolveSessionBeforeForWakeup: async () => null,
}), recovery: { escalateStrandedAssignedIssue: async () => undefined, escalateStrandedRecoveryIssueInPlace: async () => undefined } });
- deps.releaseIssueExecutionAndPromote.mockImplementation(async (run, options) => {
- await release({ companyId: run.companyId, runId: run.id, now: new Date(), ...options });
- });
+ const controlDeps = runControlCallbacks(db);
+ vi.mocked(controlDeps.wakeQueue.releaseIssueExecution).mockImplementation(release);
+ const control = createHeartbeatRunControl(db, controlDeps);
+ deps.releaseIssueExecutionAndPromote.mockImplementation(control.releaseIssueExecutionAndPromote);
for (let pass = 0; pass < 2; pass++) {
await createHeartbeatQueue(db, deps).resumeQueuedRuns();
- expect((await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, pending.id)))[0])
- .toMatchObject({ status: "deferred_issue_execution", runId: null, finishedAt: null, error: null, payload: pending.payload });
+ await expectSavedMessages(messages);
expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, company.id))).toHaveLength(1);
}
- expect(deps.releaseIssueExecutionAndPromote).toHaveBeenCalledTimes(2);
+ expect(deps.releaseIssueExecutionAndPromote).toHaveBeenCalledTimes(runtimeMode === "native" ? 2 : 6);
expect(deps.executeRun).not.toHaveBeenCalled();
});
diff --git a/server/src/services/heartbeat/queue.ts b/server/src/services/heartbeat/queue.ts
index 515bd683fb3..35ff18e6e88 100644
--- a/server/src/services/heartbeat/queue.ts
+++ b/server/src/services/heartbeat/queue.ts
@@ -1834,6 +1834,7 @@ export function createHeartbeatQueue(db: Db, dependencies: HeartbeatQueueDepende
AND live_issue_run.status IN ('queued', 'running', 'scheduled_retry'))`,
cutoff ? gte(agentWakeupRequests.requestedAt, cutoff) : undefined))
.orderBy(asc(agentWakeupRequests.updatedAt)).limit(50);
+ const releasedNativeSources = new Set();
for (const { wake } of strandedQueues) {
if (!queuedCommentIdsFromWakePayload(wake.payload).length &&
!await readQueuedInteractionResponse(db, wake.companyId, String(wake.payload?.issueId), wake.payload)) continue;
@@ -1845,6 +1846,7 @@ export function createHeartbeatQueue(db: Db, dependencies: HeartbeatQueueDepende
startedAt: heartbeatRuns.startedAt,
runtimeMode: heartbeatRuns.runtimeMode,
errorCode: heartbeatRuns.errorCode,
+ sourceVersion: sql`${heartbeatRuns}.xmin::text`,
}).from(heartbeatRuns).where(and(
eq(heartbeatRuns.companyId, wake.companyId),
sql`${heartbeatRuns.contextSnapshot}->>'issueId' = ${String(wake.payload?.issueId)}`,
@@ -1855,6 +1857,8 @@ export function createHeartbeatQueue(db: Db, dependencies: HeartbeatQueueDepende
));
if (!latest || !latest.finishedAt || !isHeartbeatRunTerminalStatus(latest.status)) continue;
if (latest.runtimeMode !== "legacy" && latest.runtimeMode !== "native") continue;
+ const releaseSource = `${latest.companyId}:${String(wake.payload?.issueId)}:${latest.id}:${latest.sourceVersion}`;
+ if (latest.runtimeMode === "native" && releasedNativeSources.has(releaseSource)) continue;
if (latest.runtimeMode === "legacy") {
const cancelledAdmission = latest.status === "cancelled" && !latest.startedAt &&
latest.errorCode === "execution_reconciliation_required";
@@ -1864,7 +1868,9 @@ export function createHeartbeatQueue(db: Db, dependencies: HeartbeatQueueDepende
});
}
}
- await releaseIssueExecutionAndPromote(latest, { suppressImmediateRecovery: true, resumeStrandedQueue: true }).catch(err => {
+ await releaseIssueExecutionAndPromote(latest, { suppressImmediateRecovery: true, resumeStrandedQueue: true }).then(() => {
+ if (latest.runtimeMode === "native") releasedNativeSources.add(releaseSource);
+ }).catch(err => {
logger.warn({ err, queueId: wake.id }, "failed to promote stranded comments");
});
}
From 2a9d527c68ab1d2e61b8408eed9a194603bddfca Mon Sep 17 00:00:00 2001
From: Dotta <34892728+cryppadotta@users.noreply.github.com>
Date: Sat, 24 Oct 2026 21:05:45 -0700
Subject: [PATCH 17/70] fix: preserve native startup during controller restart
(#17511)
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work.
> - The native runtime keeps a durable runner alive across controller
restarts.
> - A restart can begin while a provider session is still opening.
> - The old controller fenced read-only run admission before it could
publish that prepared session.
> - Admission failure then closed the runner that shutdown needed to
detach.
> - This pull request lets only the existing startup finish admission
within the bounded shutdown wait.
> - The controller detaches that exact session before it can submit a
turn. New work and state changes stay fenced.
## Linked Issues or Issue Description
**What happened?**
A controller restart during native provider startup could close the
prepared runner with `native control-plane run admission failed`.
Recovery then found no running process and placed the task in a
reconciliation hold. The same admission path follows both fresh startup
and replacement after a failed provider resume.
**Expected behavior**
Shutdown must detach the existing prepared session so the next
controller can recover it through the normal identity checks. Shutdown
must not admit a new executor, submit a turn, or override an explicit
Stop.
**Steps to reproduce**
1. Delay a native backend while it opens its provider session.
2. Request controller restart detachment for that run.
3. Let backend startup finish before the 30-second deadline.
4. Observe that read-only admission fails and shutdown reports the run
as inactive instead of detached.
**Additional context**
Related work: #17073 handles remote process-monitor classification;
#17459 handles macOS process fingerprints; #17097 covers broader hot
upgrade recovery. This change fixes the startup admission fence. It does
not relax process identity or provider launch-profile checks.
## What Changed
- Separate the read-only admission guard from the guard on control-plane
state changes.
- Let shutdown await only the startup already registered for the run.
Close its admission window when the wait ends.
- Reject a new execution after its run is fenced for shutdown.
- Detach the prepared session and exit before runtime-question
attachment or provider turn submission.
- If Stop is pending before or after publication, retain its exact
cancellation acknowledgment and cleanup owner across authority reads and
runtime-question attachment.
- Share one 30-second admission/Stop-settlement deadline across the
restart sweep. Report an unsettled run as pending while healthy runs
continue hot-restart adoption.
- Cover the real runtime and admission path, deadline expiry, new-start
rejection, persisted binding checks, and write fences.
- Document the bounded startup handoff in the restart ownership
contract.
## Verification
- Red: the new actual-runtime/admission regression failed on the
previous code. Shutdown reported the run as inactive and did not detach
it.
- Red: review regressions reproduced detach before a post-publication
Stop acknowledgment and an actual hot-restart preparation error when
startup Stop cleanup timed out.
- Green on `e10d0cb238`: 18 startup/restart tests passed, including Stop
during authority reads and runtime-question attachment, the real
recovery preparation and shutdown coordinator with a stalled Stop plus
healthy peer, and the shared sweep deadline. The earlier 36-case
Stop/restart selection also passed before this follow-up.
- Both complete changed test files previously passed on head
`8e9c2610fe`: 857 tests. They used real PostgreSQL 18 with a fresh
database per fixture. An earlier local harness attempt reused one
database and hit duplicate fixture keys; that harness was corrected
before this complete pass.
- Real PostgreSQL: 7 admission and revocation tests passed. These cover
wrong runner identity, persisted binding mismatch, checkpoint/result
rejection, and in-flight write revocation. The local test harness used a
fresh Docker PostgreSQL 18 instance because macOS embedded Postgres
exhausted the host semaphore limit. Only database provisioning was
replaced; assertions and production port code were unchanged.
- Transport safety acceptance: the current source transport controlled a
real Linux ARM runner and a deterministic Codex test provider in a
disposable Debian container. After startup, detach and generic cleanup
preserved the process. A new controller authenticated the same PID and
provider session. The provider log had exactly one session start and no
turn start. Duplicate launch was forbidden, the retained workspace was
unchanged, and no stop or suspend command was issued.
- The Linux acceptance check tests the shared native transport safety
contract. It is not a live Claude response, a hosted Daytona run, or
recovery of the original task. Those remain unverified. No original
task, sandbox, credentials, or permissions were changed.
- `pnpm -r typecheck` and `pnpm build` passed before the review
follow-up. Server typecheck passed on current head `e10d0cb238`;
current-head CI also passed the full typecheck and build gates.
- The full local `pnpm test:run` command was attempted, then stopped
after repeated embedded PostgreSQL bootstrap setup failures. That broad
local run is incomplete. The current-head four-file Docker PostgreSQL
run passed 879 tests, with one existing warm-copy `claim-uncertain` case
timing out and recovery failing to import because the temporary
provisioning mock omitted a support export. The harness is corrected and
affected cases are being rechecked. The timeout is unexplained; it is
not being labeled an infrastructure failure. No assertions are skipped
by the database provisioning replacement.
- All 55 clean-host CI checks passed on current head `e10d0cb238` (2
conditional skips), including all general test shards and native runner
verification. Greptile reviewed that exact head at 5/5 with no findings.
The previous Paperclip review findings are addressed in [the review
response](https://github.com/paperclipai/paperclip/pull/17511#issuecomment-6391520242);
its new advisory review remains queued.
## Risks
- Admission must stay read-only. All writes retain the original
synchronous controller fence, and both admission checks still validate
the exact persisted binding.
- Startup that exceeds the 30-second shutdown wait still fails closed.
It cannot claim a successful detach or start a turn.
- No schema migration or provider configuration change is included.
## Model Used
OpenAI Codex, based on GPT-6, with reasoning, repository tools, code
execution, and test execution. The exact runtime model ID and context
window are not exposed in this session.
## 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 (e.g. `docs/...`, `fix/...`)
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 | 12 +
.../native-session-executor.test.ts | 322 +++++++++++++++++-
.../native-runtime/native-session-executor.ts | 79 ++++-
.../paperclip-control-plane-port.test.ts | 32 ++
.../paperclip-control-plane-port.ts | 8 +-
5 files changed, 435 insertions(+), 18 deletions(-)
diff --git a/doc/execution-semantics.md b/doc/execution-semantics.md
index 8bc8886b075..b4d63b5687f 100644
--- a/doc/execution-semantics.md
+++ b/doc/execution-semantics.md
@@ -1683,6 +1683,18 @@ controller must recover it through the authenticated ownership checks. This
preserves active work and queued messages without treating a server restart as
user cancellation.
+During controller shutdown, already registered provider startups share one
+30-second sweep deadline to finish read-only run admission and publish their exact
+sessions for detachment. Its company, run, session, runner and completion-contract binding
+must still match persisted authority. The draining controller cannot checkpoint,
+publish events, submit a turn or settle the run. A new startup after the shutdown
+fence, or admission after the window expires, remains rejected. A Stop pending
+before or after session publication keeps cancellation and cleanup with the
+current owner. Shutdown waits for that owner only until the same sweep deadline;
+it does not detach the session or submit a prompt while Stop is pending. An
+unsettled owner remains explicitly pending for reconciliation. Its timeout does
+not abort detachment of healthy sessions or trigger a global graceful drain.
+
Recovery restores a structured result proposal from the durable run events when
its append survived but the active turn's session checkpoint did not. Advancing
the replay cursor must preserve that proposal for the exact run, session, source,
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 ad754e8e84e..bfb1f4f309f 100644
--- a/server/src/services/native-runtime/native-session-executor.test.ts
+++ b/server/src/services/native-runtime/native-session-executor.test.ts
@@ -33,6 +33,7 @@ import { dirname, join } from "node:path";
import {
heartbeatRuns,
heartbeatRunEvents,
+ issueThreadInteractions,
issues,
nativeRunFinalizations,
nativeRunResults,
@@ -49,6 +50,7 @@ import {
validatePrpStructuredRunResult,
validatePrpEvent,
parseNativeExecutionInput,
+ executeNativeSession,
type NativeExecutionInputV1,
type NativeExecutionInputV3,
type NativeExecutionInput,
@@ -343,6 +345,7 @@ import {
executePaperclipNativeSession,
detachNativeSessionsForRestart,
NativeControllerDetachedForRestartError,
+ NativeCancellationPendingRecoveryError,
getNativeSessionSteeringState,
NativeSessionSteeringError,
assertRemoteRunnerBuildMetadata,
@@ -5695,6 +5698,10 @@ function leaseDb(
agentId: boundExecution.binding.agentId,
companyId: boundExecution.binding.companyId,
nativeIssueId: boundExecution.binding.issueId,
+ nativeSessionId: boundExecution.session.normalizedSessionId ?? `session-${boundExecution.binding.runId}`,
+ runnerInstanceId: "runner",
+ completionContractId: boundExecution.completionContract.id,
+ completionContractSha256: boundExecution.completionContract.sha256,
resultJson: runResultJson,
runnerProfileJson,
runtimeMode: "native",
@@ -5892,6 +5899,306 @@ describe("native startup cancellation fence", () => {
});
describe("native startup restart detachment", () => {
+ it("uses one admission deadline for the whole restart sweep", async () => {
+ const inputs = ["first", "second"].map(label => {
+ const input = structuredClone(execution);
+ input.binding.runId = `restart-shared-deadline-${label}`;
+ input.binding.agentId = `restart-shared-deadline-agent-${label}`;
+ return input;
+ });
+ const releases: Array<() => void> = [];
+ const ready: Array> = [];
+ state.execute.mockReset();
+ for (const _input of inputs) {
+ let started!: () => void;
+ ready.push(new Promise(resolve => { started = resolve; }));
+ const opening = new Promise(resolve => { releases.push(resolve); });
+ state.execute.mockImplementationOnce(async () => {
+ started(); await opening;
+ throw new Error("startup fixture released");
+ });
+ }
+ const outcomes = inputs.map(input => executePaperclipNativeSession({
+ db: leaseDb(input), execution: input, runnerInstanceId: "runner",
+ }).catch(error => error));
+ await Promise.all(ready);
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] });
+ const finished = vi.fn();
+ const runIds = inputs.map(input => input.binding.runId);
+ const shutdown = detachNativeSessionsForRestart(runIds).then(finished);
+ try {
+ await vi.advanceTimersByTimeAsync(30_000);
+ expect(finished).toHaveBeenCalledWith({ detachedRunIds: [], inactiveRunIds: [],
+ unsupportedRunIds: [], pendingRunIds: runIds });
+ } finally {
+ vi.useRealTimers(); releases.forEach(release => release());
+ await Promise.all(outcomes); await shutdown;
+ }
+ });
+
+ it.each(["authority", "questions"])("keeps a post-publication Stop owner through %s attachment and restart", async (seam) => {
+ const restarting = structuredClone(execution);
+ restarting.binding.runId = `restart-stop-after-publication-${seam}`;
+ let published!: () => void, releaseRead!: () => void, acknowledgeStop!: () => void;
+ let cleaning!: () => void, releaseCleanup!: () => void;
+ const publication = new Promise(resolve => { published = resolve; });
+ const read = new Promise(resolve => { releaseRead = resolve; });
+ const receipt = new Promise(resolve => { acknowledgeStop = resolve; });
+ const cleanupStarted = new Promise(resolve => { cleaning = resolve; });
+ const cleanup = new Promise(resolve => { releaseCleanup = resolve; });
+ const detach = vi.fn(async () => undefined);
+ const cancel = vi.fn(() => ({ cleanup: receipt }));
+ const submit = vi.fn();
+ const db = leaseDb(restarting);
+ const select = db.select.bind(db);
+ db.select = ((fields?: Record) => {
+ if (seam === "authority" && fields && Object.keys(fields).length === 2 && "status" in fields && "resultJson" in fields) {
+ return { from: () => ({ where: () => ({ limit: () => {
+ published();
+ return read.then(() => [{ status: "running", resultJson: {} }]);
+ } }) }) };
+ }
+ if (seam === "questions" && !fields) {
+ const query = select();
+ return { from: (table: unknown) => table === issueThreadInteractions
+ ? { where: () => ({ orderBy: () => { published(); return read.then(() => []); } }) }
+ : query.from(table as never) };
+ }
+ return select(fields as never);
+ }) as typeof db.select;
+ state.execute.mockReset().mockImplementationOnce(async options => {
+ try {
+ await options.onSession({ detachControllerForRestart: detach, cancel, resolveRuntimeRequest: vi.fn() });
+ submit();
+ } finally {
+ cleaning();
+ await cleanup;
+ await options.onSession(null);
+ }
+ });
+ const outcome = executePaperclipNativeSession({ db, execution: restarting, runnerInstanceId: "runner" }).catch(error => error);
+ await publication;
+ const stopping = cancelNativeSession(restarting.binding.runId, "operator Stop after publication");
+ const completed = vi.fn();
+ const shutdown = detachNativeSessionsForRestart([restarting.binding.runId]).then(completed);
+ try {
+ releaseRead();
+ await new Promise(resolve => setImmediate(resolve));
+ expect(cancel).toHaveBeenCalledOnce();
+ expect(detach).not.toHaveBeenCalled();
+ expect(completed).not.toHaveBeenCalled();
+ // Admission must not unwind into generic close while Stop awaits its ACK.
+ const cleanupObserved = vi.fn();
+ void cleanupStarted.then(cleanupObserved);
+ await new Promise(resolve => setImmediate(resolve));
+ expect(cleanupObserved).not.toHaveBeenCalled();
+ acknowledgeStop();
+ expect(await stopping).toBe(true);
+ await cleanupStarted;
+ expect(completed).not.toHaveBeenCalled();
+ releaseCleanup();
+ await outcome;
+ await shutdown;
+ expect(completed).toHaveBeenCalledWith(expect.objectContaining({
+ detachedRunIds: [], inactiveRunIds: [restarting.binding.runId], pendingRunIds: [],
+ }));
+ expect(detach).not.toHaveBeenCalled();
+ expect(submit).not.toHaveBeenCalled();
+ } finally {
+ releaseRead(); acknowledgeStop(); releaseCleanup();
+ await stopping; await outcome; await shutdown;
+ }
+ });
+
+ it.each(["startup", "active"])("keeps healthy hot-restart adoption when another %s Stop cleanup exceeds the sweep deadline", async (mode) => {
+ const { coordinateHeartbeatSchedulerShutdown } = await import("../../shutdown.js");
+ const { createHeartbeatRecovery } = await import("../heartbeat/recovery.js");
+ const { writeHotRestartIntent, readHotRestartIntent } = await import("../hot-restart.js");
+ const home = await mkdtemp(join(tmpdir(), "native-restart-stop-sweep-"));
+ const previousHome = process.env.PAPERCLIP_HOME;
+ process.env.PAPERCLIP_HOME = home;
+ await writeHotRestartIntent({ previousServerPid: process.pid, previousServerIdentity: "test-controller" });
+ const stoppingExecution = structuredClone(execution);
+ stoppingExecution.binding.runId = `restart-${mode}-stop-timeout`;
+ stoppingExecution.session.normalizedSessionId = "d371f9bb-6c46-44c6-9e3a-026fa9be0153";
+ const healthyExecution = structuredClone(execution);
+ healthyExecution.binding.runId = `restart-healthy-with-${mode}-stopping-peer`;
+ healthyExecution.session.normalizedSessionId = "a8fa0cb8-9cf1-46b0-96f0-78518b15847e";
+ healthyExecution.binding.agentId = "healthy-restart-agent";
+ let stoppedReady!: () => void, healthyReady!: () => void;
+ let releaseStopped!: () => void, releaseHealthy!: () => void, opening!: () => void, open!: () => void;
+ const openingStarted = new Promise(resolve => { opening = resolve; });
+ const openingGate = new Promise(resolve => { open = resolve; });
+ const stopPublished = new Promise(resolve => { stoppedReady = resolve; });
+ const healthyPublished = new Promise(resolve => { healthyReady = resolve; });
+ const stoppedLifetime = new Promise(resolve => { releaseStopped = resolve; });
+ const healthyLifetime = new Promise(resolve => { releaseHealthy = resolve; });
+ const stoppedDetach = vi.fn(async () => undefined);
+ const healthyDetach = vi.fn(async () => undefined);
+ state.execute.mockReset()
+ .mockImplementationOnce(async options => {
+ opening(); await openingGate;
+ try {
+ await options.onSession({ detachControllerForRestart: stoppedDetach, cancel: () => ({ cleanup: Promise.resolve() }) });
+ } finally {
+ stoppedReady(); await stoppedLifetime;
+ await options.onSession(null);
+ }
+ throw new Error("stopped owner settled");
+ })
+ .mockImplementationOnce(async options => {
+ await options.onSession({ detachControllerForRestart: healthyDetach });
+ healthyReady(); await healthyLifetime;
+ await options.onSession(null);
+ throw new Error("healthy controller detached");
+ });
+ const stoppedOutcome = executePaperclipNativeSession({ db: leaseDb(stoppingExecution), execution: stoppingExecution, runnerInstanceId: "runner" }).catch(error => error);
+ await openingStarted;
+ const stopping = mode === "startup" ? cancelNativeSession(stoppingExecution.binding.runId, "Stop while opening") : null;
+ open();
+ await stopPublished;
+ await stopping;
+ const healthyOutcome = executePaperclipNativeSession({ db: leaseDb(healthyExecution), execution: healthyExecution, runnerInstanceId: "runner" }).catch(error => error);
+ await healthyPublished;
+ if (mode === "active") await cancelNativeSession(stoppingExecution.binding.runId, "operator Stop after active admission");
+ const snapshotRows = [stoppingExecution, healthyExecution].map(item => ({
+ adapterType: "paperclip_runner", adapterConfig: {},
+ run: { id: item.binding.runId, companyId: item.binding.companyId, agentId: item.binding.agentId,
+ runtimeMode: "native", nativeSessionId: item.session.normalizedSessionId, status: "running", contextSnapshot: {} },
+ }));
+ const snapshotDb = { select: () => ({ from: () => ({ innerJoin: () => ({ where: async () => snapshotRows }) }) }) } as unknown as Db;
+ const { logger } = await import("../../middleware/logger.js");
+ const logged = vi.spyOn(logger, "info").mockImplementation(() => undefined);
+ const recovery = createHeartbeatRecovery(snapshotDb, {
+ enterShutdown: vi.fn(), appendRunEvent: vi.fn(async () => undefined),
+ } as unknown as import("../heartbeat/recovery.js").HeartbeatRecoveryDependencies);
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] });
+ const shutdown = coordinateHeartbeatSchedulerShutdown({ signal: "SIGTERM",
+ waitForHeartbeatSchedulerIdle: async () => undefined,
+ prepareHotRestartShutdown: recovery.prepareHotRestartShutdown,
+ });
+ try {
+ // Wait for the real snapshot I/O before advancing its bounded sweep.
+ await vi.waitFor(() => expect(vi.getTimerCount()).toBeGreaterThan(0));
+ await vi.advanceTimersByTimeAsync(30_000);
+ expect(await shutdown).toMatchObject({ hotRestart: { mode: "hot_restart", skipDrain: true }, preparationError: null });
+ expect(logged).toHaveBeenCalledWith(expect.objectContaining({ detachedNativeSessions: {
+ detachedRunIds: [healthyExecution.binding.runId], inactiveRunIds: [], unsupportedRunIds: [],
+ pendingRunIds: [stoppingExecution.binding.runId],
+ } }), expect.any(String));
+ expect((await readHotRestartIntent())?.shutdownSnapshot?.activeRuns.map(run => run.runId))
+ .toEqual([stoppingExecution.binding.runId, healthyExecution.binding.runId]);
+ expect(stoppedDetach).not.toHaveBeenCalled();
+ expect(healthyDetach).toHaveBeenCalledOnce();
+ } finally {
+ vi.useRealTimers(); releaseStopped(); releaseHealthy();
+ await stoppedOutcome; await healthyOutcome; await shutdown;
+ logged.mockRestore();
+ if (previousHome === undefined) delete process.env.PAPERCLIP_HOME;
+ else process.env.PAPERCLIP_HOME = previousHome;
+ await rm(home, { recursive: true, force: true });
+ }
+ });
+
+ it.each(["detach", "expired", "stop"])("bounds read-only startup admission during drain: %s", async (mode) => {
+ const expired = mode === "expired";
+ const stopped = mode === "stop";
+ const restarting = structuredClone(execution);
+ restarting.binding.runId = `restart-real-admission-${mode}`;
+ restarting.session.normalizedSessionId = "restart-real-admission-session";
+ const identity = { ...restarting.binding, sessionId: restarting.session.normalizedSessionId };
+ let release!: () => void, started!: () => void;
+ const opening = new Promise(resolve => { release = resolve; });
+ const pending = new Promise(resolve => { started = resolve; });
+ const detach = vi.fn(async () => undefined);
+ const close = vi.fn(async () => undefined);
+ const startTurn = vi.fn();
+ let acknowledgeStop!: () => void;
+ const stopReceipt = new Promise(resolve => { acknowledgeStop = resolve; });
+ const cancel = vi.fn(() => ({ cleanup: stopReceipt }));
+ const backend = {
+ descriptor: async () => ({ kind: "mock" as const, name: "startup-drain", version: "1",
+ capabilities: { resume: true, typedEvents: true, steering: false, interruption: true, structuredResult: true },
+ runtimeContextCapabilities: { instructions: "native", skills: "native", mcp: "native" } }),
+ openSession: vi.fn(async () => {
+ started(); await opening;
+ return { identity: () => identity, detachControllerForRestart: detach, close, startTurn, cancel,
+ snapshot: async () => ({ sessionId: identity.sessionId, identity, backendKind: "mock" as const }),
+ };
+ }),
+ };
+ let runtimeError: unknown;
+ state.execute.mockReset().mockImplementationOnce(async options => {
+ try { return await executeNativeSession({ ...options, onSessionAdmission: undefined }); } catch (error) { runtimeError = error; throw error; }
+ });
+ const outcome = executePaperclipNativeSession({ db: leaseDb(restarting), execution: restarting,
+ runnerInstanceId: "runner", backend: backend as never }).catch(error => error);
+ await Promise.race([pending, outcome.then(error => { throw runtimeError ?? error; })]);
+ const stopping = stopped ? cancelNativeSession(restarting.binding.runId, "operator Stop during startup") : null;
+ if (expired) vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
+ const shutdown = detachNativeSessionsForRestart([restarting.binding.runId]).catch(error => error);
+ try {
+ if (expired) {
+ await vi.advanceTimersByTimeAsync(30_000);
+ expect(await shutdown).toMatchObject({ pendingRunIds: [restarting.binding.runId], detachedRunIds: [] });
+ vi.useRealTimers();
+ } else {
+ await new Promise(resolve => setImmediate(resolve));
+ }
+ release();
+ if (stopped) {
+ await vi.waitFor(() => expect(cancel).toHaveBeenCalledOnce());
+ expect(detach).not.toHaveBeenCalled();
+ acknowledgeStop();
+ expect(await stopping).toBe(true);
+ }
+ expect(await outcome).toBeInstanceOf(NativeControllerDetachedForRestartError);
+ if (stopped) {
+ expect(await shutdown).toMatchObject({ detachedRunIds: [], inactiveRunIds: [restarting.binding.runId] });
+ expect(detach).not.toHaveBeenCalled();
+ } else if (expired) {
+ expect(detach).not.toHaveBeenCalled();
+ expect(close).toHaveBeenCalledWith({ reason: "native control-plane run admission failed" });
+ } else {
+ expect(await shutdown).toMatchObject({ detachedRunIds: [restarting.binding.runId], inactiveRunIds: [] });
+ expect(detach).toHaveBeenCalledOnce();
+ expect(close).not.toHaveBeenCalledWith({ reason: "native control-plane run admission failed" });
+ }
+ expect(backend.openSession).toHaveBeenCalledOnce();
+ expect(startTurn).not.toHaveBeenCalled();
+ } finally { vi.useRealTimers(); release(); acknowledgeStop(); await stopping; await outcome; await shutdown; }
+ });
+ it("bounds a pending startup Stop without detaching its cleanup owner", async () => {
+ const restarting = structuredClone(execution);
+ restarting.binding.runId = "restart-startup-stop-cleanup-timeout";
+ let open!: () => void, started!: () => void, releaseCleanup!: () => void, cleaning!: () => void;
+ const opening = new Promise(resolve => { open = resolve; });
+ const pending = new Promise(resolve => { started = resolve; });
+ const cleanup = new Promise(resolve => { releaseCleanup = resolve; });
+ const cleanupStarted = new Promise(resolve => { cleaning = resolve; });
+ const detach = vi.fn(async () => undefined);
+ const cancel = vi.fn(() => ({ cleanup: Promise.resolve() }));
+ state.execute.mockReset().mockImplementationOnce(async options => {
+ started(); await opening;
+ try { await options.onSession({ detachControllerForRestart: detach, cancel }); }
+ finally { cleaning(); await cleanup; }
+ });
+ const outcome = executePaperclipNativeSession({ db: leaseDb(restarting), execution: restarting, runnerInstanceId: "runner" }).catch(error => error);
+ await pending;
+ const stopping = cancelNativeSession(restarting.binding.runId, "operator Stop before restart");
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
+ const shutdown = detachNativeSessionsForRestart([restarting.binding.runId]).catch(error => error);
+ try {
+ open();
+ await cleanupStarted;
+ expect(await stopping).toBe(true);
+ await vi.advanceTimersByTimeAsync(30_000);
+ expect(await shutdown).toMatchObject({ pendingRunIds: [restarting.binding.runId], detachedRunIds: [] });
+ expect(detach).not.toHaveBeenCalled();
+ expect(cancel).toHaveBeenCalledOnce();
+ } finally { vi.useRealTimers(); open(); releaseCleanup(); await stopping; await outcome; await shutdown; }
+ });
+
it("waits for a checkpointed governed settlement before relinquishing its controller", async () => {
const restarting = structuredClone(execution);
restarting.binding.runId = "restart-governed-settlement";
@@ -6033,7 +6340,7 @@ describe("native startup restart detachment", () => {
const detached = detachNativeSessionsForRestart([restarting.binding.runId]).catch(error => error);
if (exceedDeadline) {
await vi.advanceTimersByTimeAsync(30_000);
- expect(await detached).toMatchObject({ message: "native_restart_startup_not_ready" });
+ expect(await detached).toMatchObject({ pendingRunIds: [restarting.binding.runId], detachedRunIds: [] });
vi.useRealTimers();
} else {
release();
@@ -6049,18 +6356,13 @@ describe("native startup restart detachment", () => {
}
});
- it("remembers shutdown while the session is still opening and detaches its late publication", async () => {
+ it("rejects an entirely new startup after the run has been fenced for shutdown", async () => {
const restarting = structuredClone(execution);
- restarting.binding.runId = "restart-during-session-open";
- const detach = vi.fn(async () => undefined);
+ restarting.binding.runId = "restart-new-startup-after-drain";
await expect(detachNativeSessionsForRestart([restarting.binding.runId])).resolves.toMatchObject({ inactiveRunIds: [restarting.binding.runId] });
- state.execute.mockReset().mockImplementationOnce(async (options) => {
- await options.onSession({ detachControllerForRestart: detach });
- expect(detach).toHaveBeenCalledOnce();
- await options.onSession(null);
- throw new Error("detachment closed the old event stream");
- });
+ state.execute.mockReset();
await expect(executePaperclipNativeSession({ db: leaseDb(restarting), execution: restarting, runnerInstanceId: "runner" })).rejects.toBeInstanceOf(NativeControllerDetachedForRestartError);
+ expect(state.execute).not.toHaveBeenCalled();
});
});
diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts
index cc03fcbfa56..ccf020f577b 100644
--- a/server/src/services/native-runtime/native-session-executor.ts
+++ b/server/src/services/native-runtime/native-session-executor.ts
@@ -227,6 +227,7 @@ import {
type ActiveNativeSession = {
session: NativeSession;
cancelRequested: boolean;
+ cancellationSettled?: Promise;
restartDetach?: Promise;
};
@@ -264,6 +265,7 @@ type NativeSessionStartup = {
promise: Promise;
resolve: (session: ActiveNativeSession | null) => void;
stopRequested?: boolean;
+ restartAdmissionPending?: boolean;
cancellationSettled?: Promise;
governedWait?: boolean;
settled: Promise;
@@ -272,13 +274,14 @@ type NativeSessionStartup = {
const nativeSessionStartups = new Map();
function detachActiveNativeSessionForRestart(active: ActiveNativeSession) {
- active.restartDetach ??= Promise.resolve().then(() => active.session.detachControllerForRestart!());
+ active.restartDetach ??= active.session.detachControllerForRestart!();
return active.restartDetach;
}
async function waitForNativeSessionStartup(
runId: string,
timeoutError: () => Error = () => new Error("native_restart_startup_not_ready"),
+ timeoutMs = 30_000,
) {
const startup = nativeSessionStartups.get(runId);
if (!startup) return null;
@@ -287,7 +290,7 @@ async function waitForNativeSessionStartup(
return await Promise.race([
startup.promise,
new Promise((_resolve, reject) => {
- timer = setTimeout(() => reject(timeoutError()), 30_000);
+ timer = setTimeout(() => reject(timeoutError()), timeoutMs);
}),
]);
} finally {
@@ -301,10 +304,13 @@ export async function detachNativeSessionsForRestart(
detachedRunIds: string[];
inactiveRunIds: string[];
unsupportedRunIds: string[];
+ pendingRunIds: string[];
}> {
const detachedRunIds: string[] = [];
const inactiveRunIds: string[] = [];
const unsupportedRunIds: string[] = [];
+ const pendingRunIds: string[] = [];
+ const restartDeadline = Date.now() + 30_000;
const settlementDeadline = Date.now() + 20_000;
for (const runId of new Set(runIds)) {
// A governed stop already revoked new work and is collecting terminal
@@ -320,11 +326,48 @@ export async function detachNativeSessionsForRestart(
} finally { clearTimeout(timer); }
}
nativeRunsDetachingForRestart.add(runId);
- const active = activeNativeSessions.get(runId) ?? await waitForNativeSessionStartup(runId);
+ let active = activeNativeSessions.get(runId);
+ if (!active) {
+ // Only a startup registered before this fence may complete read-only
+ // admission. It must publish its exact session for detachment; writes and
+ // new turns remain revoked, including after the bounded wait expires.
+ const startup = nativeSessionStartups.get(runId);
+ if (startup && Date.now() >= restartDeadline) {
+ pendingRunIds.push(runId);
+ continue;
+ }
+ if (startup) startup.restartAdmissionPending = true;
+ try {
+ active = await waitForNativeSessionStartup(runId, undefined, Math.max(0, restartDeadline - Date.now())) ?? undefined;
+ } catch (error) {
+ if (!(error instanceof Error) || error.message !== "native_restart_startup_not_ready") throw error;
+ pendingRunIds.push(runId);
+ continue;
+ } finally {
+ if (startup) startup.restartAdmissionPending = false;
+ }
+ }
if (!active) {
inactiveRunIds.push(runId);
continue;
}
+ const stopping = nativeSessionStartups.get(runId);
+ if (stopping?.stopRequested || active.cancelRequested) {
+ // Stop owns cancellation and final cleanup, including after publication.
+ // A deadline leaves this run pending; it must not abort healthy adoption
+ // or claim that the transport/cleanup owner has settled.
+ let timer: ReturnType | undefined;
+ try {
+ const settled = stopping && await Promise.race([
+ stopping.settled.then(() => true),
+ new Promise(resolve => {
+ timer = setTimeout(() => resolve(false), Math.max(0, restartDeadline - Date.now()));
+ }),
+ ]);
+ (settled ? inactiveRunIds : pendingRunIds).push(runId);
+ } finally { clearTimeout(timer); }
+ continue;
+ }
if (active.session.detachControllerForRestart === undefined) {
unsupportedRunIds.push(runId);
continue;
@@ -332,7 +375,7 @@ export async function detachNativeSessionsForRestart(
await detachActiveNativeSessionForRestart(active);
detachedRunIds.push(runId);
}
- return { detachedRunIds, inactiveRunIds, unsupportedRunIds };
+ return { detachedRunIds, inactiveRunIds, unsupportedRunIds, pendingRunIds };
}
const MAX_REMOTE_CHECKPOINT_ARCHIVE_BYTES = 64 * 1024 * 1024;
const MAX_REMOTE_CHECKPOINT_EXPANDED_BYTES = 64 * 1024 * 1024;
@@ -7230,6 +7273,7 @@ export async function cancelNativeSession(
// Do not acknowledge a no-op Stop and let that owner submit a turn afterwards.
const startup = runStop && !activeNativeSessions.has(runId) ? nativeSessionStartups.get(runId) : undefined;
let settleStartupCancellation: (() => void) | undefined;
+ let settleActiveCancellation: (() => void) | undefined;
if (startup) {
startup.stopRequested = true;
startup.cancellationSettled = new Promise(resolve => { settleStartupCancellation = resolve; });
@@ -7242,6 +7286,7 @@ export async function cancelNativeSession(
dispatched = true;
if (!active.cancelRequested) {
active.cancelRequested = true;
+ active.cancellationSettled = new Promise(resolve => { settleActiveCancellation = resolve; });
try {
if (active.session.cancel) {
const cancellationAbort = new AbortController();
@@ -7451,6 +7496,7 @@ export async function cancelNativeSession(
return { dispatched, decision, decisionId, auditId };
} finally {
settleStartupCancellation?.();
+ settleActiveCancellation?.();
}
}
@@ -7674,6 +7720,9 @@ export async function executePaperclipNativeSession(input: {
}
}
const runId = input.execution.binding.runId;
+ if (nativeRunsDetachingForRestart.has(runId)) {
+ throw new NativeControllerDetachedForRestartError();
+ }
if (nativeSessionStartups.has(runId)) {
throw new Error("native_session_supervisor_busy");
}
@@ -8362,6 +8411,12 @@ async function executePaperclipNativeSessionWithinScope(
throw new NativeControllerDetachedForRestartError();
}
};
+ const startupForAdmission = nativeSessionStartups.get(input.execution.binding.runId);
+ const assertRunAdmissionActive = () => {
+ if (startupForAdmission?.restartAdmissionPending
+ && nativeSessionStartups.get(input.execution.binding.runId) === startupForAdmission) return;
+ assertControllerActive();
+ };
const controlPlane = new PaperclipControlPlanePort(
input.db,
{
@@ -8378,6 +8433,7 @@ async function executePaperclipNativeSessionWithinScope(
{
privateKeyPem: input.runnerEnvironment?.PAPERCLIP_AGENT_PRIVATE_KEY,
assertControllerActive,
+ assertRunAdmissionActive,
onCommittedEvent: async (event) => {
const textKind = !firstTokenRecorded ? observeDeliverableText(event) : null;
if (textKind) {
@@ -9220,6 +9276,13 @@ async function executePaperclipNativeSessionWithinScope(
eq(heartbeatRuns.agentId, input.execution.binding.agentId),
)).limit(1);
const cancellation = record(currentRun?.resultJson?.nativeCancellation);
+ if (active.cancelRequested) {
+ // A Stop can acquire the published handle while the authority
+ // read above is in flight. Keep its ACK/cleanup owner before
+ // unwinding admission; neither detach nor generic close wins.
+ await active.cancellationSettled;
+ throw new NativeCancellationPendingRecoveryError();
+ }
if (!currentRun || currentRun.status !== "running" ||
currentRun.resultJson?.startupCancellation ||
(cancellation.scope === "run" &&
@@ -9229,11 +9292,15 @@ async function executePaperclipNativeSessionWithinScope(
// callback fails; no provider turn may follow publication.
throw new NativeCancellationPendingRecoveryError();
}
- if (session.resolveRuntimeRequest) await liveQuestions.attach();
if (nativeRunsDetachingForRestart.has(input.execution.binding.runId)) {
if (session.detachControllerForRestart) await detachActiveNativeSessionForRestart(active);
+ throw new NativeControllerDetachedForRestartError();
+ }
+ if (session.resolveRuntimeRequest) await liveQuestions.attach();
+ if (active.cancelRequested) {
+ await active.cancellationSettled;
+ throw new NativeCancellationPendingRecoveryError();
}
- if (active.cancelRequested) throw new NativeCancellationPendingRecoveryError();
} else {
liveQuestions.close();
activeNativeSessions.delete(input.execution.binding.runId);
diff --git a/server/src/services/native-runtime/paperclip-control-plane-port.test.ts b/server/src/services/native-runtime/paperclip-control-plane-port.test.ts
index 5321a764f8b..f8b9eb1d022 100644
--- a/server/src/services/native-runtime/paperclip-control-plane-port.test.ts
+++ b/server/src/services/native-runtime/paperclip-control-plane-port.test.ts
@@ -300,6 +300,38 @@ describe("PaperclipControlPlanePort conformance", () => {
}
});
+ it("permits only read-only admission while an existing startup drains", async () => {
+ const identity = { ...CONTROL_PLANE_CONFORMANCE_OPEN.identity, runId: randomUUID(), sessionId: randomUUID() };
+ const runnerId = randomUUID();
+ await db.insert(heartbeatRuns).values({
+ id: identity.runId, companyId: identity.companyId, agentId: identity.agentId,
+ status: "running", runtimeMode: "native", nativeIssueId: identity.issueId,
+ nativeSessionId: identity.sessionId, runnerInstanceId: runnerId,
+ completionContractId: contractId, completionContractSha256: contractSha,
+ });
+ let awaitingStartup = true;
+ const port = new PaperclipControlPlanePort(db, {
+ ...identity, completionContractId: contractId, completionContractSha256: contractSha,
+ sourceInstanceId: runnerId, controlPlaneSourceInstanceId: `draining-${identity.runId}`,
+ }, {
+ assertControllerActive: () => { throw new Error("controller_detached"); },
+ assertRunAdmissionActive: () => { if (!awaitingStartup) throw new Error("controller_detached"); },
+ });
+ const open = { identity, backendKind: "mock" as const, sourceInstanceId: runnerId };
+ await expect(port.openRun({ ...open, sourceInstanceId: randomUUID() })).rejects.toThrow("native_open_run_binding_mismatch");
+ await db.update(heartbeatRuns).set({ runnerInstanceId: randomUUID() }).where(eq(heartbeatRuns.id, identity.runId));
+ await expect(port.openRun(open)).rejects.toThrow("native_open_run_not_authorized");
+ await db.update(heartbeatRuns).set({ runnerInstanceId: runnerId }).where(eq(heartbeatRuns.id, identity.runId));
+ await expect(port.openRun(open)).resolves.toBeUndefined();
+ await expect(port.checkpointSession({ backendKind: "mock", sessionId: identity.sessionId, identity })).rejects.toThrow("controller_detached");
+ await expect(port.completeRun({ result: CONTROL_PLANE_CONFORMANCE_RESULT, terminal: CONTROL_PLANE_CONFORMANCE_TERMINAL })).rejects.toThrow("controller_detached");
+ awaitingStartup = false;
+ await expect(port.openRun(open)).rejects.toThrow("controller_detached");
+ const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, identity.runId));
+ expect(run.runnerProfileJson?.sessionCheckpoint).toBeUndefined();
+ expect(await db.select().from(nativeRunResults).where(eq(nativeRunResults.runId, identity.runId))).toEqual([]);
+ });
+
it.each([
["checkpoint", "detach"], ["result", "detach"], ["event", "detach"],
["checkpoint", "abort"], ["result", "abort"], ["event", "abort"],
diff --git a/server/src/services/native-runtime/paperclip-control-plane-port.ts b/server/src/services/native-runtime/paperclip-control-plane-port.ts
index acb02e19b63..d6c6413d060 100644
--- a/server/src/services/native-runtime/paperclip-control-plane-port.ts
+++ b/server/src/services/native-runtime/paperclip-control-plane-port.ts
@@ -83,6 +83,7 @@ export class PaperclipControlPlanePort implements ControlPlanePort {
readonly #binding: PaperclipControlPlaneBinding;
#sessionId: string | null = null;
readonly #assertControllerActive?: () => void;
+ readonly #assertRunAdmissionActive?: () => void;
readonly #onCommittedEvent?: (event: PrpEvent) => Promise;
readonly #onDuplicateEvent?: (event: PrpEvent) => Promise;
@@ -94,12 +95,15 @@ export class PaperclipControlPlanePort implements ControlPlanePort {
privateKeyPem?: string;
/** Synchronous revocation fence for a controller handing off on restart. */
assertControllerActive?: () => void;
+ /** Read-only admission may finish an existing startup solely for restart detachment. */
+ assertRunAdmissionActive?: () => void;
onCommittedEvent?: (event: PrpEvent) => Promise;
onDuplicateEvent?: (event: PrpEvent) => Promise;
} = {},
) {
this.#db = db;
this.#assertControllerActive = options.assertControllerActive;
+ this.#assertRunAdmissionActive = options.assertRunAdmissionActive ?? options.assertControllerActive;
this.#identityRedactor = createAgentIdentityRedactor(options.privateKeyPem);
this.#binding = structuredClone(binding);
this.#onCommittedEvent = options.onCommittedEvent;
@@ -123,7 +127,7 @@ export class PaperclipControlPlanePort implements ControlPlanePort {
}
async openRun(input: OpenControlPlaneRunInput): Promise {
- this.#assertControllerActive?.();
+ this.#assertRunAdmissionActive?.();
const identity = input.identity;
if (
identity.companyId !== this.#binding.companyId
@@ -146,7 +150,7 @@ export class PaperclipControlPlanePort implements ControlPlanePort {
if (!run || !this.#matchesPersistedBinding(run)) {
throw new Error("native_open_run_not_authorized");
}
- this.#assertControllerActive?.();
+ this.#assertRunAdmissionActive?.();
this.#sessionId = identity.sessionId;
}
From 970127a0220d3145efe2111b0643aaa883cb162f Mon Sep 17 00:00:00 2001
From: Dotta <34892728+cryppadotta@users.noreply.github.com>
Date: Sat, 24 Oct 2026 21:32:42 -0700
Subject: [PATCH 18/70] test: retain onboarding Connect failure evidence
(#17529)
## Thinking Path
> - Paperclip manages agents and their work through normal company
controls.
> - Product E2E journeys check these controls through the browser and
public API.
> - Cloud onboarding uses one Connect action to validate the environment
and hire the first agent.
> - A failed Connect action previously left only a general journey error
in the report.
> - This pull request retains a closed diagnostic receipt for that
action.
> - The report can show the observed failure boundary without retaining
private response text.
## Linked Issues or Issue Description
**What existing behavior does this improve?**
Cloud onboarding Product E2E failure evidence and report detail.
**Current behavior**
A validation or hire failure can reach the journey as a general
operation failure. The report does not retain which Connect response
failed.
**Proposed behavior**
Observe only the same-origin, same-company validation and hire POST
requests made during one Connect action. Retain a closed receipt with
the checkpoint, response state, bounded HTTP status, and allowlisted
adapter, outcome, check code, level, and server code. The Get started
control and saved-agent readback remain required.
**Reason and benefit**
The next failure can be attributed to the exact observed browser/API
boundary. This change does not claim the cause of a historical failure.
**Breaking changes**
The new receipt is optional. Historical results remain valid. Explicit
validation and hire rejections now stop the journey with a precise
closed error code. This changes only E2E source; it does not change
product behavior or qualification gates.
## What Changed
- Add a bounded Connect observer with exact request matching and
listener cleanup.
- Retain known server codes from `details.code` or `code` without
response messages, hints, headers, request bodies, provider text, or
credentials.
- Validate the receipt as a closed schema and render its allowlisted
fields in existing report detail.
- Include the new helper in the journey definition hash.
- Add route, privacy, malformed-body, deadline, late-response,
delayed-failure, success, and historical compatibility tests. Document
the evidence boundary.
## Verification
- Focused Product E2E tests passed: 3 files, 58 tests on the review-fix
head.
- E2E typecheck, workspace typecheck, and full build passed on the
preceding implementation head. The review fix changes only report
labels, checkpoint timing, and the matching tests.
- The local full `pnpm test:run` command stopped progressing at the
general-server start header. Its owned session was interrupted. This is
not recorded as a pass.
- All 58 remote checks completed on the final review-fix head: 55
passed, one neutral, and two expected skips. Both Greptile and Paperclip
reviews are 5/5 with no unresolved review threads.
- No live campaign was run with this patch. The observed historical
failure is preserved.
## Risks
- Unknown or missing responses remain explicit diagnostic states. They
do not establish a product root cause.
- Response body reads stop after five seconds or the action deadline and
accept at most 64 KiB of JSON. Checks are capped at 32 retained entries.
- New server or check codes appear as `unrecognized` until the closed
allowlist is updated.
- The production launch classifier, native gateway qualification, and
Get started requirement are unchanged.
## Model Used
OpenAI Codex (GPT-6 family), with code execution and tool use. The host
does not expose the exact model ID or context window to this run; these
values are unavailable.
## 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 (e.g. `docs/...`, `fix/...`)
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
---
tests/runner-e2e/README.md | 5 +
tests/runner-e2e/SECURITY.md | 7 +
tests/runner-e2e/case-outcome.ts | 24 +++
.../product-journeys/cloud-onboarding.test.ts | 3 +
.../product-journeys/cloud-onboarding.ts | 6 +-
.../product-journeys/contracts.test.ts | 54 ++++++
tests/runner-e2e/product-journeys/launch.ts | 2 +-
.../onboarding-connect.test.ts | 182 ++++++++++++++++++
.../product-journeys/onboarding-connect.ts | 131 +++++++++++++
tests/runner-e2e/product-journeys/types.ts | 1 +
tests/runner-e2e/result-validation.ts | 2 +
11 files changed, 414 insertions(+), 3 deletions(-)
create mode 100644 tests/runner-e2e/product-journeys/onboarding-connect.test.ts
create mode 100644 tests/runner-e2e/product-journeys/onboarding-connect.ts
diff --git a/tests/runner-e2e/README.md b/tests/runner-e2e/README.md
index 47853d3ba74..129f75c4e31 100644
--- a/tests/runner-e2e/README.md
+++ b/tests/runner-e2e/README.md
@@ -237,6 +237,11 @@ The explicit [deployment-targeted Product journeys](PRODUCT-JOURNEYS.md) reuse
browser actions across managed self-hosted and Cloud targets. They include a
model-free task-properties story, fresh Cloud onboarding, credential-free
previews, private evidence, and prerequisite reuse; `--all` is unchanged.
+Cloud onboarding may retain a versioned `onboardingConnect` receipt for the
+Connect action: its last checkpoint, validation/hire response state and HTTP
+status, known adapters, and allowlisted outcome/check/server codes. The case
+report renders these fields explicitly; older results without the receipt remain
+valid. This diagnostic does not change the journey grade or qualify a live run.
## Live provider connection journeys
diff --git a/tests/runner-e2e/SECURITY.md b/tests/runner-e2e/SECURITY.md
index 26a326b9cca..afbcf8dd494 100644
--- a/tests/runner-e2e/SECURITY.md
+++ b/tests/runner-e2e/SECURITY.md
@@ -14,6 +14,13 @@ their expected four-field result, exact base64 download bytes, checksums, size,
and attachment/run IDs. Extra fields and arbitrary agent output are rejected.
It contains no credentials, browser state, request headers, or provider text.
+The optional `onboardingConnect` diagnostic is also a closed, versioned receipt.
+It retains only a known checkpoint, response states, HTTP statuses, known adapter
+names, and allowlisted outcome/check/server codes (at most 32 checks per response).
+Unknown codes become `unrecognized`; raw bodies, messages, URLs, headers, and
+browser errors are excluded. Reports validate the receipt before rendering its
+explicit fields, and historical results may omit it.
+
## Attended connection authentication
See [PROVIDER-CONNECTIONS.md](PROVIDER-CONNECTIONS.md) for the explicit-only
diff --git a/tests/runner-e2e/case-outcome.ts b/tests/runner-e2e/case-outcome.ts
index 03316c81995..489f16f8384 100644
--- a/tests/runner-e2e/case-outcome.ts
+++ b/tests/runner-e2e/case-outcome.ts
@@ -1,5 +1,6 @@
import type { RunnerE2EResult } from "./types.js";
import { isBlockedConnectionResult } from "./connection-evidence.js";
+import { validateOnboardingConnectEvidence } from "./product-journeys/onboarding-connect.js";
const html = (value: unknown) =>
String(value ?? "").replace(
/[&<>"']/g,
@@ -8,6 +9,28 @@ const html = (value: unknown) =>
c
]!,
);
+
+function renderOnboardingConnect(result: RunnerE2EResult): string {
+ const receipt = result.productJourney?.onboardingConnect;
+ if (receipt === undefined) return "";
+ try {
+ validateOnboardingConnectEvidence(receipt);
+ } catch {
+ return "
No behavioral matcher failed.${notReached.length ? " The journey is incomplete; unexercised checks are not passes." : failed ? " The overall failure came from a separate run, cleanup, or evidence check." : ""}
` : ""}
${notReached.length ? `
Not reached:
${notReached.map((c) => `
${html(c.id)} — ${html(c.notReached)}
`).join("")}
` : ""}
diff --git a/tests/runner-e2e/product-journeys/cloud-onboarding.test.ts b/tests/runner-e2e/product-journeys/cloud-onboarding.test.ts
index 94589ca5069..0878ade8065 100644
--- a/tests/runner-e2e/product-journeys/cloud-onboarding.test.ts
+++ b/tests/runner-e2e/product-journeys/cloud-onboarding.test.ts
@@ -86,6 +86,8 @@ function fixture(entry: Entry, options: { intentFailure?: boolean; qualification
},
});
const page = {
+ on() { return this; },
+ off() { return this; },
getByLabel(label: string) { return locator(() => controls().filter(control => control.name === label && control.role === "textbox")); },
getByRole(role: string, { name: label }: { name: string | RegExp }) {
return locator(() => controls().filter(control => control.role === role && match(control.name, label)));
@@ -93,6 +95,7 @@ function fixture(entry: Entry, options: { intentFailure?: boolean; qualification
locator(selector: string) { expect(selector).toBe("summary"); return locator(() => controls().filter(control => control.role === "summary")); },
};
const context = { page, company: { id: "company" }, identity: { kind: "cloud" }, startingState: "recipe", api: {
+ origin: "https://tenant.example.test",
async get(route: string) {
if (route.endsWith("/ai-connections")) return { connections: saved ? [connection] : [] };
if (route.endsWith("/agents")) return hired ? [{ id: "agent", name, adapterType: "paperclip_runner", adapterConfig: { provider: "codex", model: gateway.model } }] : [];
diff --git a/tests/runner-e2e/product-journeys/cloud-onboarding.ts b/tests/runner-e2e/product-journeys/cloud-onboarding.ts
index 3173676c3ad..6f9a75634a7 100644
--- a/tests/runner-e2e/product-journeys/cloud-onboarding.ts
+++ b/tests/runner-e2e/product-journeys/cloud-onboarding.ts
@@ -1,5 +1,6 @@
import { expect } from "@playwright/test";
import { createConnectionProof } from "../connection-evidence.js";
+import { onboardingConnect } from "./onboarding-connect.js";
import { onboardingFirstTask } from "./onboarding-first-task.js";
import { JourneyError, type JourneyContext, type JourneyEvidence, type TargetSession } from "./types.js";
@@ -69,11 +70,12 @@ export async function cloudOnboarding(input: CloudOnboardingInput): Promise { evidence.onboardingConnect = value; } });
const agents = await api.get(`/api/companies/${company.id}/agents`);
const agent = agents.find(a => a.name === name);
if (!agent || agents.length !== 1 || agent.adapterType !== "paperclip_runner" || agent.adapterConfig?.provider !== "codex" || agent.adapterConfig?.model !== input.gateway.model) throw new JourneyError("failed", "onboarding_agent_configuration_mismatch");
+ connect.checkpoint = "agent-verification";
await input.own({ kind: "agent", name, id: agent.id });
evidence.checks.push({ id: "ui-connection-and-native-agent", passed: true });
await input.own({ kind: "issue", name: "Paperclip onboarding" });
diff --git a/tests/runner-e2e/product-journeys/contracts.test.ts b/tests/runner-e2e/product-journeys/contracts.test.ts
index 754d6c7bab9..b02057281a3 100644
--- a/tests/runner-e2e/product-journeys/contracts.test.ts
+++ b/tests/runner-e2e/product-journeys/contracts.test.ts
@@ -13,6 +13,7 @@ import type { RunnerE2EResult } from "../types.js";
import type { JourneyContext, JourneyEvidence, TargetSession } from "./types.js";
import { createConnectionProof } from "../connection-evidence.js";
import { retainOnboardingArtifact } from "./artifact-evidence.js";
+import type { OnboardingConnectEvidence } from "./onboarding-connect.js";
vi.mock("@playwright/test", () => ({
expect: Object.assign(
(locator: { textContent(): Promise }) => ({ async toContainText(value: string) { expect(await locator.textContent()).toContain(value); } }),
@@ -29,7 +30,60 @@ function result(): RunnerE2EResult {
return { schema: "paperclip.runner-e2e.result/v2", suiteId: "product-journeys", suiteDefinitionHash: "test", executionId: "product-journeys.local-qa.local.task-properties", profileId: "local-qa", environmentId: "local", caseId: "task-properties", provider: null, model: null, runtimeMode: "none", status: "passed", cleanup: "passed", attempt: 1, startedAt: "2026-01-01T00:00:00Z", finishedAt: "2026-01-01T00:00:01Z", durationMs: 1000,
productJourney: { outcome: "passed", startingState: "recipe", phases: [{phase: "journey", outcome: "passed"}], checks: [{id:"assignment-persisted", passed:true},{id:"no-agent-work",passed:true}], privacy: "private", resourceDisposition: "released", deployment: { kind: "self-hosted", targetId: "local-qa", origin: "http://127.0.0.1:3100", commit: "a".repeat(40), cloudRelease: null, runtimeImage: null, configurationHash: "test" } } };
}
+function connectReceipt(): OnboardingConnectEvidence {
+ return {
+ version: 1, checkpoint: "hire",
+ validation: { state: "json", httpStatus: 200, requestedAdapter: "paperclip_runner", adapter: "paperclip_runner", outcome: "fail", serverCode: null, checks: [{ code: "adapter_auth_missing", level: "error" }], truncatedChecks: false },
+ hire: { state: "json", httpStatus: 422, requestedAdapter: null, adapter: null, outcome: null, serverCode: "agent_runner_unavailable", checks: [], truncatedChecks: false },
+ };
+}
describe("deployment journey contracts", () => {
+ it("accepts historical results without Connect diagnostics and optional closed receipts", () => {
+ const retained = result();
+ retained.caseId = "cloud-onboarding";
+ retained.status = "failed";
+ expect(() => validateRetainedRunnerResult(retained)).not.toThrow();
+ expect(renderCaseOutcome(retained, true, [])).not.toContain("Onboarding Connect diagnostics");
+ retained.productJourney!.onboardingConnect = connectReceipt();
+ expect(() => validateRetainedRunnerResult(JSON.parse(JSON.stringify(retained)))).not.toThrow();
+ retained.productJourney!.onboardingConnect.validation = { state: "pending", httpStatus: 200, requestedAdapter: "codex_local", adapter: null, outcome: null, serverCode: null, checks: [], truncatedChecks: false };
+ expect(() => validateRetainedRunnerResult(retained)).not.toThrow();
+ });
+ it.each([
+ ["request headers", () => ({ ...connectReceipt(), requestHeaders: { authorization: "must-not-retain" } })],
+ ["response bodies", () => ({ ...connectReceipt(), validation: { ...connectReceipt().validation, body: "must-not-retain" } })],
+ ["check messages", () => ({ ...connectReceipt(), validation: { ...connectReceipt().validation, checks: [{ code: "adapter_auth_missing", level: "error", message: "must-not-retain" }] } })],
+ ["unknown server codes", () => ({ ...connectReceipt(), hire: { ...connectReceipt().hire, serverCode: "raw-provider-error" } })],
+ ["unknown check codes", () => ({ ...connectReceipt(), validation: { ...connectReceipt().validation, checks: [{ code: "raw-provider-error", level: "error" }] } })],
+ ["unknown adapters", () => ({ ...connectReceipt(), validation: { ...connectReceipt().validation, adapter: "raw-provider-name" } })],
+ ["unknown requested adapters", () => ({ ...connectReceipt(), validation: { ...connectReceipt().validation, requestedAdapter: "raw-provider-name" } })],
+ ["unknown outcomes", () => ({ ...connectReceipt(), validation: { ...connectReceipt().validation, outcome: "raw-provider-message" } })],
+ ["unknown response states", () => ({ ...connectReceipt(), validation: { ...connectReceipt().validation, state: "raw-provider-message" } })],
+ ["unknown check levels", () => ({ ...connectReceipt(), validation: { ...connectReceipt().validation, checks: [{ code: "adapter_auth_missing", level: "fatal" }] } })],
+ ["oversized check arrays", () => ({ ...connectReceipt(), validation: { ...connectReceipt().validation, checks: Array.from({ length: 33 }, () => ({ code: "adapter_auth_missing", level: "error" })) } })],
+ ["invalid HTTP statuses", () => ({ ...connectReceipt(), validation: { ...connectReceipt().validation, httpStatus: 600 } })],
+ ["non-boolean truncation", () => ({ ...connectReceipt(), validation: { ...connectReceipt().validation, truncatedChecks: "true" } })],
+ ["missing response fields", () => ({ ...connectReceipt(), hire: { state: "missing" } })],
+ ["unsupported receipt versions", () => ({ ...connectReceipt(), version: 2 })],
+ ["unknown checkpoints", () => ({ ...connectReceipt(), checkpoint: "raw-browser-text" })],
+ ] as const)("rejects Connect receipts containing %s", (_label, makeReceipt) => {
+ const retained = result();
+ retained.productJourney!.onboardingConnect = makeReceipt() as OnboardingConnectEvidence;
+ expect(() => validateRetainedRunnerResult(retained)).toThrow("result.productJourney.onboardingConnect");
+ });
+ it("renders only validated Connect fields and marks missing or truncated evidence", () => {
+ const retained = result();
+ retained.productJourney!.onboardingConnect = connectReceipt();
+ const output = renderCaseOutcome(retained, true, []);
+ for (const text of ["Onboarding Connect diagnostics", "Last checkpoint", "Native validation", "Agent hire", "HTTP status", "Test endpoint adapter", "Returned adapter", "200", "422", "paperclip_runner", "adapter_auth_missing", "agent_runner_unavailable", "error", "Unavailable"]) expect(output).toContain(text);
+ retained.productJourney!.onboardingConnect.validation.truncatedChecks = true;
+ expect(renderCaseOutcome(retained, true, [])).toContain("Only the first 32 validation checks were retained");
+ Object.assign(retained.productJourney!.onboardingConnect.validation, { body: "" });
+ const invalid = renderCaseOutcome(retained, false, []);
+ expect(invalid).toContain("Onboarding Connect diagnostics unavailable: invalid receipt");
+ expect(invalid).not.toContain("must-not-render");
+ expect(invalid).not.toContain("adapter_auth_missing");
+ });
it("validates optional historical artifact evidence without trusting extra fields or a different run", () => {
const retained = result();
retained.caseId = "cloud-onboarding";
diff --git a/tests/runner-e2e/product-journeys/launch.ts b/tests/runner-e2e/product-journeys/launch.ts
index 2d0233bd2e9..410bb7725bf 100644
--- a/tests/runner-e2e/product-journeys/launch.ts
+++ b/tests/runner-e2e/product-journeys/launch.ts
@@ -99,7 +99,7 @@ export async function launchProductJourneys(args: string[], repositoryRoot: stri
try { sha = execFileSync("git", ["rev-parse", "HEAD"], { cwd: repositoryRoot, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); } catch { /* Packaged harnesses may not include Git metadata. */ }
const source = resolveRunnerE2ESource({ sha, ref: null, workflowRunUrl: null });
const definitionHash = createHash("sha256").update(JSON.stringify(productJourneys));
- for (const name of ["catalog.ts", "task-properties.ts", "cloud-onboarding.ts", "onboarding-first-task.ts", "artifact-evidence.ts", "fixtures.ts", "launch.ts", "types.ts", "collection.ts", "local-target.ts"]) definitionHash.update(await readFile(path.join(import.meta.dirname, name)));
+ for (const name of ["catalog.ts", "task-properties.ts", "cloud-onboarding.ts", "onboarding-connect.ts", "onboarding-first-task.ts", "artifact-evidence.ts", "fixtures.ts", "launch.ts", "types.ts", "collection.ts", "local-target.ts"]) definitionHash.update(await readFile(path.join(import.meta.dirname, name)));
const suiteDefinitionHash = definitionHash.digest("hex");
const expected = config.journeys.map(id => `product-journeys.${config.id}.${id === "cloud-onboarding" ? config.modelProfileId : "browser"}.${config.executionEnvironment}.${id}`);
let reportPersisted = false;
diff --git a/tests/runner-e2e/product-journeys/onboarding-connect.test.ts b/tests/runner-e2e/product-journeys/onboarding-connect.test.ts
new file mode 100644
index 00000000000..be9535cf0c7
--- /dev/null
+++ b/tests/runner-e2e/product-journeys/onboarding-connect.test.ts
@@ -0,0 +1,182 @@
+import { EventEmitter } from "node:events";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { onboardingConnect, validateOnboardingConnectEvidence, type OnboardingConnectEvidence } from "./onboarding-connect.js";
+
+const origin = "https://tenant.example.test", companyId = "company-1";
+const validation = `/api/companies/${companyId}/adapters/paperclip_runner/test-environment`;
+const hire = `/api/companies/${companyId}/agent-hires`;
+const secret = "PRIVATE_SENTINEL_do_not_retain";
+function fixture(options: { ready?: boolean; durationMs?: number; click?: () => Promise | void; wait?: () => Promise } = {}) {
+ const events = new EventEmitter();
+ let retained!: OnboardingConnectEvidence;
+ const page = Object.assign(events, { getByRole(_role: string, { name }: { name: string }) {
+ return { click: vi.fn(async () => { expect(name).toBe("Connect"); await options.click?.(); }),
+ waitFor: vi.fn(({ timeout }: { timeout: number }) => {
+ expect(name).toBe("Get started");
+ if (options.wait) return options.wait();
+ return options.ready ? Promise.resolve() : new Promise((_resolve, reject) => {
+ setTimeout(() => reject(Object.assign(new Error(secret), { name: "TimeoutError" })), timeout);
+ });
+ }) };
+ } });
+ const request = (path = validation, method = "POST", requestOrigin = origin) => ({ url: () => requestOrigin + path, method: () => method });
+ const respond = (body: unknown, status = 200, path = validation, method = "POST", requestOrigin = origin) => {
+ const req = request(path, method, requestOrigin);
+ const response = { request: () => req, status: () => status, body: () => Promise.resolve(Buffer.isBuffer(body) ? body : Buffer.from(JSON.stringify(body))) };
+ events.emit("request", req); events.emit("response", response);
+ return response;
+ };
+ const run = () => onboardingConnect({ page: page as any, origin, companyId, deadlineAt: Date.now() + (options.durationMs ?? 100), retain: value => { retained = value; } });
+ return { page, events, request, respond, run, receipt: () => retained };
+}
+afterEach(() => { vi.useRealTimers(); });
+
+describe("one onboarding Connect diagnostic boundary", () => {
+ it("does not press Connect after the action deadline already expired", async () => {
+ const click = vi.fn();
+ const f = fixture({ durationMs: 0, click });
+ await expect(f.run()).rejects.toMatchObject({ code: "onboarding_connect_timeout" });
+ expect(click).not.toHaveBeenCalled();
+ expect(f.receipt().checkpoint).toBe("connect");
+ expect(f.events.eventNames()).toEqual([]);
+ });
+
+ it("rejects a delayed failure body even after Get started becomes visible", async () => {
+ let resolveBody!: (bytes: Buffer) => void;
+ const body = new Promise(resolve => { resolveBody = resolve; });
+ let reviewSeen!: () => void;
+ const review = new Promise(resolve => { reviewSeen = resolve; });
+ const f = fixture({ wait: async () => { reviewSeen(); }, click: () => {
+ const req = f.request(); f.events.emit("request", req);
+ f.events.emit("response", { request: () => req, status: () => 200, body: () => body });
+ } });
+ const result = expect(f.run()).rejects.toMatchObject({ code: "onboarding_native_validation_failed" });
+ await review;
+ for (let i = 0; i < 10 && f.events.listenerCount("response"); i++) await Promise.resolve();
+ expect(f.events.listenerCount("response")).toBe(0);
+ resolveBody(Buffer.from(JSON.stringify({ status: "fail", checks: [] })));
+ await result;
+ expect(f.receipt().validation.outcome).toBe("fail");
+ expect(f.receipt().checkpoint).toBe("validation");
+ expect(f.events.eventNames()).toEqual([]);
+ });
+
+ it.each(["details", "top-level"])("retains a known %s server rejection without its free text", async location => {
+ const f = fixture({ click: () => { f.respond({ error: secret, ...(location === "details" ? { details: { code: "agent_runner_config_incompatible", fields: [secret] } } : { code: "agent_runner_unavailable" }) }, 422); } });
+ await expect(f.run()).rejects.toMatchObject({ code: "onboarding_native_validation_failed" });
+ expect(f.receipt()).toMatchObject({ checkpoint: "validation", validation: { state: "json", httpStatus: 422, serverCode: location === "details" ? "agent_runner_config_incompatible" : "agent_runner_unavailable" }, hire: { state: "missing" } });
+ expect(JSON.stringify(f.receipt())).not.toContain(secret);
+ validateOnboardingConnectEvidence(f.receipt());
+ expect(f.events.listenerCount("response")).toBe(0);
+ });
+
+ it.each([{ status: "fail", checks: [] }, { status: "pass", checks: [{ code: "adapter_auth_missing", level: "error" }] }])("fails on a 200 validation rejection: %j", async body => {
+ const f = fixture({ click: () => { f.respond(body); } });
+ await expect(f.run()).rejects.toMatchObject({ code: "onboarding_native_validation_failed" });
+ expect(f.receipt().validation.httpStatus).toBe(200);
+ });
+
+ it("retains the hire boundary rejection and does not wait for Get started", async () => {
+ const f = fixture({ click: () => { f.respond({ details: { code: "agent_runner_platform_probe_failed" }, error: secret }, 503, hire); } });
+ await expect(f.run()).rejects.toMatchObject({ code: "onboarding_agent_hire_failed" });
+ expect(f.receipt()).toMatchObject({ checkpoint: "hire", hire: { httpStatus: 503, serverCode: "agent_runner_platform_probe_failed" } });
+ });
+
+ it("projects only enums from malicious nested fields, including unknown codes and bounded checks", async () => {
+ const f = fixture({ ready: true, click: () => { f.respond({ adapterType: secret, status: secret, code: { credential: secret }, details: { code: secret },
+ message: secret, hint: secret, headers: { authorization: secret }, checks: Array.from({ length: 40 }, () => ({ code: secret, level: secret, message: secret, nested: { apiKey: secret } })) }); } });
+ const result = await f.run();
+ expect(result.validation).toMatchObject({ adapter: "unrecognized", outcome: "unrecognized", serverCode: "unrecognized", truncatedChecks: true });
+ expect(result.validation.checks).toHaveLength(32);
+ expect(result.validation.checks[0]).toEqual({ code: "unrecognized", level: "unrecognized" });
+ expect(JSON.stringify(result)).not.toContain(secret);
+ validateOnboardingConnectEvidence(result);
+ expect(() => validateOnboardingConnectEvidence({ ...result, validation: { ...result.validation, requestedAdapter: ["codex_local"] } })).toThrow();
+ });
+
+ it("ignores wrong origin, company, method, query, and responses without an in-action request", async () => {
+ vi.useFakeTimers();
+ const f = fixture({ click: () => {
+ f.respond({}, 500, validation, "POST", "https://foreign.example.test");
+ f.respond({}, 500, validation.replace(companyId, "company-2"));
+ f.respond({}, 500, validation, "GET");
+ f.respond({}, 500, validation + "?private=" + secret);
+ f.events.emit("response", { request: () => f.request(), status: () => 500 });
+ } });
+ const result = expect(f.run()).rejects.toMatchObject({ code: "onboarding_get_started_timeout" });
+ await vi.advanceTimersByTimeAsync(101); await result;
+ expect(f.receipt()).toMatchObject({ checkpoint: "connect", validation: { state: "missing" }, hire: { state: "missing" } });
+ expect(f.events.eventNames()).toEqual([]);
+ });
+
+ it.each([Buffer.from("{not-json"), Buffer.alloc(65_537, "x")])("keeps malformed or oversized bodies unknown and still requires Get started", async bytes => {
+ vi.useFakeTimers();
+ const f = fixture({ click: () => { f.respond(bytes); } });
+ const result = expect(f.run()).rejects.toMatchObject({ code: "onboarding_get_started_timeout" });
+ await vi.advanceTimersByTimeAsync(101); await result;
+ expect(f.receipt().validation.state).toBe(bytes.byteLength > 65_536 ? "oversized" : "unreadable");
+ expect(f.receipt().checkpoint).toBe("validation");
+ });
+
+ it("records missing responses and ignores late responses after timeout", async () => {
+ vi.useFakeTimers();
+ const f = fixture({ click: () => { f.events.emit("request", f.request()); } });
+ const result = expect(f.run()).rejects.toMatchObject({ code: "onboarding_get_started_timeout" });
+ await vi.advanceTimersByTimeAsync(101); await result;
+ const sealed = JSON.stringify(f.receipt());
+ f.respond({ status: "pass", adapterType: "paperclip_runner", checks: [] });
+ expect(JSON.stringify(f.receipt())).toBe(sealed);
+ expect(f.receipt().validation.state).toBe("missing");
+ });
+
+ it("bounds stalled JSON reads and cannot mutate retained evidence after return or reject unhandled", async () => {
+ vi.useFakeTimers();
+ let rejectBody!: (reason: Error) => void;
+ const body = new Promise((_resolve, reject) => { rejectBody = reject; });
+ const f = fixture({ ready: true, durationMs: 10_000, click: () => {
+ const req = f.request(); f.events.emit("request", req);
+ f.events.emit("response", { request: () => req, status: () => 200, body: () => body });
+ } });
+ const result = f.run();
+ await vi.advanceTimersByTimeAsync(5001);
+ expect((await result).validation.state).toBe("unreadable");
+ const sealed = JSON.stringify(f.receipt());
+ rejectBody(new Error(secret)); await Promise.resolve();
+ expect(JSON.stringify(f.receipt())).toBe(sealed);
+ expect(f.events.eventNames()).toEqual([]);
+ });
+
+ it("preserves the Get started timeout when a previously received body arrives later", async () => {
+ vi.useFakeTimers();
+ let resolveBody!: (bytes: Buffer) => void;
+ const body = new Promise(resolve => { resolveBody = resolve; });
+ const f = fixture();
+ const result = expect(f.run()).rejects.toMatchObject({ code: "onboarding_get_started_timeout" });
+ await vi.advanceTimersByTimeAsync(10);
+ const req = f.request(); f.events.emit("request", req);
+ f.events.emit("response", { request: () => req, status: () => 200, body: () => body });
+ await vi.advanceTimersByTimeAsync(90);
+ resolveBody(Buffer.from(JSON.stringify({ status: "fail" })));
+ await vi.advanceTimersByTimeAsync(1); await result;
+ expect(f.receipt().checkpoint).toBe("validation");
+ expect(f.events.eventNames()).toEqual([]);
+ });
+
+ it("keeps a browser error at its own boundary without retaining the browser text", async () => {
+ const f = fixture({ click: () => { throw new Error(secret); } });
+ await expect(f.run()).rejects.toMatchObject({ code: "onboarding_connect_failed" });
+ expect(f.receipt().checkpoint).toBe("connect");
+ expect(JSON.stringify(f.receipt())).not.toContain(secret);
+ expect(f.events.eventNames()).toEqual([]);
+ });
+
+ it("observes both successful responses and requires the visible review step", async () => {
+ const f = fixture({ ready: true, click: () => {
+ f.respond({ adapterType: "paperclip_runner", status: "pass", checks: [{ code: "codex_hello_probe_passed", level: "info", message: secret }] }, 200, validation.replace("paperclip_runner", "codex_local"));
+ f.respond({ agent: { id: secret }, approval: null }, 201, hire);
+ } });
+ expect(await f.run()).toMatchObject({ checkpoint: "review", validation: { requestedAdapter: "codex_local", adapter: "paperclip_runner", outcome: "pass", checks: [{ code: "codex_hello_probe_passed", level: "info" }] }, hire: { state: "json", httpStatus: 201 } });
+ expect(JSON.stringify(f.receipt())).not.toContain(secret);
+ expect(f.events.eventNames()).toEqual([]);
+ });
+});
diff --git a/tests/runner-e2e/product-journeys/onboarding-connect.ts b/tests/runner-e2e/product-journeys/onboarding-connect.ts
new file mode 100644
index 00000000000..4aa6e43becf
--- /dev/null
+++ b/tests/runner-e2e/product-journeys/onboarding-connect.ts
@@ -0,0 +1,131 @@
+import type { Page, Response, Request } from "@playwright/test";
+import { JourneyError } from "./types.js";
+
+export const onboardingServerCodes = ["agent_runner_config_incompatible", "agent_runner_unavailable", "agent_runner_platform_probe_failed", "managed_sandbox_unavailable", "environment_company_mismatch"] as const;
+export const onboardingCheckCodes = ["adapter_auth_missing", "codex_hello_probe_failed", "codex_hello_probe_passed", "codex_hello_probe_timeout", "codex_hello_probe_auth_required", "environment_lease_acquire_failed", "environment_workspace_realize_failed", "environment_target_failed", "environment_target_unsupported", "environment_not_found", "environment_target_unavailable", "managed_sandbox_unavailable", "environment_env_binding_missing", "environment_env_resolution_failed", "ai_connection_api_key_rejected", "ai_connection_verification_failed", "sandbox_test_identity"] as const;
+const checkpoints = ["connect", "validation", "hire", "review", "agent-verification"] as const;
+const states = ["missing", "pending", "json", "unreadable", "oversized"] as const;
+const requestedAdapters = ["codex_local", "paperclip_runner"] as const;
+const adapters = ["codex_local", "paperclip_runner", "unrecognized"] as const;
+const outcomes = ["pass", "warn", "fail", "unrecognized"] as const;
+const levels = ["info", "warn", "error", "unrecognized"] as const;
+type Known = T[number] | "unrecognized";
+export interface OnboardingResponseEvidence {
+ state: typeof states[number]; httpStatus: number | null;
+ requestedAdapter: "codex_local" | "paperclip_runner" | null;
+ adapter: typeof adapters[number] | null; outcome: typeof outcomes[number] | null;
+ serverCode: Known | null;
+ checks: Array<{ code: Known; level: typeof levels[number] }>;
+ truncatedChecks: boolean;
+}
+export interface OnboardingConnectEvidence {
+ version: 1; checkpoint: typeof checkpoints[number]; validation: OnboardingResponseEvidence; hire: OnboardingResponseEvidence;
+}
+const blank = (): OnboardingResponseEvidence => ({ state: "missing", httpStatus: null, requestedAdapter: null, adapter: null, outcome: null, serverCode: null, checks: [], truncatedChecks: false });
+const record = (v: unknown): v is Record => Boolean(v && typeof v === "object" && !Array.isArray(v));
+const member = (values: T, v: unknown): v is T[number] => typeof v === "string" && values.includes(v);
+const closed = (v: unknown, keys: string[]) => record(v) && Object.keys(v).length === keys.length && keys.every(key => Object.hasOwn(v, key));
+export function validateOnboardingConnectEvidence(value: unknown): void {
+ if (!closed(value, ["version", "checkpoint", "validation", "hire"]) || !record(value) || value.version !== 1 || !member(checkpoints, value.checkpoint)) throw new Error("invalid_onboarding_connect_evidence");
+ for (const response of [value.validation, value.hire]) {
+ if (!closed(response, ["state", "httpStatus", "requestedAdapter", "adapter", "outcome", "serverCode", "checks", "truncatedChecks"]) || !record(response) ||
+ !member(states, response.state) || !(response.httpStatus === null || Number.isSafeInteger(response.httpStatus) && Number(response.httpStatus) >= 100 && Number(response.httpStatus) <= 599) ||
+ !(response.requestedAdapter === null || member(requestedAdapters, response.requestedAdapter)) ||
+ !(response.adapter === null || member(adapters, response.adapter)) || !(response.outcome === null || member(outcomes, response.outcome)) ||
+ !(response.serverCode === null || response.serverCode === "unrecognized" || member(onboardingServerCodes, response.serverCode)) ||
+ typeof response.truncatedChecks !== "boolean" || !Array.isArray(response.checks) || response.checks.length > 32 ||
+ response.checks.some(check => !closed(check, ["code", "level"]) || !record(check) || !(check.code === "unrecognized" || member(onboardingCheckCodes, check.code)) || !member(levels, check.level))) throw new Error("invalid_onboarding_connect_evidence");
+ }
+}
+
+/** Observe only responses to this Connect action; never retain bodies, text, headers or browser errors. */
+export async function onboardingConnect(input: { page: Page; origin: string; companyId: string; deadlineAt: number; retain(value: OnboardingConnectEvidence): void }): Promise {
+ const { page } = input, receipt: OnboardingConnectEvidence = { version: 1, checkpoint: "connect", validation: blank(), hire: blank() };
+ input.retain(receipt);
+ let active = true, failure: JourneyError | undefined;
+ let signalFailure!: (error: JourneyError) => void;
+ const failed = new Promise(resolve => { signalFailure = resolve; });
+ const requests = new Set(), pending = new Set>();
+ const deadlineAt = Math.min(input.deadlineAt, Date.now() + 180_000);
+ const remaining = () => Math.max(1, deadlineAt - Date.now());
+ const advance = (checkpoint: OnboardingConnectEvidence["checkpoint"]) => {
+ if (checkpoints.indexOf(checkpoint) > checkpoints.indexOf(receipt.checkpoint)) receipt.checkpoint = checkpoint;
+ };
+ const reject = (code: string) => { failure ??= new JourneyError("failed", code); signalFailure(failure); };
+ const route = (request: Request): { kind: "validation" | "hire"; adapter: "codex_local" | "paperclip_runner" | null } | null => {
+ try {
+ const url = new URL(request.url());
+ if (request.method() !== "POST" || url.origin !== input.origin || url.search || url.hash) return null;
+ const prefix = `/api/companies/${encodeURIComponent(input.companyId)}`;
+ if (url.pathname === `${prefix}/agent-hires`) return { kind: "hire", adapter: null };
+ for (const adapter of ["codex_local", "paperclip_runner"] as const) if (url.pathname === `${prefix}/adapters/${adapter}/test-environment`) return { kind: "validation", adapter };
+ } catch { /* An unrelated or malformed URL is not this action's evidence. */ }
+ return null;
+ };
+ const onRequest = (request: Request) => { if (active && route(request)) requests.add(request); };
+ const onResponse = (response: Response) => {
+ const request = response.request(), matched = route(request);
+ if (!active || !matched || !requests.delete(request)) return;
+ const observed = receipt[matched.kind];
+ // One Connect action can report each boundary once. Later traffic cannot replace its failure.
+ if (observed.state !== "missing") return;
+ advance(matched.kind); observed.state = "pending";
+ const status = response.status();
+ observed.httpStatus = Number.isSafeInteger(status) && status >= 100 && status <= 599 ? status : null;
+ observed.requestedAdapter = matched.adapter;
+ if (response.status() >= 400) reject(matched.kind === "validation" ? "onboarding_native_validation_failed" : "onboarding_agent_hire_failed");
+ const classify = async () => {
+ let timer: ReturnType | undefined;
+ try {
+ const bytes = await Promise.race([response.body(), new Promise(resolve => { timer = setTimeout(() => resolve(null), Math.min(5000, remaining())); })]);
+ if (!active) return;
+ if (!bytes) { observed.state = "unreadable"; return; }
+ if (bytes.byteLength > 65_536) { observed.state = "oversized"; return; }
+ let body: unknown; try { body = JSON.parse(bytes.toString("utf8")); } catch { observed.state = "unreadable"; return; }
+ if (!record(body)) { observed.state = "unreadable"; return; }
+ observed.state = "json";
+ const detailCode = record(body.details) ? body.details.code : undefined;
+ const code = member(onboardingServerCodes, detailCode) ? detailCode : member(onboardingServerCodes, body.code) ? body.code : detailCode ?? body.code;
+ if (code !== undefined) observed.serverCode = member(onboardingServerCodes, code) ? code : "unrecognized";
+ if (matched.kind === "validation") {
+ observed.adapter = member(adapters, body.adapterType) ? body.adapterType : "unrecognized";
+ observed.outcome = member(outcomes, body.status) ? body.status : "unrecognized";
+ if (Array.isArray(body.checks)) {
+ observed.truncatedChecks = body.checks.length > 32;
+ observed.checks = body.checks.slice(0, 32).map(check => ({ code: record(check) && member(onboardingCheckCodes, check.code) ? check.code : "unrecognized", level: record(check) && member(levels, check.level) ? check.level : "unrecognized" }));
+ if (body.status === "fail" || body.checks.some(check => record(check) && check.code === "adapter_auth_missing")) reject("onboarding_native_validation_failed");
+ } else if (body.status === "fail") reject("onboarding_native_validation_failed");
+ }
+ } catch { if (active) observed.state = "unreadable"; }
+ finally { clearTimeout(timer); }
+ };
+ const work = classify(); pending.add(work); void work.then(() => pending.delete(work), () => pending.delete(work));
+ };
+ page.on("request", onRequest); page.on("response", onResponse);
+ try {
+ if (Date.now() >= deadlineAt) throw new JourneyError("failed", "onboarding_connect_timeout");
+ const clicked = page.getByRole("button", { name: "Connect", exact: true }).click({ timeout: remaining() })
+ .then(() => null, error => new JourneyError("failed", error?.name === "TimeoutError" ? "onboarding_connect_timeout" : "onboarding_connect_failed"));
+ const clickResult = await Promise.race([failed, clicked]);
+ if (clickResult) throw clickResult;
+ if (failure) throw failure;
+ const result = await Promise.race([failed, page.getByRole("button", { name: "Get started", exact: true }).waitFor({ timeout: remaining() })
+ .then(() => null, error => new JourneyError("failed", error?.name === "TimeoutError" ? "onboarding_get_started_timeout" : "onboarding_get_started_failed"))]);
+ const boundaryFailure = failure;
+ // Seal the observed boundary before awaiting body reads. A response arriving
+ // after Get started or its timeout cannot change this action's outcome.
+ page.off("request", onRequest); page.off("response", onResponse);
+ if (pending.size) await Promise.all([...pending]);
+ if (boundaryFailure) throw boundaryFailure;
+ if (result) throw result;
+ if (failure) throw failure;
+ if (Date.now() >= deadlineAt) throw new JourneyError("failed", "onboarding_get_started_timeout");
+ advance("review");
+ return receipt;
+ } finally {
+ // Settle only already observed responses within their bounded read; detach before later traffic.
+ page.off("request", onRequest); page.off("response", onResponse);
+ if (pending.size) await Promise.all([...pending]);
+ active = false; requests.clear();
+ }
+}
diff --git a/tests/runner-e2e/product-journeys/types.ts b/tests/runner-e2e/product-journeys/types.ts
index 65941f30d86..da0b054a606 100644
--- a/tests/runner-e2e/product-journeys/types.ts
+++ b/tests/runner-e2e/product-journeys/types.ts
@@ -28,6 +28,7 @@ export interface JourneyEvidence {
attemptId?: string;
operations?: JourneyOperations;
artifact?: OnboardingArtifactEvidence;
+ onboardingConnect?: import("./onboarding-connect.js").OnboardingConnectEvidence;
}
export interface OnboardingArtifactEvidence {
schema: "paperclip.product-journey.artifact/v1";
diff --git a/tests/runner-e2e/result-validation.ts b/tests/runner-e2e/result-validation.ts
index 883d928c510..56f38ed0bec 100644
--- a/tests/runner-e2e/result-validation.ts
+++ b/tests/runner-e2e/result-validation.ts
@@ -4,6 +4,7 @@ import { githubJourneyCheckpoints, gradeGitHubReviewJourney } from "./github-rev
import { STARTUP_REUSE_LAYERS, STARTUP_REUSE_REASONS } from "../../packages/adapter-utils/src/startup-reuse.js";
import type { RunnerE2ECampaignAccounting, RunnerE2EResult } from "./types.js";
import { DEPENDABLE_CELLS } from "./dependable-qualification.js";
+import { validateOnboardingConnectEvidence } from "./product-journeys/onboarding-connect.js";
import { validateOnboardingArtifact } from "./product-journeys/artifact-evidence.js";
type Rule = (value: unknown, at: string) => void;
@@ -344,6 +345,7 @@ const fields = {
productJourney: optional(shape({
targetId: optional(string),
campaignId: optional(string), attemptId: optional(string),
+ onboardingConnect: optional((value, at) => { try { validateOnboardingConnectEvidence(value); } catch { invalid(at); } }),
artifact: optional((value, at) => { try { validateOnboardingArtifact(value); } catch { invalid(at); } }),
operations: optional(closedShape({ campaignId: string, attemptId: string, modelCostUsd: nullable(number), setupCostUsd: nullable(number), requestCount: integer,
feedback: closedShape({ collection: oneOf("complete", "partial", "unavailable"), complaints: integer, suggestions: integer }), traceIds: array(string) })),
From a0e5166e974a7f6ffabe2a43f816a83449fdef57 Mon Sep 17 00:00:00 2001
From: Dotta <34892728+cryppadotta@users.noreply.github.com>
Date: Sat, 24 Oct 2026 21:39:21 -0700
Subject: [PATCH 19/70] Fix GitHub journey registration diagnostics and native
retry cleanup (#17513)
## Thinking Path
> - Paperclip manages work performed by AI agents.
> - The Product E2E harness verifies a GitHub review-bot journey.
> - GitHub can return a registration page without the expected creation
control.
> - The journey could then wait for the full login allowance without
useful evidence.
> - A failed native run can also retain a retry after its terminal
status is visible.
> - This change records a safe registration boundary and uses the normal
Stop route for owned retries.
> - Operators can diagnose the setup failure and confirm cleanup without
changing the behavioral oracle.
## Linked Issues or Issue Description
Refs: #17457.
**What happened?**
The GitHub review journey waited at App registration with no useful
handoff evidence. Its cleanup also skipped failed native runs that still
owned a durable retry.
**Expected behavior**
The journey should report a bounded registration failure or
reauthentication boundary. Cleanup should Stop an owned native retry and
require its audited acknowledgement.
**Steps to reproduce**
1. Run the configured GitHub review journey.
2. Return a GitHub registration page without the expected creation
button, or fail a native review run while its coordinator can retry.
3. Inspect the result and cleanup evidence.
**Paperclip version or commit**
Source built from current master. This extends the driver merged in
#17457.
## What Changed
- Bound the registration render wait to 30 seconds. Keep the separate
attended confirmation allowance.
- Retain GitHub origin, path, and control-presence flags. Retain
headings, button labels, and alerts only on known registration error
pages without authentication fields.
- Exclude query parameters, entered values, page bodies, grant payloads,
and authentication content from that diagnostic.
- Add an optional private authentication hook for ordinary GitHub access
reconfirmation. Detect passkey-first sudo at the manifest URL without
retaining its authentication content.
- Stop owned failed native retries through the existing run-only
cancellation route. Verify company, agent, task, and audit
acknowledgement.
- Use native Stop correlation only for native runs. Queued legacy or
unresolved runs use the ordinary cancel request. Stop scheduled retries
and wait for them to leave the live state set.
- Keep pending accounting debt intact. Add regression coverage and
journey documentation.
## Verification
- Focused journey and report suite: 58 tests passed.
- Runner E2E TypeScript check passed.
- `git diff --check` passed. Credential-value scan passed.
- Live fixture reproduced the missing creation control at the manifest
handoff. The new diagnostic retained its path and control-presence
flags. No model work started. Draft cleanup passed.
- All 58 current-head checks are terminal and green. Greptile scored the
exact head 5/5, with no unresolved review threads.
## Risks
GitHub pages that take more than 30 seconds to render the creation
control now fail early. Native cleanup fails closed if the server does
not provide the expected Stop acknowledgement. The live setup-to-Done
behavioral journey is still unqualified. These changes do not repair a
missing provider usage receipt or weaken its accounting gate.
## Model Used
OpenAI Codex, based on GPT-6. Exact runtime model identifier and
context-window size are not exposed in this session. Used for code
execution, browser tooling, and regression analysis.
## 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 (e.g. `docs/...`, `fix/...`)
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
Co-authored-by: Paperclip
---
tests/runner-e2e/GITHUB-REVIEW-JOURNEY.md | 9 ++
tests/runner-e2e/github-review-browser.ts | 48 +++++++++
tests/runner-e2e/github-review-flow.ts | 8 +-
.../runner-e2e/github-review-journey.test.ts | 101 +++++++++++++++++-
tests/runner-e2e/github-review-launch.ts | 19 ++--
tests/runner-e2e/github-review-ownership.ts | 31 ++++++
6 files changed, 203 insertions(+), 13 deletions(-)
diff --git a/tests/runner-e2e/GITHUB-REVIEW-JOURNEY.md b/tests/runner-e2e/GITHUB-REVIEW-JOURNEY.md
index ceeb56a95e0..12cddb6c387 100644
--- a/tests/runner-e2e/GITHUB-REVIEW-JOURNEY.md
+++ b/tests/runner-e2e/GITHUB-REVIEW-JOURNEY.md
@@ -89,6 +89,15 @@ connections, and completed task evidence. An unconfirmed cleanup fails the
attempt; retain the resource receipt for recovery. Read the receipt before any
manual cleanup. Never delete by a guessed name or broad account search.
+Registration render waits are bounded separately from attended confirmation.
+The driver retains only the GitHub origin/path and control-presence flags when
+the creation control is missing. On a known registration error page without auth
+fields, it also retains headings, button labels, and alerts. It excludes query
+parameters and authentication content. Reauthentication is a setup boundary,
+not evidence that an App exists.
+Cleanup also stops failed native runs that still own a retry, and requires the
+normal run-only Stop acknowledgement. It never clears pending accounting debt.
+
The configured deadline and observed run limit bound the attempt. They are not
a hard provider-dollar cap or proof that no request outlives cancellation. Use
the existing budgeted Cloud operations adapter before automated paid campaigns;
diff --git a/tests/runner-e2e/github-review-browser.ts b/tests/runner-e2e/github-review-browser.ts
index dffb516bcdd..b064d0ad384 100644
--- a/tests/runner-e2e/github-review-browser.ts
+++ b/tests/runner-e2e/github-review-browser.ts
@@ -10,6 +10,54 @@ export async function enterGitHubJourneyCompany(page: Page, origin: string, comp
await page.waitForURL(url => url.origin === origin && url.pathname === pathname);
}
+export interface GitHubRegistrationBoundary {
+ origin: string; pathname: string; passwordRequired: boolean; oneTimeCodeRequired: boolean;
+ reauthenticationRequired: boolean; createControlVisible: boolean;
+ controls?: { headings: string[]; buttons: string[]; alerts: string[] };
+}
+
+/** Keep only navigation and control-presence evidence. Authentication fields,
+ * page content, query parameters, and grant payloads must stay out of reports. */
+export async function waitForGitHubRegistrationControl(page: Page, owner: string, timeoutMs: number,
+ record: (boundary: GitHubRegistrationBoundary) => Promise = async () => {}, reauthenticate?: () => Promise) {
+ const control = page.getByRole("button", { name: new RegExp(`^Create GitHub App(?: for ${owner})?$`) });
+ const observe = async (includeErrorControls = false) => {
+ const url = new URL(page.url());
+ if (url.origin !== "https://github.com") throw new ConnectionFailure("github_registration_origin_mismatch");
+ const boundary: GitHubRegistrationBoundary = { origin: url.origin, pathname: url.pathname,
+ passwordRequired: await page.locator('input[type="password"]:visible').count() > 0,
+ oneTimeCodeRequired: await page.locator('input[autocomplete="one-time-code"]:visible').count() > 0,
+ reauthenticationRequired: await page.getByRole("heading", { name: /^Confirm access$/i }).isVisible()
+ || await page.getByRole("button", { name: /^Use (?:your password|passkey)$/i }).isVisible(),
+ createControlVisible: await control.isVisible() };
+ boundary.reauthenticationRequired ||= boundary.passwordRequired || boundary.oneTimeCodeRequired
+ || /\/(?:login|sessions)(?:\/|$)/.test(boundary.pathname);
+ if (includeErrorControls && !boundary.reauthenticationRequired
+ && ["/settings/apps/new", "/settings/apps/manifest"].includes(url.pathname)) {
+ boundary.controls = { headings: await page.getByRole("heading").allTextContents(),
+ buttons: await page.getByRole("button").allTextContents(), alerts: await page.getByRole("alert").allTextContents() };
+ }
+ await record(boundary);
+ return boundary;
+ };
+ await observe();
+ let authenticationAttempted = false;
+ for (;;) {
+ try { await control.waitFor({ timeout: Math.min(timeoutMs, 30_000) }); break; }
+ catch (error) {
+ if (page.isClosed()) throw error;
+ const boundary = await observe(true);
+ if (boundary.reauthenticationRequired) {
+ if (reauthenticate && !authenticationAttempted) { authenticationAttempted = true; await reauthenticate(); continue; }
+ throw new ConnectionBlock("awaiting_user", "github_registration_reauthentication_required");
+ }
+ throw new ConnectionFailure("github_registration_create_control_missing");
+ }
+ }
+ await observe();
+ return control;
+}
+
/** Capture measured pages only, excluding authentication and unrelated GitHub data. */
export function isGitHubJourneyEvidencePage(value: string, targetOrigin: string, repository: string, appSlug?: string) {
const url = new URL(value);
diff --git a/tests/runner-e2e/github-review-flow.ts b/tests/runner-e2e/github-review-flow.ts
index c580ed767fa..5547adf21a2 100644
--- a/tests/runner-e2e/github-review-flow.ts
+++ b/tests/runner-e2e/github-review-flow.ts
@@ -3,7 +3,7 @@ import type { GitHubTaskReview } from "../../packages/shared/src/types/chat-gith
import type { GitHubRepositoryPage } from "../../packages/shared/src/types/chat-channels.js";
import { ConnectionBlock, ConnectionFailure } from "./connection-config.js";
import { ConnectionApi } from "./connection-target.js";
-import { awaitJourneyCondition, createGitHubJourneyPullRequest, enterGitHubJourneyCompany, observeGitHubHead, selectGitHubJourneyRepository } from "./github-review-browser.js";
+import { awaitJourneyCondition, createGitHubJourneyPullRequest, enterGitHubJourneyCompany, observeGitHubHead, selectGitHubJourneyRepository, waitForGitHubRegistrationControl, type GitHubRegistrationBoundary } from "./github-review-browser.js";
import { githubCartFixture, hasOnlyOpenedAutomaticReview, matchesGitHubJourneyEnvironment, retainGitHubReview, verifyGitHubReview, verifyGitHubReviewRun, type GitHubReviewJourneyEvidence } from "./github-review-evidence.js";
import type { GitHubReviewJourneyConfig } from "./github-review-config.js";
import { captureOwnedGitHubApp, readOwnedGitHubReviewRuns, type GitHubJourneyResources } from "./github-review-ownership.js";
@@ -15,6 +15,8 @@ export async function runGitHubReviewFlow(input: {
checkpoint(id: keyof GitHubReviewJourneyEvidence["checkpoints"], page: Page): Promise;
phase(id: string): Promise;
persistResources(): Promise;
+ recordRegistrationBoundary?(boundary: GitHubRegistrationBoundary): Promise;
+ authenticateRegistration?(page: Page): Promise;
}) {
const { page, github, api, config, resources, evidence } = input;
const capabilities = await api.get('/api/instance/settings/experimental');
@@ -69,8 +71,8 @@ export async function runGitHubReviewFlow(input: {
// Manifest flow navigates the same tab. External App grants remain attended.
evidence.assisted = true;
await awaitJourneyCondition(async () => new URL(page.url()).hostname === "github.com", 30_000, "github_manifest_handoff_missing", input.signal);
- const createApp = page.getByRole("button", { name: new RegExp(`^Create GitHub App(?: for ${config.github.appOwner})?$`) });
- await createApp.waitFor({ timeout: config.browser.loginTimeoutMs });
+ const createApp = await waitForGitHubRegistrationControl(page, config.github.appOwner, config.browser.loginTimeoutMs,
+ input.recordRegistrationBoundary, input.authenticateRegistration ? () => input.authenticateRegistration!(page) : undefined);
await page.bringToFront();
await input.checkpoint("app_creation_consent", page);
console.log(`[github-review-journey] Authorize creation of only ${resources.appName} in the dedicated QA browser. Install it only on ${config.github.repository}.`);
diff --git a/tests/runner-e2e/github-review-journey.test.ts b/tests/runner-e2e/github-review-journey.test.ts
index 8489b4596b0..09ce3e869dd 100644
--- a/tests/runner-e2e/github-review-journey.test.ts
+++ b/tests/runner-e2e/github-review-journey.test.ts
@@ -8,9 +8,9 @@ import type { GitHubTaskReview } from "../../packages/shared/src/types/chat-gith
import { verifyConnectionTarget } from "./connection-target.js";
import { parseConnectionConfig } from "./connection-config.js";
import type { APIRequestContext } from "@playwright/test";
-import { captureOwnedGitHubApp, readOwnedGitHubReviewRuns, type GitHubJourneyResources } from "./github-review-ownership.js";
+import { captureOwnedGitHubApp, readOwnedGitHubReviewRuns, stopOwnedGitHubReviewRun, type GitHubJourneyResources } from "./github-review-ownership.js";
import type { ChatEndpoint } from "../../packages/shared/src/types/chat-channels.js";
-import { enterGitHubJourneyCompany, isGitHubJourneyEvidencePage } from "./github-review-browser.js";
+import { enterGitHubJourneyCompany, isGitHubJourneyEvidencePage, waitForGitHubRegistrationControl } from "./github-review-browser.js";
import { attendGitHubJourneyAction } from "./github-review-attendance.js";
import { PassThrough } from "node:stream";
import type { Locator, Page } from "@playwright/test";
@@ -19,7 +19,104 @@ import { runGitHubReviewCampaign } from "./github-review-launch.js";
import type { ConnectionApi } from "./connection-target.js";
import { runGitHubReviewFlow } from "./github-review-flow.js";
+describe("GitHub registration handoff", () => {
+ const page = (pathname: string, password: boolean, available: boolean) => ({
+ url: () => `https://github.com${pathname}?return_to=private-grant&token=never-retain`, isClosed: () => false,
+ locator: (selector: string) => ({ count: async () => selector.includes('password') && password ? 1 : 0 }),
+ getByRole: (_role?: string, options?: { name?: string | RegExp }) => ({ isVisible: async () => String(options?.name).includes('Create GitHub App') && available, allTextContents: async () => ['Registration error'],
+ waitFor: vi.fn(async () => { if (!available) throw new Error("control_missing"); }) }),
+ });
+ it("classifies a reauthentication boundary and retains no query or auth content", async () => {
+ const record = vi.fn();
+ await expect(waitForGitHubRegistrationControl(page('/sessions/sudo', true, false) as unknown as Page, 'fixture', 600000, record))
+ .rejects.toThrow('github_registration_reauthentication_required');
+ expect(record.mock.calls.at(-1)?.[0]).toEqual({ origin: 'https://github.com', pathname: '/sessions/sudo', passwordRequired: true, oneTimeCodeRequired: false, reauthenticationRequired: true, createControlVisible: false });
+ expect(JSON.stringify(record.mock.calls)).not.toMatch(/private-grant|never-retain/);
+ });
+ it("fails a missing creation control on an ordinary registration page", async () => {
+ const record = vi.fn();
+ await expect(waitForGitHubRegistrationControl(page('/settings/apps/manifest', false, false) as unknown as Page, 'fixture', 600000, record))
+ .rejects.toThrow('github_registration_create_control_missing');
+ expect(record.mock.calls.at(-1)?.[0].controls.alerts).toEqual(['Registration error']);
+ });
+ it("admits the ordinary creation control with a bounded render wait", async () => {
+ const target = page('/settings/apps/new', false, true);
+ const control = target.getByRole('button', { name: /^Create GitHub App/ });
+ const get = target.getByRole;
+ target.getByRole = (role, options) => String(options?.name).includes('Create GitHub App') ? control : get(role, options);
+ await expect(waitForGitHubRegistrationControl(target as unknown as Page, 'fixture', 600000)).resolves.toBe(control);
+ expect(control.waitFor).toHaveBeenCalledWith({ timeout: 30000 });
+ });
+ it("keeps passkey-first sudo at the manifest path out of authentication content diagnostics", async () => {
+ const target = page('/settings/apps/manifest', false, false), get = target.getByRole;
+ target.getByRole = (role, options) => String(options?.name).includes('Confirm access')
+ ? { ...get(role, options), isVisible: async () => true } : get(role, options);
+ const record = vi.fn();
+ await expect(waitForGitHubRegistrationControl(target as unknown as Page, 'fixture', 600000, record))
+ .rejects.toThrow('github_registration_reauthentication_required');
+ expect(record.mock.calls.at(-1)?.[0].reauthenticationRequired).toBe(true);
+ expect(record.mock.calls.at(-1)?.[0].controls).toBeUndefined();
+ });
+ it("uses a supplied ordinary reauthentication adapter once before retrying creation readiness", async () => {
+ const target = page('/settings/apps/manifest', true, false), get = target.getByRole;
+ let authenticated = false;
+ const control = { ...get(), isVisible: async () => authenticated,
+ waitFor: vi.fn(async () => { if (!authenticated) throw new Error('sudo_required'); }) };
+ target.getByRole = (role, options) => String(options?.name).includes('Create GitHub App') ? control : get(role, options);
+ const authenticate = vi.fn(async () => { authenticated = true; });
+ await expect(waitForGitHubRegistrationControl(target as unknown as Page, 'fixture', 600000, async () => {}, authenticate)).resolves.toBe(control);
+ expect(authenticate).toHaveBeenCalledOnce();
+ });
+});
+
describe("reused target ownership", () => {
+ it("stops a scheduled legacy retry and waits until its cancellation finishes", async () => {
+ const scope = { companyId: 'company', agentId: 'agent', issueId: 'issue' };
+ const scheduled = { ...scope, id: 'run', status: 'scheduled_retry', runtimeMode: 'legacy' };
+ const get = vi.fn().mockResolvedValueOnce(scheduled).mockResolvedValueOnce(scheduled)
+ .mockResolvedValue({ ...scheduled, status: 'cancelled' });
+ const post = vi.fn().mockResolvedValue({});
+ await stopOwnedGitHubReviewRun({ get, post } as unknown as ConnectionApi, 'run', scope, new AbortController().signal);
+ expect(post).toHaveBeenCalledExactlyOnceWith('/api/heartbeat-runs/run/cancel', {});
+ expect(get).toHaveBeenCalledTimes(3);
+ });
+ it.each(['legacy', undefined])("stops a queued %s run without a native-only cancellation request", async runtimeMode => {
+ const scope = { companyId: 'company', agentId: 'agent', issueId: 'issue' };
+ const queued = { ...scope, id: 'run', status: 'queued', runtimeMode };
+ const get = vi.fn().mockResolvedValueOnce(queued).mockResolvedValue({ ...queued, status: 'cancelled' });
+ const post = vi.fn(async (_route: string, body: Record) => {
+ if (body.cancellationRequestId) throw new Error('Correlated Stop requires a native run');
+ return {};
+ });
+ await stopOwnedGitHubReviewRun({ get, post } as unknown as ConnectionApi, 'run', scope, new AbortController().signal);
+ expect(post).toHaveBeenCalledExactlyOnceWith('/api/heartbeat-runs/run/cancel', {});
+ expect(get).toHaveBeenCalledTimes(2);
+ });
+ it.each([true, false])("stops a failed native retry and verifies its audited acknowledgement (dispatched=%s)", async dispatched => {
+ const scope = { companyId: 'company', agentId: 'agent', issueId: 'issue' };
+ const failed = { ...scope, id: 'run', status: 'failed', runtimeMode: 'native', nativePhase: 'retryable_failure' };
+ const get = vi.fn().mockResolvedValueOnce(failed).mockResolvedValue({ ...failed, status: 'cancelled', costAccountingPending: true,
+ resultJson: { nativeCancellation: { schema: 'paperclip.native-cancellation.v1', runId: 'run', companyId: 'company', issueId: 'issue', scope: 'run', reasonCode: 'cancellation_run_only', dispatched, dispatchState: 'acknowledged', intentAuditId: 'intent', acknowledgementAuditId: 'ack' } } });
+ const post = vi.fn().mockResolvedValue({});
+ await stopOwnedGitHubReviewRun({ get, post } as unknown as ConnectionApi, 'run', scope, new AbortController().signal);
+ expect(post).toHaveBeenCalledExactlyOnceWith('/api/heartbeat-runs/run/cancel', { cancellationRequestId: expect.any(String) });
+ expect(get).toHaveBeenCalledTimes(2);
+ });
+ it("refuses to stop a related run belonging to a different task", async () => {
+ const post = vi.fn();
+ const api = { get: async () => ({ id: 'run', companyId: 'company', agentId: 'agent', issueId: 'other', status: 'running' }), post };
+ await expect(stopOwnedGitHubReviewRun(api as unknown as ConnectionApi, 'run', { companyId: 'company', agentId: 'agent', issueId: 'issue' }, new AbortController().signal))
+ .rejects.toThrow('cleanup_run_owner_mismatch');
+ expect(post).not.toHaveBeenCalled();
+ });
+ it("does not accept cancelled status as native Stop acknowledgement", async () => {
+ const scope = { companyId: 'company', agentId: 'agent', issueId: 'issue' };
+ const get = vi.fn().mockResolvedValueOnce({ ...scope, id: 'run', status: 'running', runtimeMode: 'native' })
+ .mockResolvedValue({ ...scope, id: 'run', status: 'cancelled', runtimeMode: 'native' });
+ await expect(stopOwnedGitHubReviewRun({ get, post: async () => ({}) } as unknown as ConnectionApi, 'run', scope, new AbortController().signal, 1))
+ .rejects.toThrow('owned_run_cancellation_unconfirmed');
+ expect(get.mock.calls.length).toBeGreaterThan(1);
+ });
it("admits an inherited environment before setup and still leaves actual-run verification mandatory", async () => {
const setup = parseGitHubReviewJourneyConfig(config());
const company = { id: setup.target.companyId, issuePrefix: "QA" };
diff --git a/tests/runner-e2e/github-review-launch.ts b/tests/runner-e2e/github-review-launch.ts
index a78365f6add..83f4667d90c 100644
--- a/tests/runner-e2e/github-review-launch.ts
+++ b/tests/runner-e2e/github-review-launch.ts
@@ -12,7 +12,7 @@ import { createPrivateJourneyRepository, ensureGitHubJourneyLogin, closeOwnedGit
import { loadGitHubReviewJourneyConfig } from "./github-review-config.js";
import { gradeGitHubReviewJourney, type GitHubReviewJourneyEvidence } from "./github-review-evidence.js";
import { runGitHubReviewFlow } from "./github-review-flow.js";
-import { captureOwnedGitHubApp, readOwnedGitHubReviewRuns, type GitHubJourneyResources } from "./github-review-ownership.js";
+import { captureOwnedGitHubApp, readOwnedGitHubReviewRuns, stopOwnedGitHubReviewRun, type GitHubJourneyResources } from "./github-review-ownership.js";
import { JourneyDependencyError, resolveJourneyCredential } from "./journey-dependencies.js";
import { sanitizeJson, assertSecretFree } from "./redaction.js";
import { writeGitHubReviewReport } from "./github-review-report.js";
@@ -101,7 +101,14 @@ export async function runGitHubReviewCampaign(input: { executions: readonly Matr
const identity = await verifyConnectionTarget(config.target.baseURL, common, browser.context.request);
evidence.targetCommit = identity.commit; evidence.checkpoints.target = true;
await runGitHubReviewFlow({ page, github, githubUsername: config.github.account, api, config, resources, evidence, signal,
- phase: checkpoint, persistResources: () => save("owned-resources.json", resources), checkpoint: async (id, tab) => {
+ phase: checkpoint, persistResources: () => save("owned-resources.json", resources),
+ ...(typeof authentication?.authenticateGitHubJourneyRegistration === "function" ? {
+ authenticateRegistration: (tab: Page) => authentication.authenticateGitHubJourneyRegistration({ page: tab,
+ configFile: config!.authentication!.configFile, expectedAccount: config!.github.account, signal,
+ deadlineAt: Date.now() + Math.min(config!.browser.loginTimeoutMs, 120_000),
+ registerSecret: (value: string) => secrets.push(value), checkpoint: (id: string) => checkpoint(id) }),
+ } : {}),
+ recordRegistrationBoundary: boundary => save("github-registration-boundary.json", boundary), checkpoint: async (id, tab) => {
evidence.checkpoints[id] = true;
// Authentication pages and forms are deliberately never captured.
const consentUrl = new URL(tab.url());
@@ -180,12 +187,8 @@ export async function runGitHubReviewCampaign(input: { executions: readonly Matr
await save("owned-resources.json", resources);
await api.post(`/api/chat-endpoints/${resources.endpointId}/setup`, { action: "remove" });
for (const runId of resources.runIds) {
- const run = await api.get(`/api/heartbeat-runs/${runId}`);
- if (run.companyId !== config!.target.companyId || run.agentId !== config!.agent.id) throw new ConnectionFailure("cleanup_run_owner_mismatch");
- if (["queued", "running"].includes(run.status)) {
- await api.post(`/api/heartbeat-runs/${runId}/cancel`);
- await awaitJourneyCondition(async () => !["queued", "running"].includes((await api!.get(`/api/heartbeat-runs/${runId}`)).status), 30_000, "owned_run_cancellation_unconfirmed", cleanupSignal, "failed");
- }
+ if (!resources.issueId) throw new ConnectionFailure("cleanup_run_issue_missing");
+ await stopOwnedGitHubReviewRun(api, runId, { companyId: config!.target.companyId, agentId: config!.agent.id, issueId: resources.issueId }, cleanupSignal);
}
if (resources.issueId && result.status !== "passed") {
const issue = await api.get(`/api/issues/${resources.issueId}`);
diff --git a/tests/runner-e2e/github-review-ownership.ts b/tests/runner-e2e/github-review-ownership.ts
index f1d15ee4855..867ff16e84c 100644
--- a/tests/runner-e2e/github-review-ownership.ts
+++ b/tests/runner-e2e/github-review-ownership.ts
@@ -2,6 +2,10 @@ import type { ChatEndpoint } from "../../packages/shared/src/types/chat-channels
import { ConnectionFailure } from "./connection-config.js";
import type { GitHubReviewJourneyConfig } from "./github-review-config.js";
import type { ConnectionApi } from "./connection-target.js";
+import { randomUUID } from "node:crypto";
+import { awaitJourneyCondition } from "./github-review-browser.js";
+
+const liveRunStatuses = new Set(["queued", "running", "scheduled_retry"]);
/** The issue run source includes history beyond company list windows. Related
* activity can name a run for another task, so hydrate and check direct ownership. */
@@ -13,6 +17,33 @@ export async function readOwnedGitHubReviewRuns(api: Pick,
return runs.filter(run => run.issueId === scope.issueId && run.agentId === scope.agentId);
}
+/** A failed native run can still own a durable retry. Stop it through the
+ * normal audited route and require acknowledgement, not just terminal status. */
+export async function stopOwnedGitHubReviewRun(api: Pick, runId: string,
+ scope: { companyId: string; agentId: string; issueId: string }, signal: AbortSignal, timeoutMs = 30_000) {
+ const read = async () => {
+ const run = await api.get>(`/api/heartbeat-runs/${runId}`);
+ if (run.id !== runId || run.companyId !== scope.companyId || run.agentId !== scope.agentId || run.issueId !== scope.issueId) {
+ throw new ConnectionFailure("cleanup_run_owner_mismatch");
+ }
+ return run;
+ };
+ const run = await read();
+ const retry = run.runtimeMode === "native" && run.status === "failed" && run.nativePhase === "retryable_failure";
+ if (!liveRunStatuses.has(run.status) && !retry) return;
+ await api.post(`/api/heartbeat-runs/${runId}/cancel`, run.runtimeMode === "native" ? { cancellationRequestId: randomUUID() } : {});
+ await awaitJourneyCondition(async () => {
+ const stopped = await read();
+ if (run.runtimeMode !== "native") return !liveRunStatuses.has(stopped.status);
+ const intent = stopped.resultJson?.nativeCancellation;
+ return stopped.status === "cancelled" && intent?.schema === "paperclip.native-cancellation.v1"
+ && intent.runId === runId && intent.companyId === scope.companyId && intent.issueId === scope.issueId
+ && intent.scope === "run" && intent.reasonCode === "cancellation_run_only" && typeof intent.dispatched === "boolean"
+ && intent.dispatchState === "acknowledged" && typeof intent.intentAuditId === "string" && intent.intentAuditId.length > 0
+ && typeof intent.acknowledgementAuditId === "string" && intent.acknowledgementAuditId.length > 0;
+ }, timeoutMs, "owned_run_cancellation_unconfirmed", signal, "failed");
+}
+
export interface GitHubJourneyResources {
endpointId?: string; pullNumber?: number; branch: string; appName: string;
appId?: string; appSlug?: string; appSettingsUrl?: string; installationSettingsUrl?: string;
From 8415f4a679689eab68af5d41331639cacf7fd6cd Mon Sep 17 00:00:00 2001
From: Dotta <34892728+cryppadotta@users.noreply.github.com>
Date: Sat, 24 Oct 2026 21:58:43 -0700
Subject: [PATCH 20/70] fix(github): drain queued pings during active App setup
(#17551)
## Thinking Path
> - Paperclip connects external work to an agent and its ordinary tasks.
> - A dedicated GitHub App must prove signed webhook delivery during
setup.
> - Cloud queues the original bytes until the tenant can authenticate
them.
> - An older personal connection can leave a long empty-queue backoff in
that tenant.
> - Active App setup can then time out before the queued ping is read.
> - This change lets active setup request the existing event drain with
a short rate limit.
> - The same webhook handler still owns signature verification and setup
state.
## Linked Issues or Issue Description
Refs: #17457, #17513.
**What happened?**
Cloud accepted a new App's ping, but the tenant read it only after the
setup wait timed out and cleanup archived the draft. An existing
personal GitHub connection had left an empty-queue backoff. The poller
checked that deadline before it read the new App state.
**Expected behavior**
An active manager setup request must be able to read the queued ping
promptly. It must not bypass signature checks or create another App.
**Steps to reproduce**
1. Let a personal GitHub connection build its empty-queue backoff.
2. Start a dedicated App and complete its installation.
3. Return to the waiting setup screen while the signed ping is queued.
**Paperclip version or commit**
This behavior is present on the base commit of this PR.
## What Changed
- Let the authenticated wizard request a queue drain only while its
Cloud App still lacks signed-delivery verification. Existing manager,
credential, and installation checks run first.
- Register a narrow callback to the server-owned GitHub poller. Keep
provider handling in the GitHub services. Remove the callback on server
close.
- Share one in-flight attempt between setup and background requests.
Limit setup attempts to one per five seconds, including after errors.
- Retain the existing passive backoff, sealed queue, acknowledgement
rules, and canonical webhook signature handler.
- Add regressions for the old backoff, concurrent requests, failed
requests, and wizard signature verification. Update the setup
documentation and map.
## Verification
- Observed the backoff regression fail against the old service: active
setup made no additional Cloud lease request. It passes with this fix.
- Three focused event-poller tests pass. They use the real
admission/backoff code with stubbed SQL and Cloud boundaries.
- All 39 startup tests pass. Server TypeScript, map checks, and diff
checks pass.
- The Postgres integration suites could not start locally because this
host exhausted its semaphore limit. They remain required CI checks. This
is not a passing local integration result.
- Full current-head CI passes, including the Postgres and chat
integration suites. Greptile scored this exact head 5/5 with no
unresolved findings.
- The additional Paperclip review check ended neutral because its
execution did not complete. It did not publish a validated assessment.
- A fresh deployed setup attempt remains required before live
qualification.
## Risks
A waiting setup screen now makes a bounded readiness request to Cloud.
The existing connector request timeout still applies. Concurrent calls
share the same drain, and failures release it for a later retry. The
request stops after persisted verification or when the browser stops
advancing setup. No timer, durable queue, fleet setting, or sleep
blocker is added or removed. The existing background reconciliation path
still has its existing sleep restrictions. Live setup-to-review behavior
is not established by these local tests.
## Model Used
OpenAI Codex, based on GPT-6. The exact runtime model ID and
context-window size are not exposed in this session. Used for code
execution and regression analysis.
## 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 (e.g. `docs/...`, `fix/...`)
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/connections/GITHUB-REVIEW-BOT.md | 7 +
maps/feature-map/connection-setup.md | 9 +
.../chat-channels.integration.test.ts | 25 ++-
.../github-connection-events.test.ts | 20 +-
server/src/index.ts | 12 +-
.../src/services/chat-github-cloud-ingress.ts | 12 ++
server/src/services/chat-github-wizard.ts | 5 +
.../services/github-connection-events.test.ts | 74 ++++++++
.../src/services/github-connection-events.ts | 171 ++++++++++--------
9 files changed, 243 insertions(+), 92 deletions(-)
create mode 100644 server/src/services/github-connection-events.test.ts
diff --git a/doc/connections/GITHUB-REVIEW-BOT.md b/doc/connections/GITHUB-REVIEW-BOT.md
index 2d9dc55197b..f63732c56a4 100644
--- a/doc/connections/GITHUB-REVIEW-BOT.md
+++ b/doc/connections/GITHUB-REVIEW-BOT.md
@@ -40,6 +40,13 @@ Installation may return before GitHub's separately delivered signed webhook
ping. Paperclip keeps the same draft and credentials, shows a waiting state,
and continues automatically after authenticating the ping. Refreshing or
resuming that draft must not create another App or bypass signature verification.
+While this Cloud setup screen is waiting, its authenticated advance request also
+drains the existing sealed event queue. It can bypass an older idle-connection
+backoff, at most once every five seconds. Setup and background requests share
+one in-flight lease. The normal stack webhook handler still authenticates the
+original GitHub bytes before setup can continue. This adds no background timer;
+closing the screen stops these readiness requests. Existing background recovery
+and its sleep restrictions remain unchanged.
New connections default to authorized mentions, advisory reviews, linked-member
access, guests off, and enabled bot GitHub tools. Existing instructions, explicit
diff --git a/maps/feature-map/connection-setup.md b/maps/feature-map/connection-setup.md
index 22306048e8a..d27fc36c657 100644
--- a/maps/feature-map/connection-setup.md
+++ b/maps/feature-map/connection-setup.md
@@ -205,6 +205,15 @@ configuration changes, saved retry deadlines, rollback, and parking pending
repair while an endpoint or connection is inactive. The provider responses are
fixtures. Settled maintenance leaves no discovery timer or database scans.
+GitHub Cloud setup also covers an older empty-queue backoff, concurrent setup and
+background leases, and retry after a Cloud error in the
+[event poller regression](../../server/src/services/github-connection-events.test.ts).
+Channel integration checks that active setup drains its signed ping through the
+normal webhook handler; a forged ping still cannot verify the endpoint. These
+fixtures do not establish live Cloud delivery. For live proof, start a disposable
+App after an idle personal GitHub connection, complete installation, and confirm
+that the same draft advances after authenticating the queued ping.
+
Manual: complete setup for one named provider, reload endpoint settings, link
the intended sender, and send a disposable message. Verify one task and a useful
reply in the originating conversation. Inspect an unlinked sender's denial and
diff --git a/server/src/__tests__/chat-channels.integration.test.ts b/server/src/__tests__/chat-channels.integration.test.ts
index 4fd186e6271..beb2c0b436a 100644
--- a/server/src/__tests__/chat-channels.integration.test.ts
+++ b/server/src/__tests__/chat-channels.integration.test.ts
@@ -24,7 +24,7 @@ import { toolActionRequests, toolInvocations, toolPolicies } from "@paperclipai/
import { GitHubPublicationLeaseLost, withGitHubPublicationLease } from "../services/chat-github-publication-lease.js";
import { secretService } from "../services/secrets.js";
import { githubChatWizardService } from "../services/chat-github-wizard.js";
-import { dispatchGitHubBotCloudEvent } from "../services/chat-github-cloud-ingress.js";
+import { dispatchGitHubBotCloudEvent, registerGitHubBotCloudSetupPoller } from "../services/chat-github-cloud-ingress.js";
import { githubChatManagementService } from "../services/chat-github-management.js";
import { githubChatReviewService } from "../services/chat-github-reviews.js";
import { resolveGitHubReviewInstructions } from "../services/chat-channels/github/review-instructions.js";
@@ -5135,13 +5135,22 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
(await f.service.get(f.endpoint.id)).setup.webhookVerifiedAt,
).toBeNull();
expect(f.wakeup).not.toHaveBeenCalled();
- event.payload.githubApp.headers["x-hub-signature-256"] =
- `sha256=${createHmac("sha256", f.webhookSecret).update(body).digest("hex")}`;
- expect(await dispatchGitHubBotCloudEvent(db, event)).toBe(true);
- expect(
- (await f.service.get(f.endpoint.id)).setup.webhookVerifiedAt,
- ).toBeTruthy();
- expect(f.wakeup).not.toHaveBeenCalled();
+ const poll = vi.fn(async () => { await dispatchGitHubBotCloudEvent(db, event); });
+ const unregister = registerGitHubBotCloudSetupPoller(db, poll);
+ try {
+ const waiting = await f.service.githubWizard.advance(f.endpoint.id, "owner-user");
+ expect(waiting.state).toBe("verify");
+ expect(poll).toHaveBeenCalledOnce();
+ expect((await f.service.get(f.endpoint.id)).setup.webhookVerifiedAt).toBeNull();
+ event.payload.githubApp.headers["x-hub-signature-256"] =
+ `sha256=${createHmac("sha256", f.webhookSecret).update(body).digest("hex")}`;
+ const ready = await f.service.githubWizard.advance(f.endpoint.id, "owner-user");
+ expect(ready.state).not.toBe("verify");
+ expect((await f.service.get(f.endpoint.id)).setup.webhookVerifiedAt).toBeTruthy();
+ expect(f.wakeup).not.toHaveBeenCalled();
+ await f.service.githubWizard.advance(f.endpoint.id, "owner-user");
+ expect(poll).toHaveBeenCalledTimes(2);
+ } finally { unregister(); }
});
it("legacy name-only registration preserves an explicit empty repository selection", async () => {
const fixture = await seedCompany();
diff --git a/server/src/__tests__/github-connection-events.test.ts b/server/src/__tests__/github-connection-events.test.ts
index 77d34f15e6a..6bcd2913396 100644
--- a/server/src/__tests__/github-connection-events.test.ts
+++ b/server/src/__tests__/github-connection-events.test.ts
@@ -7,6 +7,7 @@ import {
connectionGrants,
createDb,
externalObjects,
+ EMBEDDED_POSTGRES_TEST_TIMEOUT_MS,
toolApplications,
toolConnections,
} from "@paperclipai/db";
@@ -30,7 +31,7 @@ describeEmbeddedPostgres("GitHub connection event delivery", () => {
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-github-events-");
db = createDb(tempDb.connectionString);
- }, 20_000);
+ }, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
afterEach(async () => {
await db.delete(activityLog);
@@ -46,7 +47,7 @@ describeEmbeddedPostgres("GitHub connection event delivery", () => {
await tempDb?.cleanup();
});
- it("applies a normalized merged PR once while retaining undecryptable siblings for retry", async () => {
+ it.each([false, true])("applies a normalized merged PR once while retaining undecryptable siblings for retry (active setup=%s)", async activeSetup => {
const companyId = randomUUID();
const userId = `github-owner-${randomUUID()}`;
const applicationId = randomUUID();
@@ -151,8 +152,21 @@ describeEmbeddedPostgres("GitHub connection event delivery", () => {
let currentTime = new Date("2026-09-04T12:00:05.000Z");
const service = githubConnectionEventService(db, { connector, now: () => currentTime });
+ if (activeSetup) {
+ // An existing personal connection can leave a long empty-queue backoff
+ // before a new dedicated App starts its signed-ping verification.
+ for (const delay of [0, 10_000, 20_000, 40_000, 80_000]) {
+ currentTime = new Date(currentTime.getTime() + delay);
+ vi.mocked(connector.leaseEvents).mockResolvedValueOnce(null);
+ await service.pollOnce();
+ }
+ currentTime = new Date(currentTime.getTime() + 5_000);
+ await expect(service.pollOnce()).resolves.toMatchObject({ leased: 0 });
+ expect(connector.leaseEvents).toHaveBeenCalledTimes(5);
+ }
+
const warning = vi.spyOn(logger, "warn").mockImplementation(() => undefined);
- await expect(service.pollOnce()).resolves.toMatchObject({ leased: 2, processed: 1, duplicate: 0, failed: 1 });
+ await expect(service.pollOnce({ forSetup: activeSetup })).resolves.toMatchObject({ leased: 2, processed: 1, duplicate: 0, failed: 1 });
expect(warning.mock.calls).toEqual([[{
deliveryId: "undecryptable-delivery", reason: "inner_event_authentication_failed",
}, "GitHub connector event retained for retry"]]);
diff --git a/server/src/index.ts b/server/src/index.ts
index e83d603e2aa..207f342a2ae 100644
--- a/server/src/index.ts
+++ b/server/src/index.ts
@@ -136,6 +136,7 @@ import {
} from "./services/device-login-reaper.js";
import { createProductionSetupTokenReaper } from "./services/setup-token-reaper.js";
import { localAiLoginService } from "./services/local-ai-login.js";
+import { registerGitHubBotCloudSetupPoller } from "./services/chat-github-cloud-ingress.js";
import { resolveWorktreeRunExecutionActivationState } from "./services/instance-settings.js";
import {
parseAdapterRegistryEnv,
@@ -1419,9 +1420,9 @@ async function startServerWithDatabaseTeardown(
?? process.env.PAPERCLIP_TOOL_RUNTIME_TRUSTED_HOST
?? null,
});
- const scheduleGitHubConnectionEventPoll = () => {
+ const scheduleGitHubConnectionEventPoll = (forSetup = false) => {
if (isIdleTaskDrainActive() || heartbeatSchedulerStopped) return;
- trackHeartbeatSchedulerWork(githubConnectionEvents.pollOnce()
+ const work = githubConnectionEvents.pollOnce({ forSetup })
.then((result) => {
if (result.leased > 0 || result.failed > 0) {
logger.info(result, "GitHub connection event poll completed");
@@ -1429,8 +1430,13 @@ async function startServerWithDatabaseTeardown(
})
.catch((err) => {
logger.error({ err }, "GitHub connection event poll failed");
- }));
+ });
+ trackHeartbeatSchedulerWork(work);
+ return work;
};
+ server.on("close", registerGitHubBotCloudSetupPoller(db, async () => {
+ await scheduleGitHubConnectionEventPoll(true);
+ }));
const scheduleGitHubConnectionContinuitySweep = () => {
if (isIdleTaskDrainActive() || heartbeatSchedulerStopped) return;
trackHeartbeatSchedulerWork(tools.sweepGitHubConnectionContinuity()
diff --git a/server/src/services/chat-github-cloud-ingress.ts b/server/src/services/chat-github-cloud-ingress.ts
index b1493d64779..2f8e75eb8f9 100644
--- a/server/src/services/chat-github-cloud-ingress.ts
+++ b/server/src/services/chat-github-cloud-ingress.ts
@@ -7,6 +7,18 @@ type Event = SealedConnectorEvents["events"][number];
type Endpoint = typeof chatEndpoints.$inferSelect;
type Handler = (endpoint: Endpoint, request: Request) => Promise;
const handlers = new WeakMap();
+const setupPollers = new WeakMap Promise>();
+
+/** The active wizard reuses the server-owned, serialized event drain. */
+export function registerGitHubBotCloudSetupPoller(db: Db, poll: () => Promise) {
+ setupPollers.set(db, poll);
+ return () => { if (setupPollers.get(db) === poll) setupPollers.delete(db); };
+}
+
+export async function pollGitHubBotCloudSetupEvents(db: Db): Promise {
+ await setupPollers.get(db)?.();
+}
+
export function registerGitHubBotCloudIngress(db: Db, handler: Handler) {
handlers.set(db, handler);
return () => {
diff --git a/server/src/services/chat-github-wizard.ts b/server/src/services/chat-github-wizard.ts
index 5f63f68db8c..701d77cba51 100644
--- a/server/src/services/chat-github-wizard.ts
+++ b/server/src/services/chat-github-wizard.ts
@@ -33,6 +33,7 @@ import {
type PaperclipCloudConnector,
} from "./paperclip-cloud-connector.js";
import { paperclipCloudConnectorEnrollmentStatus } from "./paperclip-cloud-connector-enrollment.js";
+import { pollGitHubBotCloudSetupEvents } from "./chat-github-cloud-ingress.js";
/** Loopback aliases are equivalent locally, but Cloud requires the approved exact origin. */
export function githubWizardBrowserOrigin(
@@ -920,6 +921,10 @@ export function githubChatWizardService(
// Keep the same vaulted App in the wizard instead of throwing JSON from
// the browser callback or relaxing the configure action's signature gate.
bot = await endpoint(id, userId);
+ if (!bot.setup.webhookVerifiedAt && bot.setup.github?.cloudRegistrationId) {
+ await pollGitHubBotCloudSetupEvents(db);
+ bot = await endpoint(id, userId);
+ }
if (!bot.setup.webhookVerifiedAt)
return {
endpointId: id,
diff --git a/server/src/services/github-connection-events.test.ts b/server/src/services/github-connection-events.test.ts
new file mode 100644
index 00000000000..93031ecae62
--- /dev/null
+++ b/server/src/services/github-connection-events.test.ts
@@ -0,0 +1,74 @@
+import { describe, expect, it, vi } from "vitest";
+import { chatEndpoints, type Db } from "@paperclipai/db";
+import { githubConnectionEventService } from "./github-connection-events.js";
+import type { PaperclipCloudConnector } from "./paperclip-cloud-connector.js";
+
+function fixture() {
+ let time = Date.parse("2026-09-04T12:00:00Z");
+ const bindings = [{
+ grant: { id: "grant", companyId: "company", kind: "user", subjectUserId: "owner", status: "active",
+ providerTenant: { github: { installationIds: ["101"] } } },
+ connection: { id: "connection", config: { sourceTemplateKey: "github", oauth: { connectorProfile: "github.code" } } },
+ }];
+ const bots: Array<{ companyId: string }> = [];
+ // Only SQL is stubbed. Backoff, lease admission and concurrent execution use
+ // the real service; relational persistence has its own Postgres suite.
+ const select = vi.fn(() => ({ from: (table: unknown) => table === chatEndpoints
+ ? { where: async () => bots }
+ : { innerJoin: () => ({ where: async () => bindings }) } }));
+ const leaseEvents = vi.fn().mockResolvedValue(null);
+ const connector = { leaseEvents } as unknown as PaperclipCloudConnector;
+ const service = githubConnectionEventService({ select } as unknown as Db, { connector, now: () => new Date(time) });
+ return { service, leaseEvents, select, bots, advance: (ms: number) => { time += ms; } };
+}
+
+describe("GitHub event polling during active App setup", () => {
+ it("reaches Cloud during an old empty-queue backoff without expanding idle polling", async () => {
+ const f = fixture();
+ for (const delay of [0, 10_000, 20_000, 40_000, 80_000]) {
+ f.advance(delay);
+ await f.service.pollOnce();
+ }
+ expect(f.leaseEvents).toHaveBeenCalledTimes(5);
+ const queries = f.select.mock.calls.length;
+ f.bots.push({ companyId: "company" });
+ f.advance(5_000);
+ for (let index = 0; index < 3; index++) await f.service.pollOnce();
+ expect(f.select).toHaveBeenCalledTimes(queries);
+ expect(f.leaseEvents).toHaveBeenCalledTimes(5);
+ await f.service.pollOnce({ forSetup: true });
+ expect(f.leaseEvents).toHaveBeenCalledTimes(6);
+ const setupQueries = f.select.mock.calls.length;
+ await f.service.pollOnce({ forSetup: true });
+ expect(f.leaseEvents).toHaveBeenCalledTimes(6);
+ expect(f.select).toHaveBeenCalledTimes(setupQueries);
+ f.advance(5_000);
+ await f.service.pollOnce({ forSetup: true });
+ expect(f.leaseEvents).toHaveBeenCalledTimes(7);
+ });
+
+ it("shares one lease between setup and scheduled callers while Cloud is slow", async () => {
+ const f = fixture();
+ let complete!: (value: null) => void;
+ f.leaseEvents.mockImplementationOnce(() => new Promise(resolve => { complete = resolve; }));
+ const scheduled = f.service.pollOnce();
+ const setup = f.service.pollOnce({ forSetup: true });
+ await vi.waitFor(() => expect(f.leaseEvents).toHaveBeenCalledTimes(1));
+ expect(complete).toBeTypeOf("function");
+ complete(null);
+ await Promise.all([scheduled, setup]);
+ expect(f.leaseEvents).toHaveBeenCalledTimes(1);
+ });
+
+ it("releases the shared attempt after an error and retries only after the setup throttle", async () => {
+ const f = fixture();
+ f.leaseEvents.mockRejectedValueOnce(new Error("Cloud unavailable"));
+ await expect(f.service.pollOnce({ forSetup: true })).rejects.toThrow("Cloud unavailable");
+ await f.service.pollOnce({ forSetup: true });
+ await f.service.pollOnce();
+ expect(f.leaseEvents).toHaveBeenCalledTimes(1);
+ f.advance(5_000);
+ await f.service.pollOnce({ forSetup: true });
+ expect(f.leaseEvents).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/server/src/services/github-connection-events.ts b/server/src/services/github-connection-events.ts
index 04022c647a1..6848875815f 100644
--- a/server/src/services/github-connection-events.ts
+++ b/server/src/services/github-connection-events.ts
@@ -221,6 +221,8 @@ export function githubConnectionEventService(
const now = options.now ?? (() => new Date());
let nextPollAt = 0;
let emptyPolls = 0;
+ let nextSetupPollAt = 0;
+ let inFlight: Promise | null = null;
async function activeBindings() {
const rows = await db.select({ grant: connectionGrants, connection: toolConnections })
@@ -458,88 +460,101 @@ export function githubConnectionEventService(
return "processed" as const;
}
- return {
- async pollOnce(): Promise {
- if (now().getTime() < nextPollAt) {
- return { leased: 0, processed: 0, duplicate: 0, ignored: 0, failed: 0 };
- }
- const bindings = await activeBindings();
- const bots = await db.select().from(chatEndpoints).where(and(eq(chatEndpoints.provider, "github"), sql`${chatEndpoints.setup}->'github'->>'cloudRegistrationId' is not null`, sql`${chatEndpoints.status} <> 'archived'`));
- if (bindings.length === 0 && bots.length === 0) {
- nextPollAt = now().getTime() + 5_000;
- return { leased: 0, processed: 0, duplicate: 0, ignored: 0, failed: 0 };
- }
- const config = options.connector ? null : paperclipCloudConnectorConfigFromEnv(options.env);
- const connector = options.connector ?? (config ? createPaperclipCloudConnector({ config }) : null);
- if (!connector) {
- nextPollAt = now().getTime() + 5 * 60_000;
- return { leased: 0, processed: 0, duplicate: 0, ignored: 0, failed: 0 };
- }
- const first = bindings[0] ?? { subject: "github-bot-events", companyId: bots[0]!.companyId };
- const lease = await connector.leaseEvents({ subject: first.subject, companyId: first.companyId });
- if (!lease) {
- emptyPolls += 1;
- nextPollAt = now().getTime() + Math.min(bots.length ? 15_000 : 5 * 60_000, 5_000 * (2 ** Math.min(emptyPolls, 6)));
- return { leased: 0, processed: 0, duplicate: 0, ignored: 0, failed: 0 };
- }
- emptyPolls = 0;
+ async function runPoll(): Promise {
+ const bindings = await activeBindings();
+ const bots = await db.select().from(chatEndpoints).where(and(eq(chatEndpoints.provider, "github"), sql`${chatEndpoints.setup}->'github'->>'cloudRegistrationId' is not null`, sql`${chatEndpoints.status} <> 'archived'`));
+ if (bindings.length === 0 && bots.length === 0) {
nextPollAt = now().getTime() + 5_000;
- const result: GitHubConnectionEventPollResult = {
- leased: lease.events.length + (lease.failedEventIds?.length ?? 0),
- processed: 0,
- duplicate: 0,
- ignored: 0,
- failed: lease.failedEventIds?.length ?? 0,
- };
- const acknowledge: string[] = [];
- for (const event of lease.events) {
- if (event.payload.githubApp !== undefined) {
- try { await dispatchGitHubBotCloudEvent(db, event); acknowledge.push(event.id); result.processed += 1; }
- catch { result.failed += 1; }
- continue;
- }
- const matched = bindings.filter((binding) => event.bindingIds.includes(binding.id));
- if (matched.length === 0) {
- result.ignored += 1;
- acknowledge.push(event.id);
- continue;
- }
- try {
- const companies = new Map();
- for (const binding of matched) companies.set(binding.companyId, [...(companies.get(binding.companyId) ?? []), binding]);
- for (const [companyId, companyBindings] of companies) {
- const status = await processForCompany(companyId, companyBindings, event);
- result[status] += 1;
- }
- acknowledge.push(event.id);
- if (event.event === "installation" && (event.action === "deleted" || event.action === "suspend")) {
- await Promise.all(matched.map((binding) => connector.setWebhookBinding({
- subject: binding.subject,
- companyId: binding.companyId,
- id: binding.id,
- installationId: binding.installationId,
- connectionId: binding.connectionId,
- grantId: binding.grantId,
- active: false,
- })));
- }
- } catch {
- result.failed += 1;
- }
+ return { leased: 0, processed: 0, duplicate: 0, ignored: 0, failed: 0 };
+ }
+ const config = options.connector ? null : paperclipCloudConnectorConfigFromEnv(options.env);
+ const connector = options.connector ?? (config ? createPaperclipCloudConnector({ config }) : null);
+ if (!connector) {
+ nextPollAt = now().getTime() + 5 * 60_000;
+ return { leased: 0, processed: 0, duplicate: 0, ignored: 0, failed: 0 };
+ }
+ const first = bindings[0] ?? { subject: "github-bot-events", companyId: bots[0]!.companyId };
+ const lease = await connector.leaseEvents({ subject: first.subject, companyId: first.companyId });
+ if (!lease) {
+ emptyPolls += 1;
+ nextPollAt = now().getTime() + Math.min(bots.length ? 15_000 : 5 * 60_000, 5_000 * (2 ** Math.min(emptyPolls, 6)));
+ return { leased: 0, processed: 0, duplicate: 0, ignored: 0, failed: 0 };
+ }
+ emptyPolls = 0;
+ nextPollAt = now().getTime() + 5_000;
+ const result: GitHubConnectionEventPollResult = {
+ leased: lease.events.length + (lease.failedEventIds?.length ?? 0),
+ processed: 0,
+ duplicate: 0,
+ ignored: 0,
+ failed: lease.failedEventIds?.length ?? 0,
+ };
+ const acknowledge: string[] = [];
+ for (const event of lease.events) {
+ if (event.payload.githubApp !== undefined) {
+ try { await dispatchGitHubBotCloudEvent(db, event); acknowledge.push(event.id); result.processed += 1; }
+ catch { result.failed += 1; }
+ continue;
}
- if (acknowledge.length > 0) {
- await connector.acknowledgeEvents({
- subject: first.subject,
- companyId: first.companyId,
- leaseId: lease.leaseId,
- deliveryIds: acknowledge,
- });
+ const matched = bindings.filter((binding) => event.bindingIds.includes(binding.id));
+ if (matched.length === 0) {
+ result.ignored += 1;
+ acknowledge.push(event.id);
+ continue;
}
- for (const deliveryId of lease.failedEventIds ?? []) {
- logger.warn({ deliveryId, reason: "inner_event_authentication_failed" },
- "GitHub connector event retained for retry");
+ try {
+ const companies = new Map();
+ for (const binding of matched) companies.set(binding.companyId, [...(companies.get(binding.companyId) ?? []), binding]);
+ for (const [companyId, companyBindings] of companies) {
+ const status = await processForCompany(companyId, companyBindings, event);
+ result[status] += 1;
+ }
+ acknowledge.push(event.id);
+ if (event.event === "installation" && (event.action === "deleted" || event.action === "suspend")) {
+ await Promise.all(matched.map((binding) => connector.setWebhookBinding({
+ subject: binding.subject,
+ companyId: binding.companyId,
+ id: binding.id,
+ installationId: binding.installationId,
+ connectionId: binding.connectionId,
+ grantId: binding.grantId,
+ active: false,
+ })));
+ }
+ } catch {
+ result.failed += 1;
}
- return result;
+ }
+ if (acknowledge.length > 0) {
+ await connector.acknowledgeEvents({
+ subject: first.subject,
+ companyId: first.companyId,
+ leaseId: lease.leaseId,
+ deliveryIds: acknowledge,
+ });
+ }
+ for (const deliveryId of lease.failedEventIds ?? []) {
+ logger.warn({ deliveryId, reason: "inner_event_authentication_failed" },
+ "GitHub connector event retained for retry");
+ }
+ return result;
+ }
+
+ return {
+ pollOnce(input: { forSetup?: boolean } = {}): Promise {
+ // A manager actively awaiting a dedicated App's signed ping must not
+ // inherit a personal connection's old empty-queue backoff. This is a
+ // bounded readiness probe, not an additional background polling loop.
+ if (inFlight) return inFlight;
+ const time = now().getTime();
+ if (time < (input.forSetup ? nextSetupPollAt : nextPollAt)) {
+ return Promise.resolve({ leased: 0, processed: 0, duplicate: 0, ignored: 0, failed: 0 });
+ }
+ nextSetupPollAt = time + 5_000;
+ nextPollAt = Math.max(nextPollAt, nextSetupPollAt);
+ const work = runPoll().finally(() => { if (inFlight === work) inFlight = null; });
+ inFlight = work;
+ return work;
},
};
}
From 1ad8f25c7793e4e1d94c1afd29942d05d7778d87 Mon Sep 17 00:00:00 2001
From: Devin Foley
Date: Sat, 24 Oct 2026 23:53:39 -0700
Subject: [PATCH 21/70] perf(heartbeat): narrow queued comment polling run
reads (#17560)
Read current control facts and typed issue/adapter bindings for queued-comment polling. Keep full mutation and execution reads, native preparation and steering, authorization, and stored evidence.
Validation: full build and typecheck, 160 focused tests plus two assembled route regressions; exact-head Greptile 5/5 (medium), full green CI, zero unresolved threads. Old-route counterfactual transfers 2.5 MB versus less than 2 KB.
Co-Authored-By: Paperclip
---
doc/DEVELOPING.md | 2 +
.../issue-queued-comments-routes.test.ts | 32 +++++++++-
server/src/routes/issues.ts | 14 +++--
server/src/services/heartbeat.ts | 6 ++
.../src/services/heartbeat/run-state.test.ts | 59 +++++++++++++++++++
server/src/services/heartbeat/run-state.ts | 25 ++++++++
6 files changed, 130 insertions(+), 8 deletions(-)
diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md
index e399790fbbb..432eb547d45 100644
--- a/doc/DEVELOPING.md
+++ b/doc/DEVELOPING.md
@@ -1812,6 +1812,8 @@ It owns bounded run projections, database encoding checks, task session reads an
writes, explicit resumes, session compaction, and usage/billing helpers.
`createHeartbeatRunState(db)` binds these operations without doing database work
during construction. The encoding-check cache belongs to each factory instance.
+Queued-comment polling reads current issue control fields, the typed issue binding,
+and the selected adapter type. Mutation and execution paths retain their full run reads.
`heartbeat.ts` keeps run execution and re-exports the
existing public helpers. Keep session policy changes separate from run
orchestration changes.
diff --git a/server/src/__tests__/issue-queued-comments-routes.test.ts b/server/src/__tests__/issue-queued-comments-routes.test.ts
index bb42982787b..60985954bea 100644
--- a/server/src/__tests__/issue-queued-comments-routes.test.ts
+++ b/server/src/__tests__/issue-queued-comments-routes.test.ts
@@ -4,6 +4,7 @@ import { randomUUID } from "node:crypto";
import express from "express";
import request from "supertest";
import { eq, sql } from "drizzle-orm";
+import { createDatabaseResponseCapture, installDatabaseResponseCapture } from "@paperclipai/db/response-capture";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import {
agentRuntimeState,
@@ -79,7 +80,7 @@ describeEmbeddedPostgres("issue queued-comment routes", () => {
await tempDb?.cleanup();
});
- function app(companyId: string, userId = "queue-owner", agentActor?: { agentId: string; runId: string }) {
+ function app(companyId: string, userId = "queue-owner", agentActor?: { agentId: string; runId: string }, database = db) {
const testApp = express();
testApp.use(express.json());
testApp.use((req, _res, next) => {
@@ -94,7 +95,7 @@ describeEmbeddedPostgres("issue queued-comment routes", () => {
};
next();
});
- testApp.use("/api", issueRoutes(db, {} as any, {}));
+ testApp.use("/api", issueRoutes(database, {} as any, {}));
testApp.use(errorHandler);
return testApp;
}
@@ -192,6 +193,33 @@ describeEmbeddedPostgres("issue queued-comment routes", () => {
return { companyId, agentId, issueId, runId, wakeId, commentIds };
}
+ it.each([false, true])("polls queued comments without transferring run snapshots (agent fallback: %s)", async fallback => {
+ const f = await seedQueue(), payload = "saved-execution-input🙂".repeat(25_000);
+ await db.update(heartbeatRuns).set({ contextSnapshot: { issueId: f.issueId, prompt: payload },
+ runnerProfileJson: { adapterDispatch: { adapterType: "paperclip_runner", adapterConfig: { instructions: payload } } },
+ stdoutExcerpt: payload, stderrExcerpt: payload,
+ }).where(eq(heartbeatRuns.id, f.runId));
+ if (fallback) await db.update(issues).set({ executionRunId: null }).where(eq(issues.id, f.issueId));
+ const [before] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId));
+ const observed = createDb(tempDb!.connectionString), events: Array> = [], now = Date.now();
+ const capture = createDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event));
+ installDatabaseResponseCapture(observed, { heartbeatRuns }, capture);
+ try {
+ const response = await request(app(f.companyId, "queue-owner", undefined, observed))
+ .get(`/api/issues/${f.issueId}/queued-comments`).expect(200);
+ expect(response.body.queueId).toBe(f.wakeId);
+ expect(response.body.targetRunId).toBe(f.runId);
+ expect(response.body.entries.map((entry: { comment: { id: string } }) => entry.comment.id)).toEqual(f.commentIds);
+ capture.flush();
+ const reads = events.filter(event => event.table === "heartbeat_runs" && event.operation === "select");
+ expect(reads.length).toBeGreaterThan(0);
+ expect(reads.reduce((bytes, event) => bytes + Number(event.responseValueBytes), 0)).toBeLessThan(2048);
+ await request(app(randomUUID(), "queue-owner", undefined, observed))
+ .get(`/api/issues/${f.issueId}/queued-comments`).expect(404);
+ expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId)))[0]).toEqual(before);
+ } finally { capture.flush(); await observed.$client.end(); }
+ });
+
it.each(["cancelled", "running"])("rejects a late Done from an interrupted %s task run at the write boundary", async status => {
const seeded = await seedQueue();
await db.update(heartbeatRuns).set({ status, resultJson: { executionCancellation: { state: "requested" } } })
diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts
index 9a29f1c9d91..6aa65dfb774 100644
--- a/server/src/routes/issues.ts
+++ b/server/src/routes/issues.ts
@@ -7247,18 +7247,20 @@ export function issueRoutes(
id: string;
assigneeAgentId: string | null;
executionRunId?: string | null;
- }) {
+ }, options?: { queueStateOnly: true }) {
let runToInterrupt = issue.executionRunId
- ? await heartbeat.getRun(issue.executionRunId)
+ ? options?.queueStateOnly
+ ? await heartbeat.getRunIssueControl(issue.executionRunId)
+ : await heartbeat.getRun(issue.executionRunId)
: null;
if (
(!runToInterrupt || runToInterrupt.status !== "running") &&
issue.assigneeAgentId
) {
- const activeRun = await heartbeat.getActiveRunForAgent(
- issue.assigneeAgentId,
- );
+ const activeRun = options?.queueStateOnly
+ ? await heartbeat.getActiveRunIssueControlForAgent(issue.assigneeAgentId)
+ : await heartbeat.getActiveRunForAgent(issue.assigneeAgentId);
const activeIssueId =
activeRun &&
activeRun.contextSnapshot &&
@@ -16343,7 +16345,7 @@ export function issueRoutes(
const queue = await buildQueuedCommentQueue({
executor: db,
issue,
- activeRun: await resolveActiveIssueRun(issue),
+ activeRun: await resolveActiveIssueRun(issue, { queueStateOnly: true }),
actor: getActorInfo(req),
});
res.json(
diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts
index 9a18721aaa7..f177921a36d 100644
--- a/server/src/services/heartbeat.ts
+++ b/server/src/services/heartbeat.ts
@@ -832,6 +832,8 @@ export function heartbeatService(
listEvents,
getRetryExhaustedReason,
getRunIssueSummary,
+ getRunIssueControl,
+ getActiveRunIssueControlForAgent,
getActiveRunForAgent,
getActiveRunIssueSummaryForAgent,
} = createHeartbeatRunState(db);
@@ -4153,6 +4155,10 @@ export function heartbeatService(
getRunIssueSummary,
+ getRunIssueControl,
+
+ getActiveRunIssueControlForAgent,
+
getActiveRunForAgent,
getActiveRunIssueSummaryForAgent,
diff --git a/server/src/services/heartbeat/run-state.test.ts b/server/src/services/heartbeat/run-state.test.ts
index ccc91735df4..e42d93d7286 100644
--- a/server/src/services/heartbeat/run-state.test.ts
+++ b/server/src/services/heartbeat/run-state.test.ts
@@ -20,6 +20,7 @@ import {
import * as legacy from "../heartbeat.js";
import * as extracted from "./run-state.js";
import { readRunCancellation } from "../run-cancellation.js";
+import { decideQueuedCommentQueueSteering } from "../issue-queued-comment-queue.js";
// SQL_ASCII needs a separate database cluster. Inspect its selected SQL here;
// the PostgreSQL tests below exercise the session queries and transactions.
@@ -158,6 +159,64 @@ describePostgres("heartbeat run state database wiring", () => {
return { companyId, agent, issue, state: extracted.createHeartbeatRunState(db) };
}
+ it("reads fresh issue control facts without transferring execution snapshots", async () => {
+ const { companyId, agent, issue } = await fixture();
+ const payload = randomBytes(128 * 1024).toString("base64");
+ const [run] = await db.insert(heartbeatRuns).values({ companyId, agentId: agent.id, status: "running",
+ runtimeMode: "legacy", runtimeModeResolvedAt: null,
+ startedAt: new Date("2020-01-01T00:00:00Z"),
+ contextSnapshot: { issueId: issue.id, prompt: payload },
+ runnerProfileJson: { adapterDispatch: { adapterType: "paperclip_runner", adapterConfig: { prompt: payload } }, savedInput: payload },
+ resultJson: { stdout: payload }, stdoutExcerpt: payload, stderrExcerpt: payload,
+ }).returning();
+ const observed = createDb(database.connectionString);
+ const events: Array> = [], now = Date.now();
+ const capture = createDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event));
+ installDatabaseResponseCapture(observed, { heartbeatRuns }, capture);
+ try {
+ const state = extracted.createHeartbeatRunState(observed);
+ const direct = await state.getRunIssueControl(run.id), active = await state.getActiveRunIssueControlForAgent(agent.id);
+ expect(active).toEqual(direct);
+ expect(direct).toEqual({ id: run.id, companyId, agentId: agent.id, status: "running", runtimeMode: "legacy",
+ runtimeModeResolvedAt: null, startedAt: run.startedAt, createdAt: run.createdAt,
+ contextSnapshot: { issueId: issue.id }, runnerProfileJson: { adapterDispatch: { adapterType: "paperclip_runner" } } });
+ capture.flush();
+ const summary = events.find(event => event.event === "paperclip.db_response_capture.summary.v1")!;
+ expect(summary.completedQueries).toBe(2);
+ expect(summary.responseValueBytes).toBeLessThan(2048);
+ expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, run.id)))[0]).toEqual(run);
+ await db.update(heartbeatRuns).set({ status: "failed" }).where(eq(heartbeatRuns.id, run.id));
+ expect((await state.getRunIssueControl(run.id))?.status).toBe("failed");
+ expect(await state.getActiveRunIssueControlForAgent(agent.id)).toBeNull();
+ expect(await state.getActiveRunIssueControlForAgent(randomUUID())).toBeNull();
+ expect(await state.getRunIssueControl(randomUUID())).toBeNull();
+ } finally { capture.flush(); await observed.$client.end(); }
+ });
+
+ it("preserves native preparation, legacy steering, and typed issue context in issue control reads", async () => {
+ const { companyId, agent, issue, state } = await fixture();
+ for (const mode of ["native", "legacy"] as const) {
+ for (const value of [null, false, 42, [], {}, { nested: true }, "paperclip_runner", "process"]) {
+ const [run] = await db.insert(heartbeatRuns).values({ companyId, agentId: agent.id, status: "running", runtimeMode: mode,
+ contextSnapshot: { issueId: value }, runnerProfileJson: { adapterDispatch: { adapterType: value } },
+ }).returning();
+ const narrow = await state.getRunIssueControl(run.id), full = await state.getRun(run.id);
+ expect(narrow?.contextSnapshot?.issueId).toEqual(value);
+ expect(narrow?.runnerProfileJson).toEqual({ adapterDispatch: { adapterType: value } });
+ for (const queueState of ["queued", "deferred", null] as const) {
+ const facts = { state: queueState, queueRunRuntimeMode: mode, assignedAgentAdapterType: "paperclip_runner", queuedCommentCount: 1 };
+ expect(decideQueuedCommentQueueSteering({ ...facts, activeRun: narrow }))
+ .toEqual(decideQueuedCommentQueueSteering({ ...facts, activeRun: full }));
+ }
+ await db.update(heartbeatRuns).set({ contextSnapshot: { issueId: issue.id }, runtimeModeResolvedAt: new Date("2020-01-01T00:00:00Z") })
+ .where(eq(heartbeatRuns.id, run.id));
+ const updated = await state.getRunIssueControl(run.id);
+ expect(updated?.contextSnapshot).toEqual({ issueId: issue.id });
+ expect(updated?.runtimeModeResolvedAt).toEqual(new Date("2020-01-01T00:00:00Z"));
+ }
+ }
+ });
+
it("reads cleanup and workspace settlement without transferring saved input or changing the run", async () => {
const { companyId, agent, issue, state } = await fixture();
const bulk = "cleanup-saved-input-sentinel🙂".repeat(40_000);
diff --git a/server/src/services/heartbeat/run-state.ts b/server/src/services/heartbeat/run-state.ts
index 1911ad1a25c..3bf92684a1f 100644
--- a/server/src/services/heartbeat/run-state.ts
+++ b/server/src/services/heartbeat/run-state.ts
@@ -354,6 +354,18 @@ const heartbeatRunLogAccessColumns = {
logRef: heartbeatRuns.logRef,
} as const;
+const heartbeatRunIssueControlColumns = {
+ id: heartbeatRuns.id, companyId: heartbeatRuns.companyId, agentId: heartbeatRuns.agentId,
+ status: heartbeatRuns.status, runtimeMode: heartbeatRuns.runtimeMode,
+ runtimeModeResolvedAt: heartbeatRuns.runtimeModeResolvedAt,
+ startedAt: heartbeatRuns.startedAt, createdAt: heartbeatRuns.createdAt,
+ contextSnapshot: sql