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

A kept clone with a stale git lock is unlocked before the run resumes #1306

Merged opened this in answer to

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

Merged

scout/forge:scout/1235-a-kept-clone-with-a-stale-git-lock-is-un → main 4 commits fork 2 threads · 2 unresolved

  • Merged. scout/1235-a-kept-clone-with-a-stale-git-lock-is-un was merged into main as 962bceb.

Review

Sign in to review this change.
5 files changed +292 −4
scripts/gate.ts +1
@@ -228,6 +228,7 @@ export const GATE: readonly string[] = [
228228 "test/counts.test.ts",
229229 "test/crew-loop.test.ts",
230230 "test/crew-totals.test.ts",
231 "test/crew-locks.test.ts",
231232 "test/ssh-site.test.ts",
232233 "test/web-code.test.ts",
233234 "test/web-origins.test.ts",
src/crew/locks.ts added +61
@@ -0,0 +1,61 @@
1 /**
2 * Stale git locks in a kept clone. Git never removes another process's lock,
3 * and a run the forge interrupted leaves its `config.lock` or `index.lock`
4 * behind in a clone the forge keeps; every later visit would fail on it.
5 * The runner calls this before it touches a clone it is about to own, so no
6 * git process of this forge holds it; the age guard keeps a lock a person's
7 * own git just took.
8 */
9
10 import { existsSync, readdirSync, rmSync, statSync } from "node:fs";
11 import { join } from "node:path";
12
13 export const STALE_LOCK_MS = 60_000;
14
15 /** Whether a failed git command died on a lock file. */
16 export const lockedOut = (stderr: string): boolean =>
17 /\.lock'?: File exists|Unable to create '[^']*\.lock'|could not lock config file/i.test(
18 stderr,
19 );
20
21 function locksUnder(dir: string, deep: boolean): string[] {
22 const found: string[] = [];
23 let entries;
24 try {
25 entries = readdirSync(dir, { withFileTypes: true });
26 } catch {
27 return found;
28 }
29 for (const entry of entries) {
30 const path = join(dir, entry.name);
31 if (entry.isFile() && entry.name.endsWith(".lock")) found.push(path);
32 else if (deep && entry.isDirectory())
outdated

!entry.isSymbolicLink() is redundant. Dirent.isDirectory() is false for symlinks. Remove it.

33 found.push(...locksUnder(path, true));
34 }
35 return found;
36 }
37
38 /** Remove `.git/*.lock` and `.git/refs/**\/*.lock` older than a minute. */
39 export function clearStaleLocks(
40 clone: string,
41 write: (line: string) => void,
42 now = Date.now(),
43 ): string[] {
44 const git = join(clone, ".git");
45 if (!existsSync(git)) return [];
46 const removed: string[] = [];
47 for (const path of [
48 ...locksUnder(git, false),
49 ...locksUnder(join(git, "refs"), true),
50 ]) {
51 try {
52 if (now - statSync(path).mtimeMs < STALE_LOCK_MS) continue;
53 rmSync(path, { force: true });
54 removed.push(path);
55 write(`removed stale git lock ${path.slice(clone.length + 1)}`);
56 } catch {
57 // Gone already, or not ours to remove: git will say so if it matters.
58 }
59 }
60 return removed;
61 }
src/crew/runner.ts +36
@@ -81,6 +81,7 @@ import {
8181 resumeAt,
8282 whenText,
8383 } from "./limits.ts";
84 import { clearStaleLocks, lockedOut } from "./locks.ts";
8485 import { interruptedSummary } from "./interrupted.ts";
8586 import { laterGate } from "./later.ts";
8687 import { parkedTwice } from "./parks.ts";
@@ -1261,6 +1262,7 @@ async function prepareClone(
12611262 write: (line: string) => void,
12621263 signal: AbortSignal,
12631264 keep = false,
1265 fresh = false,
12641266 ): Promise<{ ok: true } | { ok: false; error: string }> {
12651267 // Two addresses for one forge. The forge itself clones and fetches over
12661268 // `localUrl`, on this machine. The agent's remotes carry `forgeUrl`, the
@@ -1287,6 +1289,9 @@ async function prepareClone(
12871289 error: `clone failed: ${cloned.error.stderr.slice(0, 200)}`,
12881290 };
12891291 } else {
1292 // A kept clone may carry the locks of a run the forge cut off. This run
1293 // owns the clone now, so a lock a minute old is nobody's.
1294 clearStaleLocks(dir, write);
12901295 const fetched = await runGit(
12911296 [
12921297 "fetch",
@@ -1359,6 +1364,37 @@ async function prepareClone(
13591364 cwd: dir,
13601365 env,
13611366 });
1367 if (!checked.ok && !fresh && lockedOut(checked.error.stderr)) {
1368 // A lock too young to clear, or one the sweep missed: start over once
1369 // from a fresh clone rather than failing the follow-up.
1370 // Only when nothing would be lost: a cut-off run may hold commits that
1371 // were never pushed, and those live nowhere else.
1372 const unpushed = await runGit(
1373 ["rev-list", "--branches", "--not", "--remotes=origin"],
1374 { cwd: dir, env },
1375 );
1376 if (!unpushed.ok || unpushed.value.trim() !== "")
1377 return {
1378 ok: false,
1379 error:
1380 "checkout blocked by a git lock, and the kept clone holds commits that were never pushed; left as it is",
1381 };
1382 write("checkout blocked by a git lock; cloning afresh");
1383 rmSync(dir, { recursive: true, force: true });
1384 return prepareClone(
1385 app,
1386 options,
1387 repo,
1388 fork,
1389 branch,
1390 dir,
1391 env,
1392 write,
1393 signal,
1394 false,
1395 true,
1396 );
1397 }
13621398 if (!checked.ok)
13631399 return {
13641400 ok: false,
test/crew-interrupted.test.ts +130 −4
@@ -1,5 +1,5 @@
11 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2 import { chmodSync, writeFileSync } from "node:fs";
2 import { chmodSync, existsSync, utimesSync, writeFileSync } from "node:fs";
33 import { mkdtemp, rm } from "node:fs/promises";
44 import { tmpdir } from "node:os";
55 import { join } from "node:path";
@@ -15,7 +15,11 @@ import {
1515 createRun,
1616 getRun,
1717 listQueued,
18 listRuns,
1819 } from "../src/core/runs.ts";
20 import { listPulls } from "../src/core/pulls.ts";
21 import { getAgent } from "../src/core/agents.ts";
22 import { workdirFor } from "../src/crew/workdir.ts";
1923 import { STALE_AFTER_MS } from "../src/runs/lease.ts";
2024 import { createUser, type User } from "../src/core/users.ts";
2125 import { startCrew, type CrewHandle } from "../src/crew/dispatch.ts";
@@ -33,7 +37,8 @@ describe("a run the last forge left running", () => {
3337 dataDir = await mkdtemp(join(tmpdir(), "forge-interrupted-"));
3438 app = createApp({ dataDir, port: 0 });
3539 servers = await serve(app);
36 const a = await createUser(app.db, { trusted: true,
40 const a = await createUser(app.db, {
41 trusted: true,
3742 username: "alice",
3843 email: "[email protected]",
3944 password: "a-good-passphrase",
@@ -179,7 +184,9 @@ describe("a run the last forge left running", () => {
179184 if (!issued.ok) throw new Error(String(issued.error));
180185 const issue = issued.value;
181186 // The word the follow-up answers: a follow-up with nothing new never starts.
182 expect(addComment(app, alice, repo, issue, "The low branches first.").ok).toBe(true);
187 expect(
188 addComment(app, alice, repo, issue, "The low branches first.").ok,
189 ).toBe(true);
183190 // A stand-in that never finishes on its own.
184191 const slow = join(dataDir, "slow-claude");
185192 writeFileSync(
@@ -296,6 +303,124 @@ echo '{"type":"result","subtype":"success","is_error":false,"result":"Done while
296303 listQueued(app.db, "agent").filter((r) => r.issueId === issue.id),
297304 ).toHaveLength(0);
298305 }, 40_000);
306
307 const keptClone = async (name: string) => {
308 await crew?.stop();
309 crew = undefined;
310 const created = await createRepo(app, alice, {
311 name,
312 visibility: "private",
313 });
314 if (!created.ok) throw new Error(String(created.error));
315 const repo = created.value;
316 const seeded = await commitToBranch(app, alice, repo, {
317 message: "Start",
318 files: [{ path: "README.md", content: "# meadow\n" }],
319 });
320 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
321 expect(addToCrew(app, alice, repo, "bot").ok).toBe(true);
322 const issued = openIssue(app, alice, repo, { title: "Mow", body: "" });
323 if (!issued.ok) throw new Error(String(issued.error));
324 const issue = issued.value;
325 const fake = join(dataDir, "lock-claude");
326 writeFileSync(
327 fake,
328 `#!/bin/sh
329 if [ "$1" = "--version" ]; then echo "2.1.0 (fake)"; exit 0; fi
330 cat > /dev/null &
331 echo '{"type":"system","subtype":"init","session_id":"lock-1","model":"fake"}'
332 echo "mowed $(date +%s%N)" >> CREW.md
333 git add -A && git commit -qm "mowed" && git push -q -u origin HEAD
334 echo '{"type":"result","subtype":"success","is_error":false,"result":"Done.","total_cost_usd":0.01,"num_turns":2,"session_id":"lock-1","terminal_reason":"completed"}'
335 `,
336 );
337 chmodSync(fake, 0o755);
338 const options = {
339 forgeUrl: servers.url,
340 localUrl: servers.url,
341 binary: fake,
342 concurrency: 1,
343 budgetUsd: 1,
344 runTimeoutMs: 60_000,
345 };
346 const first = createRun(app.db, {
347 repoId: repo.id,
348 kind: "agent",
349 issueId: issue.id,
350 actorId: bot.id,
351 reason: "assigned",
352 });
353 crew = await startCrew(app, options);
354 const wait = async (id: string) => {
355 const deadline = Date.now() + 25_000;
356 while (
357 !["ok", "failed", "cancelled"].includes(getRun(app.db, id)!.state) &&
358 Date.now() < deadline
359 )
360 await Bun.sleep(100);
361 return getRun(app.db, id)!;
362 };
363 expect((await wait(first.id)).state).toBe("ok");
364
365 // The clone is kept while the pull is open; a cut-off run left a lock.
366 const agent = getAgent(app.db, bot.id)!;
367 const clone = workdirFor(app.config.dataDir, agent, repo, issue.number);
368 expect(existsSync(join(clone, ".git"))).toBe(true);
369 return { repo, issue, clone };
370 };
371
372 const followUp = async (
373 repo: Awaited<ReturnType<typeof keptClone>>["repo"],
374 ) => {
375 const pull = listPulls(app.db, repo.id)[0];
376 expect(pull).toBeDefined();
377 expect(
378 addComment(app, alice, repo, pull!, "Closer to the fence, please.").ok,
379 ).toBe(true);
380 const deadline = Date.now() + 25_000;
381 let next;
382 while (Date.now() < deadline) {
383 next = listRuns(app.db, repo.id, { issueId: pull!.id })[0];
384 if (next && ["ok", "failed", "cancelled"].includes(next.state)) break;
385 await Bun.sleep(100);
386 }
387 expect(next).toBeDefined();
388 return next!;
389 };
390
391 test("a kept clone with a stale git lock resumes cleanly and the lock is gone", async () => {
392 const { repo, clone } = await keptClone("meadow");
393 const lock = join(clone, ".git", "config.lock");
394 writeFileSync(lock, "");
395 const old = new Date(Date.now() - 10 * 60_000);
396 utimesSync(lock, old, old);
397 const next = await followUp(repo);
398 expect(next.summary ?? "").not.toContain("checkout failed");
399 expect(existsSync(lock)).toBe(false);
400 }, 60_000);
401
402 test("a young lock that blocks checkout is recovered by a fresh clone", async () => {
403 const { repo, clone } = await keptClone("orchard");
404 const lock = join(clone, ".git", "index.lock");
405 writeFileSync(lock, "");
406 const next = await followUp(repo);
407 expect(next.state).toBe("ok");
408 expect(existsSync(lock)).toBe(false);
409 }, 60_000);
410
411 test("a young lock is not worked around when the clone holds unpushed commits", async () => {
412 const { repo, clone } = await keptClone("paddock");
413 writeFileSync(join(clone, "LOST.md"), "unpushed\n");
414 const git = (...args: string[]) =>
415 Bun.spawnSync(["git", ...args], { cwd: clone });
416 git("add", "LOST.md");
417 git("-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", "local");
418 writeFileSync(join(clone, ".git", "index.lock"), "");
419 const next = await followUp(repo);
420 expect(next.state).toBe("failed");
421 expect(next.summary ?? "").toContain("never pushed");
422 expect(existsSync(join(clone, "LOST.md"))).toBe(true);
423 }, 60_000);
299424 });
300425
301426 describe("an issue handed to someone else", () => {
@@ -311,7 +436,8 @@ describe("an issue handed to someone else", () => {
311436 dataDir = await mkdtemp(join(tmpdir(), "forge-reassign-"));
312437 app = createApp({ dataDir, port: 0 });
313438 servers = await serve(app);
314 const a = await createUser(app.db, { trusted: true,
439 const a = await createUser(app.db, {
440 trusted: true,
315441 username: "alice",
316442 email: "[email protected]",
317443 password: "a-good-passphrase",
test/crew-locks.test.ts added +64
@@ -0,0 +1,64 @@
1 import { describe, expect, test } from "bun:test";
2 import {
3 existsSync,
4 mkdirSync,
5 mkdtempSync,
6 rmSync,
7 utimesSync,
8 writeFileSync,
9 } from "node:fs";
10 import { tmpdir } from "node:os";
11 import { join } from "node:path";
12 import { clearStaleLocks, lockedOut } from "../src/crew/locks.ts";
13
14 function clone() {
15 const dir = mkdtempSync(join(tmpdir(), "forge-locks-"));
16 mkdirSync(join(dir, ".git", "refs", "heads", "scout"), { recursive: true });
17 return dir;
18 }
19 const plant = (path: string, ageMs: number) => {
20 writeFileSync(path, "");
21 const when = new Date(Date.now() - ageMs);
22 utimesSync(path, when, when);
23 };
24
25 describe("stale git locks in a kept clone", () => {
26 test("old locks go, one line each; young locks and other files stay", () => {
27 const dir = clone();
28 const old = [
29 join(dir, ".git", "config.lock"),
30 join(dir, ".git", "index.lock"),
31 join(dir, ".git", "refs", "heads", "scout", "x.lock"),
32 ];
33 for (const path of old) plant(path, 10 * 60_000);
34 const young = join(dir, ".git", "HEAD.lock");
35 plant(young, 1_000);
36 const config = join(dir, ".git", "config");
37 plant(config, 10 * 60_000);
38 const lines: string[] = [];
39 const removed = clearStaleLocks(dir, (l) => lines.push(l));
40 expect(removed.sort()).toEqual([...old].sort());
41 expect(lines).toHaveLength(3);
42 for (const path of old) expect(existsSync(path)).toBe(false);
43 expect(existsSync(young)).toBe(true);
44 expect(existsSync(config)).toBe(true);
45 rmSync(dir, { recursive: true, force: true });
46 });
47
48 test("a directory with no .git is left alone", () => {
49 const dir = mkdtempSync(join(tmpdir(), "forge-locks-"));
50 expect(clearStaleLocks(dir, () => {})).toEqual([]);
51 rmSync(dir, { recursive: true, force: true });
52 });
53
54 test("a lock failure is recognised from git's words", () => {
55 expect(
56 lockedOut("error: could not lock config file .git/config: File exists"),
57 ).toBe(true);
58 expect(
59 lockedOut("fatal: Unable to create '/x/.git/index.lock': File exists."),
60 ).toBe(true);
61 expect(lockedOut("fatal: not a git repository")).toBe(false);
62 expect(lockedOut("error: could not lock ref: cannot lock the thing")).toBe(false);
63 });
64 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs