From 3568193a71fe7695493c2160415a8f3776153f83 Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 21 Oct 2026 08:38:18 -0500 Subject: [PATCH] fix: preserve plain task sources through shared workspace reuse Co-Authored-By: Paperclip --- .../2026-10-20-dependable-everyday-work.md | 13 +++++++ .../task-workspace-admission.test.ts | 20 +++++++++-- .../heartbeat/workspace-preparation.test.ts | 35 +++++++++++++++++++ server/src/services/workspace-runtime.ts | 20 ++++++++--- 4 files changed, 81 insertions(+), 7 deletions(-) diff --git a/doc/plans/2026-10-20-dependable-everyday-work.md b/doc/plans/2026-10-20-dependable-everyday-work.md index 1c5203793ba..2dd300b3377 100644 --- a/doc/plans/2026-10-20-dependable-everyday-work.md +++ b/doc/plans/2026-10-20-dependable-everyday-work.md @@ -134,3 +134,16 @@ blocking remainingWork entries. A real-database reproduction accepts that invali report before repair. The same gate now also checks an unfinished objective. Current questions, approvals, pause holds, conversations, and authorized external response waits retain their earlier checks. No accepted result is rewritten. + + +Review then found that shared-workspace restoration relabeled a retained task +source as project_primary before repository admission. A new regression performs +first workspace preparation and reuse against the real database; it fails at the +source transition before repair. The corrected path retains task_session for an +explicitly repository-free task directory, including its absent repository and +ref instead of inheriting organizational defaults. Configured source pointers +keep project classification. The composed regression also stages a repository +through the real service and prepares verified bytes on the reused task root, +with only remote clone transport replaced by an owned local repository. The +queued sixth campaign was cancelled during its build, before provider execution, +when this review finding made that candidate an uninformative paid retry. diff --git a/server/src/__tests__/task-workspace-admission.test.ts b/server/src/__tests__/task-workspace-admission.test.ts index cb8c3bd14e8..02a43bf5149 100644 --- a/server/src/__tests__/task-workspace-admission.test.ts +++ b/server/src/__tests__/task-workspace-admission.test.ts @@ -22,13 +22,27 @@ describe("task workspace admission authority", () => { const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-source-authority-")); try { const restored = await ensurePersistedExecutionWorkspaceAvailable({ - base: { baseCwd: cwd, source: "project_primary", projectId: "org-project", workspaceId: "org-source", repoUrl: null, repoRef: null }, + base: { baseCwd: cwd, source: "project_primary", projectId: "org-project", workspaceId: "org-source", repoUrl: "https://github.com/organization/source", repoRef: "organization-main" }, workspace: { mode: "shared_workspace", strategyType: "project_primary", cwd, providerRef: cwd, - projectId: null, projectWorkspaceId: null, repoUrl: null, baseRef: null, branchName: null }, + projectId: null, projectWorkspaceId: null, repoUrl: null, baseRef: null, branchName: null, metadata: { source: "task_session" } }, issue: { id: "task", identifier: null, title: "Retain files" }, agent: { id: "agent", companyId: "company", name: "Agent" }, }); - expect(restored).toMatchObject({ cwd, projectId: null, workspaceId: null }); + expect(restored).toMatchObject({ cwd, projectId: null, workspaceId: null, repoUrl: null, repoRef: null, source: "task_session" }); + } finally { await fs.rm(cwd, { recursive: true, force: true }); } + }); + it.each([ + { projectId: "configured-project" }, { projectWorkspaceId: "configured-source" }, { repoUrl: "https://github.com/configured/source" }, + ])("retains configured-source classification despite task_session metadata: %j", async configured => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-configured-source-")); + try { + const restored = await ensurePersistedExecutionWorkspaceAvailable({ + base: { baseCwd: cwd, source: "task_session", projectId: null, workspaceId: null, repoUrl: null, repoRef: null }, + workspace: { mode: "shared_workspace", strategyType: "project_primary", cwd, providerRef: cwd, + projectId: null, projectWorkspaceId: null, repoUrl: null, baseRef: null, branchName: null, metadata: { source: "task_session" }, ...configured }, + issue: { id: "task", identifier: null, title: "Configured source" }, agent: { id: "agent", companyId: "company", name: "Agent" }, + }); + expect(restored?.source).toBe("project_primary"); } finally { await fs.rm(cwd, { recursive: true, force: true }); } }); it("permits new admission to apply explicit pending intent", () => { diff --git a/server/src/services/heartbeat/workspace-preparation.test.ts b/server/src/services/heartbeat/workspace-preparation.test.ts index 490190dd3e7..8b611e89799 100644 --- a/server/src/services/heartbeat/workspace-preparation.test.ts +++ b/server/src/services/heartbeat/workspace-preparation.test.ts @@ -488,6 +488,41 @@ describe.skipIf(!support.supported)("heartbeat workspace preparation boundary", expect((await readIssue(issue.id)).projectId).toBe(organization.id); }); + it("retains task-directory provenance through shared workspace preparation", async () => { + const { input, issue, source } = await fixture(); + await db.update(issues).set({ projectId: null, projectWorkspaceId: null, + workspaceSelection: { version: 1, source: "explicit", selection: { kind: "task_directory" } }, + }).where(eq(issues.id, issue.id)); + input.task.issueRef = await readIssue(issue.id); + input.task.context = { issueId: issue.id }; + const first = await prepareHeartbeatWorkspace(db, input); + expect(first.executionWorkspace.source).toBe("task_session"); + expect(first.persistedExecutionWorkspace).toMatchObject({ mode: "shared_workspace", strategyType: "project_primary", + sourceIssueId: issue.id, projectId: null, projectWorkspaceId: null, repoUrl: null, metadata: { source: "task_session" } }); + await mkdir(join(first.executionWorkspace.cwd, ".git", "info"), { recursive: true }); + await writeFile(join(first.executionWorkspace.cwd, ".git", "info", "exclude"), "/.paperclip-repositories/\n"); + await writeFile(join(first.executionWorkspace.cwd, "original-context.txt"), "Preserve the original task bytes\n"); + await db.update(issues).set({ executionRunId: input.run.id }).where(eq(issues.id, issue.id)); + const taskRepositories = executionWorkspaceService(db); + const receipt = await taskRepositories.requestTaskRepository({ companyId: issue.companyId, issueId: issue.id, + actor: { type: "board", source: "local_implicit", userId: "local-board" }, + request: { repository: { kind: "url", url: "https://github.com/public/source" }, requestKey: "retained-task-directory" } }); + const materialize = managedRepositoryCheckout.materializeManagedProjectWorkspace; + vi.spyOn(managedRepositoryCheckout, "materializeManagedProjectWorkspace").mockImplementation((cwd, options) => + materialize(cwd, { ...options, localSource: source })); + await reuse(input, first.persistedExecutionWorkspace!); + await db.update(issues).set({ executionRunId: input.run.id }).where(eq(issues.id, issue.id)); + input.task.issueRef = await readIssue(issue.id); + const next = await prepareHeartbeatWorkspace(db, input); + expect(next.executionWorkspace.source).toBe("task_session"); + expect(next.persistedExecutionWorkspace!.metadata?.source).toBe("task_session"); + expect(next.persistedExecutionWorkspace!.id).toBe(first.persistedExecutionWorkspace!.id); + expect(await readFile(join(next.executionWorkspace.cwd, "original-context.txt"), "utf8")).toBe("Preserve the original task bytes\n"); + const [repository] = await taskRepositories.listTaskRepositories(issue.companyId, next.persistedExecutionWorkspace!.id); + expect(repository).toMatchObject({ id: receipt.operationId, state: "ready" }); + expect(await readFile(join(next.executionWorkspace.cwd, repository.relativePath, "README.md"), "utf8")).toBe("source content\n"); + }); + it("creates a real isolated worktree and reuses its recorded workspace", async () => { const { input, source, issue } = await fixture(true); const first = await prepareHeartbeatWorkspace(db, input); diff --git a/server/src/services/workspace-runtime.ts b/server/src/services/workspace-runtime.ts index 4fd0db5226f..1b76dafe0b1 100644 --- a/server/src/services/workspace-runtime.ts +++ b/server/src/services/workspace-runtime.ts @@ -3770,6 +3770,17 @@ export async function realizeExecutionWorkspace(input: { }; } +function isPersistedTaskDirectory(workspace: { + strategyType: string | null | undefined; + projectId: string | null | undefined; + projectWorkspaceId: string | null | undefined; + repoUrl: string | null | undefined; + metadata?: Record | null; +}): boolean { + return workspace.strategyType === "project_primary" && workspace.metadata?.source === "task_session" + && workspace.projectId === null && workspace.projectWorkspaceId === null && workspace.repoUrl === null; +} + export async function ensurePersistedExecutionWorkspaceAvailable(input: { prepareRestoredLocalSource?: (sourceCwd: string) => Promise<() => Promise>; persistContentsRestoredAt?: (at: string | null) => Promise; @@ -3807,13 +3818,14 @@ export async function ensurePersistedExecutionWorkspaceAvailable(input: { if (!cwd) return null; const strategy = input.workspace.strategyType === "git_worktree" ? "git_worktree" : "project_primary"; + const taskDirectory = isPersistedTaskDirectory(input.workspace); const realized: RealizedExecutionWorkspace = { baseCwd: input.base.baseCwd, - source: input.workspace.mode === "shared_workspace" ? "project_primary" : "task_session", + source: input.workspace.mode === "shared_workspace" && !taskDirectory ? "project_primary" : "task_session", projectId: input.workspace.projectId === undefined ? input.base.projectId : input.workspace.projectId, workspaceId: input.workspace.projectWorkspaceId === undefined ? input.base.workspaceId : input.workspace.projectWorkspaceId, - repoUrl: input.workspace.repoUrl ?? input.base.repoUrl, - repoRef: input.workspace.baseRef ?? input.base.repoRef, + repoUrl: taskDirectory ? null : input.workspace.repoUrl ?? input.base.repoUrl, + repoRef: taskDirectory ? null : input.workspace.baseRef ?? input.base.repoRef, additionalWorkspaces: input.base.additionalWorkspaces ?? [], strategy, cwd, @@ -8898,7 +8910,7 @@ export async function restartDesiredRuntimeServicesOnStartup(db: Db) { : null, workspace: { baseCwd: row.cwd, - source: row.mode === "shared_workspace" ? "project_primary" : "task_session", + source: row.mode === "shared_workspace" && !isPersistedTaskDirectory(row) ? "project_primary" : "task_session", projectId: row.projectId, workspaceId: row.projectWorkspaceId ?? null, repoUrl: row.repoUrl ?? null,