fix: serialize warm binding validation with lifecycle transitions

Persist the validated workspace mode and run identical executable acceptance scripts across live engines.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-09-07 15:03:17 -05:00
1 parent 850354d79c
commit aec14bbab8
5 files changed
+123 -28

No files matched your search

@@ -2,7 +2,7 @@ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { createHash, randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { eq, sql } from "drizzle-orm";
import fs from "node:fs/promises";
import path from "node:path";
import os from "node:os";
@@ -59,13 +59,35 @@ describe("shared sandbox work-folder lifecycle", () => {
const input = { companyId, issueId: task, runId, agentId, workspaceId };
await bindWarmSandboxWorkspace(db, input);
const [bound] = await db.select().from(issues).where(eq(issues.id, task));
expect(bound).toMatchObject({ executionWorkspaceId: workspaceId, executionWorkspacePreference: "reuse_existing", executionWorkspaceSettings: null });
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 db.update(executionWorkspaces).set({ sourceIssueId: taskId }).where(eq(executionWorkspaces.id, workspaceId));
await expect(bindWarmSandboxWorkspace(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;
let entered!: () => void;
const gate = new Promise<void>((resolve) => { release = resolve; });
const reached = new Promise<void>((resolve) => { entered = resolve; });
const audit = vi.spyOn(activityLog, "logActivity").mockImplementationOnce(async (...args) => {
entered(); await gate; return originalLogActivity(...args);
});
const pendingBinding = bindWarmSandboxWorkspace(db, input);
try {
await reached;
// These state changes must wait until the validated binding commits.
for (const target of ["run", "workspace"]) {
await expect(db.transaction(async (tx) => {
await tx.execute(sql`set local lock_timeout = '100ms'`);
if (target === "run") await tx.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, runId));
else await tx.update(executionWorkspaces).set({ status: "closed" }).where(eq(executionWorkspaces.id, workspaceId));
})).rejects.toMatchObject({ cause: { code: "55P03" } });
}
} finally {
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");
});
@@ -1,6 +1,7 @@
import { and, eq } from "drizzle-orm";
import { executionWorkspaces, heartbeatRuns, issues, type Db } from "@paperclipai/db";
import { logActivity, publishActivity, type ActivityPublication } from "./activity-log.js";
import { issueExecutionWorkspaceModeForPersistedWorkspace } from "./execution-workspace-policy.js";
/** Host runtime state must survive even when user-configurable worktrees are disabled. */
export async function bindWarmSandboxWorkspace(db: Db, input: {
@@ -14,17 +15,21 @@ export async function bindWarmSandboxWorkspace(db: Db, input: {
)).for("update");
const [workspace] = await tx.select().from(executionWorkspaces).where(and(
eq(executionWorkspaces.id, input.workspaceId), eq(executionWorkspaces.companyId, input.companyId),
));
)).for("update");
const [run] = await tx.select().from(heartbeatRuns).where(and(
eq(heartbeatRuns.id, input.runId), eq(heartbeatRuns.companyId, input.companyId),
eq(heartbeatRuns.agentId, input.agentId), eq(heartbeatRuns.status, "running"),
));
)).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");
}
await tx.update(issues).set({
executionWorkspaceId: workspace.id, executionWorkspacePreference: "reuse_existing",
executionWorkspaceSettings: {
...(issue.executionWorkspaceSettings ?? {}),
mode: issueExecutionWorkspaceModeForPersistedWorkspace(workspace.mode),
},
...(workspace.projectWorkspaceId ? { projectWorkspaceId: workspace.projectWorkspaceId } : {}),
updatedAt: new Date(),
}).where(eq(issues.id, issue.id));