mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-07 16:35:48 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Legacy adapters must save sandbox files before Paperclip releases their source. > - Graceful shutdown gave each adapter a shared 30-second settlement wait. > - When that wait ended, cleanup could stop the sandbox while the adapter still exported files. > - That race created a restore failure and blocked the next turn. > - This pull request keeps export ownership through the finalizer drain and renews its cleanup fence. > - A successful late export can release its source and schedule one bounded continuation. ## Linked Issues or Issue Description Refs #15857. This corrects the timeout path introduced by the shutdown settlement fence. Refs #16478. That repair clears verified historical holds; this change prevents an active export from losing its source. Refs #16835. That merged fix handles remote native runner ownership. This change handles legacy shutdown settlement. ## What Changed - Keep a late adapter settlement in the shared execution finalizer set. Wait for it before lease release, recovery classification, and retry scheduling. - Renew the exact owned settlement fence every 10 seconds. Use a 30-second lease, one renewal in flight, a one-second database lock timeout, and a five-second statement timeout. Report bounded failure categories without raw database errors or credentials. - Refuse ordinary cleanup while the local adapter still owns settlement. Preserve the explicit owner stop-and-retain path. - Give the finalizer drain a further shared 30-second wait after the initial shutdown drain. - Document the ownership rule, the acceptance recipe, and the controller-loss limit. ## Verification - Red before the repair: the database-backed expired-wait regression expected cleanup denial but received permission to stop the still-owned source. - Green on `615a4382c5`: all 526 tests in `recovery.test.ts`, `legacy-workspace-restore-recovery.test.ts`, `shutdown.test.ts`, and `heartbeat-process-recovery.test.ts` passed. - The assembled heartbeat regression passed. It verifies no early release or retry, joining the late owner, successful cleanup, one durable scheduled continuation, and no duplicate after service reload. - Safety coverage includes other-controller fencing, controller loss, exact source retention, stop-only cancellation, completion winning the status race, and real database lock contention during renewal. - `pnpm -r typecheck`, `pnpm build`, and `node scripts/check-maps.mjs` passed on the final source revision. - A broad local `pnpm test:run` crossed the rebase and reported eight companion supervision failures. It was stopped and its log retained. The complete companion file then passed all 753 tests on current source. This is not a passing full local suite; current-head CI covers the complete repository checks. - The 34 recovery tests include real lock-timeout classification and credential-shaped error redaction. - Current-head CI: 53 checks passed; two optional Storybook checks skipped. Greptile is 5/5 on `615a4382c5`; no unresolved review threads. - Live sandbox acceptance remains unverified. Three disposable local-controller/Daytona attempts used legacy Codex ACP, `gpt-6-astra`, API-key authentication, and the verified image `ghcr.io/paperclipai/paperclip-daytona-runner@sha256:a2bd08cefb68d9217bdd75cf87a6b51f1705b18a4796eff3f97b2726a548b8fb`. ACP initialization exited before model execution on macOS Node 26.4.0 and again on Node 24.21.0; one attempt exposed a local process-session socket `ECONNRESET`. That does not establish the transport failure's root cause. No attempt reached the delayed export or second turn, so this PR remains draft pending faithful live acceptance. Probe setup defects were retained separately from product failures, and all test-owned sandboxes were cleaned up. ## Risks - Shutdown can spend up to 30 more seconds joining finalizers. The shipped Compose stop grace remains 90 seconds. - Renewal adds at most one database operation per owned run every 10 seconds during shutdown. Lock and statement deadlines bound database contention. The local adapter still protects its source if renewal fails. - Controller death or an export that outlives the whole process still needs retained-source repair. This change does not reconstruct a legacy export after process death or clear historical failed-restore holds. - No schema or public API changes. Native restart ownership, approval gates, and retry limits retain their existing behavior. - This PR has not been deployed. It does not recover an existing retained sandbox by itself. ## Model Used OpenAI Codex, GPT-6 family, with reasoning, repository inspection, code execution, and test tools. The exact backend model identifier and context window were not exposed to 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 <noreply@paperclip.ing>
182 lines
12 KiB
TypeScript
182 lines
12 KiB
TypeScript
import { and, eq, gt, inArray, ne, or, sql } from "drizzle-orm";
|
|
import { environmentLeases, heartbeatRuns, issues, type Db } from "@paperclipai/db";
|
|
import { adapterExecutionControls } from "./adapter-execution-control.js";
|
|
import { legacyControllerBootId } from "./legacy-controller-lease.js";
|
|
import { LEGACY_WORKSPACE_RECOVERY_SCHEMA } from "./workspace-restore-recovery-state.js";
|
|
import { logger } from "../middleware/logger.js";
|
|
|
|
const SCHEMA = "paperclip.legacy-shutdown-workspace-settlement.v1";
|
|
type Lease = Pick<typeof environmentLeases.$inferSelect, "id" | "companyId" | "heartbeatRunId" | "metadata">;
|
|
type Run = Pick<typeof heartbeatRuns.$inferSelect, "id" | "companyId">;
|
|
const SETTLEMENT_RENEW_MS = 10_000;
|
|
const SETTLEMENT_LEASE_MS = 30_000;
|
|
|
|
function settlementRenewalFailureKind(error: unknown) {
|
|
for (let depth = 0; depth < 4 && error && typeof error === "object"; depth++) {
|
|
const value = error as { code?: unknown; cause?: unknown };
|
|
if (value.code === "55P03") return "lock_timeout";
|
|
if (value.code === "57014") return "statement_cancelled";
|
|
if (typeof value.code === "string" && (/^08[0-9A-Z]{3}$/.test(value.code)
|
|
|| ["ECONNRESET", "ECONNREFUSED", "ETIMEDOUT", "CONNECT_TIMEOUT", "CONNECTION_CLOSED", "CONNECTION_ENDED", "CONNECTION_DESTROYED"].includes(value.code))) {
|
|
return "connection_failed";
|
|
}
|
|
error = value.cause;
|
|
}
|
|
return "unknown";
|
|
}
|
|
|
|
export function maintainLegacyShutdownWorkspaceSettlement(db: Db, run: Run) {
|
|
let stopped = false;
|
|
let pending: Promise<void> | null = null;
|
|
const renew = () => {
|
|
if (stopped || pending) return;
|
|
pending = db.transaction(async tx => {
|
|
await tx.execute(sql`SET LOCAL lock_timeout = '1s'`);
|
|
await tx.execute(sql`SET LOCAL statement_timeout = '5s'`);
|
|
await tx.update(environmentLeases).set({ metadata: sql`jsonb_set(${environmentLeases.metadata},
|
|
'{legacyShutdownWorkspaceSettlement,deadlineAt}', ${JSON.stringify(new Date(Date.now() + SETTLEMENT_LEASE_MS).toISOString())}::jsonb)` })
|
|
.where(and(eq(environmentLeases.companyId, run.companyId), eq(environmentLeases.heartbeatRunId, run.id),
|
|
sql`${environmentLeases.metadata}->'legacyShutdownWorkspaceSettlement'->>'schema' = ${SCHEMA}`,
|
|
sql`${environmentLeases.metadata}->'legacyShutdownWorkspaceSettlement'->>'runId' = ${run.id}`,
|
|
sql`${environmentLeases.metadata}->'legacyShutdownWorkspaceSettlement'->>'controllerBootId' = ${legacyControllerBootId}`,
|
|
sql`${environmentLeases.metadata}->'legacyShutdownWorkspaceSettlement'->>'state' = 'pending'`,
|
|
sql`(${environmentLeases.metadata}->'legacyShutdownWorkspaceSettlement'->>'deadlineAt')::timestamptz > clock_timestamp()`,
|
|
));
|
|
}).catch(error => {
|
|
logger.warn({ runId: run.id, errorKind: settlementRenewalFailureKind(error) }, "Shutdown workspace settlement lease renewal failed; source remains protected locally");
|
|
}).finally(() => { pending = null; });
|
|
};
|
|
const timer = setInterval(renew, SETTLEMENT_RENEW_MS);
|
|
timer.unref?.();
|
|
return async () => { stopped = true; clearInterval(timer); await pending; };
|
|
}
|
|
|
|
export function pendingLegacyShutdownWorkspaceSettlement(lease: Lease) {
|
|
const value = lease.metadata?.legacyShutdownWorkspaceSettlement;
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
const marker = value as Record<string, unknown>;
|
|
return marker.schema === SCHEMA && marker.state === "pending" && marker.runId === lease.heartbeatRunId
|
|
&& typeof marker.deadlineAt === "string" ? marker : null;
|
|
}
|
|
|
|
/** Historical reuse rows may name the same allocation as the fenced source. */
|
|
export async function legacyShutdownWorkspaceResourceProtected(db: Db,
|
|
lease: Pick<typeof environmentLeases.$inferSelect, "provider" | "providerLeaseId">) {
|
|
if (!lease.provider || !lease.providerLeaseId) return false;
|
|
const [protectedLease] = await db.select({ id: environmentLeases.id }).from(environmentLeases).where(and(
|
|
eq(environmentLeases.provider, lease.provider), eq(environmentLeases.providerLeaseId, lease.providerLeaseId),
|
|
or(sql`${environmentLeases.metadata}->'legacyShutdownWorkspaceSettlement'->>'schema' = ${SCHEMA}
|
|
and ${environmentLeases.metadata}->'legacyShutdownWorkspaceSettlement'->>'state' = 'pending'`,
|
|
sql`${environmentLeases.metadata}->'workspaceRestoreRecovery'->>'schema' = ${LEGACY_WORKSPACE_RECOVERY_SCHEMA}`),
|
|
)).limit(1);
|
|
return !!protectedLease;
|
|
}
|
|
|
|
/** Released stop receipts still need reconciliation if export never settled. */
|
|
export async function sweepLegacyShutdownWorkspaceSettlements(db: Db, now = new Date()) {
|
|
const rows = await db.select().from(environmentLeases).where(and(
|
|
sql`${environmentLeases.metadata}->'legacyShutdownWorkspaceSettlement'->>'schema' = ${SCHEMA}`,
|
|
sql`${environmentLeases.metadata}->'legacyShutdownWorkspaceSettlement'->>'state' = 'pending'`,
|
|
sql`${environmentLeases.metadata}->'legacyShutdownWorkspaceSettlement'->>'deadlineAt' <= ${now.toISOString()}`,
|
|
)).orderBy(sql`${environmentLeases.metadata}->'legacyShutdownWorkspaceSettlement'->>'deadlineAt'`).limit(50);
|
|
for (const lease of rows) await allowLegacyShutdownWorkspaceCleanup(db, lease, now);
|
|
}
|
|
|
|
/** Commit the fence while the run is still live, before exposing interruption. */
|
|
export async function beginLegacyShutdownWorkspaceSettlement(db: Db, run: Run, deadlineAt: Date) {
|
|
await db.update(environmentLeases).set({ metadata: sql`coalesce(${environmentLeases.metadata}, '{}'::jsonb) ||
|
|
${JSON.stringify({ legacyShutdownWorkspaceSettlement: { schema: SCHEMA, state: "pending", runId: run.id,
|
|
controllerBootId: legacyControllerBootId, deadlineAt: deadlineAt.toISOString() } })}::jsonb`,
|
|
}).where(and(eq(environmentLeases.companyId, run.companyId), eq(environmentLeases.heartbeatRunId, run.id),
|
|
eq(environmentLeases.status, "active"), ne(environmentLeases.provider, "local"),
|
|
or(sql`${environmentLeases.metadata}->>'driver' = 'sandbox'`, sql`${environmentLeases.metadata}->>'sandboxProviderPlugin' = 'true'`)));
|
|
}
|
|
|
|
/** The owner joins settlement, or records exact retention before timeout cleanup. */
|
|
export async function finishLegacyShutdownWorkspaceSettlement(db: Db, run: Run, state: "settled" | "expired" = "settled") {
|
|
await db.update(environmentLeases).set({ metadata: sql`jsonb_set(${environmentLeases.metadata},
|
|
'{legacyShutdownWorkspaceSettlement,state}', ${JSON.stringify(state)}::jsonb)` }).where(and(
|
|
eq(environmentLeases.companyId, run.companyId), eq(environmentLeases.heartbeatRunId, run.id),
|
|
sql`${environmentLeases.metadata}->'legacyShutdownWorkspaceSettlement'->>'schema' = ${SCHEMA}`,
|
|
sql`${environmentLeases.metadata}->'legacyShutdownWorkspaceSettlement'->>'controllerBootId' = ${legacyControllerBootId}`,
|
|
sql`${environmentLeases.metadata}->'legacyShutdownWorkspaceSettlement'->>'state' = 'pending'`,
|
|
));
|
|
}
|
|
|
|
/** A restart or another cleanup worker must never infer saved files from timeout. */
|
|
export async function allowLegacyShutdownWorkspaceCleanup(db: Db, lease: Lease, now = new Date(),
|
|
options: { ownerStopOnly?: boolean } = {}) {
|
|
if (!pendingLegacyShutdownWorkspaceSettlement(lease)) return true;
|
|
return db.transaction(async tx => {
|
|
const [hint] = await tx.select().from(environmentLeases).where(and(
|
|
eq(environmentLeases.id, lease.id), eq(environmentLeases.companyId, lease.companyId),
|
|
eq(environmentLeases.heartbeatRunId, lease.heartbeatRunId!),
|
|
));
|
|
if (!hint) return false;
|
|
if (!pendingLegacyShutdownWorkspaceSettlement(hint)) return true;
|
|
// Match the terminal recorder's issue -> run -> lease lock order. Settlement
|
|
// and expiry compete on the current lease row, not the caller's old snapshot.
|
|
if (hint.issueId) await tx.select({ id: issues.id }).from(issues).where(and(
|
|
eq(issues.id, hint.issueId), eq(issues.companyId, lease.companyId))).for("update");
|
|
const [run] = await tx.select().from(heartbeatRuns).where(and(
|
|
eq(heartbeatRuns.companyId, lease.companyId), eq(heartbeatRuns.id, lease.heartbeatRunId!),
|
|
eq(heartbeatRuns.runtimeMode, "legacy"),
|
|
)).for("update");
|
|
const [current] = await tx.select().from(environmentLeases).where(and(
|
|
eq(environmentLeases.id, lease.id), eq(environmentLeases.companyId, lease.companyId),
|
|
eq(environmentLeases.heartbeatRunId, lease.heartbeatRunId!),
|
|
)).for("update");
|
|
if (!run || !current) return false;
|
|
const marker = pendingLegacyShutdownWorkspaceSettlement(current);
|
|
if (!marker) return true;
|
|
// Stopping and retaining the exact owner's command is a prerequisite for
|
|
// some adapters to settle. This exception never permits provider destruction.
|
|
if (options.ownerStopOnly && marker.controllerBootId === legacyControllerBootId
|
|
&& adapterExecutionControls.get(run.id)?.controller.signal.aborted) return true;
|
|
if (marker.controllerBootId === legacyControllerBootId && adapterExecutionControls.has(run.id)) return false;
|
|
const deadline = Date.parse(marker.deadlineAt as string);
|
|
if (!Number.isFinite(deadline) || deadline > now.getTime()) return false;
|
|
// A killed container cannot certify export. Preserve source and hold in
|
|
// this same transaction before any worker may perform provider cleanup.
|
|
if (current.status !== "active") {
|
|
const { hasConfirmedSandboxStopAndRetain, readStopOnlyCleanup } = await import("./sandbox-stop-and-retain.js");
|
|
if (!(current.status === "released" && hasConfirmedSandboxStopAndRetain(current))
|
|
&& current.status !== "pending_cleanup") return false;
|
|
// The shutdown marker was committed on this exact active source before
|
|
// cancellation. A stop receipt does not certify the later file export.
|
|
const [competing] = await tx.select({ id: environmentLeases.id }).from(environmentLeases).where(and(
|
|
ne(environmentLeases.id, current.id), eq(environmentLeases.provider, current.provider!),
|
|
eq(environmentLeases.providerLeaseId, current.providerLeaseId!),
|
|
or(inArray(environmentLeases.status, ["active", "pending_cleanup"]),
|
|
gt(environmentLeases.acquiredAt, current.acquiredAt)),
|
|
)).limit(1);
|
|
if (competing) return false;
|
|
if (current.status === "pending_cleanup" && !readStopOnlyCleanup(current)) {
|
|
const expiresAt = current.metadata?.pendingCleanupLeaseExpiresAtMs;
|
|
if (current.metadata?.pendingCleanupInFlight === true
|
|
&& (typeof expiresAt !== "number" || !Number.isFinite(expiresAt) || expiresAt > now.getTime())) return false;
|
|
// A failed ordinary release can race the shutdown marker. Re-enter the
|
|
// recorder's active-source path within this claim transaction so it
|
|
// replaces destructive retry with an exact stop-only recovery intent.
|
|
await tx.update(environmentLeases).set({ status: "active" }).where(eq(environmentLeases.id, current.id));
|
|
} else {
|
|
await tx.update(environmentLeases).set({ leasePolicy: "retain_on_failure",
|
|
metadata: sql`${environmentLeases.metadata} || ${JSON.stringify({ workspaceRestoreRecovery: {
|
|
schema: LEGACY_WORKSPACE_RECOVERY_SCHEMA, runId: run.id,
|
|
} })}::jsonb`,
|
|
}).where(eq(environmentLeases.id, current.id));
|
|
}
|
|
}
|
|
const { terminalizeLegacyExecution } = await import("./legacy-execution-recovery.js");
|
|
const preserved = await terminalizeLegacyExecution({ db: tx as unknown as Db, run, status: run.status,
|
|
recordRestoreFailureOnly: true, patch: { resultJson: { workspaceRestoreFailure: "restore_failed" } } });
|
|
if (!preserved) return false;
|
|
await tx.update(environmentLeases).set({ metadata: sql`jsonb_set(${environmentLeases.metadata},
|
|
'{legacyShutdownWorkspaceSettlement,state}', '"expired"'::jsonb)` }).where(and(
|
|
eq(environmentLeases.id, lease.id), eq(environmentLeases.companyId, lease.companyId),
|
|
eq(environmentLeases.heartbeatRunId, run.id),
|
|
));
|
|
return true;
|
|
});
|
|
}
|