fix(workspaces): let a missing task repository ref be corrected (#19072)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Agents can ask Paperclip to prepare a task repository at a specific
ref
> - Paperclip records that request and clones the repository at the next
admission
> - Paperclip did not check that the requested ref exists
> - When the ref was absent, every admission retried the same checkout
and failed setup
> - A corrected request was refused as a conflicting receipt, so the
task stayed blocked with no repair path
> - This pull request identifies a missing ref and lets a corrected
request replace it while nothing was published
> - The benefit is that a bad ref becomes a repairable error instead of
a permanent setup failure

## Linked Issues or Issue Description

**What happened?**
An agent asked for a catalog repository at ref `main`. The repository
default branch is `master` and has no `main` branch. Each later
admission failed with `setup_failed`: `git checkout main` reported
`pathspec 'main' did not match`. The task failed admission for more than
a week and became blocked. A new request with ref `master` returned 409
because the receipt with ref `main` already existed.

**Expected behavior**
Paperclip should report that the requested ref does not exist. A new
request with a valid ref should replace the bad ref when no checkout
exists.

**Steps to reproduce**
1. Use a repository whose only branch is `master`.
2. Request it as a task repository with `ref: "main"`.
3. Admit the task. Preparation fails, and the receipt goes to
`needs_repair`.
4. Request the same repository with `ref: "master"`. Before this change,
the request returns 409.

## What Changed

- `managed-repository-checkout.ts`: When `git checkout <ref>` fails with
Git's unknown-ref message, and each quiet `rev-parse --verify` probe
(`<ref>^{commit}`, `refs/remotes/origin/<ref>^{commit}`) exits 1, throw
`RepositoryRefNotFoundError`. The message tells the caller to request
the repository again with an existing branch, tag, or commit. Other
checkout failures keep their original cause.
- `execution-workspace-repositories.ts`: Record `failureCode:
"repository_ref_not_found"` for that error. Other failures keep
`repository_preparation_failed`.
- `execution-workspace-repositories.ts`: A request for the same
repository with a different ref replaces the ref only when all of these
are true: the receipt is `needs_repair` with `repository_ref_not_found`,
it has no pinned commit, the workspace is local (no remote authority),
and the checkout path is absent. The update is conditional on that state
and writes a `workspace.repository.ref_corrected` activity entry. All
other ref changes keep the existing 409 conflict.
- `execution-workspace-repositories.ts`: Local admission re-reads each
receipt inside the checkout lock and claims it with a conditional update
on its current ref. A correction that commits after the receipts were
listed is used, not discarded.
- Tests: one regression for the full missing-ref path, and one safety
test that keeps the receipt immutable when files exist at the checkout
path or when the failure was not a missing ref.

## Verification

- Red before the fix: `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/task-workspace-repositories-persistence.test.ts` failed
with `expected ... /ref "main" does not exist/ but got 'Failed to
prepare managed checkout fo…'`.
- Green after the fix: the same file passes (11 tests). The new test
admits with ref `main` (fails, `repository_ref_not_found`, no checkout),
corrects to `master`, admits again (ready, pinned to the source commit),
and then confirms that a later ref change after publication still
returns a conflict.
- The regression also holds the checkout lock while admission waits,
corrects the ref, and then releases the lock. Without the in-lock
re-read it fails with 409; with it, admission publishes the corrected
ref.
- `heartbeat-managed-clone-credentials.test.ts` passes: an
authentication failure during checkout keeps its cause and is not
reported as a missing ref.
- Other checkout suites pass (9 files, 317 tests): reopen, base storage,
expiration, project env, rehome, nested clone migration, object cache,
trust, source copy privacy.
- Neighbor suites pass: `managed-repository-checkout.test.ts`,
`task-workspace-repositories.test.ts`,
`task-workspace-repositories-trust.test.ts`,
`heartbeat-project-repositories.test.ts` (47 tests). In one parallel run
under heavy machine load, `heartbeat-project-repositories.test.ts` had
timing failures in different tests on each run. The same file passed
alone with this change (47/47) and on master.
- `tsc --noEmit` for `server` passes.

## Risks

- Low risk. The new correction path is narrow and fails closed. Remote
(sandbox) workspaces keep the existing conflict because the control
plane cannot inspect the remote checkout.
- Existing receipts that failed before this change have
`repository_preparation_failed`. They need one more failed admission to
get `repository_ref_not_found` before a correction is accepted.
- No schema change. `failure_code` is free text.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, agentic coding with
tool use (shell, file edit, browser read-only diagnostics).

## 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
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Opus 5.5 authored and GitHub committed 2026-11-04 18:25:41 -06:00
1 parent 5723fd82ca
commit d8dbe55368
4 files changed
+221 -14

No files matched your search

@@ -6,7 +6,7 @@ import { promisify } from "node:util";
import { pathToFileURL } from "node:url";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { ensureManagedProjectWorkspace, prepareProjectRepositoryWorkspaces } from "../services/heartbeat.ts";
import { materializeManagedProjectWorkspace } from "../services/managed-repository-checkout.js";
import { materializeManagedProjectWorkspace, RepositoryRefNotFoundError } from "../services/managed-repository-checkout.js";
import { buildGitAuthInvocation, GIT_CREDENTIAL_TOKEN_ENV_KEY } from "../services/git-credentials.ts";
import { sanitizeRuntimeServiceBaseEnv } from "../services/workspace-runtime.ts";
import { resolveManagedProjectWorkspaceDir } from "../home-paths.ts";
@@ -114,6 +114,36 @@ exit 128
}
});
it.each([
["the ref still resolves", "exit 0"],
["the ref probe fails for another reason", "exit 128"],
])("keeps the original checkout failure when %s", async (_label, revParseExit) => {
const bin = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-fake-resolvable-ref-"));
const previousPath = process.env.PATH;
await fs.writeFile(path.join(bin, "git"), `#!/bin/sh
if [ "$1" = clone ]; then
for argument in "$@"; do destination="$argument"; done
/bin/mkdir -p "$destination/.git"
exit 0
fi
if [ "$3" = rev-parse ]; then ${revParseExit}; fi
printf '%s\\n' "error: pathspec 'release' did not match any file(s) known to git" >&2
exit 1
`, { mode: 0o755 });
try {
process.env.PATH = bin;
const failure = await materializeManagedProjectWorkspace(path.join(bin, "checkout"), {
repoUrl: "https://example.test/repo.git", repoRef: "release",
}).catch((error: unknown) => error);
expect(failure).toBeInstanceOf(Error);
expect(failure).not.toBeInstanceOf(RepositoryRefNotFoundError);
expect((failure as Error).message).toContain("did not match any file(s) known to git");
} finally {
if (previousPath === undefined) delete process.env.PATH; else process.env.PATH = previousPath;
await fs.rm(bin, { recursive: true, force: true });
}
});
it("materializes every repository-only project row inside the task workspace and reuses local edits", async () => {
const first = await createLocalSourceRepo();
const second = await createLocalSourceRepo();
@@ -15,7 +15,7 @@ import * as remoteWorkspaces from "../services/remote-workspaces.js";
import type { EnvironmentLease } from "@paperclipai/shared";
import { listLocalProjectRepositories } from "@paperclipai/adapter-utils/ssh";
import { readManagedWorkspaceRepositories, disposeGitWorkspaceSnapshot } from "@paperclipai/adapter-utils/git-workspace-sync";
import { captureDirectorySnapshot, disposeDirectorySnapshot } from "@paperclipai/adapter-utils/workspace-restore-merge";
import { captureDirectorySnapshot, disposeDirectorySnapshot, withDirectoryPublicationLock } from "@paperclipai/adapter-utils/workspace-restore-merge";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
const catalog = vi.hoisted(() => ({ available: true }));
@@ -173,6 +173,103 @@ const support = externalDatabaseUrl ? { supported: true } : await getEmbeddedPos
await rm(root, { recursive: true, force: true });
}
});
it("lets a missing requested ref be corrected before any checkout is published", async () => {
const root = await mkdtemp(path.join(tmpdir(), "paperclip-missing-ref-repository-"));
const previousPath = process.env.PATH;
const exec = promisify(execFileCallback);
try {
const realGit = (await exec("which", ["git"])).stdout.trim();
const source = path.join(root, "source"), taskRoot = path.join(root, "task"), bin = path.join(root, "bin");
await Promise.all([mkdir(source), mkdir(taskRoot), mkdir(bin)]);
const git = async (...args: string[]) => (await exec(realGit, ["-C", source, ...args])).stdout.trim();
// The repository's default branch is master; an agent asks for "main".
await git("init", "--initial-branch=master");
await writeFile(path.join(source, "source.txt"), "master source\n");
await git("add", ".");
await git("-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "-m", "source");
const pin = await git("rev-parse", "HEAD");
await writeFile(path.join(bin, "git"), `#!${process.execPath}\n` +
`const { spawnSync } = require('node:child_process'); const args = process.argv.slice(2);\n` +
`const remote = args.includes('clone') ? args.at(-2) : null;\n` +
`if (remote && remote !== 'https://github.com/team/source') process.exit(97);\n` +
`if (remote) args[args.length - 2] = ${JSON.stringify(source)};\n` +
`const result = spawnSync(${JSON.stringify(realGit)}, args, { stdio: 'inherit' });\n` +
`if (result.status) process.exit(result.status);\n` +
`if (remote) process.exit(spawnSync(${JSON.stringify(realGit)}, ['-C', args.at(-1), 'remote', 'set-url', 'origin', remote], { stdio: 'inherit' }).status ?? 98);\n` +
`process.exit(result.status ?? 99);\n`, { mode: 0o755 });
process.env.PATH = `${bin}${path.delimiter}${previousPath ?? ""}`;
const service = executionWorkspaceRepositoryService(db);
const [workspace] = await db.insert(executionWorkspaces).values({ companyId, name: "Missing ref root", cwd: taskRoot,
mode: "shared_workspace", strategyType: "task_directory" }).returning();
const [task] = await db.insert(issues).values({ companyId, title: "Missing ref", executionWorkspaceId: workspace.id }).returning();
const repository = { kind: "catalog" as const, id: "123" };
const intent = await service.request({ companyId, issueId: task.id, actor, request: { repository, ref: "main", requestKey: "missing-ref" } });
const checkout = path.join(taskRoot, intent.repository.relativePath);
const admission = () => prepareForAdmission({ companyId, issueId: task.id, workspaceId: workspace.id, cwd: taskRoot,
agentId, runId: randomUUID(), responsibleUserId: "local-board" });
const receipt = async () => (await db.select().from(executionWorkspaceRepositories)
.where(eq(executionWorkspaceRepositories.id, intent.operationId)))[0];
await expect(admission()).rejects.toThrow(/ref "main" does not exist/);
expect(await receipt()).toMatchObject({ state: "needs_repair", failureCode: "repository_ref_not_found", pinnedCommit: null });
expect(await lstat(checkout).catch(() => null)).toBeNull();
// Retrying with the same missing ref keeps the receipt; a corrected ref replaces it.
await expect(service.request({ companyId, issueId: task.id, actor, request: { repository, ref: "main", requestKey: "missing-ref" } }))
.resolves.toMatchObject({ operationId: intent.operationId });
// An admission that listed the receipt before the correction must use the corrected ref.
const stagingIgnore = path.join(taskRoot, ".paperclip-runtime", "repository-staging", ".gitignore");
await rm(stagingIgnore, { force: true });
let releaseLock!: () => void;
const lockHeld = new Promise<void>(resolve => { releaseLock = resolve; });
let lockAcquired!: () => void;
const acquired = new Promise<void>(resolve => { lockAcquired = resolve; });
const holder = withDirectoryPublicationLock(checkout, async () => { lockAcquired(); await lockHeld; });
await acquired;
const pending = admission();
try {
await vi.waitFor(async () => expect(await lstat(stagingIgnore).catch(() => null)).not.toBeNull());
const corrected = await service.request({ companyId, issueId: task.id, actor, request: { repository, ref: "master", requestKey: "corrected-ref" } });
expect(corrected.operationId).toBe(intent.operationId);
expect(await receipt()).toMatchObject({ requestedRef: "master", state: "pending", failureCode: null, pinnedCommit: null });
expect((await receipt()).requestKeys.sort()).toEqual(["corrected-ref", "missing-ref"]);
} finally { releaseLock(); await holder; }
await expect(pending).resolves.toEqual([expect.objectContaining({ id: intent.operationId, pinnedCommit: pin })]);
expect(await readFile(path.join(checkout, "source.txt"), "utf8")).toBe("master source\n");
expect(await receipt()).toMatchObject({ state: "ready", requestedRef: "master", pinnedCommit: pin, failureCode: null });
// Published files are never switched to another ref.
await expect(service.request({ companyId, issueId: task.id, actor, request: { repository, ref: "main", requestKey: "after-ready" } }))
.rejects.toThrow(/conflicts/);
} finally {
if (previousPath === undefined) delete process.env.PATH; else process.env.PATH = previousPath;
await rm(root, { recursive: true, force: true });
}
});
it("keeps a missing-ref receipt immutable when files exist at its checkout path", async () => {
const root = await mkdtemp(path.join(tmpdir(), "paperclip-missing-ref-occupied-"));
try {
const service = executionWorkspaceRepositoryService(db);
const [workspace] = await db.insert(executionWorkspaces).values({ companyId, name: "Occupied checkout root", cwd: root,
mode: "shared_workspace", strategyType: "task_directory" }).returning();
const [task] = await db.insert(issues).values({ companyId, title: "Occupied checkout", executionWorkspaceId: workspace.id }).returning();
const repository = { kind: "catalog" as const, id: "123" };
const intent = await service.request({ companyId, issueId: task.id, actor, request: { repository, ref: "main", requestKey: "occupied" } });
await db.update(executionWorkspaceRepositories).set({ state: "needs_repair", failureCode: "repository_ref_not_found" })
.where(eq(executionWorkspaceRepositories.id, intent.operationId));
await mkdir(path.join(root, intent.repository.relativePath), { recursive: true });
await expect(service.request({ companyId, issueId: task.id, actor, request: { repository, ref: "master", requestKey: "occupied-fix" } }))
.rejects.toThrow(/conflicts/);
// A failure that was not a missing ref also keeps the receipt.
await rm(path.join(root, intent.repository.relativePath), { recursive: true, force: true });
await db.update(executionWorkspaceRepositories).set({ failureCode: "repository_preparation_failed" })
.where(eq(executionWorkspaceRepositories.id, intent.operationId));
await expect(service.request({ companyId, issueId: task.id, actor, request: { repository, ref: "master", requestKey: "generic-fix" } }))
.rejects.toThrow(/conflicts/);
expect((await db.select().from(executionWorkspaceRepositories).where(eq(executionWorkspaceRepositories.id, intent.operationId)))[0])
.toMatchObject({ requestedRef: "main" });
} finally { await rm(root, { recursive: true, force: true }); }
});
it.skipIf(process.platform === "win32").each(["parent", "ignore_file"])("rejects a symlinked runtime staging %s before cloning", async (unsafeEntry) => {
const root = await mkdtemp(path.join(tmpdir(), "paperclip-unsafe-repository-staging-"));
try {
@@ -9,7 +9,7 @@ import { promisify } from "node:util";
import fs from "node:fs/promises";
import { constants as fsConstants } from "node:fs";
import path from "node:path";
import { and, eq, or, sql } from "drizzle-orm";
import { and, eq, isNull, or, sql } from "drizzle-orm";
import { activityLog, agents, heartbeatRuns, projects, projectWorkspaces, executionWorkspaceRepositories, executionWorkspaces, issues, type Db } from "@paperclipai/db";
import { prepareWorkspaceRepositorySchema, type PrepareWorkspaceRepository, type ProjectRepository } from "@paperclipai/shared";
import { withDirectoryPublicationLock } from "@paperclipai/adapter-utils/workspace-restore-merge";
@@ -22,7 +22,7 @@ import { captureRunIdentity } from "./run-identity.js";
import { toolAccessService } from "./tool-access.js";
import { normalizeProjectRepositoryUrl } from "./project-repositories.js";
import { buildGitAuthInvocation, createGitRemoteAuthProvider } from "./git-credentials.js";
import { materializeManagedProjectWorkspace, ensureManagedRepositoriesIgnored } from "./managed-repository-checkout.js";
import { materializeManagedProjectWorkspace, ensureManagedRepositoriesIgnored, RepositoryRefNotFoundError } from "./managed-repository-checkout.js";
import { requiresAnonymousRepositoryAcquisition, taskRepositoryRunColumns } from "./task-repository-acquisition.js";
import { canonicalRepositoryRoot } from "./execution-workspace-source-roots.js";
@@ -32,6 +32,8 @@ const execFile = promisify(execFileCallback);
type RepositoryRow = typeof executionWorkspaceRepositories.$inferSelect;
type DbTransaction = Parameters<Parameters<Db["transaction"]>[0]>[0];
const controllerAcquisitionMessage = "New controller repository acquisition requires a trusted task. Existing verified public checkouts remain available.";
const REPOSITORY_REF_NOT_FOUND = "repository_ref_not_found";
const repositoryRetryConflictMessage = "Repository preparation conflicts with an existing receipt. A different ref requires a separate workspace; existing files are never switched automatically.";
const lowTrustRepositoryMessage = "Low-trust repository reuse permits anonymous public URLs only; new acquisition is unavailable. Use an authorized configured source for private repositories.";
function withRepositoryPublication<T>(cwd: string, work: () => Promise<T>): Promise<T> {
@@ -73,6 +75,21 @@ function resolveAcquisitionSource(request: PrepareWorkspaceRepository["repositor
return resolveTaskRepository(request, anonymousOnly ? [] : available);
}
/**
* A receipt whose requested ref was proven absent can take a corrected ref only
* while nothing was ever published for it. Published files are never switched.
*/
async function refCorrectionAllowed(row: RepositoryRow, workspace: Pick<typeof executionWorkspaces.$inferSelect, "cwd" | "metadata">) {
if (row.state !== "needs_repair" || row.failureCode !== REPOSITORY_REF_NOT_FOUND || row.pinnedCommit) return false;
// A remote checkout cannot be inspected from the control plane; keep it immutable.
if (!workspace.cwd || readRemoteWorkspaceAuthority(workspace.metadata)) return false;
// Only proven absence allows correction; an unreadable path keeps the receipt.
return !await fs.lstat(path.join(workspace.cwd, row.relativePath)).catch((error: NodeJS.ErrnoException) => {
if (error.code === "ENOENT") return null;
throw error;
});
}
function assertAcquisitionReceipt(row: Pick<RepositoryRow, "catalogRepositoryId">, anonymousOnly: boolean) {
if (anonymousOnly && row.catalogRepositoryId) throw forbidden(lowTrustRepositoryMessage);
}
@@ -91,7 +108,7 @@ export function taskRepositoryRelativePath(identity: string) {
export function assertRepositoryRetryMatches(row: Pick<RepositoryRow, "repositoryIdentity" | "requestedRef">, identity: string, ref: string) {
if (row.repositoryIdentity !== identity || row.requestedRef !== ref) {
throw conflict("Repository preparation conflicts with an existing receipt. A different ref requires a separate workspace; existing files are never switched automatically.");
throw conflict(repositoryRetryConflictMessage);
}
}
@@ -163,10 +180,20 @@ export function executionWorkspaceRepositoryService(db: Db) {
if (!workspace.cwd) throw conflict("The task workspace has no directory for repository preparation");
if (anonymousOnly) throw forbidden(controllerAcquisitionMessage);
const existing = await measureRunPhase("workspace.sources.inventory", async () => tx.select().from(executionWorkspaceRepositories).where(and(eq(executionWorkspaceRepositories.companyId, input.companyId), eq(executionWorkspaceRepositories.executionWorkspaceId, workspace.id), or(eq(executionWorkspaceRepositories.repositoryIdentity, source.repositoryIdentity), sql`${executionWorkspaceRepositories.requestKeys} ? ${parsed.requestKey}`))));
for (const row of existing) {
assertAcquisitionReceipt(row, anonymousOnly);
assertRepositoryRetryMatches(row, source.repositoryIdentity, requestedRef);
for (const row of existing) assertAcquisitionReceipt(row, anonymousOnly);
if (existing.length === 1 && existing[0].repositoryIdentity === source.repositoryIdentity && existing[0].requestedRef !== requestedRef
&& await refCorrectionAllowed(existing[0], workspace)) {
const row = existing[0];
const [corrected] = await measureRunPhase("workspace.write", () => tx.update(executionWorkspaceRepositories).set({
requestedRef, state: "pending", failureCode: null, updatedAt: new Date(),
requestKeys: row.requestKeys.includes(parsed.requestKey) ? row.requestKeys : [...row.requestKeys, parsed.requestKey],
}).where(and(eq(executionWorkspaceRepositories.id, row.id), eq(executionWorkspaceRepositories.state, "needs_repair"),
eq(executionWorkspaceRepositories.requestedRef, row.requestedRef), isNull(executionWorkspaceRepositories.pinnedCommit))).returning());
if (!corrected) throw conflict(repositoryRetryConflictMessage);
await measureRunPhase("workspace.write", () => tx.insert(activityLog).values({ companyId: input.companyId, actorType: input.actor.type === "agent" ? "agent" : "user", actorId: input.actor.agentId ?? input.actor.userId ?? "local-board", action: "workspace.repository.ref_corrected", entityType: "execution_workspace", entityId: workspace.id, runId: input.actor.runId ?? null, responsibleUserId: userId, details: { repositoryId: row.id, issueId: input.issueId, previousRef: row.requestedRef, requestedRef, applies: "next_normal_admission" } }));
return corrected;
}
for (const row of existing) assertRepositoryRetryMatches(row, source.repositoryIdentity, requestedRef);
if (existing[0]) {
const row = existing[0];
if (!row.requestKeys.includes(parsed.requestKey)) {
@@ -426,11 +453,18 @@ export function executionWorkspaceRepositoryService(db: Db) {
return stagingParent;
});
const prepared: Array<{ id: string; cwd: string; repoUrl: string; relativePath: string; pinnedCommit: string; branchName: string | null }> = [];
for (const [index, row] of rows.entries()) {
for (const [index, listedRow] of rows.entries()) {
const source = sources[index];
if (source.repositoryIdentity !== row.repositoryIdentity || row.relativePath !== taskRepositoryRelativePath(row.repositoryIdentity)) throw conflict("Repository preparation receipt does not match its source");
const cwd = path.join(root, row.relativePath);
if (source.repositoryIdentity !== listedRow.repositoryIdentity || listedRow.relativePath !== taskRepositoryRelativePath(listedRow.repositoryIdentity)) throw conflict("Repository preparation receipt does not match its source");
const cwd = path.join(root, listedRow.relativePath);
await withRepositoryPublication(cwd, async () => {
// A ref correction can commit after the receipts were listed. Use the
// current receipt so admission never checks out a discarded ref.
const [row] = await measureRunPhase("workspace.repository.receipts", () => db.select().from(executionWorkspaceRepositories)
.where(and(eq(executionWorkspaceRepositories.id, listedRow.id), eq(executionWorkspaceRepositories.companyId, input.companyId))));
if (!row || row.repositoryIdentity !== listedRow.repositoryIdentity || row.relativePath !== listedRow.relativePath) {
throw conflict("Repository preparation receipt changed during admission");
}
const existing = await measureRunPhase("workspace.repository.checkout_identity", async () => {
const existing = await fs.lstat(cwd).catch(() => null);
if (existing && (!existing.isDirectory() || existing.isSymbolicLink() || await fs.realpath(cwd) !== cwd)) throw conflict("Repository checkout path is not a contained directory");
@@ -444,7 +478,13 @@ export function executionWorkspaceRepositoryService(db: Db) {
});
const anonymous = buildGitAuthInvocation({ token: "", source: "managed_connection", secretName: null });
anonymous.env = { ...anonymous.env, GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_SYSTEM: "/dev/null" };
if (!acquisitionRestricted) await measureRunPhase("workspace.repository.state_write", () => db.update(executionWorkspaceRepositories).set({ state: "preparing", catalogRepositoryId: source.catalogRepositoryId, failureCode: null, updatedAt: new Date() }).where(eq(executionWorkspaceRepositories.id, row.id)));
if (!acquisitionRestricted) {
// Claim the receipt at the ref this admission uses. Correction only
// applies to needs_repair receipts, so it cannot change a claimed ref.
const [claimed] = await measureRunPhase("workspace.repository.state_write", () => db.update(executionWorkspaceRepositories).set({ state: "preparing", catalogRepositoryId: source.catalogRepositoryId, failureCode: null, updatedAt: new Date() })
.where(and(eq(executionWorkspaceRepositories.id, row.id), eq(executionWorkspaceRepositories.requestedRef, row.requestedRef))).returning({ id: executionWorkspaceRepositories.id }));
if (!claimed) throw conflict("Repository preparation receipt changed during admission");
}
try {
const ownershipPath = path.join(cwd, ".git", "paperclip-workspace-owner.json");
const readOwnership = () => measureRunPhase("workspace.repository.ownership_read", async () => {
@@ -521,7 +561,8 @@ export function executionWorkspaceRepositoryService(db: Db) {
await measureRunPhase("workspace.repository.state_write", () => db.update(executionWorkspaceRepositories).set({ state: "ready", pinnedCommit, branchName, failureCode: null, updatedAt: new Date() }).where(eq(executionWorkspaceRepositories.id, row.id)));
prepared.push({ id: row.id, cwd, repoUrl: row.repoUrl, relativePath: row.relativePath, pinnedCommit, branchName });
} catch (error) {
await measureRunPhase("workspace.repository.state_write", () => db.update(executionWorkspaceRepositories).set({ state: "needs_repair", failureCode: "repository_preparation_failed", updatedAt: new Date() }).where(eq(executionWorkspaceRepositories.id, row.id)));
const failureCode = error instanceof RepositoryRefNotFoundError ? REPOSITORY_REF_NOT_FOUND : "repository_preparation_failed";
await measureRunPhase("workspace.repository.state_write", () => db.update(executionWorkspaceRepositories).set({ state: "needs_repair", failureCode, updatedAt: new Date() }).where(eq(executionWorkspaceRepositories.id, row.id)));
throw error;
}
});
@@ -14,6 +14,30 @@ import { resolveRepositoryObjectCache, readRepositoryObjectCache, publishReposit
const execFile = promisify(execFileCallback);
const MANAGED_WORKSPACE_GIT_CLONE_TIMEOUT_MS = 10 * 60 * 1000;
/** The requested branch, tag, or commit is absent from a complete fresh clone. */
export class RepositoryRefNotFoundError extends Error {
constructor(message: string, readonly requestedRef: string) {
super(message);
this.name = "RepositoryRefNotFoundError";
}
}
/**
* True only when Git reported an unknown ref and the clone positively cannot
* resolve it. `rev-parse --verify --quiet` exits 1 for an unknown name; any
* other failure (auth, corruption, timeout) is not evidence of a missing ref.
*/
async function isMissingRef(cloneCwd: string, ref: string, checkoutError: unknown): Promise<boolean> {
const stderr = String((checkoutError as { stderr?: unknown } | null)?.stderr ?? "");
if (!/did not match any file\(s\) known to git|invalid reference/.test(stderr)) return false;
for (const candidate of [`${ref}^{commit}`, `refs/remotes/origin/${ref}^{commit}`]) {
const exitCode = await execFile("git", ["-C", cloneCwd, "rev-parse", "--verify", "--quiet", "--end-of-options", candidate], { timeout: 30_000 })
.then(() => 0, (error: { code?: unknown }) => error.code);
if (exitCode !== 1) return false;
}
return true;
}
export async function materializeManagedProjectWorkspace(
cwd: string,
input: {
@@ -122,7 +146,21 @@ export async function materializeManagedProjectWorkspace(
}
await execFile("git", ["-C", cloneTmpDir, "remote", "set-url", "origin", input.repoUrl], { timeout: 10_000 });
} else if (input.repoRef) {
await measureRunPhase("workspace.checkout.git_ref", () => execFile("git", ["-C", cloneTmpDir, "checkout", input.repoRef!], { timeout: MANAGED_WORKSPACE_GIT_CLONE_TIMEOUT_MS }));
const repoRef = input.repoRef;
try {
await measureRunPhase("workspace.checkout.git_ref", () => execFile("git", ["-C", cloneTmpDir, "checkout", repoRef], {
// Untranslated Git messages keep missing-ref classification locale independent.
timeout: MANAGED_WORKSPACE_GIT_CLONE_TIMEOUT_MS, env: { ...process.env, LC_ALL: "C", LANGUAGE: "C" },
}));
} catch (error) {
// Only a ref that a complete fresh clone cannot resolve is a missing ref;
// every other checkout failure keeps its original cause.
if (!await isMissingRef(cloneTmpDir, repoRef, error)) throw error;
throw new RepositoryRefNotFoundError(
`Requested ref "${repoRef}" does not exist in the repository. Request the repository again with an existing branch, tag, or commit.`,
repoRef,
);
}
}
await input.beforePublish?.(cloneTmpDir);
} catch (error) {
@@ -138,6 +176,7 @@ export async function materializeManagedProjectWorkspace(
`Failed to prepare managed checkout for "${input.repoUrl}" at "${cwd}": ${reason}${authNote ? ` ${authNote}` : ""}`,
);
if (isWorkspaceGitScanError(error)) throw new WorkspaceGitScanError(error.code, message);
if (error instanceof RepositoryRefNotFoundError) throw new RepositoryRefNotFoundError(message, error.requestedRef);
const connectionFailure = readGitConnectionFailure(error);
if (connectionFailure) throw new GitConnectionFailureError(message, connectionFailure);
throw new Error(message);