diff --git a/doc/plans/2026-10-20-dependable-everyday-work.md b/doc/plans/2026-10-20-dependable-everyday-work.md index 9ed05b2b8b4..0eb582960ff 100644 --- a/doc/plans/2026-10-20-dependable-everyday-work.md +++ b/doc/plans/2026-10-20-dependable-everyday-work.md @@ -143,3 +143,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, diff --git a/tests/runner-e2e/README.md b/tests/runner-e2e/README.md index 7d964caede1..2b220d50296 100644 --- a/tests/runner-e2e/README.md +++ b/tests/runner-e2e/README.md @@ -69,7 +69,7 @@ hard stops. The conversion journey creates a real legacy agent and selects Paperclip Runner through the public agent update API. It checks retained configuration, encrypted secret references, environment, instructions, and corresponding permission policy before submitting work through the browser. -The disposable legacy profile starts without optional CLI-only extra arguments; +The disposable legacy profile starts without optional CLI-only extra arguments or turn limits; the journey does not remove settings from an existing user agent. For either provider, Daytona's public environment probe must save platform qualification before the same agent selects Runner. Probe leases and uncertain probe accounting diff --git a/tests/runner-e2e/repository-conversion-setup.test.ts b/tests/runner-e2e/repository-conversion-setup.test.ts index ef265e0fbef..aad6ce2e91c 100644 --- a/tests/runner-e2e/repository-conversion-setup.test.ts +++ b/tests/runner-e2e/repository-conversion-setup.test.ts @@ -18,6 +18,7 @@ describe("real legacy conversion prerequisites", () => { expect(agent.adapterType).toBe(profile.provider === "codex" ? "codex_local" : "claude_local"); expect(agent.adapterConfig).toMatchObject({ model: profile.model, timeoutSec: 360, cwd: "/owned/workspace" }); expect(agent.adapterConfig).not.toHaveProperty("extraArgs"); + expect(agent.adapterConfig).not.toHaveProperty("maxTurnsPerRun"); expect((agent.adapterConfig as Record).env).toMatchObject(profile.provider === "codex" ? { OPENAI_API_KEY: { type: "secret_ref", secretId: "codex-reference", version: "latest" } } : { ANTHROPIC_API_KEY: { type: "secret_ref", secretId: "claude-reference", version: "latest" } }); diff --git a/tests/runner-e2e/repository-verification.ts b/tests/runner-e2e/repository-verification.ts index d6a13daa1e5..c6ef5325b1f 100644 --- a/tests/runner-e2e/repository-verification.ts +++ b/tests/runner-e2e/repository-verification.ts @@ -10,7 +10,10 @@ export function repositoryProofProfile(native: RunnerProfileFixture, legacy?: Ru return { ...native, buildAgent(input) { const { instructionsBundle: _manual, ...agent } = (legacy ?? native).buildAgent(input); const config = { ...(agent.adapterConfig as Record), model: native.model }; - if (legacy) delete (config as Record).extraArgs; + if (legacy) { + delete (config as Record).extraArgs; + delete (config as Record).maxTurnsPerRun; + } return { ...agent, ...(legacy ? { runner: "legacy" } : {}), budgetMonthlyCents: 1000, adapterConfig: config }; } }; }