mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 12:37:55 +01:00
fix: preserve pinned execution workspaces
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
463d2173e3
commit
7a88de4887
8 files changed
+428
-29
No files matched your search
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user