mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 21:35:36 +01:00
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:
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);
|
||||
|
||||
Reference in new issue
Block a user