diff --git a/doc/plans/2026-10-20-dependable-everyday-work.md b/doc/plans/2026-10-20-dependable-everyday-work.md index 37dfea634ce..e1ce750356d 100644 --- a/doc/plans/2026-10-20-dependable-everyday-work.md +++ b/doc/plans/2026-10-20-dependable-everyday-work.md @@ -96,3 +96,18 @@ E2E support checks pass 38. Server, runner, and E2E typechecks pass. Copilot v54 binds the changed shared completion guidance; v53 remains a historical identity. The three development pilot failures remain retained. These checks do not count as the required staging matrix, and a fresh provider run remains necessary. + +The fourth development attempt reached a successor through a monitor fallback, +then failed before provider startup while applying Git exclusions to the task +root. It is a failure of the required same-agent journey, even though a successor +started. The log records Git's exact non-repository error. A real-Git and database +reproduction shows the same error for a declared plain task directory containing +inert Git metadata. The original attempt did not retain that metadata's contents +or establish which component created it. + +The next narrow repair lets only an explicitly declared plain task root proceed +past Git's exact non-repository result. It leaves the existing files intact. +Configured checkouts keep strict validation, real Git roots still receive the +exclusion, linked metadata stays rejected, and missing Git stays an error. The +repair's Git and authorization cohort passes locally. Fresh provider +remeasurement and the monitor-fallback defect remain open. diff --git a/server/src/__tests__/managed-repository-checkout.test.ts b/server/src/__tests__/managed-repository-checkout.test.ts index 26c10afa7b2..2767407786e 100644 --- a/server/src/__tests__/managed-repository-checkout.test.ts +++ b/server/src/__tests__/managed-repository-checkout.test.ts @@ -1,14 +1,14 @@ import { execFile as execFileCallback } from "node:child_process"; import { promisify } from "node:util"; -import { mkdtemp, mkdir, writeFile, readFile, readdir, rm, stat } from "node:fs/promises"; +import { mkdtemp, mkdir, writeFile, readFile, readdir, rm, stat, symlink } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { materializeManagedProjectWorkspace, ensureManagedRepositoriesIgnored } from "../services/managed-repository-checkout.js"; const execFile = promisify(execFileCallback); const directories: string[] = []; -afterEach(async () => { await Promise.all(directories.splice(0).map(dir => rm(dir, { recursive: true, force: true }))); }); +afterEach(async () => { vi.unstubAllEnvs(); await Promise.all(directories.splice(0).map(dir => rm(dir, { recursive: true, force: true }))); }); async function fixture() { const root = await mkdtemp(path.join(tmpdir(), "paperclip-repository-publication-")); directories.push(root); @@ -33,6 +33,34 @@ describe("atomic managed repository publication", () => { await ensureManagedRepositoriesIgnored(source); expect(await readFile(path.join(source, ".git", "info", "exclude"), "utf8")).toContain("/.paperclip-repositories/"); }); + it("allows a declared plain task directory with inert Git metadata without changing its files", async () => { + const { target } = await fixture(); + await mkdir(path.join(target, ".git", "info"), { recursive: true }); + const file = path.join(target, ".git", "info", "exclude"); + await writeFile(file, "/.paperclip-repositories/\n"); + await expect(ensureManagedRepositoriesIgnored(target, { plainTaskDirectory: true })).resolves.toBeUndefined(); + expect(await readFile(file, "utf8")).toBe("/.paperclip-repositories/\n"); + await expect(ensureManagedRepositoriesIgnored(target)).rejects.toThrow("not a git repository"); + }); + it("still excludes managed repositories when a task directory contains a real checkout", async () => { + const { source } = await fixture(); + await ensureManagedRepositoriesIgnored(source, { plainTaskDirectory: true }); + expect(await readFile(path.join(source, ".git", "info", "exclude"), "utf8")).toContain("/.paperclip-repositories/"); + }); + it("retains unexpected Git errors for declared plain task directories", async () => { + const { root, target } = await fixture(); + await mkdir(path.join(target, ".git"), { recursive: true }); + vi.stubEnv("PATH", path.join(root, "missing-git")); + await expect(ensureManagedRepositoriesIgnored(target, { plainTaskDirectory: true })).rejects.toMatchObject({ code: "ENOENT" }); + }); + it("rejects linked Git metadata even for a declared plain task directory", async () => { + const { source, target } = await fixture(); + await mkdir(target); + await symlink(path.join(source, ".git"), path.join(target, ".git"), "dir"); + const original = await readFile(path.join(source, ".git", "info", "exclude"), "utf8"); + await expect(ensureManagedRepositoriesIgnored(target, { plainTaskDirectory: true })).rejects.toThrow("must not be a symlink"); + expect(await readFile(path.join(source, ".git", "info", "exclude"), "utf8")).toBe(original); + }); it("writes the pinned receipt inside the clone before making it visible", async () => { const { source, target } = await fixture(); const result = await materializeManagedProjectWorkspace(target, { repoUrl: "https://github.com/test/source", localSource: source, beforePublish: async clone => { diff --git a/server/src/__tests__/task-workspace-repositories-trust.test.ts b/server/src/__tests__/task-workspace-repositories-trust.test.ts index b33426f6a56..00e5587f823 100644 --- a/server/src/__tests__/task-workspace-repositories-trust.test.ts +++ b/server/src/__tests__/task-workspace-repositories-trust.test.ts @@ -33,7 +33,7 @@ const support = await getEmbeddedPostgresTestSupport(); let db: ReturnType; let root: string; beforeAll(async () => { ({ db, root } = await context.setup()); }, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS); - afterEach(() => { vi.clearAllMocks(); vi.restoreAllMocks(); }); + afterEach(() => { acquisition.materialize.mockReset(); vi.clearAllMocks(); vi.restoreAllMocks(); }); afterAll(context.cleanup); const { fixture, lowTrustPeer, standardPeer, privateTask, localPrivateClone } = context; @@ -334,7 +334,7 @@ const support = await getEmbeddedPostgresTestSupport(); expect(acquisition.materialize).not.toHaveBeenCalled(); }); - it.each(["sponsored", "local_board", "local_board_jwt", "routine"] as const)("prepares public Git from an idle workspace for a trusted %s task and reuses verified bytes after trust downgrade", async principal => { + it.each(["sponsored", "sponsored_git_residue", "local_board", "local_board_jwt", "routine"] as const)("prepares public Git from an idle workspace for a trusted %s task and reuses verified bytes after trust downgrade", async principal => { const f = await fixture(); await db.update(executionWorkspaces).set({ status: "idle" }).where(eq(executionWorkspaces.id, f.workspace.id)); const localBoard = principal === "local_board" || principal === "local_board_jwt"; @@ -367,6 +367,10 @@ const support = await getEmbeddedPostgresTestSupport(); await exec("git", ["-C", cwd, "remote", "set-url", "origin", input.repoUrl]); return result; }); + if (principal === "sponsored_git_residue") { + await mkdir(path.join(f.admission.cwd, ".git", "info"), { recursive: true }); + await writeFile(path.join(f.admission.cwd, ".git", "info", "exclude"), "/.paperclip-repositories/\n"); + } const [prepared] = await f.service.prepareForAdmission(admission); expect(prepared.id).toBe(receipt.operationId); expect(await readFile(path.join(prepared.cwd, "public.txt"), "utf8")).toBe("Public fixture bytes\n"); diff --git a/server/src/services/execution-workspace-repositories.ts b/server/src/services/execution-workspace-repositories.ts index f0eec1af1b3..4c2cb2c07ed 100644 --- a/server/src/services/execution-workspace-repositories.ts +++ b/server/src/services/execution-workspace-repositories.ts @@ -318,7 +318,7 @@ export function executionWorkspaceRepositoryService(db: Db) { return candidatePin; } async function prepareForAdmission(input: { companyId: string; issueId: string; workspaceId: string; cwd: string; agentId: string; runId: string; responsibleUserId: string | null }) { - const { acquisitionRestricted } = await publicationBinding(db, input, false); + const { acquisitionRestricted, workspace } = await publicationBinding(db, input, false); const rows = await list(input.companyId, input.workspaceId); if (!rows.length) return []; await publicationBinding(db, input); @@ -361,7 +361,9 @@ export function executionWorkspaceRepositoryService(db: Db) { await fs.writeFile(ignoreTemporary, "*\n", { flag: "wx", mode: 0o600 }); await fs.rename(ignoreTemporary, ignorePath); } finally { await fs.rm(ignoreTemporary, { force: true }); } - await measureRunPhase("workspace.repository.ignore_rules", () => ensureManagedRepositoriesIgnored(root)); + await measureRunPhase("workspace.repository.ignore_rules", () => ensureManagedRepositoriesIgnored(root, { + plainTaskDirectory: workspace.strategyType === "task_directory" && !workspace.repoUrl && !workspace.projectWorkspaceId, + })); } return stagingParent; }); diff --git a/server/src/services/managed-repository-checkout.ts b/server/src/services/managed-repository-checkout.ts index 3d5dd027c0f..3d18bb49769 100644 --- a/server/src/services/managed-repository-checkout.ts +++ b/server/src/services/managed-repository-checkout.ts @@ -162,12 +162,23 @@ export async function materializeManagedProjectWorkspace( } -export async function ensureManagedRepositoriesIgnored(cwd: string): Promise { +export async function ensureManagedRepositoriesIgnored(cwd: string, options: { plainTaskDirectory?: boolean } = {}): Promise { const gitEntry = await fs.lstat(path.join(cwd, ".git")).catch(() => null); if (!gitEntry) return; if (gitEntry.isSymbolicLink()) throw new Error("Workspace Git metadata must not be a symlink"); - const excludePath = await execFile("git", ["-C", cwd, "rev-parse", "--git-path", "info/exclude"], { timeout: 10_000 }) - .then((result) => path.resolve(cwd, result.stdout.trim())); + let excludePath: string; + try { + const result = await execFile("git", ["-C", cwd, "rev-parse", "--git-path", "info/exclude"], { + timeout: 10_000, env: { ...process.env, LC_ALL: "C" }, + }); + excludePath = path.resolve(cwd, result.stdout.trim()); + } catch (error) { + const failure = error as { code?: unknown; signal?: unknown; stderr?: unknown }; + if (options.plainTaskDirectory && failure.code === 128 && failure.signal == null && + failure.stderr === "fatal: not a git repository (or any of the parent directories): .git\n" && + !process.env.GIT_DIR && !process.env.GIT_WORK_TREE && !process.env.GIT_COMMON_DIR) return; + throw error; + } const exclude = await fs.readFile(excludePath, "utf8").catch(() => ""); if (!exclude.split(/\r?\n/).includes(`/${PROJECT_REPOSITORIES_DIR}/`)) { await fs.mkdir(path.dirname(excludePath), { recursive: true });