Row 137 round 4, Darkwing's candidate (agents/darkwing/work/queue-137/r4/, build.patch 0cabe175, manifest 548ee477, 31 paths, base63c82f1c4). Filbert approved round 4 (comment 28809) and Dewey approved it (28816) after round 3 changes (28801, 28805). Darkwing's round 4 ran 9/9 mutant kills, bus 317, seat 150, land-work 86. Round 5 is a rebase only (agents/sage/work/queue-137-r5/, build.patch d6cbaaa8, manifest f73598fe). Row 150 changed docs/TOOLS.md, broker.mjs and cli/README.md under it. The round 4 patch applied unchanged onto435d1c616, and with index and @@ lines removed the two patches are byte-identical. Filbert checked the interdiff (comment 28818). A landing moves refs/heads/main in <dataRoot>/working/<business>/ <project>.git, never the project root or canonical refactor. The seed now fetches the root as refs/heads/root and fast-forwards main only when it is an ancestor, so a landed main survives the next push. Only a cross-role merge decision lands. Its squash names the real channel: "via the CLI" for the human, and "as its arbiter, via the broker" for an arbiter's decision.resolve. Every other class refuses not-authorized before the human check. This carries row 139's two items. REQ-LAND-1: a gated merge decision never lands. REQ-DEC-2: the host land op accepts an arbiter-resolved merge. land-work's read.mjs stays human-only, and land-work.sh still accepts only a human CLI resolution. Sage's landing gate ran on435d1c616plus the candidate: 25 steps, all rc 0 (15 packages, 10 script suites), 20:51:00Z to 20:55:34Z, network sage-r137_default removed. Cherry-picked onto5d98fb194, which added only a docs/SESSIONS.md line. Co-Authored-By: Claude Opus 5.5 <[email protected]>
192 lines
9.8 KiB
JavaScript
192 lines
9.8 KiB
JavaScript
// The runner's git tools (src/git-tools.mjs) against a fake git socket that
|
|
// records the request line. The review loop over the real host's socket is in
|
|
// packages/cli/tests/git-socket.test.mjs.
|
|
|
|
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { spawnSync } from "node:child_process";
|
|
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
import { createServer } from "node:net";
|
|
import { join } from "node:path";
|
|
import { fetchReview, git as runnerGit, HAVE_MAX, landWork } from "../src/git-tools.mjs";
|
|
import { scratch } from "./helpers.mjs";
|
|
|
|
const ENV = { PATH: process.env.PATH, HOME: "/nonexistent", GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", LC_ALL: "C" };
|
|
|
|
function git(cwd, args, extra = {}) {
|
|
const r = spawnSync("git", ["-c", "user.name=t", "-c", "[email protected]", "-C", cwd, ...args], { env: { ...ENV, ...extra }, encoding: "utf8" });
|
|
assert.equal(r.status, 0, `git ${args.join(" ")}: ${r.stderr}`);
|
|
return r.stdout.trim();
|
|
}
|
|
|
|
// A clone at `dir` with `refs` (name -> commit time) pointing at commits of
|
|
// their own.
|
|
function clone(dir, refs) {
|
|
mkdirSync(dir, { recursive: true });
|
|
git(dir, ["init", "-q"]);
|
|
const tree = git(dir, ["mktree"]);
|
|
const oids = {};
|
|
for (const [name, time] of Object.entries(refs)) {
|
|
const date = `${time} +0000`;
|
|
oids[name] = git(dir, ["commit-tree", tree, "-m", name], { GIT_AUTHOR_DATE: date, GIT_COMMITTER_DATE: date });
|
|
git(dir, ["update-ref", name, oids[name]]);
|
|
}
|
|
return oids;
|
|
}
|
|
|
|
// A socket that keeps each request line and answers it with `reply`, a refusal by default.
|
|
async function fakeHost(t, dir, reply = { ok: false, error: "request-not-found" }) {
|
|
const path = join(dir, "git.sock");
|
|
const lines = [];
|
|
const server = createServer((s) => {
|
|
let buf = "";
|
|
s.on("data", (b) => {
|
|
buf += b;
|
|
if (!buf.includes("\n")) return;
|
|
lines.push(JSON.parse(buf.split("\n")[0]));
|
|
s.end(`${JSON.stringify(reply)}\n`);
|
|
});
|
|
});
|
|
await new Promise((r) => server.listen(path, r));
|
|
t.after(() => new Promise((r) => server.close(r)));
|
|
return { path, lines };
|
|
}
|
|
|
|
test("fetch_review's have lists the working refs before origin's, and no more than HAVE_MAX", async (t) => {
|
|
const dir = scratch(t);
|
|
const workspace = join(dir, "ws");
|
|
// 40 origin refs, newer than every working ref, and 13 working refs in two
|
|
// clones: 53 in all.
|
|
const stack = {};
|
|
for (let i = 0; i < 40; i++) stack[`refs/remotes/origin/b${i}`] = 1_800_000_000 + i;
|
|
for (let i = 1; i <= 10; i++) stack[`refs/remotes/working/work/vikunja-1-${i}`] = 1_700_000_000 + i;
|
|
const other = {};
|
|
for (let i = 1; i <= 3; i++) other[`refs/remotes/working/work/vikunja-2-${i}`] = 1_700_000_000 + i;
|
|
const a = clone(join(workspace, "stack"), stack);
|
|
const b = clone(join(workspace, "other"), other);
|
|
const host = await fakeHost(t, dir);
|
|
await assert.rejects(fetchReview({ path: host.path, cap: "c".repeat(64), workspace, task_ref: "vikunja:1/2", request: "r1", tmp: dir, env: ENV }), (e) => e.code === "request-not-found");
|
|
const [req] = host.lines;
|
|
assert.equal(req.op, "fetch");
|
|
assert.equal(HAVE_MAX, 32);
|
|
assert.equal(req.have.length, HAVE_MAX);
|
|
assert.equal(new Set(req.have).size, HAVE_MAX);
|
|
const working = [...Object.entries(a), ...Object.entries(b)].filter(([n]) => n.startsWith("refs/remotes/working/")).map(([, oid]) => oid);
|
|
assert.equal(working.length, 13);
|
|
// Every working ref, ahead of any of origin's.
|
|
assert.deepEqual(new Set(req.have.slice(0, 13)), new Set(working));
|
|
const origin = new Set(Object.entries(a).filter(([n]) => n.startsWith("refs/remotes/origin/")).map(([, oid]) => oid));
|
|
for (const oid of req.have.slice(13)) assert.ok(origin.has(oid), oid);
|
|
});
|
|
|
|
// Past HAVE_MAX the order is per clone: each clone's working refs newest
|
|
// first, one clone after another, so an earlier clone's older refs come
|
|
// ahead of a later clone's newer ones. The clones' order is the workspace's
|
|
// directory order, so the test reads it first and gives the first-listed
|
|
// clone the older refs: one order across clones by date would put the
|
|
// other clone first, whatever order the filesystem lists them in (row 106,
|
|
// #1600, Dewey b).
|
|
test("fetch_review's have orders the working refs newest first within each clone, one clone after another", async (t) => {
|
|
const dir = scratch(t);
|
|
const workspace = join(dir, "ws");
|
|
mkdirSync(join(workspace, "alpha"), { recursive: true });
|
|
mkdirSync(join(workspace, "beta"));
|
|
const [early, late] = readdirSync(workspace);
|
|
// 20 working refs in each clone; the late clone's are all newer than the
|
|
// early one's. Created in a shuffled order, so the name order isn't the
|
|
// date order.
|
|
const order = [7, 2, 15, 0, 11, 19, 4, 9, 13, 1, 17, 6, 3, 18, 10, 5, 14, 8, 16, 12];
|
|
const refs = (task, base) => Object.fromEntries(order.map((i) => [`refs/remotes/working/work/vikunja-${task}-${i + 1}`, base + i * 60]));
|
|
const older = clone(join(workspace, early), refs(1, 1_700_000_000));
|
|
const newer = clone(join(workspace, late), refs(2, 1_750_000_000));
|
|
assert.deepEqual(readdirSync(workspace), [early, late], "the clones kept their directory order");
|
|
const host = await fakeHost(t, dir);
|
|
await assert.rejects(fetchReview({ path: host.path, cap: "c".repeat(64), workspace, task_ref: "vikunja:1/2", request: "r1", tmp: dir, env: ENV }), (e) => e.code === "request-not-found");
|
|
const [req] = host.lines;
|
|
// Newest first: the ref for i = 19 first, i = 0 last.
|
|
const newest = (oids, task) => order.slice().sort((x, y) => y - x).map((i) => oids[`refs/remotes/working/work/vikunja-${task}-${i + 1}`]);
|
|
assert.deepEqual(req.have, [...newest(older, 1), ...newest(newer, 2)].slice(0, HAVE_MAX));
|
|
});
|
|
|
|
// A git on PATH that runs `body` in place of the real one.
|
|
function fakeGit(dir, body) {
|
|
const bin = join(dir, "bin");
|
|
mkdirSync(bin);
|
|
writeFileSync(join(bin, "git"), `#!/bin/bash\n${body}\n`, { mode: 0o755 });
|
|
return { ...ENV, PATH: `${bin}:${process.env.PATH}` };
|
|
}
|
|
|
|
// True once `pid` has gone, or is a zombie waiting to be reaped.
|
|
async function gone(pid, ms) {
|
|
const end = Date.now() + ms;
|
|
for (;;) {
|
|
let stat = null;
|
|
try {
|
|
stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
} catch {}
|
|
if (stat === null || stat.slice(stat.lastIndexOf(")") + 2)[0] === "Z") return true;
|
|
if (Date.now() > end) return false;
|
|
await new Promise((r) => setTimeout(r, 50));
|
|
}
|
|
}
|
|
|
|
async function pidIn(file) {
|
|
for (let i = 0; i < 100 && !existsSync(file); i++) await new Promise((r) => setTimeout(r, 20));
|
|
return Number(readFileSync(file, "utf8").trim());
|
|
}
|
|
|
|
const kill = (pid) => {
|
|
try {
|
|
process.kill(pid, "SIGKILL");
|
|
} catch {}
|
|
};
|
|
|
|
// git ignores SIGTERM and waits on a sleep it started. SIGTERM goes to the
|
|
// group, so the sleep dies at the timeout; git itself lasts until SIGKILL.
|
|
test("a runner git past its time: SIGTERM reaches its process group, and SIGKILL follows after the grace period", { timeout: 20000 }, async (t) => {
|
|
const dir = scratch(t);
|
|
const file = join(dir, "child.pid");
|
|
const env = fakeGit(dir, `sleep 30 </dev/null >/dev/null 2>&1 &\necho $! > '${file}'\ntrap '' TERM\nwait $!\nexec sleep 30`);
|
|
const started = Date.now();
|
|
const run = runnerGit(["fetch"], env, { timeoutMs: 300, graceMs: 1500 });
|
|
const pid = await pidIn(file);
|
|
t.after(() => kill(pid));
|
|
assert.equal(await gone(pid, 800), true, "the sleep git started died at SIGTERM");
|
|
assert.ok(Date.now() - started < 1500, "before the grace period was over");
|
|
const r = await run;
|
|
assert.equal(r.why, "killed by SIGKILL");
|
|
assert.ok(Date.now() - started >= 1800, "SIGKILL waited out the grace period");
|
|
});
|
|
|
|
// git dies at SIGTERM; a process it started ignores SIGTERM and holds none
|
|
// of git's output. Once git has closed, its group gets SIGKILL.
|
|
test("once a signalled runner git has closed, its group gets SIGKILL, so a process that ignored SIGTERM dies too", { timeout: 20000 }, async (t) => {
|
|
const dir = scratch(t);
|
|
const file = join(dir, "child.pid");
|
|
const env = fakeGit(dir, `(trap '' TERM; exec sleep 30) </dev/null >/dev/null 2>&1 &\necho $! > '${file}'\nexec sleep 30`);
|
|
const r = await runnerGit(["fetch"], env, { timeoutMs: 300, graceMs: 20000 });
|
|
assert.equal(r.why, "killed by SIGTERM");
|
|
const pid = await pidIn(file);
|
|
t.after(() => kill(pid));
|
|
assert.equal(await gone(pid, 1500), true, "the sleep that ignored SIGTERM is gone well inside the grace period");
|
|
});
|
|
|
|
// Row 137 (#1631): land_work sends the task and the decision and gives back
|
|
// the host's "started" answer; anything else the host says is a refusal or
|
|
// invalid-response.
|
|
test("land_work sends one land line and resolves the host's started answer", async (t) => {
|
|
const dir = scratch(t);
|
|
const started = { landing: "started", commit: "a".repeat(40), base: "b".repeat(40), reviewed: "c".repeat(40), patch_id: "d".repeat(40) };
|
|
const host = await fakeHost(t, dir, { ok: true, result: started });
|
|
assert.deepEqual(await landWork({ path: host.path, cap: "c".repeat(64), task_ref: "vikunja:1/2", decision: "decision-1" }), started);
|
|
assert.deepEqual(host.lines, [{ cap: "c".repeat(64), op: "land", task_ref: "vikunja:1/2", decision: "decision-1" }]);
|
|
|
|
const sub = (name) => (mkdirSync(join(dir, name)), join(dir, name));
|
|
const busy = await fakeHost(t, sub("busy"), { ok: false, error: "land-busy" });
|
|
await assert.rejects(landWork({ path: busy.path, cap: "c".repeat(64), task_ref: "vikunja:1/2", decision: "decision-1" }), (e) => e.code === "land-busy");
|
|
for (const [name, result] of [["landed", { ...started, landing: "landed" }], ["short", { ...started, commit: "a".repeat(7) }], ["no-base", { ...started, base: undefined }], ["null", null]]) {
|
|
const odd = await fakeHost(t, sub(name), { ok: true, result });
|
|
await assert.rejects(landWork({ path: odd.path, cap: "c".repeat(64), task_ref: "vikunja:1/2", decision: "decision-1" }), (e) => e.code === "invalid-response", name);
|
|
}
|
|
});
|