Fix execution workspace reuse and slugify worktrees

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta 2026-03-30 08:26:14 -05:00
parent c610192c53
commit 3c66683169
4 changed files with 176 additions and 46 deletions

View file

@ -4,6 +4,7 @@ import { sessionCodec as codexSessionCodec } from "@paperclipai/adapter-codex-lo
import { resolveDefaultAgentWorkspaceDir } from "../home-paths.js"; import { resolveDefaultAgentWorkspaceDir } from "../home-paths.js";
import { import {
applyPersistedExecutionWorkspaceConfig, applyPersistedExecutionWorkspaceConfig,
buildRealizedExecutionWorkspaceFromPersisted,
buildExplicitResumeSessionOverride, buildExplicitResumeSessionOverride,
formatRuntimeWorkspaceWarningLog, formatRuntimeWorkspaceWarningLog,
prioritizeProjectWorkspaceCandidatesForRun, prioritizeProjectWorkspaceCandidatesForRun,
@ -154,6 +155,51 @@ describe("applyPersistedExecutionWorkspaceConfig", () => {
}); });
}); });
describe("buildRealizedExecutionWorkspaceFromPersisted", () => {
it("reuses the persisted execution workspace path instead of deriving a new worktree", () => {
const result = buildRealizedExecutionWorkspaceFromPersisted({
base: buildResolvedWorkspace({
cwd: "/tmp/project-primary",
repoRef: "main",
}),
workspace: {
id: "execution-workspace-1",
companyId: "company-1",
projectId: "project-1",
projectWorkspaceId: "workspace-1",
sourceIssueId: "issue-1",
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "PAP-880-thumbs-capture-for-evals-feature",
status: "active",
cwd: "/tmp/reused-worktree",
repoUrl: "https://example.com/paperclip.git",
baseRef: "main",
branchName: "PAP-880-thumbs-capture-for-evals-feature",
providerType: "git_worktree",
providerRef: "/tmp/reused-worktree",
derivedFromExecutionWorkspaceId: null,
lastUsedAt: new Date(),
openedAt: new Date(),
closedAt: null,
cleanupEligibleAt: null,
cleanupReason: null,
config: null,
metadata: null,
createdAt: new Date(),
updatedAt: new Date(),
},
});
expect(result.created).toBe(false);
expect(result.strategy).toBe("git_worktree");
expect(result.cwd).toBe("/tmp/reused-worktree");
expect(result.worktreePath).toBe("/tmp/reused-worktree");
expect(result.branchName).toBe("PAP-880-thumbs-capture-for-evals-feature");
expect(result.source).toBe("task_session");
});
});
describe("stripWorkspaceRuntimeFromExecutionRunConfig", () => { describe("stripWorkspaceRuntimeFromExecutionRunConfig", () => {
it("removes workspace runtime before heartbeat execution", () => { it("removes workspace runtime before heartbeat execution", () => {
const input = { const input = {

View file

@ -247,6 +247,43 @@ describe("realizeExecutionWorkspace", () => {
expect(second.branchName).toBe(first.branchName); expect(second.branchName).toBe(first.branchName);
}); });
it("slugifies unsafe issue titles for branch names and worktree folders", async () => {
const repoRoot = await createTempRepo();
const realized = await realizeExecutionWorkspace({
base: {
baseCwd: repoRoot,
source: "project_primary",
projectId: "project-1",
workspaceId: "workspace-1",
repoUrl: null,
repoRef: "HEAD",
},
config: {
workspaceStrategy: {
type: "git_worktree",
branchTemplate: "{{issue.identifier}}-{{slug}}",
},
},
issue: {
id: "issue-unsafe",
identifier: "PAP-991",
title: "there should be a setting for the allowance of thumbs up / thumbs down data; `rm -rf`",
},
agent: {
id: "agent-1",
name: "Codex Coder",
companyId: "company-1",
},
});
expect(realized.branchName).toBe(
"PAP-991-there-should-be-a-setting-for-the-allowance-of-thumbs-up-thumbs-down-data-rm-rf",
);
expect(realized.branchName?.includes("/")).toBe(false);
expect(path.basename(realized.cwd)).toBe(realized.branchName);
});
it("runs a configured provision command inside the derived worktree", async () => { it("runs a configured provision command inside the derived worktree", async () => {
const repoRoot = await createTempRepo(); const repoRoot = await createTempRepo();
await fs.mkdir(path.join(repoRoot, "scripts"), { recursive: true }); await fs.mkdir(path.join(repoRoot, "scripts"), { recursive: true });

View file

@ -4,7 +4,7 @@ import { execFile as execFileCallback } from "node:child_process";
import { promisify } from "node:util"; import { promisify } from "node:util";
import { and, asc, desc, eq, gt, inArray, sql } from "drizzle-orm"; import { and, asc, desc, eq, gt, inArray, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db"; import type { Db } from "@paperclipai/db";
import type { BillingType, ExecutionWorkspaceConfig } from "@paperclipai/shared"; import type { BillingType, ExecutionWorkspace, ExecutionWorkspaceConfig } from "@paperclipai/shared";
import { import {
agents, agents,
agentRuntimeState, agentRuntimeState,
@ -37,6 +37,8 @@ import {
persistAdapterManagedRuntimeServices, persistAdapterManagedRuntimeServices,
realizeExecutionWorkspace, realizeExecutionWorkspace,
releaseRuntimeServicesForRun, releaseRuntimeServicesForRun,
type ExecutionWorkspaceInput,
type RealizedExecutionWorkspace,
sanitizeRuntimeServiceBaseEnv, sanitizeRuntimeServiceBaseEnv,
} from "./workspace-runtime.js"; } from "./workspace-runtime.js";
import { issueService } from "./issues.js"; import { issueService } from "./issues.js";
@ -109,6 +111,32 @@ export function stripWorkspaceRuntimeFromExecutionRunConfig(config: Record<strin
return nextConfig; return nextConfig;
} }
export function buildRealizedExecutionWorkspaceFromPersisted(input: {
base: ExecutionWorkspaceInput;
workspace: ExecutionWorkspace;
}): RealizedExecutionWorkspace {
const cwd = readNonEmptyString(input.workspace.cwd) ?? readNonEmptyString(input.workspace.providerRef);
if (!cwd) {
throw new Error(`Execution workspace ${input.workspace.id} has no local path to reuse.`);
}
const strategy = input.workspace.strategyType === "git_worktree" ? "git_worktree" : "project_primary";
return {
baseCwd: input.base.baseCwd,
source: input.workspace.mode === "shared_workspace" ? "project_primary" : "task_session",
projectId: input.workspace.projectId ?? input.base.projectId,
workspaceId: input.workspace.projectWorkspaceId ?? input.base.workspaceId,
repoUrl: input.workspace.repoUrl ?? input.base.repoUrl,
repoRef: input.workspace.baseRef ?? input.base.repoRef,
strategy,
cwd,
branchName: input.workspace.branchName ?? null,
worktreePath: strategy === "git_worktree" ? (readNonEmptyString(input.workspace.providerRef) ?? cwd) : null,
warnings: [],
created: false,
};
}
function buildExecutionWorkspaceConfigSnapshot(config: Record<string, unknown>): Partial<ExecutionWorkspaceConfig> | null { function buildExecutionWorkspaceConfigSnapshot(config: Record<string, unknown>): Partial<ExecutionWorkspaceConfig> | null {
const strategy = parseObject(config.workspaceStrategy); const strategy = parseObject(config.workspaceStrategy);
const snapshot: Partial<ExecutionWorkspaceConfig> = {}; const snapshot: Partial<ExecutionWorkspaceConfig> = {};
@ -2085,7 +2113,7 @@ export function heartbeatService(db: Db) {
(explicitResumeSessionDisplayId ? { sessionId: explicitResumeSessionDisplayId } : null) ?? (explicitResumeSessionDisplayId ? { sessionId: explicitResumeSessionDisplayId } : null) ??
normalizeSessionParams(sessionCodec.deserialize(taskSessionForRun?.sessionParamsJson ?? null)); normalizeSessionParams(sessionCodec.deserialize(taskSessionForRun?.sessionParamsJson ?? null));
const config = parseObject(agent.adapterConfig); const config = parseObject(agent.adapterConfig);
const executionWorkspaceMode = resolveExecutionWorkspaceMode({ const requestedExecutionWorkspaceMode = resolveExecutionWorkspaceMode({
projectPolicy: projectExecutionWorkspacePolicy, projectPolicy: projectExecutionWorkspacePolicy,
issueSettings: issueExecutionWorkspaceSettings, issueSettings: issueExecutionWorkspaceSettings,
legacyUseProjectWorkspace: issueAssigneeOverrides?.useProjectWorkspace ?? null, legacyUseProjectWorkspace: issueAssigneeOverrides?.useProjectWorkspace ?? null,
@ -2094,15 +2122,8 @@ export function heartbeatService(db: Db) {
agent, agent,
context, context,
previousSessionParams, previousSessionParams,
{ useProjectWorkspace: executionWorkspaceMode !== "agent_default" }, { useProjectWorkspace: requestedExecutionWorkspaceMode !== "agent_default" },
); );
const workspaceManagedConfig = buildExecutionWorkspaceAdapterConfig({
agentConfig: config,
projectPolicy: projectExecutionWorkspacePolicy,
issueSettings: issueExecutionWorkspaceSettings,
mode: executionWorkspaceMode,
legacyUseProjectWorkspace: issueAssigneeOverrides?.useProjectWorkspace ?? null,
});
const issueRef = issueContext const issueRef = issueContext
? { ? {
id: issueContext.id, id: issueContext.id,
@ -2116,10 +2137,32 @@ export function heartbeatService(db: Db) {
: null; : null;
const existingExecutionWorkspace = const existingExecutionWorkspace =
issueRef?.executionWorkspaceId ? await executionWorkspacesSvc.getById(issueRef.executionWorkspaceId) : null; issueRef?.executionWorkspaceId ? await executionWorkspacesSvc.getById(issueRef.executionWorkspaceId) : null;
const shouldReuseExisting =
issueRef?.executionWorkspacePreference === "reuse_existing" &&
existingExecutionWorkspace &&
existingExecutionWorkspace.status !== "archived";
const persistedExecutionWorkspaceMode = shouldReuseExisting && existingExecutionWorkspace
? issueExecutionWorkspaceModeForPersistedWorkspace(existingExecutionWorkspace.mode)
: null;
const effectiveExecutionWorkspaceMode: ReturnType<typeof resolveExecutionWorkspaceMode> =
persistedExecutionWorkspaceMode === "isolated_workspace" ||
persistedExecutionWorkspaceMode === "operator_branch" ||
persistedExecutionWorkspaceMode === "agent_default"
? persistedExecutionWorkspaceMode
: requestedExecutionWorkspaceMode;
const workspaceManagedConfig = shouldReuseExisting
? { ...config }
: buildExecutionWorkspaceAdapterConfig({
agentConfig: config,
projectPolicy: projectExecutionWorkspacePolicy,
issueSettings: issueExecutionWorkspaceSettings,
mode: requestedExecutionWorkspaceMode,
legacyUseProjectWorkspace: issueAssigneeOverrides?.useProjectWorkspace ?? null,
});
const persistedWorkspaceManagedConfig = applyPersistedExecutionWorkspaceConfig({ const persistedWorkspaceManagedConfig = applyPersistedExecutionWorkspaceConfig({
config: workspaceManagedConfig, config: workspaceManagedConfig,
workspaceConfig: existingExecutionWorkspace?.config ?? null, workspaceConfig: existingExecutionWorkspace?.config ?? null,
mode: executionWorkspaceMode, mode: effectiveExecutionWorkspaceMode,
}); });
const mergedConfig = issueAssigneeOverrides?.adapterConfig const mergedConfig = issueAssigneeOverrides?.adapterConfig
? { ...persistedWorkspaceManagedConfig, ...issueAssigneeOverrides.adapterConfig } ? { ...persistedWorkspaceManagedConfig, ...issueAssigneeOverrides.adapterConfig }
@ -2140,39 +2183,43 @@ export function heartbeatService(db: Db) {
heartbeatRunId: run.id, heartbeatRunId: run.id,
executionWorkspaceId: existingExecutionWorkspace?.id ?? null, executionWorkspaceId: existingExecutionWorkspace?.id ?? null,
}); });
const executionWorkspace = await realizeExecutionWorkspace({ const executionWorkspaceBase = {
base: { baseCwd: resolvedWorkspace.cwd,
baseCwd: resolvedWorkspace.cwd, source: resolvedWorkspace.source,
source: resolvedWorkspace.source, projectId: resolvedWorkspace.projectId,
projectId: resolvedWorkspace.projectId, workspaceId: resolvedWorkspace.workspaceId,
workspaceId: resolvedWorkspace.workspaceId, repoUrl: resolvedWorkspace.repoUrl,
repoUrl: resolvedWorkspace.repoUrl, repoRef: resolvedWorkspace.repoRef,
repoRef: resolvedWorkspace.repoRef, } satisfies ExecutionWorkspaceInput;
}, const executionWorkspace = shouldReuseExisting && existingExecutionWorkspace
config: runtimeConfig, ? buildRealizedExecutionWorkspaceFromPersisted({
issue: issueRef, base: executionWorkspaceBase,
agent: { workspace: existingExecutionWorkspace,
id: agent.id, })
name: agent.name, : await realizeExecutionWorkspace({
companyId: agent.companyId, base: executionWorkspaceBase,
}, config: runtimeConfig,
recorder: workspaceOperationRecorder, issue: issueRef,
}); agent: {
id: agent.id,
name: agent.name,
companyId: agent.companyId,
},
recorder: workspaceOperationRecorder,
});
const resolvedProjectId = executionWorkspace.projectId ?? issueRef?.projectId ?? executionProjectId ?? null; const resolvedProjectId = executionWorkspace.projectId ?? issueRef?.projectId ?? executionProjectId ?? null;
const resolvedProjectWorkspaceId = issueRef?.projectWorkspaceId ?? resolvedWorkspace.workspaceId ?? null; const resolvedProjectWorkspaceId = issueRef?.projectWorkspaceId ?? resolvedWorkspace.workspaceId ?? null;
const shouldReuseExisting =
issueRef?.executionWorkspacePreference === "reuse_existing" &&
existingExecutionWorkspace &&
existingExecutionWorkspace.status !== "archived";
let persistedExecutionWorkspace = null; let persistedExecutionWorkspace = null;
const nextExecutionWorkspaceMetadataBase = { const nextExecutionWorkspaceMetadataBase = {
...(existingExecutionWorkspace?.metadata ?? {}), ...(existingExecutionWorkspace?.metadata ?? {}),
source: executionWorkspace.source, source: executionWorkspace.source,
createdByRuntime: executionWorkspace.created, createdByRuntime: executionWorkspace.created,
} as Record<string, unknown>; } as Record<string, unknown>;
const nextExecutionWorkspaceMetadata = configSnapshot const nextExecutionWorkspaceMetadata = shouldReuseExisting
? mergeExecutionWorkspaceConfig(nextExecutionWorkspaceMetadataBase, configSnapshot) ? nextExecutionWorkspaceMetadataBase
: nextExecutionWorkspaceMetadataBase; : configSnapshot
? mergeExecutionWorkspaceConfig(nextExecutionWorkspaceMetadataBase, configSnapshot)
: nextExecutionWorkspaceMetadataBase;
try { try {
persistedExecutionWorkspace = shouldReuseExisting && existingExecutionWorkspace persistedExecutionWorkspace = shouldReuseExisting && existingExecutionWorkspace
? await executionWorkspacesSvc.update(existingExecutionWorkspace.id, { ? await executionWorkspacesSvc.update(existingExecutionWorkspace.id, {
@ -2193,11 +2240,11 @@ export function heartbeatService(db: Db) {
projectWorkspaceId: resolvedProjectWorkspaceId, projectWorkspaceId: resolvedProjectWorkspaceId,
sourceIssueId: issueRef?.id ?? null, sourceIssueId: issueRef?.id ?? null,
mode: mode:
executionWorkspaceMode === "isolated_workspace" requestedExecutionWorkspaceMode === "isolated_workspace"
? "isolated_workspace" ? "isolated_workspace"
: executionWorkspaceMode === "operator_branch" : requestedExecutionWorkspaceMode === "operator_branch"
? "operator_branch" ? "operator_branch"
: executionWorkspaceMode === "agent_default" : requestedExecutionWorkspaceMode === "agent_default"
? "adapter_managed" ? "adapter_managed"
: "shared_workspace", : "shared_workspace",
strategyType: executionWorkspace.strategy === "git_worktree" ? "git_worktree" : "project_primary", strategyType: executionWorkspace.strategy === "git_worktree" ? "git_worktree" : "project_primary",
@ -2272,8 +2319,8 @@ export function heartbeatService(db: Db) {
const nextIssueWorkspaceMode = issueExecutionWorkspaceModeForPersistedWorkspace(persistedExecutionWorkspace.mode); const nextIssueWorkspaceMode = issueExecutionWorkspaceModeForPersistedWorkspace(persistedExecutionWorkspace.mode);
const shouldSwitchIssueToExistingWorkspace = const shouldSwitchIssueToExistingWorkspace =
issueRef?.executionWorkspacePreference === "reuse_existing" || issueRef?.executionWorkspacePreference === "reuse_existing" ||
executionWorkspaceMode === "isolated_workspace" || requestedExecutionWorkspaceMode === "isolated_workspace" ||
executionWorkspaceMode === "operator_branch"; requestedExecutionWorkspaceMode === "operator_branch";
const nextIssuePatch: Record<string, unknown> = {}; const nextIssuePatch: Record<string, unknown> = {};
if (issueRef?.executionWorkspaceId !== persistedExecutionWorkspace.id) { if (issueRef?.executionWorkspaceId !== persistedExecutionWorkspace.id) {
nextIssuePatch.executionWorkspaceId = persistedExecutionWorkspace.id; nextIssuePatch.executionWorkspaceId = persistedExecutionWorkspace.id;
@ -2326,7 +2373,7 @@ export function heartbeatService(db: Db) {
context.paperclipWorkspace = { context.paperclipWorkspace = {
cwd: executionWorkspace.cwd, cwd: executionWorkspace.cwd,
source: executionWorkspace.source, source: executionWorkspace.source,
mode: executionWorkspaceMode, mode: effectiveExecutionWorkspaceMode,
strategy: executionWorkspace.strategy, strategy: executionWorkspace.strategy,
projectId: executionWorkspace.projectId, projectId: executionWorkspace.projectId,
workspaceId: executionWorkspace.workspaceId, workspaceId: executionWorkspace.workspaceId,

View file

@ -194,9 +194,9 @@ function toRuntimeServiceRef(record: RuntimeServiceRecord, overrides?: Partial<R
function sanitizeSlugPart(value: string | null | undefined, fallback: string): string { function sanitizeSlugPart(value: string | null | undefined, fallback: string): string {
const raw = (value ?? "").trim().toLowerCase(); const raw = (value ?? "").trim().toLowerCase();
const normalized = raw const normalized = raw
.replace(/[^a-z0-9/_-]+/g, "-") .replace(/[^a-z0-9_-]+/g, "-")
.replace(/-+/g, "-") .replace(/-+/g, "-")
.replace(/^[-/]+|[-/]+$/g, ""); .replace(/^[-_]+|[-_]+$/g, "");
return normalized.length > 0 ? normalized : fallback; return normalized.length > 0 ? normalized : fallback;
} }
@ -231,9 +231,9 @@ function renderWorkspaceTemplate(template: string, input: {
function sanitizeBranchName(value: string): string { function sanitizeBranchName(value: string): string {
return value return value
.trim() .trim()
.replace(/[^A-Za-z0-9._/-]+/g, "-") .replace(/[^A-Za-z0-9_-]+/g, "-")
.replace(/-+/g, "-") .replace(/-+/g, "-")
.replace(/^[-/.]+|[-/.]+$/g, "") .replace(/^[-_]+|[-_]+$/g, "")
.slice(0, 120) || "paperclip-work"; .slice(0, 120) || "paperclip-work";
} }