Files
PaperClipAI/server/src/__tests__/native-runtime-response-delivery.test.ts
T
DottaandPaperclip 006d5691a6 feat(server): support volume-free web server handoff (#18518)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Its web server also owns controller admission and user commands.
> - A rolling deployment starts a replacement before the old server
exits.
> - Shared database state alone does not preserve local agent files,
browser chunks, or in-process commands.
> - This pull request adds an opt-in ephemeral server contract and a
local overlap test.
> - The benefit is a web server that can relinquish traffic and local
files without losing acknowledged work.

## Linked Issues or Issue Description

Refs #18403, #18327, #18335, #18341.

**Current behavior**

The control-plane web server still has local agent-file authority and
local command targets. A new container can miss saved bytes or send a
command to the wrong process. An existing browser tab also needs chunks
from its original build.

**Proposed behavior**

Eligible servers share only PostgreSQL, object storage and stable
secrets. A replacement waits for readiness. The old server drains
admitted requests, keeps owner commands available through handoff, and
releases controller leases. Explicit maintenance adopts existing files
before the volume is removed.

**Reason and benefit**

This is the application prerequisite for rolling app-only deployments.
Schema migrations and permanent multi-replica operation are outside this
change.

## What Changed

- Store agent-file manifests in PostgreSQL and immutable bytes in S3.
Treat local roots as disposable projections. Preserve existing
permission checks and edit conflicts.
- Add an explicit migration, eligibility and object-retention command to
the production runtime bundle.
- Add `/api/ready`, admitted-request drain, browser restart signaling
and retained immutable UI chunks.
- Carry transaction-aware work hints across controllers during handoff
and active execution. Keep listeners closed when idle.
- Deliver durable Stop and runtime responses to the execution owner.
Recheck request lifecycle and access before response dispatch.
- Reject local-only configuration in ephemeral mode. Keep the feature
disabled for existing installations by default.
- Add an isolated PostgreSQL/S3/proxy/browser deployment harness with
independent server homes and retained failure evidence.

## Verification

- All CI checks pass on `ce17b95019d3cf1cabd357d19b6d37f96782cc5c`:
workspace typecheck, build, Runner checks, server/general tests and
browser shards. Greptile reviewed the runtime changes at 5/5 with all
findings resolved; the final commit updates maps and the plan.
- Local targeted checks passed: coordinator 27; Stop/recovery 33;
PostgreSQL command delivery 6; route/workspace regressions 8; bootstrap
Stop 1; restart recovery 75. PostgreSQL command tests simulate the
provider edge.
- Local browser proof passed 91 reads and 19 saves; reconnect took
1,013ms. The tab opened Audit from S3 after deletion of A's whole
home/build. This uses compiled workspace code and local-trusted auth,
not released containers or live agents.
- Local workspace typecheck/build passed; current server and runtime
bundle passed. The earlier broad local suite was stopped after three
hours and is not a passing receipt. Its retained failures and isolated
reruns are documented in the plan.
- Stacked #18526 adds stricter graceful-exit proof, an unready
replacement control, live Runner qualification and a Stop handoff
correction. Hosted enablement remains gated on those results and Cloud
#1096. No hosted stack has been enabled.

## Risks

- Apply migration 0422 before enabling the feature. Existing data must
pass each owner's migration and eligibility checks. Do not discard a
volume based on a single migration page.
- Durable manifests remain authoritative after the opt-in is disabled.
Rolling back to a binary that ignores them is unsupported.
- Cold directory restoration and complete shutdown timing still need
qualification before enabling a hosted stack.
- Retained UI assets must cover the supported browser and rollback
window. Cleanup is explicit; no new idle maintenance polling is
introduced.
- This change does not claim production zero downtime or authorize a
fleet-wide enablement.

## Model Used

OpenAI Codex, GPT-6 family. The exact serving model ID and
context-window limit are not exposed in this session. Used reasoning,
code editing, shell execution and browser automation.

## 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 <noreply@paperclip.ing>
2026-11-01 01:58:54 -06:00

127 lines
8.5 KiB
TypeScript

import { createHash, randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { afterAll, beforeAll, expect, it, vi } from "vitest";
import { agents, companies, companyMemberships, instanceUserRoles, issues, heartbeatRuns, heartbeatRunEvents,
nativeRunFinalizations, createDb } from "@paperclipai/db";
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
import { createDeliveryWorkCoordinator } from "../services/delivery-work-coordinator.js";
import { deploymentNotifications } from "../services/deployment-notifications.js";
import { enqueueNativeRuntimeResponse, registerNativeRuntimeResponseDelivery } from "../services/native-runtime/native-runtime-response-delivery.js";
import { currentNativeControllerIdentity } from "../services/native-runtime/native-restart-recovery.js";
import { setNativeExecutionActive } from "../services/native-runtime/native-active-runs.js";
import type { PendingNativeRuntimeRequest } from "../services/native-runtime/runtime-request-resolution-authority.js";
const provider = vi.hoisted(() => ({ resolve: vi.fn(), accepted: vi.fn() }));
vi.mock("../services/native-runtime/native-session-executor.js", () => ({
resolveNativeRuntimeRequest: (...args: unknown[]) => provider.resolve(...args),
NativeRuntimeRequestResolutionError: class extends Error { constructor(readonly code: string) { super(code); } },
}));
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let a: ReturnType<typeof createDb>, b: ReturnType<typeof createDb>;
let companyId: string, runId: string, agentId: string, userId: string;
let bridgeA: ReturnType<typeof deploymentNotifications>, bridgeB: ReturnType<typeof deploymentNotifications>;
let coordinatorA: ReturnType<typeof createDeliveryWorkCoordinator>, coordinatorB: ReturnType<typeof createDeliveryWorkCoordinator>;
let worker: ReturnType<typeof registerNativeRuntimeResponseDelivery>;
const errors: unknown[] = [];
let seq = 0;
beforeAll(async () => {
database = await startEmbeddedPostgresTestDatabase("runtime-response-delivery-");
a = createDb(database.connectionString); b = createDb(database.connectionString);
companyId = randomUUID(); runId = randomUUID(); agentId = randomUUID(); userId = randomUUID();
const issueId = randomUUID(), identity = await currentNativeControllerIdentity();
await a.insert(companies).values({ id: companyId, name: "Response fixture", issuePrefix: "RESP" });
await a.insert(agents).values({ id: agentId, companyId, name: "Remote fixture" });
await a.insert(issues).values({ id: issueId, companyId, title: "Response fixture", status: "in_progress" });
await a.insert(heartbeatRuns).values({ id: runId, companyId, agentId, runtimeMode: "native", status: "running", nativeIssueId: issueId });
await a.insert(nativeRunFinalizations).values({ runId, companyId, issueId, phase: "executing", controllerBootId: identity.bootId,
leaseOwner: "fixture-owner", leaseExpiresAt: new Date(Date.now() + 60_000) });
await a.insert(companyMemberships).values({ companyId, principalType: "user", principalId: userId, status: "active", membershipRole: "operator" });
await a.insert(instanceUserRoles).values({ userId, role: "instance_admin" });
coordinatorA = createDeliveryWorkCoordinator({ owner: a, canRun: () => true, onError: error => errors.push(error) });
coordinatorB = createDeliveryWorkCoordinator({ owner: b, canRun: () => true, onError: error => errors.push(error) });
bridgeA = deploymentNotifications(a, coordinatorA, { directDatabaseUrl: database.connectionString });
bridgeB = deploymentNotifications(b, coordinatorB, { directDatabaseUrl: database.connectionString });
await Promise.all([bridgeA.ready(), bridgeB.ready()]);
provider.resolve.mockImplementation(async (input: { authorizeBeforeDispatch: () => Promise<void>; commandId: string }) => {
await input.authorizeBeforeDispatch();
provider.accepted(input.commandId);
return { commandId: input.commandId };
});
worker = registerNativeRuntimeResponseDelivery(a, coordinatorA); await worker.ready;
}, 90_000);
afterAll(async () => {
setNativeExecutionActive(runId, false); await worker?.stop();
await Promise.all([bridgeA?.stop(), bridgeB?.stop()]);
await Promise.all([coordinatorA?.stop(), coordinatorB?.stop()]);
await Promise.all([a?.$client.end({ timeout: 1 }), b?.$client.end({ timeout: 1 })]); await database?.cleanup();
});
async function pending(): Promise<PendingNativeRuntimeRequest> {
const request = { companyId, runId, requestId: randomUUID(), requestKind: "command_approval" as const,
turnId: "turn-fixture", resolverPolicy: "instance_admin" as const };
await a.insert(heartbeatRunEvents).values({ companyId, runId, agentId, seq: ++seq, eventType: "runtime_request.created",
payload: { prpEvent: { schema: "paperclip.prp.event.v1", eventType: "runtime_request.created", sourceKind: "runner", runId,
payload: { request: { ...request, status: "pending" } } } } });
return request;
}
const enqueue = (request: PendingNativeRuntimeRequest) => enqueueNativeRuntimeResponse(b, { request,
actor: { type: "user", userId, isInstanceAdmin: true }, localImplicit: false, resolution: { action: "decline" } });
async function state(requestId: string) {
const [run] = await b.select({ result: heartbeatRuns.resultJson }).from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
const entries = run.result?.nativeRuntimeResponses as Record<string, { state: string }>;
return entries?.[createHash("sha256").update(requestId).digest("hex")]?.state;
}
it("persists B's response before an owner is attached, then dispatches once through A", async () => {
const request = await pending();
const saved = await enqueue(request);
expect(await enqueue(request)).toEqual(saved);
expect(await state(request.requestId)).toBe("pending");
expect(provider.resolve).not.toHaveBeenCalled();
setNativeExecutionActive(runId, true);
await expect.poll(() => state(request.requestId)).toBe("queued");
expect(provider.resolve).toHaveBeenCalledTimes(1);
expect(provider.resolve.mock.calls[0]?.[0].commandId).toBe(saved.commandId);
await enqueue(request);
expect(provider.resolve).toHaveBeenCalledTimes(1);
await expect(enqueueNativeRuntimeResponse(b, { request, actor: { type: "user", userId, isInstanceAdmin: true },
localImplicit: false, resolution: { action: "cancel" } })).rejects.toThrow("different response");
expect(errors).toEqual([]);
});
it("rechecks company access even for an administrator before dispatch", async () => {
setNativeExecutionActive(runId, false);
const request = await pending(); await enqueue(request);
await a.update(companyMemberships).set({ status: "inactive" }).where(eq(companyMemberships.principalId, userId));
setNativeExecutionActive(runId, true);
await expect.poll(() => state(request.requestId)).toBe("denied");
expect(provider.accepted).not.toHaveBeenCalled();
expect(errors).toEqual([]);
});
it("does not dispatch an answer after its request has become terminal", async () => {
setNativeExecutionActive(runId, false);
const request = await pending(); await enqueue(request);
await a.insert(heartbeatRunEvents).values({ companyId, runId, agentId, seq: ++seq, eventType: "runtime_request.resolved",
payload: { prpEvent: { payload: { requestId: request.requestId } } } });
setNativeExecutionActive(runId, true);
await expect.poll(() => state(request.requestId)).toBe("obsolete");
expect(provider.accepted).not.toHaveBeenCalled();
expect(errors).toEqual([]);
});
it("retires a stale provider turn and still delivers the next pending answer", async () => {
setNativeExecutionActive(runId, false);
await a.update(companyMemberships).set({ status: "active" }).where(eq(companyMemberships.principalId, userId));
const stale = await pending(), current = await pending();
await enqueue(stale);
await enqueue(current);
const { NativeRuntimeRequestResolutionError } = await import("../services/native-runtime/native-session-executor.js");
provider.resolve.mockImplementation(async (input: { requestId: string; authorizeBeforeDispatch: () => Promise<void>; commandId: string }) => {
if (input.requestId === stale.requestId) throw new NativeRuntimeRequestResolutionError("runtime_request_stale_turn", "fixture stale turn");
await input.authorizeBeforeDispatch();
provider.accepted(input.commandId);
return { commandId: input.commandId };
});
setNativeExecutionActive(runId, true);
await expect.poll(() => state(stale.requestId)).toBe("obsolete");
await expect.poll(() => state(current.requestId)).toBe("queued");
expect(provider.accepted).toHaveBeenCalledTimes(1);
expect(errors).toEqual([]);
});