From 7a88de4887450f853d75fdd8f5e0bad7848f3aab Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 12 Aug 2026 22:28:17 +0000 Subject: [PATCH] fix: preserve pinned execution workspaces Co-Authored-By: Paperclip --- doc/SPEC-implementation.md | 1 + .../heartbeat-workspace-busy.test.ts | 100 +++++++++++++++++ .../heartbeat-workspace-session.test.ts | 36 +++++- server/src/__tests__/issues-service.test.ts | 65 +++++++++++ .../workspace-runtime-routes-authz.test.ts | 77 ++++++++++++- .../services/execution-workspace-policy.ts | 2 +- server/src/services/heartbeat.ts | 103 +++++++++++++++--- server/src/services/issues.ts | 73 ++++++++++++- 8 files changed, 428 insertions(+), 29 deletions(-) diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index 2584bdc7244..3e1fc25f1e9 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -258,6 +258,7 @@ Invariants: - single assignee only - task must trace to company goal chain via `goal_id`, `parent_id`, or project-goal linkage - `in_progress` requires assignee +- a non-null `execution_workspace_id` with an absent or `reuse_existing` preference is a durable workspace pin: create/update normalizes an absent preference to `reuse_existing`, realization must not silently replace the pin, and unavailable pins fail with a named workspace-reuse reason - terminal states: `done | cancelled` ## 7.7 `issue_comments` diff --git a/server/src/__tests__/heartbeat-workspace-busy.test.ts b/server/src/__tests__/heartbeat-workspace-busy.test.ts index 1b6ec47da79..d4418591444 100644 --- a/server/src/__tests__/heartbeat-workspace-busy.test.ts +++ b/server/src/__tests__/heartbeat-workspace-busy.test.ts @@ -45,6 +45,8 @@ import { heartbeatService, } from "../services/heartbeat.ts"; import { instanceSettingsService } from "../services/instance-settings.ts"; +import { issueService } from "../services/issues.ts"; +import { assertCanManageExecutionWorkspaceRuntimeServices } from "../routes/workspace-runtime-service-authz.ts"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; @@ -488,6 +490,104 @@ describeEmbeddedPostgres("shared-workspace run serialization", () => { expect(retryRuns).toHaveLength(0); }); + it("preserves an explicit isolated workspace pin under shared-workspace contention", async () => { + const fixture = await seedWorkspaceFixture({ + issueWorkspaceSettings: { sharedWorkspaceConcurrency: "allow" }, + }); + const sourceIssueId = randomUUID(); + const pinnedExecutionWorkspaceId = randomUUID(); + + await db.insert(issues).values({ + id: sourceIssueId, + companyId: fixture.companyId, + title: "Completed source issue", + status: "done", + priority: "medium", + responsibleUserId: "responsible-user", + assigneeAgentId: fixture.holderAgentId, + projectId: fixture.projectId, + projectWorkspaceId: fixture.projectWorkspaceId, + issueNumber: 3, + identifier: `SRC-${sourceIssueId.slice(0, 8)}`, + completedAt: new Date(), + }); + await db.insert(executionWorkspaces).values({ + id: pinnedExecutionWorkspaceId, + companyId: fixture.companyId, + projectId: fixture.projectId, + projectWorkspaceId: fixture.projectWorkspaceId, + sourceIssueId, + mode: "isolated_workspace", + strategyType: "project_primary", + name: "Pinned isolated workspace", + status: "active", + cwd: workspaceCwd, + providerType: "local_fs", + }); + await db + .update(issues) + .set({ executionWorkspaceId: pinnedExecutionWorkspaceId }) + .where(eq(issues.id, sourceIssueId)); + + const pinnedIssue = await issueService(db).create(fixture.companyId, { + title: "Run in the pinned isolated workspace", + status: "in_progress", + priority: "high", + assigneeAgentId: fixture.agentId, + projectId: fixture.projectId, + projectWorkspaceId: fixture.projectWorkspaceId, + executionWorkspaceId: pinnedExecutionWorkspaceId, + }); + expect(pinnedIssue.executionWorkspacePreference).toBe("reuse_existing"); + expect(pinnedIssue.executionWorkspaceSettings).toEqual({ + mode: "isolated_workspace", + workspaceStrategy: { type: "project_primary" }, + }); + + const run = await heartbeat.invoke( + fixture.agentId, + "assignment", + { issueId: pinnedIssue.id, wakeReason: "issue_assigned" }, + "system", + ); + expect(run).not.toBeNull(); + + const finishedRun = await waitForRunToLeaveActiveStates(run!.id); + expect(finishedRun?.status).toBe("succeeded"); + expect(finishedRun?.errorCode).not.toBe(WORKSPACE_BUSY_ERROR_CODE); + expect(executedRunIds).toContain(run!.id); + + const realizedIssue = await db + .select({ + executionWorkspaceId: issues.executionWorkspaceId, + executionWorkspacePreference: issues.executionWorkspacePreference, + executionWorkspaceSettings: issues.executionWorkspaceSettings, + }) + .from(issues) + .where(eq(issues.id, pinnedIssue.id)) + .then((rows) => rows[0] ?? null); + expect(realizedIssue).toMatchObject({ + executionWorkspaceId: pinnedExecutionWorkspaceId, + executionWorkspacePreference: "reuse_existing", + executionWorkspaceSettings: { + mode: "isolated_workspace", + workspaceStrategy: { type: "project_primary" }, + }, + }); + + await expect(assertCanManageExecutionWorkspaceRuntimeServices(db, { + actor: { + type: "agent", + agentId: fixture.agentId, + companyId: fixture.companyId, + source: "agent_key", + }, + } as any, { + companyId: fixture.companyId, + executionWorkspaceId: pinnedExecutionWorkspaceId, + })).resolves.toBeUndefined(); + }); + it("defers a run whose issue targets a busy shared workspace and schedules a bounded retry", async () => { const fixture = await seedWorkspaceFixture(); diff --git a/server/src/__tests__/heartbeat-workspace-session.test.ts b/server/src/__tests__/heartbeat-workspace-session.test.ts index a9693828626..a7036a99ed9 100644 --- a/server/src/__tests__/heartbeat-workspace-session.test.ts +++ b/server/src/__tests__/heartbeat-workspace-session.test.ts @@ -24,6 +24,8 @@ import { preflightLowTrustWorkspaceIsolation, prioritizeProjectWorkspaceCandidatesForRun, parseSessionCompactionPolicy, + PINNED_EXECUTION_WORKSPACE_RESTORE_FAILED_ERROR_CODE, + PINNED_EXECUTION_WORKSPACE_UNAVAILABLE_ERROR_CODE, provisionExecutionWorkspaceForFreshnessDecision, reconcileReusedExecutionWorkspaceProjectWorkspaceId, resolveExecutionWorkspaceConfigFreshness, @@ -1633,10 +1635,31 @@ describe("effective run execution workspace config freshness", () => { throw new Error("restore command failed"); }, realizeWorkspace, - })).rejects.toThrow(/restore command failed/); + })).rejects.toMatchObject({ + code: PINNED_EXECUTION_WORKSPACE_RESTORE_FAILED_ERROR_CODE, + message: expect.stringContaining("restore command failed"), + resultJson: { + executionWorkspaceReuse: expect.objectContaining({ + reason: PINNED_EXECUTION_WORKSPACE_RESTORE_FAILED_ERROR_CODE, + executionWorkspaceId: "workspace-old", + }), + }, + }); expect(realizeWorkspace).not.toHaveBeenCalled(); }); + it("treats a workspace id without a preference as an explicit reuse pin", () => { + expect(resolveExecutionWorkspaceReuseRequestForIssue({ + issueExecutionWorkspaceId: "workspace-old", + issueExecutionWorkspacePreference: null, + existingExecutionWorkspaceStatus: "active", + })).toEqual({ + requestedExecutionWorkspaceId: "workspace-old", + requestedShouldReuseExisting: true, + existingExecutionWorkspaceAvailable: true, + }); + }); + it.each([ { name: "missing", status: null }, { name: "archived", status: "archived" }, @@ -1672,7 +1695,16 @@ describe("effective run execution workspace config freshness", () => { ? async () => ({ id: "workspace-old", warnings: [] }) : null, realizeWorkspace, - })).rejects.toThrow(/could not be restored/); + })).rejects.toMatchObject({ + code: PINNED_EXECUTION_WORKSPACE_UNAVAILABLE_ERROR_CODE, + message: expect.stringContaining("could not be restored"), + resultJson: { + executionWorkspaceReuse: expect.objectContaining({ + reason: PINNED_EXECUTION_WORKSPACE_UNAVAILABLE_ERROR_CODE, + executionWorkspaceId: "workspace-old", + }), + }, + }); expect(realizeWorkspace).not.toHaveBeenCalled(); }); diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index 825e0048fd1..2ef60f16a30 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -2853,6 +2853,71 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => { }); }); + it("normalizes an explicit execution workspace id into a durable reuse pin", async () => { + const companyId = randomUUID(); + const projectId = randomUUID(); + const projectWorkspaceId = randomUUID(); + const executionWorkspaceId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true }); + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Workspace project", + status: "in_progress", + }); + await db.insert(projectWorkspaces).values({ + id: projectWorkspaceId, + companyId, + projectId, + name: "Primary workspace", + }); + await db.insert(executionWorkspaces).values({ + id: executionWorkspaceId, + companyId, + projectId, + projectWorkspaceId, + mode: "isolated_workspace", + strategyType: "git_worktree", + name: "Pinned workspace", + status: "active", + providerType: "git_worktree", + providerRef: `/tmp/${executionWorkspaceId}`, + }); + + const created = await svc.create(companyId, { + title: "Pinned issue", + projectId, + executionWorkspaceId, + }); + + expect(created.executionWorkspaceId).toBe(executionWorkspaceId); + expect(created.executionWorkspacePreference).toBe("reuse_existing"); + expect(created.executionWorkspaceSettings).toEqual({ + mode: "isolated_workspace", + workspaceStrategy: { type: "git_worktree" }, + }); + + const unpinned = await svc.create(companyId, { + title: "Pin on update", + projectId, + }); + const updated = await svc.update(unpinned.id, { executionWorkspaceId }); + + expect(updated?.executionWorkspaceId).toBe(executionWorkspaceId); + expect(updated?.executionWorkspacePreference).toBe("reuse_existing"); + expect(updated?.executionWorkspaceSettings).toEqual({ + mode: "isolated_workspace", + workspaceStrategy: { type: "git_worktree" }, + }); + }); + it("inherits responsible user for agent-created child issues", async () => { const companyId = randomUUID(); const agentId = randomUUID(); diff --git a/server/src/__tests__/workspace-runtime-routes-authz.test.ts b/server/src/__tests__/workspace-runtime-routes-authz.test.ts index e13be3d6171..4f023397036 100644 --- a/server/src/__tests__/workspace-runtime-routes-authz.test.ts +++ b/server/src/__tests__/workspace-runtime-routes-authz.test.ts @@ -25,7 +25,12 @@ const mockEnvironmentService = vi.hoisted(() => ({ getById: vi.fn(), })); -const mockWorkspaceOperationService = vi.hoisted(() => ({})); +const mockWorkspaceOperationRecorder = vi.hoisted(() => ({ + recordOperation: vi.fn(async (input: { run: () => Promise }) => await input.run()), +})); +const mockWorkspaceOperationService = vi.hoisted(() => ({ + createRecorder: vi.fn(() => mockWorkspaceOperationRecorder), +})); const mockHeartbeatService = vi.hoisted(() => ({})); const mockLogActivity = vi.hoisted(() => vi.fn()); const mockGetTelemetryClient = vi.hoisted(() => vi.fn()); @@ -34,6 +39,8 @@ const mockAccessService = vi.hoisted(() => ({ })); const mockAssertCanManageProjectWorkspaceRuntimeServices = vi.hoisted(() => vi.fn()); const mockAssertCanManageExecutionWorkspaceRuntimeServices = vi.hoisted(() => vi.fn()); +const mockEnsurePersistedExecutionWorkspaceAvailable = vi.hoisted(() => vi.fn()); +const mockStartRuntimeServicesForWorkspaceControl = vi.hoisted(() => vi.fn()); vi.mock("../telemetry.js", () => ({ getTelemetryClient: mockGetTelemetryClient, @@ -51,8 +58,15 @@ vi.mock("../services/index.js", () => ({ })); vi.mock("../services/workspace-runtime.js", () => ({ + buildWorkspaceRuntimeDesiredStatePatch: vi.fn(() => ({ + desiredState: "running", + serviceStates: null, + })), cleanupExecutionWorkspaceArtifacts: vi.fn(), - startRuntimeServicesForWorkspaceControl: vi.fn(), + ensurePersistedExecutionWorkspaceAvailable: mockEnsurePersistedExecutionWorkspaceAvailable, + listConfiguredRuntimeServiceEntries: vi.fn(() => [{ serviceIndex: 0 }]), + runWorkspaceJobForControl: vi.fn(), + startRuntimeServicesForWorkspaceControl: mockStartRuntimeServicesForWorkspaceControl, stopRuntimeServicesForExecutionWorkspace: vi.fn(), stopRuntimeServicesForProjectWorkspace: vi.fn(), })); @@ -79,8 +93,15 @@ function registerWorkspaceRouteMocks() { })); vi.doMock("../services/workspace-runtime.js", () => ({ + buildWorkspaceRuntimeDesiredStatePatch: vi.fn(() => ({ + desiredState: "running", + serviceStates: null, + })), cleanupExecutionWorkspaceArtifacts: vi.fn(), - startRuntimeServicesForWorkspaceControl: vi.fn(), + ensurePersistedExecutionWorkspaceAvailable: mockEnsurePersistedExecutionWorkspaceAvailable, + listConfiguredRuntimeServiceEntries: vi.fn(() => [{ serviceIndex: 0 }]), + runWorkspaceJobForControl: vi.fn(), + startRuntimeServicesForWorkspaceControl: mockStartRuntimeServicesForWorkspaceControl, stopRuntimeServicesForExecutionWorkspace: vi.fn(), stopRuntimeServicesForProjectWorkspace: vi.fn(), })); @@ -129,6 +150,10 @@ async function createExecutionWorkspaceApp(actor: Record) { next(); }); app.use("/api", executionWorkspaceRoutes({} as any)); + app.use((err: unknown, _req: unknown, _res: unknown, next: (error: unknown) => void) => { + app.locals.lastRouteError = err; + next(err); + }); app.use(errorHandler); return app; } @@ -283,6 +308,13 @@ describe.sequential("workspace runtime service route authorization", () => { mockExecutionWorkspaceService.update.mockResolvedValue(buildExecutionWorkspace()); mockAssertCanManageProjectWorkspaceRuntimeServices.mockResolvedValue(undefined); mockAssertCanManageExecutionWorkspaceRuntimeServices.mockResolvedValue(undefined); + mockEnsurePersistedExecutionWorkspaceAvailable.mockResolvedValue({ + strategy: "project_primary", + cwd: "/tmp/workspace", + warnings: [], + created: false, + }); + mockStartRuntimeServicesForWorkspaceControl.mockResolvedValue([]); }); it("rejects agent callers for project workspace runtime service mutations when workspace auth denies access", async () => { @@ -475,6 +507,45 @@ describe.sequential("workspace runtime service route authorization", () => { expect(mockAssertCanManageExecutionWorkspaceRuntimeServices).toHaveBeenCalled(); }, 15000); + it("returns 200 for an agent allowed to start its pinned execution workspace runtime", async () => { + const workspace = buildExecutionWorkspace({ + id: executionWorkspaceId, + projectId: null, + config: { + workspaceRuntime: { + services: [{ name: "dev", command: "pnpm dev", port: 3100 }], + }, + desiredState: "stopped", + }, + }); + mockExecutionWorkspaceService.getById.mockResolvedValue(workspace); + mockExecutionWorkspaceService.update.mockResolvedValue(workspace); + const app = await createExecutionWorkspaceApp({ + type: "agent", + agentId: "agent-1", + companyId: "company-1", + source: "agent_key", + runId: "run-1", + }); + + const res = await request(app) + .post(`/api/execution-workspaces/${executionWorkspaceId}/runtime-services/start`) + .send({}); + + expect( + res.status, + app.locals.lastRouteError instanceof Error + ? app.locals.lastRouteError.stack ?? app.locals.lastRouteError.message + : JSON.stringify(res.body), + ).toBe(200); + expect(mockAssertCanManageExecutionWorkspaceRuntimeServices).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ companyId: "company-1", executionWorkspaceId }), + ); + expect(mockStartRuntimeServicesForWorkspaceControl).toHaveBeenCalled(); + }, 15000); + it("rejects agent callers that patch execution workspace command config", async () => { mockExecutionWorkspaceService.getById.mockResolvedValue(buildExecutionWorkspace({ id: executionWorkspaceId })); const app = await createExecutionWorkspaceApp({ diff --git a/server/src/services/execution-workspace-policy.ts b/server/src/services/execution-workspace-policy.ts index 1c6b3ccc93e..901a246f553 100644 --- a/server/src/services/execution-workspace-policy.ts +++ b/server/src/services/execution-workspace-policy.ts @@ -311,7 +311,7 @@ export function defaultIssueExecutionWorkspaceSettingsForProject( export function issueExecutionWorkspaceModeForPersistedWorkspace( mode: string | null | undefined, -): IssueExecutionWorkspaceSettings["mode"] { +): ParsedExecutionWorkspaceMode { if (mode === null || mode === undefined) { return "agent_default"; } diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 2500ac42b60..a14a3389a5a 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -394,6 +394,10 @@ const WORKSPACE_VALIDATION_FAILURE_CODE = "workspace_validation_failed"; const WORKSPACE_VALIDATION_RECOVERY_CAUSE = "workspace_validation_failed"; const CONFIGURATION_INCOMPLETE_FAILURE_CODE = "configuration_incomplete"; const CONFIGURATION_INCOMPLETE_RECOVERY_CAUSE = "configuration_incomplete"; +export const PINNED_EXECUTION_WORKSPACE_RESTORE_FAILED_ERROR_CODE = + "pinned_execution_workspace_restore_failed"; +export const PINNED_EXECUTION_WORKSPACE_UNAVAILABLE_ERROR_CODE = + "pinned_execution_workspace_unavailable"; const EXECUTION_REVIEW_PARTICIPANT_RECOVERY_RETRY_REASON = "execution_review_participant_recovery"; const EXECUTION_REVIEW_PARTICIPANT_RECOVERY_WAKE_REASON = "execution_review_participant_recovery"; const EXECUTION_REVIEW_PARTICIPANT_RECOVERY_CAUSE = "execution_review_participant_recovery"; @@ -4334,7 +4338,11 @@ export function resolveExecutionWorkspaceReuseRequestForIssue(input: { }): ExecutionWorkspaceReuseRequestForIssue { const requestedExecutionWorkspaceId = readNonEmptyString(input.issueExecutionWorkspaceId); const requestedShouldReuseExisting = - input.issueExecutionWorkspacePreference === "reuse_existing" && requestedExecutionWorkspaceId !== null; + requestedExecutionWorkspaceId !== null && + ( + input.issueExecutionWorkspacePreference === "reuse_existing" || + input.issueExecutionWorkspacePreference == null + ); return { requestedExecutionWorkspaceId, @@ -4365,8 +4373,12 @@ export function resolveExecutionWorkspaceReuseProvisioningPolicy(input: { }; } -function formatInheritedExecutionWorkspaceReuseFailure(input: { - reason: "inherited_workspace_reuse_failed" | "inherited_workspace_reuse_unavailable"; +type ExecutionWorkspaceReuseFailureReason = + | typeof PINNED_EXECUTION_WORKSPACE_RESTORE_FAILED_ERROR_CODE + | typeof PINNED_EXECUTION_WORKSPACE_UNAVAILABLE_ERROR_CODE; + +function formatPinnedExecutionWorkspaceReuseFailure(input: { + reason: ExecutionWorkspaceReuseFailureReason; issueRef: WorkspaceReuseIssueRef; runId: string; executionWorkspaceId: string | null | undefined; @@ -4380,16 +4392,47 @@ function formatInheritedExecutionWorkspaceReuseFailure(input: { : 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 remediation = input.reason === PINNED_EXECUTION_WORKSPACE_RESTORE_FAILED_ERROR_CODE + ? "Inspect the pinned execution workspace restore/provision logs, repair or unarchive the workspace, or intentionally clear the issue's execution workspace binding before retrying." + : "Repair or unarchive the pinned execution workspace, or intentionally clear the issue's execution 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.`; + ? `Issue ${issueLabel} pinned execution workspace ${workspaceLabel}, but the workspace could not be restored because ${causeMessage}.` + : `Issue ${issueLabel} pinned execution workspace ${workspaceLabel}, but the workspace could not be restored.`; return `${message} ${remediation}`; } +export class ExecutionWorkspaceReuseFailure extends Error { + code: ExecutionWorkspaceReuseFailureReason; + resultJson: Record; + + constructor(input: { + reason: ExecutionWorkspaceReuseFailureReason; + issueRef: WorkspaceReuseIssueRef; + runId: string; + executionWorkspaceId: string | null | undefined; + workspaceConfigFreshness: ExecutionWorkspaceConfigFreshnessDecision; + cause?: unknown; + }) { + super(formatPinnedExecutionWorkspaceReuseFailure(input)); + this.name = "ExecutionWorkspaceReuseFailure"; + this.code = input.reason; + this.resultJson = { + executionWorkspaceReuse: { + reason: input.reason, + issueId: input.issueRef?.id ?? null, + issueIdentifier: input.issueRef?.identifier ?? null, + executionWorkspaceId: input.executionWorkspaceId ?? null, + workspaceConfigFreshnessAction: input.workspaceConfigFreshness.action, + }, + }; + } +} + +function isExecutionWorkspaceReuseFailure(error: unknown): error is ExecutionWorkspaceReuseFailure { + return error instanceof ExecutionWorkspaceReuseFailure; +} + export async function provisionExecutionWorkspaceForFreshnessDecision(input: { requestedShouldReuseExisting: boolean; existingExecutionWorkspaceId?: string | null; @@ -4418,15 +4461,15 @@ export async function provisionExecutionWorkspaceForFreshnessDecision = {}; @@ -16085,11 +16141,13 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) ); const workspaceValidationFailure = isWorkspaceValidationFailure(err) ? err : null; const configurationIncompleteFailure = isConfigurationIncompleteFailure(err) ? err : null; + const executionWorkspaceReuseFailure = isExecutionWorkspaceReuseFailure(err) ? err : null; const recordedResponsibleUserDenialCode = normalizeResponsibleUserDenialCode((await getRun(run.id).catch(() => null))?.errorCode); const failureErrorCode = workspaceValidationFailure?.code ?? configurationIncompleteFailure?.code + ?? executionWorkspaceReuseFailure?.code ?? recordedResponsibleUserDenialCode ?? "adapter_failed"; logger.error({ err, runId }, "heartbeat execution failed"); @@ -16117,7 +16175,11 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) resultJson: mergeRunStopMetadataForAgent(agent, "failed", { errorCode: failureErrorCode, errorMessage: message, - resultJson: workspaceValidationFailure?.resultJson ?? configurationIncompleteFailure?.resultJson ?? null, + resultJson: + workspaceValidationFailure?.resultJson + ?? configurationIncompleteFailure?.resultJson + ?? executionWorkspaceReuseFailure?.resultJson + ?? null, }), stdoutExcerpt, stderrExcerpt, @@ -16229,11 +16291,13 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) // recovery path routes it to a human owner instead of looping retries. const workspaceValidationSetupFailure = isWorkspaceValidationFailure(outerErr) ? outerErr : null; const configurationIncompleteSetupFailure = isConfigurationIncompleteFailure(outerErr) ? outerErr : null; + const executionWorkspaceReuseSetupFailure = isExecutionWorkspaceReuseFailure(outerErr) ? outerErr : null; const recordedResponsibleUserDenialCode = normalizeResponsibleUserDenialCode((await getRun(runId).catch(() => null))?.errorCode); const setupFailureErrorCode = workspaceValidationSetupFailure?.code ?? configurationIncompleteSetupFailure?.code ?? + executionWorkspaceReuseSetupFailure?.code ?? recordedResponsibleUserDenialCode ?? "setup_failed"; logger.error({ err: outerErr, runId }, "heartbeat execution setup failed"); @@ -16247,7 +16311,10 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) errorCode: setupFailureErrorCode, errorMessage: message, resultJson: - workspaceValidationSetupFailure?.resultJson ?? configurationIncompleteSetupFailure?.resultJson ?? null, + workspaceValidationSetupFailure?.resultJson + ?? configurationIncompleteSetupFailure?.resultJson + ?? executionWorkspaceReuseSetupFailure?.resultJson + ?? null, }), } : {}), }).catch(() => ({ run: null, updated: false as const })); diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 93b67e052d2..683b218d162 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -247,6 +247,20 @@ function assertExplicitPinnedWorktreeIssueRunnable(input: { } } +function normalizePinnedExecutionWorkspaceSettings( + settings: Record | null, + workspace: { mode: string; strategyType: string }, +) { + return { + ...(settings ?? {}), + mode: issueExecutionWorkspaceModeForPersistedWorkspace(workspace.mode), + workspaceStrategy: { + ...parseObject(settings?.workspaceStrategy), + type: workspace.strategyType, + }, + }; +} + function readStringFromRecord(record: unknown, key: string) { if (!record || typeof record !== "object") return null; const value = (record as Record)[key]; @@ -4813,6 +4827,8 @@ export function issueService(db: Db) { id: executionWorkspaces.id, companyId: executionWorkspaces.companyId, projectId: executionWorkspaces.projectId, + mode: executionWorkspaces.mode, + strategyType: executionWorkspaces.strategyType, }) .from(executionWorkspaces) .where(eq(executionWorkspaces.id, executionWorkspaceId)) @@ -6989,6 +7005,11 @@ export function issueService(db: Db) { let executionWorkspacePreference = issueData.executionWorkspacePreference ?? null; let executionWorkspaceSettings = (issueData.executionWorkspaceSettings as Record | null | undefined) ?? null; + let validatedExecutionWorkspace: { + projectId: string; + mode: string; + strategyType: string; + } | null = null; const workspaceInheritanceIssueId = skipExecutionWorkspaceInheritance ? null : inheritExecutionWorkspaceFromIssueId ?? issueData.parentId ?? null; @@ -7033,6 +7054,7 @@ export function issueService(db: Db) { } if (issueData.projectId == null && executionWorkspaceId) { const workspace = await assertValidExecutionWorkspace(companyId, null, executionWorkspaceId, tx); + validatedExecutionWorkspace = workspace; issueData.projectId = workspace.projectId; } const projectGoalId = await getProjectDefaultGoalId(tx, companyId, issueData.projectId); @@ -7089,7 +7111,19 @@ export function issueService(db: Db) { await assertValidProjectWorkspace(companyId, issueData.projectId, projectWorkspaceId, tx); } if (executionWorkspaceId) { - await assertValidExecutionWorkspace(companyId, issueData.projectId, executionWorkspaceId, tx); + validatedExecutionWorkspace ??= await assertValidExecutionWorkspace( + companyId, + issueData.projectId, + executionWorkspaceId, + tx, + ); + } + if (executionWorkspaceId && executionWorkspacePreference == null && validatedExecutionWorkspace) { + executionWorkspacePreference = "reuse_existing"; + executionWorkspaceSettings = normalizePinnedExecutionWorkspaceSettings( + executionWorkspaceSettings, + validatedExecutionWorkspace, + ); } if (isolatedWorkspacesEnabled && issueData.executionWorkspaceSettings !== undefined) { assertExplicitPinnedWorktreeIssueRunnable({ @@ -7573,11 +7607,11 @@ export function issueService(db: Db) { issueData.projectWorkspaceId !== undefined ? issueData.projectWorkspaceId : existing.projectWorkspaceId; const nextExecutionWorkspaceId = issueData.executionWorkspaceId !== undefined ? issueData.executionWorkspaceId : existing.executionWorkspaceId; - const nextExecutionWorkspacePreference = + let nextExecutionWorkspacePreference = issueData.executionWorkspacePreference !== undefined ? issueData.executionWorkspacePreference : existing.executionWorkspacePreference; - const nextExecutionWorkspaceSettings = + let nextExecutionWorkspaceSettings = issueData.executionWorkspaceSettings !== undefined ? parseIssueExecutionWorkspaceSettings(issueData.executionWorkspaceSettings) : parseIssueExecutionWorkspaceSettings(existing.executionWorkspaceSettings); @@ -7587,7 +7621,11 @@ export function issueService(db: Db) { : null; } let validatedProjectWorkspace: { projectId: string } | null = null; - let validatedExecutionWorkspace: { projectId: string } | null = null; + let validatedExecutionWorkspace: { + projectId: string; + mode: string; + strategyType: string; + } | null = null; if (!nextProjectId && nextProjectWorkspaceId) { const workspace = await assertValidProjectWorkspace(existing.companyId, null, nextProjectWorkspaceId); validatedProjectWorkspace = workspace; @@ -7607,9 +7645,34 @@ export function issueService(db: Db) { } if (nextExecutionWorkspaceId) { if (!validatedExecutionWorkspace) { - await assertValidExecutionWorkspace(existing.companyId, nextProjectId, nextExecutionWorkspaceId); + validatedExecutionWorkspace = await assertValidExecutionWorkspace( + existing.companyId, + nextProjectId, + nextExecutionWorkspaceId, + ); } } + if ( + nextExecutionWorkspaceId && + nextExecutionWorkspacePreference == null && + validatedExecutionWorkspace && + ( + issueData.executionWorkspaceId !== undefined || + issueData.executionWorkspacePreference !== undefined + ) + ) { + nextExecutionWorkspacePreference = "reuse_existing"; + nextExecutionWorkspaceSettings = parseIssueExecutionWorkspaceSettings( + normalizePinnedExecutionWorkspaceSettings( + nextExecutionWorkspaceSettings ? { ...nextExecutionWorkspaceSettings } : null, + validatedExecutionWorkspace, + ), + ); + patch.executionWorkspacePreference = "reuse_existing"; + patch.executionWorkspaceSettings = nextExecutionWorkspaceSettings + ? { ...nextExecutionWorkspaceSettings } + : null; + } if (isolatedWorkspacesEnabled && issueData.executionWorkspaceSettings !== undefined) { assertExplicitPinnedWorktreeIssueRunnable({ projectId: nextProjectId ?? null,