mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 19:35:13 +01:00
Preserve sandbox sessions across upgrades and intentional resets
Keep stable workspace fingerprints compatible with existing task sessions. Bind legacy conversations to verified physical sandboxes, preserve Codex runtime files during config refresh, and retain failed preparations for retry. Authorize new native conversations separately from sandbox reuse without discarding prior state. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
b58c10611a
commit
1f4ac4df15
21 files changed
+1110
-41
No files matched your search
@@ -626,8 +626,14 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
||||
expect(call).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each(["codex_local", "paperclip_runner"])("retains a failed %s resume through run cleanup and retries the original sandbox", async (adapterType) => {
|
||||
it.each([["codex_local", true], ["paperclip_runner", true], ["codex_local", false], ["paperclip_runner", false]] as const)("retains a failed %s resume and retries the original sandbox (cleanup: %s)", async (adapterType, cleanupRan) => {
|
||||
const seeded = await seedReusablePluginSandboxLease(adapterType);
|
||||
const taskId = randomUUID();
|
||||
await db.insert(issues).values({ id: taskId, companyId: seeded.companyId, title: "Resume recovery" });
|
||||
await db.update(environmentLeases).set({ issueId: taskId, metadata: {
|
||||
...seeded.reusableLease.metadata,
|
||||
reusableSandboxLease: { ...(seeded.reusableLease.metadata!.reusableSandboxLease as object), issueId: taskId },
|
||||
} }).where(eq(environmentLeases.id, seeded.reusableLease.id));
|
||||
let failResume = false;
|
||||
const workerManager = {
|
||||
isRunning: vi.fn(() => true),
|
||||
@@ -643,7 +649,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
||||
const runtime = environmentRuntimeService(db, { pluginWorkerManager: workerManager });
|
||||
const acquire = (runId: string) => runtime.acquireRunLease({
|
||||
companyId: seeded.companyId, environment: seeded.environment, agentId: seeded.agentId,
|
||||
heartbeatRunId: runId, issueId: null, adapterType,
|
||||
heartbeatRunId: runId, issueId: taskId, adapterType,
|
||||
persistedExecutionWorkspace: { id: seeded.executionWorkspaceId, mode: "shared_workspace" },
|
||||
});
|
||||
const first = await acquire(seeded.runId);
|
||||
@@ -654,10 +660,19 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
||||
failResume = true;
|
||||
await expect(acquire(failedRun)).rejects.toThrow("the lease was preserved");
|
||||
await db.update(heartbeatRuns).set({ status: "failed" }).where(eq(heartbeatRuns.id, failedRun));
|
||||
const released = await runtime.releaseRunLeases(failedRun, "failed");
|
||||
expect(released).toHaveLength(1);
|
||||
expect(released[0]!.lease).toMatchObject({ status: "retained", expiresAt: null,
|
||||
const claimed = (await environmentService(db).listLeases(seeded.environment.id))
|
||||
.find(lease => lease.heartbeatRunId === failedRun)!;
|
||||
expect(claimed).toMatchObject({ status: "retained", expiresAt: null,
|
||||
providerLeaseId: first.lease.providerLeaseId, metadata: { sandboxResumePending: true } });
|
||||
if (cleanupRan) {
|
||||
const released = await runtime.releaseRunLeases(failedRun, "failed");
|
||||
expect(released).toHaveLength(0); // Already retained at the failed acquisition boundary.
|
||||
expect((await environmentService(db).getLeaseById(claimed.id))?.status).toBe("retained");
|
||||
} else {
|
||||
// Reproduce an older server's orphaned active claim, whose acquisition
|
||||
// threw before heartbeat obtained a lease to release.
|
||||
await db.update(environmentLeases).set({ status: "active" }).where(eq(environmentLeases.id, claimed.id));
|
||||
}
|
||||
expect(vi.mocked(workerManager.call).mock.calls.every((call) => call[1] === "environmentResumeLease")).toBe(true);
|
||||
const retryRun = randomUUID();
|
||||
await db.insert(heartbeatRuns).values({ id: retryRun, companyId: seeded.companyId, agentId: seeded.agentId, status: "running" });
|
||||
@@ -5168,7 +5183,8 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
||||
expect.anything(),
|
||||
);
|
||||
await expect(environmentService(db).getLeaseById(staleLease.id)).resolves.toMatchObject({
|
||||
status: "active",
|
||||
status: "retained", failureReason: "sandbox_resume_incomplete",
|
||||
providerLeaseId: "stale-plugin-lease",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
{
|
||||
"baseInput": {
|
||||
"adapterType": "paperclip_runner",
|
||||
"effectiveAdapterConfig": {
|
||||
"command": "codex",
|
||||
"model": "gpt-5.4-mini",
|
||||
"env": {
|
||||
"OPENAI_API_KEY": "resolved-secret-value",
|
||||
"PLAIN_FLAG": "plain-value"
|
||||
}
|
||||
},
|
||||
"agentRuntimeConfig": {
|
||||
"heartbeat": {
|
||||
"maxConcurrentRuns": 1
|
||||
}
|
||||
},
|
||||
"issueOverrides": null,
|
||||
"workspaceConfig": {
|
||||
"requestedMode": "shared_workspace",
|
||||
"effectiveMode": "shared_workspace",
|
||||
"projectConfigRevisionAt": "2026-06-01T00:00:00.000Z",
|
||||
"projectPolicy": {
|
||||
"enabled": true,
|
||||
"defaultMode": "shared_workspace"
|
||||
},
|
||||
"issueSettings": {
|
||||
"mode": "shared_workspace"
|
||||
}
|
||||
},
|
||||
"environment": {
|
||||
"selectionSource": "default",
|
||||
"selectedEnvironmentId": "environment-1",
|
||||
"selectedEnvironment": {
|
||||
"id": "environment-1",
|
||||
"driver": "local",
|
||||
"configRevisionAt": "2026-06-01T00:00:00.000Z"
|
||||
}
|
||||
},
|
||||
"environmentEnv": {
|
||||
"ENVIRONMENT_FLAG": "enabled"
|
||||
},
|
||||
"projectEnv": {
|
||||
"PROJECT_FLAG": "enabled"
|
||||
},
|
||||
"routineEnv": null,
|
||||
"secretManifest": [
|
||||
{
|
||||
"configPath": "env.OPENAI_API_KEY",
|
||||
"envKey": "OPENAI_API_KEY",
|
||||
"secretId": "secret-1",
|
||||
"bindingId": "binding-1",
|
||||
"secretKey": "openai-api-key",
|
||||
"version": 7,
|
||||
"provider": "local_encrypted",
|
||||
"outcome": "success"
|
||||
}
|
||||
],
|
||||
"runtimeSkills": [
|
||||
{
|
||||
"key": "paperclip",
|
||||
"runtimeName": "paperclip",
|
||||
"source": "/tmp/paperclip/runtime-skills/paperclip",
|
||||
"versionId": null,
|
||||
"currentVersionId": "skill-version-1",
|
||||
"sourceStatus": "available",
|
||||
"missingDetail": null
|
||||
}
|
||||
],
|
||||
"agentConfigRevision": {
|
||||
"id": "agent-config-revision-1",
|
||||
"changedKeys": [
|
||||
"adapterConfig"
|
||||
],
|
||||
"configRevisionAt": "2026-06-01T00:00:00.000Z"
|
||||
}
|
||||
},
|
||||
"cases": [
|
||||
{
|
||||
"overrides": {
|
||||
"adapterType": "codex_local",
|
||||
"workspaceConfig": {
|
||||
"requestedMode": "shared_workspace",
|
||||
"effectiveMode": "shared_workspace",
|
||||
"projectConfigRevisionAt": "2026-06-01T00:00:00.000Z",
|
||||
"projectPolicy": {
|
||||
"enabled": true,
|
||||
"defaultMode": "shared_workspace"
|
||||
},
|
||||
"issueSettings": null
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"version": 1,
|
||||
"fingerprint": "v1:sha256:65ff7f85f57a0c224b10d24d160fcbeff2a0cd3765d6c3953611a60289830ca0",
|
||||
"categories": [
|
||||
"adapter",
|
||||
"adapterConfig",
|
||||
"agentRuntimeConfig",
|
||||
"instructions",
|
||||
"issueOverrides",
|
||||
"workspaceConfig",
|
||||
"environment",
|
||||
"envBindings",
|
||||
"secrets",
|
||||
"runtimeSkills"
|
||||
],
|
||||
"categoryFingerprints": {
|
||||
"adapter": "v1:sha256:29804f650528878073c2438dfce2a079a9dd31cc2518c0fba591828dfbc67322",
|
||||
"adapterConfig": "v1:sha256:127c71e718fe73bf91c94dcc8879f17ed484ea736b0f2e90d81bc873468ea634",
|
||||
"agentRuntimeConfig": "v1:sha256:3c37c19f6928661cb205435737aa3283039119567a08df6d1a06c7e785326594",
|
||||
"instructions": "v1:sha256:8ab53c8385958d7cc11bcd9833f77cb82f02f6b4061954c5ac2cf9d27630d4f6",
|
||||
"issueOverrides": "v1:sha256:54a9d829899ea51ff0fa3b2b7ce8fa0e3f5b46b26862931646c05cc2bb623b48",
|
||||
"workspaceConfig": "v1:sha256:6eb9a524164e629c0bd3690c30bb0580ff744a169333126c399415975b09ac27",
|
||||
"environment": "v1:sha256:b2c8a01983988b1d799b543a0f98dc57c4d557f4ea1d9c018be1dec039a82815",
|
||||
"envBindings": "v1:sha256:a93a8befb4e04e7cd65dbb9a4eb32b585b9f9a03171513576bcc6872aa30dea8",
|
||||
"secrets": "v1:sha256:65d3dc5cc91f9076003b489701585236e7f54e68c5cdfa08b625a4536b4b7f96",
|
||||
"runtimeSkills": "v1:sha256:9c70a22f33f7b6672f688675bb9bc5b7b9c9a2e3b4b3b3a9d313b6748cdc592d"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"overrides": {
|
||||
"adapterType": "codex_local",
|
||||
"workspaceConfig": {
|
||||
"requestedMode": "shared_workspace",
|
||||
"effectiveMode": "shared_workspace",
|
||||
"projectConfigRevisionAt": "2026-06-01T00:00:00.000Z",
|
||||
"projectPolicy": {
|
||||
"enabled": true,
|
||||
"defaultMode": "shared_workspace"
|
||||
},
|
||||
"issueSettings": {}
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"version": 1,
|
||||
"fingerprint": "v1:sha256:cfb12b7b170c2b1b16e6f179e201add4287d04f1d468044be32d42e01d4a903a",
|
||||
"categories": [
|
||||
"adapter",
|
||||
"adapterConfig",
|
||||
"agentRuntimeConfig",
|
||||
"instructions",
|
||||
"issueOverrides",
|
||||
"workspaceConfig",
|
||||
"environment",
|
||||
"envBindings",
|
||||
"secrets",
|
||||
"runtimeSkills"
|
||||
],
|
||||
"categoryFingerprints": {
|
||||
"adapter": "v1:sha256:29804f650528878073c2438dfce2a079a9dd31cc2518c0fba591828dfbc67322",
|
||||
"adapterConfig": "v1:sha256:127c71e718fe73bf91c94dcc8879f17ed484ea736b0f2e90d81bc873468ea634",
|
||||
"agentRuntimeConfig": "v1:sha256:3c37c19f6928661cb205435737aa3283039119567a08df6d1a06c7e785326594",
|
||||
"instructions": "v1:sha256:8ab53c8385958d7cc11bcd9833f77cb82f02f6b4061954c5ac2cf9d27630d4f6",
|
||||
"issueOverrides": "v1:sha256:54a9d829899ea51ff0fa3b2b7ce8fa0e3f5b46b26862931646c05cc2bb623b48",
|
||||
"workspaceConfig": "v1:sha256:3c2a1ecc7d58ac2006e3be9d7af9ce37567fb439aab52c9cecfda254ec63e045",
|
||||
"environment": "v1:sha256:b2c8a01983988b1d799b543a0f98dc57c4d557f4ea1d9c018be1dec039a82815",
|
||||
"envBindings": "v1:sha256:a93a8befb4e04e7cd65dbb9a4eb32b585b9f9a03171513576bcc6872aa30dea8",
|
||||
"secrets": "v1:sha256:65d3dc5cc91f9076003b489701585236e7f54e68c5cdfa08b625a4536b4b7f96",
|
||||
"runtimeSkills": "v1:sha256:9c70a22f33f7b6672f688675bb9bc5b7b9c9a2e3b4b3b3a9d313b6748cdc592d"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"overrides": {
|
||||
"adapterType": "codex_local",
|
||||
"workspaceConfig": {
|
||||
"requestedMode": "shared_workspace",
|
||||
"effectiveMode": "shared_workspace",
|
||||
"projectConfigRevisionAt": "2026-06-01T00:00:00.000Z",
|
||||
"projectPolicy": {
|
||||
"enabled": true,
|
||||
"defaultMode": "shared_workspace"
|
||||
},
|
||||
"issueSettings": {
|
||||
"mode": "shared_workspace"
|
||||
}
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"version": 1,
|
||||
"fingerprint": "v1:sha256:c26eb71a3919d667731d1af324628cfbcdd8252fb83dc208502b2c85c10d6e80",
|
||||
"categories": [
|
||||
"adapter",
|
||||
"adapterConfig",
|
||||
"agentRuntimeConfig",
|
||||
"instructions",
|
||||
"issueOverrides",
|
||||
"workspaceConfig",
|
||||
"environment",
|
||||
"envBindings",
|
||||
"secrets",
|
||||
"runtimeSkills"
|
||||
],
|
||||
"categoryFingerprints": {
|
||||
"adapter": "v1:sha256:29804f650528878073c2438dfce2a079a9dd31cc2518c0fba591828dfbc67322",
|
||||
"adapterConfig": "v1:sha256:127c71e718fe73bf91c94dcc8879f17ed484ea736b0f2e90d81bc873468ea634",
|
||||
"agentRuntimeConfig": "v1:sha256:3c37c19f6928661cb205435737aa3283039119567a08df6d1a06c7e785326594",
|
||||
"instructions": "v1:sha256:8ab53c8385958d7cc11bcd9833f77cb82f02f6b4061954c5ac2cf9d27630d4f6",
|
||||
"issueOverrides": "v1:sha256:54a9d829899ea51ff0fa3b2b7ce8fa0e3f5b46b26862931646c05cc2bb623b48",
|
||||
"workspaceConfig": "v1:sha256:601f668086c690a142041137dfc69543eabb0c2f73ae28f57eed51d31b7911c2",
|
||||
"environment": "v1:sha256:b2c8a01983988b1d799b543a0f98dc57c4d557f4ea1d9c018be1dec039a82815",
|
||||
"envBindings": "v1:sha256:a93a8befb4e04e7cd65dbb9a4eb32b585b9f9a03171513576bcc6872aa30dea8",
|
||||
"secrets": "v1:sha256:65d3dc5cc91f9076003b489701585236e7f54e68c5cdfa08b625a4536b4b7f96",
|
||||
"runtimeSkills": "v1:sha256:9c70a22f33f7b6672f688675bb9bc5b7b9c9a2e3b4b3b3a9d313b6748cdc592d"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"overrides": {
|
||||
"adapterType": "paperclip_runner",
|
||||
"workspaceConfig": {
|
||||
"requestedMode": "shared_workspace",
|
||||
"effectiveMode": "shared_workspace",
|
||||
"projectConfigRevisionAt": "2026-06-01T00:00:00.000Z",
|
||||
"projectPolicy": {
|
||||
"enabled": true,
|
||||
"defaultMode": "shared_workspace"
|
||||
},
|
||||
"issueSettings": null
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"version": 1,
|
||||
"fingerprint": "v1:sha256:aefbeafb1378268238414c7510376bb93c4f647f1a052fc766f93fb9997b6b13",
|
||||
"categories": [
|
||||
"adapter",
|
||||
"adapterConfig",
|
||||
"agentRuntimeConfig",
|
||||
"instructions",
|
||||
"issueOverrides",
|
||||
"workspaceConfig",
|
||||
"environment",
|
||||
"envBindings",
|
||||
"secrets",
|
||||
"runtimeSkills"
|
||||
],
|
||||
"categoryFingerprints": {
|
||||
"adapter": "v1:sha256:f6d7d8a401559cb95feafadaba9f60d56d2c80cca8295d6a3f650e2ddc9c5c1e",
|
||||
"adapterConfig": "v1:sha256:127c71e718fe73bf91c94dcc8879f17ed484ea736b0f2e90d81bc873468ea634",
|
||||
"agentRuntimeConfig": "v1:sha256:3c37c19f6928661cb205435737aa3283039119567a08df6d1a06c7e785326594",
|
||||
"instructions": "v1:sha256:8ab53c8385958d7cc11bcd9833f77cb82f02f6b4061954c5ac2cf9d27630d4f6",
|
||||
"issueOverrides": "v1:sha256:54a9d829899ea51ff0fa3b2b7ce8fa0e3f5b46b26862931646c05cc2bb623b48",
|
||||
"workspaceConfig": "v1:sha256:6eb9a524164e629c0bd3690c30bb0580ff744a169333126c399415975b09ac27",
|
||||
"environment": "v1:sha256:b2c8a01983988b1d799b543a0f98dc57c4d557f4ea1d9c018be1dec039a82815",
|
||||
"envBindings": "v1:sha256:a93a8befb4e04e7cd65dbb9a4eb32b585b9f9a03171513576bcc6872aa30dea8",
|
||||
"secrets": "v1:sha256:65d3dc5cc91f9076003b489701585236e7f54e68c5cdfa08b625a4536b4b7f96",
|
||||
"runtimeSkills": "v1:sha256:9c70a22f33f7b6672f688675bb9bc5b7b9c9a2e3b4b3b3a9d313b6748cdc592d"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"overrides": {
|
||||
"adapterType": "paperclip_runner",
|
||||
"workspaceConfig": {
|
||||
"requestedMode": "shared_workspace",
|
||||
"effectiveMode": "shared_workspace",
|
||||
"projectConfigRevisionAt": "2026-06-01T00:00:00.000Z",
|
||||
"projectPolicy": {
|
||||
"enabled": true,
|
||||
"defaultMode": "shared_workspace"
|
||||
},
|
||||
"issueSettings": {}
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"version": 1,
|
||||
"fingerprint": "v1:sha256:d923a618fb1bd04e30a63f5d98bbc46508c330ef36a1a1187c2a2752e0b7e5cf",
|
||||
"categories": [
|
||||
"adapter",
|
||||
"adapterConfig",
|
||||
"agentRuntimeConfig",
|
||||
"instructions",
|
||||
"issueOverrides",
|
||||
"workspaceConfig",
|
||||
"environment",
|
||||
"envBindings",
|
||||
"secrets",
|
||||
"runtimeSkills"
|
||||
],
|
||||
"categoryFingerprints": {
|
||||
"adapter": "v1:sha256:f6d7d8a401559cb95feafadaba9f60d56d2c80cca8295d6a3f650e2ddc9c5c1e",
|
||||
"adapterConfig": "v1:sha256:127c71e718fe73bf91c94dcc8879f17ed484ea736b0f2e90d81bc873468ea634",
|
||||
"agentRuntimeConfig": "v1:sha256:3c37c19f6928661cb205435737aa3283039119567a08df6d1a06c7e785326594",
|
||||
"instructions": "v1:sha256:8ab53c8385958d7cc11bcd9833f77cb82f02f6b4061954c5ac2cf9d27630d4f6",
|
||||
"issueOverrides": "v1:sha256:54a9d829899ea51ff0fa3b2b7ce8fa0e3f5b46b26862931646c05cc2bb623b48",
|
||||
"workspaceConfig": "v1:sha256:3c2a1ecc7d58ac2006e3be9d7af9ce37567fb439aab52c9cecfda254ec63e045",
|
||||
"environment": "v1:sha256:b2c8a01983988b1d799b543a0f98dc57c4d557f4ea1d9c018be1dec039a82815",
|
||||
"envBindings": "v1:sha256:a93a8befb4e04e7cd65dbb9a4eb32b585b9f9a03171513576bcc6872aa30dea8",
|
||||
"secrets": "v1:sha256:65d3dc5cc91f9076003b489701585236e7f54e68c5cdfa08b625a4536b4b7f96",
|
||||
"runtimeSkills": "v1:sha256:9c70a22f33f7b6672f688675bb9bc5b7b9c9a2e3b4b3b3a9d313b6748cdc592d"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"overrides": {
|
||||
"adapterType": "paperclip_runner",
|
||||
"workspaceConfig": {
|
||||
"requestedMode": "shared_workspace",
|
||||
"effectiveMode": "shared_workspace",
|
||||
"projectConfigRevisionAt": "2026-06-01T00:00:00.000Z",
|
||||
"projectPolicy": {
|
||||
"enabled": true,
|
||||
"defaultMode": "shared_workspace"
|
||||
},
|
||||
"issueSettings": {
|
||||
"mode": "shared_workspace"
|
||||
}
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"version": 1,
|
||||
"fingerprint": "v1:sha256:649054ef19224addf2d28d1d145cdfab310fcd6aa5c7b81892b3717f6071a01e",
|
||||
"categories": [
|
||||
"adapter",
|
||||
"adapterConfig",
|
||||
"agentRuntimeConfig",
|
||||
"instructions",
|
||||
"issueOverrides",
|
||||
"workspaceConfig",
|
||||
"environment",
|
||||
"envBindings",
|
||||
"secrets",
|
||||
"runtimeSkills"
|
||||
],
|
||||
"categoryFingerprints": {
|
||||
"adapter": "v1:sha256:f6d7d8a401559cb95feafadaba9f60d56d2c80cca8295d6a3f650e2ddc9c5c1e",
|
||||
"adapterConfig": "v1:sha256:127c71e718fe73bf91c94dcc8879f17ed484ea736b0f2e90d81bc873468ea634",
|
||||
"agentRuntimeConfig": "v1:sha256:3c37c19f6928661cb205435737aa3283039119567a08df6d1a06c7e785326594",
|
||||
"instructions": "v1:sha256:8ab53c8385958d7cc11bcd9833f77cb82f02f6b4061954c5ac2cf9d27630d4f6",
|
||||
"issueOverrides": "v1:sha256:54a9d829899ea51ff0fa3b2b7ce8fa0e3f5b46b26862931646c05cc2bb623b48",
|
||||
"workspaceConfig": "v1:sha256:601f668086c690a142041137dfc69543eabb0c2f73ae28f57eed51d31b7911c2",
|
||||
"environment": "v1:sha256:b2c8a01983988b1d799b543a0f98dc57c4d557f4ea1d9c018be1dec039a82815",
|
||||
"envBindings": "v1:sha256:a93a8befb4e04e7cd65dbb9a4eb32b585b9f9a03171513576bcc6872aa30dea8",
|
||||
"secrets": "v1:sha256:65d3dc5cc91f9076003b489701585236e7f54e68c5cdfa08b625a4536b4b7f96",
|
||||
"runtimeSkills": "v1:sha256:9c70a22f33f7b6672f688675bb9bc5b7b9c9a2e3b4b3b3a9d313b6748cdc592d"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"sourceCommit": "b58c10611ae5fe5b2f41e4c67e24835195fb604d"
|
||||
}
|
||||
@@ -1806,6 +1806,23 @@ describe("effective run execution workspace config freshness", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("recovers an older unpinned sandbox binding without overriding explicit workspace choices", () => {
|
||||
const input = {
|
||||
issueExecutionWorkspaceId: "workspace-old",
|
||||
existingExecutionWorkspaceStatus: "active",
|
||||
reuseDefaultSandboxWorkspace: true,
|
||||
};
|
||||
expect(resolveExecutionWorkspaceReuseRequestForIssue(input).requestedShouldReuseExisting).toBe(true);
|
||||
for (const issueExecutionWorkspacePreference of ["isolated_workspace", "operator_branch", "agent_default", "inherit"]) {
|
||||
expect(resolveExecutionWorkspaceReuseRequestForIssue({ ...input, issueExecutionWorkspacePreference })
|
||||
.requestedShouldReuseExisting).toBe(false);
|
||||
}
|
||||
expect(resolveExecutionWorkspaceReuseRequestForIssue({ ...input, reuseDefaultSandboxWorkspace: false })
|
||||
.requestedShouldReuseExisting).toBe(false);
|
||||
expect(resolveExecutionWorkspaceReuseRequestForIssue({ ...input, requestedExistingBranch: "different-branch" })
|
||||
.requestedShouldReuseExisting).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps reusing an inherited workspace whose branch matches the pinned existing branch", () => {
|
||||
expect(resolveExecutionWorkspaceReuseRequestForIssue({
|
||||
issueExecutionWorkspaceId: "workspace-old",
|
||||
@@ -2211,6 +2228,49 @@ function sessionParamsWithConfigMetadata(
|
||||
}
|
||||
|
||||
describe("effective run session config freshness", () => {
|
||||
it("upgrades actual pre-normalization native and legacy session fingerprints without resetting", async () => {
|
||||
const fixture = JSON.parse(await fs.readFile(
|
||||
new URL("./fixtures/pre-normalization-session-fingerprints.json", import.meta.url), "utf8",
|
||||
));
|
||||
for (const prior of fixture.cases) {
|
||||
const input = { ...fixture.baseInput, ...prior.overrides };
|
||||
const next = await buildEffectiveRunSessionConfigMetadata({
|
||||
...input,
|
||||
workspaceConfig: {
|
||||
...input.workspaceConfig,
|
||||
issueSettings: { mode: "shared_workspace" },
|
||||
},
|
||||
});
|
||||
expect(next.fingerprint).not.toBe(prior.metadata.fingerprint);
|
||||
expect(resolveTaskSessionConfigFreshness({
|
||||
hasTaskSession: true, configuredModel: "gpt-5.4-mini",
|
||||
taskSessionParams: sessionParamsWithConfigMetadata(prior.metadata), configMetadata: next,
|
||||
})).toMatchObject({ reset: false, reasons: [], nextFingerprint: next.fingerprint });
|
||||
// Once published, the normalized fingerprint is reusable without the old
|
||||
// project timestamp: a later description edit remains compatible.
|
||||
const later = await buildEffectiveRunSessionConfigMetadata({
|
||||
...input,
|
||||
workspaceConfig: { ...input.workspaceConfig, projectConfigRevisionAt: "2026-06-02T00:00:00Z" },
|
||||
});
|
||||
expect(resolveTaskSessionConfigFreshness({
|
||||
hasTaskSession: true, configuredModel: "gpt-5.4-mini",
|
||||
taskSessionParams: sessionParamsWithConfigMetadata(next), configMetadata: later,
|
||||
}).reset).toBe(false);
|
||||
for (const change of [
|
||||
{ effectiveAdapterConfig: { ...input.effectiveAdapterConfig, model: "other-model" } },
|
||||
{ secretManifest: input.secretManifest.map((entry: Record<string, unknown>) => ({ ...entry, version: 8 })) },
|
||||
{ workspaceConfig: { ...input.workspaceConfig, issueSettings: { mode: "shared_workspace", networkEgress: "restricted" } } },
|
||||
{ workspaceConfig: { ...input.workspaceConfig, projectPolicy: { enabled: true, defaultMode: "isolated_workspace" } } },
|
||||
]) {
|
||||
const incompatible = await buildEffectiveRunSessionConfigMetadata({ ...input, ...change });
|
||||
expect(resolveTaskSessionConfigFreshness({
|
||||
hasTaskSession: true, configuredModel: "gpt-5.4-mini",
|
||||
taskSessionParams: sessionParamsWithConfigMetadata(prior.metadata), configMetadata: incompatible,
|
||||
}).reset).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("resets when effective adapter config changes after model/profile/env resolution", async () => {
|
||||
const base = await buildSessionConfigMetadata();
|
||||
const next = await buildSessionConfigMetadata({
|
||||
@@ -2286,6 +2346,56 @@ describe("effective run session config freshness", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["codex_local", "paperclip_runner"])(
|
||||
"preserves %s sessions across project metadata updates but detects policy changes",
|
||||
async (adapterType) => {
|
||||
const workspaceConfig = {
|
||||
requestedMode: "shared_workspace",
|
||||
effectiveMode: "shared_workspace",
|
||||
projectPolicy: { enabled: true, defaultMode: "shared_workspace" },
|
||||
issueSettings: null,
|
||||
};
|
||||
const base = await buildSessionConfigMetadata({
|
||||
adapterType,
|
||||
workspaceConfig: {
|
||||
...workspaceConfig,
|
||||
projectConfigRevisionAt: "2026-06-01T00:00:00.000Z",
|
||||
},
|
||||
});
|
||||
const metadataUpdate = await buildSessionConfigMetadata({
|
||||
adapterType,
|
||||
workspaceConfig: {
|
||||
...workspaceConfig,
|
||||
projectConfigRevisionAt: "2026-06-01T00:05:00.000Z",
|
||||
},
|
||||
});
|
||||
const policyUpdate = await buildSessionConfigMetadata({
|
||||
adapterType,
|
||||
workspaceConfig: {
|
||||
...workspaceConfig,
|
||||
projectConfigRevisionAt: "2026-06-01T00:05:00.000Z",
|
||||
projectPolicy: { enabled: true, defaultMode: "isolated_workspace" },
|
||||
},
|
||||
});
|
||||
const decide = (configMetadata: SessionConfigMetadata) =>
|
||||
resolveTaskSessionConfigFreshness({
|
||||
hasTaskSession: true,
|
||||
configuredModel: "gpt-5.4-mini",
|
||||
taskSessionParams: sessionParamsWithConfigMetadata(base),
|
||||
configMetadata,
|
||||
});
|
||||
expect(decide(metadataUpdate)).toMatchObject({
|
||||
reset: false,
|
||||
changedCategories: [],
|
||||
reasons: [],
|
||||
});
|
||||
expect(decide(policyUpdate)).toMatchObject({
|
||||
reset: true,
|
||||
changedCategories: ["workspaceConfig"],
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("does not reset when a reusable execution workspace becomes realized", async () => {
|
||||
const base = await buildSessionConfigMetadata({
|
||||
workspaceConfig: {
|
||||
@@ -2325,6 +2435,48 @@ describe("effective run session config freshness", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps native session identity when startup pins the already-effective workspace mode", async () => {
|
||||
const workspaceConfig = {
|
||||
requestedMode: "shared_workspace",
|
||||
effectiveMode: "shared_workspace",
|
||||
projectPolicy: { enabled: true, defaultMode: "shared_workspace" },
|
||||
};
|
||||
const before = await buildSessionConfigMetadata({
|
||||
adapterType: "paperclip_runner",
|
||||
workspaceConfig: { ...workspaceConfig, issueSettings: null },
|
||||
});
|
||||
const pinned = await buildSessionConfigMetadata({
|
||||
adapterType: "paperclip_runner",
|
||||
workspaceConfig: {
|
||||
...workspaceConfig,
|
||||
issueSettings: { mode: "shared_workspace" },
|
||||
},
|
||||
});
|
||||
const policyChanged = await buildSessionConfigMetadata({
|
||||
adapterType: "paperclip_runner",
|
||||
workspaceConfig: {
|
||||
...workspaceConfig,
|
||||
issueSettings: { mode: "shared_workspace", networkEgress: "restricted" },
|
||||
},
|
||||
});
|
||||
const decide = (configMetadata: SessionConfigMetadata) =>
|
||||
resolveTaskSessionConfigFreshness({
|
||||
hasTaskSession: true,
|
||||
configuredModel: "gpt-5.4-mini",
|
||||
taskSessionParams: sessionParamsWithConfigMetadata(before),
|
||||
configMetadata,
|
||||
});
|
||||
expect(decide(pinned)).toMatchObject({
|
||||
reset: false,
|
||||
reasons: [],
|
||||
changedCategories: [],
|
||||
});
|
||||
expect(decide(policyChanged)).toMatchObject({
|
||||
reset: true,
|
||||
changedCategories: ["workspaceConfig"],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps model-only compatibility as an additional reset reason", async () => {
|
||||
const base = await buildSessionConfigMetadata();
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { sessionCodec as codex } from "@paperclipai/adapter-codex-local/server";
|
||||
import { sessionCodec as claude, claudeSessionMcpServersMatch } from "@paperclipai/adapter-claude-local/server";
|
||||
import { adapterExecutionTargetSessionIdentity, adapterExecutionTargetSessionMatches, type AdapterSandboxExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
|
||||
import { recoverLegacySandboxSession } from "../services/legacy-sandbox-session.js";
|
||||
|
||||
const target: AdapterSandboxExecutionTarget = {
|
||||
kind: "remote", transport: "sandbox", providerKey: "daytona", environmentId: "env",
|
||||
leaseId: "new-run-lease", remoteCwd: "/home/daytona/repos/main",
|
||||
sandboxLeaseAcquisition: { outcome: "resumed", providerLeaseId: "physical-sandbox" },
|
||||
};
|
||||
function fixture(): Parameters<typeof recoverLegacySandboxSession>[0] {
|
||||
return {
|
||||
adapterType: "codex_local", params: { sessionId: "conversation", cwd: target.remoteCwd }, target,
|
||||
companyId: "company", agentId: "agent", taskId: "task", responsibleUserId: "user",
|
||||
executionWorkspaceId: "workspace",
|
||||
previousRun: {
|
||||
companyId: "company", agentId: "agent", responsibleUserId: "user", status: "succeeded",
|
||||
sessionIdAfter: "conversation", contextSnapshot: {
|
||||
taskId: "task", executionWorkspaceId: "workspace",
|
||||
paperclipEnvironment: {
|
||||
driver: "sandbox", id: "env", leaseId: "old-run-lease", remoteCwd: target.remoteCwd,
|
||||
workspaceRealization: { provider: "daytona", providerLeaseId: "physical-sandbox" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("legacy sandbox conversation persistence", () => {
|
||||
it("migrates only the built-in MCP server for a host-verified old Claude session", () => {
|
||||
const input = fixture(); input.adapterType = "claude_local";
|
||||
const recovered = recoverLegacySandboxSession(input)!;
|
||||
expect(recovered.legacyPlatformMcpSession).toBe(true);
|
||||
const match = { savedIdentity: "", currentIdentity: "current", currentConnectionIds: ["paperclip-runtime-tools"], legacyPlatformSession: true };
|
||||
expect(claudeSessionMcpServersMatch(match)).toBe(true);
|
||||
expect(claudeSessionMcpServersMatch({ ...match, legacyPlatformSession: false })).toBe(false);
|
||||
expect(claudeSessionMcpServersMatch({ ...match, currentConnectionIds: ["external"] })).toBe(false);
|
||||
expect(claudeSessionMcpServersMatch({ ...match, savedIdentity: "old" })).toBe(false);
|
||||
const persisted = claude.serialize({ ...recovered, mcpServerIdentity: "current" });
|
||||
expect(persisted?.legacyPlatformMcpSession).toBeUndefined();
|
||||
expect(claude.deserialize(persisted)?.mcpServerIdentity).toBe("current");
|
||||
expect(claudeSessionMcpServersMatch({ ...match, savedIdentity: "current", legacyPlatformSession: false })).toBe(true);
|
||||
});
|
||||
for (const [name, codec] of [["codex_local", codex], ["claude_local", claude]] as const) {
|
||||
it(`${name} recovers old metadata, persists it, and resumes across host leases`, () => {
|
||||
const input = fixture(); input.adapterType = name;
|
||||
const recovered = recoverLegacySandboxSession(input);
|
||||
const next = codec.deserialize(codec.serialize(recovered));
|
||||
expect(next?.sessionId).toBe("conversation");
|
||||
expect(next?.remoteExecution).toEqual(adapterExecutionTargetSessionIdentity(target));
|
||||
expect(adapterExecutionTargetSessionMatches(next?.remoteExecution, { ...target, leaseId: "third-run-lease" })).toBe(true);
|
||||
expect(adapterExecutionTargetSessionMatches(next?.remoteExecution, { ...target,
|
||||
sandboxLeaseAcquisition: { outcome: "replacement", providerLeaseId: "another-sandbox" },
|
||||
})).toBe(false);
|
||||
expect(adapterExecutionTargetSessionMatches(next?.remoteExecution, { ...target, environmentId: "other-env" })).toBe(false);
|
||||
expect(adapterExecutionTargetSessionMatches(next?.remoteExecution, { ...target, remoteCwd: "/other" })).toBe(false);
|
||||
expect(adapterExecutionTargetSessionMatches(next?.remoteExecution, { kind: "local" })).toBe(false);
|
||||
});
|
||||
|
||||
it(`${name} retains SSH identity and leaves local sessions unchanged`, () => {
|
||||
const local = { sessionId: "local-session", cwd: "/tmp/work" };
|
||||
expect(codec.deserialize(codec.serialize(local))).toEqual(local);
|
||||
const remoteExecution = { transport: "ssh", host: "host", username: "user", port: 22, remoteCwd: "/work" };
|
||||
expect(codec.deserialize(codec.serialize({ ...local, remoteExecution: { ...remoteExecution, secret: "discard-me" } }))?.remoteExecution).toEqual(remoteExecution);
|
||||
expect(adapterExecutionTargetSessionMatches(codec.serialize({ ...local, remoteExecution: {} })?.remoteExecution, { kind: "local" })).toBe(false);
|
||||
});
|
||||
}
|
||||
|
||||
for (const field of ["companyId", "agentId", "taskId", "responsibleUserId", "executionWorkspaceId"] as const) {
|
||||
it(`does not recover across a different ${field}`, () => {
|
||||
const input = fixture(); input[field] = "another";
|
||||
expect(recoverLegacySandboxSession(input)).toBe(input.params);
|
||||
});
|
||||
}
|
||||
it("does not invent a resume after replacement, a changed cwd, an unknown run, or an explicit identity", () => {
|
||||
for (const mutate of [
|
||||
(input: ReturnType<typeof fixture>) => { input.target = { ...target, sandboxLeaseAcquisition: { outcome: "replacement", providerLeaseId: "physical-sandbox" } }; },
|
||||
(input: ReturnType<typeof fixture>) => { input.target = { ...target, sandboxLeaseAcquisition: { outcome: "resumed", providerLeaseId: "other" } }; },
|
||||
(input: ReturnType<typeof fixture>) => { input.params!.cwd = "/other"; },
|
||||
(input: ReturnType<typeof fixture>) => { input.previousRun = null; },
|
||||
(input: ReturnType<typeof fixture>) => { input.previousRun!.sessionIdAfter = "other"; },
|
||||
(input: ReturnType<typeof fixture>) => { input.previousRun!.status = "failed"; },
|
||||
(input: ReturnType<typeof fixture>) => { input.params!.remoteExecution = { transport: "ssh" }; },
|
||||
(input: ReturnType<typeof fixture>) => { input.params!.remoteExecution = { transport: "sandbox", leaseId: "foreign" }; },
|
||||
(input: ReturnType<typeof fixture>) => { input.target = { kind: "local" }; },
|
||||
]) {
|
||||
const input = fixture(); mutate(input);
|
||||
expect(recoverLegacySandboxSession(input)).toBe(input.params);
|
||||
}
|
||||
});
|
||||
it("keeps legacy host-lease matching when no physical identity is available", () => {
|
||||
const oldTarget = { ...target, sandboxLeaseAcquisition: undefined };
|
||||
const saved = adapterExecutionTargetSessionIdentity(oldTarget);
|
||||
expect(adapterExecutionTargetSessionMatches(saved, oldTarget)).toBe(true);
|
||||
expect(adapterExecutionTargetSessionMatches(saved, { ...oldTarget, leaseId: "other" })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,7 @@ import os from "node:os";
|
||||
import { agents, assets, companyMemberships, issueAttachments, companies, createDb, heartbeatRuns, issues, environments, environmentLeases, executionWorkspaces, projects, projectWorkspaces, taskRepositoryBindings, workFolderObjects, workFolderRuns, startEmbeddedPostgresTestDatabase, type Db } from "@paperclipai/db";
|
||||
import { createLocalDiskStorageProvider } from "../storage/local-disk-provider.js";
|
||||
import { prepareSandboxWorkFolders } from "../services/sandbox-work-folders.js";
|
||||
import { bindWarmSandboxWorkspace } from "../services/sandbox-workspace-binding.js";
|
||||
import { bindReusableSandboxWorkspace, shouldBindReusableSandboxWorkspace } from "../services/sandbox-workspace-binding.js";
|
||||
import { findUnboundLegacyTaskWorkspace } from "../services/legacy-sandbox-workspace.js";
|
||||
import { retainUnsavedWorkFolderLease, workFolderSandboxKey } from "../services/work-folder-retention.js";
|
||||
import * as activityLog from "../services/activity-log.js";
|
||||
@@ -22,6 +22,27 @@ import type { CommandManagedRuntimeRunner } from "@paperclipai/adapter-utils/com
|
||||
import { localTestWorkFolderRunner } from "./helpers/work-folder-runner.js";
|
||||
const exec = promisify(execFile);
|
||||
|
||||
describe("sandbox workspace reuse policy", () => {
|
||||
it.each(["warm", "per_turn", undefined])(
|
||||
"pins reusable task workspaces independently of provider lifecycle %s",
|
||||
(runnerLifecycleMode) => {
|
||||
expect(shouldBindReusableSandboxWorkspace({
|
||||
driver: "sandbox", config: { reuseLease: true, runnerLifecycleMode },
|
||||
})).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
null,
|
||||
{ driver: "local", config: { reuseLease: true } },
|
||||
{ driver: "ssh", config: { reuseLease: true } },
|
||||
{ driver: "sandbox", config: { reuseLease: false, runnerLifecycleMode: "warm" } },
|
||||
{ driver: "sandbox", config: {} },
|
||||
])("leaves local and non-reusable execution unchanged: %j", (environment) => {
|
||||
expect(shouldBindReusableSandboxWorkspace(environment)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shared sandbox work-folder lifecycle", () => {
|
||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
let db: Db;
|
||||
@@ -113,14 +134,14 @@ describe("shared sandbox work-folder lifecycle", () => {
|
||||
await db.insert(executionWorkspaces).values({ id: workspaceId, companyId, projectId, sourceIssueId: task,
|
||||
mode: "shared_workspace", strategyType: "project_primary", name: "Warm binding" });
|
||||
const input = { companyId, issueId: task, runId, agentId, workspaceId };
|
||||
await bindWarmSandboxWorkspace(db, input);
|
||||
await bindReusableSandboxWorkspace(db, input);
|
||||
const [bound] = await db.select().from(issues).where(eq(issues.id, task));
|
||||
expect(bound).toMatchObject({ executionWorkspaceId: workspaceId, executionWorkspacePreference: "reuse_existing", executionWorkspaceSettings: { mode: "shared_workspace" } });
|
||||
for (const bad of [{ companyId: randomUUID() }, { agentId: randomUUID() }, { issueId: taskId }, { runId: randomUUID() }]) {
|
||||
await expect(bindWarmSandboxWorkspace(db, { ...input, ...bad })).rejects.toThrow("active task run");
|
||||
await expect(bindReusableSandboxWorkspace(db, { ...input, ...bad })).rejects.toThrow("active task run");
|
||||
}
|
||||
await db.update(executionWorkspaces).set({ sourceIssueId: taskId }).where(eq(executionWorkspaces.id, workspaceId));
|
||||
await expect(bindWarmSandboxWorkspace(db, input)).rejects.toThrow("active task run");
|
||||
await expect(bindReusableSandboxWorkspace(db, input)).rejects.toThrow("active task run");
|
||||
await db.update(executionWorkspaces).set({ sourceIssueId: task }).where(eq(executionWorkspaces.id, workspaceId));
|
||||
const originalLogActivity = activityLog.logActivity;
|
||||
let release!: () => void;
|
||||
@@ -130,7 +151,7 @@ describe("shared sandbox work-folder lifecycle", () => {
|
||||
const audit = vi.spyOn(activityLog, "logActivity").mockImplementationOnce(async (...args) => {
|
||||
entered(); await gate; return originalLogActivity(...args);
|
||||
});
|
||||
const pendingBinding = bindWarmSandboxWorkspace(db, input);
|
||||
const pendingBinding = bindReusableSandboxWorkspace(db, input);
|
||||
try {
|
||||
await reached;
|
||||
// These state changes must wait until the validated binding commits.
|
||||
@@ -145,7 +166,7 @@ describe("shared sandbox work-folder lifecycle", () => {
|
||||
release(); await pendingBinding; audit.mockRestore();
|
||||
}
|
||||
await db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, runId));
|
||||
await expect(bindWarmSandboxWorkspace(db, input)).rejects.toThrow("active task run");
|
||||
await expect(bindReusableSandboxWorkspace(db, input)).rejects.toThrow("active task run");
|
||||
});
|
||||
async function prepare(home: string, leaseId: string, physicalId = leaseId, responsibleUserId: string | null = null,
|
||||
options: { taskId?: string; branchName?: string; agentId?: string; bulkStdin?: boolean } = {}) {
|
||||
@@ -535,6 +556,13 @@ describe("shared sandbox work-folder lifecycle", () => {
|
||||
expect(await retainUnsavedWorkFolderLease(db, { id: leaseId, companyId })).toBe(true);
|
||||
await run.stop(); active.splice(active.indexOf(run), 1);
|
||||
expect(await retainUnsavedWorkFolderLease(db, { id: leaseId, companyId })).toBe(false);
|
||||
// The next startup may fail before writing a manifest; the old final save
|
||||
// must not leave its working copy active and permanently blocking retries.
|
||||
await db.update(environmentLeases).set({ status: "active" }).where(eq(environmentLeases.id, leaseId));
|
||||
expect(await retainUnsavedWorkFolderLease(db, { id: leaseId, companyId }, { runSaveFailed: true })).toBe(true);
|
||||
const [retained] = await db.select().from(environmentLeases).where(eq(environmentLeases.id, leaseId));
|
||||
expect(retained).toMatchObject({ status: "retained", expiresAt: null,
|
||||
metadata: { workFolderRecoveryRequired: true } });
|
||||
}, 120_000);
|
||||
it("reconciles file-directory replacements and preserves deleted children in trash", async () => {
|
||||
const svc = workFolderService(db, storage);
|
||||
|
||||
@@ -698,7 +698,7 @@ export class SandboxOrphanCleanupWriteError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
async function retainIncompleteSandboxResume(db: Db, lease: EnvironmentLease): Promise<EnvironmentLease | null> {
|
||||
async function retainIncompleteSandboxResume(db: Db, lease: EnvironmentLease, requireTerminalRun = false): Promise<EnvironmentLease | null> {
|
||||
if (lease.metadata?.sandboxResumePending !== true || !lease.heartbeatRunId) return null;
|
||||
// A resume failure is not proof that the existing workspace is disposable.
|
||||
// Keep the claimed reference eligible for retry, without a provider release
|
||||
@@ -715,6 +715,11 @@ async function retainIncompleteSandboxResume(db: Db, lease: EnvironmentLease): P
|
||||
eq(environmentLeases.heartbeatRunId, lease.heartbeatRunId),
|
||||
inArray(environmentLeases.status, ["active", "retained"]),
|
||||
sql`${environmentLeases.metadata}->'sandboxResumePending' = 'true'::jsonb`,
|
||||
...(requireTerminalRun ? [sql`exists (
|
||||
select 1 from ${heartbeatRuns} where ${heartbeatRuns.id} = ${lease.heartbeatRunId}
|
||||
and ${heartbeatRuns.companyId} = ${lease.companyId}
|
||||
and ${heartbeatRuns.status} in ('succeeded', 'interrupted', 'failed', 'cancelled', 'timed_out')
|
||||
)`] : []),
|
||||
)).returning();
|
||||
return retained ? toEnvironmentLeaseSnapshot(retained) : null;
|
||||
}
|
||||
@@ -1837,8 +1842,10 @@ function createSandboxEnvironmentDriver(
|
||||
const handoffDeadline = Date.now() + 30_000;
|
||||
while (true) {
|
||||
const [holder] = await db.select({
|
||||
id: environmentLeases.id,
|
||||
providerLeaseId: environmentLeases.providerLeaseId,
|
||||
runStatus: heartbeatRuns.status,
|
||||
resumePending: sql<boolean>`${environmentLeases.metadata}->'sandboxResumePending' = 'true'::jsonb`,
|
||||
releaseFailureReason: environmentLeases.failureReason,
|
||||
releasePending: sql<boolean>`coalesce(${environmentLeases.metadata}, '{}'::jsonb) ? 'sandboxReleasePending'`,
|
||||
}).from(environmentLeases).innerJoin(heartbeatRuns, and(
|
||||
@@ -1859,6 +1866,14 @@ function createSandboxEnvironmentDriver(
|
||||
sql`${heartbeatRuns.responsibleUserId} is not distinct from ${responsibleUserId}`,
|
||||
)).limit(1);
|
||||
if (!holder) break;
|
||||
// Older acquisition failures could leave an active provisional claim
|
||||
// before the caller received a lease to clean up. Recover only a
|
||||
// terminal owner's incomplete resume, never an executing/releasing run.
|
||||
if (holder.resumePending && !holder.releasePending
|
||||
&& ["succeeded", "interrupted", "failed", "cancelled", "timed_out"].includes(holder.runStatus)) {
|
||||
const incomplete = await environmentsSvc.getLeaseById(holder.id);
|
||||
if (incomplete && await retainIncompleteSandboxResume(db, incomplete, true)) continue;
|
||||
}
|
||||
if (holder.releasePending && (holder.releaseFailureReason === "sandbox_release_recovery_required" || Date.now() >= handoffDeadline)) throw new Error("sandbox_release_recovery_required");
|
||||
if (!["succeeded", "interrupted", "failed", "cancelled", "timed_out"].includes(holder.runStatus) || Date.now() >= handoffDeadline) {
|
||||
throw new ReusableSandboxResumeError({
|
||||
@@ -2096,6 +2111,7 @@ function createSandboxEnvironmentDriver(
|
||||
: "expired";
|
||||
}
|
||||
} catch (error) {
|
||||
await retainIncompleteSandboxResume(db, reusableLease);
|
||||
throw new ReusableSandboxResumeError({
|
||||
provider: parsed.config.provider,
|
||||
providerLeaseId: reusableLease.providerLeaseId,
|
||||
@@ -2408,6 +2424,7 @@ function createSandboxEnvironmentDriver(
|
||||
});
|
||||
} catch (error) {
|
||||
if (reusableLease) {
|
||||
await retainIncompleteSandboxResume(db, reusableLease);
|
||||
throw new ReusableSandboxResumeError({
|
||||
provider: parsed.config.provider,
|
||||
providerLeaseId: reusableLease.providerLeaseId!,
|
||||
|
||||
@@ -7,8 +7,9 @@ import { cleanupGitHubOperationLaunchers, prepareGitHubOperationLaunchers } from
|
||||
import fs from "node:fs/promises";
|
||||
import { retainUnsavedWorkFolderLease, workFolderSandboxKey } from "./work-folder-retention.js";
|
||||
import { findUnboundLegacyTaskWorkspace, hasLegacySandboxWorkspace } from "./legacy-sandbox-workspace.js";
|
||||
import { recoverLegacySandboxSession } from "./legacy-sandbox-session.js";
|
||||
import { prepareSandboxWorkFolders } from "./sandbox-work-folders.js";
|
||||
import { bindWarmSandboxWorkspace } from "./sandbox-workspace-binding.js";
|
||||
import { bindReusableSandboxWorkspace, shouldBindReusableSandboxWorkspace } from "./sandbox-workspace-binding.js";
|
||||
import path from "node:path";
|
||||
import { execFile as execFileCallback } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
@@ -5473,6 +5474,7 @@ type EffectiveRunSessionConfigMetadata = {
|
||||
categories: EffectiveRunSessionConfigCategory[];
|
||||
categoryFingerprints: Record<EffectiveRunSessionConfigCategory, string>;
|
||||
fingerprints: EffectiveRunConfigFingerprints;
|
||||
compatibleFingerprints: string[];
|
||||
};
|
||||
|
||||
type TaskSessionConfigFreshnessDecision = {
|
||||
@@ -5556,6 +5558,7 @@ export function resolveExecutionWorkspaceReuseRequestForIssue(input: {
|
||||
existingExecutionWorkspaceStatus?: string | null;
|
||||
requestedExistingBranch?: string | null;
|
||||
existingExecutionWorkspaceBranchName?: string | null;
|
||||
reuseDefaultSandboxWorkspace?: boolean;
|
||||
}): ExecutionWorkspaceReuseRequestForIssue {
|
||||
const requestedExecutionWorkspaceId = readNonEmptyString(
|
||||
input.issueExecutionWorkspaceId,
|
||||
@@ -5571,7 +5574,8 @@ export function resolveExecutionWorkspaceReuseRequestForIssue(input: {
|
||||
readNonEmptyString(input.existingExecutionWorkspaceBranchName) ===
|
||||
requestedExistingBranch;
|
||||
const requestedShouldReuseExisting =
|
||||
input.issueExecutionWorkspacePreference === "reuse_existing" &&
|
||||
(input.issueExecutionWorkspacePreference === "reuse_existing" ||
|
||||
(input.issueExecutionWorkspacePreference == null && input.reuseDefaultSandboxWorkspace === true)) &&
|
||||
requestedExecutionWorkspaceId !== null &&
|
||||
existingWorkspaceMatchesRequestedBranch;
|
||||
|
||||
@@ -6109,6 +6113,20 @@ function buildSessionConfigCategoryValues(input: {
|
||||
// the timestamp here makes every comment invalidate an otherwise reusable
|
||||
// task session.
|
||||
delete workspaceConfig.issueConfigRevisionAt;
|
||||
// A project's updatedAt also changes for its title, description, and other
|
||||
// metadata. Compare the effective project policy and workspace settings
|
||||
// below, not that timestamp, or an unrelated edit drops the native session
|
||||
// while its reusable sandbox still contains the original provider state.
|
||||
delete workspaceConfig.projectConfigRevisionAt;
|
||||
// First sandbox startup pins the effective mode onto the issue. An inherited
|
||||
// mode and that identical explicit pin describe the same execution contract.
|
||||
// Keep other issue settings (network, credentials, etc.) in the fingerprint.
|
||||
if (typeof workspaceConfig.effectiveMode === "string") {
|
||||
workspaceConfig.issueSettings = {
|
||||
...parseObject(workspaceConfig.issueSettings),
|
||||
mode: workspaceConfig.effectiveMode,
|
||||
};
|
||||
}
|
||||
// This row is runtime state, not requested configuration. It is absent
|
||||
// before the first reusable run is realized and present on the next turn;
|
||||
// fingerprinting that transition would rotate the native session exactly
|
||||
@@ -6181,12 +6199,38 @@ export async function buildEffectiveRunSessionConfigMetadata(input: {
|
||||
subcategories: EFFECTIVE_RUN_SESSION_CONFIG_CATEGORIES,
|
||||
secretManifest,
|
||||
});
|
||||
// Pre-normalization releases hashed the project timestamp and the issue's
|
||||
// raw mode setting. Accept only an exact old-algorithm hash of the current
|
||||
// effective configuration, never a blanket exemption for workspace changes.
|
||||
// A successful run publishes the new fingerprint through the usual path.
|
||||
const legacyWorkspace: Record<string, unknown> = {
|
||||
...categoryValues.workspaceConfig,
|
||||
projectConfigRevisionAt: parseObject(input.workspaceConfig).projectConfigRevisionAt,
|
||||
issueSettings: parseObject(input.workspaceConfig).issueSettings,
|
||||
};
|
||||
const legacyWorkspaceVariants = [legacyWorkspace];
|
||||
const legacyIssueSettings = parseObject(legacyWorkspace.issueSettings);
|
||||
if (legacyIssueSettings.mode === legacyWorkspace.effectiveMode) {
|
||||
const inheritedSettings = { ...legacyIssueSettings };
|
||||
delete inheritedSettings.mode;
|
||||
legacyWorkspaceVariants.push({ ...legacyWorkspace, issueSettings: inheritedSettings });
|
||||
if (Object.keys(inheritedSettings).length === 0) {
|
||||
legacyWorkspaceVariants.push({ ...legacyWorkspace, issueSettings: null });
|
||||
}
|
||||
}
|
||||
const compatibleFingerprints = [...new Set(legacyWorkspaceVariants.map((workspaceConfig) =>
|
||||
createEffectiveRunConfigFingerprints({
|
||||
session: { ...categoryValues, workspaceConfig },
|
||||
secretManifest,
|
||||
}).sessionFingerprint.fingerprint,
|
||||
))];
|
||||
return {
|
||||
version: EFFECTIVE_RUN_CONFIG_FINGERPRINT_VERSION,
|
||||
fingerprint: fingerprints.sessionFingerprint.fingerprint,
|
||||
categories: [...EFFECTIVE_RUN_SESSION_CONFIG_CATEGORIES],
|
||||
categoryFingerprints,
|
||||
fingerprints,
|
||||
compatibleFingerprints,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6534,7 +6578,8 @@ export function resolveTaskSessionConfigFreshness(input: {
|
||||
);
|
||||
} else if (
|
||||
storedConfig &&
|
||||
storedConfig.fingerprint !== input.configMetadata.fingerprint
|
||||
storedConfig.fingerprint !== input.configMetadata.fingerprint &&
|
||||
!input.configMetadata.compatibleFingerprints.includes(storedConfig.fingerprint)
|
||||
) {
|
||||
changedCategories = changedEffectiveRunSessionConfigCategories({
|
||||
previous: storedConfig.categoryFingerprints,
|
||||
@@ -18849,6 +18894,15 @@ export function heartbeatService(
|
||||
: (issueRef?.executionWorkspacePreference ?? null),
|
||||
existingExecutionWorkspaceStatus:
|
||||
existingExecutionWorkspace?.status ?? null,
|
||||
// Older per-turn runs recorded a task-owned workspace but left its
|
||||
// reuse preference unset. Recover that binding without interpreting
|
||||
// an explicit workspace choice as an upgrade default. Lease identity
|
||||
// and configuration freshness still gate the actual restoration.
|
||||
reuseDefaultSandboxWorkspace:
|
||||
shouldBindReusableSandboxWorkspace(selectedEnvironmentForConfig) &&
|
||||
existingExecutionWorkspace?.companyId === agent.companyId &&
|
||||
existingExecutionWorkspace?.sourceIssueId === issueId &&
|
||||
existingExecutionWorkspace?.projectId === issueRef?.projectId,
|
||||
});
|
||||
const requestedShouldReuseExisting =
|
||||
workspaceReuseRequest.requestedShouldReuseExisting;
|
||||
@@ -19415,10 +19469,8 @@ export function heartbeatService(
|
||||
issueRef?.executionWorkspacePreference ?? null;
|
||||
let issueExecutionWorkspaceModeForRun =
|
||||
issueExecutionWorkspaceSettings?.mode ?? null;
|
||||
const warmReusableExecutionWorkspace =
|
||||
selectedEnvironmentForConfig?.driver === "sandbox" &&
|
||||
selectedEnvironmentConfigForFingerprint.reuseLease === true &&
|
||||
selectedEnvironmentConfigForFingerprint.runnerLifecycleMode === "warm";
|
||||
const reusableSandboxExecutionWorkspace =
|
||||
shouldBindReusableSandboxWorkspace(selectedEnvironmentForConfig);
|
||||
const bindIssueToPersistedExecutionWorkspace = async (
|
||||
workspace: ExecutionWorkspace | null,
|
||||
) => {
|
||||
@@ -19432,7 +19484,7 @@ export function heartbeatService(
|
||||
issueRef?.executionWorkspacePreference === "reuse_existing" ||
|
||||
requestedExecutionWorkspaceMode === "isolated_workspace" ||
|
||||
requestedExecutionWorkspaceMode === "operator_branch" ||
|
||||
warmReusableExecutionWorkspace;
|
||||
reusableSandboxExecutionWorkspace;
|
||||
const nextIssuePatch: Record<string, unknown> = {};
|
||||
if (issueExecutionWorkspaceIdForRun !== workspace.id) {
|
||||
nextIssuePatch.executionWorkspaceId = workspace.id;
|
||||
@@ -19455,8 +19507,8 @@ export function heartbeatService(
|
||||
};
|
||||
}
|
||||
if (Object.keys(nextIssuePatch).length > 0) {
|
||||
if (warmReusableExecutionWorkspace && !isolatedWorkspacesEnabled) {
|
||||
await measureSandboxOperation("heartbeat.bind_warm_sandbox_workspace", { operationIndex: 58 }, async () => (bindWarmSandboxWorkspace(db, {
|
||||
if (reusableSandboxExecutionWorkspace && !isolatedWorkspacesEnabled) {
|
||||
await measureSandboxOperation("heartbeat.bind_reusable_sandbox_workspace", { operationIndex: 58 }, async () => (bindReusableSandboxWorkspace(db, {
|
||||
companyId: agent.companyId, issueId, runId: run.id, agentId: agent.id, workspaceId: workspace.id,
|
||||
})));
|
||||
} else {
|
||||
@@ -20037,6 +20089,24 @@ export function heartbeatService(
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(heartbeatRuns.id, run.id))));
|
||||
if (taskSessionForRun?.lastRunId && previousSessionParams && issueId
|
||||
&& !explicitResumeSessionParams && !explicitResumeSessionDisplayId
|
||||
&& ["codex_local", "claude_local"].includes(agent.adapterType)
|
||||
&& executionTarget?.kind === "remote" && executionTarget.transport === "sandbox"
|
||||
&& executionTarget.sandboxLeaseAcquisition?.outcome === "resumed"
|
||||
&& !parseObject(previousSessionParams.remoteExecution).providerLeaseId) {
|
||||
const previousRun = await db.select().from(heartbeatRuns).where(and(
|
||||
eq(heartbeatRuns.id, taskSessionForRun.lastRunId),
|
||||
eq(heartbeatRuns.companyId, agent.companyId),
|
||||
eq(heartbeatRuns.agentId, agent.id),
|
||||
)).then((rows) => rows[0] ?? null);
|
||||
previousSessionParams = recoverLegacySandboxSession({
|
||||
adapterType: agent.adapterType, params: previousSessionParams, target: executionTarget,
|
||||
companyId: agent.companyId, agentId: agent.id, taskId: issueId,
|
||||
responsibleUserId: run.responsibleUserId ?? null,
|
||||
executionWorkspaceId: persistedExecutionWorkspace?.id ?? null, previousRun,
|
||||
});
|
||||
}
|
||||
const runtimeSessionResolution = resolveRuntimeSessionParamsForWorkspace({
|
||||
agentId: agent.id,
|
||||
previousSessionParams,
|
||||
@@ -20600,6 +20670,7 @@ export function heartbeatService(
|
||||
workspaceId: persistedExecutionWorkspace?.id ?? null,
|
||||
});
|
||||
let nativeExecution: NativeExecutionInput | null = null;
|
||||
let freshNativeSessionAuthority: { runId: string; normalizedSessionId: string } | undefined;
|
||||
let nativeRunnerInstanceId: string | null = null;
|
||||
if (nativeRuntimeResolution.kind === "native") {
|
||||
if (!issueRef) {
|
||||
@@ -20977,6 +21048,13 @@ export function heartbeatService(
|
||||
}
|
||||
}
|
||||
}
|
||||
// A retained physical sandbox may host a newly minted conversation
|
||||
// after an intentional reset. Resume and restart paths never receive
|
||||
// this authority; the executor also requires an absent session root.
|
||||
if (!persistedNativeExecutionInput && !run.nativeSessionId &&
|
||||
nativeSessionId !== resumableTaskSessionId) {
|
||||
freshNativeSessionAuthority = { runId: run.id, normalizedSessionId: nativeSessionId };
|
||||
}
|
||||
const nativeSandboxLifecycle = resolveNativeSandboxLifecycle({
|
||||
adapterType: agent.adapterType,
|
||||
lifecyclePolicy: nativeExecution.session.lifecyclePolicy,
|
||||
@@ -21548,6 +21626,7 @@ export function heartbeatService(
|
||||
runnerInstanceId: nativeRunnerInstanceId,
|
||||
leaseOwner: runOptions.nativeLeaseOwner,
|
||||
restartRecovery: runOptions.nativeRestartRecovery,
|
||||
freshSessionAuthority: freshNativeSessionAuthority,
|
||||
backend:
|
||||
options.nativeSessionBackendFactory?.(nativeExecution),
|
||||
useRunnerd: agent.adapterType === "paperclip_runner",
|
||||
@@ -23081,7 +23160,7 @@ export function heartbeatService(
|
||||
}
|
||||
if (workFolderSaveFailed && workFolderLeaseId) {
|
||||
const unsavedLeaseId = workFolderLeaseId;
|
||||
await measureSandboxOperation("heartbeat.retain_unsaved_work_folder_lease.catch", { operationIndex: 250 }, async () => (retainUnsavedWorkFolderLease(db, { id: unsavedLeaseId, companyId: run.companyId }).catch((error) => {
|
||||
await measureSandboxOperation("heartbeat.retain_unsaved_work_folder_lease.catch", { operationIndex: 250 }, async () => (retainUnsavedWorkFolderLease(db, { id: unsavedLeaseId, companyId: run.companyId }, { runSaveFailed: true }).catch((error) => {
|
||||
logger.error({ err: error, runId: run.id }, "Could not record work folder retention; lease remains active");
|
||||
})));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
adapterExecutionTargetSessionIdentity,
|
||||
type AdapterExecutionTarget,
|
||||
} from "@paperclipai/adapter-utils/execution-target";
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
/** Recover metadata discarded by old CLI codecs only from host-owned run evidence. */
|
||||
export function recoverLegacySandboxSession(input: {
|
||||
adapterType: string;
|
||||
params: Record<string, unknown> | null;
|
||||
target: AdapterExecutionTarget | null;
|
||||
companyId: string;
|
||||
agentId: string;
|
||||
taskId: string;
|
||||
responsibleUserId: string | null;
|
||||
executionWorkspaceId: string | null;
|
||||
previousRun: {
|
||||
companyId: string;
|
||||
agentId: string;
|
||||
responsibleUserId: string | null;
|
||||
status: string;
|
||||
sessionIdAfter: string | null;
|
||||
contextSnapshot: unknown;
|
||||
} | null;
|
||||
}): Record<string, unknown> | null {
|
||||
const { params, target, previousRun: previous } = input;
|
||||
if (!params || !previous || !["codex_local", "claude_local"].includes(input.adapterType)
|
||||
|| target?.kind !== "remote" || target.transport !== "sandbox"
|
||||
|| target.sandboxLeaseAcquisition?.outcome !== "resumed") return params;
|
||||
const saved = record(params.remoteExecution);
|
||||
if (saved.providerLeaseId || (params.remoteExecution != null && saved.transport !== "sandbox")) return params;
|
||||
const context = record(previous.contextSnapshot);
|
||||
const environment = record(context.paperclipEnvironment);
|
||||
const realization = record(environment.workspaceRealization);
|
||||
if (previous.status !== "succeeded"
|
||||
|| previous.companyId !== input.companyId || previous.agentId !== input.agentId
|
||||
|| previous.responsibleUserId !== input.responsibleUserId
|
||||
|| !params.sessionId || previous.sessionIdAfter !== params.sessionId
|
||||
|| context.taskId !== input.taskId
|
||||
|| !input.executionWorkspaceId || context.executionWorkspaceId !== input.executionWorkspaceId
|
||||
|| environment.driver !== "sandbox" || environment.id !== target.environmentId
|
||||
|| realization.provider !== target.providerKey
|
||||
|| realization.providerLeaseId !== target.sandboxLeaseAcquisition.providerLeaseId
|
||||
|| params.cwd !== target.remoteCwd || environment.remoteCwd !== params.cwd
|
||||
|| (params.remoteExecution != null && (
|
||||
saved.leaseId !== environment.leaseId || saved.environmentId !== environment.id
|
||||
|| saved.providerKey !== target.providerKey || saved.remoteCwd !== params.cwd
|
||||
))) return params;
|
||||
return {
|
||||
...params, remoteExecution: adapterExecutionTargetSessionIdentity(target),
|
||||
...(input.adapterType === "claude_local" && !params.mcpServerIdentity
|
||||
? { legacyPlatformMcpSession: true } : {}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdtemp, mkdir, readFile, rm, symlink, writeFile, stat } from "node:fs/promises";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { CommandManagedRuntimeRunner } from "@paperclipai/adapter-utils/command-managed-runtime";
|
||||
import { claimFreshNativeSandboxSession } from "./fresh-native-sandbox-session.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
afterEach(async () => { await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))); });
|
||||
async function fixture() {
|
||||
const root = await mkdtemp(join(tmpdir(), "native fresh ' session-"));
|
||||
roots.push(root);
|
||||
const execute = vi.fn<CommandManagedRuntimeRunner["execute"]>(async input => {
|
||||
const result = spawnSync(input.command, input.args ?? [], { encoding: "utf8" });
|
||||
return { pid: result.pid, startedAt: new Date().toISOString(), exitCode: result.status, signal: null, timedOut: false, stdout: result.stdout, stderr: result.stderr };
|
||||
});
|
||||
return {
|
||||
authority: { runId: "run-new", normalizedSessionId: "session-new" },
|
||||
runId: "run-new", normalizedSessionId: "session-new", hasPriorState: false,
|
||||
runner: { execute }, sessionRoot: join(root, "sessions", "new"),
|
||||
};
|
||||
}
|
||||
|
||||
describe("fresh native session on a retained sandbox", () => {
|
||||
it("claims a separate private directory without changing old conversation or task files", async () => {
|
||||
const input = await fixture();
|
||||
const old = join(input.sessionRoot, "..", "old");
|
||||
await mkdir(old, { recursive: true });
|
||||
await writeFile(join(old, "conversation"), "keep conversation");
|
||||
await claimFreshNativeSandboxSession(input);
|
||||
expect((await stat(input.sessionRoot)).mode & 0o777).toBe(0o700);
|
||||
expect(await readFile(join(old, "conversation"), "utf8")).toBe("keep conversation");
|
||||
await expect(claimFreshNativeSandboxSession(input)).rejects.toThrow("runner_harness_state_mismatch");
|
||||
});
|
||||
|
||||
it.each(["missing authority", "other run", "other session", "prior state"])("refuses %s before touching the sandbox", async scenario => {
|
||||
const input = await fixture();
|
||||
const authority = scenario === "missing authority" ? undefined : {
|
||||
runId: scenario === "other run" ? "old-run" : input.runId,
|
||||
normalizedSessionId: scenario === "other session" ? "old-session" : input.normalizedSessionId,
|
||||
};
|
||||
await expect(claimFreshNativeSandboxSession({ ...input, authority, hasPriorState: scenario === "prior state" }))
|
||||
.rejects.toThrow("runner_harness_state_mismatch");
|
||||
expect(input.runner.execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["partial directory", "file", "symlink"])("preserves an existing %s", async scenario => {
|
||||
const input = await fixture();
|
||||
await mkdir(join(input.sessionRoot, ".."), { recursive: true });
|
||||
if (scenario === "partial directory") {
|
||||
await mkdir(input.sessionRoot);
|
||||
await writeFile(join(input.sessionRoot, "partial"), "keep");
|
||||
} else if (scenario === "file") await writeFile(input.sessionRoot, "keep");
|
||||
else await symlink("missing-target", input.sessionRoot);
|
||||
await expect(claimFreshNativeSandboxSession(input)).rejects.toThrow("runner_harness_state_mismatch");
|
||||
if (scenario === "partial directory") expect(await readFile(join(input.sessionRoot, "partial"), "utf8")).toBe("keep");
|
||||
if (scenario === "file") expect(await readFile(input.sessionRoot, "utf8")).toBe("keep");
|
||||
});
|
||||
|
||||
it.each(["timeout", "transport failure"])("fails closed on %s", async scenario => {
|
||||
const input = await fixture();
|
||||
if (scenario === "timeout") input.runner.execute.mockResolvedValue({ pid: null, startedAt: new Date().toISOString(), exitCode: 0, signal: null, timedOut: true, stdout: "", stderr: "" });
|
||||
else input.runner.execute.mockRejectedValue(new Error("transport unavailable"));
|
||||
await expect(claimFreshNativeSandboxSession(input)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { posix } from "node:path";
|
||||
import type { CommandManagedRuntimeRunner } from "@paperclipai/adapter-utils/command-managed-runtime";
|
||||
|
||||
/** Internal dispatch authority, never taken from adapter configuration or model input. */
|
||||
export interface FreshNativeSessionAuthority {
|
||||
runId: string;
|
||||
normalizedSessionId: string;
|
||||
}
|
||||
|
||||
export async function claimFreshNativeSandboxSession(input: {
|
||||
authority?: FreshNativeSessionAuthority;
|
||||
runId: string;
|
||||
normalizedSessionId: string;
|
||||
hasPriorState: boolean;
|
||||
runner: CommandManagedRuntimeRunner;
|
||||
sessionRoot: string;
|
||||
}): Promise<void> {
|
||||
if (input.hasPriorState || !input.authority ||
|
||||
input.authority.runId !== input.runId ||
|
||||
input.authority.normalizedSessionId !== input.normalizedSessionId) {
|
||||
throw new Error("runner_harness_state_mismatch");
|
||||
}
|
||||
const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
|
||||
// mkdir without -p atomically proves the new session has no existing state,
|
||||
// including an incomplete bootstrap or a symlink. Never clear an old root.
|
||||
const claimed = await input.runner.execute({
|
||||
command: "sh",
|
||||
args: ["-c", `umask 077; mkdir -p ${quote(posix.dirname(input.sessionRoot))} && mkdir ${quote(input.sessionRoot)}`],
|
||||
bypassSession: true,
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
if (claimed.exitCode !== 0 || claimed.timedOut) {
|
||||
throw new Error("runner_harness_state_mismatch");
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { claimFreshNativeSandboxSession, type FreshNativeSessionAuthority } from "./fresh-native-sandbox-session.js";
|
||||
import { hasSandboxPerformanceTrace } from "../sandbox-performance.js";
|
||||
import {
|
||||
adoptVerifiedRemoteRunner,
|
||||
@@ -3727,6 +3728,8 @@ export async function executePaperclipNativeSession(input: {
|
||||
runnerInstanceId: string;
|
||||
leaseOwner?: string;
|
||||
restartRecovery?: NativeRestartRecoveryClaim;
|
||||
/** Host-issued only when this dispatch minted a new logical session. */
|
||||
freshSessionAuthority?: FreshNativeSessionAuthority;
|
||||
onSpawn?: (meta: {
|
||||
pid: number;
|
||||
processGroupId: number | null;
|
||||
@@ -6414,6 +6417,8 @@ export async function createRunnerdBackend(input: {
|
||||
execution: NativeExecutionInput;
|
||||
runnerInstanceId: string;
|
||||
restartRecovery?: NativeRestartRecoveryClaim;
|
||||
/** Host-issued only when this dispatch minted a new logical session. */
|
||||
freshSessionAuthority?: FreshNativeSessionAuthority;
|
||||
durableEnvironmentLeaseId?: string;
|
||||
onSpawn?: (meta: {
|
||||
pid: number;
|
||||
@@ -7494,12 +7499,20 @@ async function createRunnerdBackendWithinSessionClaim(
|
||||
!state.runnerState ||
|
||||
!state.providerSessionIdentity
|
||||
) {
|
||||
throw new Error("runner_harness_state_mismatch");
|
||||
await claimFreshNativeSandboxSession({
|
||||
authority: input.freshSessionAuthority,
|
||||
runId: input.execution.binding.runId,
|
||||
normalizedSessionId: nativeSessionKey(input.execution),
|
||||
hasPriorState: Boolean(durableBinding) || backupAvailable || Boolean(input.restartRecovery),
|
||||
runner: remoteCommandRunner,
|
||||
sessionRoot: remoteSessionRoot!,
|
||||
});
|
||||
} else {
|
||||
await recordInPlaceHarnessReuse(
|
||||
state.providerSessionIdentity,
|
||||
reuseStartedAtMs,
|
||||
);
|
||||
}
|
||||
await recordInPlaceHarnessReuse(
|
||||
state.providerSessionIdentity,
|
||||
reuseStartedAtMs,
|
||||
);
|
||||
} else if (
|
||||
sandboxLeaseAcquisition?.outcome === "replacement"
|
||||
) {
|
||||
|
||||
@@ -3,8 +3,21 @@ import { executionWorkspaces, heartbeatRuns, issues, type Db } from "@paperclipa
|
||||
import { logActivity, publishActivity, type ActivityPublication } from "./activity-log.js";
|
||||
import { issueExecutionWorkspaceModeForPersistedWorkspace } from "./execution-workspace-policy.js";
|
||||
|
||||
/** Sandbox reuse is independent of whether the provider process stays warm. */
|
||||
export function shouldBindReusableSandboxWorkspace(environment: {
|
||||
driver: string;
|
||||
config: unknown;
|
||||
} | null | undefined): boolean {
|
||||
const config = environment?.config;
|
||||
return environment?.driver === "sandbox"
|
||||
&& typeof config === "object"
|
||||
&& config !== null
|
||||
&& !Array.isArray(config)
|
||||
&& (config as Record<string, unknown>).reuseLease === true;
|
||||
}
|
||||
|
||||
/** Host runtime state must survive even when user-configurable worktrees are disabled. */
|
||||
export async function bindWarmSandboxWorkspace(db: Db, input: {
|
||||
export async function bindReusableSandboxWorkspace(db: Db, input: {
|
||||
companyId: string; issueId: string; runId: string; agentId: string; workspaceId: string;
|
||||
}) {
|
||||
const publications: ActivityPublication[] = [];
|
||||
@@ -22,7 +35,7 @@ export async function bindWarmSandboxWorkspace(db: Db, input: {
|
||||
)).for("update");
|
||||
if (!issue || !run || !workspace || workspace.projectId !== issue.projectId || workspace.status !== "active"
|
||||
|| (workspace.sourceIssueId !== null && workspace.sourceIssueId !== issue.id)) {
|
||||
throw new Error("Warm sandbox workspace no longer belongs to this active task run");
|
||||
throw new Error("Reusable sandbox workspace no longer belongs to this active task run");
|
||||
}
|
||||
await tx.update(issues).set({
|
||||
executionWorkspaceId: workspace.id, executionWorkspacePreference: "reuse_existing",
|
||||
@@ -37,7 +50,7 @@ export async function bindWarmSandboxWorkspace(db: Db, input: {
|
||||
companyId: input.companyId, actorType: "agent", actorId: input.agentId, agentId: input.agentId,
|
||||
runId: input.runId, issueId: issue.id, action: "execution_workspace.sandbox_bound",
|
||||
entityType: "execution_workspace", entityId: workspace.id,
|
||||
details: { issueId: issue.id, reason: "warm_sandbox_reuse" },
|
||||
details: { issueId: issue.id, reason: "sandbox_reuse" },
|
||||
}, publications);
|
||||
});
|
||||
for (const publication of publications) publishActivity(publication);
|
||||
|
||||
@@ -11,7 +11,7 @@ export function workFolderSandboxKey(lease: { id: string; companyId: string; env
|
||||
* A transferred old lease is protected without mutating or resurrecting its row.
|
||||
* A periodic checkpoint is not permission to discard edits made after it.
|
||||
*/
|
||||
export async function retainUnsavedWorkFolderLease(db: Db, lease: { id: string; companyId: string }) {
|
||||
export async function retainUnsavedWorkFolderLease(db: Db, lease: { id: string; companyId: string }, options: { runSaveFailed?: boolean } = {}) {
|
||||
const [row] = await db.select().from(environmentLeases).where(and(eq(environmentLeases.id, lease.id), eq(environmentLeases.companyId, lease.companyId)));
|
||||
if (!row) return false;
|
||||
// This old row no longer owns the physical resource. Protect it from stale
|
||||
@@ -22,7 +22,9 @@ export async function retainUnsavedWorkFolderLease(db: Db, lease: { id: string;
|
||||
.from(workFolderRuns).where(and(eq(workFolderRuns.companyId, lease.companyId),
|
||||
sql`(${workFolderRuns.manifest}->>'sandboxKey' = ${sandboxKey} or ${workFolderRuns.manifest}->>'leaseId' = ${lease.id})`))
|
||||
.orderBy(desc(workFolderRuns.updatedAt)).limit(1);
|
||||
if (!run || (run.state === "saved" && run.manifest.finalCheckpointAt)) return false;
|
||||
// A startup failure can precede this run's manifest. The previous run's final
|
||||
// save cannot attest that partial hydration or the current working copy is safe.
|
||||
if (!options.runSaveFailed && (!run || (run.state === "saved" && run.manifest.finalCheckpointAt))) return false;
|
||||
await db.update(environmentLeases).set({ status: "retained", expiresAt: null,
|
||||
failureReason: "work_folder_save_required", cleanupStatus: "failed",
|
||||
metadata: sql`coalesce(${environmentLeases.metadata}, '{}'::jsonb) || '{"workFolderRecoveryRequired":true}'::jsonb`,
|
||||
|
||||
Reference in new issue
Block a user