fix: retain warm sandbox bindings independently of worktree settings

Validate live engine coverage and physical sandbox reuse in deployed acceptance.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-09-07 14:56:30 -05:00
1 parent a5d7df55f6
commit e8a6fb02bf
7 files changed
+116 -8

No files matched your search

@@ -6,9 +6,10 @@ import { eq } from "drizzle-orm";
import fs from "node:fs/promises";
import path from "node:path";
import os from "node:os";
import { agents, assets, companyMemberships, issueAttachments, companies, createDb, heartbeatRuns, issues, environments, environmentLeases, projects, projectWorkspaces, taskRepositoryBindings, workFolderObjects, workFolderRuns, startEmbeddedPostgresTestDatabase, type Db } from "@paperclipai/db";
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 { retainUnsavedWorkFolderLease, workFolderSandboxKey } from "../services/work-folder-retention.js";
import * as activityLog from "../services/activity-log.js";
import { workFolderService } from "../services/work-folders.js";
@@ -49,6 +50,25 @@ describe("shared sandbox work-folder lifecycle", () => {
for (const run of active) await run.stop().catch(() => {});
await database?.cleanup(); if (root) await fs.rm(root, { recursive: true, force: true });
});
it("keeps the host's warm task binding without enabling user-configurable worktrees", async () => {
const task = randomUUID(), runId = randomUUID(), workspaceId = randomUUID();
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "running" });
await db.insert(issues).values({ id: task, companyId, projectId, title: "Warm binding", assigneeAgentId: agentId, executionRunId: runId });
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);
const [bound] = await db.select().from(issues).where(eq(issues.id, task));
expect(bound).toMatchObject({ executionWorkspaceId: workspaceId, executionWorkspacePreference: "reuse_existing", executionWorkspaceSettings: null });
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));
await db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, runId));
await expect(bindWarmSandboxWorkspace(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 } = {}) {
await fs.mkdir(home, { recursive: true });
+8 -1
View File
@@ -1,6 +1,7 @@
import fs from "node:fs/promises";
import { retainUnsavedWorkFolderLease, workFolderSandboxKey } from "./work-folder-retention.js";
import { prepareSandboxWorkFolders } from "./sandbox-work-folders.js";
import { bindWarmSandboxWorkspace } from "./sandbox-workspace-binding.js";
import path from "node:path";
import { execFile as execFileCallback } from "node:child_process";
import { promisify } from "node:util";
@@ -19362,7 +19363,13 @@ export function heartbeatService(
};
}
if (Object.keys(nextIssuePatch).length > 0) {
await issuesSvc.update(issueId, nextIssuePatch);
if (warmReusableExecutionWorkspace && !isolatedWorkspacesEnabled) {
await bindWarmSandboxWorkspace(db, {
companyId: agent.companyId, issueId, runId: run.id, agentId: agent.id, workspaceId: workspace.id,
});
} else {
await issuesSvc.update(issueId, nextIssuePatch);
}
issueExecutionWorkspaceIdForRun = workspace.id;
issueProjectWorkspaceIdForRun =
resolvedProjectWorkspaceId ?? issueProjectWorkspaceIdForRun;
@@ -0,0 +1,39 @@
import { and, eq } from "drizzle-orm";
import { executionWorkspaces, heartbeatRuns, issues, type Db } from "@paperclipai/db";
import { logActivity, publishActivity, type ActivityPublication } from "./activity-log.js";
/** Host runtime state must survive even when user-configurable worktrees are disabled. */
export async function bindWarmSandboxWorkspace(db: Db, input: {
companyId: string; issueId: string; runId: string; agentId: string; workspaceId: string;
}) {
const publications: ActivityPublication[] = [];
await db.transaction(async (tx) => {
const [issue] = await tx.select().from(issues).where(and(
eq(issues.id, input.issueId), eq(issues.companyId, input.companyId),
eq(issues.executionRunId, input.runId),
)).for("update");
const [workspace] = await tx.select().from(executionWorkspaces).where(and(
eq(executionWorkspaces.id, input.workspaceId), eq(executionWorkspaces.companyId, input.companyId),
));
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"),
));
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",
...(workspace.projectWorkspaceId ? { projectWorkspaceId: workspace.projectWorkspaceId } : {}),
updatedAt: new Date(),
}).where(eq(issues.id, issue.id));
await logActivity(tx as unknown as Db, {
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" },
}, publications);
});
for (const publication of publications) publishActivity(publication);
}