Skip to content

Commit 9634587

Browse files
Priya RamanPaperclip-Paperclip
andcommitted
fix(environment): retry failed reusable-lease release through pending cleanup
The reusable-lease release path recorded a failed release verification as "released" or "failed" with cleanupStatus "failed". The cleanup reaper sweeps only "pending_cleanup" leases, so the provider resource stayed active. Route a failed release into "pending_cleanup" so the reaper retries it. Keep a retain_on_failure lease as "retained", because the retain policy keeps the resource for reuse and the reaper would destroy it. Drop the host-internal and per-lease runtime keys from every sandbox config the runtime sends to a lifecycle RPC. The metadata-derived config path leaked these keys, including remoteCwd, to the plugin worker. The worker must receive only the provider driver config. Co-authored-by: Paperclip <noreply@paperclip.ing>
1 parent c887cf1 commit 9634587

2 files changed

Lines changed: 60 additions & 14 deletions

File tree

server/src/__tests__/environment-runtime.test.ts

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2042,7 +2042,9 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
20422042
// methods. The lease lifecycle paths must verify the live worker before they
20432043
// dispatch a lifecycle RPC, so the runtime fails closed instead of a doomed
20442044
// dispatch.
2045-
async function seedStaleLifecycleReusableLease() {
2045+
async function seedStaleLifecycleReusableLease(
2046+
leasePolicy: "reuse_by_environment" | "retain_on_failure" = "reuse_by_environment",
2047+
) {
20462048
const pluginId = randomUUID();
20472049
const { companyId, agentId, environment: baseEnvironment, runId } = await seedEnvironment();
20482050
const providerConfig = {
@@ -2120,7 +2122,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
21202122
environmentId: environment.id,
21212123
executionWorkspaceId,
21222124
heartbeatRunId: runId,
2123-
leasePolicy: "reuse_by_environment",
2125+
leasePolicy,
21242126
provider: "fake-plugin",
21252127
providerLeaseId: "stale-lifecycle-lease",
21262128
metadata: {
@@ -2151,7 +2153,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
21512153
return { pluginId, environment, runId, lease, workerManager, runtimeWithPlugin };
21522154
}
21532155

2154-
it("fails closed on release when the worker no longer advertises the release lifecycle method", async () => {
2156+
it("routes release to pending_cleanup when the worker no longer advertises the release lifecycle method", async () => {
21552157
const { pluginId, lease, workerManager, runtimeWithPlugin } = await seedStaleLifecycleReusableLease();
21562158

21572159
const released = await runtimeWithPlugin.releaseRunLeases(lease.heartbeatRunId!);
@@ -2163,8 +2165,27 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
21632165
expect.anything(),
21642166
expect.anything(),
21652167
);
2168+
// The failed release verification must enter the pending-cleanup retry flow.
2169+
// The reaper sweeps only `pending_cleanup` leases, so a `released` status
2170+
// here would strand the still-active provider resource.
2171+
await expect(environmentService(db).getLeaseById(lease.id)).resolves.toMatchObject({
2172+
status: "pending_cleanup",
2173+
cleanupStatus: "failed",
2174+
failureReason: "release_cleanup_failed",
2175+
});
2176+
});
2177+
2178+
it("retains a retain_on_failure lease on failed release instead of routing to pending_cleanup", async () => {
2179+
const { lease, runtimeWithPlugin } = await seedStaleLifecycleReusableLease("retain_on_failure");
2180+
2181+
const released = await runtimeWithPlugin.releaseRunLeases(lease.heartbeatRunId!, "failed");
2182+
2183+
expect(released).toHaveLength(1);
2184+
// A retain_on_failure lease keeps the provider resource for reuse. The
2185+
// reaper destroys `pending_cleanup` leases, so the retained lease must not
2186+
// enter that flow even when the release verification fails.
21662187
await expect(environmentService(db).getLeaseById(lease.id)).resolves.toMatchObject({
2167-
status: "released",
2188+
status: "retained",
21682189
cleanupStatus: "failed",
21692190
});
21702191
});

server/src/services/environment-runtime.ts

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -936,7 +936,7 @@ function createSandboxEnvironmentDriver(
936936
config: sandboxConfigForLeaseMetadata(metadataConfig),
937937
});
938938
if (parsed.driver === "sandbox") {
939-
return parsed.config as unknown as Record<string, unknown>;
939+
return dropInternalPluginSandboxConfigKeys(parsed.config as unknown as Record<string, unknown>);
940940
}
941941
}
942942

@@ -948,7 +948,7 @@ function createSandboxEnvironmentDriver(
948948
input.environment,
949949
);
950950
if (parsed.driver === "sandbox" && parsed.config.provider === input.provider) {
951-
return parsed.config as unknown as Record<string, unknown>;
951+
return dropInternalPluginSandboxConfigKeys(parsed.config as unknown as Record<string, unknown>);
952952
}
953953
} catch {
954954
// Lease metadata below is intentionally kept sufficient for cleanup
@@ -958,7 +958,7 @@ function createSandboxEnvironmentDriver(
958958

959959
return {
960960
provider: input.provider,
961-
...sanitizePluginSandboxConfigFromLeaseMetadata(input.lease.metadata),
961+
...dropInternalPluginSandboxConfigKeys(input.lease.metadata),
962962
};
963963
}
964964

@@ -1763,12 +1763,26 @@ function createSandboxEnvironmentDriver(
17631763
cleanupStatus = "failed";
17641764
}
17651765

1766-
const releaseStatus =
1767-
input.lease.leasePolicy === "retain_on_failure" && input.status === "failed"
1768-
? ("retained" as const)
1766+
// A failed release verification leaves the provider resource active. The
1767+
// cleanup reaper retries only `pending_cleanup` leases, so route a failed
1768+
// release into that retry flow. A `retain_on_failure` lease keeps the
1769+
// resource on purpose for reuse, so it stays `retained` and never enters the
1770+
// reaper, which would destroy the resource the retain policy wants to keep.
1771+
const retained =
1772+
input.lease.leasePolicy === "retain_on_failure" && input.status === "failed";
1773+
const releaseStatus = retained
1774+
? ("retained" as const)
1775+
: cleanupStatus === "failed"
1776+
? ("pending_cleanup" as const)
17691777
: input.status;
1778+
const failureReason =
1779+
input.status === "failed"
1780+
? "adapter_or_run_failure"
1781+
: cleanupStatus === "failed"
1782+
? "release_cleanup_failed"
1783+
: undefined;
17701784
return await environmentsSvc.releaseLease(input.lease.id, releaseStatus, {
1771-
failureReason: input.status === "failed" ? "adapter_or_run_failure" : undefined,
1785+
failureReason,
17721786
cleanupStatus,
17731787
});
17741788
}
@@ -1857,21 +1871,32 @@ function readString(value: unknown): string | null {
18571871
return typeof value === "string" && value.length > 0 ? value : null;
18581872
}
18591873

1874+
// Keys the runtime stores in the lease metadata that are not part of the
1875+
// provider driver config. Some are host-internal control fields. `remoteCwd` is
1876+
// a per-lease runtime value. The host reads `remoteCwd` from the lease metadata
1877+
// directly, so the worker never needs it as config. Drop every key here before
1878+
// the runtime sends a config to a lifecycle RPC.
18601879
const INTERNAL_PLUGIN_SANDBOX_CONFIG_KEYS = new Set([
18611880
"driver",
18621881
"executionWorkspaceMode",
18631882
"pluginId",
18641883
"pluginKey",
18651884
"providerMetadata",
1885+
"remoteCwd",
18661886
"shellCommand",
18671887
"sandboxProviderPlugin",
18681888
]);
18691889

1870-
function sanitizePluginSandboxConfigFromLeaseMetadata(
1871-
metadata: Record<string, unknown> | null | undefined,
1890+
// Drop the host-internal and per-lease runtime keys from a sandbox config
1891+
// record. The runtime stores these keys in the lease metadata and in some
1892+
// resolved configs, but the plugin worker must receive only the provider driver
1893+
// config. Use this on every config the runtime sends to a lifecycle RPC, so no
1894+
// host-internal field reaches the worker.
1895+
function dropInternalPluginSandboxConfigKeys(
1896+
config: Record<string, unknown> | null | undefined,
18721897
): Record<string, unknown> {
18731898
const sanitized: Record<string, unknown> = {};
1874-
for (const [key, value] of Object.entries(metadata ?? {})) {
1899+
for (const [key, value] of Object.entries(config ?? {})) {
18751900
if (INTERNAL_PLUGIN_SANDBOX_CONFIG_KEYS.has(key)) continue;
18761901
sanitized[key] = value;
18771902
}

0 commit comments

Comments
 (0)