fix: preserve pinned execution workspaces

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-13 21:16:41 +00:00
1 parent 463d2173e3
commit 7a88de4887
8 files changed
+428 -29

No files matched your search

+1
View File
@@ -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`
@@ -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();
@@ -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();
});
@@ -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();
@@ -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<unknown> }) => 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<string, unknown>) {
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({
@@ -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";
}
+85 -18
View File
@@ -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<string, unknown>;
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<T extends { warnings?: string[] }>(input: {
requestedShouldReuseExisting: boolean;
existingExecutionWorkspaceId?: string | null;
@@ -4418,15 +4461,15 @@ export async function provisionExecutionWorkspaceForFreshnessDecision<T extends
}
let restored: T | null = null;
let reuseFailure: string | null = null;
let reuseFailure: ExecutionWorkspaceReuseFailure | null = null;
try {
restored = (await input.restoreExistingWorkspace?.()) ?? null;
} catch (error) {
if (isWorkspaceValidationFailure(error)) {
throw error;
}
reuseFailure = formatInheritedExecutionWorkspaceReuseFailure({
reason: "inherited_workspace_reuse_failed",
reuseFailure = new ExecutionWorkspaceReuseFailure({
reason: PINNED_EXECUTION_WORKSPACE_RESTORE_FAILED_ERROR_CODE,
issueRef: input.issueRef,
runId: input.runId,
executionWorkspaceId: input.existingExecutionWorkspaceId,
@@ -4436,8 +4479,8 @@ export async function provisionExecutionWorkspaceForFreshnessDecision<T extends
}
if (!restored) {
reuseFailure = reuseFailure ?? formatInheritedExecutionWorkspaceReuseFailure({
reason: "inherited_workspace_reuse_unavailable",
reuseFailure = reuseFailure ?? new ExecutionWorkspaceReuseFailure({
reason: PINNED_EXECUTION_WORKSPACE_UNAVAILABLE_ERROR_CODE,
issueRef: input.issueRef,
runId: input.runId,
executionWorkspaceId: input.existingExecutionWorkspaceId,
@@ -4445,7 +4488,7 @@ export async function provisionExecutionWorkspaceForFreshnessDecision<T extends
});
}
if (reuseFailure) throw new Error(reuseFailure);
if (reuseFailure) throw reuseFailure;
if (!restored) {
throw new Error("Expected restored execution workspace after reuse fallback handling");
}
@@ -13816,7 +13859,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
issueSettings: issueExecutionWorkspaceSettings,
legacyUseProjectWorkspace: issueAssigneeOverrides?.useProjectWorkspace ?? null,
});
const requestedExecutionWorkspaceMode =
let requestedExecutionWorkspaceMode =
trustPreset.kind === "low_trust_review" && resolvedExecutionWorkspaceMode === "shared_workspace"
? "isolated_workspace"
: resolvedExecutionWorkspaceMode;
@@ -13981,6 +14024,19 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
const reusableExistingExecutionWorkspace = workspaceReuseRequest.existingExecutionWorkspaceAvailable
? existingExecutionWorkspace
: null;
if (
requestedShouldReuseExisting &&
reusableExistingExecutionWorkspace &&
(
issueExecutionWorkspaceSettings?.mode == null ||
issueExecutionWorkspaceSettings.mode === "inherit" ||
issueExecutionWorkspaceSettings.mode === "reuse_existing"
)
) {
requestedExecutionWorkspaceMode = issueExecutionWorkspaceModeForPersistedWorkspace(
reusableExistingExecutionWorkspace.mode,
);
}
const requestedReusableExecutionWorkspaceConfig = reusableExistingExecutionWorkspace?.config ?? null;
const localEnvironment = await environmentsSvc.ensureLocalEnvironment(agent.companyId);
const resolvedInstanceSettings = await instanceSettings.get();
@@ -14726,7 +14782,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
if (issueId && persistedExecutionWorkspace) {
const nextIssueWorkspaceMode = issueExecutionWorkspaceModeForPersistedWorkspace(persistedExecutionWorkspace.mode);
const shouldSwitchIssueToExistingWorkspace =
issueRef?.executionWorkspacePreference === "reuse_existing" ||
requestedShouldReuseExisting ||
requestedExecutionWorkspaceMode === "isolated_workspace" ||
requestedExecutionWorkspaceMode === "operator_branch";
const nextIssuePatch: Record<string, unknown> = {};
@@ -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 }));
+68 -5
View File
@@ -247,6 +247,20 @@ function assertExplicitPinnedWorktreeIssueRunnable(input: {
}
}
function normalizePinnedExecutionWorkspaceSettings(
settings: Record<string, unknown> | 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<string, unknown>)[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<string, unknown> | 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,