fix: stop idle environment cleanup polling (#17145)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agent environments can leave pending teardown or unverified provider
resources.
> - Recovery must preserve those resources until ownership and workspace
safety allow cleanup.
> - Empty maintenance scans keep an idle instance and its database
active.
> - The existing rows, transaction signals, and scheduler can represent
the actual recovery obligation.
> - This pull request schedules pending cleanup and historical
verification only while work remains.
> - An empty or fully verified instance stops these scans without losing
cleanup recovery.

## Linked Issues or Issue Description

**What happened?**

The server scanned pending environment cleanup on its maintenance tick.
The run reaper also scanned it even when it recovered no runs or active
leases. Historical resource verification scanned every five minutes,
including when all history already had valid cleanup proof.

**What did you expect?**

Writes should wake cleanup. Pending rows, deferred orphan buffers, and
unverified history should retain recovery deadlines. When none remain,
these workers should make no periodic database or provider calls.

**Steps to reproduce**

Start a configured instance with no pending leases and fully verified
history. Leave it idle. Observe repeated pending-cleanup and
historical-verification reads from the maintenance paths.

Related: #17106 supplies the preceding login-cleanup migration. #13523
improves lease-reaper query indexes. #16809 addresses reusable-sandbox
recovery authority. This PR keeps those authority checks in their
existing services.

## What Changed

- Register two workers with the existing delivery coordinator and shared
scheduler. Remove their unconditional maintenance-tick calls. Run the
reaper's inline pending cleanup only after it recovers actual work.
- Add commit-aware signals to the audited lease and custom-image
writers. Existing transactions notify directly; other writers use one
small transaction helper. No tables or migrations are added.
- Derive pending cleanup deadlines from the existing row backoff and
retry or claim metadata. Retain a historical retry only while an
unverified candidate exists.
- Register unwritten orphan buffers across runtimes that share the
database handle. Buffer registration wakes recovery without a database
connection. Load the existing spool at startup and retry failed or
capacity-limited loads while needed.
- Isolate spool failures per runtime. Attempt all buffers and persisted
pending rows, then retain a retry for any failed spool.
- Document producer coverage, idle behavior, and remaining blockers.
Update the sleep inventory.
- Repair the inherited CSS-recovery E2E test: the current build no
longer emits task-page CSS. Exercise the actual lazy environment
stylesheet, preserving real network failure, recovery, and bounded
reload assertions.

## Verification

October 24 integration update: merged current master and resolved the
runtime/spool conflicts. Preserve serialized ingestion of records
appended after startup. Plugin replacement failures now register
deferred recovery with an idle hold; concurrent callers share one
persistence attempt. The producer audit includes this new writer.

- Focused cleanup selection: 270 tests now pass across five suites. The
initial run lacked a built plugin SDK and executed no tests; building
the SDK fixed setup. The new concurrent-flush assertion then reproduced
duplicate rows; a single-flight guard fixed it and all 18 plugin
integration tests passed on rerun.
- Server typecheck passed. After review fixes, all 211 tests across the
cleanup worker, real orphan spool, environment runtime, and plugin
integration suites passed. These overlap the earlier focused selection.
The rate-limit test initially expected a write after twenty idle minutes
to wait; corrected it to assert that subsequent writes cannot accelerate
the five-minute cadence.
- Review fixes preserve retries for unreadable spool
directories/records, keep historical provider checks off the startup
wait while joining them at shutdown, and restore their independent
five-minute minimum spacing. The complete [CI
matrix](https://github.com/paperclipai/paperclip/actions/runs/40108905033)
passed on final commit `79ae1589c3c7b924db4bedb337d0c7ed3d95020b`,
including all E2E shards, server/workspace tests, runner checks,
typecheck, build/public-install verification, and security checks.

- CI found a stale E2E asset matcher: `issue-detail-*.css` is no longer
emitted. Both corrected browser tests passed against a fresh production
UI build and disposable local instance. Fresh Greptile review is 5/5 on
that same final commit, with all review threads resolved.

Previous implementation verification:

- `pnpm -r typecheck` and `pnpm build` passed.
- New PostgreSQL worker suite: 12 tests passed. Covers quiet configured
state, verified history, writes, outer commit and rollback, retry
deadlines, restart, drain, deferred orphan buffers, spool failure,
pagination, provider uncertainty, shutdown, and the idle run-reaper
path.
- Worker and environment runtime suites after review fix: 183 tests
passed. A new assertion reproduced buffer starvation before the fix.
Updated transaction fault injection and isolated simulated-instance
database handles. Server typecheck also passed after the fix.
- Run-recovery suites: 58 tests passed. Pending-cleanup suite: 38 tests
passed after updating the logging fixture to seed real orphan recovery;
an idle reaper no longer invokes cleanup.
- Stop and workspace-recovery integration selection: 268 tests passed;
two live-provider tests skipped by their environment gate. This overlaps
the worker suite and is not an additional unique-test count.
- Started the full local `pnpm test:run`, then stopped that older
duplicate run after CI found the fixture mismatch. The corrected 38-test
suite passed locally. The full sharded [CI
matrix](https://github.com/paperclipai/paperclip/actions/runs/39678174479)
passed on final commit `fc0ff3c4554ca4190a8b0b81ec06ef7065b8ad2c`,
including build, typecheck, runner, server, workspace, and E2E checks.
This is not a claim that the local full run completed.

## Risks

- Missing a future producer signal could delay discovery until restart.
The producer audit is documented in `doc/idle-environment-cleanup.md`.
Independent SQL writers and another server process are outside the
existing process-local notification contract.
- Historical candidates blocked by ownership or provider uncertainty
keep a five-minute retry until they can be verified. Provider
destruction, claim fencing, Stop retention, and workspace safety remain
controlled by their existing services.
- This does not remove orphan-active discovery or legacy shutdown
settlement from run recovery. Active custom-image expiry is also still
periodic. External wake coverage does not change.
- Local tests prove these workers become quiet and recover. They do not
establish deployed instance suspension or fleet cost savings.

## Model Used

OpenAI Codex (GPT-6), with code execution and reasoning. The exact
serving model ID and context size were not exposed.

## 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>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-10-24 09:04:49 -07:00
1 parent e5c9e60c5a
commit 71c04c0bfb
27 files changed
+741 -197

No files matched your search

+103
View File
@@ -0,0 +1,103 @@
# Idle environment cleanup
Pending lease cleanup and historical resource verification use the existing
transaction signals, delivery coordinator, and in-process scheduler. They no
longer run unconditionally from the server maintenance tick. The run reaper
performs its inline pending-cleanup sweep only after actually recovering a run
or an orphaned active lease. Explicit user retry keeps its existing direct path.
## Work and ownership
| Duty | Truth | Trigger and next deadline | Quiet condition |
| --- | --- | --- | --- |
| Failed teardown | Existing `environment_leases` rows in `pending_cleanup` | Commit signal; later of row backoff and saved retry/ownership deadline | No pending lease or deferred orphan |
| Failed acquisition before a DB row exists | Existing local orphan spool and the producing runtime's buffer | Local buffer registration; five-minute retry while buffered; startup spool load | Durable row owns recovery and spool copy is removed |
| Historical provider verification | Existing failed ephemeral leases and finished custom-image setups without matching cleanup proof | Commit signal; five-minute retry while an unverified candidate remains | No unverified candidates, including retained verified history |
`environment-cleanup-state.ts` owns the deadline query and the shared safe reader
for retry/ownership metadata. Cleanup claims and the scheduler use that same
reader, including malformed-number handling and the existing maximum horizon.
Startup retains the immediate scan, but never bypasses a saved in-flight claim or
retry cooldown. Subsequent scans respect the five-minute row backoff. Claims,
provider readiness, Stop authority, workspace retention, copyback, receipt
validation, and escalation rules stay with their current owners.
`environment-cleanup-work.ts` registers two independent workers. Their generation
counters preserve a wake until successful reconciliation, including a first write
during drain, changes during a sweep, and failure followed by drain. The existing
heartbeat interval bounds pending-cleanup starts after bursts of writes. Historical
verification has a separate minimum five-minute spacing, including commit-triggered
passes, and does not delay server readiness. No timer remains once
there is no obligation. A self-generated write can require a final reconciliation
before quiet. A failed sweep retains a retry. Shutdown unsubscribes local buffer
wakes; the delivery coordinator stops subscriptions/timers and joins active work.
## Producer coverage
The shared `writeEnvironmentCleanup` helper registers and awaits intent before a
write in a transaction. Existing transaction owners call
`notifyEnvironmentCleanup(tx)` directly. Nested calls retain outer transaction
settlement and uncertainty handling through the shared DB implementation.
- `environments.ts`: orphan insertion, release, metadata replacement, and run-wide
release. These stores cover normal provider teardown and failed acquisition.
- `environment-runtime.ts`: reusable sandbox cleanup claims and their settlement,
and Stop/export cleanup claims. Renewal of an already active claim retains its
bounded timer; it cannot create new pending work.
- `heartbeat/recovery.ts` and `heartbeat/run-control.ts`: orphan-active recovery,
retry settlement, and explicit recovery of historical leases.
- `environment-task-runtime.ts`: unconfirmed plugin task termination and plugin
replacement orphans, including a registered deferred flusher when the row
cannot be written. The flusher retains an idle hold until the row is durable.
- `sandbox-stop-and-retain.ts`: persisted Stop intent and its settlement.
- `native-workspace-export-resume.ts` and `native-workspace-export-retry.ts`:
terminal-export cleanup, resumed-export cleanup claims, and settlement.
- `workspace-recovery.ts`: verified copyback schedules destruction.
- `native-workspace-sync.ts` and the native session executor's backup stamp:
writes that can invalidate an existing historical row-version proof.
- `environment-custom-images.ts`: provider state, terminal state, and promotion.
Initial active setup insertion is not a historical verification candidate.
Other lease metadata writes neither create pending cleanup nor change the
row-version/provider identity of a matching historical proof. Changes to an
already pending claim remain covered by its existing deadline. Deletion removes
work; an already scheduled reconciliation observes that removal. Independent SQL
writers and another server process are not covered by these process-local signals.
## Deferred orphan buffers
Runtimes that hold an unwritten orphan register their flusher in a registry scoped
to the shared database handle. Registration wakes the worker without contacting
the database. A sweep flushes registered runtimes as well as its own startup spool;
this reaches a producer runtime's buffer even when it differs from the heartbeat's
runtime. The registry retains only runtimes with unresolved buffer ownership.
Unreadable directories, records, and malformed records retain a retry; a missing
spool directory is an empty spool. A spool read failure still allows other runtimes to flush their buffers and
persisted leases to clean up, then fails the sweep so recovery stays scheduled.
Each concrete cleanup pass reloads the spool, including entries appended by
plugin task replacement after startup and entries beyond buffer capacity.
No empty-spool filesystem or database polling is introduced. Existing limits on a simultaneous database,
spool, and buffer-capacity failure remain; this is not a new durable queue.
## Verification and boundaries
The PostgreSQL worker tests exercise quiet configured state and verified history,
real store activation, persisted backoff, failed teardown and restart, nested
commit/rollback, drain/resume, cross-runtime deferred work, failed spool reads,
shutdown joining, provider uncertainty, and historical pagination. Existing
runtime, reaper, Stop, custom-image, and workspace tests cover the underlying
cleanup authority and provider contracts.
The runtime fixture now gives each simulated instance its own DB handle and spool.
Its journal-write failure injection targets the transaction boundary rather than
the direct update that moved inside that transaction. Fake-clock worker tests wait
for PostgreSQL I/O and distinguish an executing sweep from a retained next wake.
This does not migrate discovery of orphaned active leases or legacy shutdown
workspace settlement from the broader run-recovery loop. Custom-image *active
session expiry* also remains on its existing tick; only historical verification
moves here. Other maintenance duties, external probes, and connected transports
still affect idle traffic. No Cloud alarm coverage or host-sleep exemption changes.
These tests prove the changed workers become quiet and recover; they do not prove
deployed suspension or fleet savings.
@@ -613,12 +613,15 @@ Longer polling intervals alone do not complete an item.
artifacts, deliveries, and ownership before deleting resources. Source:
[execution-workspaces.ts](../../server/src/services/execution-workspaces.ts).
- [ ] **JOB-17 — Sandbox cleanup and orphan spools.** Current: **30s** scans even
when attempts have longer staleness/backoff. Change: durable cleanup queue with
next retry, immediate failed-release activation, and provider/fleet backstop.
**Complete when:** idle scans stop without leaking paid sandboxes or ignoring
malformed/unpersisted spool entries. Source:
[heartbeat.ts](../../server/src/services/heartbeat.ts), `sweepPendingCleanupLeases`.
- [ ] **JOB-17 — Sandbox cleanup and orphan spools (partially migrated).**
Pending lease cleanup now uses commit signals and saved retry/claim deadlines.
Deferred orphan buffers wake the worker without a DB connection; startup loads
the existing spool. Historical provider verification stops when matching proof
is complete. Empty maintenance passes no longer call these sweeps. Discovery of
orphaned active leases and legacy shutdown workspace settlement still belong
to the broader run-recovery loop. No Cloud sleep exemptions changed. See
[idle-environment-cleanup.md](../idle-environment-cleanup.md) for producer scope,
recovery, tests, and remaining limits.
- [x] **JOB-18 — Adapter login and setup-token reapers.** Replaced the default
**30s** empty scans with commit-aware workers, session/promotion expiry
@@ -0,0 +1,236 @@
import { mkdtemp, writeFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createSandboxOrphanCleanupSpool } from "../services/sandbox-orphan-cleanup-spool.js";
import { nextEnvironmentCleanupAt } from "../services/environment-cleanup-state.js";
import { idleWorkSnapshot } from "../services/task-admission.js";
import { randomUUID } from "node:crypto";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { eq, sql } from "drizzle-orm";
import { companies, createDb, environmentLeases, environments, type Db } from "@paperclipai/db";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
import { createDeliveryWorkCoordinator } from "../services/delivery-work-coordinator.js";
import { registerEnvironmentCleanupWork } from "../services/environment-cleanup-work.js";
import { registerDeferredEnvironmentCleanup } from "../services/environment-cleanup-notifications.js";
import { environmentService } from "../services/environments.js";
import { historicalCleanupVerificationService } from "../services/historical-cleanup-verification.js";
import { heartbeatService } from "../services/heartbeat.js";
import { environmentRuntimeService } from "../services/environment-runtime.js";
const support = await getEmbeddedPostgresTestSupport();
describe.skipIf(!support.supported)("environment cleanup scheduling", () => {
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>, db: ReturnType<typeof createDb>;
let companyId: string, environmentId: string, draining: boolean, failing: boolean;
let coordinator: ReturnType<typeof createDeliveryWorkCoordinator> | undefined;
let worker: ReturnType<typeof registerEnvironmentCleanupWork> | undefined;
let releases: ReturnType<typeof vi.fn>, inspections: ReturnType<typeof vi.fn>, reads: ReturnType<typeof vi.spyOn>;
const errors: unknown[] = [], disposers: Array<() => void> = [];
beforeAll(async () => {
database = await startEmbeddedPostgresTestDatabase("environment-cleanup-work-");
db = createDb(database.connectionString, { maxConnections: 6, idleTimeoutSeconds: 0, maxLifetimeSeconds: 0 });
await Promise.all(Array.from({ length: 6 }, () => db.execute(sql`select pg_sleep(0.01)`)));
}, 90_000);
beforeEach(async () => {
await db.execute(sql`TRUNCATE companies, environments CASCADE`);
companyId = randomUUID(); environmentId = randomUUID(); draining = false; failing = false; errors.length = 0;
await db.insert(companies).values({ id: companyId, name: "Cleanup worker", issuePrefix: "CLN" });
await db.insert(environments).values({ id: environmentId, name: environmentId, driver: "sandbox" });
releases = vi.fn(async () => { if (failing) throw new Error("provider unavailable"); return undefined; });
inspections = vi.fn(async (row: { providerLeaseId: string | null }) => ({ providerLeaseId: row.providerLeaseId, state: "destroyed" }));
reads = vi.spyOn(db, "select");
vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout", "setInterval", "clearInterval"], shouldAdvanceTime: true });
});
afterEach(async () => {
worker?.stop(); await coordinator?.stop(); coordinator = undefined; worker = undefined;
for (const stop of disposers.splice(0)) stop();
vi.useRealTimers(); vi.restoreAllMocks();
expect(errors).toEqual([]);
});
afterAll(async () => { await db?.$client.end({ timeout: 1 }); await database?.cleanup(); });
async function start(historical = false, sweepOverride?: (backoffMs: number) => Promise<unknown>) {
const runtime = environmentRuntimeService(db, { drivers: [{ driver: "sandbox", retryPendingSandboxTeardown: releases } as any] });
const heartbeat = heartbeatService(db, { environmentRuntime: runtime });
coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => !draining, canReconcile: () => true,
onError: error => errors.push(error) });
const history = historical && historicalCleanupVerificationService(db, { inspectLease: inspections, inspectSetup: inspections });
let historySettled = !history;
worker = registerEnvironmentCleanupWork(db, coordinator, {
sweepPending: sweepOverride ?? (backoffMs => heartbeat.sweepPendingCleanupLeases({ backoffMs })),
historical: history ? { hasPending: history.hasPending,
sweep: async () => { const result = await history.sweep(); historySettled = true; return result; } } : undefined,
canRun: () => !draining,
});
await worker.ready;
await vi.waitFor(() => expect(historySettled).toBe(true));
}
async function settle(ms = 30_000, wait = true) {
await vi.advanceTimersByTimeAsync(ms);
if (wait && !draining) {
await vi.waitFor(() => expect(idleWorkSnapshot().active === 0 || coordinator!.nextWakeAt() !== null).toBe(true), { timeout: 5000 });
await vi.advanceTimersByTimeAsync(1);
await vi.waitFor(() => expect(idleWorkSnapshot().active === 0 || coordinator!.nextWakeAt() !== null).toBe(true), { timeout: 5000 });
}
}
const pending = (executor: Db = db) => environmentService(executor).insertPendingCleanupLease({ companyId, environmentId,
provider: "fixture", providerLeaseId: randomUUID(), metadata: { driver: "sandbox" }, failureReason: "fixture" });
async function quiet() {
for (let pass = 0; pass < 4; pass++) {
await settle(30_000);
if (coordinator!.nextWakeAt() === null) break;
}
expect(coordinator!.nextWakeAt()).toBeNull();
const count = reads.mock.calls.length, providerCalls = releases.mock.calls.length + inspections.mock.calls.length;
await settle(20 * 60_000);
expect(reads).toHaveBeenCalledTimes(count);
expect(releases.mock.calls.length + inspections.mock.calls.length).toBe(providerCalls);
}
it("stays quiet with an idle environment and verified history, and rechecks changed proof", async () => {
const lease = await pending();
await environmentService(db).releaseLease(lease.id, "failed", { cleanupStatus: "success" });
await start(true); expect(inspections).toHaveBeenCalledTimes(1);
await quiet();
await environmentService(db).updateLeaseMetadata(lease.id, { driver: "sandbox", changed: true });
await settle(); expect(inspections).toHaveBeenCalledTimes(2);
await environmentService(db).updateLeaseMetadata(lease.id, { changed: "again" });
await settle(90_000); expect(inspections).toHaveBeenCalledTimes(2);
await settle(180_000); expect(inspections).toHaveBeenCalledTimes(3);
await quiet();
});
it("wakes on a real orphan insert, waits for its backoff, then disarms after deletion", async () => {
await start(); await quiet();
const lease = await pending(); await settle();
expect(releases).not.toHaveBeenCalled();
const count = reads.mock.calls.length;
await settle(240_000); expect(reads).toHaveBeenCalledTimes(count);
await settle(30_000); expect(releases).toHaveBeenCalledTimes(1);
expect((await environmentService(db).getLeaseById(lease.id))?.status).toBe("expired");
await quiet();
});
it("does not rescan pending cleanup from an otherwise idle run-reaper pass", async () => {
const lease = await pending();
await db.update(environmentLeases).set({ updatedAt: new Date(Date.now() - 600_000) }).where(eq(environmentLeases.id, lease.id));
const runtime = environmentRuntimeService(db, { drivers: [{ driver: "sandbox", retryPendingSandboxTeardown: releases } as any] });
await heartbeatService(db, { environmentRuntime: runtime }).reapOrphanedRuns({ staleThresholdMs: 300_000 });
expect(releases).not.toHaveBeenCalled();
await start(); expect(releases).toHaveBeenCalledTimes(1); await quiet();
});
it("retries a concrete failed teardown and reconstructs its cooldown after restart", async () => {
const lease = await pending(); failing = true;
await start(); expect(releases).toHaveBeenCalledTimes(1);
const due = await nextEnvironmentCleanupAt(db); expect(due).toBeGreaterThan(Date.now());
worker!.stop(); await coordinator!.stop();
await start(); expect(releases).toHaveBeenCalledTimes(1);
failing = false; await settle(300_000); expect(releases).toHaveBeenCalledTimes(2);
expect((await environmentService(db).getLeaseById(lease.id))?.status).toBe("expired");
await quiet();
});
it.each([false, true])("respects outer transaction settlement (rollback %s)", async rollback => {
await start(); await quiet();
let release!: () => void, written!: () => void;
const gate = new Promise<void>(resolve => { release = resolve; });
const ready = new Promise<void>(resolve => { written = resolve; });
const writing = db.transaction(async tx => { await pending(tx as unknown as Db); written(); await gate; if (rollback) throw new Error("rollback"); })
.catch(error => { if (!rollback) throw error; });
await ready; await settle(30_000, false); expect(releases).not.toHaveBeenCalled();
release(); await writing; await settle(300_000);
expect(releases).toHaveBeenCalledTimes(rollback ? 0 : 1); await quiet();
});
it("preserves the first write during drain without passive SQL", async () => {
await start(); await quiet(); draining = true;
await pending(); const count = reads.mock.calls.length;
await settle(600_000); expect(reads).toHaveBeenCalledTimes(count); expect(releases).not.toHaveBeenCalled();
draining = false; await settle(); expect(releases).toHaveBeenCalledTimes(1); await quiet();
});
it("wakes for deferred work from another runtime and retries while no durable row exists", async () => {
await start(); await quiet();
let available = false, stop!: () => void;
const flush = vi.fn(async () => {
if (!available) return { recovered: 0, pending: 1 };
await pending(); stop(); return { recovered: 1, pending: 0 };
});
stop = registerDeferredEnvironmentCleanup(db, flush); disposers.push(stop);
await settle(); expect(flush).toHaveBeenCalledTimes(1);
expect(await db.select().from(environmentLeases)).toEqual([]);
const count = reads.mock.calls.length;
await settle(240_000); expect(reads).toHaveBeenCalledTimes(count);
available = true; await settle(60_000); expect(flush).toHaveBeenCalledTimes(2);
await settle(300_000); expect(releases).toHaveBeenCalledTimes(1); await quiet();
});
it("retries a failed spool read without starving other buffers or persisted cleanup", async () => {
const lease = await pending();
const stop = registerDeferredEnvironmentCleanup(db, async () => { throw new Error("spool unavailable"); });
disposers.push(stop);
let stopHealthy!: () => void;
let bufferedLeaseId: string | undefined;
const healthy = vi.fn(async () => {
bufferedLeaseId = (await pending()).id;
stopHealthy();
return { recovered: 1, pending: 0 };
});
stopHealthy = registerDeferredEnvironmentCleanup(db, healthy); disposers.push(stopHealthy);
await start();
expect(healthy).toHaveBeenCalledTimes(1);
expect(releases).toHaveBeenCalledTimes(2);
expect((await environmentService(db).getLeaseById(lease.id))?.status).toBe("expired");
expect((await environmentService(db).getLeaseById(bufferedLeaseId!))?.status).toBe("expired");
expect(errors).toHaveLength(1); errors.length = 0;
draining = true; const count = reads.mock.calls.length;
await settle(90_000); expect(reads).toHaveBeenCalledTimes(count);
stop(); draining = false; await quiet();
});
it("retains startup recovery for an unread real spool with no buffered or durable row", async () => {
const dir = await mkdtemp(join(tmpdir(), "cleanup-unread-spool-"));
try {
await writeFile(join(dir, "unread.json"), "{ truncated");
const runtime = environmentRuntimeService(db, { orphanCleanupSpool: createSandboxOrphanCleanupSpool(dir) });
const heartbeat = heartbeatService(db, { environmentRuntime: runtime });
await start(false, backoffMs => heartbeat.sweepPendingCleanupLeases({ backoffMs }));
expect(errors).toHaveLength(1); errors.length = 0;
expect(coordinator!.nextWakeAt()).not.toBeNull();
expect(await db.select().from(environmentLeases)).toEqual([]);
await rm(join(dir, "unread.json"));
await quiet();
} finally { await rm(dir, { recursive: true, force: true }); }
});
it("does not delay startup for historical provider checks but joins them on shutdown", async () => {
let release!: () => void;
const gate = new Promise<void>(resolve => { release = resolve; });
const sweep = vi.fn(() => gate);
coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => true, onError: error => errors.push(error) });
worker = registerEnvironmentCleanupWork(db, coordinator, {
sweepPending: async () => {}, historical: { sweep, hasPending: async () => false }, canRun: () => true,
});
let ready = false, stopped = false;
void worker.ready.then(() => { ready = true; });
try {
await vi.waitFor(() => expect(ready).toBe(true));
expect(sweep).toHaveBeenCalledOnce();
const stopping = coordinator.stop().then(() => { stopped = true; });
await vi.advanceTimersByTimeAsync(1); expect(stopped).toBe(false);
release(); await stopping; expect(stopped).toBe(true);
} finally { release(); }
});
it("coalesces a write during a slow sweep and waits for it on shutdown", async () => {
await start(); await quiet(); worker!.stop(); await coordinator!.stop();
let release!: () => void;
const gate = new Promise<void>(resolve => { release = resolve; });
let calls = 0;
const starting = start(false, async () => { calls++; if (calls === 1) await gate; });
await settle(1, false); await pending();
let stopped = false; const stopping = coordinator!.stop().then(() => { stopped = true; });
await settle(30_000, false); expect(stopped).toBe(false); expect(calls).toBe(1);
release(); await starting; await stopping; expect(stopped).toBe(true);
});
it("rotates historical pages until all confirmed records are passive", async () => {
for (let i = 0; i < 33; i++) {
const lease = await pending(); await environmentService(db).releaseLease(lease.id, "failed", { cleanupStatus: "success" });
}
await start(true); await settle(300_000); await settle(300_000); expect(inspections).toHaveBeenCalledTimes(33); await quiet();
});
it("keeps unknown historical resources scheduled until provider confirmation", async () => {
const lease = await pending(); await environmentService(db).releaseLease(lease.id, "failed", { cleanupStatus: "success" });
inspections.mockResolvedValueOnce({ providerLeaseId: lease.providerLeaseId, state: "unknown" });
await start(true); expect(coordinator!.nextWakeAt()).not.toBeNull();
await settle(300_000); expect(inspections).toHaveBeenCalledTimes(2); await quiet();
});
});
@@ -187,6 +187,7 @@ describe("findReusableSandboxLeaseId", () => {
describeEmbeddedPostgres("environmentRuntimeService", () => {
let stopDb: (() => Promise<void>) | null = null;
let db!: ReturnType<typeof createDb>;
let connectionString: string;
let runtime!: ReturnType<typeof environmentRuntimeService>;
const fixtureRoots: string[] = [];
// Give each test its own orphan-cleanup spool directory, so a spool file one
@@ -198,13 +199,14 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
beforeAll(async () => {
const started = await startEmbeddedPostgresTestDatabase("environment-runtime");
stopDb = started.stop;
db = createDb(started.connectionString);
runtime = environmentRuntimeService(db);
connectionString = started.connectionString;
});
beforeEach(async () => {
orphanCleanupSpoolDir = await mkdtemp(path.join(os.tmpdir(), "orphan-cleanup-spool-"));
process.env.SANDBOX_ORPHAN_CLEANUP_SPOOL_DIR = orphanCleanupSpoolDir;
db = createDb(connectionString);
runtime = environmentRuntimeService(db);
});
afterEach(async () => {
@@ -236,6 +238,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
await db.delete(companySecrets);
await db.delete(projects);
await db.delete(companies);
await db.$client.end({ timeout: 1 });
});
afterAll(async () => {
@@ -1131,7 +1134,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
}
if (!injected && fault === "journal write failed") {
injected = true;
vi.spyOn(db, "update").mockImplementationOnce(() => { throw new Error("Database unavailable"); });
vi.spyOn(db, "transaction").mockRejectedValueOnce(new Error("Database unavailable"));
}
throw Object.assign(new Error("Persist observed sandbox identity before cleanup"), {
data: { schema: "paperclip/environment-creation-cleanup/v1", cleanup: observed },
@@ -6,6 +6,7 @@ import {
activityLog,
agents,
heartbeatRuns,
heartbeatRunEvents,
companies,
createDb,
environmentLeases,
@@ -74,6 +75,7 @@ describeEmbeddedPostgres("heartbeat sweepPendingCleanupLeases", () => {
await db.delete(activityLog);
await db.delete(environmentLeases);
await db.delete(costEvents);
await db.delete(heartbeatRunEvents);
await db.delete(heartbeatRuns);
await db.delete(environments);
await db.delete(agents);
@@ -1163,6 +1165,14 @@ describeEmbeddedPostgres("heartbeat sweepPendingCleanupLeases", () => {
updatedAt: new Date(Date.now() - 60 * 60 * 1000),
});
// Inline pending cleanup now runs only after actual orphan recovery. Seed
// an abandoned run so this test still reaches the isolated error boundary.
const agentId = randomUUID(), runId = randomUUID();
await db.insert(agents).values({ id: agentId, companyId, name: "Abandoned worker",
adapterType: "process", status: "idle" });
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId,
status: "running", invocationSource: "manual", startedAt: new Date(Date.now() - 3_600_000) });
// Make the sweep itself throw. The lease query rejects with an error that
// carries a credential-shaped sentinel in both the name and the message. The
// reaper's own queries pass through, so only the sweep fails.
@@ -1194,7 +1204,7 @@ describeEmbeddedPostgres("heartbeat sweepPendingCleanupLeases", () => {
});
// The reaper isolates the sweep, so the reaper itself resolves.
await expect(heartbeat.reapOrphanedRuns({ staleThresholdMs: 0 })).resolves.toBeDefined();
await expect(heartbeat.reapOrphanedRuns({ staleThresholdMs: 0 })).resolves.toEqual({ reaped: 1, runIds: [runId] });
const sweepCall = vi
.mocked(logger.error)
@@ -1,3 +1,4 @@
import { flushDeferredEnvironmentCleanup, hasDeferredEnvironmentCleanup, registerDeferredEnvironmentCleanup, subscribeDeferredEnvironmentCleanup } from "../services/environment-cleanup-notifications.js";
import { randomUUID } from "node:crypto";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { eq } from "drizzle-orm";
@@ -127,6 +128,14 @@ const support=await getEmbeddedPostgresTestSupport();
const f=await fixture();await f.nextRun();const next=await f.acquire();f.stopped();
if(mode==="cleanup-error" || mode==="spool") f.failRelease();
const append=vi.fn(async()=>true);
let delayFlush=false,flushCalls=0,releaseFlush!:()=>void;
const flushGate=new Promise<void>(resolve=>{releaseFlush=resolve;});
const stopGate=mode==="spool" ? registerDeferredEnvironmentCleanup(db,async()=>{
if(delayFlush && ++flushCalls===1) await flushGate;
return {recovered:0,pending:0};
}) : undefined;
const wake=vi.fn();
const unsubscribe=subscribeDeferredEnvironmentCleanup(db,wake);
const spool=mode==="spool" ? vi.spyOn(orphanCleanup,"createSandboxOrphanCleanupSpool").mockReturnValue({append,load:async()=>[],remove:async()=>{}}) : undefined;
const transaction=mode==="spool" ? vi.spyOn(db,"transaction").mockRejectedValue(Error("database unavailable")) : undefined;
const original=db.update.bind(db);
@@ -140,12 +149,30 @@ const support=await getEmbeddedPostgresTestSupport();
try {
await expect(f.runtime().task({companyId:f.companyId,leaseId:next.lease.id,operation:{kind:"describe"}}))
.rejects.toThrow("reconcile the same task");
} finally {update.mockRestore();transaction?.mockRestore();spool?.mockRestore();}
if(mode==="spool") {
expect(wake).toHaveBeenCalledOnce();
expect(hasDeferredEnvironmentCleanup(db)).toBe(true);
await expect(flushDeferredEnvironmentCleanup(db)).rejects.toThrow("Deferred orphan cleanup failed");
expect(hasDeferredEnvironmentCleanup(db)).toBe(true);
}
} finally {update.mockRestore();transaction?.mockRestore();spool?.mockRestore();unsubscribe();}
expect(f.calls).toContain("environmentReleaseLease");
if(mode==="spool") expect(append).toHaveBeenCalledWith(expect.objectContaining({providerLeaseId:"task-2",metadata:expect.objectContaining({driver:"plugin",runnerTaskDispatched:false})}));
const rows=await db.select().from(environmentLeases).where(eq(environmentLeases.companyId,f.companyId));
expect(rows.filter(row=>row.providerLeaseId === "task-2")).toEqual(mode==="cleanup-error"
? [expect.objectContaining({status:"pending_cleanup",failureReason:"plugin_task_replacement_unowned",metadata:expect.objectContaining({runnerTaskDispatched:false})})] : []);
if(mode==="spool") {
delayFlush=true;
const delayed=flushDeferredEnvironmentCleanup(db);
try { expect(await flushDeferredEnvironmentCleanup(db)).toEqual({recovered:1,pending:0}); }
finally { releaseFlush(); stopGate!(); }
expect(await delayed).toEqual({recovered:1,pending:0});
expect(hasDeferredEnvironmentCleanup(db)).toBe(false);
const recovered=await db.select().from(environmentLeases).where(eq(environmentLeases.companyId,f.companyId));
expect(recovered.filter(row=>row.providerLeaseId==="task-2")).toEqual([
expect.objectContaining({status:"pending_cleanup",failureReason:"plugin_task_replacement_unowned"}),
]);
}
});
it.each(["added","removed"] as const)("retires retained mounts when an authorized project is %s after controller restart",async mode=>{
@@ -1,7 +1,8 @@
import { promises as fs } from "node:fs";
import { mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
createSandboxOrphanCleanupSpool,
type DeferredOrphanCleanupRecord,
@@ -86,6 +87,21 @@ describe("createSandboxOrphanCleanupSpool", () => {
expect(loaded[0]?.providerLeaseId).toBe("sandbox://fake/lease-1");
});
it("distinguishes unread storage from empty storage without dropping readable records", async () => {
const spool = createSandboxOrphanCleanupSpool(dir);
const record = buildRecord();
await spool.append(record);
const denied = vi.spyOn(fs, "readdir").mockRejectedValueOnce(Object.assign(new Error("denied"), { code: "EACCES" }));
try { expect(await spool.loadWithStatus!()).toEqual({ records: [], readFailed: true }); }
finally { denied.mockRestore(); }
await writeFile(path.join(dir, "broken.json"), "{ truncated");
expect(await spool.loadWithStatus!()).toEqual({ records: [record], readFailed: true });
await rm(path.join(dir, "broken.json"));
expect(await spool.loadWithStatus!()).toEqual({ records: [record], readFailed: false });
expect(await createSandboxOrphanCleanupSpool(path.join(dir, "missing")).loadWithStatus!())
.toEqual({ records: [], readFailed: false });
});
it("returns an empty list when the spool directory does not exist", async () => {
const spool = createSandboxOrphanCleanupSpool(path.join(dir, "missing"));
expect(await spool.load()).toEqual([]);
+23 -39
View File
@@ -1,3 +1,4 @@
import { registerEnvironmentCleanupWork } from "./services/environment-cleanup-work.js";
import { loginCleanupSchedule } from "./services/login-cleanup-state.js";
import { registerLoginCleanupWork } from "./services/login-cleanup-work.js";
import { timeStartupRecovery } from "./startup-recovery-timing.js";
@@ -1304,8 +1305,8 @@ async function startServerWithDatabaseTeardown(
// foreign-company insert tears the provisioned sandbox down. If that teardown
// also fails, the acquire records a lease-less `pending_cleanup` lease row. No
// other path releases that sandbox, so the master pending-cleanup sweep retries
// the provider teardown and releases the orphan. The sweep runs on startup and
// on the scheduler interval.
// the provider teardown and releases the orphan. The sweep runs on startup,
// committed writes, and deadlines derived from unresolved cleanup.
//
// This backstop is independent of the heartbeat scheduler toggle. A leaked
// provider sandbox costs money whether or not the instance schedules
@@ -1315,13 +1316,8 @@ async function startServerWithDatabaseTeardown(
// The master pending-cleanup sweep is the single owner of these rows. Its
// atomic per-attempt claim makes two overlapping sweeps safe, so the enabled
// path can also run the sweep from the orphaned-run reaper without a second
// teardown. The heartbeat scheduler owns the sweep when it is enabled; the
// disabled path creates its own runtime to own the same sweep.
// The interval sweep waits this long after a lease's last write before it
// retries the teardown. The window matches the orphaned-run reaper staleness,
// so a just-failed lease does not draw a retry on every tick. The startup
// sweep passes zero, so a restart retries a stranded orphan at once.
const ENVIRONMENT_LEASE_CLEANUP_SWEEP_BACKOFF_MS = 5 * 60 * 1000;
// teardown. The delivery coordinator owns pending-work scheduling whether
// or not the heartbeat scheduler is enabled.
const environmentLeaseCleanupHeartbeat = accountingHeartbeat;
const chatCompletionDeliveries = chatCompletionDeliveryService(db as any, environmentLeaseCleanupHeartbeat);
const connectionDeliveries = connectionIntentDeliveryService(db as any, environmentLeaseCleanupHeartbeat);
@@ -1386,31 +1382,21 @@ async function startServerWithDatabaseTeardown(
inspectLease: lease => cleanupInspectionRuntime.inspectLeaseResource(lease.id),
inspectSetup: setup => environmentCustomImages.inspectSetupResource(setup),
});
let nextHistoricalCleanupCheck = 0;
const scheduleHistoricalCleanupVerification = () => {
if (!isCloudManagedInstance() || isIdleTaskDrainActive() || isWarmStandby() || heartbeatSchedulerStopped ||
Date.now() < nextHistoricalCleanupCheck) return;
nextHistoricalCleanupCheck = Date.now() + 5 * 60_000;
// Provider reads never delay readiness or the ordinary cleanup sweep.
trackHeartbeatSchedulerWork(historicalCleanup.sweep().then(result => {
if (result.verified > 0) logger.info(result, "verified historical provider cleanup");
}).catch(() => logger.warn("historical provider cleanup verification deferred")));
const runEnvironmentLeaseCleanupSweep = async (backoffMs: number) => {
const result = await environmentLeaseCleanupHeartbeat.sweepPendingCleanupLeases({ backoffMs });
if (result.destroyed > 0 || result.capped > 0) {
logger.info(result, "environment lease cleanup retried sandbox teardowns");
}
};
const runEnvironmentLeaseCleanupSweep = (backoffMs: number) =>
environmentLeaseCleanupHeartbeat
.sweepPendingCleanupLeases({ backoffMs })
.then((result) => {
scheduleHistoricalCleanupVerification();
if (result.destroyed > 0 || result.capped > 0) {
logger.info(result, "environment lease cleanup sweep retried orphan sandbox teardowns");
}
})
.catch((err) => {
logger.error({ err }, "environment lease cleanup sweep failed");
});
const scheduleEnvironmentLeaseCleanupSweep = () => {
if (isIdleTaskDrainActive() || heartbeatSchedulerStopped) return;
trackHeartbeatSchedulerWork(runEnvironmentLeaseCleanupSweep(ENVIRONMENT_LEASE_CLEANUP_SWEEP_BACKOFF_MS));
const startEnvironmentCleanup = async () => {
const worker = registerEnvironmentCleanupWork(db, deliveryWork, {
sweepPending: runEnvironmentLeaseCleanupSweep,
historical: isCloudManagedInstance() ? historicalCleanup : undefined,
canRun: () => !isIdleTaskDrainActive() && !isWarmStandby() && !heartbeatSchedulerStopped,
minimumSpacingMs: config.heartbeatSchedulerIntervalMs,
});
server.on("close", worker.stop);
await worker.ready;
};
const githubConnectionEvents = githubConnectionEventService(db as any, {
wakeup: environmentLeaseCleanupHeartbeat.wakeup,
@@ -1790,7 +1776,7 @@ async function startServerWithDatabaseTeardown(
], () => !isIdleTaskDrainActive() && !isWarmStandby() && !heartbeatSchedulerStopped, config.heartbeatSchedulerIntervalMs).ready);
// Retry any orphan sandbox teardown left by a failed acquire before a
// server restart, so a leaked sandbox does not stay allocated.
await step("environment_lease_cleanup", () => runEnvironmentLeaseCleanupSweep(0));
await step("environment_lease_cleanup", startEnvironmentCleanup);
await step("retention_sweep", runRetentionSweep);
};
@@ -1824,7 +1810,6 @@ async function startServerWithDatabaseTeardown(
scheduleMergedPullRequestConfirmationSweep();
scheduleGitHubConnectionEventPoll();
scheduleGitHubConnectionContinuitySweep();
scheduleEnvironmentLeaseCleanupSweep();
if (isIdleTaskDrainActive() || heartbeatSchedulerStopped) return;
scheduled.routines.wake();
@@ -1971,12 +1956,11 @@ async function startServerWithDatabaseTeardown(
} else {
// The heartbeat scheduler is disabled, but the orphan-sandbox cleanup sweep
// is still required. A failed acquire can leak a paid provider sandbox, so
// this path retries the teardown at startup and on the interval, exactly as
// the enabled path does.
await runEnvironmentLeaseCleanupSweep(0);
// this path registers the same startup recovery and pending-work deadlines
// as the enabled path.
await startEnvironmentCleanup();
startHeartbeatSchedulerInterval(() => {
scheduleExternalObjectRefreshSweep(new Date());
scheduleEnvironmentLeaseCleanupSweep();
scheduleGitHubConnectionEventPoll();
scheduleGitHubConnectionContinuitySweep();
});
@@ -7,6 +7,8 @@ export const DELIVERY_QUEUES = {
executionContinuation: "execution-continuation",
executionStatus: "execution-status",
executionDisposition: "execution-disposition",
environmentCleanup: "environment-cleanup",
historicalCleanup: "historical-cleanup",
adapterLoginCleanup: "adapter-login-cleanup",
setupTokenCleanup: "setup-token-cleanup",
localLoginCleanup: "local-login-cleanup",
@@ -0,0 +1,49 @@
import type { Db } from "@paperclipai/db";
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
export async function notifyEnvironmentCleanup(transaction: object): Promise<void> {
await notifyDeliveryWork(transaction, DELIVERY_QUEUES.environmentCleanup);
await notifyDeliveryWork(transaction, DELIVERY_QUEUES.historicalCleanup);
}
export function writeEnvironmentCleanup<T>(db: Db, write: (tx: Db) => PromiseLike<T>): Promise<T> {
return db.transaction(async tx => {
await notifyEnvironmentCleanup(tx);
return write(tx as unknown as Db);
});
}
type Flush = () => Promise<{ recovered: number; pending: number }>;
const deferred = new WeakMap<object, { flushers: Set<Flush>; listeners: Set<() => void> }>();
function state(db: object) {
let value = deferred.get(db);
if (!value) { value = { flushers: new Set(), listeners: new Set() }; deferred.set(db, value); }
return value;
}
export function registerDeferredEnvironmentCleanup(db: object, flush: Flush): () => void {
const value = state(db);
value.flushers.add(flush);
for (const wake of value.listeners) wake();
return () => { value.flushers.delete(flush); };
}
export function subscribeDeferredEnvironmentCleanup(db: object, wake: () => void): () => void {
const value = state(db);
value.listeners.add(wake);
return () => { value.listeners.delete(wake); };
}
export function hasDeferredEnvironmentCleanup(db: object): boolean {
return (deferred.get(db)?.flushers.size ?? 0) > 0;
}
export async function flushDeferredEnvironmentCleanup(db: object, additional: Iterable<Flush> = []): Promise<{ recovered: number; pending: number }> {
let recovered = 0, pending = 0, failures = 0;
for (const flush of new Set([...(deferred.get(db)?.flushers ?? []), ...additional])) {
try {
const result = await flush();
recovered += result.recovered; pending += result.pending;
} catch { failures++; }
}
// Attempt every runtime before retaining the retry. Provider/spool errors may
// contain credentials, so report only a count rather than their payloads.
if (failures > 0) throw new Error(`Deferred orphan cleanup failed for ${failures} runtime(s)`);
return { recovered, pending };
}
@@ -0,0 +1,26 @@
import { eq, sql } from "drizzle-orm";
import { environmentLeases, type Db } from "@paperclipai/db";
import { hasDeferredEnvironmentCleanup } from "./environment-cleanup-notifications.js";
export const ENVIRONMENT_CLEANUP_RETRY_MS = 5 * 60_000;
export function pendingCleanupDeadlineSql(now = Date.now()) {
const deadline = sql`case
when ${environmentLeases.metadata}->>'pendingCleanupInFlight' = 'true'
and ${environmentLeases.metadata} ? 'pendingCleanupLeaseExpiresAtMs'
then ${environmentLeases.metadata}->'pendingCleanupLeaseExpiresAtMs'
else ${environmentLeases.metadata}->'pendingCleanupRetryAfterMs' end`;
return sql`case when jsonb_typeof(${deadline}) = 'number' then
case when (${deadline})::numeric <= ${now + 30 * 60_000 + 1_000}
then greatest(0, (${deadline})::numeric) else 0 end
else 0 end`;
}
export async function nextEnvironmentCleanupAt(db: Db, backoffMs = ENVIRONMENT_CLEANUP_RETRY_MS): Promise<number | null> {
const [row] = await db.select({ next: sql<string | null>`min(greatest(
extract(epoch from ${environmentLeases.updatedAt}) * 1000 + ${backoffMs},
${pendingCleanupDeadlineSql()}))` }).from(environmentLeases).where(eq(environmentLeases.status, "pending_cleanup"));
const saved = row?.next == null ? null : Number(row.next);
const buffered = hasDeferredEnvironmentCleanup(db) ? Date.now() + backoffMs : null;
return saved === null ? buffered : buffered === null ? saved : Math.min(saved, buffered);
}
@@ -0,0 +1,51 @@
import type { Db } from "@paperclipai/db";
import { ENVIRONMENT_CLEANUP_RETRY_MS, nextEnvironmentCleanupAt } from "./environment-cleanup-state.js";
import type { createDeliveryWorkCoordinator } from "./delivery-work-coordinator.js";
import { DELIVERY_QUEUES, type DeliveryQueue } from "./delivery-work-notifications.js";
import { subscribeDeferredEnvironmentCleanup } from "./environment-cleanup-notifications.js";
export function registerEnvironmentCleanupWork(
db: Db,
coordinator: ReturnType<typeof createDeliveryWorkCoordinator>,
input: {
sweepPending: (backoffMs: number) => Promise<unknown>;
historical?: { sweep: () => Promise<unknown>; hasPending: () => Promise<boolean> };
canRun: () => boolean;
minimumSpacingMs?: number;
},
) {
const spacing = input.minimumSpacingMs ?? 30_000;
function register(queue: DeliveryQueue, run: () => Promise<unknown>, deadline: () => Promise<number | null>, minimumSpacing = spacing) {
let requested = 1, reconciled = 0, next: number | null = null, earliest = 0;
const worker = coordinator.register(queue, {
retryMs: spacing, pausePassiveMaintenance: true,
onSettled: () => { requested++; },
run: async () => {
if (!input.canRun()) { requested++; next = null; return; }
if (Date.now() < earliest) { next = earliest; return; }
const generation = ++requested;
earliest = Date.now() + minimumSpacing;
await run();
const due = await deadline();
next = due === null ? null : Math.max(due, earliest);
reconciled = generation;
},
hasPending: async () => requested !== reconciled,
nextRunAt: () => next,
});
return { ready: worker.ready, wake: () => { requested++; worker.wake(); } };
}
let initial = true;
const pending = register(DELIVERY_QUEUES.environmentCleanup, async () => {
await input.sweepPending(initial ? 0 : ENVIRONMENT_CLEANUP_RETRY_MS);
initial = false;
}, () => nextEnvironmentCleanupAt(db));
const unsubscribe = subscribeDeferredEnvironmentCleanup(db, pending.wake);
const historical = input.historical;
if (historical) register(DELIVERY_QUEUES.historicalCleanup, historical.sweep,
async () => await historical.hasPending() ? Date.now() + ENVIRONMENT_CLEANUP_RETRY_MS : null,
Math.max(spacing, ENVIRONMENT_CLEANUP_RETRY_MS));
// The coordinator tracks and joins historical work; provider checks must not
// delay server readiness or starting the heartbeat scheduler.
return { ready: pending.ready, stop: unsubscribe };
}
@@ -1,3 +1,4 @@
import { writeEnvironmentCleanup, notifyEnvironmentCleanup } from "./environment-cleanup-notifications.js";
import { randomUUID } from "node:crypto";
import { withoutCleanupVerification } from "./cleanup-verification-proof.js";
import { and, desc, eq, inArray, lte } from "drizzle-orm";
@@ -594,7 +595,7 @@ export function environmentCustomImageService(
): Promise<EnvironmentCustomImageSetupSession> {
const status = normalizePersistedStatus(providerSession.status, fallbackStatus);
const now = new Date();
const row = await db
const row = await writeEnvironmentCleanup(db, tx => tx
.update(environmentCustomImageSetupSessions)
.set({
providerLeaseId: providerSession.providerLeaseId ?? null,
@@ -607,7 +608,7 @@ export function environmentCustomImageService(
})
.where(eq(environmentCustomImageSetupSessions.id, session.id))
.returning()
.then((rows) => rows[0] ?? null);
.then((rows) => rows[0] ?? null));
if (!row) throw notFound("Environment customImage setup session not found");
return toSession(row);
}
@@ -619,7 +620,7 @@ export function environmentCustomImageService(
metadata?: Record<string, unknown> | null;
}): Promise<EnvironmentCustomImageSetupSession> {
const now = new Date();
const row = await db
const row = await writeEnvironmentCleanup(db, tx => tx
.update(environmentCustomImageSetupSessions)
.set({
status: input.status,
@@ -630,7 +631,7 @@ export function environmentCustomImageService(
})
.where(eq(environmentCustomImageSetupSessions.id, input.sessionId))
.returning()
.then((rows) => rows[0] ?? null);
.then((rows) => rows[0] ?? null));
if (!row) throw notFound("Environment customImage setup session not found");
return toSession(row);
}
@@ -1018,6 +1019,7 @@ export function environmentCustomImageService(
supersededRows.map((row) => row.id),
));
}
await notifyEnvironmentCleanup(tx);
await tx
.update(environmentCustomImageSetupSessions)
.set({
+22 -19
View File
@@ -1,3 +1,4 @@
import { flushDeferredEnvironmentCleanup, registerDeferredEnvironmentCleanup, writeEnvironmentCleanup } from "./environment-cleanup-notifications.js";
import { executeEnvironmentTask, pluginEnvironmentSupportsTasks, settleDispatchedEnvironmentTask } from "./environment-task-runtime.js";
import type { PluginEnvironmentTaskOperation } from "@paperclipai/plugin-sdk";
import { allowLegacyShutdownWorkspaceCleanup, legacyShutdownWorkspaceResourceProtected } from "./legacy-shutdown-workspace-settlement.js";
@@ -1350,12 +1351,14 @@ function createSandboxEnvironmentDriver(
// Keep the token through queue splices, failed flushes and buffer overflow.
// Release only when the database durably owns cleanup of this exact orphan.
const orphanIdleWork = new Map<string, () => void>();
let unregisterDeferredCleanup: (() => void) | undefined;
const orphanIdleKey = (record: DeferredOrphanCleanupRecord) => JSON.stringify([
record.companyId, record.provider, record.providerLeaseId ?? record,
]);
const trackOrphanIdleWork = (record: DeferredOrphanCleanupRecord) => {
const key = orphanIdleKey(record);
if (!orphanIdleWork.has(key)) orphanIdleWork.set(key, beginIdleTrackedWork());
unregisterDeferredCleanup ??= registerDeferredEnvironmentCleanup(db, flushDeferredOrphanCleanups);
};
const DEFERRED_ORPHAN_CLEANUP_FAILURE_REASON = "acquire_rejected_teardown_failed";
@@ -1390,10 +1393,15 @@ function createSandboxEnvironmentDriver(
};
const flushDeferredOrphanCleanupsOnce = async (): Promise<{ recovered: number; pending: number }> => {
// Other task paths can append after startup; each pass must ingest their records.
for (const record of await orphanCleanupSpool.load()) enqueueDeferredOrphanCleanup(record);
// Other task paths append after startup. Read on each concrete cleanup pass;
// the worker disarms entirely when no pending obligation remains.
const loaded = orphanCleanupSpool.loadWithStatus
? await orphanCleanupSpool.loadWithStatus()
: { records: await orphanCleanupSpool.load(), readFailed: false };
for (const record of loaded.records) enqueueDeferredOrphanCleanup(record);
if (deferredOrphanCleanups.length === 0) {
return { recovered: 0, pending: 0 };
if (loaded.readFailed) throw new Error("Orphan cleanup spool has unread records");
return { recovered: 0, pending: orphanIdleWork.size };
}
// New acquisitions can enqueue while this batch is being persisted.
const batch = deferredOrphanCleanups.splice(0, deferredOrphanCleanups.length);
@@ -1441,7 +1449,9 @@ function createSandboxEnvironmentDriver(
);
}
}
return { recovered, pending: deferredOrphanCleanups.length };
if (orphanIdleWork.size === 0) { unregisterDeferredCleanup?.(); unregisterDeferredCleanup = undefined; }
if (loaded.readFailed) throw new Error("Orphan cleanup spool has unread records");
return { recovered, pending: orphanIdleWork.size };
};
let orphanFlush: Promise<{ recovered: number; pending: number }> | null = null;
const flushDeferredOrphanCleanups = (): Promise<{ recovered: number; pending: number }> => {
@@ -4021,7 +4031,7 @@ export function environmentRuntimeService(
// Persist recovery ownership before any provider work. Re-check scope and
// run liveness in this write: the earlier selection cannot fence a resume.
// The in-flight lease also excludes concurrent closures and cleanup sweeps.
const [claimed] = await db.update(environmentLeases).set({
const [claimed] = await writeEnvironmentCleanup(db, tx => tx.update(environmentLeases).set({
status: "pending_cleanup",
failureReason: input.failureReason,
cleanupStatus: "failed",
@@ -4045,7 +4055,7 @@ export function environmentRuntimeService(
WHERE ${heartbeatRuns.id} = ${environmentLeases.heartbeatRunId}
AND ${heartbeatRuns.status} IN ('queued', 'scheduled_retry', 'running')
)`,
)).returning();
)).returning());
if (!claimed) return null;
const lease = toEnvironmentLeaseSnapshot(claimed);
let renewalInFlight = false;
@@ -4080,11 +4090,11 @@ export function environmentRuntimeService(
clearInterval(renewal);
// A crash skips this write; the bounded ownership lease lets the sweep
// recover. A completed failure becomes eligible for a later explicit retry.
await db.update(environmentLeases).set({
await writeEnvironmentCleanup(db, tx => tx.update(environmentLeases).set({
metadata: sql`${environmentLeases.metadata} || '{"pendingCleanupInFlight":false}'::jsonb`,
}).where(and(eq(environmentLeases.id, lease.id),
sql`${environmentLeases.metadata}->>'pendingCleanupAttemptId' = ${attemptId}`,
)).catch(() => {
))).catch(() => {
logger.warn({ leaseId: lease.id }, "scoped cleanup ownership settlement failed");
});
}
@@ -4240,7 +4250,7 @@ export function environmentRuntimeService(
if (hasStopOnlyCleanup(leaseSnapshot)) {
const intentKey = stopOnlyCleanupKey(leaseSnapshot);
const attemptId = randomUUID();
const [claimed] = await db.update(environmentLeases).set({
const [claimed] = await writeEnvironmentCleanup(db, tx => tx.update(environmentLeases).set({
status: "pending_cleanup", cleanupStatus: "failed", releasedAt: new Date(),
metadata: sql`${environmentLeases.metadata} || ${JSON.stringify({ pendingCleanupAttemptId: attemptId,
pendingCleanupInFlight: true, pendingCleanupLeaseExpiresAtMs: Date.now() + 15 * 60_000 })}::jsonb`,
@@ -4248,7 +4258,7 @@ export function environmentRuntimeService(
inArray(environmentLeases.status, ["active", "pending_cleanup"]),
sql`coalesce(${environmentLeases.metadata}->>'pendingCleanupInFlight', 'false') = 'false'`,
sql`${environmentLeases.metadata}->${intentKey} = ${JSON.stringify(leaseSnapshot.metadata?.[intentKey])}::jsonb`,
)).returning();
)).returning());
if (!claimed) continue;
const snapshot = toEnvironmentLeaseSnapshot(claimed);
try {
@@ -4419,15 +4429,8 @@ export function environmentRuntimeService(
// it down. The dispatcher sums the recovered and pending counts across all
// drivers. A driver with no buffer contributes nothing.
async flushDeferredOrphanCleanups(): Promise<{ recovered: number; pending: number }> {
let recovered = 0;
let pending = 0;
for (const driver of drivers.values()) {
if (!driver.flushDeferredOrphanCleanups) continue;
const result = await driver.flushDeferredOrphanCleanups();
recovered += result.recovered;
pending += result.pending;
}
return { recovered, pending };
return flushDeferredEnvironmentCleanup(db, Array.from(drivers.values())
.flatMap(driver => driver.flushDeferredOrphanCleanups ? [driver.flushDeferredOrphanCleanups] : []));
},
async destroyReusableSandboxLeases(input: {
@@ -1,3 +1,5 @@
import { beginIdleTrackedWork } from "./task-admission.js";
import { registerDeferredEnvironmentCleanup, writeEnvironmentCleanup } from "./environment-cleanup-notifications.js";
import { environmentService } from "./environments.js";
import { createSandboxOrphanCleanupSpool, type DeferredOrphanCleanupRecord } from "./sandbox-orphan-cleanup-spool.js";
import { logger } from "../middleware/logger.js";
@@ -245,7 +247,22 @@ export async function executeEnvironmentTask(db: Db, workers: PluginWorkerManage
await environmentService(db).insertPendingCleanupLease({...orphan,failureReason:"plugin_task_replacement_unowned"});
} catch {
// The existing orphan spool recovers cleanup even when the database is unavailable.
const persisted=await createSandboxOrphanCleanupSpool().append(orphan);
const spool = createSandboxOrphanCleanupSpool();
const persisted = await spool.append(orphan);
const finish = beginIdleTrackedWork();
let flushing: Promise<{ recovered: number; pending: number }> | null = null;
const unregister = registerDeferredEnvironmentCleanup(db, () => {
flushing ??= (async () => {
await environmentService(db).insertPendingCleanupLease({...orphan,failureReason:"plugin_task_replacement_unowned"});
await spool.remove(orphan);
unregister();
finish();
return { recovered: 1, pending: 0 };
})().catch(error => { flushing = null; throw error; });
// Keep successful completion for a concurrent sweep that already
// captured this callback before it was unregistered.
return flushing;
});
logger.error({providerLeaseId:fresh.providerLeaseId,persisted},"Plugin replacement cleanup requires recovery");
}
}
@@ -352,9 +369,9 @@ export async function settleDispatchedEnvironmentTask(db: Db, workers: PluginWor
await executeEnvironmentTask(db, workers, { ...scope, operation: { kind: "complete" } });
return true;
} catch {
await db.update(environmentLeases).set({ status: "pending_cleanup", cleanupStatus: "failed",
await writeEnvironmentCleanup(db, tx => tx.update(environmentLeases).set({ status: "pending_cleanup", cleanupStatus: "failed",
failureReason: "plugin_runner_task_cleanup_unconfirmed", releasedAt: new Date(), updatedAt: new Date(),
}).where(and(eq(environmentLeases.id, lease.id), eq(environmentLeases.companyId, lease.companyId)));
}).where(and(eq(environmentLeases.id, lease.id), eq(environmentLeases.companyId, lease.companyId))));
throw new Error("Plugin Runner termination is unconfirmed; the saved lease requires cleanup");
}
}
+8 -6
View File
@@ -1,3 +1,4 @@
import { writeEnvironmentCleanup, notifyEnvironmentCleanup } from "./environment-cleanup-notifications.js";
import { withoutCleanupVerification } from "./cleanup-verification-proof.js";
import { randomUUID } from "node:crypto";
@@ -1670,7 +1671,7 @@ export function environmentService(db: Db) {
: sql`${environmentLeases.metadata} - 'remoteExecutionTermination'`;
const cleanupSettled = options?.expectedPendingCleanupAttemptId && options.cleanupStatus === "success"
&& status !== "pending_cleanup";
const row = await db
const row = await writeEnvironmentCleanup(db, tx => tx
.update(environmentLeases)
.set({
status,
@@ -1690,7 +1691,7 @@ export function environmentService(db: Db) {
sql`${environmentLeases.metadata}->>'pendingCleanupAttemptId' = ${options.expectedPendingCleanupAttemptId}`)
: undefined))
.returning()
.then((rows) => rows[0] ?? null);
.then((rows) => rows[0] ?? null));
return row ? toEnvironmentLease(row) : null;
},
@@ -1739,6 +1740,7 @@ export function environmentService(db: Db) {
.where(eq(environments.id, input.environmentId))
.for("update");
const environmentIdForRow = environmentRows[0]?.id ?? null;
await notifyEnvironmentCleanup(tx);
return tx
.insert(environmentLeases)
.values({
@@ -1774,7 +1776,7 @@ export function environmentService(db: Db) {
id: string,
metadata: Record<string, unknown> | null,
): Promise<EnvironmentLease | null> => {
const row = await db
const row = await writeEnvironmentCleanup(db, tx => tx
.update(environmentLeases)
.set({
metadata: withoutCleanupVerification(metadata),
@@ -1783,7 +1785,7 @@ export function environmentService(db: Db) {
})
.where(eq(environmentLeases.id, id))
.returning()
.then((rows) => rows[0] ?? null);
.then((rows) => rows[0] ?? null));
return row ? toEnvironmentLease(row) : null;
},
@@ -1792,7 +1794,7 @@ export function environmentService(db: Db) {
status: Extract<EnvironmentLeaseStatus, "released" | "expired" | "failed"> = "released",
): Promise<EnvironmentLease[]> => {
const now = new Date();
const rows = await db
const rows = await writeEnvironmentCleanup(db, tx => tx
.update(environmentLeases)
.set({
status,
@@ -1806,7 +1808,7 @@ export function environmentService(db: Db) {
eq(environmentLeases.status, "active"),
),
)
.returning();
.returning());
return rows.map(toEnvironmentLease);
},
};
+14 -23
View File
@@ -1,3 +1,5 @@
import { pendingCleanupDeadlineSql } from "../environment-cleanup-state.js";
import { writeEnvironmentCleanup } from "../environment-cleanup-notifications.js";
import type { WorkspaceWorkers } from "../plugin-workspaces.js";
import { createHeartbeatAgentStatus } from "./agent-status.js";
import { preserveWorkspaceRestoreRecoveryMetadataSql } from "../legacy-workspace-restore-recovery.js";
@@ -211,20 +213,9 @@ function pendingCleanupAttemptsSql() {
}
function pendingCleanupRetryDueSql(explicitRetry = false) {
// Ownership renewal and retry cooldown are separate clocks. A late renewal
// cannot change when a completed attempt may retry. Older claims used the
// retry field for both clocks, so retain that fallback until they settle.
const deadline = sql`case
when ${environmentLeases.metadata}->>'pendingCleanupInFlight' = 'true'
and ${environmentLeases.metadata} ? 'pendingCleanupLeaseExpiresAtMs'
then ${environmentLeases.metadata}->'pendingCleanupLeaseExpiresAtMs'
else ${environmentLeases.metadata}->'pendingCleanupRetryAfterMs' end`;
return sql`case
when ${explicitRetry} and coalesce(${environmentLeases.metadata}->>'pendingCleanupInFlight', 'false') != 'true' then true
when jsonb_typeof(${deadline}) = 'number'
then (${deadline})::numeric <= ${Date.now()}
or (${deadline})::numeric > ${Date.now() + 30 * 60_000 + 1_000}
else true end`;
else ${pendingCleanupDeadlineSql()} <= ${Date.now()} end`;
}
// Choose the `jsonb_set` target root. A provider can write a scalar or array
@@ -1533,7 +1524,7 @@ export function createHeartbeatRecovery(db: Db, dependencies: HeartbeatRecoveryD
// pending_cleanup sweep can accept the same row in this same tick. A
// fresh timestamp here would push the row inside that sweep's own
// backoff window and delay the teardown by one full tick.
const flipped = await db
const flipped = await writeEnvironmentCleanup(db, tx => tx
.update(environmentLeases)
.set({
status: "pending_cleanup",
@@ -1545,7 +1536,7 @@ export function createHeartbeatRecovery(db: Db, dependencies: HeartbeatRecoveryD
eq(environmentLeases.status, "active"),
),
)
.returning({ id: environmentLeases.id });
.returning({ id: environmentLeases.id }));
if (flipped.length > 0) recovered += 1;
}
@@ -1575,6 +1566,7 @@ export function createHeartbeatRecovery(db: Db, dependencies: HeartbeatRecoveryD
const now = new Date();
const cutoff = new Date(now.getTime() - backoffMs);
let deferredFlushFailed = false;
// Flush the in-process orphan-cleanup buffer first. A failed acquire buffers
// an orphan there when every synchronous pending-cleanup write failed after a
// failed teardown. The flush re-inserts each buffered record, so a durable
@@ -1593,9 +1585,8 @@ export function createHeartbeatRecovery(db: Db, dependencies: HeartbeatRecoveryD
// later tick, and the database rows below still need this sweep. The caught
// exception never enters the log, because a write error can carry a
// credential in its message, code, cause, or stack.
logger.warn(
"orphan sandbox cleanup buffer flush failed; the sweep continues",
);
logger.warn("orphan sandbox cleanup buffer flush failed; cleanup will retry");
deferredFlushFailed = true;
}
const rows = await db
@@ -1781,14 +1772,14 @@ export function createHeartbeatRecovery(db: Db, dependencies: HeartbeatRecoveryD
}
// Persist the cooldown independently of process memory. A crash before
// this write leaves the bounded in-flight lease for a later sweep.
await db.update(environmentLeases).set({
await writeEnvironmentCleanup(db, tx => tx.update(environmentLeases).set({
metadata: sql`${pendingCleanupMetadataObjectSql()} || ${JSON.stringify({
pendingCleanupInFlight: false,
pendingCleanupRetryAfterMs: Date.now() + (attempts + 1 >= PENDING_CLEANUP_SWEEP_ATTEMPT_CAP
? 30 * 60_000 : Math.min(30 * 60_000, Math.max(30_000, backoffMs))),
})}::jsonb`,
}).where(and(eq(environmentLeases.id, lease.id),
sql`${environmentLeases.metadata}->>'pendingCleanupAttemptId' = ${claimed}`));
sql`${environmentLeases.metadata}->>'pendingCleanupAttemptId' = ${claimed}`)));
if (lease.heartbeatRunId) {
// Delivery failure must not revert successful provider cleanup. A new
// message can still use the persisted receipt on its next admission.
@@ -1800,6 +1791,7 @@ export function createHeartbeatRecovery(db: Db, dependencies: HeartbeatRecoveryD
}
}
if (deferredFlushFailed) throw new Error("Deferred orphan cleanup flush failed");
return { swept: rows.length, destroyed, capped };
}
@@ -2504,11 +2496,13 @@ export function createHeartbeatRecovery(db: Db, dependencies: HeartbeatRecoveryD
// Recover an active lease whose run already ended before the same-tick
// pending_cleanup sweep, so this tick can stop the recovered sandbox.
// Isolate the sweep so its failure never hides the reaper result.
let recoveredActiveLeases = 0;
try {
await sweepLegacyShutdownWorkspaceSettlements(db);
const orphanedActiveLeaseSweep = await sweepOrphanedActiveLeases({
backoffMs: staleThresholdMs,
});
recoveredActiveLeases = orphanedActiveLeaseSweep.recovered;
if (orphanedActiveLeaseSweep.recovered > 0) {
logger.warn(
{ recovered: orphanedActiveLeaseSweep.recovered },
@@ -2524,10 +2518,7 @@ export function createHeartbeatRecovery(db: Db, dependencies: HeartbeatRecoveryD
);
}
// Retry stranded pending_cleanup leases on the same tick. Isolate the sweep
// so its failure never hides the reaper result. The backoff equals the
// reaper staleness threshold.
try {
if (reaped.length > 0 || recoveredActiveLeases > 0) try {
const sweep = await sweepPendingCleanupLeases({
backoffMs: staleThresholdMs,
});
@@ -1,3 +1,4 @@
import { notifyEnvironmentCleanup } from "../environment-cleanup-notifications.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";
@@ -698,6 +699,7 @@ export function createHeartbeatRunControl(db: Db, dependencies: HeartbeatRunCont
or(isNull(environmentLeases.releasedAt), inArray(environmentLeases.status, ["active", "retained", "pending_cleanup"])),
)).limit(1);
if (otherOwner) continue;
await notifyEnvironmentCleanup(tx);
await tx.update(environmentLeases).set({ status: "pending_cleanup", updatedAt: new Date() })
.where(eq(environmentLeases.id, lease.id));
}
@@ -143,7 +143,24 @@ export function historicalCleanupVerificationService(db: Db, inspectors: Inspect
}
}
}
return { sweep(): Promise<Result> {
async function hasPending(): Promise<boolean> {
const [lease] = await db.select({ id: environmentLeases.id }).from(environmentLeases).where(and(
eq(environmentLeases.status, "failed"), eq(environmentLeases.leasePolicy, "ephemeral"),
eq(environmentLeases.cleanupStatus, "success"),
sql`${environmentLeases.releasedAt} IS NOT NULL AND ${environmentLeases.environmentId} IS NOT NULL
AND ${environmentLeases.provider} IS NOT NULL AND ${environmentLeases.provider} <> 'local'
AND ${environmentLeases.providerLeaseId} IS NOT NULL`,
sql`(${sql.raw(verifiedResourceCleanup('environment_leases', 'lease'))}) IS NOT TRUE`,
)).limit(1);
if (lease) return true;
const [setup] = await db.select({ id: environmentCustomImageSetupSessions.id }).from(environmentCustomImageSetupSessions).where(and(
inArray(environmentCustomImageSetupSessions.status, ["promoted", "cancelled", "timed_out", "failed"]),
sql`${environmentCustomImageSetupSessions.finishedAt} IS NOT NULL AND ${environmentCustomImageSetupSessions.providerLeaseId} IS NOT NULL`,
sql`(${sql.raw(verifiedResourceCleanup('environment_custom_image_setup_sessions', 'image_setup'))}) IS NOT TRUE`,
)).limit(1);
return Boolean(setup);
}
return { hasPending, sweep(): Promise<Result> {
if (!inFlight) inFlight = run().finally(() => { inFlight = undefined; });
return inFlight;
} };
@@ -1,3 +1,4 @@
import { writeEnvironmentCleanup } from "../environment-cleanup-notifications.js";
import { DELIVERY_QUEUES } from "../delivery-work-notifications.js";
import { writeExecutionControl } from "../execution-control-notifications.js";
import { notifyExecutionRunWork } from "../execution-control-notifications.js";
@@ -12561,7 +12562,8 @@ async function createRunnerdBackendWithinSessionClaim(
runnerInstanceId: backup.manifest.runnerInstanceId,
completedAt: backup.manifest.completedAt,
});
const updated = await input.db
const leaseId = remoteTarget.leaseId;
const updated = await writeEnvironmentCleanup(input.db, tx => tx
.update(environmentLeases)
.set({
metadata: {
@@ -12570,9 +12572,9 @@ async function createRunnerdBackendWithinSessionClaim(
},
updatedAt: new Date(),
})
.where(eq(environmentLeases.id, remoteTarget.leaseId))
.where(eq(environmentLeases.id, leaseId))
.returning({ id: environmentLeases.id })
.then((rows) => rows[0] ?? null);
.then((rows) => rows[0] ?? null));
if (!updated) throw new Error("runner_harness_backup_lease_missing");
};
@@ -1,3 +1,4 @@
import { writeEnvironmentCleanup } from "../environment-cleanup-notifications.js";
import { randomUUID } from "node:crypto";
import { and, eq, inArray, ne, sql } from "drizzle-orm";
import { environmentLeases, heartbeatRuns, nativeRunFinalizations, nativeRunResults, type Db } from "@paperclipai/db";
@@ -55,7 +56,7 @@ export async function preserveNativeWorkspaceExportLease(db: Db, run: typeof hea
)).for("update").limit(1);
if (!lease || lease.status !== "active") return;
const requestId = randomUUID(), now = new Date();
await db.update(environmentLeases).set({ status: "pending_cleanup", cleanupStatus: "failed",
await writeEnvironmentCleanup(db, tx => tx.update(environmentLeases).set({ status: "pending_cleanup", cleanupStatus: "failed",
failureReason: "workspace_export_stop_pending", releasedAt: now, updatedAt: now,
metadata: { ...lease.metadata, remoteExecutionTermination: undefined,
[NATIVE_WORKSPACE_EXPORT_RESUME_KEY]: { schema: "paperclip.workspace-export-resume.v2", requestId,
@@ -64,7 +65,7 @@ export async function preserveNativeWorkspaceExportLease(db: Db, run: typeof hea
pluginId: lease.metadata?.pluginId },
pendingCleanupAttemptId: requestId, pendingCleanupInFlight: false, pendingCleanupLeaseExpiresAtMs: 0,
pendingCleanupRetryAfterMs: 0, pendingCleanupRetryAttempts: 0, pendingCleanupRetryCapWarned: false },
}).where(eq(environmentLeases.id, lease.id));
}).where(eq(environmentLeases.id, lease.id)));
}
/** Only verified copyback plus commitment permits the original ephemeral
@@ -106,7 +107,7 @@ export async function settleNativeWorkspaceExportResume(db: Db, lease: Lease, op
const receipt = remoteTerminationReceipt(lease, options.receipt);
const stopped = receipt?.state === "stopped";
const now = new Date();
const [row] = await db.update(environmentLeases).set({
const [row] = await writeEnvironmentCleanup(db, tx => tx.update(environmentLeases).set({
status: stopped ? options.status ?? "released" : "pending_cleanup", cleanupStatus: stopped ? "success" : "failed",
releasedAt: now, lastUsedAt: now, updatedAt: now,
metadata: sql`(${environmentLeases.metadata} - 'remoteExecutionTermination'
@@ -120,6 +121,6 @@ export async function settleNativeWorkspaceExportResume(db: Db, lease: Lease, op
sql`${environmentLeases.metadata}->>'pendingCleanupAttemptId' = ${options.attemptId}`,
sql`${environmentLeases.metadata}->'nativeWorkspaceExportResume'->>'requestId' = ${marker.requestId}`,
sql`${environmentLeases.metadata}->>'pluginId' = ${marker.pluginId}`,
)).returning();
)).returning());
return row ?? null;
}
@@ -1,3 +1,4 @@
import { notifyEnvironmentCleanup } from "../environment-cleanup-notifications.js";
import { randomUUID } from "node:crypto";
import type { Environment, EnvironmentLease } from "@paperclipai/shared";
import { isNativeWorkspaceExportRepairCause } from "@paperclipai/shared";
@@ -92,6 +93,7 @@ export async function retryNativeWorkspaceExport(input: {
// Persist cleanup authority before any provider call. A lost resume
// reply or controller restart must not leave a running sandbox behind
// an old stopped receipt. The pending claim fences the cleanup sweeper.
await notifyEnvironmentCleanup(tx);
const [lease] = await tx.update(environmentLeases).set({ status: "pending_cleanup", cleanupStatus: "failed",
releasedAt: now, failureReason: "workspace_export_resume_pending", updatedAt: now,
metadata: { ...current.lease.metadata, remoteExecutionTermination: undefined,
@@ -140,6 +142,7 @@ export async function retryNativeWorkspaceExport(input: {
await tx.update(issues).set({ executionRunId: current.run.id, updatedAt: now }).where(eq(issues.id, current.issue.id));
// Reclaim the same lease only for control-plane copyback. The normal
// finalization settlement will stop/retain it again on success or failure.
await notifyEnvironmentCleanup(tx);
await tx.update(environmentLeases).set({ status: "active", releasedAt: null, cleanupStatus: null, failureReason: null,
metadata: { ...current.lease.metadata, remoteExecutionTermination: undefined,
// Keep stop-only authority through copyback and commitment. A crash
@@ -1,3 +1,4 @@
import { notifyEnvironmentCleanup } from "../environment-cleanup-notifications.js";
import { measureRunPhase } from "../execution-preparation-timing.js";
import { emitStartupReuse, type StartupReuseReason } from "@paperclipai/adapter-utils/startup-reuse";
import type { AdapterRuntimeEvent } from "@paperclipai/adapter-utils";
@@ -647,6 +648,7 @@ async function persistLeaseStamp(input: {
.limit(1)
.then((rows) => rows[0] ?? null);
if (!lease) throw new Error("native_workspace_sync_lease_missing");
await notifyEnvironmentCleanup(tx);
await tx
.update(environmentLeases)
.set({
@@ -50,6 +50,8 @@ export interface SandboxOrphanCleanupSpool {
* failure and skips a malformed file.
*/
load(): Promise<DeferredOrphanCleanupRecord[]>;
/** Distinguish empty storage from unread work without discarding readable records. */
loadWithStatus?(): Promise<{ records: DeferredOrphanCleanupRecord[]; readFailed: boolean }>;
/**
* Remove one persisted orphan record after its durable `pending_cleanup` row
* lands. The removal runs after the database insert succeeds, so a crash
@@ -143,6 +145,29 @@ export function createSandboxOrphanCleanupSpool(spoolDir?: string): SandboxOrpha
const dir = spoolDir ?? idleOrphanSpoolPath();
registerIdleSpoolDirectory(dir);
async function loadWithStatus() {
let names: string[];
try { names = await fs.readdir(dir); }
catch (error) {
return { records: [], readFailed: (error as NodeJS.ErrnoException).code !== "ENOENT" };
}
const records: DeferredOrphanCleanupRecord[] = [];
let readFailed = false;
for (const name of names) {
if (!name.endsWith(RECORD_FILE_SUFFIX)) continue;
try {
const parsed: unknown = JSON.parse(await fs.readFile(path.join(dir, name), "utf8"));
if (isDeferredOrphanCleanupRecord(parsed)) records.push(parsed);
else readFailed = true;
} catch (error) {
// A concurrent successful flush can remove a listed record. Other read
// failures and malformed records remain unresolved cleanup obligations.
if ((error as NodeJS.ErrnoException).code !== "ENOENT") readFailed = true;
}
}
return { records, readFailed };
}
return {
async append(record) {
const absPath = path.join(dir, recordFileName(record));
@@ -158,31 +183,8 @@ export function createSandboxOrphanCleanupSpool(spoolDir?: string): SandboxOrpha
}
},
async load() {
let names: string[];
try {
names = await fs.readdir(dir);
} catch {
// The directory does not exist yet, or the read failed. Either way, no
// record waits on the disk.
return [];
}
const records: DeferredOrphanCleanupRecord[] = [];
for (const name of names) {
if (!name.endsWith(RECORD_FILE_SUFFIX)) continue;
try {
const raw = await fs.readFile(path.join(dir, name), "utf8");
const parsed: unknown = JSON.parse(raw);
if (isDeferredOrphanCleanupRecord(parsed)) {
records.push(parsed);
}
} catch {
// Skip a malformed or unreadable file. A partial file cannot authorize
// a teardown, so the load ignores it and keeps the other records.
}
}
return records;
},
async load() { return (await loadWithStatus()).records; },
loadWithStatus,
async remove(record) {
const absPath = path.join(dir, recordFileName(record));
@@ -1,3 +1,4 @@
import { writeEnvironmentCleanup } from "./environment-cleanup-notifications.js";
import { randomUUID } from "node:crypto";
import { and, eq, sql, type SQL } from "drizzle-orm";
import { environmentLeases, heartbeatRuns, type Db } from "@paperclipai/db";
@@ -102,7 +103,7 @@ async function prepareStopAndRetain(db: Db, lease: Lease, fromStatus: "active" |
provider: lease.provider, providerLeaseId: lease.providerLeaseId,
...(lease.provider && isBuiltinSandboxProvider(lease.provider) && !lease.metadata?.sandboxProviderPlugin && lease.metadata?.pluginId == null
? { builtinProvider: lease.provider } : { pluginId: lease.metadata?.pluginId }) };
const [updated] = await db.update(environmentLeases).set({
const [updated] = await writeEnvironmentCleanup(db, tx => tx.update(environmentLeases).set({
status: "pending_cleanup", cleanupStatus: "failed", failureReason: "sandbox_stop_pending", releasedAt: now, updatedAt: now,
metadata: sql`(coalesce(${environmentLeases.metadata}, '{}'::jsonb) - 'remoteExecutionTermination' - 'sandboxStopAndRetainReceipt') || ${JSON.stringify({
[SANDBOX_STOP_AND_RETAIN_KEY]: intent, pendingCleanupAttemptId: requestId,
@@ -114,7 +115,7 @@ async function prepareStopAndRetain(db: Db, lease: Lease, fromStatus: "active" |
sql`${environmentLeases.provider} is not distinct from ${lease.provider}`,
sql`${environmentLeases.providerLeaseId} is not distinct from ${lease.providerLeaseId}`,
sql`${environmentLeases.metadata} is not distinct from ${lease.metadata === null ? null : JSON.stringify(lease.metadata)}::jsonb`,
)).returning();
)).returning());
return updated ?? null;
}
@@ -125,7 +126,7 @@ export async function settleStopOnlyCleanup(db: Db, lease: Lease, options: { att
const receipt = remoteTerminationReceipt(lease, options.receipt), stopped = receipt?.state === "stopped";
const now = new Date();
const removeIntent: SQL = stopped ? sql`- 'sandboxStopAndRetain'` : sql``;
const [updated] = await db.update(environmentLeases).set({
const [updated] = await writeEnvironmentCleanup(db, tx => tx.update(environmentLeases).set({
status: stopped ? "released" : "pending_cleanup", cleanupStatus: stopped ? "success" : "failed",
failureReason: stopped ? null : "sandbox_stop_pending",
releasedAt: now, lastUsedAt: now, updatedAt: now,
@@ -146,7 +147,7 @@ export async function settleStopOnlyCleanup(db: Db, lease: Lease, options: { att
sql`${environmentLeases.metadata}->>'pendingCleanupAttemptId' = ${options.attemptId}`,
sql`${environmentLeases.metadata}->'sandboxStopAndRetain' = ${JSON.stringify(lease.metadata?.[SANDBOX_STOP_AND_RETAIN_KEY])}::jsonb`,
sql`${environmentLeases.metadata}->>'pluginId' is not distinct from ${intent.pluginId}`,
)).returning();
)).returning());
return updated ?? null;
}
+3 -2
View File
@@ -1,3 +1,4 @@
import { writeEnvironmentCleanup } from "./environment-cleanup-notifications.js";
import { resumePreservedResultFinalization } from "./preserved-result-finalization.js";
import { and, asc, eq, gt, inArray, ne, or, sql } from "drizzle-orm";
import { agentWakeupRequests, environmentLeases, heartbeatRuns, issues, issueRecoveryActions, workspaceOperations, type Db } from "@paperclipai/db";
@@ -298,7 +299,7 @@ export async function scheduleRecoveredWorkspaceCleanup(db: Db, run: typeof hear
ne(environmentLeases.id, lease.id), eq(environmentLeases.provider, lease.provider!), eq(environmentLeases.providerLeaseId, lease.providerLeaseId!),
)).limit(1);
if (other) return;
await db.update(environmentLeases).set({ status: "pending_cleanup", cleanupStatus: "failed", leasePolicy: "ephemeral",
await writeEnvironmentCleanup(db, tx => tx.update(environmentLeases).set({ status: "pending_cleanup", cleanupStatus: "failed", leasePolicy: "ephemeral",
updatedAt: new Date(), metadata: sql`(${environmentLeases.metadata} - 'workspaceRestoreRecovery' - 'remoteExecutionTermination') ||
${JSON.stringify({ workspaceRecoveryCleanup: { runId: run.id, descriptorSha256: ref.descriptorSha256 },
pendingCleanupInFlight: false, pendingCleanupRetryAfterMs: 0, pendingCleanupRetryAttempts: 0, pendingCleanupLeaseExpiresAtMs: 0 })}::jsonb`,
@@ -306,7 +307,7 @@ export async function scheduleRecoveredWorkspaceCleanup(db: Db, run: typeof hear
eq(environmentLeases.heartbeatRunId, run.id), eq(environmentLeases.providerLeaseId, ref.providerLeaseId),
eq(environmentLeases.status, "released"), eq(environmentLeases.cleanupStatus, "success"),
sql`${environmentLeases.metadata} = ${JSON.stringify(lease.metadata)}::jsonb`,
sql`${environmentLeases.metadata}->>'workspaceRecoveryCleanup' is null`));
sql`${environmentLeases.metadata}->>'workspaceRecoveryCleanup' is null`)));
}
export function retainedWorkspaceCandidates(db: Db, cursor?: string) {
return db.select({ action: issueRecoveryActions, run: heartbeatRuns }).from(issueRecoveryActions)
+40 -52
View File
@@ -6,9 +6,9 @@ async function json(response: Awaited<ReturnType<APIRequestContext["get"]>>) {
return response.json();
}
async function failFirstTaskCss(page: Page) {
async function failFirstLazyCss(page: Page) {
const counts = { failed: 0, served: 0, documents: 0 };
await page.route(/\/assets\/issue-detail-[^/]+\.css$/, async (route) => {
await page.route(/\/assets\/CompanyEnvironments-[^/]+\.css$/, async (route) => {
if (counts.failed === 0) {
counts.failed += 1;
await route.abort("failed");
@@ -25,57 +25,45 @@ async function failFirstTaskCss(page: Page) {
test.use({ serviceWorkers: "block" });
for (const entry of ["task", "agents"] as const) {
test(`a failed task-page CSS preload recovers with one reload (${entry} page)`, async ({ page, request }) => {
const company = await json(await request.post("/api/companies", {
data: { name: `Chunk recovery ${randomUUID()}` },
}));
const issue = await json(await request.post(`/api/companies/${company.id}/issues`, {
data: { title: "Open after a failed stylesheet", status: "backlog" },
}));
const counts = await failFirstTaskCss(page);
// Task-page styles now live in the initial CSS bundle. Exercise an emitted
// lazy stylesheet so this remains a real network/preload recovery test.
const environmentsPath = "/company/settings/instance/environments";
if (entry === "task") {
await page.goto(`/${company.issuePrefix}/issues/${issue.identifier}`);
await expect(page.getByRole("heading", { name: issue.title, exact: true })).toBeVisible();
} else {
await page.goto(`/${company.issuePrefix}/agents`);
await expect(page.getByRole("heading", { name: "Agents", exact: true }).first()).toBeVisible();
}
test("a failed lazy CSS preload recovers with one reload", async ({ page, request }) => {
const company = await json(await request.post("/api/companies", {
data: { name: `Chunk recovery ${randomUUID()}` },
}));
const counts = await failFirstLazyCss(page);
await expect(page.getByText("This page hit an error")).toHaveCount(0);
expect(counts.failed).toBe(1);
expect(counts.served).toBeGreaterThan(0);
expect(counts.documents).toBe(2);
await page.goto(`/${company.issuePrefix}${environmentsPath}`);
await expect(page.getByText("Enable Environments in instance experimental settings to manage shared execution targets.")
.or(page.getByTestId("instance-settings-environments-section"))).toBeVisible();
await expect(page.getByText("This page hit an error")).toHaveCount(0);
expect(counts.failed).toBe(1);
expect(counts.served).toBeGreaterThan(0);
expect(counts.documents).toBe(2);
});
test("a lazy CSS asset that stays broken reloads once, then shows the error", async ({ page, request }) => {
const company = await json(await request.post("/api/companies", {
data: { name: `Chunk recovery ${randomUUID()}` },
}));
let failed = 0;
let documents = 0;
await page.route(/\/assets\/CompanyEnvironments-[^/]+\.css$/, async (route) => {
failed += 1;
await route.abort("failed");
});
}
for (const entry of ["task", "agents"] as const) {
test(`a task-page CSS asset that stays broken reloads once, then shows the error (${entry} page)`, async ({ page, request }) => {
const company = await json(await request.post("/api/companies", {
data: { name: `Chunk recovery ${randomUUID()}` },
}));
const issue = await json(await request.post(`/api/companies/${company.id}/issues`, {
data: { title: "Open with a broken stylesheet", status: "backlog" },
}));
let failed = 0;
let documents = 0;
await page.route(/\/assets\/issue-detail-[^/]+\.css$/, async (route) => {
failed += 1;
await route.abort("failed");
});
page.on("request", (request) => {
if (request.resourceType() === "document" && request.frame() === page.mainFrame()) documents += 1;
});
await page.goto(entry === "task"
? `/${company.issuePrefix}/issues/${issue.identifier}`
: `/${company.issuePrefix}/agents`);
await expect(page.getByText("This page hit an error")).toBeVisible();
await expect(page.getByText(/Unable to preload CSS for .*issue-detail-/)).toBeVisible();
await page.waitForTimeout(1_000);
expect(documents).toBe(2);
expect(failed).toBe(2);
page.on("request", (request) => {
if (request.resourceType() === "document" && request.frame() === page.mainFrame()) documents += 1;
});
}
await page.goto(`/${company.issuePrefix}${environmentsPath}`);
await expect(page.getByText("This page hit an error")).toBeVisible();
await expect(page.getByText(/Unable to preload CSS for .*CompanyEnvironments-/)).toBeVisible();
await page.waitForTimeout(1_000);
expect(documents).toBe(2);
expect(failed).toBe(2);
});