mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 01:50:13 +01:00
## Thinking Path
> - Paperclip manages AI agents and their execution state.
> - Remote workspace admission must preserve files before execution
starts.
> - Admission saved the entire workspace once per registered repository
on every run.
> - Existing snapshot receipts can prove that validated repositories are
already saved.
> - This change uses those receipts and saves new repositories once per
admission batch.
> - It also preserves fresh configuration metadata and reports actual
workspace reuse.
## Linked Issues or Issue Description
**What happened**
Remote follow-up runs uploaded an unchanged workspace for each
registered repository before starting the provider. Configuration
persistence also overwrote new evaluation metadata, and freshness logs
could claim replacement when the same workspace was reused.
**Expected behavior**
Validate existing repositories without uploads when a committed snapshot
covers their descriptors. Save acquired repositories before publishing
ready receipts. Retain current evaluation metadata and report the actual
workspace outcome.
**Steps to reproduce**
Use a remote workspace with two registered repositories. Prepare it,
then admit a follow-up run. Observe repository snapshot operations
despite unchanged repositories. The regression tests exercise this with
real Postgres, Git, and separate sandbox directories.
**Environment**
Server remote workspace admission at commit
c0214826b3. Related work: #19374 handles
interrupted clone staging; this change handles snapshot admission cost.
## What Changed
- Skip repository saves when the committed snapshot covers the validated
descriptor.
- Save all repositories in one admission snapshot before publishing
ready receipts.
- Retain descriptor coverage only when the new archive contains the
checkout. Drop deleted checkout coverage so admission can acquire it
again.
- Restore `needs_repair` status for failed batch saves and retry failed
uploads.
- Let fresh preparation metadata override stale reserved metadata while
preserving storage authority.
- Report actual reuse when freshness policy requested replacement.
- Add regression coverage and fix an existing routine user fixture
missing its membership role.
## Verification
- Red: original code fails unnecessary-snapshot, evaluation-metadata,
and reuse-message regressions.
- Green: 517 affected tests across six suites before the final coverage
correction. After that correction, all 102 remote workspace/concurrency
tests and 96 archive tests pass. Imported-repository, deleted-checkout,
and upload-retry cases pass.
- Additional red/green: deleted checkout coverage fails before the fix
and passes after it. All 96 archive tests pass, including manifest
digest and size bounds.
- `pnpm -r typecheck`, `pnpm build`, and `pnpm check:token-gates` pass
locally.
- All final-head CI verification jobs pass, including the aggregate `ci
/ verify` check. Greptile gives the final head 5/5.
- The monolithic local `pnpm test:run` passed all six chat shards and
continued through general server tests. It was stopped after final-head
CI passed; it did not complete locally. The targeted local regressions,
affected suites, typecheck, and build completed successfully.
## Risks
Snapshot receipts must refer to committed bytes. Legacy receipts require
one conservative save. Failed uploads must not publish repository
readiness. Replacement-class drift still retains the original
configuration for an explicitly reused workspace.
Existing directories with damaged or missing Git metadata still require
repair. Master and this PR both validate those directories without
cloning over them, preserving any surviving user files. Clearing a
snapshot descriptor would not change that path. Complete directory
deletion is different: this PR clears coverage so normal acquisition can
clone it again.
## Model Used
OpenAI gpt-6.1-sol through Codex. Used reasoning, repository inspection,
code editing, and local execution. The host does not expose the context
window size.
## 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>
3432 lines
120 KiB
TypeScript
3432 lines
120 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { execFile as execFileCallback } from "node:child_process";
|
|
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, 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";
|
|
import {
|
|
applyPersistedExecutionWorkspaceConfig,
|
|
assertGitSensitiveAdapterWorkspaceValid,
|
|
assertGitWorktreeBaseWorkspaceReady,
|
|
assertPushCapabilityCheckoutValid,
|
|
buildExplicitResumeSessionOverride,
|
|
buildEffectiveRunSessionConfigMetadata,
|
|
buildEffectiveRunWorkspaceConfigMetadata,
|
|
buildWorkspaceConfigFreshnessOperation,
|
|
deriveTaskKeyWithHeartbeatFallback,
|
|
formatRuntimeWorkspaceWarningLog,
|
|
mergeExecutionWorkspaceMetadataForPersistence,
|
|
mergeCoalescedContextSnapshot,
|
|
preflightLowTrustWorkspaceIsolation,
|
|
prioritizeProjectWorkspaceCandidatesForRun,
|
|
parseSessionCompactionPolicy,
|
|
provisionExecutionWorkspaceForFreshnessDecision,
|
|
reconcileReusedExecutionWorkspaceProjectWorkspaceId,
|
|
resolveNativeRecoveryExecutionWorkspaceBinding,
|
|
resolveExecutionWorkspaceBranchOwnership,
|
|
resolveExecutionWorkspaceConfigFreshness,
|
|
resolveExecutionWorkspaceReuseRequestForIssue,
|
|
resolveExecutionWorkspaceReuseProvisioningPolicy,
|
|
resolveNextSessionState,
|
|
resolveTaskSessionConfigFreshness,
|
|
isWorkspaceSyncConflictFailure,
|
|
requiresPushCapabilityPreflight,
|
|
resolveWorkspaceAfterLowTrustPreflight,
|
|
resolveRuntimeSessionParamsForWorkspace,
|
|
shouldDeferFollowupWakeForSameIssue,
|
|
stripWorkspaceRuntimeFromExecutionRunConfig,
|
|
shouldResetTaskSessionForModelChange,
|
|
stripConfiguredModelFromSessionParams,
|
|
stripPaperclipSessionMetadataFromSessionParams,
|
|
normalizeSessionParams,
|
|
isTaskSessionCredentialCompatible,
|
|
shouldResetTaskSessionForWake,
|
|
buildAnchorFallbackWorkspaceNotes,
|
|
type ResolvedWorkspaceForRun,
|
|
} from "../services/heartbeat.ts";
|
|
import { stripHostWorkspaceProvision } from "../services/heartbeat/workspaces.js";
|
|
import type { TrustPresetResolution } from "../services/trust-preset-resolver.ts";
|
|
|
|
const execFile = promisify(execFileCallback);
|
|
|
|
function buildResolvedWorkspace(overrides: Partial<ResolvedWorkspaceForRun> = {}): ResolvedWorkspaceForRun {
|
|
return {
|
|
cwd: "/tmp/project",
|
|
source: "project_primary",
|
|
projectId: "project-1",
|
|
workspaceId: "workspace-1",
|
|
repoUrl: null,
|
|
repoRef: null,
|
|
workspaceHints: [],
|
|
warnings: [],
|
|
baseCwdFallback: false,
|
|
materializationFailures: [],
|
|
additionalWorkspaces: [],
|
|
referencedProjectFailures: [],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
type WorkspaceValidationInput = Parameters<typeof assertGitSensitiveAdapterWorkspaceValid>[0];
|
|
|
|
function buildWorkspaceValidationInput(
|
|
overrides: Partial<WorkspaceValidationInput> = {},
|
|
): WorkspaceValidationInput {
|
|
return {
|
|
adapterType: "codex_local",
|
|
agentId: "agent-1",
|
|
issue: {
|
|
id: "issue-1",
|
|
identifier: "PAP-1",
|
|
projectId: "project-1",
|
|
projectWorkspaceId: "workspace-1",
|
|
},
|
|
resolvedWorkspace: buildResolvedWorkspace(),
|
|
executionWorkspace: {
|
|
baseCwd: "/tmp/project",
|
|
source: "project_primary",
|
|
projectId: "project-1",
|
|
workspaceId: "workspace-1",
|
|
repoUrl: null,
|
|
repoRef: null,
|
|
strategy: "project_primary",
|
|
cwd: "/tmp/project",
|
|
branchName: null,
|
|
worktreePath: null,
|
|
warnings: [],
|
|
created: false,
|
|
branchCreatedByRuntime: false,
|
|
baseRefSha: null,
|
|
},
|
|
persistedExecutionWorkspace: {
|
|
id: "execution-workspace-1",
|
|
companyId: "company-1",
|
|
projectId: "project-1",
|
|
projectWorkspaceId: "workspace-1",
|
|
sourceIssueId: "issue-1",
|
|
mode: "shared_workspace",
|
|
strategyType: "project_primary",
|
|
name: "Primary workspace",
|
|
status: "active",
|
|
deliveryState: "unknown",
|
|
cwd: "/tmp/project",
|
|
repoUrl: null,
|
|
baseRef: null,
|
|
branchName: null,
|
|
providerType: "local_fs",
|
|
providerRef: null,
|
|
derivedFromExecutionWorkspaceId: null,
|
|
lastUsedAt: new Date("2026-06-06T00:00:00.000Z"),
|
|
openedAt: new Date("2026-06-06T00:00:00.000Z"),
|
|
closedAt: null,
|
|
cleanupEligibleAt: null,
|
|
cleanupReason: null,
|
|
config: null,
|
|
metadata: null,
|
|
createdAt: new Date("2026-06-06T00:00:00.000Z"),
|
|
updatedAt: new Date("2026-06-06T00:00:00.000Z"),
|
|
},
|
|
executionTarget: { kind: "local" },
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
async function runGit(cwd: string, args: string[]) {
|
|
await execFile("git", args, { cwd });
|
|
}
|
|
|
|
async function createGitCheckout(options: { withRemote: boolean }) {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-push-preflight-"));
|
|
await runGit(root, ["init"]);
|
|
if (options.withRemote) {
|
|
await runGit(root, ["remote", "add", "origin", "https://github.com/example/repo.git"]);
|
|
}
|
|
return root;
|
|
}
|
|
|
|
async function expectWorkspaceValidationFailure(
|
|
input: WorkspaceValidationInput,
|
|
reason: string,
|
|
message: string,
|
|
) {
|
|
await expect(assertGitSensitiveAdapterWorkspaceValid(input)).rejects.toMatchObject({
|
|
code: "workspace_validation_failed",
|
|
message: expect.stringContaining(message),
|
|
resultJson: {
|
|
workspaceValidation: expect.objectContaining({
|
|
reason,
|
|
adapterType: input.adapterType,
|
|
issueId: input.issue?.id,
|
|
}),
|
|
},
|
|
});
|
|
}
|
|
|
|
function buildAgent(adapterType: string, runtimeConfig: Record<string, unknown> = {}) {
|
|
return {
|
|
id: "agent-1",
|
|
companyId: "company-1",
|
|
projectId: null,
|
|
goalId: null,
|
|
name: "Agent",
|
|
role: "engineer",
|
|
title: null,
|
|
icon: null,
|
|
status: "running",
|
|
reportsTo: null,
|
|
capabilities: null,
|
|
adapterType,
|
|
adapterConfig: {},
|
|
runtimeConfig,
|
|
budgetMonthlyCents: 0,
|
|
spentMonthlyCents: 0,
|
|
permissions: {},
|
|
lastHeartbeatAt: null,
|
|
metadata: null,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
} as unknown as typeof agents.$inferSelect;
|
|
}
|
|
|
|
const hermesSessionCodec = {
|
|
deserialize(raw: unknown) {
|
|
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
|
|
const record = raw as Record<string, unknown>;
|
|
const sessionId = typeof record.sessionId === "string" && record.sessionId.trim() ? record.sessionId.trim() : null;
|
|
return sessionId ? { sessionId } : null;
|
|
},
|
|
serialize(params: Record<string, unknown> | null) {
|
|
if (!params) return null;
|
|
const sessionId = typeof params.sessionId === "string" && params.sessionId.trim() ? params.sessionId.trim() : null;
|
|
return sessionId ? { sessionId } : null;
|
|
},
|
|
getDisplayId(params: Record<string, unknown> | null) {
|
|
return typeof params?.sessionId === "string" && params.sessionId.trim() ? params.sessionId.trim() : null;
|
|
},
|
|
};
|
|
|
|
const truncatingHermesSessionCodec = {
|
|
...hermesSessionCodec,
|
|
getDisplayId(params: Record<string, unknown> | null) {
|
|
const sessionId = hermesSessionCodec.getDisplayId(params);
|
|
return sessionId ? sessionId.slice(0, 16) : null;
|
|
},
|
|
};
|
|
|
|
function lowTrustResolution(): TrustPresetResolution {
|
|
return {
|
|
kind: "low_trust_review",
|
|
preset: "low_trust_review",
|
|
boundary: {
|
|
mode: "low_trust_review",
|
|
companyId: "company-1",
|
|
rootIssueId: "issue-1",
|
|
},
|
|
sourcePresets: { agent: "low_trust_review" },
|
|
};
|
|
}
|
|
|
|
function standardTrustResolution(): TrustPresetResolution {
|
|
return {
|
|
kind: "standard",
|
|
preset: "standard",
|
|
boundary: null,
|
|
sourcePresets: {},
|
|
};
|
|
}
|
|
|
|
function buildIssueAncestryDb(rows: Array<{ id: string; companyId: string; parentId: string | null }>) {
|
|
const queue = [...rows];
|
|
return {
|
|
select: () => ({
|
|
from: () => ({
|
|
where: () => {
|
|
const row = queue.shift();
|
|
return Promise.resolve(row ? [row] : []);
|
|
},
|
|
}),
|
|
}),
|
|
};
|
|
}
|
|
|
|
describe("assertGitSensitiveAdapterWorkspaceValid", () => {
|
|
it("accepts a projectless issue using an authorized persisted source workspace", async () => {
|
|
await expect(assertGitSensitiveAdapterWorkspaceValid(buildWorkspaceValidationInput({
|
|
issue: { id: "issue-1", identifier: "PAP-1", projectId: null, projectWorkspaceId: "workspace-1" },
|
|
resolvedWorkspace: buildResolvedWorkspace({ localPathOnlyWorkspace: true }),
|
|
}))).resolves.toBeUndefined();
|
|
});
|
|
|
|
it("rejects a configured source without source-project evidence", async () => {
|
|
const input = buildWorkspaceValidationInput();
|
|
await expectWorkspaceValidationFailure(buildWorkspaceValidationInput({
|
|
issue: { id: "issue-1", identifier: "PAP-1", projectId: null, projectWorkspaceId: "workspace-1" },
|
|
persistedExecutionWorkspace: { ...input.persistedExecutionWorkspace!, projectId: null },
|
|
}), "missing_project_id", "linked to a project workspace but has no project id");
|
|
});
|
|
|
|
it("rejects a git-sensitive local adapter when effective cwd differs from the persisted workspace cwd", async () => {
|
|
const input = buildWorkspaceValidationInput();
|
|
|
|
await expectWorkspaceValidationFailure(
|
|
buildWorkspaceValidationInput({
|
|
executionWorkspace: {
|
|
...input.executionWorkspace,
|
|
cwd: "/tmp/agent-fallback",
|
|
},
|
|
}),
|
|
"persisted_cwd_mismatch",
|
|
'resolved adapter cwd "/tmp/agent-fallback"',
|
|
);
|
|
});
|
|
|
|
it("rejects a workspace-linked issue when no execution workspace was persisted", async () => {
|
|
await expectWorkspaceValidationFailure(
|
|
buildWorkspaceValidationInput({
|
|
persistedExecutionWorkspace: null,
|
|
}),
|
|
"missing_persisted_execution_workspace",
|
|
"requires a project execution workspace",
|
|
);
|
|
});
|
|
|
|
it("rejects a workspace-linked issue when no effective adapter cwd was resolved", async () => {
|
|
const input = buildWorkspaceValidationInput();
|
|
|
|
await expectWorkspaceValidationFailure(
|
|
buildWorkspaceValidationInput({
|
|
executionWorkspace: {
|
|
...input.executionWorkspace,
|
|
cwd: "",
|
|
},
|
|
}),
|
|
"missing_effective_cwd",
|
|
"no adapter cwd was resolved",
|
|
);
|
|
});
|
|
|
|
it("rejects a persisted execution workspace linked to a different project workspace", async () => {
|
|
const input = buildWorkspaceValidationInput();
|
|
|
|
await expectWorkspaceValidationFailure(
|
|
buildWorkspaceValidationInput({
|
|
persistedExecutionWorkspace: {
|
|
...input.persistedExecutionWorkspace!,
|
|
projectWorkspaceId: "workspace-other",
|
|
},
|
|
}),
|
|
"project_workspace_mismatch",
|
|
'expected project workspace "workspace-1"',
|
|
);
|
|
});
|
|
|
|
it("rejects a persisted execution workspace missing its project workspace id", async () => {
|
|
const input = buildWorkspaceValidationInput();
|
|
|
|
await expectWorkspaceValidationFailure(
|
|
buildWorkspaceValidationInput({
|
|
persistedExecutionWorkspace: {
|
|
...input.persistedExecutionWorkspace!,
|
|
projectWorkspaceId: null,
|
|
},
|
|
}),
|
|
"persisted_workspace_missing_project_workspace_id",
|
|
"has no project workspace id",
|
|
);
|
|
});
|
|
|
|
it("rejects a workspace-linked issue that would launch from the agent fallback cwd", async () => {
|
|
const input = buildWorkspaceValidationInput();
|
|
const fallbackCwd = resolveDefaultAgentWorkspaceDir("agent-1");
|
|
|
|
await expectWorkspaceValidationFailure(
|
|
buildWorkspaceValidationInput({
|
|
executionWorkspace: {
|
|
...input.executionWorkspace,
|
|
cwd: fallbackCwd,
|
|
},
|
|
persistedExecutionWorkspace: {
|
|
...input.persistedExecutionWorkspace!,
|
|
cwd: fallbackCwd,
|
|
},
|
|
}),
|
|
"fallback_agent_home_cwd",
|
|
"would launch from agent fallback cwd",
|
|
);
|
|
});
|
|
|
|
it("rejects a git worktree persisted workspace when cwd differs from providerRef", async () => {
|
|
const input = buildWorkspaceValidationInput();
|
|
|
|
await expectWorkspaceValidationFailure(
|
|
buildWorkspaceValidationInput({
|
|
executionWorkspace: {
|
|
...input.executionWorkspace,
|
|
strategy: "git_worktree",
|
|
cwd: "/tmp/worktree-current",
|
|
},
|
|
persistedExecutionWorkspace: {
|
|
...input.persistedExecutionWorkspace!,
|
|
strategyType: "git_worktree",
|
|
cwd: "/tmp/worktree-current",
|
|
providerRef: "/tmp/worktree-expected",
|
|
},
|
|
}),
|
|
"git_worktree_provider_ref_mismatch",
|
|
'expected git worktree "/tmp/worktree-expected"',
|
|
);
|
|
});
|
|
|
|
it("rejects a git worktree persisted workspace when the checked-out branch differs from the recorded branch", async () => {
|
|
const repoRoot = await createGitCheckout({ withRemote: false });
|
|
const worktreeParent = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-branch-worktree-"));
|
|
const worktreePath = path.join(worktreeParent, "workspace");
|
|
const recordedBranch = "PAP-1-recorded-branch";
|
|
const actualBranch = "PAP-1-push-pr-head";
|
|
try {
|
|
await runGit(repoRoot, ["config", "user.email", "test@example.com"]);
|
|
await runGit(repoRoot, ["config", "user.name", "Paperclip Test"]);
|
|
await fs.writeFile(path.join(repoRoot, "README.md"), "initial\n", "utf8");
|
|
await runGit(repoRoot, ["add", "README.md"]);
|
|
await runGit(repoRoot, ["commit", "-m", "Initial commit"]);
|
|
await runGit(repoRoot, ["worktree", "add", "-b", recordedBranch, worktreePath, "HEAD"]);
|
|
await runGit(worktreePath, ["checkout", "-b", actualBranch]);
|
|
|
|
const input = buildWorkspaceValidationInput();
|
|
await expectWorkspaceValidationFailure(
|
|
buildWorkspaceValidationInput({
|
|
resolvedWorkspace: buildResolvedWorkspace({ cwd: worktreePath }),
|
|
executionWorkspace: {
|
|
...input.executionWorkspace,
|
|
strategy: "git_worktree",
|
|
baseCwd: repoRoot,
|
|
cwd: worktreePath,
|
|
branchName: recordedBranch,
|
|
worktreePath,
|
|
},
|
|
persistedExecutionWorkspace: {
|
|
...input.persistedExecutionWorkspace!,
|
|
strategyType: "git_worktree",
|
|
cwd: worktreePath,
|
|
providerType: "git_worktree",
|
|
providerRef: worktreePath,
|
|
branchName: recordedBranch,
|
|
},
|
|
}),
|
|
"git_worktree_branch_mismatch",
|
|
`expected git worktree branch "${recordedBranch}"`,
|
|
);
|
|
} finally {
|
|
await fs.rm(repoRoot, { recursive: true, force: true });
|
|
await fs.rm(worktreeParent, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("rejects a workspace-linked issue when adapter cwd has no git metadata", async () => {
|
|
const input = buildWorkspaceValidationInput();
|
|
const cwd = "/tmp/paperclip-workspace-without-git-metadata";
|
|
|
|
await expectWorkspaceValidationFailure(
|
|
buildWorkspaceValidationInput({
|
|
resolvedWorkspace: buildResolvedWorkspace({ cwd }),
|
|
executionWorkspace: {
|
|
...input.executionWorkspace,
|
|
baseCwd: cwd,
|
|
cwd,
|
|
},
|
|
persistedExecutionWorkspace: {
|
|
...input.persistedExecutionWorkspace!,
|
|
cwd,
|
|
},
|
|
}),
|
|
"missing_git_metadata",
|
|
"has no .git metadata",
|
|
);
|
|
});
|
|
|
|
it("does not apply the git-sensitive workspace guard to non-local execution targets", async () => {
|
|
const input = buildWorkspaceValidationInput();
|
|
|
|
await expect(
|
|
assertGitSensitiveAdapterWorkspaceValid(
|
|
buildWorkspaceValidationInput({
|
|
executionTarget: { kind: "cloud" },
|
|
executionWorkspace: {
|
|
...input.executionWorkspace,
|
|
cwd: "/tmp/agent-fallback",
|
|
},
|
|
}),
|
|
),
|
|
).resolves.toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("assertGitWorktreeBaseWorkspaceReady", () => {
|
|
it.each(["all_external", "mixed", "malformed"])("keeps materialization aggregation fail-closed: %s", async (kind) => {
|
|
const first = { schemaVersion: 1, provider: "git", operation: "clone", reason: "authentication_failed" } as const;
|
|
const second = kind === "all_external" ? { ...first, reason: "dns_failure" as const } :
|
|
kind === "malformed" ? { ...first, reason: "unknown" } : undefined;
|
|
const failures = [first, second].map((connectionFailure, index) => ({
|
|
projectWorkspaceId: `workspace-${index}`, repoUrl: "https://example.test/team/repo.git",
|
|
error: "Managed checkout failed", ...(connectionFailure ? { connectionFailure } : {}),
|
|
}));
|
|
const error = await assertGitWorktreeBaseWorkspaceReady({
|
|
requestedExecutionWorkspaceMode: "isolated_workspace",
|
|
config: { workspaceStrategy: { type: "git_worktree" } },
|
|
issue: { id: "issue-1", identifier: "TEST-1", projectId: "project-1", projectWorkspaceId: "workspace-0" },
|
|
base: { baseCwd: "/tmp/unused-fallback", source: "project_primary", projectId: "project-1", workspaceId: "workspace-0", repoUrl: "https://example.test/team/repo.git", repoRef: null },
|
|
anchor: { baseCwdFallback: true, materializationFailures: failures as never },
|
|
}).catch((error: unknown) => error);
|
|
expect(error).toMatchObject({
|
|
code: "workspace_validation_failed",
|
|
resultJson: { workspaceValidation: { reason: "git_worktree_base_materialization_failed", materializationFailures: failures } },
|
|
});
|
|
expect((error as { resultJson: Record<string, unknown> }).resultJson.connectionFailure)
|
|
.toEqual(kind === "all_external" ? first : undefined);
|
|
});
|
|
|
|
it("rejects projectless isolated git worktrees that resolved to agent_home", async () => {
|
|
const fallbackCwd = resolveDefaultAgentWorkspaceDir("agent-1");
|
|
|
|
await expect(assertGitWorktreeBaseWorkspaceReady({
|
|
requestedExecutionWorkspaceMode: "isolated_workspace",
|
|
config: { workspaceStrategy: { type: "git_worktree" } },
|
|
issue: {
|
|
id: "issue-1",
|
|
identifier: "PAP-1",
|
|
projectId: null,
|
|
projectWorkspaceId: null,
|
|
executionWorkspaceId: null,
|
|
executionWorkspacePreference: "isolated_workspace",
|
|
},
|
|
base: {
|
|
baseCwd: fallbackCwd,
|
|
source: "agent_home",
|
|
projectId: null,
|
|
workspaceId: null,
|
|
repoUrl: null,
|
|
repoRef: null,
|
|
},
|
|
})).rejects.toMatchObject({
|
|
code: "workspace_validation_failed",
|
|
message: expect.stringContaining("needs a project / project workspace or a reusable execution workspace"),
|
|
resultJson: {
|
|
workspaceValidation: expect.objectContaining({
|
|
reason: "git_worktree_base_agent_home",
|
|
issueId: "issue-1",
|
|
resolvedWorkspaceSource: "agent_home",
|
|
requestedExecutionWorkspaceMode: "isolated_workspace",
|
|
workspaceStrategyType: "git_worktree",
|
|
}),
|
|
},
|
|
});
|
|
});
|
|
|
|
it("rejects operator-branch git worktrees that resolved to agent_home", async () => {
|
|
const fallbackCwd = resolveDefaultAgentWorkspaceDir("agent-1");
|
|
|
|
await expect(assertGitWorktreeBaseWorkspaceReady({
|
|
requestedExecutionWorkspaceMode: "operator_branch",
|
|
config: { workspaceStrategy: { type: "git_worktree" } },
|
|
issue: {
|
|
id: "issue-1",
|
|
identifier: "PAP-1",
|
|
projectId: null,
|
|
projectWorkspaceId: null,
|
|
},
|
|
base: {
|
|
baseCwd: fallbackCwd,
|
|
source: "agent_home",
|
|
projectId: null,
|
|
workspaceId: null,
|
|
repoUrl: null,
|
|
repoRef: null,
|
|
},
|
|
})).rejects.toMatchObject({
|
|
code: "workspace_validation_failed",
|
|
resultJson: {
|
|
workspaceValidation: expect.objectContaining({
|
|
reason: "git_worktree_base_agent_home",
|
|
requestedExecutionWorkspaceMode: "operator_branch",
|
|
}),
|
|
},
|
|
});
|
|
});
|
|
|
|
it("rejects isolated git worktrees when the resolved base is not a git checkout", async () => {
|
|
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-non-git-workspace-"));
|
|
try {
|
|
await expect(assertGitWorktreeBaseWorkspaceReady({
|
|
requestedExecutionWorkspaceMode: "isolated_workspace",
|
|
config: { workspaceStrategy: { type: "git_worktree" } },
|
|
issue: {
|
|
id: "issue-1",
|
|
identifier: "PAP-1",
|
|
projectId: "project-1",
|
|
projectWorkspaceId: "workspace-1",
|
|
},
|
|
base: {
|
|
baseCwd: cwd,
|
|
source: "project_primary",
|
|
projectId: "project-1",
|
|
workspaceId: "workspace-1",
|
|
repoUrl: "https://github.com/example/repo.git",
|
|
repoRef: "origin/master",
|
|
},
|
|
})).rejects.toMatchObject({
|
|
code: "workspace_validation_failed",
|
|
message: expect.stringContaining("is not a git checkout"),
|
|
resultJson: {
|
|
workspaceValidation: expect.objectContaining({
|
|
reason: "git_worktree_base_not_git_checkout",
|
|
issueId: "issue-1",
|
|
resolvedWorkspaceSource: "project_primary",
|
|
resolvedWorkspaceCwd: cwd,
|
|
}),
|
|
},
|
|
});
|
|
} finally {
|
|
await fs.rm(cwd, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
function configuredLocalPathInput(cwd: string): Parameters<typeof assertGitWorktreeBaseWorkspaceReady>[0] {
|
|
return {
|
|
requestedExecutionWorkspaceMode: "isolated_workspace",
|
|
config: { workspaceStrategy: { type: "git_worktree" } },
|
|
issue: { id: "issue-1", identifier: null, projectId: "project-1", projectWorkspaceId: "workspace-1" },
|
|
base: { baseCwd: cwd, source: "project_primary", projectId: "project-1", workspaceId: "workspace-1", repoUrl: null, repoRef: null },
|
|
anchor: { localPathOnlyWorkspace: true, baseCwdFallback: false, materializationFailures: [] },
|
|
};
|
|
}
|
|
|
|
it("marks a proven non-Git local path as an owner configuration conflict without changing the failure", async () => {
|
|
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-local-path-policy-"));
|
|
try {
|
|
await expect(assertGitWorktreeBaseWorkspaceReady(configuredLocalPathInput(cwd))).rejects.toMatchObject({
|
|
code: "workspace_validation_failed",
|
|
message: expect.stringContaining("is not a git checkout"),
|
|
resultJson: { workspaceValidation: {
|
|
reason: "git_worktree_base_not_git_checkout",
|
|
configurationReason: "local_path_requires_git_checkout",
|
|
} },
|
|
});
|
|
} finally { await fs.rm(cwd, { recursive: true, force: true }); }
|
|
});
|
|
|
|
it.each(["legacy", "repository", "fallback", "materialization", "session", "unbound"])(
|
|
"does not mark ambiguous non-Git workspace evidence as a configuration-only failure: %s", async (kind) => {
|
|
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-ambiguous-workspace-"));
|
|
try {
|
|
const input = configuredLocalPathInput(cwd);
|
|
if (kind === "legacy") input.anchor!.localPathOnlyWorkspace = undefined;
|
|
if (kind === "repository") input.base.repoUrl = "https://example.com/repository.git";
|
|
if (kind === "fallback") input.anchor!.baseCwdFallback = true;
|
|
if (kind === "materialization") input.anchor!.materializationFailures = [{ projectWorkspaceId: "another-workspace", repoUrl: null, error: "checkout failed" }];
|
|
if (kind === "session") input.base.source = "task_session";
|
|
if (kind === "unbound") input.base.workspaceId = null;
|
|
const error = await assertGitWorktreeBaseWorkspaceReady(input).catch((error) => error);
|
|
expect(error.code).toBe("workspace_validation_failed");
|
|
expect(error.resultJson.workspaceValidation.configurationReason).toBeUndefined();
|
|
} finally { await fs.rm(cwd, { recursive: true, force: true }); }
|
|
},
|
|
);
|
|
|
|
it.each(["missing_git", "permission", "git_error", "killed", "missing_directory"])(
|
|
"does not classify a Git or filesystem failure as an owner-only configuration mismatch: %s", async (kind) => {
|
|
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-git-probe-failure-"));
|
|
const bin = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-git-probe-bin-"));
|
|
try {
|
|
if (kind !== "missing_git" && kind !== "missing_directory") {
|
|
const command = kind === "killed"
|
|
? "kill -TERM $$"
|
|
: kind === "permission"
|
|
? "printf '%s\\n' 'fatal: cannot open .git: Permission denied' >&2; exit 128"
|
|
: "printf '%s\\n' 'fatal: bad object HEAD' >&2; exit 128";
|
|
await fs.writeFile(path.join(bin, "git"), `#!/bin/sh\n${command}\n`, { mode: 0o755 });
|
|
}
|
|
if (kind !== "missing_directory") vi.stubEnv("PATH", bin);
|
|
const input = configuredLocalPathInput(kind === "missing_directory" ? path.join(cwd, "absent") : cwd);
|
|
const error = await assertGitWorktreeBaseWorkspaceReady(input).catch((error) => error);
|
|
expect(error.code).toBe("workspace_validation_failed");
|
|
expect(error.resultJson.workspaceValidation.configurationReason).toBeUndefined();
|
|
} finally {
|
|
vi.unstubAllEnvs();
|
|
await fs.rm(cwd, { recursive: true, force: true });
|
|
await fs.rm(bin, { recursive: true, force: true });
|
|
}
|
|
},
|
|
);
|
|
|
|
it("still accepts a configured local path that is a Git checkout", async () => {
|
|
const cwd = await createGitCheckout({ withRemote: false });
|
|
try { await expect(assertGitWorktreeBaseWorkspaceReady(configuredLocalPathInput(cwd))).resolves.toBeUndefined(); }
|
|
finally { await fs.rm(cwd, { recursive: true, force: true }); }
|
|
});
|
|
|
|
it.each(["empty_metadata", "missing_head", "damaged_head", "dangling_metadata", "ancestor_metadata", "bare_metadata"])(
|
|
"keeps real broken repository metadata reportable even when Git says not a repository: %s", async (kind) => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-broken-local-repo-"));
|
|
try {
|
|
let cwd = root;
|
|
if (kind === "missing_head" || kind === "damaged_head") {
|
|
await execFile("git", ["init", root]);
|
|
if (kind === "missing_head") await fs.rm(path.join(root, ".git", "HEAD"));
|
|
else await fs.writeFile(path.join(root, ".git", "HEAD"), "damaged HEAD\n");
|
|
} else if (kind === "dangling_metadata") {
|
|
await fs.symlink(path.join(root, "missing-git-dir"), path.join(root, ".git"));
|
|
} else if (kind === "bare_metadata") {
|
|
await fs.mkdir(path.join(root, "objects"));
|
|
} else {
|
|
await fs.mkdir(path.join(root, ".git"));
|
|
if (kind === "ancestor_metadata") {
|
|
cwd = path.join(root, "documents");
|
|
await fs.mkdir(cwd);
|
|
}
|
|
}
|
|
const gitFailure = await execFile("git", ["rev-parse", "--show-toplevel"], {
|
|
cwd, env: { ...process.env, LC_ALL: "C" },
|
|
}).catch((error) => error);
|
|
expect(gitFailure.code).toBe(128);
|
|
expect(gitFailure.stderr).toMatch(/^fatal: not a git repository/);
|
|
const failure = await assertGitWorktreeBaseWorkspaceReady(configuredLocalPathInput(cwd)).catch((error) => error);
|
|
expect(failure.code).toBe("workspace_validation_failed");
|
|
expect(failure.resultJson.workspaceValidation.configurationReason).toBeUndefined();
|
|
} finally { await fs.rm(root, { recursive: true, force: true }); }
|
|
},
|
|
);
|
|
|
|
it("rejects isolated git worktrees when the project workspace could not be materialized, even if the fallback cwd is a git checkout", async () => {
|
|
// The fallback agent-home dir being a git repo must not let the run proceed: it would be an
|
|
// unrelated repository, and the not-a-git-checkout probe would mask the real clone failure.
|
|
const cwd = await createGitCheckout({ withRemote: false });
|
|
try {
|
|
await expect(assertGitWorktreeBaseWorkspaceReady({
|
|
requestedExecutionWorkspaceMode: "isolated_workspace",
|
|
config: { workspaceStrategy: { type: "git_worktree" } },
|
|
issue: {
|
|
id: "issue-1",
|
|
identifier: "PAP-1",
|
|
projectId: "project-1",
|
|
projectWorkspaceId: "workspace-1",
|
|
},
|
|
base: {
|
|
baseCwd: cwd,
|
|
source: "project_primary",
|
|
projectId: "project-1",
|
|
workspaceId: "workspace-1",
|
|
repoUrl: "https://github.com/example/private-repo.git",
|
|
repoRef: "origin/master",
|
|
},
|
|
anchor: {
|
|
baseCwdFallback: true,
|
|
materializationFailures: [{
|
|
projectWorkspaceId: "workspace-1",
|
|
repoUrl: "https://github.com/example/private-repo.git",
|
|
error: 'Failed to prepare managed checkout for "https://github.com/example/private-repo.git": fatal: could not read Username',
|
|
}],
|
|
},
|
|
})).rejects.toMatchObject({
|
|
code: "workspace_validation_failed",
|
|
message: expect.stringContaining("could not be prepared: Failed to prepare managed checkout"),
|
|
resultJson: {
|
|
workspaceValidation: expect.objectContaining({
|
|
reason: "git_worktree_base_materialization_failed",
|
|
issueId: "issue-1",
|
|
baseCwdFallback: true,
|
|
materializationFailures: [expect.objectContaining({
|
|
projectWorkspaceId: "workspace-1",
|
|
error: expect.stringContaining("could not read Username"),
|
|
})],
|
|
}),
|
|
},
|
|
});
|
|
} finally {
|
|
await fs.rm(cwd, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("keeps the not-a-git-checkout reason for a fallback with no failed materialization attempt", async () => {
|
|
// A configured path that is simply unavailable is not a clone failure; the message must
|
|
// not steer the operator toward repairing clone access.
|
|
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-unavailable-path-fallback-"));
|
|
try {
|
|
await expect(assertGitWorktreeBaseWorkspaceReady({
|
|
requestedExecutionWorkspaceMode: "isolated_workspace",
|
|
config: { workspaceStrategy: { type: "git_worktree" } },
|
|
issue: {
|
|
id: "issue-1",
|
|
identifier: "PAP-1",
|
|
projectId: "project-1",
|
|
projectWorkspaceId: "workspace-1",
|
|
},
|
|
base: {
|
|
baseCwd: cwd,
|
|
source: "project_primary",
|
|
projectId: "project-1",
|
|
workspaceId: "workspace-1",
|
|
repoUrl: null,
|
|
repoRef: null,
|
|
},
|
|
anchor: { baseCwdFallback: true },
|
|
})).rejects.toMatchObject({
|
|
code: "workspace_validation_failed",
|
|
message: expect.stringContaining("is not a git checkout"),
|
|
resultJson: {
|
|
workspaceValidation: expect.objectContaining({
|
|
reason: "git_worktree_base_not_git_checkout",
|
|
}),
|
|
},
|
|
});
|
|
} finally {
|
|
await fs.rm(cwd, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("refuses a git-checkout fallback cwd that is not the project workspace, without claiming a clone failed", async () => {
|
|
const cwd = await createGitCheckout({ withRemote: false });
|
|
try {
|
|
await expect(assertGitWorktreeBaseWorkspaceReady({
|
|
requestedExecutionWorkspaceMode: "isolated_workspace",
|
|
config: { workspaceStrategy: { type: "git_worktree" } },
|
|
issue: {
|
|
id: "issue-1",
|
|
identifier: "PAP-1",
|
|
projectId: "project-1",
|
|
projectWorkspaceId: "workspace-1",
|
|
},
|
|
base: {
|
|
baseCwd: cwd,
|
|
source: "project_primary",
|
|
projectId: "project-1",
|
|
workspaceId: "workspace-1",
|
|
repoUrl: null,
|
|
repoRef: null,
|
|
},
|
|
anchor: { baseCwdFallback: true, materializationFailures: [] },
|
|
})).rejects.toMatchObject({
|
|
code: "workspace_validation_failed",
|
|
message: expect.stringContaining("configured project workspace path is not available"),
|
|
resultJson: {
|
|
workspaceValidation: expect.objectContaining({
|
|
reason: "git_worktree_base_fallback_not_project_workspace",
|
|
baseCwdFallback: true,
|
|
materializationFailures: [],
|
|
}),
|
|
},
|
|
});
|
|
} finally {
|
|
await fs.rm(cwd, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("allows isolated git worktrees when the anchor reports no fallback", async () => {
|
|
const cwd = await createGitCheckout({ withRemote: false });
|
|
try {
|
|
await expect(assertGitWorktreeBaseWorkspaceReady({
|
|
requestedExecutionWorkspaceMode: "isolated_workspace",
|
|
config: { workspaceStrategy: { type: "git_worktree" } },
|
|
issue: {
|
|
id: "issue-1",
|
|
identifier: "PAP-1",
|
|
projectId: "project-1",
|
|
projectWorkspaceId: "workspace-1",
|
|
},
|
|
base: {
|
|
baseCwd: cwd,
|
|
source: "project_primary",
|
|
projectId: "project-1",
|
|
workspaceId: "workspace-1",
|
|
repoUrl: "https://github.com/example/repo.git",
|
|
repoRef: "origin/master",
|
|
},
|
|
anchor: { baseCwdFallback: false, materializationFailures: [] },
|
|
})).resolves.toBeUndefined();
|
|
} finally {
|
|
await fs.rm(cwd, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("allows isolated git worktrees when the resolved base is a git checkout", async () => {
|
|
const cwd = await createGitCheckout({ withRemote: false });
|
|
try {
|
|
await expect(assertGitWorktreeBaseWorkspaceReady({
|
|
requestedExecutionWorkspaceMode: "isolated_workspace",
|
|
config: { workspaceStrategy: { type: "git_worktree" } },
|
|
issue: {
|
|
id: "issue-1",
|
|
identifier: "PAP-1",
|
|
projectId: "project-1",
|
|
projectWorkspaceId: "workspace-1",
|
|
},
|
|
base: {
|
|
baseCwd: cwd,
|
|
source: "project_primary",
|
|
projectId: "project-1",
|
|
workspaceId: "workspace-1",
|
|
repoUrl: "https://github.com/example/repo.git",
|
|
repoRef: "origin/master",
|
|
},
|
|
})).resolves.toBeUndefined();
|
|
} finally {
|
|
await fs.rm(cwd, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("does not require git for shared project-primary workspaces", async () => {
|
|
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-shared-workspace-"));
|
|
try {
|
|
await expect(assertGitWorktreeBaseWorkspaceReady({
|
|
requestedExecutionWorkspaceMode: "shared_workspace",
|
|
config: { workspaceStrategy: { type: "git_worktree" } },
|
|
issue: {
|
|
id: "issue-1",
|
|
identifier: "PAP-1",
|
|
projectId: "project-1",
|
|
projectWorkspaceId: "workspace-1",
|
|
},
|
|
base: {
|
|
baseCwd: cwd,
|
|
source: "project_primary",
|
|
projectId: "project-1",
|
|
workspaceId: "workspace-1",
|
|
repoUrl: null,
|
|
repoRef: null,
|
|
},
|
|
})).resolves.toBeUndefined();
|
|
} finally {
|
|
await fs.rm(cwd, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("allows isolated workspace with no explicit strategy type even when base is agent_home", async () => {
|
|
// No workspaceStrategy.type → realizeExecutionWorkspace defaults to project_primary (not git_worktree),
|
|
// so the guard must not fire. This prevents false workspace_validation_failed for configs that omit type.
|
|
const fallbackCwd = resolveDefaultAgentWorkspaceDir("agent-1");
|
|
await expect(assertGitWorktreeBaseWorkspaceReady({
|
|
requestedExecutionWorkspaceMode: "isolated_workspace",
|
|
config: {},
|
|
issue: {
|
|
id: "issue-1",
|
|
identifier: "PAP-1",
|
|
projectId: null,
|
|
projectWorkspaceId: null,
|
|
},
|
|
base: {
|
|
baseCwd: fallbackCwd,
|
|
source: "agent_home",
|
|
projectId: null,
|
|
workspaceId: null,
|
|
repoUrl: null,
|
|
repoRef: null,
|
|
},
|
|
})).resolves.toBeUndefined();
|
|
});
|
|
|
|
it("allows operator-branch workspace with no explicit strategy type even when base is agent_home", async () => {
|
|
const fallbackCwd = resolveDefaultAgentWorkspaceDir("agent-1");
|
|
await expect(assertGitWorktreeBaseWorkspaceReady({
|
|
requestedExecutionWorkspaceMode: "operator_branch",
|
|
config: {},
|
|
issue: {
|
|
id: "issue-1",
|
|
identifier: "PAP-1",
|
|
projectId: null,
|
|
projectWorkspaceId: null,
|
|
},
|
|
base: {
|
|
baseCwd: fallbackCwd,
|
|
source: "agent_home",
|
|
projectId: null,
|
|
workspaceId: null,
|
|
repoUrl: null,
|
|
repoRef: null,
|
|
},
|
|
})).resolves.toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("buildAnchorFallbackWorkspaceNotes", () => {
|
|
it("reports materialization failures ahead of the generic no-cwd note", () => {
|
|
expect(buildAnchorFallbackWorkspaceNotes({
|
|
fallbackCwd: "/paperclip/workspaces/agent-1",
|
|
preferredWorkspaceWarning: null,
|
|
materializationFailures: [{
|
|
projectWorkspaceId: "workspace-1",
|
|
repoUrl: "https://github.com/example/private.git",
|
|
error: "fatal: could not read Username",
|
|
}],
|
|
missingProjectCwds: [],
|
|
hasConfiguredProjectCwd: false,
|
|
})).toEqual([
|
|
'Failed to prepare the project workspace checkout: fatal: could not read Username. Using fallback workspace "/paperclip/workspaces/agent-1" for this run.',
|
|
]);
|
|
});
|
|
|
|
it("counts additional failed candidates", () => {
|
|
const failure = {
|
|
projectWorkspaceId: "workspace-1",
|
|
repoUrl: null,
|
|
error: "clone timed out",
|
|
};
|
|
expect(buildAnchorFallbackWorkspaceNotes({
|
|
fallbackCwd: "/fallback",
|
|
preferredWorkspaceWarning: null,
|
|
materializationFailures: [failure, { ...failure, projectWorkspaceId: "workspace-2" }],
|
|
missingProjectCwds: [],
|
|
hasConfiguredProjectCwd: false,
|
|
})).toEqual([
|
|
'Failed to prepare the project workspace checkout (clone timed out), and 1 other candidate workspace(s) also failed. Using fallback workspace "/fallback" for this run.',
|
|
]);
|
|
});
|
|
|
|
it("preserves the existing missing-path and no-cwd notes when nothing failed to materialize", () => {
|
|
expect(buildAnchorFallbackWorkspaceNotes({
|
|
fallbackCwd: "/fallback",
|
|
preferredWorkspaceWarning: "Selected project workspace \"workspace-9\" is not available on this project.",
|
|
materializationFailures: [],
|
|
missingProjectCwds: ["/missing/path"],
|
|
hasConfiguredProjectCwd: true,
|
|
})).toEqual([
|
|
'Selected project workspace "workspace-9" is not available on this project.',
|
|
'Project workspace path "/missing/path" is not available yet. Using fallback workspace "/fallback" for this run.',
|
|
]);
|
|
expect(buildAnchorFallbackWorkspaceNotes({
|
|
fallbackCwd: "/fallback",
|
|
preferredWorkspaceWarning: null,
|
|
materializationFailures: [],
|
|
missingProjectCwds: [],
|
|
hasConfiguredProjectCwd: false,
|
|
})).toEqual([
|
|
'Project workspace has no local cwd configured. Using fallback workspace "/fallback" for this run.',
|
|
]);
|
|
});
|
|
|
|
it("emits both failure and missing-path notes when a project has both kinds of candidates", () => {
|
|
expect(buildAnchorFallbackWorkspaceNotes({
|
|
fallbackCwd: "/fallback",
|
|
preferredWorkspaceWarning: null,
|
|
materializationFailures: [{
|
|
projectWorkspaceId: "workspace-1",
|
|
repoUrl: "https://github.com/example/private.git",
|
|
error: "authentication failed",
|
|
}],
|
|
missingProjectCwds: ["/missing/path"],
|
|
hasConfiguredProjectCwd: true,
|
|
})).toEqual([
|
|
'Failed to prepare the project workspace checkout: authentication failed. Using fallback workspace "/fallback" for this run.',
|
|
'Project workspace path "/missing/path" is not available yet. Using fallback workspace "/fallback" for this run.',
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("assertPushCapabilityCheckoutValid", () => {
|
|
it("rejects a GitHub PR workflow checkout without a configured push remote", async () => {
|
|
const cwd = await createGitCheckout({ withRemote: false });
|
|
try {
|
|
await expect(assertPushCapabilityCheckoutValid({
|
|
enabled: true,
|
|
issue: {
|
|
id: "issue-1",
|
|
identifier: "PAP-1",
|
|
},
|
|
cwd,
|
|
})).rejects.toMatchObject({
|
|
code: "workspace_validation_failed",
|
|
message: expect.stringContaining("has no configured push remote"),
|
|
resultJson: {
|
|
workspaceValidation: expect.objectContaining({
|
|
reason: "missing_git_push_remote",
|
|
issueId: "issue-1",
|
|
executionWorkspaceCwd: cwd,
|
|
}),
|
|
},
|
|
});
|
|
} finally {
|
|
await fs.rm(cwd, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("allows a GitHub PR workflow checkout when a push remote is configured", async () => {
|
|
const cwd = await createGitCheckout({ withRemote: true });
|
|
try {
|
|
await expect(assertPushCapabilityCheckoutValid({
|
|
enabled: true,
|
|
issue: {
|
|
id: "issue-1",
|
|
identifier: "PAP-1",
|
|
},
|
|
cwd,
|
|
})).resolves.toBeUndefined();
|
|
} finally {
|
|
await fs.rm(cwd, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("requiresPushCapabilityPreflight", () => {
|
|
it("only enables the guard when the issue explicitly mentions the GitHub PR workflow skill", () => {
|
|
expect(requiresPushCapabilityPreflight({
|
|
adapterType: "codex_local",
|
|
issueId: "issue-1",
|
|
explicitRunScopedSkillKeys: ["paperclipai/bundled/software-development/github-pr-workflow"],
|
|
})).toBe(true);
|
|
|
|
expect(requiresPushCapabilityPreflight({
|
|
adapterType: "codex_local",
|
|
issueId: "issue-1",
|
|
explicitRunScopedSkillKeys: [],
|
|
})).toBe(false);
|
|
|
|
expect(requiresPushCapabilityPreflight({
|
|
adapterType: "cursor-cloud",
|
|
issueId: "issue-1",
|
|
explicitRunScopedSkillKeys: ["paperclipai/bundled/software-development/github-pr-workflow"],
|
|
})).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("stripHostWorkspaceProvision", () => {
|
|
it.each(["sandbox", "ssh"])("removes nested and top-level runtime commands immutably for %s", selectedEnvironmentDriver => {
|
|
const config = { runtimeProvisionCommand: "top-level", workspaceStrategy: { type: "git_worktree", provisionCommand: "host", runtimeProvisionCommand: "nested" } };
|
|
const original = structuredClone(config);
|
|
expect(stripHostWorkspaceProvision({ config, skipHostProvision: true }))
|
|
.toEqual({ workspaceStrategy: { type: "git_worktree" } });
|
|
expect(config).toEqual(original);
|
|
});
|
|
it("removes controller provisioning while retaining unrelated settings", () => {
|
|
const config = {
|
|
workspaceStrategy: {
|
|
type: "git_worktree",
|
|
branchTemplate: "{{issue.identifier}}-{{slug}}",
|
|
provisionCommand: "bash ./scripts/provision-worktree.sh",
|
|
teardownCommand: "bash ./scripts/teardown-worktree.sh",
|
|
},
|
|
workspaceRuntime: {
|
|
services: [{ name: "web" }],
|
|
},
|
|
};
|
|
|
|
const result = stripHostWorkspaceProvision({
|
|
config,
|
|
skipHostProvision: true,
|
|
});
|
|
|
|
expect(result).not.toBe(config);
|
|
expect(result.workspaceStrategy).toEqual({
|
|
type: "git_worktree",
|
|
branchTemplate: "{{issue.identifier}}-{{slug}}",
|
|
teardownCommand: "bash ./scripts/teardown-worktree.sh",
|
|
});
|
|
expect(result.workspaceRuntime).toBe(config.workspaceRuntime);
|
|
expect(config.workspaceStrategy.provisionCommand).toBe("bash ./scripts/provision-worktree.sh");
|
|
});
|
|
|
|
it("strips provision commands when the controller is not the execution target", () => {
|
|
const config = {
|
|
workspaceStrategy: {
|
|
type: "git_worktree",
|
|
provisionCommand: "bash ./scripts/provision-worktree.sh",
|
|
},
|
|
};
|
|
|
|
expect(stripHostWorkspaceProvision({
|
|
config,
|
|
skipHostProvision: true,
|
|
})).toEqual({ workspaceStrategy: { type: "git_worktree" } });
|
|
});
|
|
|
|
it("preserves provision commands when the server permits controller provisioning", () => {
|
|
const config = {
|
|
workspaceStrategy: {
|
|
type: "git_worktree",
|
|
provisionCommand: "bash ./scripts/provision-worktree.sh",
|
|
},
|
|
};
|
|
|
|
expect(stripHostWorkspaceProvision({
|
|
config,
|
|
skipHostProvision: false,
|
|
})).toBe(config);
|
|
});
|
|
});
|
|
|
|
describe("preflightLowTrustWorkspaceIsolation", () => {
|
|
it("fails non-sandbox low-trust runs before the caller reaches host workspace side effects", async () => {
|
|
let hostWorkspaceSideEffectReached = false;
|
|
|
|
await expect((async () => {
|
|
await preflightLowTrustWorkspaceIsolation({
|
|
trustPreset: lowTrustResolution(),
|
|
isolatedWorkspacesEnabled: true,
|
|
effectiveExecutionWorkspaceMode: "isolated_workspace",
|
|
issue: {
|
|
companyId: "company-1",
|
|
id: "issue-1",
|
|
projectId: "project-1",
|
|
},
|
|
resolveSelectedEnvironmentDriver: async () => "local",
|
|
});
|
|
hostWorkspaceSideEffectReached = true;
|
|
})()).rejects.toMatchObject({
|
|
status: 422,
|
|
details: expect.objectContaining({
|
|
code: "low_trust_requires_sandbox_environment",
|
|
}),
|
|
});
|
|
|
|
expect(hostWorkspaceSideEffectReached).toBe(false);
|
|
});
|
|
|
|
it("returns the sandbox driver for sandbox-backed low-trust runs", async () => {
|
|
await expect(preflightLowTrustWorkspaceIsolation({
|
|
trustPreset: lowTrustResolution(),
|
|
isolatedWorkspacesEnabled: true,
|
|
effectiveExecutionWorkspaceMode: "isolated_workspace",
|
|
issue: {
|
|
companyId: "company-1",
|
|
id: "issue-1",
|
|
projectId: "project-1",
|
|
},
|
|
resolveSelectedEnvironmentDriver: async () => "sandbox",
|
|
})).resolves.toBe("sandbox");
|
|
});
|
|
|
|
it("allows child issues inside a rootIssueId low-trust boundary during workspace preflight", async () => {
|
|
await expect(preflightLowTrustWorkspaceIsolation({
|
|
db: buildIssueAncestryDb([
|
|
{ id: "issue-child", companyId: "company-1", parentId: "issue-1" },
|
|
{ id: "issue-1", companyId: "company-1", parentId: null },
|
|
]) as any,
|
|
trustPreset: lowTrustResolution(),
|
|
isolatedWorkspacesEnabled: true,
|
|
effectiveExecutionWorkspaceMode: "isolated_workspace",
|
|
issue: {
|
|
companyId: "company-1",
|
|
id: "issue-child",
|
|
projectId: null,
|
|
},
|
|
resolveSelectedEnvironmentDriver: async () => "sandbox",
|
|
})).resolves.toBe("sandbox");
|
|
});
|
|
});
|
|
|
|
describe("resolveWorkspaceAfterLowTrustPreflight", () => {
|
|
it("fails non-sandbox low-trust runs before resolving workspaces", async () => {
|
|
let workspaceResolverReached = false;
|
|
|
|
await expect(resolveWorkspaceAfterLowTrustPreflight({
|
|
trustPreset: lowTrustResolution(),
|
|
isolatedWorkspacesEnabled: true,
|
|
effectiveExecutionWorkspaceMode: "isolated_workspace",
|
|
issue: {
|
|
companyId: "company-1",
|
|
id: "issue-1",
|
|
projectId: "project-1",
|
|
},
|
|
resolveSelectedEnvironmentDriver: async () => "local",
|
|
resolveWorkspace: async () => {
|
|
workspaceResolverReached = true;
|
|
return buildResolvedWorkspace();
|
|
},
|
|
})).rejects.toMatchObject({
|
|
status: 422,
|
|
details: expect.objectContaining({
|
|
code: "low_trust_requires_sandbox_environment",
|
|
}),
|
|
});
|
|
|
|
expect(workspaceResolverReached).toBe(false);
|
|
});
|
|
|
|
it("preserves standard-trust workspace resolution", async () => {
|
|
const workspace = buildResolvedWorkspace({ cwd: "/tmp/standard-workspace" });
|
|
|
|
await expect(resolveWorkspaceAfterLowTrustPreflight({
|
|
trustPreset: standardTrustResolution(),
|
|
isolatedWorkspacesEnabled: false,
|
|
effectiveExecutionWorkspaceMode: "shared_workspace",
|
|
issue: {
|
|
companyId: "company-1",
|
|
id: "issue-1",
|
|
projectId: "project-1",
|
|
},
|
|
resolveSelectedEnvironmentDriver: async () => {
|
|
throw new Error("standard trust should not inspect the environment driver");
|
|
},
|
|
resolveWorkspace: async () => workspace,
|
|
})).resolves.toEqual({
|
|
selectedEnvironmentDriver: null,
|
|
workspace,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("resolveRuntimeSessionParamsForWorkspace", () => {
|
|
it("keeps a legacy projectless Codex session in the default agent workspace", () => {
|
|
const agentId = "agent-projectless-legacy";
|
|
const fallbackCwd = resolveDefaultAgentWorkspaceDir(agentId);
|
|
const previousSessionParams = {
|
|
sessionId: "legacy-session-1",
|
|
cwd: fallbackCwd,
|
|
};
|
|
|
|
const result = resolveRuntimeSessionParamsForWorkspace({
|
|
agentId,
|
|
previousSessionParams,
|
|
resolvedWorkspace: buildResolvedWorkspace({
|
|
cwd: fallbackCwd,
|
|
source: "agent_home",
|
|
projectId: null,
|
|
workspaceId: null,
|
|
}),
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
sessionParams: previousSessionParams,
|
|
warning: null,
|
|
});
|
|
});
|
|
|
|
it("migrates fallback workspace sessions to project workspace when project cwd becomes available", () => {
|
|
const agentId = "agent-123";
|
|
const fallbackCwd = resolveDefaultAgentWorkspaceDir(agentId);
|
|
|
|
const result = resolveRuntimeSessionParamsForWorkspace({
|
|
agentId,
|
|
previousSessionParams: {
|
|
sessionId: "session-1",
|
|
cwd: fallbackCwd,
|
|
workspaceId: "workspace-1",
|
|
},
|
|
resolvedWorkspace: buildResolvedWorkspace({ cwd: "/tmp/new-project-cwd" }),
|
|
});
|
|
|
|
expect(result.sessionParams).toMatchObject({
|
|
sessionId: "session-1",
|
|
cwd: "/tmp/new-project-cwd",
|
|
workspaceId: "workspace-1",
|
|
});
|
|
expect(result.warning).toContain("Attempting to resume session");
|
|
});
|
|
|
|
it("does not migrate when previous session cwd is not the fallback workspace", () => {
|
|
const result = resolveRuntimeSessionParamsForWorkspace({
|
|
agentId: "agent-123",
|
|
previousSessionParams: {
|
|
sessionId: "session-1",
|
|
cwd: "/tmp/some-other-cwd",
|
|
workspaceId: "workspace-1",
|
|
},
|
|
resolvedWorkspace: buildResolvedWorkspace({ cwd: "/tmp/new-project-cwd" }),
|
|
});
|
|
|
|
expect(result.sessionParams).toEqual({
|
|
sessionId: "session-1",
|
|
cwd: "/tmp/some-other-cwd",
|
|
workspaceId: "workspace-1",
|
|
});
|
|
expect(result.warning).toBeNull();
|
|
});
|
|
|
|
it("does not migrate when resolved workspace id differs from previous session workspace id", () => {
|
|
const agentId = "agent-123";
|
|
const fallbackCwd = resolveDefaultAgentWorkspaceDir(agentId);
|
|
|
|
const result = resolveRuntimeSessionParamsForWorkspace({
|
|
agentId,
|
|
previousSessionParams: {
|
|
sessionId: "session-1",
|
|
cwd: fallbackCwd,
|
|
workspaceId: "workspace-1",
|
|
},
|
|
resolvedWorkspace: buildResolvedWorkspace({
|
|
cwd: "/tmp/new-project-cwd",
|
|
workspaceId: "workspace-2",
|
|
}),
|
|
});
|
|
|
|
expect(result.sessionParams).toEqual({
|
|
sessionId: "session-1",
|
|
cwd: fallbackCwd,
|
|
workspaceId: "workspace-1",
|
|
});
|
|
expect(result.warning).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("applyPersistedExecutionWorkspaceConfig", () => {
|
|
it("does not add workspace runtime when only the project workspace had manual runtime config", () => {
|
|
const result = applyPersistedExecutionWorkspaceConfig({
|
|
config: {},
|
|
workspaceConfig: null,
|
|
mode: "isolated_workspace",
|
|
});
|
|
|
|
expect("workspaceRuntime" in result).toBe(false);
|
|
});
|
|
|
|
it("applies explicit persisted execution workspace runtime config when present", () => {
|
|
const result = applyPersistedExecutionWorkspaceConfig({
|
|
config: {},
|
|
workspaceConfig: {
|
|
provisionCommand: null,
|
|
teardownCommand: null,
|
|
cleanupCommand: null,
|
|
desiredState: null,
|
|
workspaceRuntime: {
|
|
services: [{ name: "workspace-web" }],
|
|
},
|
|
},
|
|
mode: "isolated_workspace",
|
|
});
|
|
|
|
expect(result.workspaceRuntime).toEqual({
|
|
services: [{ name: "workspace-web" }],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("mergeExecutionWorkspaceMetadataForPersistence", () => {
|
|
it("persists branch ownership independently of fresh worktree creation", () => {
|
|
const executionWorkspace = {
|
|
created: true,
|
|
branchCreatedByRuntime: false,
|
|
};
|
|
|
|
const metadata = mergeExecutionWorkspaceMetadataForPersistence({
|
|
existingMetadata: null,
|
|
source: "task_session",
|
|
createdByRuntime: resolveExecutionWorkspaceBranchOwnership(executionWorkspace),
|
|
strategyType: "git_worktree",
|
|
configSnapshot: null,
|
|
shouldReuseExisting: false,
|
|
baseRef: "origin/main",
|
|
baseRefSha: null,
|
|
});
|
|
|
|
expect(metadata?.createdByRuntime).toBe(false);
|
|
expect(metadata?.gitBranchOwnershipVersion).toBe(1);
|
|
});
|
|
|
|
it("does not downgrade recorded runtime ownership after worktree reuse", () => {
|
|
const executionWorkspace = {
|
|
created: false,
|
|
branchCreatedByRuntime: true,
|
|
};
|
|
|
|
const metadata = mergeExecutionWorkspaceMetadataForPersistence({
|
|
existingMetadata: { createdByRuntime: true },
|
|
source: "task_session",
|
|
createdByRuntime: resolveExecutionWorkspaceBranchOwnership(executionWorkspace),
|
|
strategyType: "git_worktree",
|
|
configSnapshot: null,
|
|
shouldReuseExisting: false,
|
|
baseRef: "origin/main",
|
|
baseRefSha: null,
|
|
});
|
|
|
|
expect(metadata?.createdByRuntime).toBe(true);
|
|
expect(metadata?.gitBranchOwnershipVersion).toBe(1);
|
|
});
|
|
|
|
it("merges config snapshot for newly realized workspaces", () => {
|
|
expect(mergeExecutionWorkspaceMetadataForPersistence({
|
|
existingMetadata: null,
|
|
source: "task_session",
|
|
createdByRuntime: true,
|
|
strategyType: "project_primary",
|
|
configSnapshot: {
|
|
environmentId: "env-new",
|
|
provisionCommand: "bash ./scripts/provision.sh",
|
|
},
|
|
shouldReuseExisting: false,
|
|
baseRef: null,
|
|
baseRefSha: null,
|
|
})).toEqual({
|
|
source: "task_session",
|
|
createdByRuntime: true,
|
|
config: {
|
|
environmentId: "env-new",
|
|
provisionCommand: "bash ./scripts/provision.sh",
|
|
runtimeProvisionCommand: null,
|
|
teardownCommand: null,
|
|
cleanupCommand: null,
|
|
desiredState: null,
|
|
serviceStates: null,
|
|
workspaceRuntime: null,
|
|
},
|
|
});
|
|
});
|
|
|
|
it("preserves persisted config snapshot when reusing an existing workspace", () => {
|
|
expect(mergeExecutionWorkspaceMetadataForPersistence({
|
|
existingMetadata: {
|
|
config: {
|
|
environmentId: "env-old",
|
|
provisionCommand: "bash ./scripts/existing-provision.sh",
|
|
},
|
|
},
|
|
source: "task_session",
|
|
createdByRuntime: false,
|
|
strategyType: "project_primary",
|
|
configSnapshot: {
|
|
environmentId: "env-new",
|
|
provisionCommand: "bash ./scripts/new-provision.sh",
|
|
},
|
|
shouldReuseExisting: true,
|
|
baseRef: null,
|
|
baseRefSha: null,
|
|
})).toEqual({
|
|
config: {
|
|
environmentId: "env-old",
|
|
provisionCommand: "bash ./scripts/existing-provision.sh",
|
|
},
|
|
source: "task_session",
|
|
createdByRuntime: false,
|
|
});
|
|
});
|
|
|
|
it("records the resolved base ref SHA for newly realized workspaces", () => {
|
|
expect(mergeExecutionWorkspaceMetadataForPersistence({
|
|
existingMetadata: null,
|
|
source: "task_session",
|
|
createdByRuntime: true,
|
|
strategyType: "project_primary",
|
|
configSnapshot: null,
|
|
shouldReuseExisting: false,
|
|
baseRef: "origin/main",
|
|
baseRefSha: "abc1234567890",
|
|
})).toEqual({
|
|
source: "task_session",
|
|
createdByRuntime: true,
|
|
baseRefSnapshot: {
|
|
baseRef: "origin/main",
|
|
resolvedSha: "abc1234567890",
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
type WorkspaceConfigMetadata = ReturnType<typeof buildEffectiveRunWorkspaceConfigMetadata>;
|
|
|
|
function buildWorkspaceConfigMetadata(
|
|
overrides: Partial<Parameters<typeof buildEffectiveRunWorkspaceConfigMetadata>[0]> = {},
|
|
) {
|
|
return buildEffectiveRunWorkspaceConfigMetadata({
|
|
mode: "isolated_workspace",
|
|
projectId: "project-1",
|
|
projectWorkspaceId: "workspace-1",
|
|
strategyType: "git_worktree",
|
|
workspaceStrategy: {
|
|
type: "git_worktree",
|
|
baseRef: "origin/main",
|
|
branchTemplate: "{{issue.identifier}}-{{slug}}",
|
|
worktreeParentDir: ".paperclip/worktrees",
|
|
},
|
|
repoUrl: "https://github.com/example/repo.git",
|
|
repoRef: "origin/main",
|
|
configSnapshot: {
|
|
provisionCommand: "pnpm install",
|
|
teardownCommand: "pnpm stop",
|
|
cleanupCommand: "pnpm clean",
|
|
desiredState: "running",
|
|
serviceStates: { "0": "running" },
|
|
workspaceRuntime: {
|
|
services: [{ name: "web", command: "pnpm dev", port: 3100 }],
|
|
},
|
|
},
|
|
environment: {
|
|
selectedEnvironmentId: "environment-1",
|
|
driver: "local",
|
|
config: { provider: "local" },
|
|
},
|
|
realization: {
|
|
environmentDriver: "local",
|
|
environmentProvider: "local",
|
|
},
|
|
evaluatedAt: "2026-06-26T00:00:00.000Z",
|
|
...overrides,
|
|
});
|
|
}
|
|
|
|
function persistedWorkspaceConfigFingerprint(metadata: WorkspaceConfigMetadata) {
|
|
return {
|
|
configFingerprint: {
|
|
version: metadata.version,
|
|
workspaceHash: metadata.fingerprint,
|
|
categories: metadata.categories,
|
|
categoryFingerprints: metadata.categoryFingerprints,
|
|
lastEvaluatedAt: metadata.evaluatedAt,
|
|
},
|
|
};
|
|
}
|
|
|
|
describe("effective run execution workspace config freshness", () => {
|
|
it("reuses an existing workspace when the stored workspace fingerprint is unchanged", () => {
|
|
const metadata = buildWorkspaceConfigMetadata();
|
|
|
|
const decision = resolveExecutionWorkspaceConfigFreshness({
|
|
hasExistingWorkspace: true,
|
|
existingWorkspaceMetadata: persistedWorkspaceConfigFingerprint(metadata),
|
|
nextMetadata: metadata,
|
|
});
|
|
|
|
expect(decision).toMatchObject({
|
|
action: "reuse",
|
|
shouldReuseExisting: true,
|
|
shouldRefreshConfigSnapshot: false,
|
|
changedCategories: [],
|
|
storedFingerprintPresent: true,
|
|
});
|
|
});
|
|
|
|
it("refreshes metadata and config for runtime-service-only drift without replacing the workspace", () => {
|
|
const base = buildWorkspaceConfigMetadata();
|
|
const next = buildWorkspaceConfigMetadata({
|
|
configSnapshot: {
|
|
provisionCommand: "pnpm install",
|
|
teardownCommand: "pnpm stop",
|
|
cleanupCommand: "pnpm clean",
|
|
desiredState: "running",
|
|
serviceStates: { "0": "running" },
|
|
workspaceRuntime: {
|
|
services: [{ name: "web", command: "pnpm dev -- --host 0.0.0.0", port: 3200 }],
|
|
},
|
|
},
|
|
});
|
|
|
|
const decision = resolveExecutionWorkspaceConfigFreshness({
|
|
hasExistingWorkspace: true,
|
|
existingWorkspaceMetadata: persistedWorkspaceConfigFingerprint(base),
|
|
nextMetadata: next,
|
|
});
|
|
|
|
expect(decision).toMatchObject({
|
|
action: "refresh",
|
|
shouldReuseExisting: true,
|
|
shouldRefreshConfigSnapshot: true,
|
|
changedCategories: ["runtimeServices"],
|
|
});
|
|
|
|
const metadata = mergeExecutionWorkspaceMetadataForPersistence({
|
|
existingMetadata: {
|
|
config: {
|
|
workspaceRuntime: { services: [{ name: "web", command: "pnpm dev", port: 3100 }] },
|
|
},
|
|
...persistedWorkspaceConfigFingerprint(base),
|
|
},
|
|
source: "task_session",
|
|
createdByRuntime: false,
|
|
strategyType: "project_primary",
|
|
configSnapshot: {
|
|
workspaceRuntime: {
|
|
services: [{ name: "web", command: "pnpm dev -- --host 0.0.0.0", port: 3200 }],
|
|
},
|
|
desiredState: "running",
|
|
serviceStates: { "0": "running" },
|
|
},
|
|
shouldReuseExisting: true,
|
|
shouldRefreshConfigSnapshot: true,
|
|
workspaceConfigMetadata: next,
|
|
baseRef: "origin/main",
|
|
baseRefSha: "abc123",
|
|
});
|
|
|
|
expect(metadata?.config).toMatchObject({
|
|
workspaceRuntime: {
|
|
services: [{ name: "web", command: "pnpm dev -- --host 0.0.0.0", port: 3200 }],
|
|
},
|
|
});
|
|
expect(metadata?.configFingerprint).toMatchObject({
|
|
workspaceHash: next.fingerprint,
|
|
categories: next.categories,
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "mode",
|
|
category: "mode",
|
|
next: buildWorkspaceConfigMetadata({ mode: "shared_workspace" }),
|
|
},
|
|
{
|
|
name: "workspace strategy",
|
|
category: "strategy",
|
|
next: buildWorkspaceConfigMetadata({
|
|
workspaceStrategy: {
|
|
type: "git_worktree",
|
|
baseRef: "origin/main",
|
|
branchTemplate: "custom-{{issue.identifier}}",
|
|
worktreeParentDir: ".paperclip/worktrees",
|
|
},
|
|
}),
|
|
},
|
|
{
|
|
name: "project workspace",
|
|
category: "projectWorkspace",
|
|
next: buildWorkspaceConfigMetadata({ projectWorkspaceId: "workspace-2" }),
|
|
},
|
|
{
|
|
name: "base ref",
|
|
category: "repo",
|
|
next: buildWorkspaceConfigMetadata({
|
|
repoRef: "origin/release",
|
|
workspaceStrategy: {
|
|
type: "git_worktree",
|
|
baseRef: "origin/release",
|
|
branchTemplate: "{{issue.identifier}}-{{slug}}",
|
|
worktreeParentDir: ".paperclip/worktrees",
|
|
},
|
|
}),
|
|
},
|
|
{
|
|
name: "environment realization",
|
|
category: "environment",
|
|
next: buildWorkspaceConfigMetadata({
|
|
environment: {
|
|
selectedEnvironmentId: "environment-2",
|
|
driver: "sandbox",
|
|
config: { provider: "daytona" },
|
|
},
|
|
realization: {
|
|
environmentDriver: "sandbox",
|
|
environmentProvider: "daytona",
|
|
},
|
|
}),
|
|
},
|
|
] as const)("replaces the workspace when $name changes", ({ category, next }) => {
|
|
const base = buildWorkspaceConfigMetadata();
|
|
|
|
const decision = resolveExecutionWorkspaceConfigFreshness({
|
|
hasExistingWorkspace: true,
|
|
existingWorkspaceMetadata: persistedWorkspaceConfigFingerprint(base),
|
|
nextMetadata: next,
|
|
});
|
|
|
|
expect(decision.action).toBe("replace");
|
|
expect(decision.shouldReuseExisting).toBe(false);
|
|
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,
|
|
strategyType: "project_primary",
|
|
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 loudly when explicit reuse restore errors", 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", warnings: [] }));
|
|
|
|
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.toThrow(/restore command failed/);
|
|
expect(realizeWorkspace).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each([
|
|
{ name: "missing", status: null },
|
|
{ name: "archived", status: "archived" },
|
|
])("fails loudly 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", warnings: [] }));
|
|
|
|
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", warnings: [] })
|
|
: null,
|
|
realizeWorkspace,
|
|
})).rejects.toThrow(/could not be restored/);
|
|
expect(realizeWorkspace).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not mistake a projectless native run-id binding for a missing persisted workspace", () => {
|
|
expect(resolveNativeRecoveryExecutionWorkspaceBinding({
|
|
bindingId: "run-projectless",
|
|
persistedWorkspaceFound: false,
|
|
})).toBeNull();
|
|
expect(resolveNativeRecoveryExecutionWorkspaceBinding({
|
|
bindingId: "workspace-persisted",
|
|
persistedWorkspaceFound: true,
|
|
})).toBe("workspace-persisted");
|
|
});
|
|
|
|
it.each([
|
|
{ name: "a different branch", branchName: "PAP-9001-derived-child-branch" },
|
|
{ name: "no recorded branch", branchName: null },
|
|
])(
|
|
"realizes the pinned existing branch instead of reusing an inherited workspace on $name",
|
|
async ({ branchName }) => {
|
|
const reuseRequest = resolveExecutionWorkspaceReuseRequestForIssue({
|
|
issueExecutionWorkspaceId: "workspace-old",
|
|
issueExecutionWorkspacePreference: "reuse_existing",
|
|
existingExecutionWorkspaceStatus: "active",
|
|
requestedExistingBranch: "PAP-14380-salvage-pap-9514",
|
|
existingExecutionWorkspaceBranchName: branchName,
|
|
});
|
|
|
|
expect(reuseRequest).toEqual({
|
|
requestedExecutionWorkspaceId: "workspace-old",
|
|
requestedShouldReuseExisting: false,
|
|
existingExecutionWorkspaceAvailable: false,
|
|
});
|
|
|
|
const metadata = buildWorkspaceConfigMetadata();
|
|
const decision = resolveExecutionWorkspaceConfigFreshness({
|
|
hasExistingWorkspace: false,
|
|
existingWorkspaceMetadata: null,
|
|
nextMetadata: metadata,
|
|
});
|
|
const realizeWorkspace = vi.fn(async () => ({ id: "pinned-branch-workspace", warnings: [] }));
|
|
const restoreExistingWorkspace = vi.fn(async () => ({ id: "workspace-old", warnings: [] }));
|
|
|
|
const result = await provisionExecutionWorkspaceForFreshnessDecision({
|
|
requestedShouldReuseExisting: reuseRequest.requestedShouldReuseExisting,
|
|
existingExecutionWorkspaceId: reuseRequest.requestedExecutionWorkspaceId,
|
|
issueRef: { id: "issue-1", identifier: "PAP-42" },
|
|
runId: "run-1",
|
|
workspaceConfigFreshness: decision,
|
|
restoreExistingWorkspace,
|
|
realizeWorkspace,
|
|
});
|
|
|
|
expect(result.executionWorkspace).toEqual({ id: "pinned-branch-workspace", warnings: [] });
|
|
expect(result.reusedExecutionWorkspace).toBeNull();
|
|
expect(restoreExistingWorkspace).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
it("keeps reusing an inherited workspace whose branch matches the pinned existing branch", () => {
|
|
expect(resolveExecutionWorkspaceReuseRequestForIssue({
|
|
issueExecutionWorkspaceId: "workspace-old",
|
|
issueExecutionWorkspacePreference: "reuse_existing",
|
|
existingExecutionWorkspaceStatus: "active",
|
|
requestedExistingBranch: "PAP-14380-salvage-pap-9514",
|
|
existingExecutionWorkspaceBranchName: "PAP-14380-salvage-pap-9514",
|
|
})).toEqual({
|
|
requestedExecutionWorkspaceId: "workspace-old",
|
|
requestedShouldReuseExisting: true,
|
|
existingExecutionWorkspaceAvailable: true,
|
|
});
|
|
});
|
|
|
|
it("fails loudly when explicit reuse restore returns no workspace", async () => {
|
|
const metadata = buildWorkspaceConfigMetadata();
|
|
const decision = resolveExecutionWorkspaceConfigFreshness({
|
|
hasExistingWorkspace: true,
|
|
existingWorkspaceMetadata: persistedWorkspaceConfigFingerprint(metadata),
|
|
nextMetadata: metadata,
|
|
});
|
|
const realizeWorkspace = vi.fn(async () => ({ id: "fallback-workspace", warnings: [] }));
|
|
|
|
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.toThrow(/could not be restored/);
|
|
expect(realizeWorkspace).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("formats a safe workspace operation payload for config drift decisions", () => {
|
|
const decision = resolveExecutionWorkspaceConfigFreshness({
|
|
hasExistingWorkspace: true,
|
|
existingWorkspaceMetadata: persistedWorkspaceConfigFingerprint(buildWorkspaceConfigMetadata()),
|
|
nextMetadata: buildWorkspaceConfigMetadata({
|
|
configSnapshot: {
|
|
workspaceRuntime: {
|
|
services: [{ name: "web", command: "pnpm dev -- --host 0.0.0.0", port: 3200 }],
|
|
},
|
|
},
|
|
}),
|
|
});
|
|
|
|
const operation = buildWorkspaceConfigFreshnessOperation({
|
|
decision,
|
|
hasExistingWorkspace: true,
|
|
reuseRequested: true,
|
|
workspaceReused: true,
|
|
configSnapshotRefreshed: true,
|
|
previousWorkspaceId: "workspace-old",
|
|
activeWorkspaceId: "workspace-old",
|
|
});
|
|
|
|
expect(operation).toMatchObject({
|
|
metadata: {
|
|
kind: "config_freshness",
|
|
action: "refresh",
|
|
changedCategories: ["lifecycleCommands", "runtimeServices"],
|
|
changedCategoryLabels: ["workspace lifecycle commands", "runtime services"],
|
|
reuseRequested: true,
|
|
workspaceReused: true,
|
|
configSnapshotRefreshed: true,
|
|
previousWorkspaceId: "workspace-old",
|
|
activeWorkspaceId: "workspace-old",
|
|
},
|
|
system: expect.stringContaining("refreshed execution workspace config"),
|
|
});
|
|
const serialized = JSON.stringify(operation);
|
|
expect(serialized).toContain("runtime services");
|
|
expect(serialized).not.toContain("pnpm dev");
|
|
expect(serialized).not.toContain("0.0.0.0");
|
|
});
|
|
|
|
it("reports actual reuse when freshness policy requested replacement", () => {
|
|
const decision = resolveExecutionWorkspaceConfigFreshness({
|
|
hasExistingWorkspace: true,
|
|
existingWorkspaceMetadata: {},
|
|
nextMetadata: buildWorkspaceConfigMetadata(),
|
|
});
|
|
const operation = buildWorkspaceConfigFreshnessOperation({
|
|
decision, hasExistingWorkspace: true, reuseRequested: true,
|
|
workspaceReused: true, configSnapshotRefreshed: true,
|
|
previousWorkspaceId: "same", activeWorkspaceId: "same",
|
|
});
|
|
expect(operation?.metadata.action).toBe("replace");
|
|
expect(operation?.system).toContain("reused execution workspace");
|
|
expect(operation?.system).not.toContain("replaced execution workspace");
|
|
});
|
|
|
|
it("does not record a freshness operation when an unchanged workspace is simply reused", () => {
|
|
const metadata = buildWorkspaceConfigMetadata();
|
|
const decision = resolveExecutionWorkspaceConfigFreshness({
|
|
hasExistingWorkspace: true,
|
|
existingWorkspaceMetadata: persistedWorkspaceConfigFingerprint(metadata),
|
|
nextMetadata: metadata,
|
|
});
|
|
|
|
expect(buildWorkspaceConfigFreshnessOperation({
|
|
decision,
|
|
hasExistingWorkspace: true,
|
|
reuseRequested: true,
|
|
workspaceReused: true,
|
|
configSnapshotRefreshed: false,
|
|
previousWorkspaceId: "workspace-1",
|
|
activeWorkspaceId: "workspace-1",
|
|
})).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("stripWorkspaceRuntimeFromExecutionRunConfig", () => {
|
|
it("removes workspace runtime before heartbeat execution", () => {
|
|
const input = {
|
|
cwd: "/tmp/project",
|
|
workspaceStrategy: {
|
|
type: "git_worktree",
|
|
},
|
|
workspaceRuntime: {
|
|
services: [{ name: "web" }],
|
|
},
|
|
};
|
|
|
|
const result = stripWorkspaceRuntimeFromExecutionRunConfig(input);
|
|
|
|
expect(result).toEqual({
|
|
cwd: "/tmp/project",
|
|
workspaceStrategy: {
|
|
type: "git_worktree",
|
|
},
|
|
});
|
|
expect(input.workspaceRuntime).toEqual({
|
|
services: [{ name: "web" }],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("shouldResetTaskSessionForWake", () => {
|
|
it("resets session context on assignment wake", () => {
|
|
expect(shouldResetTaskSessionForWake({ wakeReason: "issue_assigned" })).toBe(true);
|
|
});
|
|
|
|
it("preserves session context on execution review handoff wakes", () => {
|
|
expect(shouldResetTaskSessionForWake({ wakeReason: "execution_review_requested" })).toBe(false);
|
|
});
|
|
|
|
it("resets session context on execution approval wakes", () => {
|
|
expect(shouldResetTaskSessionForWake({ wakeReason: "execution_approval_requested" })).toBe(true);
|
|
});
|
|
|
|
it("preserves session context on execution changes-requested handoff wakes", () => {
|
|
expect(shouldResetTaskSessionForWake({ wakeReason: "execution_changes_requested" })).toBe(false);
|
|
});
|
|
|
|
it("preserves session context on timer heartbeats", () => {
|
|
expect(shouldResetTaskSessionForWake({ wakeSource: "timer" })).toBe(false);
|
|
});
|
|
|
|
it("preserves session context on manual on-demand invokes by default", () => {
|
|
expect(
|
|
shouldResetTaskSessionForWake({
|
|
wakeSource: "on_demand",
|
|
wakeTriggerDetail: "manual",
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("resets session context when a fresh session is explicitly requested", () => {
|
|
expect(
|
|
shouldResetTaskSessionForWake({
|
|
wakeSource: "on_demand",
|
|
wakeTriggerDetail: "manual",
|
|
forceFreshSession: true,
|
|
}),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("resets session context for accepted planning confirmations that refresh workspace selection", () => {
|
|
expect(
|
|
shouldResetTaskSessionForWake({
|
|
wakeReason: "issue_commented",
|
|
interactionKind: "request_confirmation",
|
|
interactionStatus: "accepted",
|
|
forceFreshSession: true,
|
|
workspaceRefreshReason: "accepted_plan_confirmation",
|
|
}),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("does not reset session context on mention wake comment", () => {
|
|
expect(
|
|
shouldResetTaskSessionForWake({
|
|
wakeReason: "issue_comment_mentioned",
|
|
wakeCommentId: "comment-1",
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("does not reset session context when commentId is present", () => {
|
|
expect(
|
|
shouldResetTaskSessionForWake({
|
|
wakeReason: "issue_commented",
|
|
commentId: "comment-2",
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("does not reset for comment wakes", () => {
|
|
expect(shouldResetTaskSessionForWake({ wakeReason: "issue_commented" })).toBe(false);
|
|
});
|
|
|
|
it("does not reset when wake reason is missing", () => {
|
|
expect(shouldResetTaskSessionForWake({})).toBe(false);
|
|
});
|
|
|
|
it("does not reset session context on callback on-demand invokes", () => {
|
|
expect(
|
|
shouldResetTaskSessionForWake({
|
|
wakeSource: "on_demand",
|
|
wakeTriggerDetail: "callback",
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("shouldDeferFollowupWakeForSameIssue", () => {
|
|
it("defers a same-agent follow-up for mention-style comment wakes while a run is active", () => {
|
|
expect(
|
|
shouldDeferFollowupWakeForSameIssue({
|
|
activeRunStatus: "running",
|
|
isSameExecutionAgent: true,
|
|
wakeCommentId: "comment-1",
|
|
forceFreshSession: false,
|
|
}),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("defers a same-agent follow-up when a fresh session is explicitly requested", () => {
|
|
expect(
|
|
shouldDeferFollowupWakeForSameIssue({
|
|
activeRunStatus: "running",
|
|
isSameExecutionAgent: true,
|
|
wakeCommentId: null,
|
|
forceFreshSession: true,
|
|
}),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("does not defer when the existing run is only queued", () => {
|
|
expect(
|
|
shouldDeferFollowupWakeForSameIssue({
|
|
activeRunStatus: "queued",
|
|
isSameExecutionAgent: true,
|
|
wakeCommentId: null,
|
|
forceFreshSession: true,
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("does not defer normal same-agent wakes without a comment or fresh-session request", () => {
|
|
expect(
|
|
shouldDeferFollowupWakeForSameIssue({
|
|
activeRunStatus: "running",
|
|
isSameExecutionAgent: true,
|
|
wakeCommentId: null,
|
|
forceFreshSession: false,
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("shouldResetTaskSessionForModelChange", () => {
|
|
it("resets when configured model differs from persisted session model", () => {
|
|
expect(
|
|
shouldResetTaskSessionForModelChange({
|
|
configuredModel: "gpt-5.4-mini",
|
|
taskSessionParams: {
|
|
sessionId: "thread-1",
|
|
__paperclipConfiguredModel: "opencode/mimo-v2-pro-free",
|
|
},
|
|
}),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("does not reset when models match", () => {
|
|
expect(
|
|
shouldResetTaskSessionForModelChange({
|
|
configuredModel: "gpt-5.4-mini",
|
|
taskSessionParams: {
|
|
sessionId: "thread-1",
|
|
__paperclipConfiguredModel: "gpt-5.4-mini",
|
|
},
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("does not reset when persisted session model is missing", () => {
|
|
expect(
|
|
shouldResetTaskSessionForModelChange({
|
|
configuredModel: "gpt-5.4-mini",
|
|
taskSessionParams: {
|
|
sessionId: "thread-1",
|
|
},
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("does not reset when configured model is missing", () => {
|
|
expect(
|
|
shouldResetTaskSessionForModelChange({
|
|
configuredModel: null,
|
|
taskSessionParams: {
|
|
sessionId: "thread-1",
|
|
__paperclipConfiguredModel: "gpt-5.4-mini",
|
|
},
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("does not reset when task session params are missing", () => {
|
|
expect(
|
|
shouldResetTaskSessionForModelChange({
|
|
configuredModel: "gpt-5.4-mini",
|
|
taskSessionParams: null,
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
});
|
|
|
|
type SessionConfigMetadata = Awaited<ReturnType<typeof buildEffectiveRunSessionConfigMetadata>>;
|
|
|
|
async function buildSessionConfigMetadata(
|
|
overrides: Partial<Parameters<typeof buildEffectiveRunSessionConfigMetadata>[0]> = {},
|
|
) {
|
|
return buildEffectiveRunSessionConfigMetadata({
|
|
adapterType: "codex_local",
|
|
effectiveAdapterConfig: {
|
|
command: "codex",
|
|
model: "gpt-5.4-mini",
|
|
env: {
|
|
OPENAI_API_KEY: "resolved-secret-value",
|
|
PLAIN_FLAG: "plain-value",
|
|
},
|
|
},
|
|
agentRuntimeConfig: {
|
|
heartbeat: {
|
|
maxConcurrentRuns: 1,
|
|
},
|
|
},
|
|
issueOverrides: null,
|
|
workspaceConfig: {
|
|
requestedMode: "agent_default",
|
|
effectiveMode: "agent_default",
|
|
projectConfigRevisionAt: "2026-06-01T00:00:00.000Z",
|
|
},
|
|
environment: {
|
|
selectionSource: "default",
|
|
selectedEnvironmentId: "environment-1",
|
|
selectedEnvironment: {
|
|
id: "environment-1",
|
|
driver: "local",
|
|
configRevisionAt: "2026-06-01T00:00:00.000Z",
|
|
},
|
|
},
|
|
environmentEnv: {
|
|
ENVIRONMENT_FLAG: "enabled",
|
|
},
|
|
projectEnv: {
|
|
PROJECT_FLAG: "enabled",
|
|
},
|
|
routineEnv: null,
|
|
secretManifest: [
|
|
{
|
|
configPath: "env.OPENAI_API_KEY",
|
|
envKey: "OPENAI_API_KEY",
|
|
secretId: "secret-1",
|
|
bindingId: "binding-1",
|
|
secretKey: "openai-api-key",
|
|
version: 7,
|
|
provider: "local_encrypted",
|
|
outcome: "success",
|
|
},
|
|
],
|
|
runtimeSkills: [
|
|
{
|
|
key: "paperclip",
|
|
runtimeName: "paperclip",
|
|
source: "/tmp/paperclip/runtime-skills/paperclip",
|
|
versionId: null,
|
|
currentVersionId: "skill-version-1",
|
|
sourceStatus: "available",
|
|
missingDetail: null,
|
|
},
|
|
],
|
|
agentConfigRevision: {
|
|
id: "agent-config-revision-1",
|
|
changedKeys: ["adapterConfig"],
|
|
configRevisionAt: "2026-06-01T00:00:00.000Z",
|
|
},
|
|
...overrides,
|
|
});
|
|
}
|
|
|
|
function sessionParamsWithConfigMetadata(
|
|
metadata: SessionConfigMetadata,
|
|
configuredModel = "gpt-5.4-mini",
|
|
) {
|
|
return {
|
|
sessionId: "thread-1",
|
|
__paperclipConfiguredModel: configuredModel,
|
|
__paperclipConfigFingerprint: metadata.fingerprint,
|
|
__paperclipConfigFingerprintVersion: metadata.version,
|
|
__paperclipConfigCategories: metadata.categories,
|
|
__paperclipConfigCategoryFingerprints: metadata.categoryFingerprints,
|
|
};
|
|
}
|
|
|
|
describe("effective run session config freshness", () => {
|
|
it("resets legacy sessions after connection instructions change or disappear", async () => {
|
|
const first = await buildSessionConfigMetadata({ effectiveAdapterConfig: { paperclipConnectionInstructions: { text: "Use the handbook.", digest: "before" } } });
|
|
for (const instructions of [null, { text: "Use the updated handbook.", digest: "after" }]) {
|
|
const next = await buildSessionConfigMetadata({ effectiveAdapterConfig: { paperclipConnectionInstructions: instructions } });
|
|
expect(resolveTaskSessionConfigFreshness({ hasTaskSession: true, configuredModel: "gpt-5.4-mini", taskSessionParams: sessionParamsWithConfigMetadata(first), configMetadata: next }).reset).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("reuses managed AI sessions across temporary credential homes while preserving configuration boundaries", async () => {
|
|
const config = (home: string) => ({
|
|
model: "gpt-5.4-mini",
|
|
approvalPolicy: "never",
|
|
managedAiConnection: { identity: "account-1:credential-generation-1" },
|
|
env: {
|
|
HOME: home,
|
|
XDG_CONFIG_HOME: path.join(home, "config"),
|
|
XDG_DATA_HOME: path.join(home, "data"),
|
|
CODEX_HOME: path.join(home, "provider"),
|
|
GROK_HOME: path.join(home, "provider"),
|
|
CLAUDE_CONFIG_DIR: path.join(home, "provider"),
|
|
CUSTOM_SETTING: "original",
|
|
},
|
|
});
|
|
const first = await buildSessionConfigMetadata({ effectiveAdapterConfig: config("/tmp/ai-first"), managedAiHome: "/tmp/ai-first" });
|
|
const nextConfig = config("/tmp/ai-next");
|
|
const next = await buildSessionConfigMetadata({ effectiveAdapterConfig: nextConfig, managedAiHome: "/tmp/ai-next" });
|
|
expect(next.fingerprint).toBe(first.fingerprint);
|
|
expect(nextConfig.env.HOME).toBe("/tmp/ai-next");
|
|
expect(resolveTaskSessionConfigFreshness({
|
|
hasTaskSession: true, configuredModel: "gpt-5.4-mini",
|
|
taskSessionParams: sessionParamsWithConfigMetadata(first), configMetadata: next,
|
|
}).reset).toBe(false);
|
|
for (const changed of [
|
|
{ ...nextConfig, model: "different-model" },
|
|
{ ...nextConfig, approvalPolicy: "on-request" },
|
|
{ ...nextConfig, managedAiConnection: { identity: "account-2:credential-generation-1" } },
|
|
{ ...nextConfig, managedAiConnection: { identity: "account-1:credential-generation-2" } },
|
|
{ ...nextConfig, env: { ...nextConfig.env, CUSTOM_SETTING: "changed" } },
|
|
{ ...nextConfig, env: { ...nextConfig.env, CODEX_HOME: "/custom/provider" } },
|
|
]) {
|
|
const metadata = await buildSessionConfigMetadata({ effectiveAdapterConfig: changed, managedAiHome: "/tmp/ai-next" });
|
|
expect(metadata.fingerprint).not.toBe(first.fingerprint);
|
|
}
|
|
const unmanagedFirst = await buildSessionConfigMetadata({ effectiveAdapterConfig: config("/custom/first") });
|
|
const unmanagedNext = await buildSessionConfigMetadata({ effectiveAdapterConfig: config("/custom/next") });
|
|
expect(unmanagedFirst.fingerprint).not.toBe(unmanagedNext.fingerprint);
|
|
});
|
|
|
|
it("resets when effective adapter config changes after model/profile/env resolution", async () => {
|
|
const base = await buildSessionConfigMetadata();
|
|
const next = await buildSessionConfigMetadata({
|
|
effectiveAdapterConfig: {
|
|
command: "codex",
|
|
model: "gpt-5.4-mini",
|
|
approvalPolicy: "never",
|
|
},
|
|
});
|
|
|
|
const decision = resolveTaskSessionConfigFreshness({
|
|
hasTaskSession: true,
|
|
configuredModel: "gpt-5.4-mini",
|
|
taskSessionParams: sessionParamsWithConfigMetadata(base),
|
|
configMetadata: next,
|
|
});
|
|
|
|
expect(decision).toMatchObject({
|
|
reset: true,
|
|
changedCategories: ["adapterConfig"],
|
|
});
|
|
expect(decision.reasons.join("\n")).toContain("adapter config");
|
|
});
|
|
|
|
it("does not reset for issue comment timestamps but still resets for workspace settings", async () => {
|
|
const base = await buildSessionConfigMetadata({
|
|
workspaceConfig: {
|
|
requestedMode: "agent_default",
|
|
effectiveMode: "agent_default",
|
|
issueConfigRevisionAt: "2026-06-01T00:00:00.000Z",
|
|
issueSettings: null,
|
|
},
|
|
});
|
|
const commentOnly = await buildSessionConfigMetadata({
|
|
workspaceConfig: {
|
|
requestedMode: "agent_default",
|
|
effectiveMode: "agent_default",
|
|
issueConfigRevisionAt: "2026-06-01T00:05:00.000Z",
|
|
issueSettings: null,
|
|
},
|
|
});
|
|
const workspaceChanged = await buildSessionConfigMetadata({
|
|
workspaceConfig: {
|
|
requestedMode: "isolated_workspace",
|
|
effectiveMode: "isolated_workspace",
|
|
issueConfigRevisionAt: "2026-06-01T00:05:00.000Z",
|
|
issueSettings: { mode: "isolated_workspace" },
|
|
},
|
|
});
|
|
|
|
expect(
|
|
resolveTaskSessionConfigFreshness({
|
|
hasTaskSession: true,
|
|
configuredModel: "gpt-5.4-mini",
|
|
taskSessionParams: sessionParamsWithConfigMetadata(base),
|
|
configMetadata: commentOnly,
|
|
}),
|
|
).toMatchObject({
|
|
reset: false,
|
|
changedCategories: [],
|
|
reasons: [],
|
|
});
|
|
expect(
|
|
resolveTaskSessionConfigFreshness({
|
|
hasTaskSession: true,
|
|
configuredModel: "gpt-5.4-mini",
|
|
taskSessionParams: sessionParamsWithConfigMetadata(base),
|
|
configMetadata: workspaceChanged,
|
|
}),
|
|
).toMatchObject({
|
|
reset: true,
|
|
changedCategories: ["workspaceConfig"],
|
|
});
|
|
});
|
|
|
|
it.each([null, "2026-06-01T00:00:00.000Z"])("does not reset for project row timestamps (%s) but still resets for project policy", async (revision) => {
|
|
const base = await buildSessionConfigMetadata({
|
|
workspaceConfig: {
|
|
requestedMode: "agent_default",
|
|
effectiveMode: "agent_default",
|
|
projectConfigRevisionAt: revision,
|
|
projectPolicy: null,
|
|
},
|
|
});
|
|
const projectTouched = await buildSessionConfigMetadata({
|
|
workspaceConfig: {
|
|
requestedMode: "agent_default",
|
|
effectiveMode: "agent_default",
|
|
projectConfigRevisionAt: "2026-06-01T00:05:00.000Z",
|
|
projectPolicy: null,
|
|
},
|
|
});
|
|
const policyChanged = await buildSessionConfigMetadata({
|
|
workspaceConfig: {
|
|
requestedMode: "agent_default",
|
|
effectiveMode: "agent_default",
|
|
projectConfigRevisionAt: "2026-06-01T00:05:00.000Z",
|
|
projectPolicy: { enabled: true, defaultMode: "isolated_workspace" },
|
|
},
|
|
});
|
|
|
|
expect(
|
|
resolveTaskSessionConfigFreshness({
|
|
hasTaskSession: true,
|
|
configuredModel: "gpt-5.4-mini",
|
|
taskSessionParams: sessionParamsWithConfigMetadata(base),
|
|
configMetadata: projectTouched,
|
|
}),
|
|
).toMatchObject({
|
|
reset: false,
|
|
changedCategories: [],
|
|
reasons: [],
|
|
});
|
|
expect(
|
|
resolveTaskSessionConfigFreshness({
|
|
hasTaskSession: true,
|
|
configuredModel: "gpt-5.4-mini",
|
|
taskSessionParams: sessionParamsWithConfigMetadata(base),
|
|
configMetadata: policyChanged,
|
|
}),
|
|
).toMatchObject({
|
|
reset: true,
|
|
changedCategories: ["workspaceConfig"],
|
|
});
|
|
});
|
|
|
|
it("does not reset when a reusable execution workspace becomes realized", async () => {
|
|
const base = await buildSessionConfigMetadata({
|
|
workspaceConfig: {
|
|
requestedMode: "shared_workspace",
|
|
effectiveMode: "shared_workspace",
|
|
issueSettings: null,
|
|
reusableExecutionWorkspaceConfig: null,
|
|
existingExecutionWorkspace: null,
|
|
},
|
|
});
|
|
const realized = await buildSessionConfigMetadata({
|
|
workspaceConfig: {
|
|
requestedMode: "shared_workspace",
|
|
effectiveMode: "shared_workspace",
|
|
issueSettings: { mode: "shared_workspace" },
|
|
reusableExecutionWorkspaceConfig: {
|
|
strategyType: "project_primary",
|
|
workspaceGeneration: 1,
|
|
},
|
|
existingExecutionWorkspace: {
|
|
id: "workspace-realized-after-first-turn",
|
|
mode: "shared_workspace",
|
|
strategyType: "project_primary",
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(
|
|
resolveTaskSessionConfigFreshness({
|
|
hasTaskSession: true,
|
|
configuredModel: "gpt-5.4-mini",
|
|
taskSessionParams: sessionParamsWithConfigMetadata(base),
|
|
configMetadata: realized,
|
|
}),
|
|
).toMatchObject({
|
|
reset: false,
|
|
changedCategories: [],
|
|
reasons: [],
|
|
});
|
|
});
|
|
|
|
it("keeps model-only compatibility as an additional reset reason", async () => {
|
|
const base = await buildSessionConfigMetadata();
|
|
|
|
const decision = resolveTaskSessionConfigFreshness({
|
|
hasTaskSession: true,
|
|
configuredModel: "gpt-5.4-mini",
|
|
taskSessionParams: sessionParamsWithConfigMetadata(base, "opencode/mimo-v2-pro-free"),
|
|
configMetadata: base,
|
|
});
|
|
|
|
expect(decision.reset).toBe(true);
|
|
expect(decision.reasons).toEqual([
|
|
'configured model changed from "opencode/mimo-v2-pro-free" to "gpt-5.4-mini"',
|
|
]);
|
|
});
|
|
|
|
it("freshens legacy task sessions that lack versioned config metadata", async () => {
|
|
const metadata = await buildSessionConfigMetadata();
|
|
|
|
const decision = resolveTaskSessionConfigFreshness({
|
|
hasTaskSession: true,
|
|
configuredModel: "gpt-5.4-mini",
|
|
taskSessionParams: {
|
|
sessionId: "thread-1",
|
|
__paperclipConfiguredModel: "gpt-5.4-mini",
|
|
},
|
|
configMetadata: metadata,
|
|
});
|
|
|
|
expect(decision.reset).toBe(true);
|
|
expect(decision.changedCategories).toEqual(metadata.categories);
|
|
expect(decision.reasons).toEqual(["effective run configuration fingerprint metadata is missing"]);
|
|
});
|
|
|
|
it("uses persisted fingerprint metadata even when an adapter codec omits it from resume params", async () => {
|
|
const metadata = await buildSessionConfigMetadata();
|
|
const persistedParams = sessionParamsWithConfigMetadata(metadata);
|
|
|
|
const decision = resolveTaskSessionConfigFreshness({
|
|
hasTaskSession: true,
|
|
configuredModel: "gpt-5.4-mini",
|
|
taskSessionParams: persistedParams,
|
|
configMetadata: metadata,
|
|
});
|
|
|
|
expect(decision.reset).toBe(false);
|
|
expect(decision.reasons).toEqual([]);
|
|
});
|
|
|
|
it("preserves legacy metadata gaps only for active accepted-plan continuation sessions", async () => {
|
|
const metadata = await buildSessionConfigMetadata();
|
|
|
|
const decision = resolveTaskSessionConfigFreshness({
|
|
hasTaskSession: true,
|
|
configuredModel: "gpt-5.4-mini",
|
|
taskSessionParams: {
|
|
sessionId: "thread-1",
|
|
__paperclipConfiguredModel: "gpt-5.4-mini",
|
|
},
|
|
configMetadata: metadata,
|
|
preserveLegacySessionWithoutConfigMetadata: true,
|
|
});
|
|
|
|
expect(decision.reset).toBe(false);
|
|
expect(decision.changedCategories).toEqual([]);
|
|
expect(decision.reasons).toEqual([]);
|
|
});
|
|
|
|
it("names safe categories for issue override, env, secret, and runtime skill drift", async () => {
|
|
const base = await buildSessionConfigMetadata();
|
|
const cases: Array<{
|
|
name: string;
|
|
category: string;
|
|
metadata: SessionConfigMetadata;
|
|
}> = [
|
|
{
|
|
name: "issue overrides",
|
|
category: "issueOverrides",
|
|
metadata: await buildSessionConfigMetadata({
|
|
issueOverrides: {
|
|
adapterConfig: {
|
|
reasoningEffort: "high",
|
|
},
|
|
},
|
|
}),
|
|
},
|
|
{
|
|
name: "project env bindings",
|
|
category: "envBindings",
|
|
metadata: await buildSessionConfigMetadata({
|
|
projectEnv: {
|
|
PROJECT_FLAG: "enabled",
|
|
NEW_PROJECT_FLAG: "present",
|
|
},
|
|
}),
|
|
},
|
|
{
|
|
name: "secret version",
|
|
category: "secrets",
|
|
metadata: await buildSessionConfigMetadata({
|
|
secretManifest: [
|
|
{
|
|
configPath: "env.OPENAI_API_KEY",
|
|
envKey: "OPENAI_API_KEY",
|
|
secretId: "secret-1",
|
|
bindingId: "binding-1",
|
|
secretKey: "openai-api-key",
|
|
version: 8,
|
|
provider: "local_encrypted",
|
|
outcome: "success",
|
|
},
|
|
],
|
|
}),
|
|
},
|
|
{
|
|
name: "runtime skills",
|
|
category: "runtimeSkills",
|
|
metadata: await buildSessionConfigMetadata({
|
|
runtimeSkills: [
|
|
{
|
|
key: "paperclip",
|
|
runtimeName: "paperclip",
|
|
source: "/tmp/paperclip/runtime-skills/paperclip",
|
|
versionId: null,
|
|
currentVersionId: "skill-version-2",
|
|
sourceStatus: "available",
|
|
missingDetail: null,
|
|
},
|
|
],
|
|
}),
|
|
},
|
|
];
|
|
|
|
for (const testCase of cases) {
|
|
const decision = resolveTaskSessionConfigFreshness({
|
|
hasTaskSession: true,
|
|
configuredModel: "gpt-5.4-mini",
|
|
taskSessionParams: sessionParamsWithConfigMetadata(base),
|
|
configMetadata: testCase.metadata,
|
|
});
|
|
|
|
expect(decision.reset, testCase.name).toBe(true);
|
|
expect(decision.changedCategories, testCase.name).toContain(testCase.category);
|
|
}
|
|
});
|
|
|
|
it("detects instructions content drift without storing the contents", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-session-fingerprint-"));
|
|
const instructionsPath = path.join(root, "AGENTS.md");
|
|
await fs.writeFile(instructionsPath, "Version one instructions.\n", "utf8");
|
|
const base = await buildSessionConfigMetadata({
|
|
effectiveAdapterConfig: {
|
|
command: "codex",
|
|
model: "gpt-5.4-mini",
|
|
instructionsBundleMode: "managed",
|
|
instructionsRootPath: root,
|
|
instructionsEntryFile: "AGENTS.md",
|
|
instructionsFilePath: instructionsPath,
|
|
},
|
|
});
|
|
await fs.writeFile(instructionsPath, "Version two instructions.\n", "utf8");
|
|
const next = await buildSessionConfigMetadata({
|
|
effectiveAdapterConfig: {
|
|
command: "codex",
|
|
model: "gpt-5.4-mini",
|
|
instructionsBundleMode: "managed",
|
|
instructionsRootPath: root,
|
|
instructionsEntryFile: "AGENTS.md",
|
|
instructionsFilePath: instructionsPath,
|
|
},
|
|
});
|
|
|
|
const decision = resolveTaskSessionConfigFreshness({
|
|
hasTaskSession: true,
|
|
configuredModel: "gpt-5.4-mini",
|
|
taskSessionParams: sessionParamsWithConfigMetadata(base),
|
|
configMetadata: next,
|
|
});
|
|
|
|
expect(decision.reset).toBe(true);
|
|
expect(decision.changedCategories).toContain("instructions");
|
|
expect(next.fingerprints.sessionFingerprint.canonicalJson).not.toContain("Version two instructions");
|
|
});
|
|
|
|
it("retains a session across relocated durable instruction and versioned skill caches", async () => {
|
|
const metadata = (home: string, contentHash = "sha256:entry-v1", versionId = "skill-version-1") => {
|
|
const runtimeSkills = [{ key: "paperclip", runtimeName: "paperclip", versionId,
|
|
currentVersionId: versionId, source: `${home}/packages/skill`, sourceStatus: "available" }];
|
|
return buildSessionConfigMetadata({
|
|
adapterType: "paperclip_runner",
|
|
effectiveAdapterConfig: { model: "gpt-5.4-mini", instructionsBundleMode: "managed",
|
|
instructionsRootPath: `${home}/instructions`, instructionsFilePath: `${home}/instructions/AGENTS.md`,
|
|
instructionsEntryFile: "AGENTS.md", paperclipRuntimeSkills: runtimeSkills },
|
|
managedInstructions: { entryFile: "AGENTS.md", contentHash },
|
|
runtimeSkills,
|
|
});
|
|
};
|
|
const before = await metadata("/retired-controller");
|
|
const after = await metadata("/replacement-controller");
|
|
const freshness = (next: SessionConfigMetadata) => resolveTaskSessionConfigFreshness({
|
|
hasTaskSession: true, configuredModel: "gpt-5.4-mini",
|
|
taskSessionParams: sessionParamsWithConfigMetadata(before), configMetadata: next,
|
|
});
|
|
expect(freshness(after).reset).toBe(false);
|
|
expect(after.fingerprints.sessionFingerprint.canonicalJson).not.toContain("/replacement-controller");
|
|
expect(freshness(await metadata("/replacement-controller", "sha256:entry-v2")).changedCategories).toContain("instructions");
|
|
expect(freshness(await metadata("/replacement-controller", "sha256:entry-v1", "skill-version-2")).changedCategories).toContain("runtimeSkills");
|
|
});
|
|
|
|
it("retains a legacy managed-instruction session after verified import removes the old home", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "legacy-instruction-session-"));
|
|
const body = "Preserve this exact agent identity and instructions.\n";
|
|
await fs.writeFile(path.join(root, "AGENTS.md"), body);
|
|
const config = { model: "gpt-5.4-mini", instructionsBundleMode: "managed", instructionsRootPath: root,
|
|
instructionsFilePath: path.join(root, "AGENTS.md"), instructionsEntryFile: "AGENTS.md" };
|
|
const before = await buildSessionConfigMetadata({ adapterType: "paperclip_runner", effectiveAdapterConfig: config });
|
|
await fs.rm(root, { recursive: true });
|
|
const managedInstructions = { entryFile: "AGENTS.md", contentHash: "sha256:" + createHash("sha256").update(body).digest("hex") };
|
|
const metadata = (contentHash = managedInstructions.contentHash, model = config.model, retainManagedInstructionPaths = false) =>
|
|
buildSessionConfigMetadata({ adapterType: "paperclip_runner", effectiveAdapterConfig: { ...config, model },
|
|
managedInstructions: { ...managedInstructions, contentHash }, retainManagedInstructionPaths });
|
|
const after = await metadata();
|
|
const decide = (current: SessionConfigMetadata, compatible: SessionConfigMetadata[] = []) => resolveTaskSessionConfigFreshness({
|
|
hasTaskSession: true, configuredModel: config.model, taskSessionParams: sessionParamsWithConfigMetadata(before),
|
|
configMetadata: current, compatibleConfigMetadata: compatible });
|
|
expect(decide(after)).toMatchObject({ reset: true, changedCategories: ["adapterConfig"] });
|
|
expect(decide(after, [await metadata(undefined, undefined, true)]).reset).toBe(false);
|
|
expect(decide(await metadata("sha256:changed"), [await metadata("sha256:changed", undefined, true)]).reset).toBe(true);
|
|
expect(decide(await metadata(undefined, "different-model"), [await metadata(undefined, "different-model", true)]).reset).toBe(true);
|
|
});
|
|
|
|
it("does not read unbounded legacy instructions paths for config fingerprints", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-session-fingerprint-"));
|
|
const instructionsPath = path.join(root, "AGENTS.md");
|
|
await fs.writeFile(instructionsPath, "Legacy direct-path instructions.\n", "utf8");
|
|
const metadata = await buildSessionConfigMetadata({
|
|
effectiveAdapterConfig: {
|
|
command: "codex",
|
|
model: "gpt-5.4-mini",
|
|
instructionsFilePath: instructionsPath,
|
|
},
|
|
});
|
|
|
|
const canonical = metadata.fingerprints.sessionFingerprint.canonicalJson;
|
|
|
|
expect(canonical).toContain("missing_absolute_root");
|
|
expect(canonical).not.toContain("Legacy direct-path instructions");
|
|
expect(canonical).not.toContain("contentHash");
|
|
});
|
|
|
|
it("does not include raw secret or plain env values in canonical session metadata", async () => {
|
|
const metadata = await buildSessionConfigMetadata();
|
|
const canonical = metadata.fingerprints.sessionFingerprint.canonicalJson;
|
|
|
|
expect(canonical).toContain("secret-1");
|
|
expect(canonical).toContain('"version":7');
|
|
expect(canonical).not.toContain("resolved-secret-value");
|
|
expect(canonical).not.toContain("plain-value");
|
|
expect(canonical).not.toContain("enabled");
|
|
expect(canonical).not.toContain("openai-api-key");
|
|
});
|
|
});
|
|
|
|
describe("stripConfiguredModelFromSessionParams", () => {
|
|
it("removes the internal model key from persisted session params", () => {
|
|
expect(
|
|
stripConfiguredModelFromSessionParams({
|
|
sessionId: "thread-1",
|
|
__paperclipConfiguredModel: "gpt-5.4-mini",
|
|
}),
|
|
).toEqual({ sessionId: "thread-1" });
|
|
});
|
|
|
|
it("returns null when session params are missing", () => {
|
|
expect(stripConfiguredModelFromSessionParams(null)).toBeNull();
|
|
expect(stripConfiguredModelFromSessionParams(undefined)).toBeNull();
|
|
});
|
|
|
|
it("returns a copy without mutating the input", () => {
|
|
const input = { sessionId: "thread-1", __paperclipConfiguredModel: "gpt-5.4-mini" };
|
|
const result = stripConfiguredModelFromSessionParams(input);
|
|
expect(result).not.toBe(input);
|
|
expect(input.__paperclipConfiguredModel).toBe("gpt-5.4-mini");
|
|
});
|
|
|
|
it("returns an empty object when only the internal model key is present (caller must normalize)", () => {
|
|
const stripped = stripConfiguredModelFromSessionParams({
|
|
__paperclipConfiguredModel: "gpt-5.4-mini",
|
|
});
|
|
expect(stripped).toEqual({});
|
|
// Callers that forward params to adapters must normalize {} back to null so
|
|
// the pre-PR null contract is preserved (adapters distinguishing {} from null).
|
|
expect(normalizeSessionParams(stripped)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("stripPaperclipSessionMetadataFromSessionParams", () => {
|
|
it("removes all internal Paperclip session metadata before adapter invocation", () => {
|
|
expect(
|
|
stripPaperclipSessionMetadataFromSessionParams({
|
|
sessionId: "thread-1",
|
|
cwd: "/tmp/project",
|
|
paperclipAiCredentialIdentity: "grant:user:generation",
|
|
__paperclipConfiguredModel: "gpt-5.4-mini",
|
|
__paperclipConfigFingerprint: "v1:sha256:abc",
|
|
__paperclipConfigFingerprintVersion: 1,
|
|
__paperclipConfigCategories: ["adapterConfig"],
|
|
__paperclipConfigCategoryFingerprints: { adapterConfig: "v1:sha256:def" },
|
|
}),
|
|
).toEqual({
|
|
sessionId: "thread-1",
|
|
cwd: "/tmp/project",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("isTaskSessionCredentialCompatible", () => {
|
|
it("retains the server-owned identity even when the Codex codec drops it", () => {
|
|
const saved = { sessionId: "thread-1", paperclipAiCredentialIdentity: "grant:user:generation" };
|
|
const decoded = codexSessionCodec.deserialize(saved);
|
|
expect(decoded).toEqual({ sessionId: "thread-1" });
|
|
expect(isTaskSessionCredentialCompatible(saved, "grant:user:generation")).toBe(true);
|
|
});
|
|
|
|
it.each([
|
|
undefined,
|
|
null,
|
|
{},
|
|
{ paperclipAiCredentialIdentity: "other-grant:user:generation" },
|
|
{ paperclipAiCredentialIdentity: "grant:other-user:generation" },
|
|
{ paperclipAiCredentialIdentity: "grant:user:new-generation" },
|
|
])("requires the same saved grant, responsible user, and credential generation: %j", (saved) => {
|
|
expect(isTaskSessionCredentialCompatible(saved, "grant:user:generation")).toBe(false);
|
|
});
|
|
|
|
it("preserves unmanaged session behavior", () => {
|
|
expect(isTaskSessionCredentialCompatible({ sessionId: "thread-1" }, undefined)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("normalizeSessionParams", () => {
|
|
it("collapses an empty object to null", () => {
|
|
expect(normalizeSessionParams({})).toBeNull();
|
|
});
|
|
|
|
it("returns null for null or undefined inputs", () => {
|
|
expect(normalizeSessionParams(null)).toBeNull();
|
|
expect(normalizeSessionParams(undefined)).toBeNull();
|
|
});
|
|
|
|
it("preserves a non-empty object", () => {
|
|
const params = { sessionId: "thread-1" };
|
|
expect(normalizeSessionParams(params)).toBe(params);
|
|
});
|
|
});
|
|
|
|
describe("deriveTaskKeyWithHeartbeatFallback", () => {
|
|
it("returns explicit taskKey when present", () => {
|
|
expect(deriveTaskKeyWithHeartbeatFallback({ taskKey: "issue-123" }, null)).toBe("issue-123");
|
|
});
|
|
|
|
it("returns explicit issueId when no taskKey", () => {
|
|
expect(deriveTaskKeyWithHeartbeatFallback({ issueId: "issue-456" }, null)).toBe("issue-456");
|
|
});
|
|
|
|
it("returns __heartbeat__ for timer wakes with no explicit key", () => {
|
|
expect(deriveTaskKeyWithHeartbeatFallback({ wakeSource: "timer" }, null)).toBe("__heartbeat__");
|
|
});
|
|
|
|
it("prefers explicit key over heartbeat fallback even on timer wakes", () => {
|
|
expect(
|
|
deriveTaskKeyWithHeartbeatFallback({ wakeSource: "timer", taskKey: "issue-789" }, null),
|
|
).toBe("issue-789");
|
|
});
|
|
|
|
it("returns null for non-timer wakes with no explicit key", () => {
|
|
expect(deriveTaskKeyWithHeartbeatFallback({ wakeSource: "on_demand" }, null)).toBeNull();
|
|
});
|
|
|
|
it("returns null for empty context", () => {
|
|
expect(deriveTaskKeyWithHeartbeatFallback({}, null)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("comment wake batching", () => {
|
|
it("updates the latest comment when coalescing queued follow-up wakes", () => {
|
|
const merged = mergeCoalescedContextSnapshot(
|
|
{
|
|
issueId: "issue-1",
|
|
wakeReason: "issue_commented",
|
|
wakeCommentId: "comment-1",
|
|
wakeCommentIds: ["comment-1"],
|
|
paperclipWake: {
|
|
latestCommentId: "comment-1",
|
|
},
|
|
},
|
|
{
|
|
issueId: "issue-1",
|
|
wakeReason: "issue_commented",
|
|
wakeCommentId: "comment-2",
|
|
},
|
|
);
|
|
|
|
expect(merged.wakeCommentIds).toEqual(["comment-1", "comment-2"]);
|
|
expect(merged.commentId).toBe("comment-2");
|
|
expect(merged.wakeCommentId).toBe("comment-2");
|
|
expect(merged.paperclipWake).toBeUndefined();
|
|
});
|
|
|
|
it("keeps forceFreshSession sticky once any coalesced wake requests it", () => {
|
|
const merged = mergeCoalescedContextSnapshot(
|
|
{
|
|
issueId: "issue-1",
|
|
forceFreshSession: true,
|
|
},
|
|
{
|
|
issueId: "issue-1",
|
|
forceFreshSession: false,
|
|
},
|
|
);
|
|
|
|
expect(merged.forceFreshSession).toBe(true);
|
|
});
|
|
|
|
it("keeps connection tool refresh intent while allowing harness session recovery", () => {
|
|
const merged = mergeCoalescedContextSnapshot(
|
|
{ issueId: "issue-1", wakeReason: "issue_commented", refreshTools: true },
|
|
{ issueId: "issue-1", wakeReason: "issue_commented", refreshTools: false },
|
|
);
|
|
expect(merged.refreshTools).toBe(true);
|
|
expect(shouldResetTaskSessionForWake(merged)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("buildExplicitResumeSessionOverride", () => {
|
|
it("reuses saved task session params when they belong to the selected failed run", () => {
|
|
const result = buildExplicitResumeSessionOverride({
|
|
resumeFromRunId: "run-1",
|
|
resumeRunSessionIdBefore: "session-before",
|
|
resumeRunSessionIdAfter: "session-after",
|
|
taskSession: {
|
|
sessionParamsJson: {
|
|
sessionId: "session-after",
|
|
cwd: "/tmp/project",
|
|
},
|
|
sessionDisplayId: "session-after",
|
|
lastRunId: "run-1",
|
|
},
|
|
sessionCodec: codexSessionCodec,
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
sessionDisplayId: "session-after",
|
|
sessionParams: {
|
|
sessionId: "session-after",
|
|
cwd: "/tmp/project",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("falls back to the selected run session id when no matching task session params are available", () => {
|
|
const result = buildExplicitResumeSessionOverride({
|
|
resumeFromRunId: "run-1",
|
|
resumeRunSessionIdBefore: "session-before",
|
|
resumeRunSessionIdAfter: "session-after",
|
|
taskSession: {
|
|
sessionParamsJson: {
|
|
sessionId: "other-session",
|
|
cwd: "/tmp/project",
|
|
},
|
|
sessionDisplayId: "other-session",
|
|
lastRunId: "run-2",
|
|
},
|
|
sessionCodec: codexSessionCodec,
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
sessionDisplayId: "session-after",
|
|
sessionParams: {
|
|
sessionId: "session-after",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("does not synthesize Hermes resume params from a truncated display id", () => {
|
|
const result = buildExplicitResumeSessionOverride({
|
|
adapterType: "hermes_local",
|
|
resumeFromRunId: "run-1",
|
|
resumeRunSessionIdBefore: null,
|
|
resumeRunSessionIdAfter: "20260601_141558_",
|
|
taskSession: {
|
|
sessionParamsJson: {
|
|
sessionId: "20260601_141000_c861e4",
|
|
},
|
|
sessionDisplayId: "20260601_141000_",
|
|
lastRunId: "run-2",
|
|
},
|
|
sessionCodec: truncatingHermesSessionCodec,
|
|
});
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("uses validated Hermes run result params before truncated display ids", () => {
|
|
const result = buildExplicitResumeSessionOverride({
|
|
adapterType: "hermes_local",
|
|
resumeFromRunId: "run-1",
|
|
resumeRunSessionIdBefore: null,
|
|
resumeRunSessionIdAfter: "20260601_141558_",
|
|
resumeRunSessionParams: {
|
|
sessionId: "20260601_141558_c861e4",
|
|
},
|
|
taskSession: null,
|
|
sessionCodec: truncatingHermesSessionCodec,
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
sessionDisplayId: "20260601_141558_c861e4",
|
|
sessionParams: {
|
|
sessionId: "20260601_141558_c861e4",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("keeps Hermes run result params and display id together when falling back from a prior session", () => {
|
|
const result = buildExplicitResumeSessionOverride({
|
|
adapterType: "hermes_local",
|
|
resumeFromRunId: "run-1",
|
|
resumeRunSessionIdBefore: "20260601_140000_old123",
|
|
resumeRunSessionIdAfter: "20260601_141558_",
|
|
resumeRunSessionParams: {
|
|
sessionId: "20260601_141558_c861e4",
|
|
},
|
|
taskSession: null,
|
|
sessionCodec: truncatingHermesSessionCodec,
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
sessionDisplayId: "20260601_141558_c861e4",
|
|
sessionParams: {
|
|
sessionId: "20260601_141558_c861e4",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("ignores invalid Hermes run result params", () => {
|
|
const result = buildExplicitResumeSessionOverride({
|
|
adapterType: "hermes_local",
|
|
resumeFromRunId: "run-1",
|
|
resumeRunSessionIdBefore: null,
|
|
resumeRunSessionIdAfter: "20260601_141558_",
|
|
resumeRunSessionParams: {
|
|
sessionId: "from",
|
|
},
|
|
taskSession: null,
|
|
sessionCodec: truncatingHermesSessionCodec,
|
|
});
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("keeps full Hermes task-session params even when the saved display id is truncated", () => {
|
|
const result = buildExplicitResumeSessionOverride({
|
|
adapterType: "hermes_local",
|
|
resumeFromRunId: "run-1",
|
|
resumeRunSessionIdBefore: null,
|
|
resumeRunSessionIdAfter: "20260601_141558_",
|
|
taskSession: {
|
|
sessionParamsJson: {
|
|
sessionId: "20260601_141558_c861e4",
|
|
},
|
|
sessionDisplayId: "20260601_141558_",
|
|
lastRunId: "run-1",
|
|
},
|
|
sessionCodec: truncatingHermesSessionCodec,
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
sessionDisplayId: "20260601_141558_c861e4",
|
|
sessionParams: {
|
|
sessionId: "20260601_141558_c861e4",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("falls back from a poisoned Hermes session-after value to a valid session-before value", () => {
|
|
const result = buildExplicitResumeSessionOverride({
|
|
adapterType: "hermes_local",
|
|
resumeFromRunId: "run-1",
|
|
resumeRunSessionIdBefore: "20260601_141558_c861e4",
|
|
resumeRunSessionIdAfter: "from",
|
|
taskSession: null,
|
|
sessionCodec: hermesSessionCodec,
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
sessionDisplayId: "20260601_141558_c861e4",
|
|
sessionParams: {
|
|
sessionId: "20260601_141558_c861e4",
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("resolveNextSessionState", () => {
|
|
it("preserves previous valid Hermes session state when failed adapter output reports prose tokens", () => {
|
|
const result = resolveNextSessionState({
|
|
adapterType: "hermes_local",
|
|
codec: truncatingHermesSessionCodec,
|
|
adapterResult: {
|
|
exitCode: 1,
|
|
signal: null,
|
|
timedOut: false,
|
|
sessionParams: {
|
|
sessionId: "from",
|
|
},
|
|
sessionId: "from",
|
|
sessionDisplayId: "from",
|
|
errorMessage: "Session not found: 20260601_141558_",
|
|
},
|
|
outcome: "failed",
|
|
previousParams: {
|
|
sessionId: "20260601_141558_c861e4",
|
|
},
|
|
previousDisplayId: "20260601_141558_c861e4",
|
|
previousLegacySessionId: "20260601_141558_c861e4",
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
params: {
|
|
sessionId: "20260601_141558_c861e4",
|
|
},
|
|
displayId: "20260601_141558_c861e4",
|
|
legacySessionId: "20260601_141558_c861e4",
|
|
});
|
|
});
|
|
|
|
it("drops poisoned previous Hermes session state instead of passing it to the next run", () => {
|
|
const result = resolveNextSessionState({
|
|
adapterType: "hermes_local",
|
|
codec: truncatingHermesSessionCodec,
|
|
adapterResult: {
|
|
exitCode: 1,
|
|
signal: null,
|
|
timedOut: false,
|
|
sessionId: "from",
|
|
sessionDisplayId: "from",
|
|
errorMessage: "Session not found: from",
|
|
},
|
|
outcome: "failed",
|
|
previousParams: {
|
|
sessionId: "from",
|
|
},
|
|
previousDisplayId: "from",
|
|
previousLegacySessionId: "from",
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
params: null,
|
|
displayId: null,
|
|
legacySessionId: null,
|
|
});
|
|
});
|
|
|
|
it("derives Hermes display state from canonical params instead of adapter-truncated display ids", () => {
|
|
const result = resolveNextSessionState({
|
|
adapterType: "hermes_local",
|
|
codec: truncatingHermesSessionCodec,
|
|
adapterResult: {
|
|
exitCode: 0,
|
|
signal: null,
|
|
timedOut: false,
|
|
sessionParams: {
|
|
sessionId: "20260601_141558_c861e4",
|
|
},
|
|
sessionDisplayId: "20260601_141558_",
|
|
},
|
|
outcome: "succeeded",
|
|
previousParams: null,
|
|
previousDisplayId: null,
|
|
previousLegacySessionId: null,
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
params: {
|
|
sessionId: "20260601_141558_c861e4",
|
|
},
|
|
displayId: "20260601_141558_c861e4",
|
|
legacySessionId: "20260601_141558_c861e4",
|
|
});
|
|
});
|
|
|
|
it("uses one canonical Hermes explicit session candidate instead of mixing valid and invalid fields", () => {
|
|
const result = resolveNextSessionState({
|
|
adapterType: "hermes_local",
|
|
codec: truncatingHermesSessionCodec,
|
|
adapterResult: {
|
|
exitCode: 0,
|
|
signal: null,
|
|
timedOut: false,
|
|
sessionParams: {
|
|
sessionId: "from",
|
|
},
|
|
sessionId: "20260601_141558_c861e4",
|
|
sessionDisplayId: "20260601_141558_",
|
|
},
|
|
outcome: "succeeded",
|
|
previousParams: {
|
|
sessionId: "20260601_140000_previous",
|
|
},
|
|
previousDisplayId: "20260601_140000_previous",
|
|
previousLegacySessionId: "20260601_140000_previous",
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
params: {
|
|
sessionId: "20260601_141558_c861e4",
|
|
},
|
|
displayId: "20260601_141558_c861e4",
|
|
legacySessionId: "20260601_141558_c861e4",
|
|
});
|
|
});
|
|
|
|
it("keeps non-Hermes arbitrary session ids unchanged", () => {
|
|
const result = resolveNextSessionState({
|
|
adapterType: "codex_local",
|
|
codec: codexSessionCodec,
|
|
adapterResult: {
|
|
exitCode: 1,
|
|
signal: null,
|
|
timedOut: false,
|
|
sessionId: "from",
|
|
},
|
|
outcome: "failed",
|
|
previousParams: null,
|
|
previousDisplayId: null,
|
|
previousLegacySessionId: null,
|
|
});
|
|
|
|
expect(result.legacySessionId).toBe("from");
|
|
});
|
|
});
|
|
|
|
describe("formatRuntimeWorkspaceWarningLog", () => {
|
|
it("emits informational workspace warnings on stdout", () => {
|
|
expect(formatRuntimeWorkspaceWarningLog("Using fallback workspace")).toEqual({
|
|
stream: "stdout",
|
|
chunk: "[paperclip] Using fallback workspace\n",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("prioritizeProjectWorkspaceCandidatesForRun", () => {
|
|
it("moves the explicitly selected workspace to the front", () => {
|
|
const rows = [
|
|
{ id: "workspace-1", cwd: "/tmp/one" },
|
|
{ id: "workspace-2", cwd: "/tmp/two" },
|
|
{ id: "workspace-3", cwd: "/tmp/three" },
|
|
];
|
|
|
|
expect(
|
|
prioritizeProjectWorkspaceCandidatesForRun(rows, "workspace-2").map((row) => row.id),
|
|
).toEqual(["workspace-2", "workspace-1", "workspace-3"]);
|
|
});
|
|
|
|
it("keeps the original order when no preferred workspace is selected", () => {
|
|
const rows = [
|
|
{ id: "workspace-1" },
|
|
{ id: "workspace-2" },
|
|
];
|
|
|
|
expect(
|
|
prioritizeProjectWorkspaceCandidatesForRun(rows, null).map((row) => row.id),
|
|
).toEqual(["workspace-1", "workspace-2"]);
|
|
});
|
|
|
|
it("keeps the original order when the selected workspace is missing", () => {
|
|
const rows = [
|
|
{ id: "workspace-1" },
|
|
{ id: "workspace-2" },
|
|
];
|
|
|
|
expect(
|
|
prioritizeProjectWorkspaceCandidatesForRun(rows, "workspace-9").map((row) => row.id),
|
|
).toEqual(["workspace-1", "workspace-2"]);
|
|
});
|
|
});
|
|
|
|
describe("parseSessionCompactionPolicy", () => {
|
|
it("disables Paperclip-managed rotation by default for codex and claude local", () => {
|
|
expect(parseSessionCompactionPolicy(buildAgent("codex_local"))).toEqual({
|
|
enabled: true,
|
|
maxSessionRuns: 0,
|
|
maxRawInputTokens: 0,
|
|
maxSessionAgeHours: 0,
|
|
});
|
|
expect(parseSessionCompactionPolicy(buildAgent("claude_local"))).toEqual({
|
|
enabled: true,
|
|
maxSessionRuns: 0,
|
|
maxRawInputTokens: 0,
|
|
maxSessionAgeHours: 0,
|
|
});
|
|
});
|
|
|
|
it("keeps conservative defaults for adapters without confirmed native compaction", () => {
|
|
expect(parseSessionCompactionPolicy(buildAgent("cursor"))).toEqual({
|
|
enabled: true,
|
|
maxSessionRuns: 200,
|
|
maxRawInputTokens: 2_000_000,
|
|
maxSessionAgeHours: 72,
|
|
});
|
|
expect(parseSessionCompactionPolicy(buildAgent("opencode_local"))).toEqual({
|
|
enabled: true,
|
|
maxSessionRuns: 200,
|
|
maxRawInputTokens: 2_000_000,
|
|
maxSessionAgeHours: 72,
|
|
});
|
|
});
|
|
|
|
it("lets explicit agent overrides win over adapter defaults", () => {
|
|
expect(
|
|
parseSessionCompactionPolicy(
|
|
buildAgent("codex_local", {
|
|
heartbeat: {
|
|
sessionCompaction: {
|
|
maxSessionRuns: 25,
|
|
maxRawInputTokens: 500_000,
|
|
},
|
|
},
|
|
}),
|
|
),
|
|
).toEqual({
|
|
enabled: true,
|
|
maxSessionRuns: 25,
|
|
maxRawInputTokens: 500_000,
|
|
maxSessionAgeHours: 0,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("isWorkspaceSyncConflictFailure", () => {
|
|
it("matches the git workspace reconciliation failure signatures", () => {
|
|
expect(isWorkspaceSyncConflictFailure(
|
|
"Failed to merge concurrent remote git histories for a5d46a8005b3 and c1042c11774a: Command failed: git merge-tree --write-tree",
|
|
)).toBe(true);
|
|
expect(isWorkspaceSyncConflictFailure(
|
|
"Failed to integrate concurrent remote git history for a5d46a8005b3 after multiple retries.",
|
|
)).toBe(true);
|
|
expect(isWorkspaceSyncConflictFailure(
|
|
"error: /tmp/restore/git-delta.bundle did not send all necessary objects",
|
|
)).toBe(true);
|
|
expect(isWorkspaceSyncConflictFailure(
|
|
"error: Repository lacks these prerequisite commits: 4c631700",
|
|
)).toBe(true);
|
|
});
|
|
|
|
it("ignores unrelated adapter failures", () => {
|
|
expect(isWorkspaceSyncConflictFailure("Codex exited with code 2")).toBe(false);
|
|
expect(isWorkspaceSyncConflictFailure("no Codex credentials provisioned for managed home")).toBe(false);
|
|
expect(isWorkspaceSyncConflictFailure(null)).toBe(false);
|
|
expect(isWorkspaceSyncConflictFailure("")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("reconcileReusedExecutionWorkspaceProjectWorkspaceId", () => {
|
|
it("backfills a null existing binding from the resolved value", () => {
|
|
expect(
|
|
reconcileReusedExecutionWorkspaceProjectWorkspaceId(null, "resolved-workspace"),
|
|
).toBe("resolved-workspace");
|
|
});
|
|
|
|
it("never overwrites an existing binding, even when a resolved value is present", () => {
|
|
expect(
|
|
reconcileReusedExecutionWorkspaceProjectWorkspaceId("existing-workspace", "resolved-workspace"),
|
|
).toBe("existing-workspace");
|
|
});
|
|
|
|
it("returns null when both existing and resolved are absent", () => {
|
|
expect(reconcileReusedExecutionWorkspaceProjectWorkspaceId(null, null)).toBeNull();
|
|
expect(reconcileReusedExecutionWorkspaceProjectWorkspaceId(undefined, undefined)).toBeNull();
|
|
expect(reconcileReusedExecutionWorkspaceProjectWorkspaceId(null, undefined)).toBeNull();
|
|
});
|
|
|
|
it("backfills when existing is undefined and resolved is present", () => {
|
|
expect(
|
|
reconcileReusedExecutionWorkspaceProjectWorkspaceId(undefined, "resolved-workspace"),
|
|
).toBe("resolved-workspace");
|
|
});
|
|
});
|