Fix inherited workspace reuse fallback (#8963)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agent heartbeats provision execution workspaces before invoking
local or sandboxed adapters.
> - Some follow-up issues intentionally request `reuse_existing` so they
continue in an inherited execution workspace.
> - The heartbeat provisioning path treated missing or archived
workspace rows as if no explicit reuse request existed.
> - That could silently realize and persist a fresh project/default
workspace over an explicit inherited-workspace binding.
> - This pull request keys explicit reuse off the issue preference and
workspace id, then either restores that workspace or fails with a
structured workspace validation error.
> - The benefit is that intentional workspace inheritance remains
auditable and does not silently degrade into unrelated fallback
workspaces.

## Linked Issues or Issue Description

Refs #8058
Refs #6036
Refs #2203

This fixes a narrower heartbeat provisioning bug around explicit
`reuse_existing` issue runs: if the target inherited execution workspace
is missing, archived, or fails restore, provisioning now reports the
reuse failure instead of replacing the issue's workspace binding with a
freshly realized fallback.

## What Changed

- Added explicit helpers for resolving workspace reuse requests and
deciding whether reuse should restore, refresh metadata, or keep prior
replacement-class drift visible.
- Changed heartbeat workspace provisioning so explicit `reuse_existing`
requests go through restore-or-fail behavior instead of falling back to
`realizeExecutionWorkspace` when the stored workspace row is
unavailable.
- Added structured `workspace_validation_failed` details for inherited
workspace reuse failures.
- Added regression coverage for replacement-class drift, restore errors,
missing rows, archived rows, and restore misses.

## Verification

- `pnpm install --frozen-lockfile`
- `pnpm --filter @paperclipai/plugin-sdk ensure-build-deps`
- `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/heartbeat-workspace-session.test.ts`
- `pnpm --filter @paperclipai/server typecheck`
- `git diff --check origin/master...HEAD`
- Scanned the branch diff and commit messages for credentials, tokens,
private URLs, PII-style values, and internal issue links before pushing;
no unsafe hits remained.

## Risks

- Explicit reuse requests whose stored workspace cannot be restored now
fail the run instead of opportunistically creating a replacement
workspace. That is intentional, but it may surface stale or archived
workspace rows as visible provisioning failures that require repair.
- Non-reuse workspace provisioning still uses the existing realization
path, so the behavior shift is scoped to issues that explicitly request
existing workspace reuse.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

OpenAI GPT-5 via Codex local agent, with shell/tool use enabled for
repository inspection, code editing, verification, git, and GitHub CLI
operations. Runtime context-window details were not exposed by the
adapter.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Devin Foley 2026-07-04 06:35:06 -07:00 committed by GitHub
parent 85e36aaefb
commit a328ec953a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 455 additions and 85 deletions

View File

@ -3,7 +3,7 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import type { agents } from "@paperclipai/db";
import { sessionCodec as codexSessionCodec } from "@paperclipai/adapter-codex-local/server";
import { resolveDefaultAgentWorkspaceDir } from "../home-paths.js";
@ -23,7 +23,10 @@ import {
preflightLowTrustWorkspaceIsolation,
prioritizeProjectWorkspaceCandidatesForRun,
parseSessionCompactionPolicy,
provisionExecutionWorkspaceForFreshnessDecision,
resolveExecutionWorkspaceConfigFreshness,
resolveExecutionWorkspaceReuseRequestForIssue,
resolveExecutionWorkspaceReuseProvisioningPolicy,
resolveNextSessionState,
resolveTaskSessionConfigFreshness,
requiresPushCapabilityPreflight,
@ -1062,6 +1065,200 @@ describe("effective run execution workspace config freshness", () => {
expect(decision.changedCategories).toContain(category);
});
it("keeps replacement-class drift visible when explicit reuse restores the old workspace", () => {
const base = buildWorkspaceConfigMetadata();
const next = buildWorkspaceConfigMetadata({
repoRef: "origin/release",
workspaceStrategy: {
type: "git_worktree",
baseRef: "origin/release",
branchTemplate: "{{issue.identifier}}-{{slug}}",
worktreeParentDir: ".paperclip/worktrees",
},
configSnapshot: {
provisionCommand: "pnpm install --frozen-lockfile",
},
});
const decision = resolveExecutionWorkspaceConfigFreshness({
hasExistingWorkspace: true,
existingWorkspaceMetadata: persistedWorkspaceConfigFingerprint(base),
nextMetadata: next,
});
const policy = resolveExecutionWorkspaceReuseProvisioningPolicy({
requestedShouldReuseExisting: true,
workspaceConfigFreshness: decision,
});
expect(decision.action).toBe("replace");
expect(policy).toEqual({
shouldRestoreExistingWorkspace: true,
shouldRefreshWorkspaceConfigSnapshot: false,
shouldPersistLatestWorkspaceConfigMetadata: false,
});
const metadata = mergeExecutionWorkspaceMetadataForPersistence({
existingMetadata: {
config: {
provisionCommand: "pnpm install",
},
...persistedWorkspaceConfigFingerprint(base),
},
source: "task_session",
createdByRuntime: false,
configSnapshot: {
provisionCommand: "pnpm install --frozen-lockfile",
},
shouldReuseExisting: policy.shouldRestoreExistingWorkspace,
shouldRefreshConfigSnapshot: policy.shouldRefreshWorkspaceConfigSnapshot,
workspaceConfigMetadata: policy.shouldPersistLatestWorkspaceConfigMetadata ? next : null,
baseRef: "origin/release",
baseRefSha: "release-sha",
});
expect(metadata?.config).toEqual({
provisionCommand: "pnpm install",
});
expect(metadata?.configFingerprint).toMatchObject({
workspaceHash: base.fingerprint,
categories: base.categories,
});
expect(metadata?.configFingerprint).not.toMatchObject({
workspaceHash: next.fingerprint,
});
});
it("fails explicit reuse restore errors without realizing a fallback workspace", async () => {
const base = buildWorkspaceConfigMetadata();
const next = buildWorkspaceConfigMetadata({
repoRef: "origin/release",
workspaceStrategy: {
type: "git_worktree",
baseRef: "origin/release",
branchTemplate: "{{issue.identifier}}-{{slug}}",
worktreeParentDir: ".paperclip/worktrees",
},
});
const decision = resolveExecutionWorkspaceConfigFreshness({
hasExistingWorkspace: true,
existingWorkspaceMetadata: persistedWorkspaceConfigFingerprint(base),
nextMetadata: next,
});
const realizeWorkspace = vi.fn(async () => ({ id: "fallback-workspace" }));
await expect(provisionExecutionWorkspaceForFreshnessDecision({
requestedShouldReuseExisting: true,
existingExecutionWorkspaceId: "workspace-old",
issueRef: { id: "issue-1", identifier: "PAP-42" },
runId: "run-1",
workspaceConfigFreshness: decision,
restoreExistingWorkspace: async () => {
throw new Error("restore command failed");
},
realizeWorkspace,
})).rejects.toMatchObject({
code: "workspace_validation_failed",
resultJson: {
workspaceValidation: expect.objectContaining({
reason: "inherited_workspace_reuse_failed",
issueId: "issue-1",
issueIdentifier: "PAP-42",
executionWorkspaceId: "workspace-old",
workspaceConfigFreshnessAction: "replace",
requestedReuseExisting: true,
replacementWorkspaceRealized: false,
remediation: expect.stringContaining("restore/provision logs"),
}),
},
});
expect(realizeWorkspace).not.toHaveBeenCalled();
});
it.each([
{ name: "missing", status: null },
{ name: "archived", status: "archived" },
])("fails explicit reuse when the inherited workspace row is $name", async ({ status }) => {
const reuseRequest = resolveExecutionWorkspaceReuseRequestForIssue({
issueExecutionWorkspaceId: "workspace-old",
issueExecutionWorkspacePreference: "reuse_existing",
existingExecutionWorkspaceStatus: status,
});
expect(reuseRequest).toEqual({
requestedExecutionWorkspaceId: "workspace-old",
requestedShouldReuseExisting: true,
existingExecutionWorkspaceAvailable: false,
});
const metadata = buildWorkspaceConfigMetadata();
const decision = resolveExecutionWorkspaceConfigFreshness({
hasExistingWorkspace: reuseRequest.requestedShouldReuseExisting &&
reuseRequest.existingExecutionWorkspaceAvailable,
existingWorkspaceMetadata: null,
nextMetadata: metadata,
});
const realizeWorkspace = vi.fn(async () => ({ id: "fallback-workspace" }));
await expect(provisionExecutionWorkspaceForFreshnessDecision({
requestedShouldReuseExisting: reuseRequest.requestedShouldReuseExisting,
existingExecutionWorkspaceId: reuseRequest.requestedExecutionWorkspaceId,
issueRef: { id: "issue-1", identifier: "PAP-42" },
runId: "run-1",
workspaceConfigFreshness: decision,
restoreExistingWorkspace: reuseRequest.existingExecutionWorkspaceAvailable
? async () => ({ id: "workspace-old" })
: null,
realizeWorkspace,
})).rejects.toMatchObject({
code: "workspace_validation_failed",
resultJson: {
workspaceValidation: expect.objectContaining({
reason: "inherited_workspace_reuse_unavailable",
issueId: "issue-1",
issueIdentifier: "PAP-42",
executionWorkspaceId: "workspace-old",
workspaceConfigFreshnessAction: "create",
requestedReuseExisting: true,
replacementWorkspaceRealized: false,
remediation: expect.stringContaining("clear the issue's reuse_existing workspace binding"),
}),
},
});
expect(realizeWorkspace).not.toHaveBeenCalled();
});
it("fails explicit reuse restore misses without realizing a fallback workspace", async () => {
const metadata = buildWorkspaceConfigMetadata();
const decision = resolveExecutionWorkspaceConfigFreshness({
hasExistingWorkspace: true,
existingWorkspaceMetadata: persistedWorkspaceConfigFingerprint(metadata),
nextMetadata: metadata,
});
const realizeWorkspace = vi.fn(async () => ({ id: "fallback-workspace" }));
await expect(provisionExecutionWorkspaceForFreshnessDecision({
requestedShouldReuseExisting: true,
existingExecutionWorkspaceId: "workspace-old",
issueRef: { id: "issue-1", identifier: "PAP-42" },
runId: "run-1",
workspaceConfigFreshness: decision,
restoreExistingWorkspace: async () => null,
realizeWorkspace,
})).rejects.toMatchObject({
code: "workspace_validation_failed",
resultJson: {
workspaceValidation: expect.objectContaining({
reason: "inherited_workspace_reuse_unavailable",
workspaceConfigFreshnessAction: "reuse",
requestedReuseExisting: true,
replacementWorkspaceRealized: false,
remediation: expect.stringContaining("clear the issue's reuse_existing workspace binding"),
}),
},
});
expect(realizeWorkspace).not.toHaveBeenCalled();
});
it("formats a safe workspace operation payload for config drift decisions", () => {
const decision = resolveExecutionWorkspaceConfigFreshness({
hasExistingWorkspace: true,

View File

@ -2664,6 +2664,156 @@ type WorkspaceConfigFreshnessOperationInput = {
activeWorkspaceId: string | null;
};
type ExecutionWorkspaceReuseProvisioningPolicy = {
shouldRestoreExistingWorkspace: boolean;
shouldRefreshWorkspaceConfigSnapshot: boolean;
shouldPersistLatestWorkspaceConfigMetadata: boolean;
};
type WorkspaceReuseIssueRef = {
id?: string | null;
identifier?: string | null;
} | null | undefined;
export type ExecutionWorkspaceReuseRequestForIssue = {
requestedExecutionWorkspaceId: string | null;
requestedShouldReuseExisting: boolean;
existingExecutionWorkspaceAvailable: boolean;
};
export function resolveExecutionWorkspaceReuseRequestForIssue(input: {
issueExecutionWorkspaceId?: string | null;
issueExecutionWorkspacePreference?: string | null;
existingExecutionWorkspaceStatus?: string | null;
}): ExecutionWorkspaceReuseRequestForIssue {
const requestedExecutionWorkspaceId = readNonEmptyString(input.issueExecutionWorkspaceId);
const requestedShouldReuseExisting =
input.issueExecutionWorkspacePreference === "reuse_existing" && requestedExecutionWorkspaceId !== null;
return {
requestedExecutionWorkspaceId,
requestedShouldReuseExisting,
existingExecutionWorkspaceAvailable:
requestedShouldReuseExisting &&
input.existingExecutionWorkspaceStatus !== null &&
input.existingExecutionWorkspaceStatus !== undefined &&
input.existingExecutionWorkspaceStatus !== "archived",
};
}
export function resolveExecutionWorkspaceReuseProvisioningPolicy(input: {
requestedShouldReuseExisting: boolean;
workspaceConfigFreshness: ExecutionWorkspaceConfigFreshnessDecision;
}): ExecutionWorkspaceReuseProvisioningPolicy {
const shouldRestoreExistingWorkspace = input.requestedShouldReuseExisting;
const replacementClassDrift =
input.requestedShouldReuseExisting && input.workspaceConfigFreshness.action === "replace";
return {
shouldRestoreExistingWorkspace,
shouldRefreshWorkspaceConfigSnapshot:
shouldRestoreExistingWorkspace &&
!replacementClassDrift &&
input.workspaceConfigFreshness.shouldRefreshConfigSnapshot,
shouldPersistLatestWorkspaceConfigMetadata: !replacementClassDrift,
};
}
function createInheritedExecutionWorkspaceReuseFailure(input: {
reason: "inherited_workspace_reuse_failed" | "inherited_workspace_reuse_unavailable";
issueRef: WorkspaceReuseIssueRef;
runId: string;
executionWorkspaceId: string | null | undefined;
workspaceConfigFreshness: ExecutionWorkspaceConfigFreshnessDecision;
cause?: unknown;
}) {
const issueLabel = input.issueRef?.identifier ?? input.issueRef?.id ?? input.runId;
const workspaceLabel = input.executionWorkspaceId ?? "unknown workspace";
const causeMessage = input.cause instanceof Error
? input.cause.message
: 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 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; workspace provisioning cannot replace it because this is an explicit reuse path.`;
return new WorkspaceValidationFailure(message, {
workspaceValidation: {
reason: input.reason,
issueId: input.issueRef?.id ?? null,
issueIdentifier: input.issueRef?.identifier ?? null,
executionWorkspaceId: input.executionWorkspaceId ?? null,
workspaceConfigFreshnessAction: input.workspaceConfigFreshness.action,
workspaceConfigFreshnessReasons: input.workspaceConfigFreshness.reasons,
requestedReuseExisting: true,
replacementWorkspaceRealized: false,
remediation,
},
});
}
export async function provisionExecutionWorkspaceForFreshnessDecision<T>(input: {
requestedShouldReuseExisting: boolean;
existingExecutionWorkspaceId?: string | null;
issueRef: WorkspaceReuseIssueRef;
runId: string;
workspaceConfigFreshness: ExecutionWorkspaceConfigFreshnessDecision;
restoreExistingWorkspace?: (() => Promise<T | null>) | null;
realizeWorkspace: () => Promise<T>;
}): Promise<{
executionWorkspace: T;
reusedExecutionWorkspace: T | null;
policy: ExecutionWorkspaceReuseProvisioningPolicy;
}> {
const policy = resolveExecutionWorkspaceReuseProvisioningPolicy({
requestedShouldReuseExisting: input.requestedShouldReuseExisting,
workspaceConfigFreshness: input.workspaceConfigFreshness,
});
if (!policy.shouldRestoreExistingWorkspace) {
const executionWorkspace = await input.realizeWorkspace();
return {
executionWorkspace,
reusedExecutionWorkspace: null,
policy,
};
}
let restored: T | null = null;
try {
restored = (await input.restoreExistingWorkspace?.()) ?? null;
} catch (error) {
throw createInheritedExecutionWorkspaceReuseFailure({
reason: "inherited_workspace_reuse_failed",
issueRef: input.issueRef,
runId: input.runId,
executionWorkspaceId: input.existingExecutionWorkspaceId,
workspaceConfigFreshness: input.workspaceConfigFreshness,
cause: error,
});
}
if (!restored) {
throw createInheritedExecutionWorkspaceReuseFailure({
reason: "inherited_workspace_reuse_unavailable",
issueRef: input.issueRef,
runId: input.runId,
executionWorkspaceId: input.existingExecutionWorkspaceId,
workspaceConfigFreshness: input.workspaceConfigFreshness,
});
}
return {
executionWorkspace: restored,
reusedExecutionWorkspace: restored,
policy,
};
}
const EFFECTIVE_RUN_SESSION_CONFIG_CATEGORY_LABELS: Record<EffectiveRunSessionConfigCategory, string> = {
adapter: "adapter",
adapterConfig: "adapter config",
@ -9818,15 +9968,19 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
} else {
delete context.paperclipTaskMarkdown;
}
const requestedExecutionWorkspaceId = readNonEmptyString(issueRef?.executionWorkspaceId);
const existingExecutionWorkspace =
issueRef?.executionWorkspaceId ? await executionWorkspacesSvc.getById(issueRef.executionWorkspaceId) : null;
const requestedShouldReuseExisting =
issueRef?.executionWorkspacePreference === "reuse_existing" &&
existingExecutionWorkspace !== null &&
existingExecutionWorkspace.status !== "archived";
const requestedReusableExecutionWorkspaceConfig = requestedShouldReuseExisting
? existingExecutionWorkspace?.config ?? null
requestedExecutionWorkspaceId ? await executionWorkspacesSvc.getById(requestedExecutionWorkspaceId) : null;
const workspaceReuseRequest = resolveExecutionWorkspaceReuseRequestForIssue({
issueExecutionWorkspaceId: requestedExecutionWorkspaceId,
issueExecutionWorkspacePreference: issueRef?.executionWorkspacePreference ?? null,
existingExecutionWorkspaceStatus: existingExecutionWorkspace?.status ?? null,
});
const requestedShouldReuseExisting = workspaceReuseRequest.requestedShouldReuseExisting;
const reusableExistingExecutionWorkspace = workspaceReuseRequest.existingExecutionWorkspaceAvailable
? existingExecutionWorkspace
: null;
const requestedReusableExecutionWorkspaceConfig = reusableExistingExecutionWorkspace?.config ?? null;
const localEnvironment = await environmentsSvc.ensureLocalEnvironment(agent.companyId);
const resolvedInstanceSettings = await instanceSettings.get();
const environmentResolution = resolveExecutionWorkspaceEnvironmentId({
@ -10025,16 +10179,16 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
projectPolicy: projectExecutionWorkspacePolicy,
issueSettings: issueExecutionWorkspaceSettings,
reusableExecutionWorkspaceConfig: requestedReusableExecutionWorkspaceConfig,
existingExecutionWorkspace: existingExecutionWorkspace
existingExecutionWorkspace: reusableExistingExecutionWorkspace
? {
id: existingExecutionWorkspace.id,
mode: existingExecutionWorkspace.mode,
strategyType: existingExecutionWorkspace.strategyType,
projectWorkspaceId: existingExecutionWorkspace.projectWorkspaceId,
repoUrl: existingExecutionWorkspace.repoUrl,
baseRef: existingExecutionWorkspace.baseRef,
branchName: existingExecutionWorkspace.branchName,
config: existingExecutionWorkspace.config,
id: reusableExistingExecutionWorkspace.id,
mode: reusableExistingExecutionWorkspace.mode,
strategyType: reusableExistingExecutionWorkspace.strategyType,
projectWorkspaceId: reusableExistingExecutionWorkspace.projectWorkspaceId,
repoUrl: reusableExistingExecutionWorkspace.repoUrl,
baseRef: reusableExistingExecutionWorkspace.baseRef,
branchName: reusableExistingExecutionWorkspace.branchName,
config: reusableExistingExecutionWorkspace.config,
}
: null,
},
@ -10178,24 +10332,24 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
realization: workspaceRealizationFingerprint,
secretManifest,
});
const inferredExistingWorkspaceConfigMetadata = existingExecutionWorkspace
const inferredExistingWorkspaceConfigMetadata = reusableExistingExecutionWorkspace
? buildEffectiveRunWorkspaceConfigMetadata({
mode: issueExecutionWorkspaceModeForPersistedWorkspace(existingExecutionWorkspace.mode),
projectId: existingExecutionWorkspace.projectId,
projectWorkspaceId: existingExecutionWorkspace.projectWorkspaceId,
strategyType: existingExecutionWorkspace.strategyType,
mode: issueExecutionWorkspaceModeForPersistedWorkspace(reusableExistingExecutionWorkspace.mode),
projectId: reusableExistingExecutionWorkspace.projectId,
projectWorkspaceId: reusableExistingExecutionWorkspace.projectWorkspaceId,
strategyType: reusableExistingExecutionWorkspace.strategyType,
workspaceStrategy: workspaceStrategyFingerprintValue
? {
...workspaceStrategyFingerprintValue,
type: existingExecutionWorkspace.strategyType,
...(existingExecutionWorkspace.baseRef
? { baseRef: existingExecutionWorkspace.baseRef }
type: reusableExistingExecutionWorkspace.strategyType,
...(reusableExistingExecutionWorkspace.baseRef
? { baseRef: reusableExistingExecutionWorkspace.baseRef }
: {}),
}
: { type: existingExecutionWorkspace.strategyType },
repoUrl: existingExecutionWorkspace.repoUrl,
repoRef: existingExecutionWorkspace.baseRef,
configSnapshot: existingExecutionWorkspace.config,
: { type: reusableExistingExecutionWorkspace.strategyType },
repoUrl: reusableExistingExecutionWorkspace.repoUrl,
repoRef: reusableExistingExecutionWorkspace.baseRef,
configSnapshot: reusableExistingExecutionWorkspace.config,
environment: workspaceEnvironmentFingerprint,
realization: workspaceRealizationFingerprint,
secretManifest,
@ -10203,42 +10357,65 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
})
: null;
const workspaceConfigFreshness = resolveExecutionWorkspaceConfigFreshness({
hasExistingWorkspace: requestedShouldReuseExisting && Boolean(existingExecutionWorkspace),
existingWorkspaceMetadata: existingExecutionWorkspace?.metadata ?? null,
hasExistingWorkspace: requestedShouldReuseExisting && Boolean(reusableExistingExecutionWorkspace),
existingWorkspaceMetadata: reusableExistingExecutionWorkspace?.metadata ?? null,
inferredMetadata: inferredExistingWorkspaceConfigMetadata,
nextMetadata: latestWorkspaceConfigMetadata,
});
const shouldReuseExisting = requestedShouldReuseExisting && workspaceConfigFreshness.shouldReuseExisting;
const shouldRefreshWorkspaceConfigSnapshot = shouldReuseExisting && workspaceConfigFreshness.shouldRefreshConfigSnapshot;
const workspaceReuseProvisioningPolicy = resolveExecutionWorkspaceReuseProvisioningPolicy({
requestedShouldReuseExisting,
workspaceConfigFreshness,
});
const workspaceOperationRecorder = workspaceOperationsSvc.createRecorder({
companyId: agent.companyId,
heartbeatRunId: run.id,
executionWorkspaceId: shouldReuseExisting ? existingExecutionWorkspace?.id ?? null : null,
executionWorkspaceId: workspaceReuseProvisioningPolicy.shouldRestoreExistingWorkspace
? workspaceReuseRequest.requestedExecutionWorkspaceId
: null,
issueId,
});
const reusedExecutionWorkspace = shouldReuseExisting && existingExecutionWorkspace
? await ensurePersistedExecutionWorkspaceAvailable({
const { executionWorkspace, reusedExecutionWorkspace, policy: resolvedWorkspaceReusePolicy } =
await provisionExecutionWorkspaceForFreshnessDecision<RealizedExecutionWorkspace>({
requestedShouldReuseExisting,
existingExecutionWorkspaceId: workspaceReuseRequest.requestedExecutionWorkspaceId,
issueRef,
runId: run.id,
workspaceConfigFreshness,
restoreExistingWorkspace: reusableExistingExecutionWorkspace
? () => ensurePersistedExecutionWorkspaceAvailable({
base: executionWorkspaceBase,
workspace: {
id: reusableExistingExecutionWorkspace.id,
mode: reusableExistingExecutionWorkspace.mode,
strategyType: reusableExistingExecutionWorkspace.strategyType,
cwd: reusableExistingExecutionWorkspace.cwd,
providerRef: reusableExistingExecutionWorkspace.providerRef,
projectId: reusableExistingExecutionWorkspace.projectId,
projectWorkspaceId: reusableExistingExecutionWorkspace.projectWorkspaceId,
repoUrl: reusableExistingExecutionWorkspace.repoUrl,
baseRef: reusableExistingExecutionWorkspace.baseRef,
branchName: reusableExistingExecutionWorkspace.branchName,
metadata: reusableExistingExecutionWorkspace.metadata as Record<string, unknown> | null,
config: {
provisionCommand:
configSnapshot?.provisionCommand
?? reusableExistingExecutionWorkspace.config?.provisionCommand
?? projectExecutionWorkspacePolicy?.workspaceStrategy?.provisionCommand
?? null,
},
},
issue: issueRef,
agent: {
id: agent.id,
name: agent.name,
companyId: agent.companyId,
},
recorder: workspaceOperationRecorder,
})
: null,
realizeWorkspace: () => realizeExecutionWorkspace({
base: executionWorkspaceBase,
workspace: {
id: existingExecutionWorkspace.id,
mode: existingExecutionWorkspace.mode,
strategyType: existingExecutionWorkspace.strategyType,
cwd: existingExecutionWorkspace.cwd,
providerRef: existingExecutionWorkspace.providerRef,
projectId: existingExecutionWorkspace.projectId,
projectWorkspaceId: existingExecutionWorkspace.projectWorkspaceId,
repoUrl: existingExecutionWorkspace.repoUrl,
baseRef: existingExecutionWorkspace.baseRef,
branchName: existingExecutionWorkspace.branchName,
metadata: existingExecutionWorkspace.metadata as Record<string, unknown> | null,
config: {
provisionCommand:
configSnapshot?.provisionCommand
?? existingExecutionWorkspace.config?.provisionCommand
?? projectExecutionWorkspacePolicy?.workspaceStrategy?.provisionCommand
?? null,
},
},
config: hostExecutionWorkspaceConfig,
issue: issueRef,
agent: {
id: agent.id,
@ -10246,36 +10423,29 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
companyId: agent.companyId,
},
recorder: workspaceOperationRecorder,
})
: null;
const executionWorkspace = reusedExecutionWorkspace ?? await realizeExecutionWorkspace({
base: executionWorkspaceBase,
config: hostExecutionWorkspaceConfig,
issue: issueRef,
agent: {
id: agent.id,
name: agent.name,
companyId: agent.companyId,
},
recorder: workspaceOperationRecorder,
});
}),
});
const resolvedProjectId = executionWorkspace.projectId ?? issueRef?.projectId ?? executionProjectId ?? null;
const resolvedProjectWorkspaceId = issueRef?.projectWorkspaceId ?? resolvedWorkspace.workspaceId ?? null;
let persistedExecutionWorkspace = null;
const nextExecutionWorkspaceMetadata = mergeExecutionWorkspaceMetadataForPersistence({
existingMetadata: shouldReuseExisting ? existingExecutionWorkspace?.metadata ?? null : null,
existingMetadata: resolvedWorkspaceReusePolicy.shouldRestoreExistingWorkspace
? reusableExistingExecutionWorkspace?.metadata ?? null
: null,
source: executionWorkspace.source,
createdByRuntime: executionWorkspace.created,
configSnapshot,
shouldReuseExisting,
shouldRefreshConfigSnapshot: shouldRefreshWorkspaceConfigSnapshot,
workspaceConfigMetadata: latestWorkspaceConfigMetadata,
shouldReuseExisting: resolvedWorkspaceReusePolicy.shouldRestoreExistingWorkspace,
shouldRefreshConfigSnapshot: resolvedWorkspaceReusePolicy.shouldRefreshWorkspaceConfigSnapshot,
workspaceConfigMetadata: resolvedWorkspaceReusePolicy.shouldPersistLatestWorkspaceConfigMetadata
? latestWorkspaceConfigMetadata
: null,
baseRef: executionWorkspace.repoRef,
baseRefSha: executionWorkspace.baseRefSha ?? null,
});
try {
persistedExecutionWorkspace = shouldReuseExisting && existingExecutionWorkspace
? await executionWorkspacesSvc.update(existingExecutionWorkspace.id, {
persistedExecutionWorkspace = resolvedWorkspaceReusePolicy.shouldRestoreExistingWorkspace && reusableExistingExecutionWorkspace
? await executionWorkspacesSvc.update(reusableExistingExecutionWorkspace.id, {
cwd: executionWorkspace.cwd,
repoUrl: executionWorkspace.repoUrl,
baseRef: executionWorkspace.repoRef,
@ -10319,7 +10489,10 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
try {
await cleanupExecutionWorkspaceArtifacts({
workspace: {
id: existingExecutionWorkspace?.id ?? `transient-${run.id}`,
id:
reusableExistingExecutionWorkspace?.id
?? workspaceReuseRequest.requestedExecutionWorkspaceId
?? `transient-${run.id}`,
cwd: executionWorkspace.cwd,
providerType: executionWorkspace.strategy === "git_worktree" ? "git_worktree" : "local_fs",
providerRef: executionWorkspace.worktreePath,
@ -10361,20 +10534,20 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
recorder: workspaceOperationRecorder,
runId: run.id,
decision: workspaceConfigFreshness,
hasExistingWorkspace: Boolean(existingExecutionWorkspace),
hasExistingWorkspace: Boolean(reusableExistingExecutionWorkspace),
reuseRequested: requestedShouldReuseExisting,
workspaceReused: Boolean(reusedExecutionWorkspace),
configSnapshotRefreshed: shouldRefreshWorkspaceConfigSnapshot,
previousWorkspaceId: existingExecutionWorkspace?.id ?? null,
configSnapshotRefreshed: resolvedWorkspaceReusePolicy.shouldRefreshWorkspaceConfigSnapshot,
previousWorkspaceId: workspaceReuseRequest.requestedExecutionWorkspaceId,
activeWorkspaceId: persistedExecutionWorkspace?.id ?? null,
});
if (
existingExecutionWorkspace &&
reusableExistingExecutionWorkspace &&
persistedExecutionWorkspace &&
existingExecutionWorkspace.id !== persistedExecutionWorkspace.id &&
existingExecutionWorkspace.status === "active"
reusableExistingExecutionWorkspace.id !== persistedExecutionWorkspace.id &&
reusableExistingExecutionWorkspace.status === "active"
) {
await executionWorkspacesSvc.update(existingExecutionWorkspace.id, {
await executionWorkspacesSvc.update(reusableExistingExecutionWorkspace.id, {
status: "idle",
cleanupReason: null,
});
@ -10644,12 +10817,12 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
reasons: workspaceConfigFreshness.reasons,
reuseRequested: requestedShouldReuseExisting,
workspaceReused: Boolean(reusedExecutionWorkspace),
configSnapshotRefreshed: shouldRefreshWorkspaceConfigSnapshot,
configSnapshotRefreshed: resolvedWorkspaceReusePolicy.shouldRefreshWorkspaceConfigSnapshot,
storedFingerprintPresent: workspaceConfigFreshness.storedFingerprintPresent,
storedFingerprint: workspaceConfigFreshness.storedFingerprint,
inferredFingerprint: workspaceConfigFreshness.inferredFingerprint,
nextFingerprint: workspaceConfigFreshness.nextFingerprint,
previousWorkspaceId: existingExecutionWorkspace?.id ?? null,
previousWorkspaceId: workspaceReuseRequest.requestedExecutionWorkspaceId,
activeWorkspaceId: persistedExecutionWorkspace?.id ?? null,
},
};