Files
PaperClipAI/packages/db/src/work-signals.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

156 lines
7.9 KiB
TypeScript

import { sql } from "drizzle-orm";
import { randomUUID } from "node:crypto";
// Hints about existing durable queues; process-local by default, with optional
// commit-bound PostgreSQL notifications during controller replacement. No SQL
// triggers, tables, timers, or query replay. Only registered work fetches an XID.
type Listener = (event: "intent" | "settled", hints?: ReadonlySet<string>) => void;
type Transaction = { topics: Set<string>; hints: Map<string, Set<string>>; xid?: Promise<string>; uncertain: boolean };
type Scope = { key: object; listeners: Map<string, Set<Listener>>; transactions: Set<Transaction>; root: Connection; notificationOrigin: string; notify: boolean };
type Context = { scope: Scope; transaction?: Transaction };
type Connection = { transaction: (...args: any[]) => Promise<any>; execute: (...args: any[]) => Promise<any> };
const contexts = new WeakMap<object, Context>();
function publish(scope: Scope, topic: string, event: "intent" | "settled" = "settled", hints?: ReadonlySet<string>) {
for (const listener of scope.listeners.get(topic) ?? []) {
try { listener(event, hints); }
catch (error) { process.emitWarning(`Database work listener failed: ${String(error)}`); }
}
}
/** Installed once by createDb; dedicated child pools share their owner's scope. */
export function installDatabaseWorkSignals<T extends Connection>(db: T, owner?: object): T {
const scope = (owner && contexts.get(owner)?.scope) || { key: {}, listeners: new Map(), transactions: new Set(), root: db, notificationOrigin: randomUUID(), notify: false };
install(db, { scope });
return db;
}
function install(connection: Connection, context: Context) {
contexts.set(connection, context);
const transaction = connection.transaction;
connection.transaction = async function (callback, ...options) {
// Nested savepoints belong to the outermost transaction. Even a released
// savepoint must not wake a consumer before its parent commits.
const state = context.transaction ?? { topics: new Set<string>(), hints: new Map<string, Set<string>>(), uncertain: false };
try {
const result = await transaction.call(this, async (tx: Connection) => {
install(tx, { scope: context.scope, transaction: state });
return callback(tx);
}, ...options);
if (!context.transaction) context.scope.transactions.delete(state);
return result;
} catch (error) {
if (!context.transaction) {
// An XID is known before the first opted-in write can execute. If
// acquisition itself failed there cannot be a corresponding write.
if (state.xid) state.uncertain = true;
else context.scope.transactions.delete(state);
}
throw error;
} finally {
if (!context.transaction) for (const topic of state.topics) publish(context.scope, topic, "settled", state.hints.get(topic));
}
};
}
/** Await BEFORE the queue write, on its transaction. Nested transactions work. */
export async function signalDatabaseWork(connection: object, topic: string, hint?: string): Promise<void> {
const context = contexts.get(connection);
if (!context) return; // Lightweight service test doubles have no DB lifecycle.
const state = context.transaction;
if (!state) throw new Error("Database work must be registered inside a transaction");
state.topics.add(topic);
if (hint !== undefined) {
let hints = state.hints.get(topic);
if (!hints) state.hints.set(topic, hints = new Set());
hints.add(hint);
}
context.scope.transactions.add(state);
publish(context.scope, topic, "intent", state.hints.get(topic));
if (!state.xid) {
state.xid = (connection as Connection).execute(sql`select pg_current_xact_id()::text as xid`)
.then((rows: { xid: string }[]) => rows[0]!.xid);
}
// Keep a failed acquisition distinguishable from an uncertain queue write.
try { await state.xid; }
catch (error) { state.xid = undefined; throw error; }
if (context.scope.notify) {
// PostgreSQL delivers only after the outer commit, including savepoints.
// Queue rows remain authoritative; notification payloads contain no data.
await (connection as Connection).execute(sql`select pg_notify(${DATABASE_WORK_CHANNEL}, ${notification(context.scope, topic)})`);
}
}
export const DATABASE_WORK_CHANNEL = "paperclip_work_v1";
const validTopic = (topic: string) => /^[a-zA-Z0-9:_-]{1,200}$/.test(topic);
function notification(scope: Scope, topic: string) {
if (!validTopic(topic)) throw new Error("Invalid database work topic");
return JSON.stringify({ version: 1, origin: scope.notificationOrigin, topic });
}
export function enableDatabaseWorkNotifications(owner: object): void {
const scope = contexts.get(owner)?.scope;
if (!scope) throw new Error("Database work notifications require createDb");
scope.notify = true;
}
/** A post-commit invalidation hint, for already-persisted live UI state. */
export async function publishDatabaseWorkHint(owner: object, topic: string): Promise<void> {
const scope = contexts.get(owner)?.scope;
if (scope?.notify) await scope.root.execute(sql`select pg_notify(${DATABASE_WORK_CHANNEL}, ${notification(scope, topic)})`);
}
export function receiveDatabaseWorkNotification(owner: object, payload: string): void {
const scope = contexts.get(owner)?.scope;
if (!scope || payload.length > 1024) return;
let value: { version?: unknown; origin?: unknown; topic?: unknown };
try { value = JSON.parse(payload); } catch { return; }
if (!value || value.version !== 1 || typeof value.origin !== "string" || value.origin === scope.notificationOrigin
|| typeof value.topic !== "string" || !validTopic(value.topic)) return;
publish(scope, value.topic);
}
/** Reconnect can lose hints. Reconstruct existing durable queues once; no poll. */
export function reconcileDatabaseWorkNotifications(owner: object): void {
const scope = contexts.get(owner)?.scope;
if (scope) for (const topic of scope.listeners.keys()) publish(scope, topic);
}
export function databaseWorkPending(owner: object, topic: string): boolean {
return [...(contexts.get(owner)?.scope.transactions ?? [])].some(state => state.topics.has(topic));
}
/** Resolve only failed transactions, on a fresh root-pool statement. Call
* BEFORE the queue scan so rows from a newly confirmed commit are visible.
* NULL means the XID aged out; it can no longer be in progress, so scanning
* the durable queue is sufficient even though its outcome is unavailable.
*/
export async function reconcileDatabaseWork(owner: object, topic: string): Promise<void> {
const scope = contexts.get(owner)?.scope;
if (!scope) return;
for (const state of [...scope.transactions]) {
if (!state.uncertain || !state.topics.has(topic) || !state.xid) continue;
const xid = await state.xid;
const rows = await scope.root.execute(sql`select pg_xact_status(${xid}::xid8) as status`);
if (rows[0]?.status === "in progress") continue;
if (!rows.length || ![null, "committed", "aborted"].includes(rows[0].status)) {
throw new Error("Unexpected database transaction status");
}
scope.transactions.delete(state);
for (const affected of state.topics) publish(scope, affected, "settled", state.hints.get(affected));
}
}
/** Includes intent, outer settlement, and uncertainty-resolution changes. */
export function subscribeDatabaseWork(owner: object, topic: string, listener: Listener): () => void {
const context = contexts.get(owner);
if (!context) throw new Error("Database work subscriptions require createDb");
let listeners = context.scope.listeners.get(topic);
if (!listeners) context.scope.listeners.set(topic, listeners = new Set());
listeners.add(listener);
if (databaseWorkPending(owner, topic)) {
try { listener("intent"); } catch (error) { process.emitWarning(`Database work listener failed: ${String(error)}`); }
}
return () => { listeners.delete(listener); };
}
export function databaseWorkScopeKey(connection: object): object {
return contexts.get(connection)?.scope.key ?? connection;
}