-
Notifications
You must be signed in to change notification settings - Fork 0
Tests for the routing policy; honour --backend on route --role reviewer #5
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
ac9f34c
701768f
618f0a5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -134,7 +134,8 @@ const die = (msg, code = 1) => { | |
| const nowIso = () => new Date().toISOString(); | ||
| const newId = () => `${new Date().toISOString().replace(/[-:T]/g, "").slice(0, 14)}-${Math.random().toString(36).slice(2, 6)}`; | ||
| const readJson = (p, fallback = null) => { try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch { return fallback; } }; | ||
| const writeJson = (p, v) => fs.writeFileSync(p, JSON.stringify(v, null, 2) + "\n"); | ||
| // tmp + rename: a concurrent reader sees the old file or the new one, never a truncated one | ||
| const writeJson = (p, v) => { const tmp = `${p}.${process.pid}.tmp`; fs.writeFileSync(tmp, JSON.stringify(v, null, 2) + "\n"); fs.renameSync(tmp, p); }; | ||
| const runDir = (id) => path.join(RUNS_DIR, id); | ||
| const metaOf = (id) => readJson(path.join(runDir(id), "meta.json")); | ||
| const saveMeta = (m) => writeJson(path.join(runDir(m.id), "meta.json"), m); | ||
|
|
@@ -273,13 +274,37 @@ function acquireCapsMutex(timeoutMs = 15_000) { | |
| const deadline = Date.now() + timeoutMs; | ||
| for (;;) { | ||
| try { fs.writeFileSync(CAPS_MUTEX, String(process.pid), { flag: "wx" }); capsMutexHeld = true; return; } catch (e) { if (e.code !== "EEXIST") throw e; } | ||
| const holder = Number(fs.readFileSync(CAPS_MUTEX, "utf8").trim() || 0); | ||
| let holder; try { holder = Number(fs.readFileSync(CAPS_MUTEX, "utf8").trim() || 0); } catch (e) { if (e.code === "ENOENT") continue; throw e; } | ||
| if (!holder || !alive(holder)) { try { fs.rmSync(CAPS_MUTEX, { force: true }); } catch {} continue; } | ||
| if (Date.now() > deadline) die(`another agent-run (pid ${holder}) has held ${CAPS_MUTEX} for ${timeoutMs / 1000}s; if it is stuck, kill it or remove the file`); | ||
| Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50); | ||
| } | ||
| } | ||
| function releaseCapsMutex() { if (!capsMutexHeld) return; capsMutexHeld = false; try { fs.rmSync(CAPS_MUTEX, { force: true }); } catch {} } | ||
| /* | ||
| * Второй мьютекс — репозитория, общий с `agent-wt lock`: каталог | ||
| * <common .git>/delegate-kit.caps.lock, mkdir атомарен. Без него внешний и | ||
| * нативный писатель одного репозитория считали слоты под разными замками и | ||
| * оба проходили при cap 1. agent-run берёт сначала машинный, потом | ||
| * репозиторный; agent-wt только репозиторный — цикла нет. | ||
| */ | ||
| let repoMutexHeld = null; | ||
| function acquireRepoMutex(cwd, timeoutMs = 15_000) { | ||
| const r = sh("git", ["rev-parse", "--git-common-dir"], { cwd }); | ||
| if (r.status !== 0) return; | ||
| const dir = path.join(path.resolve(cwd, r.stdout.trim()), "delegate-kit.caps.lock"); | ||
| const deadline = Date.now() + timeoutMs; | ||
| for (;;) { | ||
| try { fs.mkdirSync(dir); fs.writeFileSync(path.join(dir, "pid"), String(process.pid)); repoMutexHeld = dir; return; } catch (e) { if (e.code !== "EEXIST") throw e; } | ||
| let holder = 0; try { holder = Number(fs.readFileSync(path.join(dir, "pid"), "utf8").trim() || 0); } catch {} | ||
| if (!holder || !alive(holder)) { try { fs.rmSync(dir, { recursive: true, force: true }); } catch {} continue; } | ||
| if (Date.now() > deadline) die(`another agent-run or agent-wt (pid ${holder}) has held ${dir} for ${timeoutMs / 1000}s; if it is stuck, kill it or remove the directory`); | ||
| Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50); | ||
| } | ||
| } | ||
| function releaseCapsMutex() { | ||
| if (repoMutexHeld) { const d = repoMutexHeld; repoMutexHeld = null; try { fs.rmSync(d, { recursive: true, force: true }); } catch {} } | ||
| if (!capsMutexHeld) return; capsMutexHeld = false; try { fs.rmSync(CAPS_MUTEX, { force: true }); } catch {} | ||
| } | ||
|
|
||
| // Refuse before anything is spawned. Native locks (`agent-wt lock`) of the repository of | ||
| // cwd count as workers and as writers; external runs count machine-wide. | ||
|
|
@@ -704,12 +729,15 @@ function suggestDepth(stats, kind) { | |
| // Slot A is always the other family than the author: that is where independence comes from and | ||
| // nothing moves it. Slot B may sit on the author's family — independence is already paid for, and | ||
| // the second slot buys a second lens, not a second opinion from the same angle. Slot C alternates. | ||
| function reviewComposition(depth, author, kind) { | ||
| const other = otherBackend(author); | ||
| // Slot A is the other family than the author unless the user pins it with --backend: an | ||
| // explicit same-family reviewer is the user's call, reported as independent: false. Before | ||
| // it was honoured, `route --role reviewer --backend X` was silently overridden here. | ||
| function reviewComposition(depth, author, kind, pinned = null) { | ||
| const first = pinned || otherBackend(author); | ||
| const lenses = kind === "refactor" ? ["correctness", "standards", "spec"] : ["correctness", "spec", "standards"]; | ||
| const n = depth === "single" ? 1 : depth === "panel" ? 2 : 3; | ||
| const families = [other, author, other]; | ||
| return lenses.slice(0, n).map((lens, i) => ({ slot: "ABC"[i], lens, backend: families[i], independent: families[i] !== author })); | ||
| const families = [first, otherBackend(first), first]; | ||
| return lenses.slice(0, n).map((lens, i) => ({ slot: "ABC"[i], lens, backend: families[i], independent: families[i] !== author, pinned: Boolean(pinned) && i === 0 })); | ||
| } | ||
|
|
||
| function cmdRoute(argv) { | ||
|
|
@@ -722,10 +750,13 @@ function cmdRoute(argv) { | |
| let depth = argv.depth && argv.depth !== true ? String(argv.depth) : (suggested ? suggested.depth : "single"); | ||
| if (!DEPTHS.includes(depth)) die(`--depth must be ${DEPTHS.join("|")} (got ${depth})`); | ||
| const author = base.author; | ||
| const reviewers = reviewComposition(depth, author, kind).map((r) => { | ||
| const rr = resolveRoute({ ...argv, role: "reviewer", backend: r.backend, _composed: true, depth: undefined, diff: undefined }); | ||
| const note = rr.why.find((w) => w.includes("falls back")); | ||
| return { ...r, backend: rr.backend, independent: rr.backend !== author, model: rr.model, effort: rr.effort, dispatch: rr.dispatch, invoke: rr.invoke, ...(note ? { note } : {}) }; | ||
| const reviewers = reviewComposition(depth, author, kind, argv.backend && argv.backend !== true ? String(argv.backend) : null).map((r) => { | ||
| // a slot the user pinned with --backend keeps it even when that CLI is missing; composed slots may fall back | ||
| const rr = resolveRoute({ ...argv, role: "reviewer", backend: r.backend, _composed: !r.pinned, depth: undefined, diff: undefined }); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When an explicit reviewer backend is unavailable, setting Useful? React with 👍 / 👎. |
||
| // keep what the caller must act on: a fallback that happened, or a pinned CLI that is missing | ||
| const note = rr.why.find((w) => w.includes("falls back") || w.includes("not installed")); | ||
| const { pinned: _p, ...slot } = r; | ||
| return { ...slot, backend: rr.backend, independent: rr.backend !== author, model: rr.model, effort: rr.effort, dispatch: rr.dispatch, invoke: rr.invoke, ...(note ? { note } : {}) }; | ||
| }); | ||
| const lead = depth === "led" ? (() => { const l = resolveRoute({ ...argv, role: "review-lead", backend: undefined, depth: undefined, diff: undefined }); return { backend: l.backend, model: l.model, effort: l.effort, dispatch: l.dispatch, invoke: l.invoke }; })() : null; | ||
| const sessions = reviewers.length + (lead ? 2 : 0); | ||
|
|
@@ -873,7 +904,7 @@ async function cmdRun(opts, resumeOf = null) { | |
| if (opts._supervise) SUPERVISED = { id: opts.id, role, backend, model, effort, cwd, write }; | ||
| // The initial supervised start runs under the mutex its parent still holds; every other | ||
| // start (foreground, detach parent, quota fallback) takes it itself. | ||
| if (!opts._supervise || opts._fallbackFrom) acquireCapsMutex(); | ||
| if (!opts._supervise || opts._fallbackFrom) { acquireCapsMutex(); if (write) acquireRepoMutex(cwd); } | ||
| checkCaps(caps, write, cwd); | ||
| if (write) { const conflict = writeLockConflict(cwd, null); if (conflict) die(conflict); } | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Allowing
--backendto select slot A introduces a new exception to the documented independence policy:references/review.md:7-15still says only CLI availability can move A off the family opposite the author, while this branch permits an explicit same-family reviewer. Update the policy documentation and the contradictory comment immediately above this function so coordinators do not continue treating cross-family slot A as guaranteed.Useful? React with 👍 / 👎.