Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 134 additions & 3 deletions server/src/__tests__/environment-run-orchestrator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,10 @@ function makeLease(overrides: Partial<EnvironmentLease> = {}): EnvironmentLease
};
}

function makeExecutionWorkspace(cwd: string = "/workspace/project"): RealizedExecutionWorkspace {
function makeExecutionWorkspace(
cwd: string = "/workspace/project",
overrides: Partial<RealizedExecutionWorkspace> = {},
): RealizedExecutionWorkspace {
return {
baseCwd: "/workspace",
source: "project_primary",
Expand All @@ -113,7 +116,8 @@ function makeExecutionWorkspace(cwd: string = "/workspace/project"): RealizedExe
branchName: null,
worktreePath: null,
warnings: [],
created: false,
created: true,
...overrides,
};
}

Expand Down Expand Up @@ -154,6 +158,7 @@ function makeRealizeInput(overrides: {
environment?: Environment;
lease?: EnvironmentLease;
persistedExecutionWorkspace?: ExecutionWorkspace | null;
executionWorkspace?: RealizedExecutionWorkspace;
} = {}): Parameters<ReturnType<typeof environmentRunOrchestrator>["realizeForRun"]>[0] {
return {
environment: overrides.environment ?? makeEnvironment("local"),
Expand All @@ -162,7 +167,7 @@ function makeRealizeInput(overrides: {
companyId: "company-1",
issueId: null,
heartbeatRunId: "run-1",
executionWorkspace: makeExecutionWorkspace(),
executionWorkspace: overrides.executionWorkspace ?? makeExecutionWorkspace(),
effectiveExecutionWorkspaceMode: null,
persistedExecutionWorkspace: overrides.persistedExecutionWorkspace !== undefined
? overrides.persistedExecutionWorkspace
Expand Down Expand Up @@ -550,6 +555,132 @@ describe("environmentRunOrchestrator — realizeForRun", () => {
expect(mockResolveEnvironmentExecutionTarget).toHaveBeenCalledOnce();
});

it("skips remote provision command when reusing an existing isolated worktree workspace", async () => {
mockBuildWorkspaceRealizationRequest.mockReturnValue({
version: 1,
adapterType: "claude_local",
companyId: "company-1",
environmentId: "env-1",
executionWorkspaceId: null,
issueId: null,
heartbeatRunId: "run-1",
requestedMode: null,
source: {
kind: "task_session",
localPath: "/workspace/worktrees/issue-1",
projectId: null,
projectWorkspaceId: null,
repoUrl: null,
repoRef: null,
strategy: "git_worktree",
branchName: "issue-1",
worktreePath: "/workspace/worktrees/issue-1",
},
runtimeOverlay: {
provisionCommand: "npm install -g @anthropic-ai/claude-code",
},
});
mockResolveEnvironmentExecutionTarget.mockResolvedValue({
kind: "remote",
transport: "sandbox",
providerKey: "e2b",
remoteCwd: "/remote/workspace",
environmentId: "env-1",
leaseId: "lease-1",
});

const runtime = makeMockRuntime({
realizeWorkspace: vi.fn().mockResolvedValue({
cwd: "/remote/workspace",
metadata: {
workspaceRealization: {
version: 1,
transport: "sandbox",
remote: { path: "/remote/workspace" },
isNew: false,
},
},
}),
});
const orchestrator = environmentRunOrchestrator(mockDb, { environmentRuntime: runtime });

await orchestrator.realizeForRun(makeRealizeInput({
environment: makeEnvironment("sandbox"),
executionWorkspace: makeExecutionWorkspace("/workspace/worktrees/issue-1", {
strategy: "git_worktree",
branchName: "issue-1",
worktreePath: "/workspace/worktrees/issue-1",
created: false,
}),
}));

expect(runtime.execute).not.toHaveBeenCalled();
});

it("still runs the remote provision command for a shared project_primary workspace even though its local directory is never reported as freshly \"created\"", async () => {
// `project_primary` (shared workspace) realizations always report `created: false` for the
// local directory (see workspace-runtime.ts) because it is the project's long-lived primary
// checkout rather than something freshly created per run. The reuse-skip gate must not key off
// that flag for this strategy, or shared-workspace remote/sandbox provisioning would silently
// never run again — not even on the very first run.
mockBuildWorkspaceRealizationRequest.mockReturnValue({
version: 1,
adapterType: "claude_local",
companyId: "company-1",
environmentId: "env-1",
executionWorkspaceId: null,
issueId: null,
heartbeatRunId: "run-1",
requestedMode: null,
source: {
kind: "project_primary",
localPath: "/workspace/project",
projectId: null,
projectWorkspaceId: null,
repoUrl: null,
repoRef: null,
strategy: "project_primary",
branchName: null,
worktreePath: null,
},
runtimeOverlay: {
provisionCommand: "npm install -g @anthropic-ai/claude-code",
},
});
mockResolveEnvironmentExecutionTarget.mockResolvedValue({
kind: "remote",
transport: "sandbox",
providerKey: "e2b",
remoteCwd: "/remote/workspace",
environmentId: "env-1",
leaseId: "lease-1",
});

const runtime = makeMockRuntime({
realizeWorkspace: vi.fn().mockResolvedValue({
cwd: "/remote/workspace",
metadata: {
workspaceRealization: {
version: 1,
transport: "sandbox",
remote: { path: "/remote/workspace" },
},
},
}),
});
const orchestrator = environmentRunOrchestrator(mockDb, { environmentRuntime: runtime });

await orchestrator.realizeForRun(makeRealizeInput({
environment: makeEnvironment("sandbox"),
executionWorkspace: makeExecutionWorkspace("/workspace/project", {
strategy: "project_primary",
created: false,
}),
}));

expect(runtime.execute).toHaveBeenCalledOnce();
});

it("surfaces remote provision command failures before resolving the adapter target", async () => {
mockBuildWorkspaceRealizationRequest.mockReturnValue({
version: 1,
Expand Down
16 changes: 9 additions & 7 deletions server/src/__tests__/workspace-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1204,6 +1204,8 @@ describe("realizeExecutionWorkspace", () => {
"true\n",
);

await fs.rm(path.join(workspace.cwd, ".paperclip-provision-created"));

const reused = await realizeExecutionWorkspace({
base: {
baseCwd: repoRoot,
Expand Down Expand Up @@ -1232,10 +1234,11 @@ describe("realizeExecutionWorkspace", () => {
},
});

await expect(fs.readFile(path.join(reused.cwd, ".paperclip-provision-created"), "utf8")).resolves.toBe("false\n");
expect(reused.created).toBe(false);
await expect(fs.access(path.join(reused.cwd, ".paperclip-provision-created"))).rejects.toThrow();
});

it("uses the latest repo-managed provision script when reusing an existing worktree", async () => {
it("skips running the provision script when reusing an existing worktree", async () => {
const repoRoot = await createTempRepo();
await fs.mkdir(path.join(repoRoot, "scripts"), { recursive: true });
await fs.writeFile(
Expand Down Expand Up @@ -1292,8 +1295,6 @@ describe("realizeExecutionWorkspace", () => {
await runGit(repoRoot, ["add", "scripts/provision.sh"]);
await runGit(repoRoot, ["commit", "-m", "Update provision script"]);

await expect(fs.readFile(path.join(initial.cwd, "scripts", "provision.sh"), "utf8")).resolves.toContain("v1");

const reused = await realizeExecutionWorkspace({
base: {
baseCwd: repoRoot,
Expand Down Expand Up @@ -1322,7 +1323,8 @@ describe("realizeExecutionWorkspace", () => {
},
});

await expect(fs.readFile(path.join(reused.cwd, ".paperclip-provision-version"), "utf8")).resolves.toBe("v2\n");
expect(reused.created).toBe(false);
await expect(fs.readFile(path.join(reused.cwd, ".paperclip-provision-version"), "utf8")).resolves.toBe("v1\n");
}, 30_000);

it("writes an isolated repo-local Paperclip config and worktree branding when provisioning", async () => {
Expand Down Expand Up @@ -3043,7 +3045,7 @@ describe("realizeExecutionWorkspace", () => {
expect(restored).toBeNull();
});

it("reprovisions an existing persisted git worktree before manual control starts it", async () => {
it("does not reprovision an existing persisted git worktree that is reused before manual control starts it", async () => {
const repoRoot = await createTempRepo();
await fs.mkdir(path.join(repoRoot, "scripts"), { recursive: true });
await fs.writeFile(
Expand Down Expand Up @@ -3124,7 +3126,7 @@ describe("realizeExecutionWorkspace", () => {
},
});

await expect(fs.readFile(path.join(initial.cwd, ".paperclip-restored-state"), "utf8")).resolves.toBe("reprovisioned\n");
await expect(fs.access(path.join(initial.cwd, ".paperclip-restored-state"))).rejects.toThrow();
}, 15_000);

it("auto-detects the default branch when baseRef is not configured", async () => {
Expand Down
29 changes: 28 additions & 1 deletion server/src/services/environment-run-orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -420,7 +420,21 @@ export function environmentRunOrchestrator(
(typeof lease.metadata?.remoteCwd === "string" && lease.metadata.remoteCwd.trim().length > 0
? lease.metadata.remoteCwd.trim()
: executionWorkspace.cwd);
if (provisionCommand && environment.driver !== "local") {
// `executionWorkspace.created` only carries a meaningful reuse signal for the
// `git_worktree` strategy (isolated per-issue worktrees, which are explicitly
// created or reused). For `project_primary` (shared workspace) realizations the
// local directory always reports `created: false` — it is the project's
// long-lived primary checkout, not something freshly created per run — so gating
// on that flag would silently skip provisioning forever for every shared-workspace
// remote/sandbox environment, including the very first run. Treat non-worktree
// strategies as always requiring provisioning, matching prior behavior for that
// strategy, and only apply the reuse skip where "created" is well-defined.
const isNew =
executionWorkspace.strategy !== "git_worktree" ||
executionWorkspace.created === true ||
workspaceRealization.isNew === true ||
workspaceRealization.created === true;
if (provisionCommand && environment.driver !== "local" && isNew) {
try {
const provisionResult = await environmentRuntime.execute({
environment,
Expand Down Expand Up @@ -456,6 +470,19 @@ export function environmentRunOrchestrator(
}
}

// Record the provisioning decision on the realization metadata so operators viewing
// run/workspace details can tell whether a workspace was freshly provisioned or reused
// without the provision command re-running.
if (provisionCommand && environment.driver !== "local") {
workspaceRealization = {
...workspaceRealization,
provisioning: {
ran: isNew,
reason: isNew ? "new_workspace" : "reused_existing_workspace",
},
};
}

// Step 3: Persist realization metadata on lease and execution workspace
if (Object.keys(workspaceRealization).length > 0) {
const nextLeaseMetadata = {
Expand Down
3 changes: 2 additions & 1 deletion server/src/services/workspace-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2638,6 +2638,7 @@ async function provisionExecutionWorktree(input: {
created: boolean;
recorder?: WorkspaceOperationRecorder | null;
}) {
if (!input.created) return;
const provisionCommand = asString(input.strategy.provisionCommand, "").trim();
if (!provisionCommand) return;
const resolvedProvisionCommand = resolveRepoManagedWorkspaceCommand(provisionCommand, input.repoRoot);
Expand Down Expand Up @@ -3135,7 +3136,7 @@ export async function ensurePersistedExecutionWorkspaceAvailable(input: {
: [];
const restoreCurrentBaseRefSha = restoreBaseRef ? await resolveBaseRefSha(repoRoot, restoreBaseRef) : null;

let created = false;
let created = true;
try {
await recordGitOperation(input.recorder, {
phase: "worktree_prepare",
Expand Down
Loading