mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 12:59:13 +01:00
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:
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));
|
||||
|
||||
Reference in new issue
Block a user