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:
parent
85e36aaefb
commit
a328ec953a
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue