fix(runner): preserve workspace binding on recovery

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-21 14:10:45 -05:00
1 parent baf49f5e97
commit f34591016e
2 files changed
+45 -8

No files matched your search

@@ -241,6 +241,7 @@ describe("P6-25 persisted reaper-to-finalization recovery", () => {
const projectId = randomUUID();
const projectWorkspaceId = randomUUID();
const executionWorkspaceId = randomUUID();
const newerExecutionWorkspaceId = randomUUID();
const issueId = randomUUID();
const freshIssueId = randomUUID();
const runId = randomUUID();
@@ -403,8 +404,23 @@ describe("P6-25 persisted reaper-to-finalization recovery", () => {
cwd: repoRoot,
providerType: "local_fs",
});
await db.insert(executionWorkspaces).values({
id: newerExecutionWorkspaceId,
companyId,
projectId,
projectWorkspaceId,
sourceIssueId: issueId,
mode: "shared_workspace",
strategyType: "project_primary",
name: "Newer issue workspace",
status: "active",
cwd: repoRoot,
providerType: "local_fs",
});
await db.update(issues).set({
executionWorkspaceId,
// Simulate a newer run moving the issue-level pointer before the older native run is
// recovered. The older run must still restore its own immutable workspace binding.
executionWorkspaceId: newerExecutionWorkspaceId,
executionWorkspacePreference: "reuse_existing",
executionWorkspaceSettings: { mode: "shared_workspace" },
}).where(eq(issues.id, issueId));
@@ -523,8 +539,15 @@ describe("P6-25 persisted reaper-to-finalization recovery", () => {
expect(new Set(effects.map((effect) => effect.decisionId))).toEqual(new Set([decisions[0]!.id]));
expect(effects.map((effect) => effect.effectKind).sort()).toEqual(["issue_status_projection", "release_checkout"]);
await expect(db.select().from(issues).where(eq(issues.id, issueId))).resolves.toEqual([
expect.objectContaining({ status: "done", statusVersion: 1, lastStatusDecisionId: decisions[0]!.id }),
expect.objectContaining({
status: "done",
statusVersion: 1,
lastStatusDecisionId: decisions[0]!.id,
executionWorkspaceId: newerExecutionWorkspaceId,
}),
]);
await expect(db.select().from(executionWorkspaces).where(eq(executionWorkspaces.companyId, companyId)))
.resolves.toHaveLength(2);
await expect(db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, runId))).resolves.toEqual([
expect.objectContaining({ phase: "committed", resultId: expect.any(String), assessmentId: expect.any(String), decisionId: decisions[0]!.id }),
]);
+20 -6
View File
@@ -14433,12 +14433,26 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
context.paperclipTaskMarkdownCompact = redactedWakeContext.paperclipTaskMarkdownCompact;
}
}
const requestedExecutionWorkspaceId = readNonEmptyString(issueRef?.executionWorkspaceId);
// A native run's execution input is immutable once persisted. Recovery must therefore
// restore the workspace bound to that input rather than consulting the issue's current
// workspace pointer: a newer run may already have moved or cleared the issue binding while
// this older provider session is still recoverable.
const persistedRunnerProfile = parseObject(run.runnerProfileJson);
const persistedNativeExecutionInput =
run.runtimeMode === "native" && persistedRunnerProfile.nativeExecutionInput !== undefined
? parseNativeExecutionInput(persistedRunnerProfile.nativeExecutionInput)
: null;
const nativeRecoveryExecutionWorkspaceId =
persistedNativeExecutionInput?.binding.executionWorkspaceId ?? null;
const requestedExecutionWorkspaceId = nativeRecoveryExecutionWorkspaceId
?? readNonEmptyString(issueRef?.executionWorkspaceId);
const existingExecutionWorkspace =
requestedExecutionWorkspaceId ? await executionWorkspacesSvc.getById(requestedExecutionWorkspaceId) : null;
const workspaceReuseRequest = resolveExecutionWorkspaceReuseRequestForIssue({
issueExecutionWorkspaceId: requestedExecutionWorkspaceId,
issueExecutionWorkspacePreference: issueRef?.executionWorkspacePreference ?? null,
issueExecutionWorkspacePreference: nativeRecoveryExecutionWorkspaceId
? "reuse_existing"
: issueRef?.executionWorkspacePreference ?? null,
existingExecutionWorkspaceStatus: existingExecutionWorkspace?.status ?? null,
});
const requestedShouldReuseExisting = workspaceReuseRequest.requestedShouldReuseExisting;
@@ -15187,7 +15201,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
cleanupReason: null,
});
}
if (issueId && persistedExecutionWorkspace) {
if (issueId && persistedExecutionWorkspace && !nativeRecoveryExecutionWorkspaceId) {
const nextIssueWorkspaceMode = issueExecutionWorkspaceModeForPersistedWorkspace(persistedExecutionWorkspace.mode);
const shouldSwitchIssueToExistingWorkspace =
issueRef?.executionWorkspacePreference === "reuse_existing" ||
@@ -15887,9 +15901,9 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
});
nativeRunnerInstanceId = run.runnerInstanceId ?? randomUUID();
const nativeSessionId = run.nativeSessionId ?? randomUUID();
const persistedProfile = parseObject(run.runnerProfileJson);
if (persistedProfile.nativeExecutionInput !== undefined) {
nativeExecution = parseNativeExecutionInput(persistedProfile.nativeExecutionInput);
const persistedProfile = persistedRunnerProfile;
if (persistedNativeExecutionInput) {
nativeExecution = persistedNativeExecutionInput;
if (
nativeExecution.binding.companyId !== agent.companyId
|| nativeExecution.binding.runId !== run.id