fix: retain the original repository for reused task workspaces (#15528)

Apply the reviewed change for fix: retain the original repository for reused task workspaces.

Validation: required local typecheck and tests, passing CI, Greptile 5/5, and independent review of the exact source head.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Devin FoleyandPaperclip authored and GitHub committed 2026-10-07 19:52:30 -07:00
1 parent e4b39da6f6
commit 5717523b9e
9 files changed
+788 -12

No files matched your search

+2
View File
@@ -868,6 +868,8 @@ Agent, project, environment, secret, skill, and workspace config edits are sampl
When effective run config changes, Paperclip may intentionally skip a saved adapter session, refresh persisted workspace runtime config, replace a reused execution workspace, or avoid reusing a sandbox/environment lease. Fresh execution can lose adapter-specific session, workspace, or sandbox state; correctness of the next run's config takes priority over continuity. Plain environment values affect freshness through value hashes; run result JSON and workspace operation logs expose only the non-sensitive freshness decision categories, without storing secret values, full env maps, provider credentials, or private path details.
An explicit `reuse_existing` Git workspace keeps its recorded source repository, including after its project-workspace row is deleted or the project primary changes. The server verifies the original company, project, repository origin, and worktree registration before reuse. It does not attach a deleted association to the new primary. A conflicting explicit project-workspace selection, missing source identity, or an unverified legacy local checkout blocks the run. Restore the original source. Before intentionally clearing the existing-workspace binding to choose a new repository, review and preserve its retained work. Missing worktrees may be reconstructed only from the verified original source; current project defaults are not a fallback.
## Workspace Git Scan Protection
Paperclip applies one process-wide scheduler to expensive host-side workspace Git enumeration, including changed-file browsing, runtime/finalization cleanliness guards, and adapter sandbox-sync snapshots. The scheduler defaults to two active scans and a bounded queue of 32. Identical buffered scans of the same canonical worktree share one subprocess, while successful changed-file listings are cached for 10 seconds. Streaming snapshot scans have caller-owned sinks, so they use separate jobs in the same queue and are never cached or coalesced. Correctness-sensitive runtime guards bypass the result cache.
+11
View File
@@ -584,6 +584,17 @@ from 1 through 255. No paths, repository or branch names, IDs, command output, o
arbitrary messages enter these fields. Validation still blocks reuse; it does
not repair Git metadata, change the selected repository, or retry the task.
For explicit reuse of a retained Git workspace, `persisted_workspace_source_conflict`
uses the same two fields with the fixed reason codes `source_scope_mismatch`,
`explicit_project_workspace_conflict`, `source_path_unproven`,
`source_registration_unproven`, `source_repository_mismatch`, or
`source_repository_unavailable`. These distinguish an unverified original source
from a missing Git registration. Paths, repository URLs, and workspace IDs stay
out of the diagnostic fields. The task remains blocked until its original source
is available or the owner intentionally selects a different workspace. A failed
Git registration probe retains `git_inspection_failed` and the same bounded
inspection fields, including when it occurs during original-source selection.
When available, the saved `workspaceRestoreDiagnostic` adds the bounded fields
`workspaceRestorePhase`, `workspaceRestoreStep`, `workspaceRestoreErrorCode`,
`workspaceRestoreHttpStatus`, and `workspaceRestoreExitCode` to `run_execution`.
@@ -1,9 +1,10 @@
import { execFile } from "node:child_process";
import { createHash, randomUUID } from "node:crypto";
import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import { pathToFileURL } from "node:url";
import { and, eq, inArray } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import {
@@ -15,6 +16,7 @@ import {
companies,
companySkills,
createDb,
closeRegisteredClients,
documentRevisions,
documents,
environmentLeases,
@@ -37,7 +39,8 @@ import {
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { drainHeartbeatRunsToQuiescence } from "./helpers/drain-heartbeat-runs.js";
import { heartbeatService } from "../services/heartbeat.ts";
import { buildEffectiveRunWorkspaceConfigMetadata, heartbeatService } from "../services/heartbeat.ts";
import { resolveManagedProjectWorkspaceDir } from "../home-paths.js";
import { noticeMetadataReferencesRecoveryAction } from "../services/recovery/index.ts";
import { instanceSettingsService } from "../services/instance-settings.ts";
import {
@@ -85,7 +88,7 @@ function fingerprintWorkspaceBranchIncoherenceForTest(input: {
}
const adapterExecute = vi.hoisted(() =>
vi.fn(async () => ({
vi.fn(async (_input?: unknown) => ({
exitCode: 0,
signal: null,
timedOut: false,
@@ -109,7 +112,8 @@ vi.mock("../adapters/index.js", () => ({
runningProcesses: new Map(),
}));
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const externalTestDatabaseUrl = process.env.PAPERCLIP_TEST_DATABASE_URL?.trim();
const embeddedPostgresSupport = externalTestDatabaseUrl ? { supported: true } : await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
@@ -871,8 +875,11 @@ describeEmbeddedPostgres("heartbeat workspace branch containment", () => {
const tempRoots: string[] = [];
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-branch-containment-");
db = createDb(tempDb.connectionString);
if (externalTestDatabaseUrl) db = createDb(externalTestDatabaseUrl);
else {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-branch-containment-");
db = createDb(tempDb.connectionString);
}
}, 20_000);
afterEach(async () => {
@@ -921,13 +928,136 @@ describeEmbeddedPostgres("heartbeat workspace branch containment", () => {
await db.delete(environments);
await db.delete(companySkills);
await db.delete(companies);
vi.unstubAllEnvs();
});
afterAll(async () => {
if (externalTestDatabaseUrl) await closeRegisteredClients(externalTestDatabaseUrl);
await db.$client.end();
await tempDb?.cleanup();
}, 60_000);
async function seedRetainedSourceRun(kind: "deleted" | "explicit_conflict" | "foreign_project" | "foreign_company" | "unproven") {
const home = await realpath(await mkdtemp(path.join(os.tmpdir(), "paperclip-retained-source-home-")));
tempRoots.push(home);
vi.stubEnv("PAPERCLIP_HOME", home);
const original = await createGitRepo();
const replacement = await createGitRepo();
tempRoots.push(original, replacement);
const companyId = randomUUID(), projectId = randomUUID(), oldWorkspaceId = randomUUID(), newWorkspaceId = randomUUID();
const agentId = randomUUID(), issueId = randomUUID(), executionWorkspaceId = randomUUID(), runId = randomUUID(), wakeupId = randomUUID();
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
const repoUrl = pathToFileURL(original).href;
const replacementUrl = pathToFileURL(replacement).href;
const owner = kind === "unproven" ? original : resolveManagedProjectWorkspaceDir({ companyId, projectId, repoName: path.basename(original) });
if (owner !== original) {
await mkdir(path.dirname(owner), { recursive: true });
await runGit(original, ["clone", repoUrl, owner]);
}
const branch = "retained-task";
const worktree = path.join(owner, ".paperclip", "worktrees", branch);
await runGit(owner, ["worktree", "add", "-b", branch, worktree]);
await writeFile(path.join(worktree, "retained.txt"), "unexported original task edits\n");
await writeFile(path.join(replacement, "replacement.txt"), "replacement source must stay untouched\n");
const snapshot = { provisionCommand: null, runtimeProvisionCommand: null };
const fingerprint = buildEffectiveRunWorkspaceConfigMetadata({
mode: "isolated_workspace", projectId, projectWorkspaceId: oldWorkspaceId, strategyType: "git_worktree",
workspaceStrategy: { type: "git_worktree", baseRef: "HEAD" }, repoUrl, repoRef: "HEAD", configSnapshot: snapshot,
environment: null, realization: null,
});
const metadata = {
createdByRuntime: false, config: snapshot,
configFingerprint: { version: fingerprint.version, workspaceHash: fingerprint.fingerprint, categories: fingerprint.categories,
categoryFingerprints: fingerprint.categoryFingerprints, lastEvaluatedAt: fingerprint.evaluatedAt },
};
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
await db.insert(companies).values({ id: companyId, name: "Retained source", issuePrefix, status: "active", defaultResponsibleUserId: "responsible-user" });
await db.insert(projects).values({ id: projectId, companyId, name: "Repository replacement", status: "active",
executionWorkspacePolicy: { enabled: true, defaultMode: "isolated_workspace", workspaceStrategy: { type: "git_worktree", baseRef: "HEAD" } } });
await db.insert(projectWorkspaces).values([
{ id: oldWorkspaceId, companyId, projectId, name: "Original", cwd: owner, repoUrl: kind === "unproven" ? null : repoUrl, isPrimary: true },
{ id: newWorkspaceId, companyId, projectId, name: "Replacement", cwd: replacement, repoUrl: replacementUrl, isPrimary: false },
]);
await db.insert(agents).values({ id: agentId, companyId, name: "Source tester", role: "engineer", status: "idle", adapterType: "codex_local",
adapterConfig: {}, runtimeConfig: { heartbeat: { wakeOnDemand: true, maxConcurrentRuns: 1 } }, permissions: {} });
await db.insert(executionWorkspaces).values({ id: executionWorkspaceId, companyId, projectId, projectWorkspaceId: oldWorkspaceId,
mode: "isolated_workspace", strategyType: "git_worktree", name: branch, status: "active", cwd: worktree, providerRef: worktree,
providerType: "git_worktree", branchName: branch, repoUrl: kind === "unproven" ? null : repoUrl, baseRef: "HEAD", metadata });
await db.insert(agentWakeupRequests).values({ id: wakeupId, companyId, agentId, source: "assignment", triggerDetail: "system",
reason: "issue_assigned", payload: { issueId }, status: "queued", runId });
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, invocationSource: "assignment", triggerDetail: "system", status: "queued",
wakeupRequestId: wakeupId, responsibleUserId: "responsible-user", contextSnapshot: { issueId, taskId: issueId, wakeReason: "issue_assigned" } });
await db.insert(issues).values({ id: issueId, companyId, projectId, projectWorkspaceId: oldWorkspaceId, title: "Continue retained work",
status: "in_progress", workMode: "standard", assigneeAgentId: agentId, responsibleUserId: "responsible-user", issueNumber: 1,
identifier: `${issuePrefix}-1`, executionWorkspaceId, executionWorkspacePreference: "reuse_existing", executionWorkspaceSettings: { mode: "isolated_workspace" },
checkoutRunId: runId, executionRunId: runId, executionAgentNameKey: "source-tester", executionLockedAt: new Date() });
await db.update(executionWorkspaces).set({ sourceIssueId: issueId }).where(eq(executionWorkspaces.id, executionWorkspaceId));
// Exercise the real FK deletion semantics, rather than manufacturing nulls.
await db.delete(projectWorkspaces).where(eq(projectWorkspaces.id, oldWorkspaceId));
await db.update(projectWorkspaces).set({ isPrimary: true }).where(eq(projectWorkspaces.id, newWorkspaceId));
if (kind === "explicit_conflict") await db.update(issues).set({ projectWorkspaceId: newWorkspaceId }).where(eq(issues.id, issueId));
if (kind === "foreign_project" || kind === "foreign_company") {
const foreignCompanyId = kind === "foreign_company" ? randomUUID() : companyId;
if (foreignCompanyId !== companyId) await db.insert(companies).values({ id: foreignCompanyId, name: "Other company", issuePrefix: `F${foreignCompanyId.slice(0, 7)}` });
const foreignProjectId = randomUUID();
await db.insert(projects).values({ id: foreignProjectId, companyId: foreignCompanyId, name: "Other project", status: "active" });
await db.update(executionWorkspaces).set({ companyId: foreignCompanyId, projectId: foreignProjectId }).where(eq(executionWorkspaces.id, executionWorkspaceId));
}
return { companyId, projectId, newWorkspaceId, issueId, agentId, runId, executionWorkspaceId, worktree, branch, owner, replacement, repoUrl, metadata };
}
it("preserves a retained task source after its project repository is replaced and the original FK is deleted", async () => {
const seeded = await seedRetainedSourceRun("deleted");
const heartbeat = heartbeatService(db);
await heartbeat.resumeQueuedRuns();
const run = await waitForRunToFinish(heartbeat, seeded.runId);
await heartbeat.waitForRunExecutionDrain(seeded.runId);
expect(run).toMatchObject({ status: "succeeded" });
expect(adapterExecute).toHaveBeenCalledTimes(1);
expect(readAdapterWorkspace(adapterExecute.mock.calls[0]?.[0])).toEqual({ cwd: seeded.worktree, branchName: seeded.branch, executionWorkspaceId: seeded.executionWorkspaceId });
expect(adapterExecute.mock.calls[0]?.[0]).toMatchObject({ context: { paperclipWorkspace: {
projectId: seeded.projectId, workspaceId: null, repoUrl: seeded.repoUrl, repoRef: "HEAD", source: "task_session",
} } });
const [workspace] = await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, seeded.executionWorkspaceId));
const [issue] = await db.select().from(issues).where(eq(issues.id, seeded.issueId));
expect(workspace).toMatchObject({ companyId: seeded.companyId, projectId: seeded.projectId, projectWorkspaceId: null,
cwd: seeded.worktree, providerRef: seeded.worktree, branchName: seeded.branch, repoUrl: seeded.repoUrl, metadata: seeded.metadata });
expect(issue).toMatchObject({ projectWorkspaceId: null, executionWorkspaceId: seeded.executionWorkspaceId, executionWorkspacePreference: "reuse_existing" });
expect(await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.companyId, seeded.companyId))).toHaveLength(1);
expect(run?.resultJson).toMatchObject({ configFreshness: { workspace: { action: "replace", reuseRequested: true, workspaceReused: true, configSnapshotRefreshed: false,
changedCategories: expect.arrayContaining(["projectWorkspace", "repo"]) } } });
expect(await readFile(path.join(seeded.worktree, "retained.txt"), "utf8")).toBe("unexported original task edits\n");
expect(await readFile(path.join(seeded.replacement, "replacement.txt"), "utf8")).toBe("replacement source must stay untouched\n");
expect(await readGit(seeded.worktree, ["branch", "--show-current"])).toBe(seeded.branch);
expect(await readGit(seeded.owner, ["worktree", "list", "--porcelain"])).toContain(`worktree ${seeded.worktree}`);
});
it.each(["explicit_conflict", "foreign_project", "foreign_company", "unproven"] as const)(
"does not dispatch or rebind a retained source with %s authority", async (kind) => {
const seeded = await seedRetainedSourceRun(kind);
const heartbeat = heartbeatService(db);
await heartbeat.resumeQueuedRuns();
const run = await waitForRunToFinish(heartbeat, seeded.runId);
await heartbeat.waitForRunExecutionDrain(seeded.runId);
expect(run).toMatchObject({ status: "failed", errorCode: "workspace_validation_failed" });
expect(run?.resultJson).toMatchObject({ workspaceValidation: { reason: "persisted_workspace_source_conflict",
reasonCode: kind === "explicit_conflict" ? "explicit_project_workspace_conflict"
: kind === "unproven" ? "source_registration_unproven" : "source_scope_mismatch" } });
expect(adapterExecute).not.toHaveBeenCalled();
const [workspace] = await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, seeded.executionWorkspaceId));
expect(workspace).toMatchObject({ projectWorkspaceId: null, cwd: seeded.worktree, providerRef: seeded.worktree, branchName: seeded.branch, metadata: seeded.metadata });
const [issue] = await db.select().from(issues).where(eq(issues.id, seeded.issueId));
expect(issue).toMatchObject({ executionWorkspaceId: seeded.executionWorkspaceId,
projectWorkspaceId: kind === "explicit_conflict" ? seeded.newWorkspaceId : null });
// Intentionally malformed cross-company/project FKs also prevent the
// existing issue-update service from changing status. The source gate
// still must fail before dispatch and may never silently repair that FK.
if (kind === "explicit_conflict" || kind === "unproven") expect(issue.status).toBe("blocked");
expect(await readFile(path.join(seeded.worktree, "retained.txt"), "utf8")).toBe("unexported original task edits\n");
expect(await readFile(path.join(seeded.replacement, "replacement.txt"), "utf8")).toBe("replacement source must stay untouched\n");
},
);
it("blocks projectless isolated git-worktree issues before dispatch", async () => {
const companyId = randomUUID();
const agentId = randomUUID();
@@ -0,0 +1,296 @@
import { execFile } 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 { afterEach, describe, expect, it, vi } from "vitest";
import { resolvePersistedGitWorkspaceSource } from "../services/persisted-workspace-source.js";
const exec = promisify(execFile);
const roots: string[] = [];
afterEach(async () => {
vi.unstubAllEnvs();
await Promise.all(roots.splice(0).map(root => fs.rm(root, { recursive: true, force: true })));
});
async function fixture() {
const root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "persisted-source-")));
roots.push(root);
const original = path.join(root, "original");
const replacement = path.join(root, "replacement");
const worktree = path.join(root, "task");
for (const repo of [original, replacement]) {
await exec("git", ["init", repo]);
await exec("git", ["-C", repo, "-c", "user.name=Test", "-c", "user.email=test@example.invalid", "commit", "--allow-empty", "-m", "initial"]);
await exec("git", ["-C", repo, "remote", "add", "origin", `https://example.invalid/${path.basename(repo)}.git`]);
}
await exec("git", ["-C", original, "worktree", "add", "-b", "task", worktree]);
await fs.writeFile(path.join(worktree, "retained.txt"), "keep this work\n");
const workspace = {
id: "execution-workspace", companyId: "company", projectId: "project", projectWorkspaceId: null,
repoUrl: "https://example.invalid/original.git", baseRef: "HEAD", cwd: worktree, providerRef: worktree,
};
return { root, original, replacement, worktree, workspace, input: {
companyId: "company", projectId: "project", explicitProjectWorkspaceId: null,
workspace, boundProjectWorkspace: null, candidateBaseCwds: [original], managedSourceRoot: root,
} };
}
describe("persisted Git workspace source", () => {
it("keeps the original registered source after its project binding is deleted", async () => {
const { original, replacement, worktree, input } = await fixture();
const source = await resolvePersistedGitWorkspaceSource(input);
expect(source).toBe(original);
expect(source).not.toBe(replacement);
expect(input.workspace.projectWorkspaceId).toBeNull();
expect(await fs.readFile(path.join(worktree, "retained.txt"), "utf8")).toBe("keep this work\n");
});
it.each(["companyId", "projectId"] as const)("rejects a foreign %s before source inspection or materialization", async field => {
const { input } = await fixture();
const materializeOriginalRepository = vi.fn();
await expect(resolvePersistedGitWorkspaceSource({ ...input, workspace: { ...input.workspace, [field]: "foreign" },
materializeOriginalRepository })).rejects.toMatchObject({ resultJson: { workspaceValidation: { reasonCode: "source_scope_mismatch" } } });
expect(materializeOriginalRepository).not.toHaveBeenCalled();
});
it("rejects an explicit new project selection without rebinding the old task source", async () => {
const { input } = await fixture();
await expect(resolvePersistedGitWorkspaceSource({ ...input, explicitProjectWorkspaceId: "replacement" })).rejects.toMatchObject({
code: "workspace_validation_failed", resultJson: { workspaceValidation: { reasonCode: "explicit_project_workspace_conflict" } },
});
});
it.each([null, { id: "bound", companyId: "foreign", projectId: "project", cwd: null },
{ id: "bound", companyId: "company", projectId: "foreign", cwd: null }])("rejects an unverified non-null project binding (%j)", async binding => {
const { input } = await fixture();
await expect(resolvePersistedGitWorkspaceSource({ ...input, workspace: { ...input.workspace, projectWorkspaceId: "bound" },
boundProjectWorkspace: binding })).rejects.toMatchObject({ resultJson: { workspaceValidation: { reasonCode: "source_scope_mismatch" } } });
});
it("rejects a supplied source binding when the saved association is null", async () => {
const { original, input } = await fixture();
await expect(resolvePersistedGitWorkspaceSource({ ...input,
boundProjectWorkspace: { id: "foreign-binding", companyId: "company", projectId: "project", cwd: original },
})).rejects.toMatchObject({ resultJson: { workspaceValidation: { reasonCode: "source_scope_mismatch" } } });
});
it("does not trust another Git owner just because its origin matches", async () => {
const { original, replacement, input } = await fixture();
await exec("git", ["-C", replacement, "remote", "set-url", "origin", input.workspace.repoUrl]);
await expect(resolvePersistedGitWorkspaceSource({ ...input, candidateBaseCwds: [replacement] })).rejects.toMatchObject({
resultJson: { workspaceValidation: { reasonCode: "source_registration_unproven" } },
});
expect(original).not.toBe(replacement);
});
it("uses the stored origin identity without transport-only URL rewrites", async () => {
const { original, input } = await fixture();
await exec("git", ["-C", original, "config", "url.ssh://git@example.invalid/.insteadOf", "https://example.invalid/"]);
expect((await exec("git", ["-C", original, "remote", "get-url", "origin"])).stdout.trim()).not.toBe(input.workspace.repoUrl);
expect(await resolvePersistedGitWorkspaceSource(input)).toBe(original);
});
it("rejects a symlink that redirects a managed source into another repository", async () => {
const { root, original, input } = await fixture();
const alias = path.join(root, "scoped", "original");
await fs.mkdir(path.dirname(alias));
await fs.symlink(original, alias);
await expect(resolvePersistedGitWorkspaceSource({ ...input, candidateBaseCwds: [alias] })).rejects.toMatchObject({
resultJson: { workspaceValidation: { reasonCode: "source_registration_unproven" } },
});
});
it("rejects multiple origin URLs even if the last one matches the saved repository", async () => {
const { original, input } = await fixture();
await exec("git", ["-C", original, "remote", "set-url", "origin", "https://example.invalid/other.git"]);
await exec("git", ["-C", original, "config", "--add", "remote.origin.url", input.workspace.repoUrl]);
await expect(resolvePersistedGitWorkspaceSource(input)).rejects.toMatchObject({ resultJson: { workspaceValidation: {
reasonCode: "source_repository_mismatch",
} } });
});
it("rejects changed origin identity even at the recorded managed path", async () => {
const { original, input } = await fixture();
await exec("git", ["-C", original, "remote", "set-url", "origin", "https://example.invalid/other.git"]);
await expect(resolvePersistedGitWorkspaceSource(input)).rejects.toMatchObject({ resultJson: { workspaceValidation: {
reasonCode: "source_repository_mismatch",
} } });
});
it("refuses an independent clone masquerading as the selected linked worktree", async () => {
const { root, original, input } = await fixture();
const clone = path.join(root, "clone");
await exec("git", ["clone", original, clone]);
await expect(resolvePersistedGitWorkspaceSource({ ...input, workspace: { ...input.workspace, cwd: clone, providerRef: clone },
candidateBaseCwds: [clone] })).rejects.toMatchObject({ resultJson: { workspaceValidation: { reasonCode: "source_registration_unproven" } } });
});
it("refuses a primary checkout whose separate Git directory impersonates a managed base", async () => {
const { root, original, input } = await fixture();
const fakeOwner = path.join(root, "fake-owner");
const clone = path.join(root, "separate-checkout");
await fs.mkdir(fakeOwner);
await exec("git", ["clone", "--separate-git-dir", path.join(fakeOwner, ".git"), original, clone]);
await exec("git", ["-C", clone, "remote", "set-url", "origin", input.workspace.repoUrl]);
// This shape can appear in a complete worktree list, but has no per-worktree
// storage under the owner's .git/worktrees directory.
await expect(resolvePersistedGitWorkspaceSource({ ...input, workspace: { ...input.workspace, cwd: clone, providerRef: clone },
candidateBaseCwds: [fakeOwner] })).rejects.toMatchObject({ resultJson: { workspaceValidation: { reasonCode: "source_registration_unproven" } } });
});
it("preserves registered local sources whose configured cwd is a repository subfolder", async () => {
const { original, input } = await fixture();
const cwd = path.join(original, "subfolder");
await fs.mkdir(cwd);
expect(await resolvePersistedGitWorkspaceSource({ ...input,
workspace: { ...input.workspace, repoUrl: null, projectWorkspaceId: "bound" }, candidateBaseCwds: [],
boundProjectWorkspace: { id: "bound", companyId: "company", projectId: "project", cwd },
})).toBe(original);
});
it("supports a bound local repository with separate Git storage", async () => {
const { root, input } = await fixture();
const owner = path.join(root, "separate-owner");
const storage = path.join(root, "git-storage");
const task = path.join(root, "separate-task");
await exec("git", ["init", "--separate-git-dir", storage, owner]);
await exec("git", ["-C", owner, "-c", "user.name=Test", "-c", "user.email=test@example.invalid", "commit", "--allow-empty", "-m", "initial"]);
await exec("git", ["-C", owner, "worktree", "add", "-b", "retained", task]);
expect(await resolvePersistedGitWorkspaceSource({ ...input,
workspace: { ...input.workspace, repoUrl: null, cwd: task, providerRef: task, projectWorkspaceId: "bound" }, candidateBaseCwds: [],
boundProjectWorkspace: { id: "bound", companyId: "company", projectId: "project", cwd: owner },
})).toBe(owner);
});
it("does not reconstruct a missing local worktree from its binding's replacement cwd", async () => {
const { original, replacement, worktree, input } = await fixture();
await exec("git", ["-C", original, "worktree", "remove", "--force", worktree]);
await expect(resolvePersistedGitWorkspaceSource({ ...input,
workspace: { ...input.workspace, repoUrl: null, projectWorkspaceId: "bound" }, candidateBaseCwds: [],
boundProjectWorkspace: { id: "bound", companyId: "company", projectId: "project", cwd: replacement },
})).rejects.toMatchObject({ resultJson: { workspaceValidation: { reasonCode: "source_repository_unavailable" } } });
});
it("fails closed when a legacy local source lost its only project association", async () => {
const { input } = await fixture();
await expect(resolvePersistedGitWorkspaceSource({ ...input, workspace: { ...input.workspace, repoUrl: null },
candidateBaseCwds: [] })).rejects.toMatchObject({ resultJson: { workspaceValidation: { reasonCode: "source_registration_unproven" } } });
});
it("rejects a changed bound local cwd instead of adopting its new repository", async () => {
const { replacement, input } = await fixture();
await expect(resolvePersistedGitWorkspaceSource({ ...input,
workspace: { ...input.workspace, repoUrl: null, projectWorkspaceId: "bound" }, candidateBaseCwds: [],
boundProjectWorkspace: { id: "bound", companyId: "company", projectId: "project", cwd: replacement },
})).rejects.toMatchObject({ resultJson: { workspaceValidation: { reasonCode: "source_registration_unproven" } } });
});
it("materializes only the recorded original source when both managed checkout and worktree are absent", async () => {
const { root, original, input } = await fixture();
const restored = path.join(root, "restored-source");
const missingWorktree = path.join(root, "missing-worktree");
const materializeOriginalRepository = vi.fn(async () => {
await exec("git", ["clone", original, restored]);
await exec("git", ["-C", restored, "remote", "set-url", "origin", input.workspace.repoUrl]);
return restored;
});
expect(await resolvePersistedGitWorkspaceSource({ ...input,
workspace: { ...input.workspace, cwd: missingWorktree, providerRef: missingWorktree },
candidateBaseCwds: [restored], materializeOriginalRepository,
})).toBe(restored);
expect(materializeOriginalRepository).toHaveBeenCalledTimes(1);
});
it("rejects a materializer result outside the retained source candidates", async () => {
const { root, original, input } = await fixture();
const missingWorktree = path.join(root, "missing-worktree");
const materializeOriginalRepository = vi.fn(async () => original);
await expect(resolvePersistedGitWorkspaceSource({ ...input,
workspace: { ...input.workspace, cwd: missingWorktree, providerRef: missingWorktree },
candidateBaseCwds: [path.join(root, "expected-clone")], materializeOriginalRepository,
})).rejects.toMatchObject({ resultJson: { workspaceValidation: { reasonCode: "source_repository_unavailable" } } });
expect(materializeOriginalRepository).toHaveBeenCalledTimes(1);
});
it("does not invoke materialization through a managed project symlink", async () => {
const { root, original, input } = await fixture();
const scoped = path.join(root, "scoped");
await fs.symlink(original, scoped);
const missingWorktree = path.join(root, "missing-worktree");
const materializeOriginalRepository = vi.fn(async () => original);
await expect(resolvePersistedGitWorkspaceSource({ ...input,
workspace: { ...input.workspace, cwd: missingWorktree, providerRef: missingWorktree },
candidateBaseCwds: [path.join(scoped, "new-clone")], materializeOriginalRepository,
})).rejects.toMatchObject({ resultJson: { workspaceValidation: { reasonCode: "source_repository_unavailable" } } });
expect(materializeOriginalRepository).not.toHaveBeenCalled();
});
it.each([false, true])("keeps bounded Git inspection evidence before restoring a retained source (missing=%s)", async missing => {
const { root, original, worktree, input } = await fixture();
if (missing) await exec("git", ["-C", original, "worktree", "remove", "--force", worktree]);
const realGit = (await exec("/bin/sh", ["-c", "command -v git"])).stdout.trim();
const bin = path.join(root, "bin");
await fs.mkdir(bin);
await fs.writeFile(path.join(bin, "git"), `#!${process.execPath}\nconst args=process.argv.slice(2);\nif(args[0]==="worktree"&&args[1]==="list"){process.stderr.write("private-provider-output /private/path");process.exitCode=128;}else{const r=require("node:child_process").spawnSync(${JSON.stringify(realGit)},args,{stdio:"inherit"});process.exitCode=r.status??1;}\n`, { mode: 0o755 });
vi.stubEnv("PATH", `${bin}${path.delimiter}${process.env.PATH ?? ""}`);
const materializeOriginalRepository = vi.fn();
const failure = await resolvePersistedGitWorkspaceSource({ ...input, materializeOriginalRepository }).catch(error => error);
expect(failure).toMatchObject({ code: "workspace_validation_failed", resultJson: { workspaceValidation: {
reason: "git_worktree_not_reusable", reasonCode: "git_inspection_failed",
inspectionDiagnostic: { command: "worktree_list", failure: "nonzero_exit", exitCode: 128 },
} } });
expect(JSON.stringify(failure.resultJson)).not.toContain("private");
expect(materializeOriginalRepository).not.toHaveBeenCalled();
if (!missing) expect(await fs.readFile(path.join(worktree, "retained.txt"), "utf8")).toBe("keep this work\n");
});
it("rebuilds a missing worktree only from the retained source, not a new primary", async () => {
const { original, replacement, worktree, input } = await fixture();
await exec("git", ["-C", original, "worktree", "remove", "--force", worktree]);
expect(await resolvePersistedGitWorkspaceSource({ ...input, candidateBaseCwds: [replacement, original] })).toBe(original);
await expect(resolvePersistedGitWorkspaceSource({ ...input, candidateBaseCwds: [replacement] })).rejects.toMatchObject({
resultJson: { workspaceValidation: { reasonCode: "source_repository_unavailable" } },
});
});
it("accepts a symlink for the configured instance root only", async () => {
const { root, original, input } = await fixture();
const alias = `${root}-alias`;
roots.push(alias);
await fs.symlink(root, alias);
expect(await resolvePersistedGitWorkspaceSource({ ...input, managedSourceRoot: alias,
candidateBaseCwds: [path.join(alias, "original")] })).toBe(original);
});
it("mismatched saved paths never materialize another source", async () => {
const { replacement, input } = await fixture();
const materializeOriginalRepository = vi.fn();
await expect(resolvePersistedGitWorkspaceSource({ ...input,
workspace: { ...input.workspace, providerRef: replacement }, materializeOriginalRepository,
})).rejects.toMatchObject({ resultJson: { workspaceValidation: { reasonCode: "source_path_unproven" } } });
expect(materializeOriginalRepository).not.toHaveBeenCalled();
});
it("a dangling saved-worktree symlink is not replaced", async () => {
const { root, input } = await fixture();
const dangling = path.join(root, "dangling-task");
await fs.symlink(path.join(root, "absent-target"), dangling);
const materializeOriginalRepository = vi.fn();
await expect(resolvePersistedGitWorkspaceSource({ ...input,
workspace: { ...input.workspace, cwd: dangling, providerRef: dangling }, materializeOriginalRepository,
})).rejects.toMatchObject({ resultJson: { workspaceValidation: { reasonCode: "source_path_unproven" } } });
expect(materializeOriginalRepository).not.toHaveBeenCalled();
expect((await fs.lstat(dangling)).isSymbolicLink()).toBe(true);
});
it("a substituted worktree backlink cannot authorize reuse", async () => {
const { original, worktree, input } = await fixture();
const storage = (await exec("git", ["-C", worktree, "rev-parse", "--absolute-git-dir"])).stdout.trim();
await fs.writeFile(path.join(storage, "gitdir"), path.join(original, ".git") + "\n");
await expect(resolvePersistedGitWorkspaceSource(input)).rejects.toMatchObject({ resultJson: {
workspaceValidation: { reasonCode: "source_registration_unproven" },
} });
expect(await fs.readFile(path.join(worktree, "retained.txt"), "utf8")).toBe("keep this work\n");
});
});
@@ -49,6 +49,30 @@ describe("run failure diagnostics", () => {
expect(inspect({ ...base, inspectionDiagnostic: { command: "worktree_list", failure: "spawn_failed", errorCode: "EACCES", exitCode: 128 } })).toMatchObject({ workspaceValidationInspectionErrorCode: "EACCES" });
});
it.each(["source_scope_mismatch", "explicit_project_workspace_conflict", "source_path_unproven",
"source_registration_unproven", "source_repository_mismatch", "source_repository_unavailable"])(
"exports only the fixed retained-source reason %s", reasonCode => {
const workspaceValidation = { reason: "persisted_workspace_source_conflict", reasonCode,
executionWorkspaceId: "private-id", repoUrl: "private-url", cwd: "/private-path", message: "private-message" };
expect(collectRunFailureDiagnostics(run({ errorCode: "workspace_validation_failed", resultJson: { workspaceValidation } }), {}).execution)
.toEqual({ workspaceValidationReason: "persisted_workspace_source_conflict", workspaceValidationReasonCode: reasonCode });
expect(collectRunFailureDiagnostics(run({ errorCode: "adapter_failed", resultJson: { workspaceValidation } }), {}).execution).toEqual({});
},
);
it("omits arbitrary retained-source reason strings and hostile getters", () => {
for (const reasonCode of ["private-url", Object.defineProperty({}, "value", { get() { throw new Error("private"); } })]) {
expect(collectRunFailureDiagnostics(run({ errorCode: "workspace_validation_failed", resultJson: { workspaceValidation: {
reason: "persisted_workspace_source_conflict", reasonCode,
} } }), {}).execution).toEqual({ workspaceValidationReason: "persisted_workspace_source_conflict" });
}
const workspaceValidation = Object.defineProperty({ reason: "persisted_workspace_source_conflict" }, "reasonCode", {
get() { throw new Error("private"); },
});
expect(collectRunFailureDiagnostics(run({ errorCode: "workspace_validation_failed", resultJson: { workspaceValidation } }), {}).execution)
.toEqual({ workspaceValidationReason: "persisted_workspace_source_conflict" });
});
it("exports bounded orphan evidence only for a process-loss failure", () => {
const processLossDiagnostic = { pidRecorded: false, groupRecorded: false, localCheck: "no_identifiers",
retryEligible: false, runPredatesObserver: true, observerUptimeMs: 30_000, lastOutputAgeMs: 120_000,
+106 -5
View File
@@ -434,6 +434,7 @@ import {
type UnresolvedWorkspaceBaseRefError,
sanitizeRuntimeServiceBaseEnv,
} from "./workspace-runtime.js";
import { resolvePersistedGitWorkspaceSource } from "./persisted-workspace-source.js";
import {
readManagedWorktreeInstanceOwnership,
WORKTREE_INSTANCE_ROOT_METADATA_KEY,
@@ -3947,6 +3948,8 @@ export type ResolvedWorkspaceForRun = {
materializationFailures: WorkspaceMaterializationFailure[];
/** True only for an explicitly configured local project path without a repository URL. */
localPathOnlyWorkspace?: boolean;
/** Current configuration for drift reporting; never an alternative restore source. */
freshnessSource?: { projectId: string | null; workspaceId: string | null; repoUrl: string | null; repoRef: string | null };
/**
* Read-only referenced (mentioned) project workspaces for this run, one per authorized
* additional project. The array is empty unless the multi-project workspace-sync flag is on
@@ -12601,6 +12604,85 @@ export function heartbeatService(
};
}
async function resolveReusedGitWorkspaceAnchor(input: {
agent: typeof agents.$inferSelect;
workspace: ExecutionWorkspace;
projectId: string | null;
explicitProjectWorkspaceId: string | null;
issueId: string | null;
runId: string;
responsibleUserId: string | null;
immutableNativeBinding: boolean;
}): Promise<ResolvedAnchorWorkspaceForRun> {
const { workspace, agent } = input;
// Configuration preparation may have awaited credentials and skills since
// issueRef was read. Match the ordinary anchor resolver's fresh selection
// check; only an already-admitted native input owns immutable source scope.
const currentIssueSource = input.issueId && !input.immutableNativeBinding
? await db.select({ projectId: issues.projectId, projectWorkspaceId: issues.projectWorkspaceId }).from(issues)
.where(and(eq(issues.id, input.issueId), eq(issues.companyId, agent.companyId))).then(rows => rows[0] ?? null)
: null;
const sourceProjectId = input.issueId && !input.immutableNativeBinding ? currentIssueSource?.projectId ?? null : input.projectId;
const explicitProjectWorkspaceId = currentIssueSource?.projectWorkspaceId ?? input.explicitProjectWorkspaceId;
const projectWorkspaceRows = await db.select().from(projectWorkspaces).where(and(
eq(projectWorkspaces.companyId, agent.companyId),
eq(projectWorkspaces.projectId, workspace.projectId),
)).orderBy(asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id));
const boundProjectWorkspace = projectWorkspaceRows.find(row => row.id === workspace.projectWorkspaceId) ?? null;
const configuredSource = prioritizeProjectWorkspaceCandidatesForRun(projectWorkspaceRows, explicitProjectWorkspaceId)[0];
const managedBase = workspace.repoUrl ? resolveManagedProjectWorkspaceDir({
companyId: agent.companyId,
projectId: workspace.projectId,
repoName: deriveRepoNameFromRepoUrl(workspace.repoUrl),
}) : null;
const candidateBaseCwds = [
managedBase,
managedBase && workspace.repoUrl
? `${managedBase}-${createHash("sha256").update(workspace.repoUrl).digest("hex").slice(0, 12)}` : null,
].filter((value): value is string => Boolean(value));
const cwd = await resolvePersistedGitWorkspaceSource({
companyId: agent.companyId,
projectId: sourceProjectId,
explicitProjectWorkspaceId,
workspace,
boundProjectWorkspace,
candidateBaseCwds,
managedSourceRoot: resolvePaperclipInstanceRoot(),
materializeOriginalRepository: workspace.repoUrl ? async () => {
const original = await ensureManagedProjectWorkspace({
companyId: agent.companyId,
projectId: workspace.projectId,
repoUrl: workspace.repoUrl,
resolveGitAuth: createGitRemoteAuthProvider(db, agent.companyId, {
issueId: input.issueId, heartbeatRunId: input.runId, agentId: agent.id,
responsibleUserId: input.responsibleUserId,
}),
});
return original.cwd;
} : undefined,
});
return {
cwd,
source: "task_session",
projectId: workspace.projectId,
workspaceId: workspace.projectWorkspaceId,
repoUrl: workspace.repoUrl,
repoRef: workspace.baseRef,
// Freshness must still expose drift in current project configuration.
// This snapshot authorizes no filesystem lookup or repository fallback.
freshnessSource: {
projectId: workspace.projectId,
workspaceId: configuredSource?.id ?? null,
repoUrl: configuredSource?.repoUrl ?? null,
repoRef: configuredSource?.repoRef ?? null,
},
workspaceHints: [],
warnings: [],
baseCwdFallback: false,
materializationFailures: [],
};
}
async function resolveAnchorWorkspaceForRun(
agent: typeof agents.$inferSelect,
context: Record<string, unknown>,
@@ -22288,6 +22370,22 @@ export function heartbeatService(
{
useProjectWorkspace:
requestedExecutionWorkspaceMode !== "agent_default",
anchorWorkspace: requestedShouldReuseExisting && reusableExistingExecutionWorkspace?.strategyType === "git_worktree"
? await resolveReusedGitWorkspaceAnchor({
agent,
workspace: reusableExistingExecutionWorkspace,
responsibleUserId,
immutableNativeBinding: Boolean(nativeRecoveryExecutionWorkspaceId),
projectId: nativeRecoveryExecutionWorkspaceId
? reusableExistingExecutionWorkspace.projectId
: issueRef?.projectId ?? readNonEmptyString(context.projectId),
explicitProjectWorkspaceId: nativeRecoveryExecutionWorkspaceId
? reusableExistingExecutionWorkspace.projectWorkspaceId
: readNonEmptyString(context.projectWorkspaceId),
issueId,
runId: run.id,
})
: undefined,
// Thread the selected environment driver so run-workspace resolution can tell a local
// target from a remote one, and a confined sandbox target from an unconfined remote
// target. A remote run resolves referenced projects only for the confined sandbox
@@ -22371,17 +22469,18 @@ export function heartbeatService(
trustPreset: trustPreset.kind,
lowTrustSandboxDriver: lowTrustPreflightEnvironmentDriver,
};
const workspaceFreshnessSource = resolvedWorkspace.freshnessSource ?? executionWorkspaceBase;
const latestWorkspaceConfigMetadata =
buildEffectiveRunWorkspaceConfigMetadata({
mode: requestedExecutionWorkspaceMode,
projectId: executionWorkspaceBase.projectId,
projectWorkspaceId: executionWorkspaceBase.workspaceId,
projectId: workspaceFreshnessSource.projectId,
projectWorkspaceId: workspaceFreshnessSource.workspaceId,
strategyType: latestWorkspaceStrategyType,
workspaceStrategy: workspaceStrategyFingerprintValue,
repoUrl: executionWorkspaceBase.repoUrl,
repoUrl: workspaceFreshnessSource.repoUrl,
repoRef:
readNonEmptyString(workspaceStrategyForFingerprint.baseRef) ??
executionWorkspaceBase.repoRef,
workspaceFreshnessSource.repoRef,
configSnapshot,
environment: workspaceEnvironmentFingerprint,
realization: workspaceRealizationFingerprint,
@@ -22560,7 +22659,9 @@ export function heartbeatService(
executionProjectId ??
null;
const resolvedProjectWorkspaceId =
issueRef?.projectWorkspaceId ?? resolvedWorkspace.workspaceId ?? null;
resolvedWorkspaceReusePolicy.shouldRestoreExistingWorkspace && reusableExistingExecutionWorkspace?.strategyType === "git_worktree"
? reusableExistingExecutionWorkspace.projectWorkspaceId
: issueRef?.projectWorkspaceId ?? resolvedWorkspace.workspaceId ?? null;
let persistedExecutionWorkspace: ExecutionWorkspace | null = null;
let issueExecutionWorkspaceIdForRun =
issueRef?.executionWorkspaceId ?? null;
@@ -0,0 +1,200 @@
import { execFile } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
import type { ExecutionWorkspace } from "@paperclipai/shared";
import { inspectManagedGitWorktreeBranch, WorkspaceRuntimeValidationFailure } from "./workspace-runtime.js";
import type { ManagedGitInspectionDiagnostic, PersistedWorkspaceSourceReasonCode } from "./workspace-validation-diagnostics.js";
const exec = promisify(execFile);
type SourceWorkspace = Pick<ExecutionWorkspace,
"id" | "companyId" | "projectId" | "projectWorkspaceId" | "repoUrl" | "baseRef" | "cwd" | "providerRef"
>;
function fail(workspace: SourceWorkspace, reasonCode: PersistedWorkspaceSourceReasonCode): never {
throw new WorkspaceRuntimeValidationFailure(
"The selected execution workspace's original repository could not be verified. "
+ "Restore its original project source. Before intentionally clearing the task's existing-workspace binding to choose another repository, review and preserve its retained work.",
{ workspaceValidation: { reason: "persisted_workspace_source_conflict", reasonCode, executionWorkspaceId: workspace.id } },
);
}
function failInspection(workspace: SourceWorkspace, diagnostic: ManagedGitInspectionDiagnostic): never {
throw new WorkspaceRuntimeValidationFailure(
"The original repository's complete Git worktree registration could not be inspected. Inspect the source before retrying; its workspace binding was preserved.",
{ workspaceValidation: { reason: "git_worktree_not_reusable", reasonCode: "git_inspection_failed",
executionWorkspaceId: workspace.id, inspectionDiagnostic: diagnostic } },
);
}
async function canonicalDirectory(value: string): Promise<string | null> {
try {
const resolved = await fs.realpath(value);
return (await fs.stat(resolved)).isDirectory() ? resolved : null;
} catch {
return null;
}
}
// Read the stored identity, not `remote get-url` which expands transport-only
// insteadOf settings (including the managed SSH-to-HTTPS credential rewrite).
async function readOrigin(cwd: string): Promise<string | null> {
return exec("git", ["config", "--local", "--null", "--get-all", "remote.origin.url"], {
cwd, env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" }, timeout: 10_000, maxBuffer: 16 * 1024,
}).then(result => {
const urls = result.stdout.split("\0");
if (urls.at(-1) === "") urls.pop();
// Fetch uses the first URL, while a scalar config read can select the last.
// Ambiguous origins are not proof that this is the recorded repository.
return urls.length === 1 ? urls[0]!.trim() || null : null;
}).catch(() => null);
}
async function gitDirectory(cwd: string, flag: "--absolute-git-dir" | "--git-common-dir"): Promise<string | null> {
const directory = await exec("git", ["rev-parse", "--path-format=absolute", flag], {
cwd, env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" }, timeout: 10_000, maxBuffer: 16 * 1024,
}).then(result => result.stdout.trim() || null).catch(() => null);
return directory ? canonicalDirectory(directory) : null;
}
async function hasLinkedWorktreeStorage(cwd: string, owner: string): Promise<boolean> {
const [storage, common, ownerCommon] = await Promise.all([
gitDirectory(cwd, "--absolute-git-dir"), gitDirectory(cwd, "--git-common-dir"), gitDirectory(owner, "--git-common-dir"),
]);
if (!storage || !common || common !== ownerCommon) return false;
const worktrees = await canonicalDirectory(path.join(common, "worktrees"));
if (!worktrees || path.dirname(storage) !== worktrees) return false;
try {
const backlinkPath = path.join(storage, "gitdir");
if ((await fs.stat(backlinkPath)).size > 16 * 1024) return false;
const backlink = (await fs.readFile(backlinkPath, "utf8")).trim();
return await fs.realpath(backlink) === await fs.realpath(path.join(cwd, ".git"));
} catch {
return false;
}
}
async function resolveBoundLocalOwner(cwd: string): Promise<string | null> {
const root = await exec("git", ["rev-parse", "--show-toplevel"], {
cwd, env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" }, timeout: 10_000, maxBuffer: 16 * 1024,
}).then(result => result.stdout.trim() || null).catch(() => null);
return root ? canonicalDirectory(root) : null;
}
async function canMaterializeManagedCandidate(root: string, candidate: string): Promise<boolean> {
const relative = path.relative(root, candidate);
if (!relative || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return false;
if (!await canonicalDirectory(root)) return false;
let current = root;
for (const part of relative.split(path.sep)) {
current = path.join(current, part);
try {
const entry = await fs.lstat(current);
if (!entry.isDirectory() || entry.isSymbolicLink()) return false;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") return false;
}
}
return true;
}
/** Source candidates come from scoped project state and the instance's managed path generator, never Git output. */
export async function resolvePersistedGitWorkspaceSource(input: {
companyId: string;
projectId: string | null;
explicitProjectWorkspaceId: string | null;
workspace: SourceWorkspace;
boundProjectWorkspace: { id: string; companyId: string; projectId: string; cwd: string | null } | null;
candidateBaseCwds: string[];
managedSourceRoot: string;
materializeOriginalRepository?: () => Promise<string>;
}): Promise<string> {
const workspace = input.workspace;
if (workspace.companyId !== input.companyId || workspace.projectId !== input.projectId) {
fail(workspace, "source_scope_mismatch");
}
const binding = input.boundProjectWorkspace;
if ((workspace.projectWorkspaceId && !binding) || (binding && (binding.id !== workspace.projectWorkspaceId
|| binding.companyId !== input.companyId || binding.projectId !== workspace.projectId))) {
fail(workspace, "source_scope_mismatch");
}
if (input.explicitProjectWorkspaceId && input.explicitProjectWorkspaceId !== workspace.projectWorkspaceId) {
fail(workspace, "explicit_project_workspace_conflict");
}
const cwd = workspace.providerRef ?? workspace.cwd;
if (!cwd || (workspace.cwd && workspace.providerRef && path.resolve(workspace.cwd) !== path.resolve(workspace.providerRef))) {
fail(workspace, "source_path_unproven");
}
const roots = async () => {
const canonicalRoot = await canonicalDirectory(input.managedSourceRoot);
if (!canonicalRoot) return new Set<string>();
const candidates = await Promise.all(input.candidateBaseCwds.map(async candidate => {
const relative = path.relative(input.managedSourceRoot, candidate);
if (!relative || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return null;
const resolved = await canonicalDirectory(candidate);
// The configured instance root may be a symlink. A project or repository
// below it may not redirect the scoped managed identity elsewhere.
return resolved === path.join(canonicalRoot, relative) ? resolved : null;
}));
return new Set(candidates.filter((value): value is string => value !== null));
};
const knownRoots = await roots();
if (binding?.cwd) {
// A configured local path may be a repository subfolder or another linked
// checkout. Resolve its Git owner as the old restore path already did.
const localOwner = await resolveBoundLocalOwner(binding.cwd);
if (localOwner) knownRoots.add(localOwner);
}
const existing = await canonicalDirectory(cwd);
const sourceEntry = await fs.lstat(cwd).catch((error: NodeJS.ErrnoException) => {
if (error.code === "ENOENT") return null;
return fail(workspace, "source_path_unproven");
});
if (sourceEntry && !existing) fail(workspace, "source_path_unproven");
if (existing) {
// An independent clone (including a stand-alone checkout with separate Git
// storage) is not a linked worktree authorized by the persisted binding.
const dotGit = await fs.lstat(path.join(existing, ".git")).catch(() => null);
if (!dotGit?.isFile()) fail(workspace, "source_registration_unproven");
let repositoryMismatch = false;
let failedInspection: ManagedGitInspectionDiagnostic | null = null;
for (const owner of knownRoots) {
if (owner === existing) continue;
const inspection = await inspectManagedGitWorktreeBranch({ worktreePath: existing, repoRoot: owner, expectedBranchName: null });
if (inspection.inspectionDiagnostic) failedInspection ??= inspection.inspectionDiagnostic;
if (!inspection.valid || !await hasLinkedWorktreeStorage(existing, owner)) continue;
if (workspace.repoUrl && await readOrigin(owner) !== workspace.repoUrl.trim()) {
repositoryMismatch = true;
continue;
}
return owner;
}
if (failedInspection) failInspection(workspace, failedInspection);
fail(workspace, repositoryMismatch ? "source_repository_mismatch" : "source_registration_unproven");
}
// A mutable local project cwd alone cannot prove the repository that owned a
// now-missing worktree. Do not reconstruct historical work from its new cwd.
if (!workspace.repoUrl) fail(workspace, "source_repository_unavailable");
// Reconstruct only from a source authorized by the old binding. A new project
// primary is never a fallback for a missing historical worktree.
let failedInspection: ManagedGitInspectionDiagnostic | null = null;
for (const candidate of knownRoots) {
if (workspace.repoUrl && await readOrigin(candidate) !== workspace.repoUrl.trim()) continue;
const inspection = await inspectManagedGitWorktreeBranch({ worktreePath: candidate, repoRoot: candidate, expectedBranchName: null });
if (inspection.valid) return candidate;
if (inspection.inspectionDiagnostic) failedInspection ??= inspection.inspectionDiagnostic;
}
if (failedInspection) failInspection(workspace, failedInspection);
if (workspace.repoUrl && input.materializeOriginalRepository) {
if (input.candidateBaseCwds.length === 0 || !(await Promise.all(input.candidateBaseCwds.map(
candidate => canMaterializeManagedCandidate(input.managedSourceRoot, candidate),
))).every(Boolean)) fail(workspace, "source_repository_unavailable");
const materialized = await input.materializeOriginalRepository();
const owner = await canonicalDirectory(materialized);
const expectedRoots = await roots();
if (owner && expectedRoots.has(owner) && await readOrigin(owner) === workspace.repoUrl.trim()) return owner;
}
fail(workspace, "source_repository_unavailable");
}
@@ -7,7 +7,7 @@ import { sanitizeWorkspaceRestoreDiagnostic } from "@paperclipai/adapter-utils/w
import { redactCurrentUserText } from "../log-redaction.js";
import { redactSensitiveText, REDACTED_EVENT_VALUE } from "../redaction.js";
import { readNativeModelRejectionDiagnostic } from "./native-runtime/native-provider-failure.js";
import { MANAGED_GIT_WORKTREE_REASON_CODES, readManagedGitInspectionDiagnostic } from "./workspace-validation-diagnostics.js";
import { MANAGED_GIT_WORKTREE_REASON_CODES, PERSISTED_WORKSPACE_SOURCE_REASON_CODES, readManagedGitInspectionDiagnostic } from "./workspace-validation-diagnostics.js";
type Run = typeof heartbeatRuns.$inferSelect;
type Context = Record<string, string | number | boolean>;
@@ -162,6 +162,11 @@ export function collectRunFailureDiagnostics(run: Run, options: RunFailureReport
if (diagnostic.exitCode !== undefined) execution.workspaceValidationInspectionExitCode = diagnostic.exitCode;
}
}
if (run.errorCode === "workspace_validation_failed" && read(workspaceValidation, "reason") === "persisted_workspace_source_conflict") {
execution.workspaceValidationReason = "persisted_workspace_source_conflict";
const reasonCode = PERSISTED_WORKSPACE_SOURCE_REASON_CODES.find(code => code === read(workspaceValidation, "reasonCode"));
if (reasonCode) execution.workspaceValidationReasonCode = reasonCode;
}
if (run.errorCode === "process_lost") {
const diagnostic = readProcessLossDiagnostic(read(result, "processLossDiagnostic"));
for (const [field, value] of Object.entries(diagnostic)) {
@@ -1,3 +1,10 @@
export const PERSISTED_WORKSPACE_SOURCE_REASON_CODES = [
"source_scope_mismatch", "explicit_project_workspace_conflict", "source_path_unproven",
"source_registration_unproven", "source_repository_mismatch", "source_repository_unavailable",
] as const;
export type PersistedWorkspaceSourceReasonCode = typeof PERSISTED_WORKSPACE_SOURCE_REASON_CODES[number];
export const MANAGED_GIT_WORKTREE_REASON_CODES = [
"missing_worktree", "not_a_git_checkout", "not_registered",
"wrong_repository_root", "branch_mismatch", "git_inspection_failed",