seat, cli, harness: git kill reach, close bound and host.lock (#1597)

Row 103, the row 100 follow-up. Built by Darkwing, packet
agents/darkwing/work/queue-103/r1/, candidate manifest
6e5ac8c0782bc595d7c0d711d457738ffb42f1ba5f38253912943dd55e55888a.
Sage approved on #1597, comment 28252; Dewey approved, comment 28260.

Host git runs through runGit: a detached group, SIGTERM at the limit,
SIGKILL after the grace, and one more SIGKILL to the group after a
signalled git closes. git-socket close() waits at most 2 s. startHost
claims bus-host/host.lock by hard link before the broker fork.
packages/cli/README.md and tests/host.test.mjs carry row 85's hunks
too; row 85 lands first.

Sage's gate rerun on 3a1e11b83 with rows 85, 102 and 103 applied,
Docker up: node suites bus 128, business 67, cli 165, control-board
124, conversation 182, discord 178, harness 73, ledger 78, mosaic 69,
queue 155, runs 43, seat 113, tasks 92, webui 108; scripts auth 15,
conductor 17, config 24, discord 66, extension-package 18,
foundation 44, queue 28, release 14, task 98. All pass, 0 fail.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-13 16:34:47 -05:00
co-authored by Claude Opus 5.5
parent 2a9b77b56b
commit 465bbdfbbc
12 changed files with 665 additions and 106 deletions
+37 -7
View File
@@ -28,6 +28,9 @@ const PROJECT = /^[a-z0-9][a-z0-9._-]{0,63}$/;
const WORK_REF = /^work\/vikunja-[1-9][0-9]*-[1-9][0-9]*$/;
const OID = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
const TIMEOUT_MS = 300_000;
// How long a git has after SIGTERM before SIGKILL, as on the host.
const GRACE_MS = 5000;
const LIMITS = { timeoutMs: TIMEOUT_MS, graceMs: GRACE_MS };
const SAFE = [
"-c", "core.hooksPath=/dev/null",
"-c", "core.fsmonitor=false",
@@ -42,20 +45,44 @@ const failure = (code, message = code) => Object.assign(new Error(message), { co
export const validBranch = (branch) =>
typeof branch === "string" && /^[A-Za-z0-9][A-Za-z0-9._\/-]{0,199}$/.test(branch) && !/\.\.|\/\/|\/\.|@\{|\.lock(\/|$)|[./]$/.test(branch);
function git(args, env = process.env) {
// git with SAFE first, in its own process group (detached). Past
// limits.timeoutMs the group gets SIGTERM, on which git removes its
// lockfiles, then SIGKILL limits.graceMs later; once a signalled git has
// closed, the group gets SIGKILL again. The same sequence as the host's
// runGit in packages/seat/src/checkout.mjs, kept here so the runner doesn't
// load the host's module. Exported for the tests, which shorten `limits`.
export function git(args, env = process.env, limits = LIMITS) {
return new Promise((resolve) => {
const child = spawn("git", [...SAFE, ...args], {
env: { PATH: env.PATH ?? "/usr/bin:/bin", HOME: "/nonexistent", GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", GIT_TERMINAL_PROMPT: "0", LC_ALL: "C" },
timeout: TIMEOUT_MS,
killSignal: "SIGKILL",
detached: true,
stdio: ["ignore", "pipe", "pipe"],
});
let out = "";
let err = "";
let signalled = false;
let kill = null;
// A git that never started has no pid; an empty group is ESRCH.
const group = (signal) => {
try {
process.kill(-child.pid, signal);
} catch {}
};
const term = setTimeout(() => {
signalled = true;
group("SIGTERM");
kill = setTimeout(() => group("SIGKILL"), limits.graceMs);
}, limits.timeoutMs);
const settle = (r) => {
clearTimeout(term);
clearTimeout(kill);
if (signalled) group("SIGKILL");
resolve(r);
};
child.stdout.setEncoding("utf8").on("data", (d) => (out += d));
child.stderr.setEncoding("utf8").on("data", (d) => (err += d));
child.on("error", (e) => resolve({ ok: false, out, err, why: e.code ?? e.message }));
child.on("close", (code, signal) => resolve({ ok: code === 0, out, err, why: signal ? `killed by ${signal}` : err.trim().split("\n").pop() || `exit ${code}` }));
child.on("error", (e) => settle({ ok: false, out, err, why: e.code ?? e.message }));
child.on("close", (code, signal) => settle({ ok: code === 0, out, err, why: signal ? `killed by ${signal}` : err.trim().split("\n").pop() || `exit ${code}` }));
});
}
@@ -169,8 +196,11 @@ export async function pushWorking({ path, cap, workspace, task_ref, project, bra
// Resolves { project, ref, commit, clone }; ref is the clone's remote ref.
export async function fetchReview({ path, cap, workspace, task_ref, request, tmp = "/tmp", env = process.env }) {
// Commits the host may leave out: what the clones' remotes already hold.
// The working refs come first, newest first, since a review is most often
// of a commit built on one; origin's fill what is left of HAVE_MAX.
// The working refs come first, since a review is most often of a commit
// built on one; origin's fill what is left of HAVE_MAX. The order is per
// clone, not across clones: each clone's refs newest first, one clone
// after another in the workspace's directory order. So past HAVE_MAX, a
// later clone's newer refs can lose to an earlier clone's older ones.
const have = new Set();
const projects = projectsIn(workspace);
for (const remote of ["refs/remotes/working", "refs/remotes/origin"])