diff --git a/server/src/__tests__/execution-workspace-reopen.test.ts b/server/src/__tests__/execution-workspace-reopen.test.ts index cdde33b18b2..a7ee37675df 100644 --- a/server/src/__tests__/execution-workspace-reopen.test.ts +++ b/server/src/__tests__/execution-workspace-reopen.test.ts @@ -21,6 +21,7 @@ import { EXECUTION_WORKSPACE_LIFECYCLE_GENERATION_METADATA_KEY, EXECUTION_WORKSPACE_REOPEN_FAILED_REASON, executionWorkspaceService, + metadataHasReopenPendingConsumption, readExecutionWorkspaceLifecycleGeneration, } from "../services/execution-workspaces.js"; @@ -176,6 +177,9 @@ describeEmbeddedPostgres("reopen archived isolated execution workspace", () => { expect(row?.cleanupReason).toBeNull(); expect(row?.cleanupEligibleAt).toBeNull(); expect(readExecutionWorkspaceLifecycleGeneration(row?.metadata as Record | null)).toBe(4); + // The reopen flags the row so the terminal reaper does not archive and + // destroy the rebuilt worktree before the caller consumes it. + expect(metadataHasReopenPendingConsumption(row?.metadata as Record | null)).toBe(true); // The reopen never changes the issue-to-workspace link. const issueRow = await db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0] ?? null); diff --git a/server/src/__tests__/execution-workspaces-service.test.ts b/server/src/__tests__/execution-workspaces-service.test.ts index 1bb8db49f3e..3ef97f2a983 100644 --- a/server/src/__tests__/execution-workspaces-service.test.ts +++ b/server/src/__tests__/execution-workspaces-service.test.ts @@ -28,9 +28,12 @@ import { startEmbeddedPostgresTestDatabase, } from "./helpers/embedded-postgres.js"; import { + EXECUTION_WORKSPACE_LIFECYCLE_GENERATION_METADATA_KEY, + EXECUTION_WORKSPACE_REOPEN_PENDING_METADATA_KEY, executionWorkspaceService, deriveExecutionWorkspaceDeliveryState, mergeExecutionWorkspaceConfig, + metadataHasReopenPendingConsumption, readExecutionWorkspaceConfig, } from "../services/execution-workspaces.ts"; import { issueService } from "../services/issues.ts"; @@ -952,6 +955,73 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { await expect(fs.access(cleanupMarker)).rejects.toThrow(); }); + it("does not reap a reopened workspace while the source issue is still terminal", async () => { + // Reproduce the reverse-ordering race. A resume reopens the archived + // workspace and publishes it active, but the route has not yet changed the + // source issue out of the terminal state. The sweep must not archive and + // destroy the rebuilt worktree in this window. + const seeded = await seedTerminalWorkspace({ mergedPr: true }); + await db + .update(executionWorkspaces) + .set({ + status: "active", + closedAt: null, + cleanupReason: null, + cleanupEligibleAt: null, + metadata: { + createdByRuntime: true, + [EXECUTION_WORKSPACE_LIFECYCLE_GENERATION_METADATA_KEY]: 4, + [EXECUTION_WORKSPACE_REOPEN_PENDING_METADATA_KEY]: true, + }, + }) + .where(eq(executionWorkspaces.id, seeded.executionWorkspaceId)); + + const sweep = await svc.sweepTerminalWorkspaces(); + const [workspace] = await db + .select({ status: executionWorkspaces.status, metadata: executionWorkspaces.metadata }) + .from(executionWorkspaces) + .where(eq(executionWorkspaces.id, seeded.executionWorkspaceId)); + + expect(sweep).toMatchObject({ archived: 0, skippedReopened: 1 }); + expect(workspace?.status).toBe("active"); + // The reopen flag stays until the source issue leaves the terminal state. + expect(metadataHasReopenPendingConsumption(workspace?.metadata as Record | null)).toBe(true); + // The rebuilt worktree is intact. + await expect(fs.access(seeded.worktreePath)).resolves.toBeUndefined(); + }); + + it("clears the reopen flag once the source issue leaves the terminal state", async () => { + // The resume transition committed, so the source issue is non-terminal. The + // sweep clears the stale reopen flag so a later terminal cycle can reap the + // workspace normally. + const seeded = await seedTerminalWorkspace({ mergedPr: true }); + await db + .update(executionWorkspaces) + .set({ + metadata: { + createdByRuntime: true, + [EXECUTION_WORKSPACE_LIFECYCLE_GENERATION_METADATA_KEY]: 4, + [EXECUTION_WORKSPACE_REOPEN_PENDING_METADATA_KEY]: true, + }, + }) + .where(eq(executionWorkspaces.id, seeded.executionWorkspaceId)); + await db + .update(issues) + .set({ status: "in_progress" }) + .where(eq(issues.id, seeded.sourceIssueId)); + + const sweep = await svc.sweepTerminalWorkspaces(); + const [workspace] = await db + .select({ status: executionWorkspaces.status, metadata: executionWorkspaces.metadata }) + .from(executionWorkspaces) + .where(eq(executionWorkspaces.id, seeded.executionWorkspaceId)); + + expect(sweep).toMatchObject({ archived: 0, skippedNonTerminalTree: 1 }); + expect(workspace?.status).toBe("active"); + expect(metadataHasReopenPendingConsumption(workspace?.metadata as Record | null)).toBe(false); + await expect(fs.access(seeded.worktreePath)).resolves.toBeUndefined(); + }); + it("holds Git index and ref locks across terminal cleanup", async () => { const seeded = await seedTerminalWorkspace({ mergedPr: true }); await db.update(executionWorkspaces).set({