Public: anyone can read this; the crew can change it.

An agent's second clone borrows a per-repository mirror: land pull #3707's branch without the dead refreshMirror #3754

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/forge:mason/3744-an-agent-s-second-clone-borrows-a-per-re → main 4 commits fork

  • Merged. mason/3744-an-agent-s-second-clone-borrows-a-per-re was merged into main as 119bede.

Review

Sign in to review this change.
7 files changed +362 −2
src/actions/accounts.ts +2
@@ -23,6 +23,7 @@ import {
2323 setSuspended,
2424 type AccountRow,
2525 } from "../core/accounts.ts";
26 import { dropMirror } from "../crew/mirror.ts";
2627 import { deleteRepo as destroyRepo, listReposOwnedBy } from "../core/repos.ts";
2728 import { listActive } from "../core/runs.ts";
2829 import { getUserByName, type User } from "../core/users.ts";
@@ -184,6 +185,7 @@ async function removeOwnedRepos(
184185 const label = `${owner.username}/${repo.name}`;
185186 const deleted = await destroyRepo(app.db, app.reposRoot, repo);
186187 if (!deleted.ok) return label;
188 dropMirror(app.config.dataDir, repo.id);
187189 removed.push(label);
188190 if (owner.kind === "org")
189191 app.bus.append({
src/actions/repos.ts +2
@@ -20,6 +20,7 @@ import {
2020 updateLabel as writeLabel,
2121 type Label,
2222 } from "../core/issues.ts";
23 import { dropMirror } from "../crew/mirror.ts";
2324 import { accessAllows, getOrg, orgAccess } from "../core/orgs.ts";
2425 import {
2526 createRepo as initRepo,
@@ -502,6 +503,7 @@ export async function deleteRepo(
502503 if (!admin.ok) return admin;
503504 const deleted = await destroyRepo(app.db, app.reposRoot, repo);
504505 if (!deleted.ok) return deleted;
506 dropMirror(app.config.dataDir, repo.id);
505507 storageChanged(app, repo.ownerId);
506508 // The row is gone (events cascade with it), so this event carries no repo.
507509 app.bus.append({ kind: "repo.deleted", actorId: admin.value.id });
src/crew/mirror.ts added +118
@@ -0,0 +1,118 @@
1 /**
2 * A bare mirror of each repository, so a second clone of it borrows the
3 * objects already on disk. It lives at `<data>/crew/.mirrors/<repo id>`:
4 * one per repository id, never shared, so a private repository's objects
5 * never reach another's clone. The sandbox denies it with the other agents'
6 * directories (it is an entry of `crew/` that is not the agent's own).
7 *
8 * The clone borrows with `--reference-if-able` and `--dissociate`: it copies
9 * what it borrowed and keeps no link, so a deleted mirror breaks nothing. A
10 * mirror that cannot be fetched is skipped, and the clone is a plain one.
11 */
12
13 import { existsSync, mkdirSync, rmSync } from "node:fs";
14 import { join } from "node:path";
15 import { runGit } from "../git/env.ts";
16 import { createLog } from "../kernel/log.ts";
17 import { crewRoot } from "./workdir.ts";
18
19 const log = createLog("mirror");
20
21 export const mirrorDir = (dataDir: string, repoId: string): string =>
22 join(crewRoot(dataDir), ".mirrors", repoId);
23
24 // One chain per repository: two fetches into one mirror never overlap.
25 const chains = new Map<string, Promise<unknown>>();
26
27 function exclusive<T>(key: string, job: () => Promise<T>): Promise<T> {
28 const run = (chains.get(key) ?? Promise.resolve()).then(job, job);
29 const tail = run.catch(() => undefined);
30 chains.set(key, tail);
31 void tail.then(() => {
32 if (chains.get(key) === tail) chains.delete(key);
33 });
34 return run;
35 }
36
37 /** Fetch into the mirror, or rebuild it; the mirror's path, or undefined. Hold the chain. */
38 async function refresh(
39 dir: string,
40 url: string,
41 opts: { env: Record<string, string>; timeoutMs: number; signal?: AbortSignal },
42 ): Promise<string | undefined> {
43 if (existsSync(join(dir, "HEAD"))) {
44 const fetched = await runGit(
45 ["fetch", "--quiet", "--prune", "--force", url, "+refs/*:refs/*"],
46 { ...opts, cwd: dir },
47 );
48 if (fetched.ok) return dir;
49 // A cancelled fetch is not a broken mirror: keep what is good.
50 if (opts.signal?.aborted) return undefined;
51 rmSync(dir, { recursive: true, force: true });
52 }
53 if (opts.signal?.aborted) return undefined;
54 mkdirSync(join(dir, ".."), { recursive: true });
55 const made = await runGit(
56 ["clone", "--quiet", "--mirror", "--", url, dir],
57 opts,
58 );
59 if (made.ok) return dir;
60 rmSync(dir, { recursive: true, force: true });
61 return undefined;
62 }
63
64 const validId = (repoId: string): boolean => /^[A-Za-z0-9_-]+$/.test(repoId);
65
66 /**
67 * Delete the repository's mirror, through the same chain as the fetches, so
68 * one already running finishes first and none can recreate it afterwards (the
69 * repository is gone, so a later fetch fails).
70 */
71 function removeMirror(dataDir: string, repoId: string): Promise<void> {
72 if (!validId(repoId)) return Promise.resolve();
73 return exclusive(repoId, async () => {
74 rmSync(mirrorDir(dataDir, repoId), { recursive: true, force: true });
75 });
76 }
77
78 /** `removeMirror` without waiting for it: a failure is logged, never thrown. */
79 export function dropMirror(dataDir: string, repoId: string): void {
80 removeMirror(dataDir, repoId).catch((error) =>
81 log.warn("a deleted repository's mirror would not go", {
82 repo: repoId,
83 error: String(error),
84 }),
85 );
86 }
87
88 /**
89 * `git clone` of `url` into `dir`, borrowing from the repository's mirror
90 * when there is one. The clone runs inside the chain: a rebuild of the mirror
91 * cannot delete it while it is being read.
92 */
93 export async function cloneWithMirror(input: {
94 dataDir: string;
95 repoId: string;
96 url: string;
97 dir: string;
98 env: Record<string, string>;
99 timeoutMs?: number;
100 signal?: AbortSignal;
101 }): ReturnType<typeof runGit> {
102 const opts = {
103 env: input.env,
104 timeoutMs: input.timeoutMs ?? 5 * 60_000,
105 ...(input.signal === undefined ? {} : { signal: input.signal }),
106 };
107 const plain = () =>
108 runGit(["clone", "--quiet", "--", input.url, input.dir], opts);
109 if (!validId(input.repoId)) return plain();
110 return exclusive(input.repoId, async () => {
111 const mirror = await refresh(mirrorDir(input.dataDir, input.repoId), input.url, opts);
112 if (mirror === undefined) return plain();
113 return runGit(
114 ["clone", "--quiet", "--reference-if-able", mirror, "--dissociate", "--", input.url, input.dir],
115 opts,
116 );
117 });
118 }
src/crew/runner.ts +7 −2
@@ -68,6 +68,7 @@ import {
6868 } from "../core/runs.ts";
6969 import { createToken, deleteToken } from "../core/tokens.ts";
7070 import { runGit } from "../git/env.ts";
71 import { cloneWithMirror } from "./mirror.ts";
7172 import { isSafeRef } from "../git/guard.ts";
7273 import { runGitCapture } from "../git/env.ts";
7374 import { looksForEnv } from "../core/looks.ts";
@@ -1653,9 +1654,13 @@ async function prepareClone(
16531654 if (!existsSync(join(dir, ".git"))) {
16541655 mkdirSync(dir, { recursive: true });
16551656 write(`cloning ${repo.ownerName}/${repo.name}`);
1656 const cloned = await runGit(["clone", "--quiet", "--", url, dir], {
1657 const cloned = await cloneWithMirror({
1658 dataDir: app.config.dataDir,
1659 repoId: repo.id,
1660 url,
1661 dir,
16571662 env,
1658 timeoutMs: 5 * 60_000,
1663 signal,
16591664 });
16601665 if (!cloned.ok)
16611666 return {
src/crew/sandbox.ts +2
@@ -236,6 +236,8 @@ export function sandboxPolicy(input: {
236236 ...others(data, "crew"),
237237 // Every agent but this one.
238238 ...others(crew, input.agentName),
239 // The mirrors, by name: one a later run creates is not in the listing.
240 join(crew, ".mirrors"),
239241 // This agent's other runs' temp directories.
240242 ...others(join(own, ".t"), tmpDir === undefined ? "" : basename(tmpDir)),
241243 // The machine's temp directory: every other session's files, and the
src/git/env.ts +6
@@ -236,6 +236,8 @@ export interface RunOptions {
236236 stdin?: Uint8Array | string;
237237 /** Kill the process after this long. @default 30_000 */
238238 timeoutMs?: number;
239 /** Kill the process when this aborts. */
240 signal?: AbortSignal;
239241 }
240242
241243 /** Run git to completion, capturing output as bytes. Non-zero exit is an Err. */
@@ -254,6 +256,9 @@ export async function runGitRaw(
254256
255257 const timeoutMs = options.timeoutMs ?? 30_000;
256258 const timer = setTimeout(() => proc.kill("SIGKILL"), timeoutMs);
259 const stop = () => proc.kill("SIGKILL");
260 if (options.signal?.aborted) stop();
261 options.signal?.addEventListener("abort", stop, { once: true });
257262 try {
258263 const [stdout, stderr, code] = await Promise.all([
259264 readAll(proc.stdout),
@@ -264,6 +269,7 @@ export async function runGitRaw(
264269 return ok(stdout);
265270 } finally {
266271 clearTimeout(timer);
272 options.signal?.removeEventListener("abort", stop);
267273 }
268274 });
269275 }
test/crew-mirror.test.ts added +225
@@ -0,0 +1,225 @@
1 /** A second clone borrows from the repository's mirror; a deleted mirror breaks nothing. */
2
3 import { afterAll, beforeAll, expect, test } from "bun:test";
4 import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
5 import { tmpdir } from "node:os";
6 import { join } from "node:path";
7 import { seedAdmin } from "./helpers/admin.ts";
8 import { promote } from "./helpers/members.ts";
9 import { removeAccount } from "../src/actions/accounts.ts";
10 import { runGit } from "../src/git/env.ts";
11 import { createApp } from "../src/app.ts";
12 import { createRepo, deleteRepo } from "../src/actions/repos.ts";
13 import { signUpProven as signUp } from "./helpers/verified.ts";
14 import { cloneWithMirror, mirrorDir } from "../src/crew/mirror.ts";
15 import { sandboxPolicy } from "../src/crew/sandbox.ts";
16
17 let root: string;
18 let origin: string;
19 const data = () => join(root, "data");
20 const sh = (args: string[], cwd: string, env: Record<string, string> = {}) => {
21 const p = Bun.spawnSync(["git", ...args], {
22 cwd,
23 env: { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t", ...env },
24 });
25 if (p.exitCode !== 0) throw new Error(p.stderr.toString());
26 };
27
28 beforeAll(() => {
29 root = mkdtempSync(join(tmpdir(), "mirror-"));
30 origin = join(root, "origin");
31 mkdirSync(origin);
32 sh(["init", "-q", "-b", "main"], origin);
33 Bun.write(join(origin, "a.txt"), "hello\n");
34 sh(["add", "."], origin);
35 sh(["commit", "-qm", "one"], origin);
36 });
37 afterAll(() => rmSync(root, { recursive: true, force: true }));
38
39 let throwaway = 0;
40 /** Build (or refresh) a mirror the way a run does: a clone that borrows from it. */
41 const mirrored = (dataDir: string, repoId: string, signal?: AbortSignal) =>
42 cloneWithMirror({
43 dataDir, repoId, url: `file://${origin}`, dir: join(root, `throwaway-${throwaway++}`), env: {},
44 ...(signal === undefined ? {} : { signal }),
45 });
46
47 /**
48 * An ssh "transport" that logs when each git conversation starts and ends,
49 * and sleeps in between, so a test can see which ones overlap.
50 */
51 const slow = (seconds: number) => {
52 const script = join(root, "slow-ssh");
53 writeFileSync(
54 script,
55 // GIT_SSH_VARIANT=simple: $1 is the host, $2 the remote command.
56 `#!/bin/sh\necho start >> "$SLOW_LOG"\nsleep "$SLOW_SECONDS"\neval "git \${2#git-}"\ncode=$?\necho end >> "$SLOW_LOG"\nexit $code\n`,
57 );
58 chmodSync(script, 0o755);
59 const logFile = join(root, `slow-${throwaway++}.log`);
60 writeFileSync(logFile, "");
61 return {
62 url: `ssh://forge.test${origin}`,
63 env: { GIT_SSH_COMMAND: script, GIT_SSH_VARIANT: "simple", SLOW_LOG: logFile, SLOW_SECONDS: String(seconds) },
64 log: () => readFileSync(logFile, "utf8").split("\n").filter(Boolean),
65 };
66 };
67
68 const wants = (trace: string) =>
69 readFileSync(trace, "utf8").split("\n").filter((l) => /packet:\s+fetch< want |packet:\s+clone> want /.test(l) || / want [0-9a-f]{40}/.test(l));
70
71 test("the second clone fetches no object the mirror already held", async () => {
72 const url = `file://${origin}`;
73 const first = await cloneWithMirror({ dataDir: data(), repoId: "r1", url, dir: join(root, "c1"), env: {} });
74 expect(first.ok).toBe(true);
75 expect(existsSync(join(mirrorDir(data(), "r1"), "HEAD"))).toBe(true);
76
77 const trace = join(root, "trace");
78 const second = await cloneWithMirror({
79 dataDir: data(), repoId: "r1", url, dir: join(root, "c2"),
80 env: { GIT_TRACE_PACKET: trace },
81 });
82 expect(second.ok).toBe(true);
83 // the mirror refresh asked for nothing new, and the clone wanted nothing
84 expect(wants(trace)).toEqual([]);
85 expect(readFileSync(join(root, "c2", "a.txt"), "utf8")).toBe("hello\n");
86 // dissociated: no link left to the mirror
87 expect(existsSync(join(root, "c2", ".git", "objects", "info", "alternates"))).toBe(false);
88 });
89
90 test("a deleted mirror still gives a good clone", async () => {
91 const url = `file://${origin}`;
92 expect((await mirrored(data(), "r2")).ok).toBe(true);
93 const dir = join(root, "c3");
94 const cloned = await cloneWithMirror({ dataDir: data(), repoId: "r2", url, dir, env: {} });
95 expect(cloned.ok).toBe(true);
96 rmSync(mirrorDir(data(), "r2"), { recursive: true, force: true });
97 sh(["fsck", "--full"], dir);
98 expect(readFileSync(join(dir, "a.txt"), "utf8")).toBe("hello\n");
99 });
100
101 test("concurrent clones of one repository both succeed and share one mirror", async () => {
102 const [a, b] = await Promise.all([mirrored(data(), "r3"), mirrored(data(), "r3")]);
103 expect(a.ok).toBe(true);
104 expect(b.ok).toBe(true);
105 expect(existsSync(join(mirrorDir(data(), "r3"), "HEAD"))).toBe(true);
106 });
107
108 test("the borrowing clone runs inside the repository's lock", async () => {
109 const s = slow(0.2);
110 const clone = (dir: string) =>
111 cloneWithMirror({ dataDir: data(), repoId: "r6", url: s.url, dir: join(root, dir), env: s.env });
112 const [a, b] = await Promise.all([clone("locked-a"), clone("locked-b")]);
113 expect(a.ok).toBe(true);
114 expect(b.ok).toBe(true);
115 // Mirror build, clone, mirror fetch, clone: one at a time, never two at once.
116 expect(s.log()).toEqual(["start", "end", "start", "end", "start", "end", "start", "end"]);
117 });
118
119 test("an agent's sandbox denies the mirrors", () => {
120 const policy = sandboxPolicy({ dataDir: data(), agentName: "scout", urls: [] });
121 expect(policy.denyRead).toContain(join(data(), "crew", ".mirrors"));
122 });
123
124 /** A clone of `repoId` that holds its lock for about a second. */
125 const cloningSlowly = (dataDir: string, repoId: string) => {
126 const s = slow(0.5);
127 let settled = false;
128 const done = cloneWithMirror({ dataDir, repoId, url: s.url, dir: join(root, `slow-clone-${throwaway++}`), env: s.env });
129 void done.finally(() => (settled = true));
130 return { done, settled: () => settled };
131 };
132
133 test("deleting a repository deletes its mirror without waiting for a running clone", async () => {
134 const dataDir = join(root, "forge");
135 const app = createApp({ dataDir, port: 0 });
136 try {
137 const u = await signUp(app, { username: "carol", email: "[email protected]", password: "a-long-passphrase" });
138 if (!u.ok) throw new Error("signup failed");
139 promote(app.db, u.value);
140 const made = await createRepo(app, u.value, { name: "gone" });
141 if (!made.ok) throw new Error("repo failed");
142 expect((await mirrored(dataDir, made.value.id)).ok).toBe(true);
143 expect(existsSync(mirrorDir(dataDir, made.value.id))).toBe(true);
144 const running = cloningSlowly(dataDir, made.value.id);
145 await Bun.sleep(100);
146 expect((await deleteRepo(app, u.value, made.value)).ok).toBe(true);
147 // The delete did not wait for the clone holding the lock.
148 expect(running.settled()).toBe(false);
149 expect((await running.done).ok).toBe(true);
150 for (let i = 0; i < 60 && existsSync(mirrorDir(dataDir, made.value.id)); i++) await Bun.sleep(50);
151 expect(existsSync(mirrorDir(dataDir, made.value.id))).toBe(false);
152 } finally {
153 app.close();
154 }
155 });
156
157 const gone = async (pid: number) => {
158 for (let i = 0; i < 40; i++) {
159 try {
160 process.kill(pid, 0);
161 } catch {
162 return true;
163 }
164 await Bun.sleep(50);
165 }
166 return false;
167 };
168
169 test("aborting a running git ends it early and leaves no process behind", async () => {
170 const pidFile = join(root, "pid");
171 const ctl = new AbortController();
172 setTimeout(() => ctl.abort(), 300);
173 const started = Date.now();
174 const result = await runGit(
175 ["clone", "--quiet", "--upload-pack", `echo $$ > ${pidFile}; exec cat #`, `file://${origin}`, join(root, "slow")],
176 { timeoutMs: 60_000, signal: ctl.signal },
177 );
178 expect(result.ok).toBe(false);
179 expect(Date.now() - started).toBeLessThan(10_000);
180 expect(await gone(Number(readFileSync(pidFile, "utf8").trim()))).toBe(true);
181 });
182
183 test("an already aborted signal stops a clone that would succeed", async () => {
184 const ctl = new AbortController();
185 ctl.abort();
186 const dir = join(root, "aborted");
187 const cloned = await cloneWithMirror({
188 dataDir: data(), repoId: "r4", url: `file://${origin}`, dir, env: {}, signal: ctl.signal,
189 });
190 expect(cloned.ok).toBe(false);
191 expect(existsSync(join(dir, "a.txt"))).toBe(false);
192 });
193
194 test("a cancelled clone keeps a good mirror", async () => {
195 expect((await mirrored(data(), "r5")).ok).toBe(true);
196 const ctl = new AbortController();
197 ctl.abort();
198 expect((await mirrored(data(), "r5", ctl.signal)).ok).toBe(false);
199 expect(existsSync(join(mirrorDir(data(), "r5"), "HEAD"))).toBe(true);
200 });
201
202 test("removing an account deletes its repositories' mirrors without waiting for a running clone", async () => {
203 const dataDir = join(root, "forge2");
204 const app = createApp({ dataDir, port: 0 });
205 try {
206 const admin = await seedAdmin(app, { username: "boss", email: "[email protected]", password: "a-long-passphrase" });
207 const dave = await signUp(app, { username: "dave", email: "[email protected]", password: "a-long-passphrase" });
208 if (!admin.ok || !dave.ok) throw new Error("signup failed");
209 promote(app.db, dave.value);
210 const made = await createRepo(app, dave.value, { name: "mine" });
211 if (!made.ok) throw new Error("repo failed");
212 expect((await mirrored(dataDir, made.value.id)).ok).toBe(true);
213 expect(existsSync(mirrorDir(dataDir, made.value.id))).toBe(true);
214 const running = cloningSlowly(dataDir, made.value.id);
215 await Bun.sleep(100);
216 const removed = await removeAccount(app, admin.value, "dave");
217 expect(removed.ok).toBe(true);
218 expect(running.settled()).toBe(false);
219 expect((await running.done).ok).toBe(true);
220 for (let i = 0; i < 60 && existsSync(mirrorDir(dataDir, made.value.id)); i++) await Bun.sleep(50);
221 expect(existsSync(mirrorDir(dataDir, made.value.id))).toBe(false);
222 } finally {
223 app.close();
224 }
225 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs