paperclipai/paperclip · warning
CreateOS lease cleanup must finish before resume.
Error message
CreateOS lease cleanup must finish before resume.
What it means
Thrown by onEnvironmentResumeLease when the lease is currently being cleaned up (scope present in 'closing') or had an unconfirmed cleanup (scope in 'unconfirmedCleanup'). Resuming a lease mid-destroy or with unverifiable prior cleanup would attach work to a sandbox that may disappear or still hold stale processes, so the provider refuses. Wait for cleanup to finish or destroy and re-acquire.
Solutions
- Wait for the concurrent release to complete, then retry resume; the 'closing' guard is transient.
- If the lease has unconfirmed cleanup, destroy it (release with destroy=true) and acquire a fresh lease instead of resuming.
- Serialize lease lifecycle operations per lease so resume never overlaps release.
- Add a small backoff around resume retries to avoid racing the teardown path.
Example fix
// before: immediate resume after teardown request
stopEnvironment(env);
await plugin.onEnvironmentResumeLease(resumeParams); // throws
// after: await teardown, then resume or re-acquire
await stopEnvironment(env);
try { await plugin.onEnvironmentResumeLease(resumeParams); }
catch { await plugin.onEnvironmentAcquireLease(acquireParams); } Defensive patterns
Strategy: retry
Validate before calling
// Track lifecycle state per lease in caller code
const releasing = new Set<string>();
function canResume(leaseId: string): boolean {
return !releasing.has(leaseId);
} Type guard
function isCleanupPendingError(e: unknown): boolean {
return e instanceof Error && e.message.includes('cleanup must finish before resume');
} Try / catch
try {
await plugin.onEnvironmentResumeLease(params);
} catch (e) {
if (isCleanupPendingError(e)) {
await sleep(1000);
await plugin.onEnvironmentResumeLease(params); // or acquire fresh lease
} else throw e;
} Prevention
- Serialize lease lifecycle: never call resume while release/stop is in flight for the same lease
- Mark leases as 'destroying' in your scheduler and route resumes to fresh acquires instead
- Use exponential backoff on resume retries rather than immediate re-calls
- Always destroy (not reuse) leases that previously reported unconfirmed cleanup before reacquiring
When it happens
Trigger: Calling onEnvironmentResumeLease concurrently with release()/stopActive for the same lease (cleanup in progress), or resuming a lease previously released without destroy whose process cleanup was never confirmed (see error 116).
Common situations: Agent scheduler resuming work while an environment stop/teardown is still awaiting the CreateOS API; a retry loop resuming immediately after a failed release; reuse-mode pools where a prior pause was unconfirmed.
Understand the failure class
Background: "Invalid state transition" errors: "status must be X, actually Y", "already rejected/charging/uninstalled", "cannot ... while running" — what they mean when a library rejects your call — this error's family across 31 libraries.
Related errors
- CreateOS lease cleanup is already in progress.
- CreateOS process creation could not be confirmed; destroy…
- Bridge host response body capacity is busy.
- CreateOS command cleanup failed; process termination is…
- CreateOS does not yet support leases with a guaranteed…
AI-assisted analysis of paperclipai/paperclip@3f1d897a7c (2026-09-18).
Data as JSON: /api/errors/ded0ad2890521039.
Report an issue: GitHub.
Appendix: source
Thrown at packages/plugins/sandbox-providers/createos/src/plugin.ts:155
try {
lease = await acquire({ ...params, runId: "probe" });
const result = await execute(new CreateosClient(parseConfig(params.config)), {
...params, lease, command: "/bin/echo", args: ["paperclip-createos-ready"], cwd: CWD,
}, AbortSignal.timeout(parseConfig(params.config).timeoutMs));
if (result.timedOut || result.exitCode !== 0 || !result.stdout.includes("paperclip-createos-ready")) throw new Error("CreateOS command probe failed.");
return { ok: true, summary: "CreateOS sandbox creation and command execution succeeded." };
} catch (error) {
return { ok: false, summary: error instanceof Error ? error.message : "CreateOS probe failed." };
} finally {
// Never leave a reusable probe sandbox behind or hide deletion failure.
if (lease?.providerLeaseId) await new CreateosClient(parseConfig(params.config)).destroySandbox(lease.providerLeaseId);
}
},
onEnvironmentAcquireLease: acquire,
async onEnvironmentResumeLease(params) {
if (!metadataMatches(params, params.leaseMetadata)) throw new Error("CreateOS lease does not belong to this environment.");
const scope = key(params, params.providerLeaseId);
if (closing.has(scope) || unconfirmedCleanup.has(scope)) throw new Error("CreateOS lease cleanup must finish before resume.");
const marker = params.leaseMetadata?.marker;
if (typeof marker !== "string" || !/^[0-9a-f-]{36}$/.test(marker)) return { providerLeaseId: null, metadata: { expired: true } };
const config = parseConfig(params.config);
if (params.leaseMetadata?.shape !== config.shape || params.leaseMetadata.rootfs !== config.rootfs || params.leaseMetadata.region !== config.region) {
return { providerLeaseId: null, metadata: { expired: true } };
}
const client = new CreateosClient(config);
const signal = AbortSignal.timeout(config.timeoutMs);
try {
const sandbox = await client.getSandbox(params.providerLeaseId, signal);
if (["destroyed", "failed"].includes(sandbox.status!)) return { providerLeaseId: null, metadata: { expired: true } };
await client.transition(params.providerLeaseId, "running", signal);
const data = await client.json(`/sandboxes/${params.providerLeaseId}/exec`, "POST", {
cmd: "/bin/cat", args: [MARKER],
}, signal);
const result = object(data.result);
if (result.exit_code !== 0 || result.stdout !== marker) return { providerLeaseId: null, metadata: { expired: true } };
return { providerLeaseId: params.providerLeaseId, metadata: { ...params.leaseMetadata, resumedLease: true } };View on GitHub (pinned to 3f1d897a7c)