Files
PaperClipAI/server/src/services/native-runtime/native-runtime-response-delivery.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

142 lines
8.6 KiB
TypeScript

import { createHash, randomUUID } from "node:crypto";
import { and, eq, gt, inArray, sql } from "drizzle-orm";
import { heartbeatRuns, nativeRunFinalizations, type Db } from "@paperclipai/db";
import type { HarnessRuntimeRequestResolution } from "../../vendor/paperclip-runner/index.js";
import { accessService } from "../access.js";
import { loadConfig } from "../../config.js";
import { conflict } from "../../errors.js";
import { DELIVERY_QUEUES, notifyDeliveryWork } from "../delivery-work-notifications.js";
import type { createDeliveryWorkCoordinator } from "../delivery-work-coordinator.js";
import { activeNativeExecutionRunIds, subscribeActiveNativeExecutions } from "./native-active-runs.js";
import { currentNativeControllerIdentity } from "./native-restart-recovery.js";
import { assertNativeRuntimeRequestResolverAuthorized, NativeRuntimeRequestResolutionAuthorizationError,
readPendingNativeRuntimeRequest, type NativeRuntimeRequestResolver, type PendingNativeRuntimeRequest } from "./runtime-request-resolution-authority.js";
type ResponseCommand = {
commandId: string;
request: PendingNativeRuntimeRequest;
actor: NativeRuntimeRequestResolver;
localImplicit: boolean;
resolution: HarnessRuntimeRequestResolution;
fingerprint: string;
state: "pending" | "queued" | "obsolete" | "denied";
};
function commands(result: Record<string, unknown> | null): Record<string, ResponseCommand> {
const value = result?.nativeRuntimeResponses;
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, ResponseCommand> : {};
}
const requestKey = (id: string) => createHash("sha256").update(id).digest("hex");
/** The run owns these commands just as it owns Stop intent. HTTP admission is
* durable; the controller lease and canonical request lifecycle own dispatch. */
export async function enqueueNativeRuntimeResponse(db: Db, input: {
request: PendingNativeRuntimeRequest;
actor: NativeRuntimeRequestResolver;
localImplicit: boolean;
resolution: HarnessRuntimeRequestResolution;
}) {
assertNativeRuntimeRequestResolverAuthorized(input.request, input.actor);
const key = requestKey(input.request.requestId);
const fingerprint = JSON.stringify({ request: input.request, userId: input.actor.userId, resolution: input.resolution });
return db.transaction(async tx => {
await notifyDeliveryWork(tx, DELIVERY_QUEUES.nativeRuntimeResponses);
const [run] = await tx.select().from(heartbeatRuns).where(and(
eq(heartbeatRuns.id, input.request.runId), eq(heartbeatRuns.companyId, input.request.companyId),
)).for("update").limit(1);
if (!run || run.runtimeMode !== "native" || run.status !== "running") throw conflict("This runner session is no longer accepting runtime responses.");
const existing = commands(run.resultJson);
const prior = existing[key];
if (prior) {
if (prior.fingerprint !== fingerprint) throw conflict("A different response was already submitted for this runtime request.");
if (prior.state === "denied" || prior.state === "obsolete") throw conflict("This runtime request is no longer actionable.");
return { commandId: prior.commandId };
}
const pending = await readPendingNativeRuntimeRequest(tx as unknown as Db, input.request);
if (!pending || pending.turnId !== input.request.turnId || pending.requestKind !== input.request.requestKind) {
throw conflict("This runtime request is stale or is no longer pending.");
}
assertNativeRuntimeRequestResolverAuthorized(pending, input.actor);
const command: ResponseCommand = { ...input, request: pending, commandId: `native-runtime-response:${randomUUID()}`, fingerprint, state: "pending" };
await tx.update(heartbeatRuns).set({ resultJson: { ...run.resultJson, nativeRuntimeResponses: { ...existing, [key]: command } },
updatedAt: new Date() }).where(eq(heartbeatRuns.id, run.id));
return { commandId: command.commandId };
});
}
/** Recheck access at consumption, including revocation during controller
* replacement. Only the configured local-trusted operator bypasses membership. */
async function authorize(db: Db, command: ResponseCommand) {
const pending = await readPendingNativeRuntimeRequest(db, command.request);
if (!pending || pending.turnId !== command.request.turnId || pending.requestKind !== command.request.requestKind) {
throw new RuntimeResponseObsoleteError();
}
const access = accessService(db);
const local = command.localImplicit && loadConfig().deploymentMode === "local_trusted";
const admin = local || await access.isInstanceAdmin(command.actor.userId);
if (!local) {
const membership = await access.getMembership(command.request.companyId, "user", command.actor.userId);
if (membership?.status !== "active" || (!admin && membership.membershipRole === "viewer")) {
throw new NativeRuntimeRequestResolutionAuthorizationError("native_runtime_request_resolver_denied");
}
}
assertNativeRuntimeRequestResolverAuthorized(pending, { ...command.actor, isInstanceAdmin: admin });
return { pending, actor: { ...command.actor, isInstanceAdmin: admin } };
}
class RuntimeResponseObsoleteError extends Error {}
export function registerNativeRuntimeResponseDelivery(db: Db, coordinator: ReturnType<typeof createDeliveryWorkCoordinator>) {
const worker = coordinator.register(DELIVERY_QUEUES.nativeRuntimeResponses, {
retryMs: 1000, runDuringDrain: true,
async run(signal) {
const ids = [...activeNativeExecutionRunIds];
if (!ids.length) return;
const identity = await currentNativeControllerIdentity();
const rows = await db.select({ id: heartbeatRuns.id, result: heartbeatRuns.resultJson }).from(heartbeatRuns)
.innerJoin(nativeRunFinalizations, eq(nativeRunFinalizations.runId, heartbeatRuns.id)).where(and(
inArray(heartbeatRuns.id, ids), eq(nativeRunFinalizations.controllerBootId, identity.bootId),
gt(nativeRunFinalizations.leaseExpiresAt, sql`clock_timestamp()`),
sql`${heartbeatRuns.resultJson} ? 'nativeRuntimeResponses'`,
));
for (const row of rows) for (const [key, command] of Object.entries(commands(row.result))) {
signal.throwIfAborted();
if (command.state !== "pending" || !activeNativeExecutionRunIds.has(row.id)) continue;
let state: ResponseCommand["state"] = "queued";
try {
const { resolveNativeRuntimeRequest, NativeRuntimeRequestResolutionError } = await import("./native-session-executor.js");
try {
await resolveNativeRuntimeRequest({ runId: row.id, requestId: command.request.requestId,
turnId: command.request.turnId, resolution: command.resolution, commandId: command.commandId,
authorizeBeforeDispatch: async () => { await authorize(db, command); } });
} catch (error) {
if (error instanceof NativeRuntimeRequestResolutionError && error.code === "runtime_request_stale_turn") {
throw new RuntimeResponseObsoleteError();
}
if (!(error instanceof NativeRuntimeRequestResolutionError)
|| !["native_session_not_active", "runtime_request_resolution_unsupported"].includes(error.code)) throw error;
const { pending, actor } = await authorize(db, command);
const { queueRunnerPrpRuntimeRequestResolution } = await import("../../realtime/runner-prp-ws.js");
queueRunnerPrpRuntimeRequestResolution({ companyId: pending.companyId, runId: row.id,
pendingRequest: pending, actor, resolution: command.resolution, commandId: command.commandId });
}
} catch (error) {
if (error instanceof RuntimeResponseObsoleteError) state = "obsolete";
else if (error instanceof NativeRuntimeRequestResolutionAuthorizationError) state = "denied";
else throw error; // The concrete pending command remains retryable.
}
await db.transaction(async tx => {
const [current] = await tx.select({ result: heartbeatRuns.resultJson }).from(heartbeatRuns)
.where(eq(heartbeatRuns.id, row.id)).for("update").limit(1);
const saved = commands(current?.result ?? null);
if (saved[key]?.commandId !== command.commandId || saved[key]?.state !== "pending") return;
await tx.update(heartbeatRuns).set({ resultJson: { ...current.result, nativeRuntimeResponses: {
...saved, [key]: { ...saved[key], state },
} }, updatedAt: new Date() }).where(eq(heartbeatRuns.id, row.id));
});
}
},
hasPending: async () => false,
});
const unsubscribe = subscribeActiveNativeExecutions(worker.wake);
return { ready: worker.ready, async stop() { unsubscribe(); await worker.stop(); } };
}