From a328ec953aadc3cea9c683dd9c72fa5a84eca25c Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Sat, 4 Jul 2026 06:35:06 -0700 Subject: [PATCH] Fix inherited workspace reuse fallback (#8963) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Agent heartbeats provision execution workspaces before invoking local or sandboxed adapters. > - Some follow-up issues intentionally request `reuse_existing` so they continue in an inherited execution workspace. > - The heartbeat provisioning path treated missing or archived workspace rows as if no explicit reuse request existed. > - That could silently realize and persist a fresh project/default workspace over an explicit inherited-workspace binding. > - This pull request keys explicit reuse off the issue preference and workspace id, then either restores that workspace or fails with a structured workspace validation error. > - The benefit is that intentional workspace inheritance remains auditable and does not silently degrade into unrelated fallback workspaces. ## Linked Issues or Issue Description Refs #8058 Refs #6036 Refs #2203 This fixes a narrower heartbeat provisioning bug around explicit `reuse_existing` issue runs: if the target inherited execution workspace is missing, archived, or fails restore, provisioning now reports the reuse failure instead of replacing the issue's workspace binding with a freshly realized fallback. ## What Changed - Added explicit helpers for resolving workspace reuse requests and deciding whether reuse should restore, refresh metadata, or keep prior replacement-class drift visible. - Changed heartbeat workspace provisioning so explicit `reuse_existing` requests go through restore-or-fail behavior instead of falling back to `realizeExecutionWorkspace` when the stored workspace row is unavailable. - Added structured `workspace_validation_failed` details for inherited workspace reuse failures. - Added regression coverage for replacement-class drift, restore errors, missing rows, archived rows, and restore misses. ## Verification - `pnpm install --frozen-lockfile` - `pnpm --filter @paperclipai/plugin-sdk ensure-build-deps` - `pnpm --filter @paperclipai/server exec vitest run src/__tests__/heartbeat-workspace-session.test.ts` - `pnpm --filter @paperclipai/server typecheck` - `git diff --check origin/master...HEAD` - Scanned the branch diff and commit messages for credentials, tokens, private URLs, PII-style values, and internal issue links before pushing; no unsafe hits remained. ## Risks - Explicit reuse requests whose stored workspace cannot be restored now fail the run instead of opportunistically creating a replacement workspace. That is intentional, but it may surface stale or archived workspace rows as visible provisioning failures that require repair. - Non-reuse workspace provisioning still uses the existing realization path, so the behavior shift is scoped to issues that explicitly request existing workspace reuse. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI GPT-5 via Codex local agent, with shell/tool use enabled for repository inspection, code editing, verification, git, and GitHub CLI operations. Runtime context-window details were not exposed by the adapter. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- .../heartbeat-workspace-session.test.ts | 199 +++++++++- server/src/services/heartbeat.ts | 341 +++++++++++++----- 2 files changed, 455 insertions(+), 85 deletions(-) diff --git a/server/src/__tests__/heartbeat-workspace-session.test.ts b/server/src/__tests__/heartbeat-workspace-session.test.ts index 63456956b3..d0a4853d09 100644 --- a/server/src/__tests__/heartbeat-workspace-session.test.ts +++ b/server/src/__tests__/heartbeat-workspace-session.test.ts @@ -3,7 +3,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { promisify } from "node:util"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import type { agents } from "@paperclipai/db"; import { sessionCodec as codexSessionCodec } from "@paperclipai/adapter-codex-local/server"; import { resolveDefaultAgentWorkspaceDir } from "../home-paths.js"; @@ -23,7 +23,10 @@ import { preflightLowTrustWorkspaceIsolation, prioritizeProjectWorkspaceCandidatesForRun, parseSessionCompactionPolicy, + provisionExecutionWorkspaceForFreshnessDecision, resolveExecutionWorkspaceConfigFreshness, + resolveExecutionWorkspaceReuseRequestForIssue, + resolveExecutionWorkspaceReuseProvisioningPolicy, resolveNextSessionState, resolveTaskSessionConfigFreshness, requiresPushCapabilityPreflight, @@ -1062,6 +1065,200 @@ describe("effective run execution workspace config freshness", () => { expect(decision.changedCategories).toContain(category); }); + it("keeps replacement-class drift visible when explicit reuse restores the old workspace", () => { + const base = buildWorkspaceConfigMetadata(); + const next = buildWorkspaceConfigMetadata({ + repoRef: "origin/release", + workspaceStrategy: { + type: "git_worktree", + baseRef: "origin/release", + branchTemplate: "{{issue.identifier}}-{{slug}}", + worktreeParentDir: ".paperclip/worktrees", + }, + configSnapshot: { + provisionCommand: "pnpm install --frozen-lockfile", + }, + }); + + const decision = resolveExecutionWorkspaceConfigFreshness({ + hasExistingWorkspace: true, + existingWorkspaceMetadata: persistedWorkspaceConfigFingerprint(base), + nextMetadata: next, + }); + const policy = resolveExecutionWorkspaceReuseProvisioningPolicy({ + requestedShouldReuseExisting: true, + workspaceConfigFreshness: decision, + }); + + expect(decision.action).toBe("replace"); + expect(policy).toEqual({ + shouldRestoreExistingWorkspace: true, + shouldRefreshWorkspaceConfigSnapshot: false, + shouldPersistLatestWorkspaceConfigMetadata: false, + }); + + const metadata = mergeExecutionWorkspaceMetadataForPersistence({ + existingMetadata: { + config: { + provisionCommand: "pnpm install", + }, + ...persistedWorkspaceConfigFingerprint(base), + }, + source: "task_session", + createdByRuntime: false, + configSnapshot: { + provisionCommand: "pnpm install --frozen-lockfile", + }, + shouldReuseExisting: policy.shouldRestoreExistingWorkspace, + shouldRefreshConfigSnapshot: policy.shouldRefreshWorkspaceConfigSnapshot, + workspaceConfigMetadata: policy.shouldPersistLatestWorkspaceConfigMetadata ? next : null, + baseRef: "origin/release", + baseRefSha: "release-sha", + }); + + expect(metadata?.config).toEqual({ + provisionCommand: "pnpm install", + }); + expect(metadata?.configFingerprint).toMatchObject({ + workspaceHash: base.fingerprint, + categories: base.categories, + }); + expect(metadata?.configFingerprint).not.toMatchObject({ + workspaceHash: next.fingerprint, + }); + }); + + it("fails explicit reuse restore errors without realizing a fallback workspace", async () => { + const base = buildWorkspaceConfigMetadata(); + const next = buildWorkspaceConfigMetadata({ + repoRef: "origin/release", + workspaceStrategy: { + type: "git_worktree", + baseRef: "origin/release", + branchTemplate: "{{issue.identifier}}-{{slug}}", + worktreeParentDir: ".paperclip/worktrees", + }, + }); + const decision = resolveExecutionWorkspaceConfigFreshness({ + hasExistingWorkspace: true, + existingWorkspaceMetadata: persistedWorkspaceConfigFingerprint(base), + nextMetadata: next, + }); + const realizeWorkspace = vi.fn(async () => ({ id: "fallback-workspace" })); + + await expect(provisionExecutionWorkspaceForFreshnessDecision({ + requestedShouldReuseExisting: true, + existingExecutionWorkspaceId: "workspace-old", + issueRef: { id: "issue-1", identifier: "PAP-42" }, + runId: "run-1", + workspaceConfigFreshness: decision, + restoreExistingWorkspace: async () => { + throw new Error("restore command failed"); + }, + realizeWorkspace, + })).rejects.toMatchObject({ + code: "workspace_validation_failed", + resultJson: { + workspaceValidation: expect.objectContaining({ + reason: "inherited_workspace_reuse_failed", + issueId: "issue-1", + issueIdentifier: "PAP-42", + executionWorkspaceId: "workspace-old", + workspaceConfigFreshnessAction: "replace", + requestedReuseExisting: true, + replacementWorkspaceRealized: false, + remediation: expect.stringContaining("restore/provision logs"), + }), + }, + }); + expect(realizeWorkspace).not.toHaveBeenCalled(); + }); + + it.each([ + { name: "missing", status: null }, + { name: "archived", status: "archived" }, + ])("fails explicit reuse when the inherited workspace row is $name", async ({ status }) => { + const reuseRequest = resolveExecutionWorkspaceReuseRequestForIssue({ + issueExecutionWorkspaceId: "workspace-old", + issueExecutionWorkspacePreference: "reuse_existing", + existingExecutionWorkspaceStatus: status, + }); + + expect(reuseRequest).toEqual({ + requestedExecutionWorkspaceId: "workspace-old", + requestedShouldReuseExisting: true, + existingExecutionWorkspaceAvailable: false, + }); + + const metadata = buildWorkspaceConfigMetadata(); + const decision = resolveExecutionWorkspaceConfigFreshness({ + hasExistingWorkspace: reuseRequest.requestedShouldReuseExisting && + reuseRequest.existingExecutionWorkspaceAvailable, + existingWorkspaceMetadata: null, + nextMetadata: metadata, + }); + const realizeWorkspace = vi.fn(async () => ({ id: "fallback-workspace" })); + + await expect(provisionExecutionWorkspaceForFreshnessDecision({ + requestedShouldReuseExisting: reuseRequest.requestedShouldReuseExisting, + existingExecutionWorkspaceId: reuseRequest.requestedExecutionWorkspaceId, + issueRef: { id: "issue-1", identifier: "PAP-42" }, + runId: "run-1", + workspaceConfigFreshness: decision, + restoreExistingWorkspace: reuseRequest.existingExecutionWorkspaceAvailable + ? async () => ({ id: "workspace-old" }) + : null, + realizeWorkspace, + })).rejects.toMatchObject({ + code: "workspace_validation_failed", + resultJson: { + workspaceValidation: expect.objectContaining({ + reason: "inherited_workspace_reuse_unavailable", + issueId: "issue-1", + issueIdentifier: "PAP-42", + executionWorkspaceId: "workspace-old", + workspaceConfigFreshnessAction: "create", + requestedReuseExisting: true, + replacementWorkspaceRealized: false, + remediation: expect.stringContaining("clear the issue's reuse_existing workspace binding"), + }), + }, + }); + expect(realizeWorkspace).not.toHaveBeenCalled(); + }); + + it("fails explicit reuse restore misses without realizing a fallback workspace", async () => { + const metadata = buildWorkspaceConfigMetadata(); + const decision = resolveExecutionWorkspaceConfigFreshness({ + hasExistingWorkspace: true, + existingWorkspaceMetadata: persistedWorkspaceConfigFingerprint(metadata), + nextMetadata: metadata, + }); + const realizeWorkspace = vi.fn(async () => ({ id: "fallback-workspace" })); + + await expect(provisionExecutionWorkspaceForFreshnessDecision({ + requestedShouldReuseExisting: true, + existingExecutionWorkspaceId: "workspace-old", + issueRef: { id: "issue-1", identifier: "PAP-42" }, + runId: "run-1", + workspaceConfigFreshness: decision, + restoreExistingWorkspace: async () => null, + realizeWorkspace, + })).rejects.toMatchObject({ + code: "workspace_validation_failed", + resultJson: { + workspaceValidation: expect.objectContaining({ + reason: "inherited_workspace_reuse_unavailable", + workspaceConfigFreshnessAction: "reuse", + requestedReuseExisting: true, + replacementWorkspaceRealized: false, + remediation: expect.stringContaining("clear the issue's reuse_existing workspace binding"), + }), + }, + }); + expect(realizeWorkspace).not.toHaveBeenCalled(); + }); + it("formats a safe workspace operation payload for config drift decisions", () => { const decision = resolveExecutionWorkspaceConfigFreshness({ hasExistingWorkspace: true, diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 856b01129f..86803fd893 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -2664,6 +2664,156 @@ type WorkspaceConfigFreshnessOperationInput = { activeWorkspaceId: string | null; }; +type ExecutionWorkspaceReuseProvisioningPolicy = { + shouldRestoreExistingWorkspace: boolean; + shouldRefreshWorkspaceConfigSnapshot: boolean; + shouldPersistLatestWorkspaceConfigMetadata: boolean; +}; + +type WorkspaceReuseIssueRef = { + id?: string | null; + identifier?: string | null; +} | null | undefined; + +export type ExecutionWorkspaceReuseRequestForIssue = { + requestedExecutionWorkspaceId: string | null; + requestedShouldReuseExisting: boolean; + existingExecutionWorkspaceAvailable: boolean; +}; + +export function resolveExecutionWorkspaceReuseRequestForIssue(input: { + issueExecutionWorkspaceId?: string | null; + issueExecutionWorkspacePreference?: string | null; + existingExecutionWorkspaceStatus?: string | null; +}): ExecutionWorkspaceReuseRequestForIssue { + const requestedExecutionWorkspaceId = readNonEmptyString(input.issueExecutionWorkspaceId); + const requestedShouldReuseExisting = + input.issueExecutionWorkspacePreference === "reuse_existing" && requestedExecutionWorkspaceId !== null; + + return { + requestedExecutionWorkspaceId, + requestedShouldReuseExisting, + existingExecutionWorkspaceAvailable: + requestedShouldReuseExisting && + input.existingExecutionWorkspaceStatus !== null && + input.existingExecutionWorkspaceStatus !== undefined && + input.existingExecutionWorkspaceStatus !== "archived", + }; +} + +export function resolveExecutionWorkspaceReuseProvisioningPolicy(input: { + requestedShouldReuseExisting: boolean; + workspaceConfigFreshness: ExecutionWorkspaceConfigFreshnessDecision; +}): ExecutionWorkspaceReuseProvisioningPolicy { + const shouldRestoreExistingWorkspace = input.requestedShouldReuseExisting; + const replacementClassDrift = + input.requestedShouldReuseExisting && input.workspaceConfigFreshness.action === "replace"; + + return { + shouldRestoreExistingWorkspace, + shouldRefreshWorkspaceConfigSnapshot: + shouldRestoreExistingWorkspace && + !replacementClassDrift && + input.workspaceConfigFreshness.shouldRefreshConfigSnapshot, + shouldPersistLatestWorkspaceConfigMetadata: !replacementClassDrift, + }; +} + +function createInheritedExecutionWorkspaceReuseFailure(input: { + reason: "inherited_workspace_reuse_failed" | "inherited_workspace_reuse_unavailable"; + issueRef: WorkspaceReuseIssueRef; + runId: string; + executionWorkspaceId: string | null | undefined; + workspaceConfigFreshness: ExecutionWorkspaceConfigFreshnessDecision; + cause?: unknown; +}) { + const issueLabel = input.issueRef?.identifier ?? input.issueRef?.id ?? input.runId; + const workspaceLabel = input.executionWorkspaceId ?? "unknown workspace"; + const causeMessage = input.cause instanceof Error + ? input.cause.message + : input.cause != null + ? String(input.cause) + : null; + const remediation = input.reason === "inherited_workspace_reuse_failed" + ? "Inspect the referenced execution workspace restore/provision logs, repair or unarchive the workspace, or intentionally clear the issue's reuse_existing workspace binding before retrying." + : "Repair or unarchive the referenced execution workspace, or intentionally clear the issue's reuse_existing workspace binding before retrying."; + const message = causeMessage + ? `Issue ${issueLabel} requested inherited execution workspace reuse for ${workspaceLabel}, but the workspace could not be restored because ${causeMessage}.` + : `Issue ${issueLabel} requested inherited execution workspace reuse for ${workspaceLabel} but the workspace could not be restored; workspace provisioning cannot replace it because this is an explicit reuse path.`; + + return new WorkspaceValidationFailure(message, { + workspaceValidation: { + reason: input.reason, + issueId: input.issueRef?.id ?? null, + issueIdentifier: input.issueRef?.identifier ?? null, + executionWorkspaceId: input.executionWorkspaceId ?? null, + workspaceConfigFreshnessAction: input.workspaceConfigFreshness.action, + workspaceConfigFreshnessReasons: input.workspaceConfigFreshness.reasons, + requestedReuseExisting: true, + replacementWorkspaceRealized: false, + remediation, + }, + }); +} + +export async function provisionExecutionWorkspaceForFreshnessDecision(input: { + requestedShouldReuseExisting: boolean; + existingExecutionWorkspaceId?: string | null; + issueRef: WorkspaceReuseIssueRef; + runId: string; + workspaceConfigFreshness: ExecutionWorkspaceConfigFreshnessDecision; + restoreExistingWorkspace?: (() => Promise) | null; + realizeWorkspace: () => Promise; +}): Promise<{ + executionWorkspace: T; + reusedExecutionWorkspace: T | null; + policy: ExecutionWorkspaceReuseProvisioningPolicy; +}> { + const policy = resolveExecutionWorkspaceReuseProvisioningPolicy({ + requestedShouldReuseExisting: input.requestedShouldReuseExisting, + workspaceConfigFreshness: input.workspaceConfigFreshness, + }); + + if (!policy.shouldRestoreExistingWorkspace) { + const executionWorkspace = await input.realizeWorkspace(); + return { + executionWorkspace, + reusedExecutionWorkspace: null, + policy, + }; + } + + let restored: T | null = null; + try { + restored = (await input.restoreExistingWorkspace?.()) ?? null; + } catch (error) { + throw createInheritedExecutionWorkspaceReuseFailure({ + reason: "inherited_workspace_reuse_failed", + issueRef: input.issueRef, + runId: input.runId, + executionWorkspaceId: input.existingExecutionWorkspaceId, + workspaceConfigFreshness: input.workspaceConfigFreshness, + cause: error, + }); + } + + if (!restored) { + throw createInheritedExecutionWorkspaceReuseFailure({ + reason: "inherited_workspace_reuse_unavailable", + issueRef: input.issueRef, + runId: input.runId, + executionWorkspaceId: input.existingExecutionWorkspaceId, + workspaceConfigFreshness: input.workspaceConfigFreshness, + }); + } + + return { + executionWorkspace: restored, + reusedExecutionWorkspace: restored, + policy, + }; +} + const EFFECTIVE_RUN_SESSION_CONFIG_CATEGORY_LABELS: Record = { adapter: "adapter", adapterConfig: "adapter config", @@ -9818,15 +9968,19 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) } else { delete context.paperclipTaskMarkdown; } + const requestedExecutionWorkspaceId = readNonEmptyString(issueRef?.executionWorkspaceId); const existingExecutionWorkspace = - issueRef?.executionWorkspaceId ? await executionWorkspacesSvc.getById(issueRef.executionWorkspaceId) : null; - const requestedShouldReuseExisting = - issueRef?.executionWorkspacePreference === "reuse_existing" && - existingExecutionWorkspace !== null && - existingExecutionWorkspace.status !== "archived"; - const requestedReusableExecutionWorkspaceConfig = requestedShouldReuseExisting - ? existingExecutionWorkspace?.config ?? null + requestedExecutionWorkspaceId ? await executionWorkspacesSvc.getById(requestedExecutionWorkspaceId) : null; + const workspaceReuseRequest = resolveExecutionWorkspaceReuseRequestForIssue({ + issueExecutionWorkspaceId: requestedExecutionWorkspaceId, + issueExecutionWorkspacePreference: issueRef?.executionWorkspacePreference ?? null, + existingExecutionWorkspaceStatus: existingExecutionWorkspace?.status ?? null, + }); + const requestedShouldReuseExisting = workspaceReuseRequest.requestedShouldReuseExisting; + const reusableExistingExecutionWorkspace = workspaceReuseRequest.existingExecutionWorkspaceAvailable + ? existingExecutionWorkspace : null; + const requestedReusableExecutionWorkspaceConfig = reusableExistingExecutionWorkspace?.config ?? null; const localEnvironment = await environmentsSvc.ensureLocalEnvironment(agent.companyId); const resolvedInstanceSettings = await instanceSettings.get(); const environmentResolution = resolveExecutionWorkspaceEnvironmentId({ @@ -10025,16 +10179,16 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) projectPolicy: projectExecutionWorkspacePolicy, issueSettings: issueExecutionWorkspaceSettings, reusableExecutionWorkspaceConfig: requestedReusableExecutionWorkspaceConfig, - existingExecutionWorkspace: existingExecutionWorkspace + existingExecutionWorkspace: reusableExistingExecutionWorkspace ? { - id: existingExecutionWorkspace.id, - mode: existingExecutionWorkspace.mode, - strategyType: existingExecutionWorkspace.strategyType, - projectWorkspaceId: existingExecutionWorkspace.projectWorkspaceId, - repoUrl: existingExecutionWorkspace.repoUrl, - baseRef: existingExecutionWorkspace.baseRef, - branchName: existingExecutionWorkspace.branchName, - config: existingExecutionWorkspace.config, + id: reusableExistingExecutionWorkspace.id, + mode: reusableExistingExecutionWorkspace.mode, + strategyType: reusableExistingExecutionWorkspace.strategyType, + projectWorkspaceId: reusableExistingExecutionWorkspace.projectWorkspaceId, + repoUrl: reusableExistingExecutionWorkspace.repoUrl, + baseRef: reusableExistingExecutionWorkspace.baseRef, + branchName: reusableExistingExecutionWorkspace.branchName, + config: reusableExistingExecutionWorkspace.config, } : null, }, @@ -10178,24 +10332,24 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) realization: workspaceRealizationFingerprint, secretManifest, }); - const inferredExistingWorkspaceConfigMetadata = existingExecutionWorkspace + const inferredExistingWorkspaceConfigMetadata = reusableExistingExecutionWorkspace ? buildEffectiveRunWorkspaceConfigMetadata({ - mode: issueExecutionWorkspaceModeForPersistedWorkspace(existingExecutionWorkspace.mode), - projectId: existingExecutionWorkspace.projectId, - projectWorkspaceId: existingExecutionWorkspace.projectWorkspaceId, - strategyType: existingExecutionWorkspace.strategyType, + mode: issueExecutionWorkspaceModeForPersistedWorkspace(reusableExistingExecutionWorkspace.mode), + projectId: reusableExistingExecutionWorkspace.projectId, + projectWorkspaceId: reusableExistingExecutionWorkspace.projectWorkspaceId, + strategyType: reusableExistingExecutionWorkspace.strategyType, workspaceStrategy: workspaceStrategyFingerprintValue ? { ...workspaceStrategyFingerprintValue, - type: existingExecutionWorkspace.strategyType, - ...(existingExecutionWorkspace.baseRef - ? { baseRef: existingExecutionWorkspace.baseRef } + type: reusableExistingExecutionWorkspace.strategyType, + ...(reusableExistingExecutionWorkspace.baseRef + ? { baseRef: reusableExistingExecutionWorkspace.baseRef } : {}), } - : { type: existingExecutionWorkspace.strategyType }, - repoUrl: existingExecutionWorkspace.repoUrl, - repoRef: existingExecutionWorkspace.baseRef, - configSnapshot: existingExecutionWorkspace.config, + : { type: reusableExistingExecutionWorkspace.strategyType }, + repoUrl: reusableExistingExecutionWorkspace.repoUrl, + repoRef: reusableExistingExecutionWorkspace.baseRef, + configSnapshot: reusableExistingExecutionWorkspace.config, environment: workspaceEnvironmentFingerprint, realization: workspaceRealizationFingerprint, secretManifest, @@ -10203,42 +10357,65 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) }) : null; const workspaceConfigFreshness = resolveExecutionWorkspaceConfigFreshness({ - hasExistingWorkspace: requestedShouldReuseExisting && Boolean(existingExecutionWorkspace), - existingWorkspaceMetadata: existingExecutionWorkspace?.metadata ?? null, + hasExistingWorkspace: requestedShouldReuseExisting && Boolean(reusableExistingExecutionWorkspace), + existingWorkspaceMetadata: reusableExistingExecutionWorkspace?.metadata ?? null, inferredMetadata: inferredExistingWorkspaceConfigMetadata, nextMetadata: latestWorkspaceConfigMetadata, }); - const shouldReuseExisting = requestedShouldReuseExisting && workspaceConfigFreshness.shouldReuseExisting; - const shouldRefreshWorkspaceConfigSnapshot = shouldReuseExisting && workspaceConfigFreshness.shouldRefreshConfigSnapshot; + const workspaceReuseProvisioningPolicy = resolveExecutionWorkspaceReuseProvisioningPolicy({ + requestedShouldReuseExisting, + workspaceConfigFreshness, + }); const workspaceOperationRecorder = workspaceOperationsSvc.createRecorder({ companyId: agent.companyId, heartbeatRunId: run.id, - executionWorkspaceId: shouldReuseExisting ? existingExecutionWorkspace?.id ?? null : null, + executionWorkspaceId: workspaceReuseProvisioningPolicy.shouldRestoreExistingWorkspace + ? workspaceReuseRequest.requestedExecutionWorkspaceId + : null, issueId, }); - const reusedExecutionWorkspace = shouldReuseExisting && existingExecutionWorkspace - ? await ensurePersistedExecutionWorkspaceAvailable({ + const { executionWorkspace, reusedExecutionWorkspace, policy: resolvedWorkspaceReusePolicy } = + await provisionExecutionWorkspaceForFreshnessDecision({ + requestedShouldReuseExisting, + existingExecutionWorkspaceId: workspaceReuseRequest.requestedExecutionWorkspaceId, + issueRef, + runId: run.id, + workspaceConfigFreshness, + restoreExistingWorkspace: reusableExistingExecutionWorkspace + ? () => ensurePersistedExecutionWorkspaceAvailable({ + base: executionWorkspaceBase, + workspace: { + id: reusableExistingExecutionWorkspace.id, + mode: reusableExistingExecutionWorkspace.mode, + strategyType: reusableExistingExecutionWorkspace.strategyType, + cwd: reusableExistingExecutionWorkspace.cwd, + providerRef: reusableExistingExecutionWorkspace.providerRef, + projectId: reusableExistingExecutionWorkspace.projectId, + projectWorkspaceId: reusableExistingExecutionWorkspace.projectWorkspaceId, + repoUrl: reusableExistingExecutionWorkspace.repoUrl, + baseRef: reusableExistingExecutionWorkspace.baseRef, + branchName: reusableExistingExecutionWorkspace.branchName, + metadata: reusableExistingExecutionWorkspace.metadata as Record | null, + config: { + provisionCommand: + configSnapshot?.provisionCommand + ?? reusableExistingExecutionWorkspace.config?.provisionCommand + ?? projectExecutionWorkspacePolicy?.workspaceStrategy?.provisionCommand + ?? null, + }, + }, + issue: issueRef, + agent: { + id: agent.id, + name: agent.name, + companyId: agent.companyId, + }, + recorder: workspaceOperationRecorder, + }) + : null, + realizeWorkspace: () => realizeExecutionWorkspace({ base: executionWorkspaceBase, - workspace: { - id: existingExecutionWorkspace.id, - mode: existingExecutionWorkspace.mode, - strategyType: existingExecutionWorkspace.strategyType, - cwd: existingExecutionWorkspace.cwd, - providerRef: existingExecutionWorkspace.providerRef, - projectId: existingExecutionWorkspace.projectId, - projectWorkspaceId: existingExecutionWorkspace.projectWorkspaceId, - repoUrl: existingExecutionWorkspace.repoUrl, - baseRef: existingExecutionWorkspace.baseRef, - branchName: existingExecutionWorkspace.branchName, - metadata: existingExecutionWorkspace.metadata as Record | null, - config: { - provisionCommand: - configSnapshot?.provisionCommand - ?? existingExecutionWorkspace.config?.provisionCommand - ?? projectExecutionWorkspacePolicy?.workspaceStrategy?.provisionCommand - ?? null, - }, - }, + config: hostExecutionWorkspaceConfig, issue: issueRef, agent: { id: agent.id, @@ -10246,36 +10423,29 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) companyId: agent.companyId, }, recorder: workspaceOperationRecorder, - }) - : null; - const executionWorkspace = reusedExecutionWorkspace ?? await realizeExecutionWorkspace({ - base: executionWorkspaceBase, - config: hostExecutionWorkspaceConfig, - issue: issueRef, - agent: { - id: agent.id, - name: agent.name, - companyId: agent.companyId, - }, - recorder: workspaceOperationRecorder, - }); + }), + }); const resolvedProjectId = executionWorkspace.projectId ?? issueRef?.projectId ?? executionProjectId ?? null; const resolvedProjectWorkspaceId = issueRef?.projectWorkspaceId ?? resolvedWorkspace.workspaceId ?? null; let persistedExecutionWorkspace = null; const nextExecutionWorkspaceMetadata = mergeExecutionWorkspaceMetadataForPersistence({ - existingMetadata: shouldReuseExisting ? existingExecutionWorkspace?.metadata ?? null : null, + existingMetadata: resolvedWorkspaceReusePolicy.shouldRestoreExistingWorkspace + ? reusableExistingExecutionWorkspace?.metadata ?? null + : null, source: executionWorkspace.source, createdByRuntime: executionWorkspace.created, configSnapshot, - shouldReuseExisting, - shouldRefreshConfigSnapshot: shouldRefreshWorkspaceConfigSnapshot, - workspaceConfigMetadata: latestWorkspaceConfigMetadata, + shouldReuseExisting: resolvedWorkspaceReusePolicy.shouldRestoreExistingWorkspace, + shouldRefreshConfigSnapshot: resolvedWorkspaceReusePolicy.shouldRefreshWorkspaceConfigSnapshot, + workspaceConfigMetadata: resolvedWorkspaceReusePolicy.shouldPersistLatestWorkspaceConfigMetadata + ? latestWorkspaceConfigMetadata + : null, baseRef: executionWorkspace.repoRef, baseRefSha: executionWorkspace.baseRefSha ?? null, }); try { - persistedExecutionWorkspace = shouldReuseExisting && existingExecutionWorkspace - ? await executionWorkspacesSvc.update(existingExecutionWorkspace.id, { + persistedExecutionWorkspace = resolvedWorkspaceReusePolicy.shouldRestoreExistingWorkspace && reusableExistingExecutionWorkspace + ? await executionWorkspacesSvc.update(reusableExistingExecutionWorkspace.id, { cwd: executionWorkspace.cwd, repoUrl: executionWorkspace.repoUrl, baseRef: executionWorkspace.repoRef, @@ -10319,7 +10489,10 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) try { await cleanupExecutionWorkspaceArtifacts({ workspace: { - id: existingExecutionWorkspace?.id ?? `transient-${run.id}`, + id: + reusableExistingExecutionWorkspace?.id + ?? workspaceReuseRequest.requestedExecutionWorkspaceId + ?? `transient-${run.id}`, cwd: executionWorkspace.cwd, providerType: executionWorkspace.strategy === "git_worktree" ? "git_worktree" : "local_fs", providerRef: executionWorkspace.worktreePath, @@ -10361,20 +10534,20 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) recorder: workspaceOperationRecorder, runId: run.id, decision: workspaceConfigFreshness, - hasExistingWorkspace: Boolean(existingExecutionWorkspace), + hasExistingWorkspace: Boolean(reusableExistingExecutionWorkspace), reuseRequested: requestedShouldReuseExisting, workspaceReused: Boolean(reusedExecutionWorkspace), - configSnapshotRefreshed: shouldRefreshWorkspaceConfigSnapshot, - previousWorkspaceId: existingExecutionWorkspace?.id ?? null, + configSnapshotRefreshed: resolvedWorkspaceReusePolicy.shouldRefreshWorkspaceConfigSnapshot, + previousWorkspaceId: workspaceReuseRequest.requestedExecutionWorkspaceId, activeWorkspaceId: persistedExecutionWorkspace?.id ?? null, }); if ( - existingExecutionWorkspace && + reusableExistingExecutionWorkspace && persistedExecutionWorkspace && - existingExecutionWorkspace.id !== persistedExecutionWorkspace.id && - existingExecutionWorkspace.status === "active" + reusableExistingExecutionWorkspace.id !== persistedExecutionWorkspace.id && + reusableExistingExecutionWorkspace.status === "active" ) { - await executionWorkspacesSvc.update(existingExecutionWorkspace.id, { + await executionWorkspacesSvc.update(reusableExistingExecutionWorkspace.id, { status: "idle", cleanupReason: null, }); @@ -10644,12 +10817,12 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) reasons: workspaceConfigFreshness.reasons, reuseRequested: requestedShouldReuseExisting, workspaceReused: Boolean(reusedExecutionWorkspace), - configSnapshotRefreshed: shouldRefreshWorkspaceConfigSnapshot, + configSnapshotRefreshed: resolvedWorkspaceReusePolicy.shouldRefreshWorkspaceConfigSnapshot, storedFingerprintPresent: workspaceConfigFreshness.storedFingerprintPresent, storedFingerprint: workspaceConfigFreshness.storedFingerprint, inferredFingerprint: workspaceConfigFreshness.inferredFingerprint, nextFingerprint: workspaceConfigFreshness.nextFingerprint, - previousWorkspaceId: existingExecutionWorkspace?.id ?? null, + previousWorkspaceId: workspaceReuseRequest.requestedExecutionWorkspaceId, activeWorkspaceId: persistedExecutionWorkspace?.id ?? null, }, };